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.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: padwan-cli
3
- Version: 0.2.3
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.1.0
7
- Requires-Dist: piou[tui]>=0.32.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
@@ -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,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 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,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.2.3"
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.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"
@@ -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)