Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2b5e819
feat(chat): file attachments + talk voice tutor
Andarius Jul 16, 2026
f8f0615
fix(talk): clean shutdown — cancel turn tasks, route Ctrl-C through t…
Andarius Aug 17, 2026
613289e
feat(talk): hold-to-talk — hold Space while speaking, release to send
Andarius Aug 17, 2026
a91fb84
fix(talk): batch mic audio per send and flush before commit
Andarius Aug 17, 2026
3acbfe6
feat(talk): print your transcript before the tutor's reply
Andarius Aug 17, 2026
bf4a5c4
refactor(talk): generic voice assistant instead of the language-tutor…
Andarius Aug 17, 2026
d4e8777
feat(chat): chat:start interactive terminal session
Andarius Aug 17, 2026
5ca11a4
feat: just chat recipe and env.template
Andarius Aug 17, 2026
f19b813
feat(chat): optional first message for chat:start; quote just chat args
Andarius Aug 17, 2026
5645bcb
refactor(chat): drop chat:start; just chat wraps chat:send
Andarius Aug 17, 2026
31e1a39
refactor: just chat opens the chat TUI instead of headless send
Andarius Aug 18, 2026
0d5cee6
refactor(talk): drop the hardcoded .env fallback; require OPENAI_API_…
Andarius Aug 18, 2026
516d7aa
refactor(talk): let the padwan-llm client validate the API key
Andarius Aug 18, 2026
df1fa83
refactor(chat): adopt piou's list[Path] paste contract
Andarius Aug 18, 2026
16aa9b8
style: format run.py
Andarius Aug 18, 2026
2222182
chore: use released piou 0.36.0 instead of the local editable source
Andarius Aug 18, 2026
0c78579
perf(talk): tracktolib BytesBuffer for speaker playback
Andarius Aug 18, 2026
3e2576b
chore: use released padwan-llm 0.8.0 instead of the local editable so…
Andarius Aug 19, 2026
881f297
style: format test_chat and test_widgets
Andarius Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/commands/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions env.template
Original file line number Diff line number Diff line change
@@ -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=
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
140 changes: 129 additions & 11 deletions padwan_cli/chat.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions padwan_cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,24 @@
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")

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:
Expand Down
Loading
Loading