padwan-cli 0.2.1__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,43 @@
1
+ Metadata-Version: 2.3
2
+ Name: padwan-cli
3
+ Version: 0.2.1
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/main/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
+ ```
@@ -0,0 +1,33 @@
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/main/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
+ ```
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .run import cli, main, main_tui
4
+
5
+ __all__ = ("cli", "main", "main_tui")
@@ -0,0 +1,3 @@
1
+ from padwan_cli import main
2
+
3
+ main()
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .run import batch_group
4
+
5
+ __all__ = ["batch_group"]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from rich.table import Table
6
+
7
+ from ..utils import console
8
+
9
+ if TYPE_CHECKING:
10
+ from padwan_llm.gemini import BatchJob
11
+
12
+
13
+ def format_job(job: BatchJob) -> None:
14
+ """Pretty print a batch job with colored state and key fields."""
15
+ from ..widgets import STATE_COLORS
16
+
17
+ state_color = STATE_COLORS.get(job.state, "white")
18
+ console.print(f" Name: {job.name}")
19
+ console.print(f" State: [{state_color}]{job.state}[/{state_color}]")
20
+ if job.display_name:
21
+ console.print(f" Display: {job.display_name}")
22
+ if job.model:
23
+ console.print(f" Model: {job.model}")
24
+ if job.create_time:
25
+ console.print(f" Created: {job.create_time}")
26
+ if job.stats:
27
+ stats = job.stats
28
+ succeeded = stats.get("successfulRequestCount", 0)
29
+ total = stats.get("requestCount", 0)
30
+ console.print(f" Stats: {succeeded}/{total} succeeded")
31
+ if job.error:
32
+ console.print(f" [red]Error: {job.error}[/red]")
33
+
34
+
35
+ def format_job_table(jobs: list[BatchJob]) -> Table:
36
+ """Create a rich table displaying batch jobs with state colors and stats."""
37
+ from ..widgets import STATE_COLORS
38
+
39
+ table = Table(title="Batch Jobs")
40
+ table.add_column("Name", style="cyan", no_wrap=True)
41
+ table.add_column("State", style="bold")
42
+ table.add_column("Display Name")
43
+ table.add_column("Model")
44
+ table.add_column("Stats", justify="right")
45
+
46
+ for job in jobs:
47
+ state_color = STATE_COLORS.get(job.state, "white")
48
+ stats_str = ""
49
+ if job.stats:
50
+ succeeded = job.stats.get("successfulRequestCount", 0)
51
+ total = job.stats.get("requestCount", 0)
52
+ stats_str = f"{succeeded}/{total}"
53
+
54
+ table.add_row(
55
+ job.name.split("/")[-1], # Just the job ID
56
+ f"[{state_color}]{job.state.replace('JOB_STATE_', '')}[/{state_color}]",
57
+ job.display_name or "",
58
+ job.model or "",
59
+ stats_str,
60
+ )
61
+
62
+ return table
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from piou import CommandGroup, Option
7
+ from piou.tui import TuiContext, TuiOption
8
+
9
+ from padwan_llm import GEMINI_MODELS
10
+ from padwan_llm.gemini import BatchRequest, BatchResult, GeminiClient
11
+ from ..widgets import BatchProgressWidget, BatchResultWidget
12
+ from .format import console, format_job, format_job_table
13
+ from .utils import load_prompts_from_file, save_results_to_file
14
+
15
+ batch_group = CommandGroup(name="batch", help="Gemini batch operations")
16
+
17
+
18
+ def _print_results(results: list[BatchResult]) -> None:
19
+ """Print batch results as plain text for CLI mode."""
20
+ for result in results:
21
+ console.print(
22
+ f"[cyan][{result.key}][/cyan] "
23
+ f"[dim]({result.input_tokens} in, {result.output_tokens} out, "
24
+ f"{result.total_tokens} total tokens)[/dim]"
25
+ )
26
+ preview = result.content[:300].replace("\n", " ")
27
+ if len(result.content) > 300:
28
+ preview += "..."
29
+ console.print(f" {preview}\n")
30
+
31
+
32
+ @batch_group.command("create", help="Create a batch job with prompts")
33
+ async def batch_create(
34
+ prompts: list[str] | None = Option(
35
+ None, "-p", "--prompt", help="Inline prompts (multiple)"
36
+ ),
37
+ file: str | None = Option(
38
+ None, "-f", "--file", help="Read prompts from JSON/text file"
39
+ ),
40
+ model: str = Option(
41
+ "gemini-2.0-flash",
42
+ "-m",
43
+ "--model",
44
+ help="Model to use",
45
+ choices=sorted(GEMINI_MODELS),
46
+ ),
47
+ name: str = Option("cli-batch", "-n", "--name", help="Display name for the batch"),
48
+ ) -> None:
49
+ """Create a batch job with inline prompts or from a file.
50
+
51
+ Prompts can be specified inline with -p (multiple times) or loaded from
52
+ a file with -f. The file can be JSON (array of strings or objects with
53
+ 'prompt' and 'key' fields) or plain text (one prompt per line).
54
+ """
55
+ requests: list[BatchRequest] = []
56
+
57
+ if file:
58
+ requests = load_prompts_from_file(file)
59
+ elif prompts:
60
+ requests = [
61
+ BatchRequest(
62
+ contents=[{"role": "user", "parts": [{"text": prompt}]}],
63
+ key=f"prompt-{i}",
64
+ )
65
+ for i, prompt in enumerate(prompts)
66
+ ]
67
+
68
+ if not requests:
69
+ console.print(
70
+ "[red]No prompts provided. Use -p or -f to specify prompts.[/red]"
71
+ )
72
+ return
73
+
74
+ async with GeminiClient(model=model) as client: # pyright: ignore[reportArgumentType]
75
+ console.print(f"Creating batch with {len(requests)} requests...")
76
+ job = await client.create_batch(requests, display_name=name)
77
+ console.print("[green]Batch created:[/green]")
78
+ format_job(job)
79
+
80
+
81
+ @batch_group.command("status", help="Get status of a batch job")
82
+ async def batch_status(
83
+ job_name: str = Option(..., "-j", "--job", help="Batch job name"),
84
+ show_results: bool = Option(
85
+ False, "-r", "--results", help="Show results if completed"
86
+ ),
87
+ ctx: TuiContext = TuiOption(),
88
+ ) -> None:
89
+ """Get the current status of a batch job with optional results display."""
90
+ async with GeminiClient() as client:
91
+ job = await client.get_batch(job_name)
92
+ console.print("[bold]Batch status:[/bold]")
93
+ format_job(job)
94
+
95
+ if show_results and job.succeeded and job.inlined_responses:
96
+ console.print("\n[bold]Results:[/bold]")
97
+ results = [
98
+ BatchResult.from_inlined_response(r) for r in job.inlined_responses
99
+ ]
100
+ if ctx.is_tui:
101
+ ctx.mount_widget(BatchResultWidget(results))
102
+ else:
103
+ _print_results(results)
104
+
105
+
106
+ @batch_group.command("list", help="List batch jobs")
107
+ async def batch_list(
108
+ limit: int = Option(10, "-l", "--limit", help="Maximum jobs to show"),
109
+ ) -> None:
110
+ """List recent batch jobs with their status."""
111
+ async with GeminiClient() as client:
112
+ jobs, next_token = await client.list_batches(page_size=limit)
113
+
114
+ if not jobs:
115
+ console.print("No batch jobs found.")
116
+ return
117
+
118
+ table = format_job_table(jobs)
119
+ console.print(table)
120
+
121
+ if next_token:
122
+ console.print(
123
+ f"\n[dim]More jobs available (next_token: {next_token})[/dim]"
124
+ )
125
+
126
+
127
+ @batch_group.command("cancel", help="Cancel a batch job")
128
+ async def batch_cancel(
129
+ job_name: str = Option(..., "-j", "--job", help="Batch job name to cancel"),
130
+ ) -> None:
131
+ """Cancel a pending or running batch job."""
132
+ async with GeminiClient() as client:
133
+ console.print(f"Cancelling batch job: {job_name}")
134
+ await client.cancel_batch(job_name)
135
+ console.print("[green]Batch job cancelled.[/green]")
136
+
137
+
138
+ @batch_group.command("poll", help="Poll a batch job until completion")
139
+ async def batch_poll(
140
+ job_name: str = Option(..., "-j", "--job", help="Batch job name to poll"),
141
+ interval: int = Option(5, "-i", "--interval", help="Poll interval in seconds"),
142
+ timeout: int | None = Option(
143
+ None, "-t", "--timeout", help="Max wait time in seconds"
144
+ ),
145
+ show_results: bool = Option(
146
+ True, "-r", "--results", help="Show results on completion"
147
+ ),
148
+ output: str | None = Option(None, "-o", "--output", help="Save results to file"),
149
+ ctx: TuiContext = TuiOption(),
150
+ ) -> None:
151
+ """Poll a batch job until it reaches a terminal state.
152
+
153
+ Displays a progress widget showing the job state and stats.
154
+ Can optionally save results to a file when complete.
155
+ """
156
+ async with GeminiClient() as client:
157
+ console.print(f"Polling batch job: {job_name} (every {interval}s)")
158
+ job = await client.get_batch(job_name)
159
+
160
+ widget = None
161
+ if ctx.is_tui:
162
+ widget = BatchProgressWidget(job)
163
+ ctx.mount_widget(widget)
164
+
165
+ elapsed = 0
166
+ while not job.is_terminal:
167
+ if timeout and elapsed >= timeout:
168
+ console.print(f"[yellow]Timeout reached after {elapsed}s[/yellow]")
169
+ break
170
+
171
+ await asyncio.sleep(interval)
172
+ elapsed += interval
173
+ job = await client.get_batch(job_name)
174
+ if widget:
175
+ widget.update_job(job)
176
+ else:
177
+ console.print(f" [{job.state}] {elapsed}s elapsed")
178
+
179
+ if job.is_terminal:
180
+ console.print("\n[bold]Batch job completed:[/bold]")
181
+ else:
182
+ console.print("\n[bold]Batch job status (timed out):[/bold]")
183
+ format_job(job)
184
+
185
+ if job.succeeded and job.inlined_responses:
186
+ results = [
187
+ BatchResult.from_inlined_response(r) for r in job.inlined_responses
188
+ ]
189
+
190
+ if output:
191
+ fmt = Path(output).suffix.lstrip(".") or "json"
192
+ if fmt not in ("json", "csv", "txt"):
193
+ fmt = "json"
194
+ save_results_to_file(results, output, fmt)
195
+ console.print(f"[green]Results saved to: {output}[/green]")
196
+
197
+ if show_results:
198
+ console.print("\n[bold]Results:[/bold]")
199
+ if ctx.is_tui:
200
+ ctx.mount_widget(BatchResultWidget(results))
201
+ else:
202
+ _print_results(results)
203
+
204
+
205
+ @batch_group.command("retry", help="Retry failed requests from a batch")
206
+ async def batch_retry(
207
+ job_name: str = Option(..., "-j", "--job", help="Original job name"),
208
+ ) -> None:
209
+ """Create a new batch with only the failed requests from an original job.
210
+
211
+ Extracts failed request keys from the original job and creates a new
212
+ batch job to retry just those requests.
213
+ """
214
+ async with GeminiClient() as client:
215
+ original = await client.get_batch(job_name)
216
+
217
+ if not original.is_terminal:
218
+ console.print("[red]Original job has not completed yet.[/red]")
219
+ return
220
+
221
+ if not original.inlined_responses:
222
+ console.print("[red]No responses found in original job.[/red]")
223
+ return
224
+
225
+ # Find failed requests by checking for missing or error responses
226
+ failed_keys: list[str] = []
227
+ all_keys: set[str] = set()
228
+
229
+ for resp in original.inlined_responses:
230
+ key = resp.get("key", "")
231
+ all_keys.add(key)
232
+ response = resp.get("response", {})
233
+ # Check if response has an error or no candidates
234
+ if response.get("error") or not response.get("candidates"):
235
+ failed_keys.append(key)
236
+
237
+ if not failed_keys:
238
+ console.print("[green]No failed requests to retry.[/green]")
239
+ return
240
+
241
+ console.print(
242
+ f"Found {len(failed_keys)} failed requests out of {len(all_keys)}"
243
+ )
244
+
245
+ # For now, we can't easily recreate the original requests without
246
+ # storing them. This command would need the original prompts.
247
+ console.print(
248
+ "[yellow]Note: Retry requires original prompts to be available.[/yellow]"
249
+ )
250
+ console.print("Failed keys: " + ", ".join(failed_keys))
251
+
252
+
253
+ @batch_group.command("export", help="Export batch results to a file")
254
+ async def batch_export(
255
+ job_name: str = Option(..., "-j", "--job", help="Batch job name"),
256
+ output: str = Option(..., "-o", "--output", help="Output file path"),
257
+ fmt: str = Option("json", "-f", "--format", help="Format: json, csv, txt"),
258
+ ) -> None:
259
+ """Export completed batch results to a file.
260
+
261
+ Supports JSON (structured with all fields), CSV (tabular), and TXT
262
+ (key-content pairs) formats.
263
+ """
264
+ if fmt not in ("json", "csv", "txt"):
265
+ console.print(f"[red]Unknown format: {fmt}. Use json, csv, or txt.[/red]")
266
+ return
267
+
268
+ async with GeminiClient() as client:
269
+ job = await client.get_batch(job_name)
270
+
271
+ if not job.succeeded:
272
+ console.print(f"[red]Job not successful. State: {job.state}[/red]")
273
+ return
274
+
275
+ if not job.inlined_responses:
276
+ console.print("[red]No results found in job.[/red]")
277
+ return
278
+
279
+ results = [BatchResult.from_inlined_response(r) for r in job.inlined_responses]
280
+ save_results_to_file(results, output, fmt)
281
+
282
+ console.print(f"[green]Exported {len(results)} results to: {output}[/green]")
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from padwan_llm.gemini import BatchRequest, BatchResult
9
+
10
+ if TYPE_CHECKING:
11
+ from typing import Any
12
+
13
+
14
+ def load_prompts_from_file(file_path: str) -> list[BatchRequest]:
15
+ """Load prompts from a JSON array or newline-delimited text file.
16
+
17
+ For JSON files, accepts either a simple string array or an array of objects
18
+ with 'prompt' and optional 'key' fields. For text files, each line becomes
19
+ a separate prompt.
20
+ """
21
+ path = Path(file_path)
22
+ content = path.read_text()
23
+
24
+ if path.suffix.lower() == ".json":
25
+ data: list[str | dict[str, Any]] = json.loads(content)
26
+ requests: list[BatchRequest] = []
27
+ for i, item in enumerate(data):
28
+ if isinstance(item, str):
29
+ requests.append(
30
+ BatchRequest(
31
+ contents=[{"role": "user", "parts": [{"text": item}]}],
32
+ key=f"prompt-{i}",
33
+ )
34
+ )
35
+ else:
36
+ prompt = item.get("prompt", "")
37
+ key = item.get("key", f"prompt-{i}")
38
+ requests.append(
39
+ BatchRequest(
40
+ contents=[{"role": "user", "parts": [{"text": prompt}]}],
41
+ key=key,
42
+ )
43
+ )
44
+ return requests
45
+
46
+ # Text file: one prompt per line
47
+ lines = [line.strip() for line in content.splitlines() if line.strip()]
48
+ return [
49
+ BatchRequest(
50
+ contents=[{"role": "user", "parts": [{"text": line}]}],
51
+ key=f"prompt-{i}",
52
+ )
53
+ for i, line in enumerate(lines)
54
+ ]
55
+
56
+
57
+ def save_results_to_file(results: list[BatchResult], path: str, fmt: str) -> None:
58
+ """Export batch results in the specified format (json, csv, or txt)."""
59
+ output = Path(path)
60
+
61
+ if fmt == "json":
62
+ data = [
63
+ {
64
+ "key": r.key,
65
+ "content": r.content,
66
+ "input_tokens": r.input_tokens,
67
+ "output_tokens": r.output_tokens,
68
+ "total_tokens": r.total_tokens,
69
+ }
70
+ for r in results
71
+ ]
72
+ output.write_text(json.dumps(data, indent=2))
73
+
74
+ elif fmt == "csv":
75
+ with output.open("w", newline="") as f:
76
+ writer = csv.writer(f)
77
+ writer.writerow(
78
+ ["key", "content", "input_tokens", "output_tokens", "total_tokens"]
79
+ )
80
+ for r in results:
81
+ writer.writerow(
82
+ [r.key, r.content, r.input_tokens, r.output_tokens, r.total_tokens]
83
+ )
84
+
85
+ else: # txt
86
+ lines = [f"[{r.key}]\n{r.content}\n" for r in results]
87
+ output.write_text("\n".join(lines))
@@ -0,0 +1,144 @@
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")
@@ -0,0 +1,94 @@
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)
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from piou import Cli, Option
6
+
7
+ from rich.table import Table
8
+
9
+ from padwan_llm import (
10
+ GEMINI_MODELS,
11
+ GROK_MODELS,
12
+ MISTRAL_MODELS,
13
+ OPENAI_MODELS,
14
+ LLMClient,
15
+ )
16
+ from padwan_llm.conversation import Message
17
+ from padwan_llm.errors import Provider
18
+
19
+ from .utils import ALL_MODELS, console
20
+ from .batch import batch_group
21
+ from .chat import chat_group
22
+
23
+ CUSTOM_CSS = """
24
+ Rule.chat-mode {
25
+ color: #87ceeb;
26
+ }
27
+ """
28
+
29
+ cli = Cli(description="Padwan CLI for the unified LLM client library")
30
+
31
+ cli.add_command_group(batch_group)
32
+ cli.add_command_group(chat_group)
33
+
34
+
35
+ @cli.command("models", help="List available models")
36
+ def list_models(
37
+ provider: Provider | None = Option(
38
+ None, "-p", "--provider", help="Filter by provider"
39
+ ),
40
+ ) -> None:
41
+ """List all available models, optionally filtered by provider."""
42
+ provider_models: dict[str, list[str]] = {}
43
+ if not provider or provider == "openai":
44
+ provider_models["OpenAI"] = list(OPENAI_MODELS)
45
+ if not provider or provider == "gemini":
46
+ provider_models["Gemini"] = list(GEMINI_MODELS)
47
+ if not provider or provider == "mistral":
48
+ provider_models["Mistral"] = list(MISTRAL_MODELS)
49
+ if not provider or provider == "grok":
50
+ provider_models["Grok"] = list(GROK_MODELS)
51
+
52
+ if not provider_models:
53
+ console.print(f"[red]Unknown provider: {provider}[/red]")
54
+ return
55
+
56
+ table = Table(title="Available Models")
57
+ table.add_column("Provider", style="cyan")
58
+ table.add_column("Model", style="green")
59
+
60
+ for prov, models in provider_models.items():
61
+ for i, model in enumerate(sorted(models)):
62
+ table.add_row(prov if i == 0 else "", model)
63
+
64
+ console.print(table)
65
+
66
+
67
+ @cli.command("info", help="Show library information")
68
+ def info() -> None:
69
+ """Show library information."""
70
+ table = Table(title="Padwan LLM Library")
71
+ table.add_column("Provider", style="cyan")
72
+ table.add_column("Models", style="green", justify="right")
73
+
74
+ table.add_row("OpenAI", str(len(OPENAI_MODELS)))
75
+ table.add_row("Gemini", str(len(GEMINI_MODELS)))
76
+ table.add_row("Mistral", str(len(MISTRAL_MODELS)))
77
+ table.add_row("Grok", str(len(GROK_MODELS)))
78
+
79
+ total = (
80
+ len(OPENAI_MODELS) + len(GEMINI_MODELS) + len(MISTRAL_MODELS) + len(GROK_MODELS)
81
+ )
82
+ table.add_section()
83
+ table.add_row("[bold]Total[/bold]", f"[bold]{total}[/bold]")
84
+
85
+ console.print(table)
86
+
87
+
88
+ @cli.main(help="One-shot LLM query")
89
+ async def oneshot(
90
+ 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
+ ),
94
+ stream: bool = Option(False, "--stream", "-s", help="Stream output as it arrives"),
95
+ ) -> None:
96
+ """Send a single prompt and print the response."""
97
+ client = LLMClient(model=model)
98
+ messages: list[Message] = [Message(role="user", content=prompt)]
99
+ async with client:
100
+ if stream:
101
+ async for chunk in client.stream_chat(messages):
102
+ sys.stdout.write(chunk)
103
+ sys.stdout.flush()
104
+ else:
105
+ text, _ = await client.complete_chat(messages)
106
+ print(text)
107
+ print()
108
+
109
+
110
+ def main() -> None:
111
+ """Entry point for the CLI."""
112
+ cli.run(css=CUSTOM_CSS)
113
+
114
+
115
+ def main_tui() -> None:
116
+ """Entry point for the TUI CLI."""
117
+ cli.tui = True
118
+ main()
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
@@ -0,0 +1,7 @@
1
+ from rich.console import Console
2
+
3
+ from padwan_llm import GEMINI_MODELS, GROK_MODELS, MISTRAL_MODELS, OPENAI_MODELS
4
+
5
+ console = Console()
6
+
7
+ ALL_MODELS = sorted([*OPENAI_MODELS, *GEMINI_MODELS, *MISTRAL_MODELS, *GROK_MODELS])
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ from rich.text import Text
6
+ from textual.widgets import Static
7
+
8
+ from piou.tui import StreamingMessage as _StreamingMessage
9
+
10
+ if TYPE_CHECKING:
11
+ from padwan_llm.gemini import BatchJob, BatchResult
12
+
13
+ # Colors for chat messages
14
+ USER_COLOR = "#87ceeb"
15
+ ASSISTANT_COLOR = "#98fb98"
16
+
17
+ # Colors for batch job states
18
+ STATE_COLORS = {
19
+ "JOB_STATE_PENDING": "#ffa500",
20
+ "JOB_STATE_QUEUED": "#ffff00",
21
+ "JOB_STATE_RUNNING": "#00bfff",
22
+ "JOB_STATE_SUCCEEDED": "#98fb98",
23
+ "JOB_STATE_FAILED": "#ff6b6b",
24
+ "JOB_STATE_CANCELLED": "#808080",
25
+ "JOB_STATE_EXPIRED": "#808080",
26
+ }
27
+
28
+
29
+ class UserMessage(Static):
30
+ """A styled widget for displaying user messages."""
31
+
32
+ def __init__(self, content: str, **kwargs) -> None:
33
+ text = Text(content, style=USER_COLOR)
34
+ super().__init__(text, **kwargs)
35
+
36
+
37
+ class StreamingMessage(_StreamingMessage):
38
+ """Streaming message with assistant color styling.
39
+
40
+ Extends piou's StreamingMessage (which handles autoscroll) to render
41
+ text in the assistant color.
42
+ """
43
+
44
+ def append(self, text: str) -> None:
45
+ self._text += text
46
+ self.update(Text(self._text, style=ASSISTANT_COLOR))
47
+ from piou.tui import TuiApp
48
+
49
+ if isinstance(self.app, TuiApp):
50
+ self.call_after_refresh(self.app._scroll_to_bottom)
51
+
52
+
53
+ class BatchProgressWidget(Static):
54
+ """A widget that displays batch job progress with state coloring.
55
+
56
+ Shows job name, current state (with color), progress bar showing
57
+ succeeded/total requests, and elapsed time. Call update() to refresh
58
+ with new job data.
59
+ """
60
+
61
+ def __init__(self, job: BatchJob, **kwargs) -> None:
62
+ super().__init__(**kwargs)
63
+ self._job = job
64
+ self._state_history: list[str] = [job.state]
65
+ self._refresh_display()
66
+
67
+ def _refresh_display(self) -> None:
68
+ """Update the display with current job state."""
69
+ job = self._job
70
+ state_color = STATE_COLORS.get(job.state, "white")
71
+
72
+ # Build progress info
73
+ succeeded = 0
74
+ total = 0
75
+ if job.stats:
76
+ succeeded = cast(int, job.stats.get("successfulRequestCount", 0))
77
+ total = cast(int, job.stats.get("requestCount", 0))
78
+
79
+ # Create progress bar
80
+ bar_width = 30
81
+ if total > 0:
82
+ filled = int((succeeded / total) * bar_width)
83
+ else:
84
+ filled = 0
85
+ bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
86
+
87
+ # Build display text
88
+ lines = [
89
+ f"Job: {job.name.split('/')[-1]}",
90
+ f"State: [{state_color}]{job.state}[/{state_color}]",
91
+ f"Progress: [{bar}] {succeeded}/{total}",
92
+ ]
93
+
94
+ if job.display_name:
95
+ lines.insert(1, f"Name: {job.display_name}")
96
+
97
+ # Show state history if there were transitions
98
+ if len(self._state_history) > 1:
99
+ history = " -> ".join(
100
+ s.replace("JOB_STATE_", "") for s in self._state_history
101
+ )
102
+ lines.append(f"History: {history}")
103
+
104
+ text = Text("\n".join(lines))
105
+ self.update(text)
106
+
107
+ def update_job(self, job: BatchJob) -> None:
108
+ """Update with new job data and refresh display."""
109
+ if job.state != self._job.state:
110
+ self._state_history.append(job.state)
111
+ self._job = job
112
+ self._refresh_display()
113
+
114
+
115
+ class BatchResultWidget(Static):
116
+ """A widget that displays batch results in a styled format.
117
+
118
+ Shows each result with its key, token counts, and content preview.
119
+ Useful for viewing results in TUI mode.
120
+ """
121
+
122
+ def __init__(self, results: list[BatchResult], **kwargs) -> None:
123
+ super().__init__(**kwargs)
124
+ self._results = results
125
+ self._refresh_display()
126
+
127
+ def _refresh_display(self) -> None:
128
+ """Update the display with results."""
129
+ lines: list[str] = []
130
+
131
+ for result in self._results:
132
+ # Header with key and token info
133
+ lines.append(
134
+ f"[cyan][{result.key}][/cyan] "
135
+ f"[dim]({result.input_tokens} in, {result.output_tokens} out, "
136
+ f"{result.total_tokens} total tokens)[/dim]"
137
+ )
138
+
139
+ # Content preview (truncated)
140
+ preview = result.content[:300].replace("\n", " ")
141
+ if len(result.content) > 300:
142
+ preview += "..."
143
+ lines.append(f" {preview}")
144
+ lines.append("")
145
+
146
+ text = Text.from_markup("\n".join(lines))
147
+ self.update(text)
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "padwan-cli"
3
+ version = "0.2.1"
4
+ description = "CLI and TUI for padwan-llm"
5
+ readme = "README.md"
6
+ authors = [
7
+ { email = "julien.brayere@polarsen.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "padwan-llm>=0.1.0",
12
+ "piou[tui]>=0.32.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ padwan-cli = "padwan_cli:main_tui"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.9.28,<0.10.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [tool.uv.build-backend]
23
+ module-root = ""
24
+
25
+ [tool.uv.sources]
26
+ padwan-llm = { path = "../padwan-llm", editable = true }
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "piou[tui-reload]>=0.33.0",
31
+ "pytest>=8.0.0",
32
+ "ruff>=0.11.0",
33
+ ]
34
+ docs = [
35
+ "mkdocs>=1.6.0",
36
+ "mkdocs-material>=9.5.0",
37
+ ]
38
+ bump = [
39
+ "commitizen>=4.0.0",
40
+ ]
41
+
42
+ [tool.commitizen]
43
+ name = "cz_conventional_commits"
44
+ version_provider = "pep621"
45
+ tag_format = "$version"
46
+ update_changelog_on_bump = true
47
+ bump_message = "chore(release): release $current_version → $new_version [skip ci]"