diff --git a/docs/commands/chat.md b/docs/commands/chat.md index f0b43ae..23e2b59 100644 --- a/docs/commands/chat.md +++ b/docs/commands/chat.md @@ -39,6 +39,24 @@ in: 42 out: 128 cached: 0 | session: 170 | mcp: 1 | 2 queued - **mcp** — number of connected MCP servers (only shown when > 0) - **queued** — messages typed while the model was responding, awaiting their turn (only shown when > 0) +### Attaching files + +In TUI mode you can **drag and drop files** onto the terminal while in chat mode. +The terminal pastes the dropped file's path, which the chat detects and turns into +an attachment — a chip appears above the input listing each queued file. Type your +message and press Enter to send the files along with it; press **Ctrl+U** to clear +the queued attachments. + +- **Text files** (code, Markdown, JSON, …) are read and inlined into your message, + so they work with any model. +- **Images** are sent to the model as image input — this requires a vision-capable + model (e.g. `gpt-4o`, `gemini-2.5-flash`, `pixtral-large-latest`). If the active + model can't see images, the chip is marked `⚠ needs a vision model` and the image + is left out of the request (switch models with `-m`). + +You need to type at least a short message to send; dropping a file alone won't send. +Drag-and-drop is a TUI-only feature. + ### Tools and MCP Chat sessions run without tools by default. Pass `--mcp` with one or more streamable-HTTP MCP server URLs to expose their tools to the model: diff --git a/env.template b/env.template new file mode 100644 index 0000000..dd8e7ea --- /dev/null +++ b/env.template @@ -0,0 +1,19 @@ +# API keys — copy to `.env` (gitignored) and fill in your own keys. +# `just` loads `.env` automatically (set dotenv-load). +# +# cp env.template .env +# # edit .env with your keys +# just chat + +OPENAI_API_KEY= +GEMINI_API_KEY= +MISTRAL_API_KEY= +GROK_API_KEY= +ANTHROPIC_API_KEY= +# For OpenAI-compatible endpoints passed via --base-url. +NVIDIA_API_KEY= + +# Unified gateway: one OpenAI-compatible URL + token for every model. When set, +# all models route through it via OpenAIClient. +# PADWAN_BASE_URL=https://your-gateway.example.com/v1/ +# PADWAN_API_KEY= diff --git a/justfile b/justfile index 9ff2e68..922c565 100644 --- a/justfile +++ b/justfile @@ -1,10 +1,15 @@ set quiet set dotenv-load +set positional-arguments # List available recipes default: @just --list +# Open the chat TUI (.env keys loaded automatically) +chat *args: + uv run padwan-cli /chat:send "$@" + # Run unit tests [group('dev')] test *args: diff --git a/padwan_cli/chat.py b/padwan_cli/chat.py index 6d05e43..35fd702 100644 --- a/padwan_cli/chat.py +++ b/padwan_cli/chat.py @@ -1,7 +1,9 @@ import contextlib import json +import mimetypes import time from contextlib import contextmanager +from pathlib import Path from typing import Any from piou import Option, CommandGroup @@ -10,22 +12,30 @@ from padwan_llm import ( AgentSession, + ContentPart, ConversationSnapshot, LLMClient, McpStreamable, McpTool, McpTransport, ToolCallContext, + image_part, + supports_vision, + text_file_part, + text_part, ) from padwan_llm.gemini import GeminiClient from padwan_llm.gemini.models import ThinkingConfig from .utils import ALL_MODELS, console from .widgets import ( + Attachment, + AttachmentBadge, ErrorMessage, StreamingMessage, ThoughtMessage, ToolCallMessage, UserMessage, + render_attachments, ) chat_group = CommandGroup(name="chat", help="Chat with an LLM") @@ -85,6 +95,48 @@ def _format_tokens(session: AgentSession, ctx: TuiContext | None = None) -> str: return " ".join(parts) +def _build_user_content( + text: str, attachments: list[Attachment] +) -> str | list[ContentPart]: + """Assemble the user turn from typed text and queued attachments. + + Returns the plain string when nothing is attached. Otherwise builds content + parts: the text, each text file inlined, and each supported image as a data + URL. Unsupported images (text-only model) and unreadable files are dropped + from the payload — they still appear in the sent badge. + """ + if not attachments: + return text + parts: list[ContentPart] = [] + if text: + parts.append(text_part(text)) + for a in attachments: + if a.is_image: + if a.supported: + parts.append(image_part(a.path)) + else: + try: + parts.append(text_file_part(a.path)) + except OSError, UnicodeDecodeError: + continue + return parts + + +def _parse_extra_params(raw: str | None) -> tuple[dict[str, Any] | None, bool]: + """Parse --extra-params JSON; print the error and return ok=False on failure.""" + if raw is None: + return None, True + try: + parsed = json.loads(raw) + except json.JSONDecodeError as e: + console.print(f"[red]--extra-params is not valid JSON: {e}[/red]") + return None, False + if not isinstance(parsed, dict): + console.print("[red]--extra-params must be a JSON object[/red]") + return None, False + return parsed, True + + @chat_group.command("send", help="Send a message to the LLM") async def chat_send_fn( message: str = Option(..., help="Message to send"), @@ -158,16 +210,9 @@ def _on_mcp_connect(transport: McpTransport) -> None: else: console.print(f"[green]MCP connected[/green] [dim]{transport.label}[/dim]") - parsed_extra: dict[str, Any] | None = None - if extra_params is not None: - try: - parsed_extra = json.loads(extra_params) - except json.JSONDecodeError as e: - console.print(f"[red]--extra-params is not valid JSON: {e}[/red]") - return - if not isinstance(parsed_extra, dict): - console.print("[red]--extra-params must be a JSON object[/red]") - return + parsed_extra, ok = _parse_extra_params(extra_params) + if not ok: + return try: client = LLMClient(model=model, on_thought=_on_thought, base_url=base_url) @@ -196,16 +241,84 @@ def _on_mcp_connect(transport: McpTransport) -> None: ctx.set_rule_above(add_class="chat-mode") ctx.set_rule_below(add_class="chat-mode") + # Files dropped onto the terminal arrive as a paste of their path(s); + # queue them here and send with the next typed message. + pending: list[Attachment] = [] + + def _set_attach_warning(on: bool) -> None: + """Turn the input bars amber (or back to chat-mode cyan).""" + add, remove = ( + ("attach-warning", "chat-mode") + if on + else ("chat-mode", "attach-warning") + ) + ctx.set_rule_above(add_class=add, remove_class=remove) + ctx.set_rule_below(add_class=add, remove_class=remove) + + def _on_drop(paths: list[Path]) -> None: + for p in paths: + try: + size = p.stat().st_size + except OSError: + continue + is_image = (mimetypes.guess_type(p)[0] or "").startswith("image/") + pending.append( + Attachment( + path=p, + name=p.name, + size=size, + is_image=is_image, + supported=(not is_image) or supports_vision(model), + ) + ) + blind = [a for a in pending if a.is_image and not a.supported] + warning = ( + f"{model} can't read images — {len(blind)} will be skipped" + if blind + else None + ) + ctx.set_attachments(render_attachments(pending, warning=warning)) + _set_attach_warning(bool(blind)) + ctx.set_hint( + f"{len(pending)} attached — type a message · Ctrl+U clears" + ) + + def _on_clear() -> None: + pending.clear() + ctx.set_attachments(None) + _set_attach_warning(False) + ctx.set_hint("Chat mode - press Ctrl+C to exit") + + ctx.register_paste_handler(_on_drop) + ctx.register_attachment_clear(_on_clear) + try: while user_input: if ctx.is_tui: + sent = list(pending) ctx.mount_widget(UserMessage(user_input)) + if sent: + skipped = [ + a for a in sent if a.is_image and not a.supported + ] + badge_warning = ( + f"{model} can't read images — {len(skipped)} skipped" + if skipped + else None + ) + ctx.mount_widget( + AttachmentBadge(sent, warning=badge_warning) + ) + content = _build_user_content(user_input, sent) + pending.clear() + ctx.set_attachments(None) + _set_attach_warning(False) ctx.set_hint("Responding...") ctx.set_silent_queue(True) needs_new_text_widget = True widget: StreamingMessage | None = None try: - async for chunk in session.stream(user_input): + async for chunk in session.stream(content): if needs_new_text_widget or widget is None: widget = StreamingMessage() ctx.mount_widget(widget) @@ -237,6 +350,8 @@ def _on_mcp_connect(transport: McpTransport) -> None: break finally: session.save() + ctx.register_paste_handler(None) + ctx.register_attachment_clear(None) # Cleanup is best-effort — on a double-Ctrl+C the TUI screen # is already tearing down, so query_one("#status-above") and # friends raise NoMatches. Don't surface that as a misleading @@ -245,9 +360,12 @@ def _on_mcp_connect(transport: McpTransport) -> None: ctx.set_silent_queue(False) ctx.clear_queue() # Discard any messages typed during streaming ctx.set_status_above(None) + ctx.set_attachments(None) ctx.set_hint(None) ctx.set_rule_above(remove_class="chat-mode") ctx.set_rule_below(remove_class="chat-mode") + ctx.set_rule_above(remove_class="attach-warning") + ctx.set_rule_below(remove_class="attach-warning") if original_style: ctx.set_prompt_style(original_style) except Exception as e: diff --git a/padwan_cli/run.py b/padwan_cli/run.py index a320a0f..c7a523f 100644 --- a/padwan_cli/run.py +++ b/padwan_cli/run.py @@ -23,11 +23,15 @@ from .utils import console from .batch import batch_group from .chat import chat_group +from .talk import talk_command CUSTOM_CSS = """ Rule.chat-mode { color: #87ceeb; } +Rule.attach-warning { + color: #fbbf24; +} """ cli = Cli(description="Padwan CLI for the unified LLM client library") @@ -35,6 +39,8 @@ cli.add_command_group(batch_group) cli.add_command_group(chat_group) +cli.command("talk", help="Speech-to-speech voice chat")(talk_command) + @cli.tui_on_ready def _tui_startup_hint() -> None: diff --git a/padwan_cli/talk.py b/padwan_cli/talk.py new file mode 100644 index 0000000..643364b --- /dev/null +++ b/padwan_cli/talk.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import asyncio +import contextlib +import functools +import os +import queue +import signal +import sys +import termios +import threading +import tty + +from piou import Option +from piou.tui import get_tui_context +from tracktolib.utils import BytesBuffer + +from padwan_llm import RealtimeClient, RealtimeConnection, RealtimeServerEvent +from padwan_llm.errors import LLMError +from padwan_llm.openai.realtime import NO_TURN_DETECTION, REALTIME_SAMPLE_RATE + +from .utils import console + +SR = REALTIME_SAMPLE_RATE # 24 kHz mono PCM16, as gpt-realtime expects +CHANNELS = 1 +DTYPE = "int16" +BLOCK = 1200 # 50 ms frames + +# Terminals report no key-release: a held Space arrives as auto-repeated spaces. +# A gap longer than the keyboard's initial repeat delay (~0.66 s default) means +# the key was released; shorter turns are discarded as accidental taps. +_HOLD_RELEASE_GAP = 0.8 +_MIN_TURN_SECS = 0.45 + +_DEFAULT_INSTRUCTIONS = """You are a friendly voice assistant. +Keep spoken replies brief and conversational — one or two sentences — so the +exchange stays a real back-and-forth.""" + + +class Speaker: + """Buffered PCM16 playback with barge-in support. + + BytesBuffer keeps the audio callback O(read size): appends are reference- + only and reads copy at chunk boundaries, instead of memmoving the whole + queued backlog on every 50 ms tick. + """ + + def __init__(self, sd) -> None: + self._buf = BytesBuffer() + self._lock = threading.Lock() + self.stream = sd.RawOutputStream( + samplerate=SR, + channels=CHANNELS, + dtype=DTYPE, + blocksize=BLOCK, + callback=self._callback, + ) + + def _callback(self, outdata, frames, _time, _status) -> None: # PortAudio thread + need = frames * CHANNELS * 2 + with self._lock: + take = self._buf.get(need) if len(self._buf) else b"" + if len(take) < need: + take += b"\x00" * (need - len(take)) # underflow -> silence + outdata[:] = take + + def play(self, pcm16: bytes) -> None: + with self._lock: + self._buf.put(pcm16) + + def clear(self) -> None: + with self._lock: + self._buf = BytesBuffer() + + +def _import_sounddevice(): + """Import sounddevice lazily, with a friendly hint if the extra is missing.""" + try: + import sounddevice as sd + except OSError as e: # PortAudio shared library not found + console.print(f"[red]PortAudio unavailable: {e}[/red]") + console.print( + "[dim]Install the system library, e.g. `apt install libportaudio2`.[/dim]" + ) + raise SystemExit(1) + except ImportError: + console.print("[red]sounddevice not installed.[/red]") + console.print("[dim]Install the voice extra: `uv sync --extra voice`.[/dim]") + raise SystemExit(1) + return sd + + +@contextlib.contextmanager +def _cbreak(fd: int): + """Put the terminal in cbreak mode so single keypresses arrive immediately.""" + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) # leaves ISIG on, so Ctrl-C still quits + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +async def _handle_events( + conn: RealtimeConnection, speaker: Speaker, *, push_to_talk: bool +) -> None: + """Consume server events: play audio, stream transcripts, prompt for the turn. + + Your own transcript arrives asynchronously and usually after the assistant has + started answering, so assistant text is held until the "you:" line has printed + (flushed unconditionally on response.done so nothing is ever lost). + """ + speaking = False # is the assistant mid-utterance on this line? + held: list[str] = [] # assistant transcript held until the "you:" line prints + user_shown = True # has the current turn's input transcript been printed? + prepaid = False # input transcript arrived before its response was created + + def flush_held() -> None: + nonlocal speaking + if held: + console.print("[cyan]assistant:[/cyan] ", end="") + sys.stdout.write("".join(held)) + sys.stdout.flush() + held.clear() + speaking = True + + async for event in conn: + kind = event.get("type") + if kind == RealtimeServerEvent.AUDIO_DELTA: + if pcm := conn.audio_delta_bytes(event): + speaker.play(pcm) + elif kind == RealtimeServerEvent.AUDIO_TRANSCRIPT_DELTA: + delta = event.get("delta", "") + if not user_shown: + held.append(delta) + continue + if not speaking: + console.print("[cyan]assistant:[/cyan] ", end="") + speaking = True + sys.stdout.write(delta) + sys.stdout.flush() + elif kind == RealtimeServerEvent.RESPONSE_CREATED: + if prepaid: + prepaid = False # transcript already printed for this turn + else: + user_shown = False + elif kind == RealtimeServerEvent.SPEECH_STARTED: # hands-free VAD only + speaker.clear() # barge-in: drop queued assistant audio + flush_held() + if speaking: + sys.stdout.write("\n") + speaking = False + elif kind == RealtimeServerEvent.INPUT_TRANSCRIPT_COMPLETED: + text = (event.get("transcript") or "").strip() + if speaking: + sys.stdout.write("\n") + speaking = False + if text: + console.print(f"[green]you:[/green] {text}") + if user_shown: + prepaid = True # transcript beat its response.created + else: + user_shown = True + flush_held() + elif kind == RealtimeServerEvent.RESPONSE_DONE: + flush_held() # transcript never arrived; don't lose the assistant text + user_shown = True + if speaking: + sys.stdout.write("\n") + speaking = False + if push_to_talk: + console.print("[dim]· hold Space to talk[/dim]") + elif kind == RealtimeServerEvent.ERROR: + console.print(f"\n[dim][error] {event.get('error')}[/dim]") + + +async def _converse( + conn: RealtimeConnection, sd, speaker: Speaker, *, push_to_talk: bool +) -> None: + """Pump mic → model and run the event loop, in push-to-talk or hands-free mode.""" + loop = asyncio.get_running_loop() + mic_q: queue.Queue[bytes] = queue.Queue() + recording = threading.Event() + if not push_to_talk: + recording.set() # hands-free: stream continuously, server VAD picks turns + + def mic_callback(indata, _frames, _time, _status) -> None: # PortAudio thread + if recording.is_set(): + mic_q.put(bytes(indata)) + + mic = sd.RawInputStream( + samplerate=SR, + channels=CHANNELS, + dtype=DTYPE, + blocksize=BLOCK, + callback=mic_callback, + ) + + draining = False + sent_bytes = 0 + + async def pump_mic() -> None: + nonlocal draining, sent_bytes + # Timed get so the executor thread exits shortly after cancellation + # instead of blocking threading shutdown at exit. + blocking_get = functools.partial(mic_q.get, True, 0.25) + while True: + try: + chunk = await loop.run_in_executor(None, blocking_get) + except queue.Empty: + continue + # The transport lets roughly one send through per read-poll tick, + # so batch everything captured while we waited for the socket. + parts = [chunk] + while True: + try: + parts.append(mic_q.get_nowait()) + except queue.Empty: + break + draining = True + try: + joined = b"".join(parts) + await conn.append_audio(joined) + sent_bytes += len(joined) + finally: + draining = False + + turn_task: asyncio.Task | None = None + last_space = 0.0 + turn_started_at = 0.0 + + async def start_turn() -> None: + nonlocal turn_started_at, sent_bytes + with mic_q.mutex: + mic_q.queue.clear() # drop audio captured before the press + speaker.clear() # stop the assistant if it was still talking + await conn.send_event({"type": "input_audio_buffer.clear"}) + recording.set() + turn_started_at = loop.time() + sent_bytes = 0 + console.print("[red]● recording…[/red] [dim]release Space to send[/dim]") + + async def end_turn() -> None: + recording.clear() + # Wait until everything captured actually reached the socket, so the + # commit doesn't clip the tail of the turn. + deadline = loop.time() + 3 + while (not mic_q.empty() or draining) and loop.time() < deadline: + await asyncio.sleep(0.05) + if loop.time() - turn_started_at < _MIN_TURN_SECS: + await conn.send_event({"type": "input_audio_buffer.clear"}) + console.print("[dim](too short — hold Space while speaking)[/dim]") + return + await conn.commit_audio() + await conn.create_response() + console.print(f"[dim](sent {sent_bytes / (SR * 2):.1f}s of audio)[/dim]") + + def _spawn_turn(coro_fn) -> None: + nonlocal turn_task + if turn_task is not None and not turn_task.done(): + return # previous transition still in flight + turn_task = asyncio.create_task(coro_fn()) + + def on_key() -> None: # called by the event loop when stdin is readable + nonlocal last_space + try: + data = os.read(sys.stdin.fileno(), 1) + except OSError: + return + if data == b" ": + # Held Space shows up as auto-repeated spaces; each one refreshes + # the hold. The release watcher ends the turn once they stop. + last_space = loop.time() + if not recording.is_set(): + _spawn_turn(start_turn) + elif data in (b"\r", b"\n") and recording.is_set(): + _spawn_turn(end_turn) # manual send fallback + + async def watch_release() -> None: + while True: + await asyncio.sleep(0.1) + if recording.is_set() and loop.time() - last_space > _HOLD_RELEASE_GAP: + _spawn_turn(end_turn) + + use_keys = push_to_talk and sys.stdin.isatty() + with mic: + sender = asyncio.create_task(pump_mic()) + watcher = asyncio.create_task(watch_release()) if use_keys else None + fd = sys.stdin.fileno() + try: + if use_keys: + with _cbreak(fd): + loop.add_reader(fd, on_key) + try: + await _handle_events(conn, speaker, push_to_talk=True) + finally: + loop.remove_reader(fd) + else: + await _handle_events(conn, speaker, push_to_talk=False) + finally: + sender.cancel() + if watcher is not None: + watcher.cancel() + if turn_task is not None: + turn_task.cancel() + + +async def talk_command( + voice: str = Option("marin", "--voice", help="Realtime voice (marin, cedar, …)"), + model: str = Option("gpt-realtime", "-m", "--model", help="Realtime model"), + instructions: str | None = Option( + None, "--instructions", help="System prompt for the voice assistant" + ), + hands_free: bool = Option( + False, "--hands-free", help="Auto voice detection instead of push-to-talk" + ), + check: bool = Option( + False, "--check", help="List audio devices and key status, then exit" + ), +) -> None: + """Talk with a real-time voice assistant (speech-to-speech).""" + # A live mic/speaker session needs the whole terminal; the Textual TUI owns it, + # so this command only works headless. Fail loudly instead of hanging silently. + if get_tui_context().is_tui: + console.print( + "[yellow]`talk` is a full-terminal voice session and can't run inside " + "the TUI.[/yellow]\n[dim]Run it headless:[/dim] [bold]uv run padwan-talk" + "[/bold] [dim](or `python -m padwan_cli talk`).[/dim]" + ) + return + + sd = _import_sounddevice() + + if check: + console.print(sd.query_devices()) + console.print(f"\nDefault (input, output): {sd.default.device}") + try: + RealtimeClient(model=model) + key_status = "found" + except LLMError: + key_status = "[red]MISSING[/red]" + console.print(f"OPENAI_API_KEY: {key_status}") + console.print(f"model={model} voice={voice}") + return + + push_to_talk = not hands_free + if push_to_talk and not sys.stdin.isatty(): + console.print( + "[yellow]push-to-talk needs a terminal; using --hands-free.[/yellow]" + ) + push_to_talk = False + + prompt = instructions or _DEFAULT_INSTRUCTIONS + # Push-to-talk disables server VAD so we commit each turn ourselves. + turn_detection = NO_TURN_DETECTION if push_to_talk else None + # The client validates the API key itself (LLMError when unset). + try: + client = RealtimeClient( + model=model, + instructions=prompt, + voice=voice, + turn_detection=turn_detection, + ) + except LLMError as e: + console.print(f"[red]{e}[/red] [dim]— export it or add it to .env[/dim]") + return + speaker = Speaker(sd) + console.print(f"[dim]connecting to {model} (voice: {voice})…[/dim]") + # Route Ctrl-C through task cancellation: a raw KeyboardInterrupt tears the + # loop down without unwinding the session/mic/speaker, leaking pending tasks. + loop = asyncio.get_running_loop() + task = asyncio.current_task() + if task is not None: + loop.add_signal_handler(signal.SIGINT, task.cancel) + try: + async with client as conn: + with speaker.stream: + if push_to_talk: + console.print( + "[green]Ready.[/green] [dim]Hold Space while talking, " + "release to send. Ctrl-C to quit.[/dim]\n" + ) + else: + console.print( + "[green]Go ahead.[/green] [dim](hands-free — just speak. " + "Ctrl-C to quit.)[/dim]\n" + ) + await _converse(conn, sd, speaker, push_to_talk=push_to_talk) + except asyncio.CancelledError: + console.print("\n[dim]Bye! 👋[/dim]") + finally: + if task is not None: + loop.remove_signal_handler(signal.SIGINT) + + +def main() -> None: + """Headless entry point (`padwan-talk`): run the voice chat without the TUI.""" + # Deferred import avoids a circular import (run imports talk at module load). + from .run import cli + + cli.run_with_args("talk", *sys.argv[1:]) diff --git a/padwan_cli/widgets.py b/padwan_cli/widgets.py index 095f169..20cf5ea 100644 --- a/padwan_cli/widgets.py +++ b/padwan_cli/widgets.py @@ -1,8 +1,14 @@ from __future__ import annotations import json +from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any, cast +from rich import box +from rich.columns import Columns +from rich.console import Group, RenderableType +from rich.panel import Panel from rich.text import Text from textual.widgets import Static @@ -17,6 +23,75 @@ TOOL_CALL_COLOR = "#ffa500" THOUGHT_COLOR = "#9370db" ERROR_COLOR = "#ff6b6b" +ATTACHMENT_COLOR = USER_COLOR +WARN_COLOR = "#fbbf24" +CHIP_BG = "#1f2230" +CHIP_BORDER = "#3a3f55" + + +@dataclass +class Attachment: + """A file dropped into the chat, queued to send with the next message.""" + + path: Path + name: str + size: int + is_image: bool + supported: bool # whether the active model can use it (images need vision) + + +def human_size(num_bytes: int) -> str: + """Format a byte count as a short human-readable string.""" + size = float(num_bytes) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" + + +def _chip_panel(a: Attachment) -> Panel: + """Build one bordered pill chip for an attachment. + + Unsupported files get an amber border (the "which"); the reason — a + model-level fact — is carried once by the warning bar and hint. + """ + label = Text() + label.append(f"📎 {a.name}", style=ATTACHMENT_COLOR) + label.append(f" {human_size(a.size)}", style="dim") + return Panel( + label, + box=box.ROUNDED, + border_style=WARN_COLOR if not a.supported else CHIP_BORDER, + style=f"on {CHIP_BG}", + padding=(0, 1), + expand=False, + ) + + +def render_chips(attachments: list[Attachment]) -> Columns: + """Render queued attachments as a row of bordered pill chips.""" + return Columns([_chip_panel(a) for a in attachments], padding=(0, 1), expand=False) + + +def render_attachments( + attachments: list[Attachment], *, warning: str | None = None +) -> RenderableType: + """Render the attachment block: an optional amber warning line, then chips.""" + chips = render_chips(attachments) + if warning: + return Group(Text(f"⚠ {warning}", style=WARN_COLOR), chips) + return chips + + +class AttachmentBadge(Static): + """Bordered pill chips shown under a sent user message.""" + + def __init__( + self, attachments: list[Attachment], *, warning: str | None = None, **kwargs + ) -> None: + super().__init__(render_attachments(attachments, warning=warning), **kwargs) + # Colors for batch job states STATE_COLORS = { diff --git a/pyproject.toml b/pyproject.toml index 3d788bd..57ae64f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,18 @@ authors = [ ] requires-python = ">=3.14" dependencies = [ - "padwan-llm>=0.6.0", - "piou[tui]>=0.34.1", + "padwan-llm[realtime]>=0.8.0", + "piou[tui]>=0.36.0", + "tracktolib>=0.74.0", ] +[project.optional-dependencies] +# `talk` command audio I/O; also needs the PortAudio system lib (libportaudio2). +voice = ["sounddevice>=0.5"] + [project.scripts] padwan-cli = "padwan_cli:main_tui" +padwan-talk = "padwan_cli.talk:main" [build-system] requires = ["uv_build>=0.9.28,<0.10.0"] @@ -22,12 +28,9 @@ build-backend = "uv_build" [tool.uv.build-backend] module-root = "" -#[tool.uv.sources] -#padwan-llm = { path = "../padwan-llm", editable = true } - [dependency-groups] dev = [ - "piou[tui-reload]>=0.33.0", + "piou[tui-reload]>=0.36.0", "pyright>=1.1.408", "pytest>=8.0.0", "pytest-asyncio>=1.3.0", diff --git a/tests/test_chat.py b/tests/test_chat.py index a0cd730..680dcab 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import cast import pytest @@ -5,7 +6,18 @@ from padwan_llm import AgentSession, LLMClientBase from padwan_llm.models import UsageToken -from padwan_cli.chat import _format_tokens +from padwan_cli.chat import _build_user_content, _format_tokens +from padwan_cli.widgets import Attachment + + +def _attachment(path: Path, *, is_image: bool, supported: bool) -> Attachment: + return Attachment( + path=str(path), + name=path.name, + size=path.stat().st_size, + is_image=is_image, + supported=supported, + ) class FakeClient: @@ -50,3 +62,47 @@ def test_format_tokens(self, last_usage, total_usage, expected_parts): else: for part in expected_parts: assert part in result + + +class TestBuildUserContent: + def test_no_attachments_returns_plain_text(self): + assert _build_user_content("hi", []) == "hi" + + def test_text_and_supported_image(self, tmp_path): + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG\r\n") + content = _build_user_content( + "look", [_attachment(img, is_image=True, supported=True)] + ) + + assert isinstance(content, list) + assert content[0] == {"type": "text", "text": "look"} + assert content[1]["type"] == "image_url" + assert content[1]["image_url"]["url"].startswith("data:image/png;base64,") + + def test_unsupported_image_dropped_from_payload(self, tmp_path): + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG\r\n") + content = _build_user_content( + "look", [_attachment(img, is_image=True, supported=False)] + ) + + assert content == [{"type": "text", "text": "look"}] + + def test_text_file_inlined_with_header(self, tmp_path): + f = tmp_path / "notes.md" + f.write_text("# Hi") + content = _build_user_content( + "read", [_attachment(f, is_image=False, supported=True)] + ) + + assert content[1] == {"type": "text", "text": "--- notes.md ---\n# Hi"} + + def test_undecodable_file_skipped(self, tmp_path): + f = tmp_path / "blob.bin" + f.write_bytes(b"\xff\xfe\x00\x01") + content = _build_user_content( + "read", [_attachment(f, is_image=False, supported=True)] + ) + + assert content == [{"type": "text", "text": "read"}] diff --git a/tests/test_chat_tui.py b/tests/test_chat_tui.py new file mode 100644 index 0000000..c61c4ec --- /dev/null +++ b/tests/test_chat_tui.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from rich.console import Console +from textual.events import Paste +from textual.widgets import Static + +from piou.tui.app import PromptInput + +from padwan_cli.run import cli, CUSTOM_CSS +from padwan_cli.widgets import Attachment, render_attachments + + +def _app(): + return cli.tui_app(css=CUSTOM_CSS) + + +def _render_text(renderable, width: int = 200) -> str: + console = Console(width=width) + with console.capture() as cap: + console.print(renderable) + return cap.get() + + +class TestChatTuiAttachments: + async def test_app_composes_with_attachment_tray(self): + app = _app() + async with app.run_test(): + tray = app.query_one("#attachment-tray", Static) + assert app.query_one(PromptInput) is not None + assert tray.display is False + + async def test_dropped_file_shows_chip_and_keeps_input_clean(self, tmp_path): + f = tmp_path / "report.md" + f.write_text("hello") + pending: list[Attachment] = [] + + app = _app() + async with app.run_test() as pilot: + # Mirror the chat command's drop handler over the real framework path. + def on_drop(paths: list[str]) -> None: + for p in paths: + pending.append( + Attachment( + path=p, + name=Path(p).name, + size=Path(p).stat().st_size, + is_image=False, + supported=True, + ) + ) + app.set_attachments(render_attachments(pending)) + + app.register_paste_handler(on_drop) + inp = app.query_one(PromptInput) + inp.post_message(Paste(f"file://{f}")) + await pilot.pause() + + tray = app.query_one("#attachment-tray", Static) + assert tray.display is True + assert "report.md" in _render_text(tray.content) + assert inp.value == "" diff --git a/tests/test_talk.py b/tests/test_talk.py new file mode 100644 index 0000000..b1ecd4a --- /dev/null +++ b/tests/test_talk.py @@ -0,0 +1,22 @@ +import pytest + + +async def test_talk_command_refuses_tui(monkeypatch: pytest.MonkeyPatch) -> None: + from types import SimpleNamespace + + import padwan_cli.talk as talk + + monkeypatch.setattr(talk, "get_tui_context", lambda: SimpleNamespace(is_tui=True)) + + def _boom(): + raise AssertionError("audio must not be touched inside the TUI") + + monkeypatch.setattr(talk, "_import_sounddevice", _boom) + # The guard must return early, before importing sounddevice or opening a session. + result = await talk.talk_command( + voice="marin", + model="gpt-realtime", + instructions=None, + check=False, + ) + assert result is None diff --git a/tests/test_widgets.py b/tests/test_widgets.py index af3b2e8..a75fb47 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,16 +1,47 @@ from collections.abc import Callable +import pytest from textual.app import App, ComposeResult from textual.widgets import Static +from rich.console import Console + from padwan_cli.widgets import ( USER_COLOR, + Attachment, + AttachmentBadge, BatchProgressWidget, BatchResultWidget, UserMessage, + human_size, + render_attachments, + render_chips, ) +def _render_text(renderable, width: int = 200) -> str: + """Render a Rich renderable to plain text for assertions.""" + console = Console(width=width) + with console.capture() as cap: + console.print(renderable) + return cap.get() + + +def _attachment( + name: str = "f.txt", + size: int = 1024, + is_image: bool = False, + supported: bool = True, +) -> Attachment: + return Attachment( + path=f"/tmp/{name}", + name=name, + size=size, + is_image=is_image, + supported=supported, + ) + + class WidgetApp(App): """Minimal app that defers widget creation via a factory. @@ -122,3 +153,69 @@ async def test_empty_results(self): widget = app.query_one(BatchResultWidget) rendered = str(widget.render()) assert rendered.strip() == "" + + +# Attachments + + +class TestHumanSize: + @pytest.mark.parametrize( + "n, expected", + [ + pytest.param(0, "0 B", id="zero"), + pytest.param(512, "512 B", id="bytes"), + pytest.param(1024, "1.0 KB", id="one-kb"), + pytest.param(1536, "1.5 KB", id="kb-frac"), + pytest.param(5 * 1024 * 1024, "5.0 MB", id="mb"), + ], + ) + def test_human_size(self, n, expected): + assert human_size(n) == expected + + +class TestRenderChips: + def test_includes_name_and_size(self): + text = _render_text(render_chips([_attachment(name="a.png", size=4400)])) + assert "a.png" in text + assert "4.3 KB" in text + + def test_literal_name_with_brackets(self): + text = _render_text(render_chips([_attachment(name="[bold].txt")])) + assert "[bold].txt" in text + + +class TestRenderAttachments: + def test_warning_line_above_chips(self): + text = _render_text( + render_attachments( + [_attachment(name="x.png", is_image=True, supported=False)], + warning="gpt-x can't read images — 1 will be skipped", + ) + ) + assert "⚠ gpt-x can't read images" in text + assert "x.png" in text + + def test_no_warning_when_none(self): + text = _render_text(render_attachments([_attachment(name="ok.txt")])) + assert "⚠" not in text + assert "ok.txt" in text + + +class TestAttachmentBadge: + async def test_renders_name_and_size(self): + app = WidgetApp(lambda: AttachmentBadge([_attachment(name="a.txt", size=2048)])) + async with app.run_test(): + rendered = _render_text(app.query_one(AttachmentBadge).content) + assert "a.txt" in rendered + assert "2.0 KB" in rendered + + async def test_shows_vision_warning(self): + app = WidgetApp( + lambda: AttachmentBadge( + [_attachment(name="img.png", is_image=True, supported=False)], + warning="gpt-x can't read images — 1 skipped", + ) + ) + async with app.run_test(): + rendered = _render_text(app.query_one(AttachmentBadge).content) + assert "can't read images" in rendered diff --git a/uv.lock b/uv.lock index 850f64e..0db89ec 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -346,16 +379,21 @@ wheels = [ [[package]] name = "niquests" -version = "3.18.7" +version = "3.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "urllib3-future" }, { name = "wassima", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/1d/b40f0c7d62e3543483a8be5c74f9235cf70540fb1ba3480a33a1e8d6cdd6/niquests-3.18.7.tar.gz", hash = "sha256:7587a20e5c0fba0f2474d06b1f95794310585ba3a90e509c9a41f817100011e4", size = 1025148, upload-time = "2026-04-28T05:29:46.076Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/1c/836ad34fb18fac781722019100e2bffd81eb9a0011d7a91c434a7094460d/niquests-3.21.0.tar.gz", hash = "sha256:5b7d10a05f4c7ed08cede0af74f492ae7a8a5a71291833d029e23365fc3ea80a", size = 1070211, upload-time = "2026-07-29T10:26:24.993Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/0e/840094cf85a375533e0a25e7a6adb88eeb8dc557895f631a8938913c09bf/niquests-3.18.7-py3-none-any.whl", hash = "sha256:6126e20bd5b09d5deaa2c20634e4d9c1c0d0112aaa92cb24be56d6a858b22daa", size = 208486, upload-time = "2026-04-28T05:29:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/c08403f905f9aaffc74b483fbb0ad4cc372d1da22cccb4ca771a3e65a185/niquests-3.21.0-py3-none-any.whl", hash = "sha256:dbea441b9e9d5d755e02caa2b57b94cdb97b23d754ead21b80a4ac18ef66805f", size = 230382, upload-time = "2026-07-29T10:26:23.194Z" }, +] + +[package.optional-dependencies] +ws = [ + { name = "urllib3-future", extra = ["ws"] }, ] [[package]] @@ -378,11 +416,17 @@ wheels = [ [[package]] name = "padwan-cli" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ - { name = "padwan-llm" }, + { name = "padwan-llm", extra = ["realtime"] }, { name = "piou", extra = ["tui"] }, + { name = "tracktolib" }, +] + +[package.optional-dependencies] +voice = [ + { name = "sounddevice" }, ] [package.dev-dependencies] @@ -403,14 +447,17 @@ docs = [ [package.metadata] requires-dist = [ - { name = "padwan-llm", specifier = ">=0.6.0" }, - { name = "piou", extras = ["tui"], specifier = ">=0.34.1" }, + { name = "padwan-llm", extras = ["realtime"], specifier = ">=0.8.0" }, + { name = "piou", extras = ["tui"], specifier = ">=0.36.0" }, + { name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.5" }, + { name = "tracktolib", specifier = ">=0.74.0" }, ] +provides-extras = ["voice"] [package.metadata.requires-dev] bump = [{ name = "commitizen", specifier = ">=4.0.0" }] dev = [ - { name = "piou", extras = ["tui-reload"], specifier = ">=0.33.0" }, + { name = "piou", extras = ["tui-reload"], specifier = ">=0.36.0" }, { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, @@ -421,29 +468,34 @@ docs = [{ name = "zensical", specifier = ">=0.0.31" }] [[package]] name = "padwan-llm" -version = "0.6.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "niquests" }, { name = "qh3" }, { name = "urllib3-future" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/cf/95e6ebef25e3c69057291a50ec40fdcb9714fe0b90d993cdb2058cf0a874/padwan_llm-0.6.0.tar.gz", hash = "sha256:80333f362c24a741c36f27faef10b16afc6fed58d5a11e1d10c7e1e71801b722", size = 47491, upload-time = "2026-05-02T19:43:05.57Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/60/c15877fbb37a72ab11b03d387f676c3dcfe4499c54e8f8d061df45a58e80/padwan_llm-0.8.0.tar.gz", hash = "sha256:abcd54ba0d5c6d0e8eb39ec39f652b37cb7ead79ffd99ebffca80538776f236b", size = 66924, upload-time = "2026-08-19T19:50:08.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/41/543fee325ce9a6e1cb2a2f494d882a928a9c1058c889a4b35a84f92df7ed/padwan_llm-0.6.0-py3-none-any.whl", hash = "sha256:85df19df054e1ad8b7e527ef60f04c3e528a632cd3eb5069bb69a01583a12856", size = 59555, upload-time = "2026-05-02T19:43:06.589Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/7452830179218c835cb9cb8759a0cb178adcb8de05dd00e311e65191f333/padwan_llm-0.8.0-py3-none-any.whl", hash = "sha256:6af6e811b23e998d1fb0d1e2f7e4afe7361e21e1cc11c443d7975fb531ac6c08", size = 85167, upload-time = "2026-08-19T19:50:06.947Z" }, +] + +[package.optional-dependencies] +realtime = [ + { name = "niquests", extra = ["ws"] }, ] [[package]] name = "piou" -version = "0.34.1" +version = "0.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/7f/c811767cbdc8e9dcabb2a1f51364dec230ee352e053fbe445902e923aaa2/piou-0.34.1.tar.gz", hash = "sha256:1b763e7f05e236c8cc5d9f453765a46b5e4f3057b2ddb96d4f5de11bc916d2e3", size = 1626534, upload-time = "2026-05-02T19:57:18.328Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/eb/3b09d8b8330b036871c7093ba8abf5f7f3a708b5c1652bc2696252f11c7e/piou-0.36.0.tar.gz", hash = "sha256:22eabc0dc86a314ee095ce2be44ab3dfa3703dbc0b772081df6d47d6e355b345", size = 1650375, upload-time = "2026-08-18T13:09:05.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/a2/0fd4224ae3b04d5594113e2e9f1020fb3ec07c0cec8cc4dd723c60178d84/piou-0.34.1-py3-none-any.whl", hash = "sha256:313cd36a7417422ead14036c1fcb7c58f44e988425c7aca62f9bdf042a311aab", size = 54913, upload-time = "2026-05-02T19:57:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/40dae59cf84e64b086b799b92151818edc4eef9536afc146b84000019acb/piou-0.36.0-py3-none-any.whl", hash = "sha256:40094a9c08716e4a1030500631b4b2a9b2fc82f784d4ac3412bc93f834a2d73a", size = 57019, upload-time = "2026-08-18T13:09:04.054Z" }, ] [package.optional-dependencies] @@ -485,6 +537,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -680,6 +741,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "sounddevice" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, +] + [[package]] name = "termcolor" version = "3.3.0" @@ -742,6 +819,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, ] +[[package]] +name = "tracktolib" +version = "0.74.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/9a/a01f81aa6399971de9c5a9fae26c258f705beb60db26befdb8034e02c03c/tracktolib-0.74.0.tar.gz", hash = "sha256:4fcc6d1225ac40590376328f198ff5b861d851685b9db372167b9bb86197cc38", size = 50360, upload-time = "2026-02-26T20:33:12.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e4/476c23cae02f8d2df43face6b9ac470d7094fe1ac93ef06a68b1a349c7f6/tracktolib-0.74.0-py3-none-any.whl", hash = "sha256:196257eb51d172e2caeaf6f36710ccedc0058a514c9c18a0844d355a87995818", size = 60750, upload-time = "2026-02-26T20:33:11.003Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -774,6 +860,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/0a/38a6bc3788eafc6a55ad53d27938644880927a9486fd58fbab1943d593f4/urllib3_future-2.19.913-py3-none-any.whl", hash = "sha256:a2d92dfd354d2ad737a1dfac13addea67fb9973893f0572ffee467c96e2bd22e", size = 731291, upload-time = "2026-04-27T05:33:01.648Z" }, ] +[package.optional-dependencies] +ws = [ + { name = "wsproto" }, +] + [[package]] name = "wassima" version = "2.0.6" @@ -857,6 +948,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "zensical" version = "0.0.39"