padwan-cli 0.2.3__tar.gz → 0.4.0__tar.gz
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.
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/PKG-INFO +6 -3
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/README.md +3 -0
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/batch/run.py +1 -1
- padwan_cli-0.4.0/padwan_cli/chat.py +281 -0
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/run.py +44 -6
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/widgets.py +61 -2
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/pyproject.toml +12 -7
- padwan_cli-0.2.3/padwan_cli/chat.py +0 -144
- padwan_cli-0.2.3/padwan_cli/conversation.py +0 -94
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/__init__.py +0 -0
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/__main__.py +0 -0
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/batch/__init__.py +0 -0
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/batch/format.py +0 -0
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/batch/utils.py +0 -0
- {padwan_cli-0.2.3 → padwan_cli-0.4.0}/padwan_cli/utils.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: padwan-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: CLI and TUI for padwan-llm
|
|
5
5
|
Author-email: julien.brayere@polarsen.com
|
|
6
|
-
Requires-Dist: padwan-llm>=0.
|
|
7
|
-
Requires-Dist: piou[tui]>=0.
|
|
6
|
+
Requires-Dist: padwan-llm>=0.6.0
|
|
7
|
+
Requires-Dist: piou[tui]>=0.34.1
|
|
8
8
|
Requires-Python: >=3.14
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
|
|
@@ -12,6 +12,9 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
|
|
13
13
|
Interactive CLI/TUI for [`padwan-llm`](https://github.com/polarsen-io/padwan-llm).
|
|
14
14
|
|
|
15
|
+
> [!NOTE]
|
|
16
|
+
> This is a playground for experimenting with `padwan-llm` features (streaming, agents, MCP, thinking tokens, batch jobs) — not a production-grade tool. Expect rough edges and breaking changes.
|
|
17
|
+
|
|
15
18
|
<img alt="Chat demo" src="https://github.com/polarsen-io/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
|
|
16
19
|
|
|
17
20
|
## Quick start
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Interactive CLI/TUI for [`padwan-llm`](https://github.com/polarsen-io/padwan-llm).
|
|
4
4
|
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This is a playground for experimenting with `padwan-llm` features (streaming, agents, MCP, thinking tokens, batch jobs) — not a production-grade tool. Expect rough edges and breaking changes.
|
|
7
|
+
|
|
5
8
|
<img alt="Chat demo" src="https://github.com/polarsen-io/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
|
|
6
9
|
|
|
7
10
|
## Quick start
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from piou import Option, CommandGroup
|
|
8
|
+
from piou.tui import PromptStyle, TuiContext, TuiOption
|
|
9
|
+
from textual.css.query import NoMatches
|
|
10
|
+
|
|
11
|
+
from padwan_llm import (
|
|
12
|
+
AgentSession,
|
|
13
|
+
ConversationSnapshot,
|
|
14
|
+
LLMClient,
|
|
15
|
+
McpStreamable,
|
|
16
|
+
McpTool,
|
|
17
|
+
McpTransport,
|
|
18
|
+
ToolCallContext,
|
|
19
|
+
)
|
|
20
|
+
from padwan_llm.gemini import GeminiClient
|
|
21
|
+
from padwan_llm.gemini.models import ThinkingConfig
|
|
22
|
+
from .utils import ALL_MODELS, console
|
|
23
|
+
from .widgets import (
|
|
24
|
+
ErrorMessage,
|
|
25
|
+
StreamingMessage,
|
|
26
|
+
ThoughtMessage,
|
|
27
|
+
ToolCallMessage,
|
|
28
|
+
UserMessage,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
chat_group = CommandGroup(name="chat", help="Chat with an LLM")
|
|
32
|
+
|
|
33
|
+
# Public, no-auth streamable-HTTP MCP server for data.gouv.fr
|
|
34
|
+
DATAGOUV_MCP_URL = "https://mcp.data.gouv.fr/mcp"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _InMemoryStore:
|
|
38
|
+
"""ConversationStore keeping snapshots in memory across /chat:send calls.
|
|
39
|
+
|
|
40
|
+
Implements the `padwan_llm.ConversationStore` protocol so that
|
|
41
|
+
`AgentSession.load` / `AgentSession.save` transparently preserve chat
|
|
42
|
+
history across successive command invocations within the same TUI run.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._data: dict[str, ConversationSnapshot] = {}
|
|
47
|
+
|
|
48
|
+
def save(self, session_id: str, snapshot: ConversationSnapshot) -> None:
|
|
49
|
+
self._data[session_id] = snapshot
|
|
50
|
+
|
|
51
|
+
def load(self, session_id: str) -> ConversationSnapshot:
|
|
52
|
+
return self._data[session_id]
|
|
53
|
+
|
|
54
|
+
def forget(self, session_id: str | None) -> bool:
|
|
55
|
+
"""Drop one or all snapshots; return True if anything was removed."""
|
|
56
|
+
if session_id is None:
|
|
57
|
+
existed = bool(self._data)
|
|
58
|
+
self._data.clear()
|
|
59
|
+
return existed
|
|
60
|
+
return self._data.pop(session_id, None) is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_store = _InMemoryStore()
|
|
64
|
+
|
|
65
|
+
CHAT_PROMPT = PromptStyle(text="You: ", css_class="chat-mode")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_tokens(session: AgentSession, ctx: TuiContext | None = None) -> str:
|
|
69
|
+
"""Format token usage and connected MCP count for display."""
|
|
70
|
+
if not session.last_usage:
|
|
71
|
+
return ""
|
|
72
|
+
last = session.last_usage
|
|
73
|
+
total = session.total_usage
|
|
74
|
+
parts = [f"in: {last['input']}", f"out: {last['output']}"]
|
|
75
|
+
if "cached" in last:
|
|
76
|
+
parts.append(f"cached: {last['cached']}")
|
|
77
|
+
parts.append(f"| session: {total['total']}")
|
|
78
|
+
# Count MCP transports (each represents a connected server); standalone
|
|
79
|
+
# McpTool entries in mcp_tools are individual tool definitions, not servers.
|
|
80
|
+
mcp_count = sum(1 for t in session.mcp_tools if not isinstance(t, McpTool))
|
|
81
|
+
if mcp_count:
|
|
82
|
+
parts.append(f"| mcp: {mcp_count}")
|
|
83
|
+
if ctx and ctx.pending_count > 0:
|
|
84
|
+
parts.append(f"| {ctx.pending_count} queued")
|
|
85
|
+
return " ".join(parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@chat_group.command("send", help="Send a message to the LLM")
|
|
89
|
+
async def chat_send_fn(
|
|
90
|
+
message: str = Option(..., help="Message to send"),
|
|
91
|
+
model: str = Option("gpt-4o-mini", "-m", "--model", help="Model to use"),
|
|
92
|
+
session_id: str | None = Option(None, "--resume", help="Resume session"),
|
|
93
|
+
max_tool_rounds: int = Option(
|
|
94
|
+
20, "--max-tools-round", help="Maximum number of tool calls per round"
|
|
95
|
+
),
|
|
96
|
+
base_url: str | None = Option(
|
|
97
|
+
None, "--base-url", help="Custom OpenAI-compatible endpoint"
|
|
98
|
+
),
|
|
99
|
+
extra_params: str | None = Option(
|
|
100
|
+
None,
|
|
101
|
+
"--extra-params",
|
|
102
|
+
help="Extra JSON object merged into every request body (e.g. '{\"temperature\": 0}')",
|
|
103
|
+
),
|
|
104
|
+
mcp_urls: list[str] | None = Option(
|
|
105
|
+
None,
|
|
106
|
+
"--mcp",
|
|
107
|
+
help=f"Streamable-HTTP MCP server URL(s) to expose as tools (e.g. {DATAGOUV_MCP_URL})",
|
|
108
|
+
),
|
|
109
|
+
ctx: TuiContext = TuiOption(),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Start a conversation. Use Ctrl+C to exit."""
|
|
112
|
+
|
|
113
|
+
# Tracks whether the next text chunk should start a fresh streaming
|
|
114
|
+
# widget. Mounted widgets keep their position in the message scroll, so
|
|
115
|
+
# if we mounted a single StreamingMessage up-front any tool/thought
|
|
116
|
+
# widgets fired mid-stream would land *below* the (still empty) text
|
|
117
|
+
# placeholder — making the final answer appear above the tool calls
|
|
118
|
+
# that produced it. Resetting this flag on every event splits the text
|
|
119
|
+
# into one widget per "section" with tool/thought widgets between them.
|
|
120
|
+
needs_new_text_widget = True
|
|
121
|
+
# Consecutive thought chunks stream into the same widget; any non-thought
|
|
122
|
+
# event (tool call or text chunk) closes the current thought block so
|
|
123
|
+
# the next thought starts a fresh widget.
|
|
124
|
+
current_thought: ThoughtMessage | None = None
|
|
125
|
+
|
|
126
|
+
@contextmanager
|
|
127
|
+
def _on_tool(tc: ToolCallContext):
|
|
128
|
+
nonlocal needs_new_text_widget, current_thought
|
|
129
|
+
current_thought = None
|
|
130
|
+
if ctx.is_tui:
|
|
131
|
+
widget = ToolCallMessage(tc.name, tc.args)
|
|
132
|
+
ctx.mount_widget(widget)
|
|
133
|
+
needs_new_text_widget = True
|
|
134
|
+
t0 = time.perf_counter()
|
|
135
|
+
yield
|
|
136
|
+
widget.set_elapsed(time.perf_counter() - t0)
|
|
137
|
+
else:
|
|
138
|
+
console.print(f"[dim]→ tool call: {tc.name}({tc.args})[/dim]")
|
|
139
|
+
t0 = time.perf_counter()
|
|
140
|
+
yield
|
|
141
|
+
console.print(f"[dim] ↳ {time.perf_counter() - t0:.1f}s[/dim]")
|
|
142
|
+
|
|
143
|
+
def _on_thought(text: str) -> None:
|
|
144
|
+
nonlocal needs_new_text_widget, current_thought
|
|
145
|
+
if ctx.is_tui:
|
|
146
|
+
if current_thought is None:
|
|
147
|
+
current_thought = ThoughtMessage(text)
|
|
148
|
+
ctx.mount_widget(current_thought)
|
|
149
|
+
else:
|
|
150
|
+
current_thought.append(text)
|
|
151
|
+
needs_new_text_widget = True
|
|
152
|
+
else:
|
|
153
|
+
console.print(f"[dim italic]💭 {text}[/dim italic]", end="")
|
|
154
|
+
|
|
155
|
+
def _on_mcp_connect(transport: McpTransport) -> None:
|
|
156
|
+
if ctx.is_tui:
|
|
157
|
+
ctx.notify(transport.label, title="MCP connected")
|
|
158
|
+
else:
|
|
159
|
+
console.print(f"[green]MCP connected[/green] [dim]{transport.label}[/dim]")
|
|
160
|
+
|
|
161
|
+
parsed_extra: dict[str, Any] | None = None
|
|
162
|
+
if extra_params is not None:
|
|
163
|
+
try:
|
|
164
|
+
parsed_extra = json.loads(extra_params)
|
|
165
|
+
except json.JSONDecodeError as e:
|
|
166
|
+
console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
|
|
167
|
+
return
|
|
168
|
+
if not isinstance(parsed_extra, dict):
|
|
169
|
+
console.print("[red]--extra-params must be a JSON object[/red]")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
client = LLMClient(model=model, on_thought=_on_thought, base_url=base_url)
|
|
174
|
+
if isinstance(client, GeminiClient):
|
|
175
|
+
client.thinking_config = ThinkingConfig(includeThoughts=True)
|
|
176
|
+
|
|
177
|
+
# `--resume` lets the user pick an explicit session id; otherwise we
|
|
178
|
+
# key history by model so a second `/chat:send` continues the same
|
|
179
|
+
# conversation (and `chat_clear_fn` can target it by model name).
|
|
180
|
+
# `AgentSession.load` treats a missing snapshot as get-or-create, so
|
|
181
|
+
# the first turn under a new id lands in the store via `session.save()`.
|
|
182
|
+
session = AgentSession.load(
|
|
183
|
+
client=client,
|
|
184
|
+
mcp_tools=[McpStreamable(url=url) for url in mcp_urls or ()],
|
|
185
|
+
on_tool=_on_tool,
|
|
186
|
+
on_mcp_connect=_on_mcp_connect,
|
|
187
|
+
store=_store,
|
|
188
|
+
session_id=session_id or model,
|
|
189
|
+
max_tool_rounds=max_tool_rounds,
|
|
190
|
+
extra_params=parsed_extra,
|
|
191
|
+
)
|
|
192
|
+
async with session:
|
|
193
|
+
user_input: str | None = message
|
|
194
|
+
original_style = ctx.set_prompt_style(CHAT_PROMPT)
|
|
195
|
+
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
196
|
+
ctx.set_rule_above(add_class="chat-mode")
|
|
197
|
+
ctx.set_rule_below(add_class="chat-mode")
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
while user_input:
|
|
201
|
+
if ctx.is_tui:
|
|
202
|
+
ctx.mount_widget(UserMessage(user_input))
|
|
203
|
+
ctx.set_hint("Responding...")
|
|
204
|
+
ctx.set_silent_queue(True)
|
|
205
|
+
needs_new_text_widget = True
|
|
206
|
+
widget: StreamingMessage | None = None
|
|
207
|
+
try:
|
|
208
|
+
async for chunk in session.stream(user_input):
|
|
209
|
+
if needs_new_text_widget or widget is None:
|
|
210
|
+
widget = StreamingMessage()
|
|
211
|
+
ctx.mount_widget(widget)
|
|
212
|
+
needs_new_text_widget = False
|
|
213
|
+
current_thought = None
|
|
214
|
+
widget.append(chunk)
|
|
215
|
+
if (n := ctx.pending_count) > 0:
|
|
216
|
+
ctx.set_hint(f"Responding... ({n} queued)")
|
|
217
|
+
except Exception as e:
|
|
218
|
+
# Surface the error inline: the outer handler is
|
|
219
|
+
# only reached after session teardown, which can
|
|
220
|
+
# hang on the MCP listener (see padwan-llm mcp.py).
|
|
221
|
+
ctx.mount_widget(ErrorMessage(str(e)))
|
|
222
|
+
needs_new_text_widget = True
|
|
223
|
+
ctx.set_silent_queue(False)
|
|
224
|
+
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
225
|
+
ctx.set_status_above(_format_tokens(session, ctx) or None)
|
|
226
|
+
user_input = await ctx.prompt()
|
|
227
|
+
else:
|
|
228
|
+
try:
|
|
229
|
+
async for chunk in session.stream(user_input):
|
|
230
|
+
console.print(chunk, end="")
|
|
231
|
+
except Exception as e:
|
|
232
|
+
console.print(f"\n[red]✖ {e}[/red]")
|
|
233
|
+
break
|
|
234
|
+
console.print()
|
|
235
|
+
if tokens := _format_tokens(session):
|
|
236
|
+
console.print(f"[dim]{tokens}[/dim]")
|
|
237
|
+
break
|
|
238
|
+
finally:
|
|
239
|
+
session.save()
|
|
240
|
+
# Cleanup is best-effort — on a double-Ctrl+C the TUI screen
|
|
241
|
+
# is already tearing down, so query_one("#status-above") and
|
|
242
|
+
# friends raise NoMatches. Don't surface that as a misleading
|
|
243
|
+
# "chat session failed" on what was actually a clean exit.
|
|
244
|
+
with contextlib.suppress(NoMatches):
|
|
245
|
+
ctx.set_silent_queue(False)
|
|
246
|
+
ctx.clear_queue() # Discard any messages typed during streaming
|
|
247
|
+
ctx.set_status_above(None)
|
|
248
|
+
ctx.set_hint(None)
|
|
249
|
+
ctx.set_rule_above(remove_class="chat-mode")
|
|
250
|
+
ctx.set_rule_below(remove_class="chat-mode")
|
|
251
|
+
if original_style:
|
|
252
|
+
ctx.set_prompt_style(original_style)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
console.print_exception(show_locals=False)
|
|
255
|
+
console.print(f"[red]Chat session failed: {e}[/red]")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@chat_group.command("clear", help="Clear conversation history")
|
|
259
|
+
async def chat_clear_fn(
|
|
260
|
+
model: str | None = Option(
|
|
261
|
+
None,
|
|
262
|
+
"-m",
|
|
263
|
+
"--model",
|
|
264
|
+
help="Model session to clear (all if omitted)",
|
|
265
|
+
choices=ALL_MODELS,
|
|
266
|
+
),
|
|
267
|
+
ctx: TuiContext = TuiOption(),
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Clear conversation history for a model or all models."""
|
|
270
|
+
if model:
|
|
271
|
+
if _store.forget(model):
|
|
272
|
+
ctx.notify(f"Cleared history for {model}", title="Chat")
|
|
273
|
+
print(f"Cleared conversation history for {model}")
|
|
274
|
+
else:
|
|
275
|
+
print(f"No active session for {model}")
|
|
276
|
+
else:
|
|
277
|
+
if _store.forget(None):
|
|
278
|
+
ctx.notify("Cleared all chat history", title="Chat")
|
|
279
|
+
print("Cleared all conversation history")
|
|
280
|
+
else:
|
|
281
|
+
print("No chat history to clear")
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import sys
|
|
5
|
+
from typing import Any
|
|
4
6
|
|
|
5
7
|
from piou import Cli, Option
|
|
8
|
+
from piou.tui import get_tui_context
|
|
6
9
|
|
|
7
10
|
from rich.table import Table
|
|
8
11
|
|
|
@@ -13,10 +16,11 @@ from padwan_llm import (
|
|
|
13
16
|
OPENAI_MODELS,
|
|
14
17
|
LLMClient,
|
|
15
18
|
)
|
|
19
|
+
from padwan_llm._base import OnThought
|
|
16
20
|
from padwan_llm.conversation import Message
|
|
17
21
|
from padwan_llm.errors import Provider
|
|
18
22
|
|
|
19
|
-
from .utils import
|
|
23
|
+
from .utils import console
|
|
20
24
|
from .batch import batch_group
|
|
21
25
|
from .chat import chat_group
|
|
22
26
|
|
|
@@ -32,6 +36,12 @@ cli.add_command_group(batch_group)
|
|
|
32
36
|
cli.add_command_group(chat_group)
|
|
33
37
|
|
|
34
38
|
|
|
39
|
+
@cli.tui_on_ready
|
|
40
|
+
def _tui_startup_hint() -> None:
|
|
41
|
+
"""Show a hint pointing new users at the command palette when the TUI opens."""
|
|
42
|
+
get_tui_context().set_hint("Type / to browse commands - e.g. /chat:send 'Hello'")
|
|
43
|
+
|
|
44
|
+
|
|
35
45
|
@cli.command("models", help="List available models")
|
|
36
46
|
def list_models(
|
|
37
47
|
provider: Provider | None = Option(
|
|
@@ -88,17 +98,45 @@ def info() -> None:
|
|
|
88
98
|
@cli.main(help="One-shot LLM query")
|
|
89
99
|
async def oneshot(
|
|
90
100
|
prompt: str = Option(..., help="Prompt to send"),
|
|
91
|
-
model: str = Option(
|
|
92
|
-
"gpt-4o-mini", "-m", "--model", help="Model to use", choices=ALL_MODELS
|
|
93
|
-
),
|
|
101
|
+
model: str = Option("gpt-4o-mini", "-m", "--model", help="Model to use"),
|
|
94
102
|
stream: bool = Option(False, "--stream", "-s", help="Stream output as it arrives"),
|
|
103
|
+
base_url: str | None = Option(
|
|
104
|
+
None, "--base-url", help="Custom OpenAI-compatible endpoint"
|
|
105
|
+
),
|
|
106
|
+
extra_params: str | None = Option(
|
|
107
|
+
None,
|
|
108
|
+
"--extra-params",
|
|
109
|
+
help="Extra JSON object merged into the request body (e.g. '{\"temperature\": 0}')",
|
|
110
|
+
),
|
|
111
|
+
stream_thinking: bool = Option(
|
|
112
|
+
False,
|
|
113
|
+
"--stream-thinking",
|
|
114
|
+
help="Stream model reasoning/thinking tokens to stderr (requires --stream)",
|
|
115
|
+
),
|
|
95
116
|
) -> None:
|
|
96
117
|
"""Send a single prompt and print the response."""
|
|
97
|
-
|
|
118
|
+
parsed_extra: dict[str, Any] | None = None
|
|
119
|
+
if extra_params is not None:
|
|
120
|
+
try:
|
|
121
|
+
parsed_extra = json.loads(extra_params)
|
|
122
|
+
except json.JSONDecodeError as e:
|
|
123
|
+
console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
|
|
124
|
+
raise SystemExit(1)
|
|
125
|
+
if not isinstance(parsed_extra, dict):
|
|
126
|
+
console.print("[red]--extra-params must be a JSON object[/red]")
|
|
127
|
+
raise SystemExit(1)
|
|
128
|
+
|
|
129
|
+
def _thought_streamer(chunk: str) -> None:
|
|
130
|
+
sys.stderr.write(chunk)
|
|
131
|
+
sys.stderr.flush()
|
|
132
|
+
|
|
133
|
+
on_thought: OnThought | None = _thought_streamer if stream_thinking else None
|
|
134
|
+
|
|
135
|
+
client = LLMClient(model=model, base_url=base_url, on_thought=on_thought)
|
|
98
136
|
messages: list[Message] = [Message(role="user", content=prompt)]
|
|
99
137
|
async with client:
|
|
100
138
|
if stream:
|
|
101
|
-
async for chunk in client.stream_chat(messages):
|
|
139
|
+
async for chunk in client.stream_chat(messages, extra_params=parsed_extra):
|
|
102
140
|
sys.stdout.write(chunk)
|
|
103
141
|
sys.stdout.flush()
|
|
104
142
|
else:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
4
5
|
|
|
5
6
|
from rich.text import Text
|
|
6
7
|
from textual.widgets import Static
|
|
@@ -13,6 +14,9 @@ if TYPE_CHECKING:
|
|
|
13
14
|
# Colors for chat messages
|
|
14
15
|
USER_COLOR = "#87ceeb"
|
|
15
16
|
ASSISTANT_COLOR = "#98fb98"
|
|
17
|
+
TOOL_CALL_COLOR = "#ffa500"
|
|
18
|
+
THOUGHT_COLOR = "#9370db"
|
|
19
|
+
ERROR_COLOR = "#ff6b6b"
|
|
16
20
|
|
|
17
21
|
# Colors for batch job states
|
|
18
22
|
STATE_COLORS = {
|
|
@@ -34,6 +38,61 @@ class UserMessage(Static):
|
|
|
34
38
|
super().__init__(text, **kwargs)
|
|
35
39
|
|
|
36
40
|
|
|
41
|
+
class ToolCallMessage(Static):
|
|
42
|
+
"""A styled widget for displaying an MCP tool call inline in the chat.
|
|
43
|
+
|
|
44
|
+
Shows the tool name and a truncated one-line preview of its arguments,
|
|
45
|
+
so the user can actually see when the agent reaches out to an MCP server
|
|
46
|
+
mid-response (toast notifications are easy to miss).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_MAX_ARGS_CHARS = 120
|
|
50
|
+
|
|
51
|
+
def __init__(self, name: str, args: dict[str, Any], **kwargs) -> None:
|
|
52
|
+
self._tool_name = name
|
|
53
|
+
args_str = json.dumps(args, ensure_ascii=False, sort_keys=True)
|
|
54
|
+
if len(args_str) > self._MAX_ARGS_CHARS:
|
|
55
|
+
args_str = args_str[: self._MAX_ARGS_CHARS - 1] + "…"
|
|
56
|
+
self._args_str = args_str
|
|
57
|
+
text = Text(f"\u26a1 {name}({args_str})", style=TOOL_CALL_COLOR)
|
|
58
|
+
super().__init__(text, **kwargs)
|
|
59
|
+
|
|
60
|
+
def set_elapsed(self, seconds: float) -> None:
|
|
61
|
+
"""Append elapsed time in dim style."""
|
|
62
|
+
text = Text()
|
|
63
|
+
text.append(
|
|
64
|
+
f"\u26a1 {self._tool_name}({self._args_str})", style=TOOL_CALL_COLOR
|
|
65
|
+
)
|
|
66
|
+
text.append(f" {seconds:.1f}s", style="dim")
|
|
67
|
+
self.update(text)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ErrorMessage(Static):
|
|
71
|
+
"""A styled widget for displaying an error inline in the chat."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, content: str, **kwargs) -> None:
|
|
74
|
+
text = Text(f"✖ {content}", style=ERROR_COLOR)
|
|
75
|
+
super().__init__(text, **kwargs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ThoughtMessage(Static):
|
|
79
|
+
"""A styled widget for displaying a model reasoning/thought block.
|
|
80
|
+
|
|
81
|
+
Providers stream reasoning/thoughts in small chunks. `append` grows the
|
|
82
|
+
widget in place so a continuous thought renders as one block instead
|
|
83
|
+
of one widget per chunk.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, content: str = "", **kwargs) -> None:
|
|
87
|
+
super().__init__(**kwargs)
|
|
88
|
+
self._text = content
|
|
89
|
+
self.update(Text(f"\U0001f4ad {self._text}", style=f"italic {THOUGHT_COLOR}"))
|
|
90
|
+
|
|
91
|
+
def append(self, text: str) -> None:
|
|
92
|
+
self._text += text
|
|
93
|
+
self.update(Text(f"\U0001f4ad {self._text}", style=f"italic {THOUGHT_COLOR}"))
|
|
94
|
+
|
|
95
|
+
|
|
37
96
|
class StreamingMessage(_StreamingMessage):
|
|
38
97
|
"""Streaming message with assistant color styling.
|
|
39
98
|
|
|
@@ -131,7 +190,7 @@ class BatchResultWidget(Static):
|
|
|
131
190
|
for result in self._results:
|
|
132
191
|
# Header with key and token info
|
|
133
192
|
lines.append(
|
|
134
|
-
f"[cyan][{result.key}][/cyan] "
|
|
193
|
+
f"[cyan]\\[{result.key}][/cyan] "
|
|
135
194
|
f"[dim]({result.input_tokens} in, {result.output_tokens} out, "
|
|
136
195
|
f"{result.total_tokens} total tokens)[/dim]"
|
|
137
196
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "padwan-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "CLI and TUI for padwan-llm"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -8,8 +8,8 @@ authors = [
|
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.14"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"padwan-llm>=0.
|
|
12
|
-
"piou[tui]>=0.
|
|
11
|
+
"padwan-llm>=0.6.0",
|
|
12
|
+
"piou[tui]>=0.34.1",
|
|
13
13
|
]
|
|
14
14
|
|
|
15
15
|
[project.scripts]
|
|
@@ -22,18 +22,20 @@ build-backend = "uv_build"
|
|
|
22
22
|
[tool.uv.build-backend]
|
|
23
23
|
module-root = ""
|
|
24
24
|
|
|
25
|
-
[tool.uv.sources]
|
|
26
|
-
padwan-llm = { path = "../padwan-llm", editable = true }
|
|
25
|
+
#[tool.uv.sources]
|
|
26
|
+
#padwan-llm = { path = "../padwan-llm", editable = true }
|
|
27
27
|
|
|
28
28
|
[dependency-groups]
|
|
29
29
|
dev = [
|
|
30
30
|
"piou[tui-reload]>=0.33.0",
|
|
31
|
+
"pyright>=1.1.408",
|
|
31
32
|
"pytest>=8.0.0",
|
|
33
|
+
"pytest-asyncio>=1.3.0",
|
|
34
|
+
"pytest-cov>=7.0.0",
|
|
32
35
|
"ruff>=0.11.0",
|
|
33
36
|
]
|
|
34
37
|
docs = [
|
|
35
|
-
"
|
|
36
|
-
"mkdocs-material>=9.5.0",
|
|
38
|
+
"zensical>=0.0.31",
|
|
37
39
|
]
|
|
38
40
|
bump = [
|
|
39
41
|
"commitizen>=4.0.0",
|
|
@@ -45,3 +47,6 @@ version_provider = "pep621"
|
|
|
45
47
|
tag_format = "$version"
|
|
46
48
|
update_changelog_on_bump = true
|
|
47
49
|
bump_message = "chore(release): release $current_version → $new_version [skip ci]"
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
asyncio_mode = "auto"
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
4
|
-
|
|
5
|
-
from piou import Option, CommandGroup
|
|
6
|
-
from piou.tui import PromptStyle, TuiContext, TuiOption
|
|
7
|
-
|
|
8
|
-
from padwan_llm import LLMClient
|
|
9
|
-
|
|
10
|
-
from .utils import ALL_MODELS, console
|
|
11
|
-
from .conversation import Conversation, Message
|
|
12
|
-
from .widgets import StreamingMessage, UserMessage
|
|
13
|
-
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from padwan_llm import LLMClientBase
|
|
16
|
-
|
|
17
|
-
chat_group = CommandGroup(name="chat", help="Chat with an LLM")
|
|
18
|
-
|
|
19
|
-
# Persistent conversation sessions keyed by model name
|
|
20
|
-
_sessions: dict[str, tuple[LLMClientBase, Conversation]] = {}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _get_or_create_session(
|
|
24
|
-
model: str,
|
|
25
|
-
system: str | None = None,
|
|
26
|
-
) -> tuple[LLMClientBase, Conversation]:
|
|
27
|
-
"""Get existing session or create a new one for the given model."""
|
|
28
|
-
if model in _sessions:
|
|
29
|
-
client, conv = _sessions[model]
|
|
30
|
-
# Update system prompt if provided and different
|
|
31
|
-
if system and conv.system != system:
|
|
32
|
-
conv.system = system
|
|
33
|
-
# Rebuild messages with new system prompt
|
|
34
|
-
old_messages = [m for m in conv.messages if m["role"] != "system"]
|
|
35
|
-
conv.messages = []
|
|
36
|
-
if system:
|
|
37
|
-
conv.messages.append(Message(role="system", content=system))
|
|
38
|
-
conv.messages.extend(old_messages)
|
|
39
|
-
return client, conv
|
|
40
|
-
|
|
41
|
-
client = LLMClient(model=model)
|
|
42
|
-
conv = Conversation(client, system=system)
|
|
43
|
-
_sessions[model] = (client, conv)
|
|
44
|
-
return client, conv
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
CHAT_PROMPT = PromptStyle(text="You: ", css_class="chat-mode")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _format_tokens(conv: Conversation, ctx: TuiContext | None = None) -> str:
|
|
51
|
-
"""Format token usage for display."""
|
|
52
|
-
if not conv.last_usage:
|
|
53
|
-
return ""
|
|
54
|
-
last = conv.last_usage
|
|
55
|
-
total = conv.total_usage
|
|
56
|
-
parts = [f"in: {last['input']}", f"out: {last['output']}"]
|
|
57
|
-
if "cached" in last:
|
|
58
|
-
parts.append(f"cached: {last['cached']}")
|
|
59
|
-
parts.append(f"| session: {total['total']}")
|
|
60
|
-
if ctx and ctx.pending_count > 0:
|
|
61
|
-
parts.append(f"| {ctx.pending_count} queued")
|
|
62
|
-
return " ".join(parts)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@chat_group.command("send", help="Send a message to the LLM")
|
|
66
|
-
async def chat_send_fn(
|
|
67
|
-
message: str = Option(..., help="Message to send"),
|
|
68
|
-
model: str = Option(
|
|
69
|
-
"gpt-4o-mini", "-m", "--model", help="Model to use", choices=ALL_MODELS
|
|
70
|
-
),
|
|
71
|
-
ctx: TuiContext = TuiOption(),
|
|
72
|
-
) -> None:
|
|
73
|
-
"""Start a conversation. Use Ctrl+C to exit."""
|
|
74
|
-
try:
|
|
75
|
-
client, conv = _get_or_create_session(model)
|
|
76
|
-
except Exception as e:
|
|
77
|
-
console.print(f"[red]Error creating session for {model}: {e}[/red]")
|
|
78
|
-
return
|
|
79
|
-
|
|
80
|
-
async with client:
|
|
81
|
-
user_input: str | None = message
|
|
82
|
-
original_style = ctx.set_prompt_style(CHAT_PROMPT)
|
|
83
|
-
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
84
|
-
ctx.set_rule_above(add_class="chat-mode")
|
|
85
|
-
ctx.set_rule_below(add_class="chat-mode")
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
while user_input:
|
|
89
|
-
if ctx.is_tui:
|
|
90
|
-
ctx.mount_widget(UserMessage(user_input))
|
|
91
|
-
widget = StreamingMessage()
|
|
92
|
-
ctx.mount_widget(widget)
|
|
93
|
-
ctx.set_hint("Responding...")
|
|
94
|
-
ctx.set_silent_queue(True)
|
|
95
|
-
async for chunk in conv.stream(user_input):
|
|
96
|
-
widget.append(chunk)
|
|
97
|
-
if (n := ctx.pending_count) > 0:
|
|
98
|
-
ctx.set_hint(f"Responding... ({n} queued)")
|
|
99
|
-
ctx.set_silent_queue(False)
|
|
100
|
-
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
101
|
-
ctx.set_status_above(_format_tokens(conv, ctx) or None)
|
|
102
|
-
user_input = await ctx.prompt()
|
|
103
|
-
else:
|
|
104
|
-
async for chunk in conv.stream(user_input):
|
|
105
|
-
console.print(chunk, end="")
|
|
106
|
-
console.print()
|
|
107
|
-
if tokens := _format_tokens(conv):
|
|
108
|
-
console.print(f"[dim]{tokens}[/dim]")
|
|
109
|
-
break
|
|
110
|
-
finally:
|
|
111
|
-
ctx.set_silent_queue(False)
|
|
112
|
-
ctx.clear_queue() # Discard any messages typed during streaming
|
|
113
|
-
ctx.set_status_above(None)
|
|
114
|
-
ctx.set_hint(None)
|
|
115
|
-
ctx.set_rule_above(remove_class="chat-mode")
|
|
116
|
-
ctx.set_rule_below(remove_class="chat-mode")
|
|
117
|
-
if original_style:
|
|
118
|
-
ctx.set_prompt_style(original_style)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
@chat_group.command("clear", help="Clear conversation history")
|
|
122
|
-
async def chat_clear_fn(
|
|
123
|
-
model: str | None = Option(
|
|
124
|
-
None,
|
|
125
|
-
"-m",
|
|
126
|
-
"--model",
|
|
127
|
-
help="Model session to clear (all if omitted)",
|
|
128
|
-
choices=ALL_MODELS,
|
|
129
|
-
),
|
|
130
|
-
ctx: TuiContext = TuiOption(),
|
|
131
|
-
) -> None:
|
|
132
|
-
"""Clear conversation history for a model or all models."""
|
|
133
|
-
if model:
|
|
134
|
-
if model in _sessions:
|
|
135
|
-
_sessions[model][1].clear()
|
|
136
|
-
ctx.notify(f"Cleared history for {model}", title="Chat")
|
|
137
|
-
print(f"Cleared conversation history for {model}")
|
|
138
|
-
else:
|
|
139
|
-
print(f"No active session for {model}")
|
|
140
|
-
else:
|
|
141
|
-
for _, conv in _sessions.values():
|
|
142
|
-
conv.clear()
|
|
143
|
-
ctx.notify("Cleared all chat history", title="Chat")
|
|
144
|
-
print("Cleared all conversation history")
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from collections.abc import AsyncIterator
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
|
|
6
|
-
from padwan_llm._base import LLMClientBase
|
|
7
|
-
from padwan_llm.conversation import ConversationState, Message
|
|
8
|
-
from padwan_llm.models import UsageToken
|
|
9
|
-
|
|
10
|
-
__all__ = ("Conversation", "Message")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@dataclass
|
|
14
|
-
class Conversation:
|
|
15
|
-
"""Manages a multi-turn conversation with an LLM client.
|
|
16
|
-
|
|
17
|
-
The Conversation class wraps ConversationState and provides streaming
|
|
18
|
-
interface for chat interactions. Provider-specific details are handled
|
|
19
|
-
by the client's stream_chat method.
|
|
20
|
-
|
|
21
|
-
Example:
|
|
22
|
-
async with LLMClient(model="gpt-4o") as client:
|
|
23
|
-
conv = Conversation(client, system="You are a helpful assistant.")
|
|
24
|
-
async for chunk in conv.stream("Hello!"):
|
|
25
|
-
print(chunk, end="", flush=True)
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
client: LLMClientBase
|
|
29
|
-
system: str | None = None
|
|
30
|
-
_state: ConversationState = field(init=False)
|
|
31
|
-
|
|
32
|
-
def __post_init__(self) -> None:
|
|
33
|
-
self._state = ConversationState(system=self.system)
|
|
34
|
-
|
|
35
|
-
@property
|
|
36
|
-
def messages(self) -> list[Message]:
|
|
37
|
-
"""Access the conversation message history."""
|
|
38
|
-
return self._state.messages
|
|
39
|
-
|
|
40
|
-
@messages.setter
|
|
41
|
-
def messages(self, value: list[Message]) -> None:
|
|
42
|
-
"""Set the conversation message history."""
|
|
43
|
-
self._state.messages = value
|
|
44
|
-
|
|
45
|
-
@property
|
|
46
|
-
def last_usage(self) -> UsageToken | None:
|
|
47
|
-
"""Token usage from the last request."""
|
|
48
|
-
return self._state.last_usage
|
|
49
|
-
|
|
50
|
-
@property
|
|
51
|
-
def total_usage(self) -> UsageToken:
|
|
52
|
-
"""Cumulative token usage across all requests."""
|
|
53
|
-
return self._state.total_usage
|
|
54
|
-
|
|
55
|
-
def add_user_message(self, content: str) -> Message:
|
|
56
|
-
"""Add a user message to the conversation history."""
|
|
57
|
-
return self._state.add_user_message(content)
|
|
58
|
-
|
|
59
|
-
def add_assistant_message(self, content: str) -> Message:
|
|
60
|
-
"""Add an assistant message to the conversation history."""
|
|
61
|
-
return self._state.add_assistant_message(content)
|
|
62
|
-
|
|
63
|
-
def clear(self) -> None:
|
|
64
|
-
"""Clear conversation history, keeping only the system message if set."""
|
|
65
|
-
self._state.clear()
|
|
66
|
-
|
|
67
|
-
async def stream(self, user_input: str) -> AsyncIterator[str]:
|
|
68
|
-
"""Send a user message and stream the assistant's response.
|
|
69
|
-
|
|
70
|
-
Adds the user message to history, streams the response via the client's
|
|
71
|
-
stream_chat method, yields text as it arrives, and saves the complete
|
|
72
|
-
response to history when done. Also tracks token usage.
|
|
73
|
-
"""
|
|
74
|
-
self.add_user_message(user_input)
|
|
75
|
-
|
|
76
|
-
response_chunks: list[str] = []
|
|
77
|
-
chat_stream = self.client.stream_chat(list(self.messages))
|
|
78
|
-
|
|
79
|
-
async for text in chat_stream:
|
|
80
|
-
response_chunks.append(text)
|
|
81
|
-
yield text
|
|
82
|
-
|
|
83
|
-
if chat_stream.usage:
|
|
84
|
-
self._state.accumulate_usage(chat_stream.usage)
|
|
85
|
-
|
|
86
|
-
full_response = "".join(response_chunks)
|
|
87
|
-
self.add_assistant_message(full_response)
|
|
88
|
-
|
|
89
|
-
async def send(self, user_input: str) -> str:
|
|
90
|
-
"""Send a user message and return the complete response (non-streaming)."""
|
|
91
|
-
chunks = []
|
|
92
|
-
async for chunk in self.stream(user_input):
|
|
93
|
-
chunks.append(chunk)
|
|
94
|
-
return "".join(chunks)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|