padwan-cli 0.2.2__tar.gz → 0.3.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.
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.3
2
+ Name: padwan-cli
3
+ Version: 0.3.0
4
+ Summary: CLI and TUI for padwan-llm
5
+ Author-email: julien.brayere@polarsen.com
6
+ Requires-Dist: padwan-llm>=0.6.0
7
+ Requires-Dist: piou[tui]>=0.34.1
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Padwan CLI
12
+
13
+ Interactive CLI/TUI for [`padwan-llm`](https://github.com/polarsen-io/padwan-llm).
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
+
18
+ <img alt="Chat demo" src="https://github.com/polarsen-io/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ export OPENAI_API_KEY=...
24
+ uvx padwan-cli
25
+ # then /help for commands
26
+ ```
27
+
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install padwan-cli
33
+ ```
34
+
35
+ ## Local Development
36
+
37
+ ```bash
38
+ uv sync --group dev
39
+ uv run padwan-cli
40
+ ```
41
+
42
+ ## One-shot Mode
43
+
44
+ ```bash
45
+ uvx padwan-cli "Hello" -m gpt-4o-mini
46
+ ```
@@ -0,0 +1,36 @@
1
+ # Padwan CLI
2
+
3
+ Interactive CLI/TUI for [`padwan-llm`](https://github.com/polarsen-io/padwan-llm).
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
+
8
+ <img alt="Chat demo" src="https://github.com/polarsen-io/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ export OPENAI_API_KEY=...
14
+ uvx padwan-cli
15
+ # then /help for commands
16
+ ```
17
+
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install padwan-cli
23
+ ```
24
+
25
+ ## Local Development
26
+
27
+ ```bash
28
+ uv sync --group dev
29
+ uv run padwan-cli
30
+ ```
31
+
32
+ ## One-shot Mode
33
+
34
+ ```bash
35
+ uvx padwan-cli "Hello" -m gpt-4o-mini
36
+ ```
@@ -38,7 +38,7 @@ async def batch_create(
38
38
  None, "-f", "--file", help="Read prompts from JSON/text file"
39
39
  ),
40
40
  model: str = Option(
41
- "gemini-2.0-flash",
41
+ "gemini-2.5-flash",
42
42
  "-m",
43
43
  "--model",
44
44
  help="Model to use",
@@ -0,0 +1,260 @@
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 StreamingMessage, ThoughtMessage, ToolCallMessage, UserMessage
24
+
25
+ chat_group = CommandGroup(name="chat", help="Chat with an LLM")
26
+
27
+ # Public, no-auth streamable-HTTP MCP server for data.gouv.fr
28
+ DATAGOUV_MCP_URL = "https://mcp.data.gouv.fr/mcp"
29
+
30
+
31
+ class _InMemoryStore:
32
+ """ConversationStore keeping snapshots in memory across /chat:send calls.
33
+
34
+ Implements the `padwan_llm.ConversationStore` protocol so that
35
+ `AgentSession.load` / `AgentSession.save` transparently preserve chat
36
+ history across successive command invocations within the same TUI run.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._data: dict[str, ConversationSnapshot] = {}
41
+
42
+ def save(self, session_id: str, snapshot: ConversationSnapshot) -> None:
43
+ self._data[session_id] = snapshot
44
+
45
+ def load(self, session_id: str) -> ConversationSnapshot:
46
+ return self._data[session_id]
47
+
48
+ def forget(self, session_id: str | None) -> bool:
49
+ """Drop one or all snapshots; return True if anything was removed."""
50
+ if session_id is None:
51
+ existed = bool(self._data)
52
+ self._data.clear()
53
+ return existed
54
+ return self._data.pop(session_id, None) is not None
55
+
56
+
57
+ _store = _InMemoryStore()
58
+
59
+ CHAT_PROMPT = PromptStyle(text="You: ", css_class="chat-mode")
60
+
61
+
62
+ def _format_tokens(session: AgentSession, ctx: TuiContext | None = None) -> str:
63
+ """Format token usage and connected MCP count for display."""
64
+ if not session.last_usage:
65
+ return ""
66
+ last = session.last_usage
67
+ total = session.total_usage
68
+ parts = [f"in: {last['input']}", f"out: {last['output']}"]
69
+ if "cached" in last:
70
+ parts.append(f"cached: {last['cached']}")
71
+ parts.append(f"| session: {total['total']}")
72
+ # Count MCP transports (each represents a connected server); standalone
73
+ # McpTool entries in mcp_tools are individual tool definitions, not servers.
74
+ mcp_count = sum(1 for t in session.mcp_tools if not isinstance(t, McpTool))
75
+ if mcp_count:
76
+ parts.append(f"| mcp: {mcp_count}")
77
+ if ctx and ctx.pending_count > 0:
78
+ parts.append(f"| {ctx.pending_count} queued")
79
+ return " ".join(parts)
80
+
81
+
82
+ @chat_group.command("send", help="Send a message to the LLM")
83
+ async def chat_send_fn(
84
+ message: str = Option(..., help="Message to send"),
85
+ model: str = Option("gpt-4o-mini", "-m", "--model", help="Model to use"),
86
+ session_id: str | None = Option(None, "--resume", help="Resume session"),
87
+ max_tool_rounds: int = Option(
88
+ 20, "--max-tools-round", help="Maximum number of tool calls per round"
89
+ ),
90
+ base_url: str | None = Option(
91
+ None, "--base-url", help="Custom OpenAI-compatible endpoint"
92
+ ),
93
+ extra_params: str | None = Option(
94
+ None,
95
+ "--extra-params",
96
+ help="Extra JSON object merged into every request body (e.g. '{\"temperature\": 0}')",
97
+ ),
98
+ ctx: TuiContext = TuiOption(),
99
+ ) -> None:
100
+ """Start a conversation. Use Ctrl+C to exit."""
101
+
102
+ # Tracks whether the next text chunk should start a fresh streaming
103
+ # widget. Mounted widgets keep their position in the message scroll, so
104
+ # if we mounted a single StreamingMessage up-front any tool/thought
105
+ # widgets fired mid-stream would land *below* the (still empty) text
106
+ # placeholder — making the final answer appear above the tool calls
107
+ # that produced it. Resetting this flag on every event splits the text
108
+ # into one widget per "section" with tool/thought widgets between them.
109
+ needs_new_text_widget = True
110
+ # Consecutive thought chunks stream into the same widget; any non-thought
111
+ # event (tool call or text chunk) closes the current thought block so
112
+ # the next thought starts a fresh widget.
113
+ current_thought: ThoughtMessage | None = None
114
+
115
+ @contextmanager
116
+ def _on_tool(tc: ToolCallContext):
117
+ nonlocal needs_new_text_widget, current_thought
118
+ current_thought = None
119
+ if ctx.is_tui:
120
+ widget = ToolCallMessage(tc.name, tc.args)
121
+ ctx.mount_widget(widget)
122
+ needs_new_text_widget = True
123
+ t0 = time.perf_counter()
124
+ yield
125
+ widget.set_elapsed(time.perf_counter() - t0)
126
+ else:
127
+ console.print(f"[dim]→ tool call: {tc.name}({tc.args})[/dim]")
128
+ t0 = time.perf_counter()
129
+ yield
130
+ console.print(f"[dim] ↳ {time.perf_counter() - t0:.1f}s[/dim]")
131
+
132
+ def _on_thought(text: str) -> None:
133
+ nonlocal needs_new_text_widget, current_thought
134
+ if ctx.is_tui:
135
+ if current_thought is None:
136
+ current_thought = ThoughtMessage(text)
137
+ ctx.mount_widget(current_thought)
138
+ else:
139
+ current_thought.append(text)
140
+ needs_new_text_widget = True
141
+ else:
142
+ console.print(f"[dim italic]💭 {text}[/dim italic]", end="")
143
+
144
+ def _on_mcp_connect(transport: McpTransport) -> None:
145
+ if ctx.is_tui:
146
+ ctx.notify(transport.label, title="MCP connected")
147
+ else:
148
+ console.print(f"[green]MCP connected[/green] [dim]{transport.label}[/dim]")
149
+
150
+ parsed_extra: dict[str, Any] | None = None
151
+ if extra_params is not None:
152
+ try:
153
+ parsed_extra = json.loads(extra_params)
154
+ except json.JSONDecodeError as e:
155
+ console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
156
+ return
157
+ if not isinstance(parsed_extra, dict):
158
+ console.print("[red]--extra-params must be a JSON object[/red]")
159
+ return
160
+
161
+ try:
162
+ client = LLMClient(model=model, on_thought=_on_thought, base_url=base_url)
163
+ if isinstance(client, GeminiClient):
164
+ client.thinking_config = ThinkingConfig(includeThoughts=True)
165
+
166
+ # `--resume` lets the user pick an explicit session id; otherwise we
167
+ # key history by model so a second `/chat:send` continues the same
168
+ # conversation (and `chat_clear_fn` can target it by model name).
169
+ # `AgentSession.load` treats a missing snapshot as get-or-create, so
170
+ # the first turn under a new id lands in the store via `session.save()`.
171
+ session = AgentSession.load(
172
+ client=client,
173
+ mcp_tools=[McpStreamable(url=DATAGOUV_MCP_URL)],
174
+ on_tool=_on_tool,
175
+ on_mcp_connect=_on_mcp_connect,
176
+ store=_store,
177
+ session_id=session_id or model,
178
+ max_tool_rounds=max_tool_rounds,
179
+ extra_params=parsed_extra,
180
+ )
181
+ async with session:
182
+ user_input: str | None = message
183
+ original_style = ctx.set_prompt_style(CHAT_PROMPT)
184
+ ctx.set_hint("Chat mode - press Ctrl+C to exit")
185
+ ctx.set_rule_above(add_class="chat-mode")
186
+ ctx.set_rule_below(add_class="chat-mode")
187
+
188
+ try:
189
+ while user_input:
190
+ if ctx.is_tui:
191
+ ctx.mount_widget(UserMessage(user_input))
192
+ ctx.set_hint("Responding...")
193
+ ctx.set_silent_queue(True)
194
+ needs_new_text_widget = True
195
+ widget: StreamingMessage | None = None
196
+ async for chunk in session.stream(user_input):
197
+ if needs_new_text_widget:
198
+ widget = StreamingMessage()
199
+ ctx.mount_widget(widget)
200
+ needs_new_text_widget = False
201
+ current_thought = None
202
+ assert widget is not None
203
+ widget.append(chunk)
204
+ if (n := ctx.pending_count) > 0:
205
+ ctx.set_hint(f"Responding... ({n} queued)")
206
+ ctx.set_silent_queue(False)
207
+ ctx.set_hint("Chat mode - press Ctrl+C to exit")
208
+ ctx.set_status_above(_format_tokens(session, ctx) or None)
209
+ user_input = await ctx.prompt()
210
+ else:
211
+ async for chunk in session.stream(user_input):
212
+ console.print(chunk, end="")
213
+ console.print()
214
+ if tokens := _format_tokens(session):
215
+ console.print(f"[dim]{tokens}[/dim]")
216
+ break
217
+ finally:
218
+ session.save()
219
+ # Cleanup is best-effort — on a double-Ctrl+C the TUI screen
220
+ # is already tearing down, so query_one("#status-above") and
221
+ # friends raise NoMatches. Don't surface that as a misleading
222
+ # "chat session failed" on what was actually a clean exit.
223
+ with contextlib.suppress(NoMatches):
224
+ ctx.set_silent_queue(False)
225
+ ctx.clear_queue() # Discard any messages typed during streaming
226
+ ctx.set_status_above(None)
227
+ ctx.set_hint(None)
228
+ ctx.set_rule_above(remove_class="chat-mode")
229
+ ctx.set_rule_below(remove_class="chat-mode")
230
+ if original_style:
231
+ ctx.set_prompt_style(original_style)
232
+ except Exception as e:
233
+ console.print_exception(show_locals=False)
234
+ console.print(f"[red]Chat session failed: {e}[/red]")
235
+
236
+
237
+ @chat_group.command("clear", help="Clear conversation history")
238
+ async def chat_clear_fn(
239
+ model: str | None = Option(
240
+ None,
241
+ "-m",
242
+ "--model",
243
+ help="Model session to clear (all if omitted)",
244
+ choices=ALL_MODELS,
245
+ ),
246
+ ctx: TuiContext = TuiOption(),
247
+ ) -> None:
248
+ """Clear conversation history for a model or all models."""
249
+ if model:
250
+ if _store.forget(model):
251
+ ctx.notify(f"Cleared history for {model}", title="Chat")
252
+ print(f"Cleared conversation history for {model}")
253
+ else:
254
+ print(f"No active session for {model}")
255
+ else:
256
+ if _store.forget(None):
257
+ ctx.notify("Cleared all chat history", title="Chat")
258
+ print("Cleared all conversation history")
259
+ else:
260
+ 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 ALL_MODELS, console
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
- client = LLMClient(model=model)
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
- from typing import TYPE_CHECKING, cast
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,8 @@ 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"
16
19
 
17
20
  # Colors for batch job states
18
21
  STATE_COLORS = {
@@ -34,6 +37,53 @@ class UserMessage(Static):
34
37
  super().__init__(text, **kwargs)
35
38
 
36
39
 
40
+ class ToolCallMessage(Static):
41
+ """A styled widget for displaying an MCP tool call inline in the chat.
42
+
43
+ Shows the tool name and a truncated one-line preview of its arguments,
44
+ so the user can actually see when the agent reaches out to an MCP server
45
+ mid-response (toast notifications are easy to miss).
46
+ """
47
+
48
+ _MAX_ARGS_CHARS = 120
49
+
50
+ def __init__(self, name: str, args: dict[str, Any], **kwargs) -> None:
51
+ self._tool_name = name
52
+ args_str = json.dumps(args, ensure_ascii=False, sort_keys=True)
53
+ if len(args_str) > self._MAX_ARGS_CHARS:
54
+ args_str = args_str[: self._MAX_ARGS_CHARS - 1] + "…"
55
+ self._args_str = args_str
56
+ text = Text(f"\u26a1 {name}({args_str})", style=TOOL_CALL_COLOR)
57
+ super().__init__(text, **kwargs)
58
+
59
+ def set_elapsed(self, seconds: float) -> None:
60
+ """Append elapsed time in dim style."""
61
+ text = Text()
62
+ text.append(
63
+ f"\u26a1 {self._tool_name}({self._args_str})", style=TOOL_CALL_COLOR
64
+ )
65
+ text.append(f" {seconds:.1f}s", style="dim")
66
+ self.update(text)
67
+
68
+
69
+ class ThoughtMessage(Static):
70
+ """A styled widget for displaying a model reasoning/thought block.
71
+
72
+ Providers stream reasoning/thoughts in small chunks. `append` grows the
73
+ widget in place so a continuous thought renders as one block instead
74
+ of one widget per chunk.
75
+ """
76
+
77
+ def __init__(self, content: str = "", **kwargs) -> None:
78
+ super().__init__(**kwargs)
79
+ self._text = content
80
+ self.update(Text(f"\U0001f4ad {self._text}", style=f"italic {THOUGHT_COLOR}"))
81
+
82
+ def append(self, text: str) -> None:
83
+ self._text += text
84
+ self.update(Text(f"\U0001f4ad {self._text}", style=f"italic {THOUGHT_COLOR}"))
85
+
86
+
37
87
  class StreamingMessage(_StreamingMessage):
38
88
  """Streaming message with assistant color styling.
39
89
 
@@ -131,7 +181,7 @@ class BatchResultWidget(Static):
131
181
  for result in self._results:
132
182
  # Header with key and token info
133
183
  lines.append(
134
- f"[cyan][{result.key}][/cyan] "
184
+ f"[cyan]\\[{result.key}][/cyan] "
135
185
  f"[dim]({result.input_tokens} in, {result.output_tokens} out, "
136
186
  f"{result.total_tokens} total tokens)[/dim]"
137
187
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "padwan-cli"
3
- version = "0.2.2"
3
+ version = "0.3.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.1.0",
12
- "piou[tui]>=0.32.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
- "mkdocs>=1.6.0",
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"
padwan_cli-0.2.2/PKG-INFO DELETED
@@ -1,43 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: padwan-cli
3
- Version: 0.2.2
4
- Summary: CLI and TUI for padwan-llm
5
- Author-email: julien.brayere@polarsen.com
6
- Requires-Dist: padwan-llm>=0.1.0
7
- Requires-Dist: piou[tui]>=0.32.0
8
- Requires-Python: >=3.14
9
- Description-Content-Type: text/markdown
10
-
11
- # Padwan CLI
12
-
13
- Interactive CLI/TUI for `padwan-llm`.
14
-
15
- <img alt="Chat demo" src="https://github.com/Polarsen/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
16
-
17
- ## Quick start
18
-
19
- ```bash
20
- export OPENAI_API_KEY=...
21
- uvx padwan-cli
22
- # then /help for commands
23
- ```
24
-
25
-
26
- ## Install
27
-
28
- ```bash
29
- pip install padwan-cli
30
- ```
31
-
32
- ## Local Development
33
-
34
- ```bash
35
- uv sync --group dev
36
- uv run padwan-cli
37
- ```
38
-
39
- ## One-shot Mode
40
-
41
- ```bash
42
- uvx padwan-cli "Hello" -m gpt-4o-mini
43
- ```
@@ -1,33 +0,0 @@
1
- # Padwan CLI
2
-
3
- Interactive CLI/TUI for `padwan-llm`.
4
-
5
- <img alt="Chat demo" src="https://github.com/Polarsen/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
6
-
7
- ## Quick start
8
-
9
- ```bash
10
- export OPENAI_API_KEY=...
11
- uvx padwan-cli
12
- # then /help for commands
13
- ```
14
-
15
-
16
- ## Install
17
-
18
- ```bash
19
- pip install padwan-cli
20
- ```
21
-
22
- ## Local Development
23
-
24
- ```bash
25
- uv sync --group dev
26
- uv run padwan-cli
27
- ```
28
-
29
- ## One-shot Mode
30
-
31
- ```bash
32
- uvx padwan-cli "Hello" -m gpt-4o-mini
33
- ```
@@ -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)