padwan-cli 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: padwan-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: CLI and TUI for padwan-llm
5
5
  Author-email: julien.brayere@polarsen.com
6
- Requires-Dist: padwan-llm>=0.6.0
7
- Requires-Dist: piou[tui]>=0.34.1
6
+ Requires-Dist: padwan-llm[realtime]>=0.8.0
7
+ Requires-Dist: piou[tui]>=0.36.0
8
+ Requires-Dist: tracktolib>=0.74.0
9
+ Requires-Dist: sounddevice>=0.5 ; extra == 'voice'
8
10
  Requires-Python: >=3.14
11
+ Provides-Extra: voice
9
12
  Description-Content-Type: text/markdown
10
13
 
11
14
  # Padwan CLI
@@ -1,7 +1,9 @@
1
1
  import contextlib
2
2
  import json
3
+ import mimetypes
3
4
  import time
4
5
  from contextlib import contextmanager
6
+ from pathlib import Path
5
7
  from typing import Any
6
8
 
7
9
  from piou import Option, CommandGroup
@@ -10,22 +12,30 @@ from textual.css.query import NoMatches
10
12
 
11
13
  from padwan_llm import (
12
14
  AgentSession,
15
+ ContentPart,
13
16
  ConversationSnapshot,
14
17
  LLMClient,
15
18
  McpStreamable,
16
19
  McpTool,
17
20
  McpTransport,
18
21
  ToolCallContext,
22
+ image_part,
23
+ supports_vision,
24
+ text_file_part,
25
+ text_part,
19
26
  )
20
27
  from padwan_llm.gemini import GeminiClient
21
28
  from padwan_llm.gemini.models import ThinkingConfig
22
29
  from .utils import ALL_MODELS, console
23
30
  from .widgets import (
31
+ Attachment,
32
+ AttachmentBadge,
24
33
  ErrorMessage,
25
34
  StreamingMessage,
26
35
  ThoughtMessage,
27
36
  ToolCallMessage,
28
37
  UserMessage,
38
+ render_attachments,
29
39
  )
30
40
 
31
41
  chat_group = CommandGroup(name="chat", help="Chat with an LLM")
@@ -85,6 +95,48 @@ def _format_tokens(session: AgentSession, ctx: TuiContext | None = None) -> str:
85
95
  return " ".join(parts)
86
96
 
87
97
 
98
+ def _build_user_content(
99
+ text: str, attachments: list[Attachment]
100
+ ) -> str | list[ContentPart]:
101
+ """Assemble the user turn from typed text and queued attachments.
102
+
103
+ Returns the plain string when nothing is attached. Otherwise builds content
104
+ parts: the text, each text file inlined, and each supported image as a data
105
+ URL. Unsupported images (text-only model) and unreadable files are dropped
106
+ from the payload — they still appear in the sent badge.
107
+ """
108
+ if not attachments:
109
+ return text
110
+ parts: list[ContentPart] = []
111
+ if text:
112
+ parts.append(text_part(text))
113
+ for a in attachments:
114
+ if a.is_image:
115
+ if a.supported:
116
+ parts.append(image_part(a.path))
117
+ else:
118
+ try:
119
+ parts.append(text_file_part(a.path))
120
+ except OSError, UnicodeDecodeError:
121
+ continue
122
+ return parts
123
+
124
+
125
+ def _parse_extra_params(raw: str | None) -> tuple[dict[str, Any] | None, bool]:
126
+ """Parse --extra-params JSON; print the error and return ok=False on failure."""
127
+ if raw is None:
128
+ return None, True
129
+ try:
130
+ parsed = json.loads(raw)
131
+ except json.JSONDecodeError as e:
132
+ console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
133
+ return None, False
134
+ if not isinstance(parsed, dict):
135
+ console.print("[red]--extra-params must be a JSON object[/red]")
136
+ return None, False
137
+ return parsed, True
138
+
139
+
88
140
  @chat_group.command("send", help="Send a message to the LLM")
89
141
  async def chat_send_fn(
90
142
  message: str = Option(..., help="Message to send"),
@@ -158,16 +210,9 @@ async def chat_send_fn(
158
210
  else:
159
211
  console.print(f"[green]MCP connected[/green] [dim]{transport.label}[/dim]")
160
212
 
161
- parsed_extra: dict[str, Any] | None = None
162
- if extra_params is not None:
163
- try:
164
- parsed_extra = json.loads(extra_params)
165
- except json.JSONDecodeError as e:
166
- console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
167
- return
168
- if not isinstance(parsed_extra, dict):
169
- console.print("[red]--extra-params must be a JSON object[/red]")
170
- return
213
+ parsed_extra, ok = _parse_extra_params(extra_params)
214
+ if not ok:
215
+ return
171
216
 
172
217
  try:
173
218
  client = LLMClient(model=model, on_thought=_on_thought, base_url=base_url)
@@ -196,16 +241,84 @@ async def chat_send_fn(
196
241
  ctx.set_rule_above(add_class="chat-mode")
197
242
  ctx.set_rule_below(add_class="chat-mode")
198
243
 
244
+ # Files dropped onto the terminal arrive as a paste of their path(s);
245
+ # queue them here and send with the next typed message.
246
+ pending: list[Attachment] = []
247
+
248
+ def _set_attach_warning(on: bool) -> None:
249
+ """Turn the input bars amber (or back to chat-mode cyan)."""
250
+ add, remove = (
251
+ ("attach-warning", "chat-mode")
252
+ if on
253
+ else ("chat-mode", "attach-warning")
254
+ )
255
+ ctx.set_rule_above(add_class=add, remove_class=remove)
256
+ ctx.set_rule_below(add_class=add, remove_class=remove)
257
+
258
+ def _on_drop(paths: list[Path]) -> None:
259
+ for p in paths:
260
+ try:
261
+ size = p.stat().st_size
262
+ except OSError:
263
+ continue
264
+ is_image = (mimetypes.guess_type(p)[0] or "").startswith("image/")
265
+ pending.append(
266
+ Attachment(
267
+ path=p,
268
+ name=p.name,
269
+ size=size,
270
+ is_image=is_image,
271
+ supported=(not is_image) or supports_vision(model),
272
+ )
273
+ )
274
+ blind = [a for a in pending if a.is_image and not a.supported]
275
+ warning = (
276
+ f"{model} can't read images — {len(blind)} will be skipped"
277
+ if blind
278
+ else None
279
+ )
280
+ ctx.set_attachments(render_attachments(pending, warning=warning))
281
+ _set_attach_warning(bool(blind))
282
+ ctx.set_hint(
283
+ f"{len(pending)} attached — type a message · Ctrl+U clears"
284
+ )
285
+
286
+ def _on_clear() -> None:
287
+ pending.clear()
288
+ ctx.set_attachments(None)
289
+ _set_attach_warning(False)
290
+ ctx.set_hint("Chat mode - press Ctrl+C to exit")
291
+
292
+ ctx.register_paste_handler(_on_drop)
293
+ ctx.register_attachment_clear(_on_clear)
294
+
199
295
  try:
200
296
  while user_input:
201
297
  if ctx.is_tui:
298
+ sent = list(pending)
202
299
  ctx.mount_widget(UserMessage(user_input))
300
+ if sent:
301
+ skipped = [
302
+ a for a in sent if a.is_image and not a.supported
303
+ ]
304
+ badge_warning = (
305
+ f"{model} can't read images — {len(skipped)} skipped"
306
+ if skipped
307
+ else None
308
+ )
309
+ ctx.mount_widget(
310
+ AttachmentBadge(sent, warning=badge_warning)
311
+ )
312
+ content = _build_user_content(user_input, sent)
313
+ pending.clear()
314
+ ctx.set_attachments(None)
315
+ _set_attach_warning(False)
203
316
  ctx.set_hint("Responding...")
204
317
  ctx.set_silent_queue(True)
205
318
  needs_new_text_widget = True
206
319
  widget: StreamingMessage | None = None
207
320
  try:
208
- async for chunk in session.stream(user_input):
321
+ async for chunk in session.stream(content):
209
322
  if needs_new_text_widget or widget is None:
210
323
  widget = StreamingMessage()
211
324
  ctx.mount_widget(widget)
@@ -237,6 +350,8 @@ async def chat_send_fn(
237
350
  break
238
351
  finally:
239
352
  session.save()
353
+ ctx.register_paste_handler(None)
354
+ ctx.register_attachment_clear(None)
240
355
  # Cleanup is best-effort — on a double-Ctrl+C the TUI screen
241
356
  # is already tearing down, so query_one("#status-above") and
242
357
  # friends raise NoMatches. Don't surface that as a misleading
@@ -245,9 +360,12 @@ async def chat_send_fn(
245
360
  ctx.set_silent_queue(False)
246
361
  ctx.clear_queue() # Discard any messages typed during streaming
247
362
  ctx.set_status_above(None)
363
+ ctx.set_attachments(None)
248
364
  ctx.set_hint(None)
249
365
  ctx.set_rule_above(remove_class="chat-mode")
250
366
  ctx.set_rule_below(remove_class="chat-mode")
367
+ ctx.set_rule_above(remove_class="attach-warning")
368
+ ctx.set_rule_below(remove_class="attach-warning")
251
369
  if original_style:
252
370
  ctx.set_prompt_style(original_style)
253
371
  except Exception as e:
@@ -23,11 +23,15 @@ from padwan_llm.errors import Provider
23
23
  from .utils import console
24
24
  from .batch import batch_group
25
25
  from .chat import chat_group
26
+ from .talk import talk_command
26
27
 
27
28
  CUSTOM_CSS = """
28
29
  Rule.chat-mode {
29
30
  color: #87ceeb;
30
31
  }
32
+ Rule.attach-warning {
33
+ color: #fbbf24;
34
+ }
31
35
  """
32
36
 
33
37
  cli = Cli(description="Padwan CLI for the unified LLM client library")
@@ -35,6 +39,8 @@ cli = Cli(description="Padwan CLI for the unified LLM client library")
35
39
  cli.add_command_group(batch_group)
36
40
  cli.add_command_group(chat_group)
37
41
 
42
+ cli.command("talk", help="Speech-to-speech voice chat")(talk_command)
43
+
38
44
 
39
45
  @cli.tui_on_ready
40
46
  def _tui_startup_hint() -> None:
@@ -0,0 +1,401 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import functools
6
+ import os
7
+ import queue
8
+ import signal
9
+ import sys
10
+ import termios
11
+ import threading
12
+ import tty
13
+
14
+ from piou import Option
15
+ from piou.tui import get_tui_context
16
+ from tracktolib.utils import BytesBuffer
17
+
18
+ from padwan_llm import RealtimeClient, RealtimeConnection, RealtimeServerEvent
19
+ from padwan_llm.errors import LLMError
20
+ from padwan_llm.openai.realtime import NO_TURN_DETECTION, REALTIME_SAMPLE_RATE
21
+
22
+ from .utils import console
23
+
24
+ SR = REALTIME_SAMPLE_RATE # 24 kHz mono PCM16, as gpt-realtime expects
25
+ CHANNELS = 1
26
+ DTYPE = "int16"
27
+ BLOCK = 1200 # 50 ms frames
28
+
29
+ # Terminals report no key-release: a held Space arrives as auto-repeated spaces.
30
+ # A gap longer than the keyboard's initial repeat delay (~0.66 s default) means
31
+ # the key was released; shorter turns are discarded as accidental taps.
32
+ _HOLD_RELEASE_GAP = 0.8
33
+ _MIN_TURN_SECS = 0.45
34
+
35
+ _DEFAULT_INSTRUCTIONS = """You are a friendly voice assistant.
36
+ Keep spoken replies brief and conversational — one or two sentences — so the
37
+ exchange stays a real back-and-forth."""
38
+
39
+
40
+ class Speaker:
41
+ """Buffered PCM16 playback with barge-in support.
42
+
43
+ BytesBuffer keeps the audio callback O(read size): appends are reference-
44
+ only and reads copy at chunk boundaries, instead of memmoving the whole
45
+ queued backlog on every 50 ms tick.
46
+ """
47
+
48
+ def __init__(self, sd) -> None:
49
+ self._buf = BytesBuffer()
50
+ self._lock = threading.Lock()
51
+ self.stream = sd.RawOutputStream(
52
+ samplerate=SR,
53
+ channels=CHANNELS,
54
+ dtype=DTYPE,
55
+ blocksize=BLOCK,
56
+ callback=self._callback,
57
+ )
58
+
59
+ def _callback(self, outdata, frames, _time, _status) -> None: # PortAudio thread
60
+ need = frames * CHANNELS * 2
61
+ with self._lock:
62
+ take = self._buf.get(need) if len(self._buf) else b""
63
+ if len(take) < need:
64
+ take += b"\x00" * (need - len(take)) # underflow -> silence
65
+ outdata[:] = take
66
+
67
+ def play(self, pcm16: bytes) -> None:
68
+ with self._lock:
69
+ self._buf.put(pcm16)
70
+
71
+ def clear(self) -> None:
72
+ with self._lock:
73
+ self._buf = BytesBuffer()
74
+
75
+
76
+ def _import_sounddevice():
77
+ """Import sounddevice lazily, with a friendly hint if the extra is missing."""
78
+ try:
79
+ import sounddevice as sd
80
+ except OSError as e: # PortAudio shared library not found
81
+ console.print(f"[red]PortAudio unavailable: {e}[/red]")
82
+ console.print(
83
+ "[dim]Install the system library, e.g. `apt install libportaudio2`.[/dim]"
84
+ )
85
+ raise SystemExit(1)
86
+ except ImportError:
87
+ console.print("[red]sounddevice not installed.[/red]")
88
+ console.print("[dim]Install the voice extra: `uv sync --extra voice`.[/dim]")
89
+ raise SystemExit(1)
90
+ return sd
91
+
92
+
93
+ @contextlib.contextmanager
94
+ def _cbreak(fd: int):
95
+ """Put the terminal in cbreak mode so single keypresses arrive immediately."""
96
+ old = termios.tcgetattr(fd)
97
+ try:
98
+ tty.setcbreak(fd) # leaves ISIG on, so Ctrl-C still quits
99
+ yield
100
+ finally:
101
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
102
+
103
+
104
+ async def _handle_events(
105
+ conn: RealtimeConnection, speaker: Speaker, *, push_to_talk: bool
106
+ ) -> None:
107
+ """Consume server events: play audio, stream transcripts, prompt for the turn.
108
+
109
+ Your own transcript arrives asynchronously and usually after the assistant has
110
+ started answering, so assistant text is held until the "you:" line has printed
111
+ (flushed unconditionally on response.done so nothing is ever lost).
112
+ """
113
+ speaking = False # is the assistant mid-utterance on this line?
114
+ held: list[str] = [] # assistant transcript held until the "you:" line prints
115
+ user_shown = True # has the current turn's input transcript been printed?
116
+ prepaid = False # input transcript arrived before its response was created
117
+
118
+ def flush_held() -> None:
119
+ nonlocal speaking
120
+ if held:
121
+ console.print("[cyan]assistant:[/cyan] ", end="")
122
+ sys.stdout.write("".join(held))
123
+ sys.stdout.flush()
124
+ held.clear()
125
+ speaking = True
126
+
127
+ async for event in conn:
128
+ kind = event.get("type")
129
+ if kind == RealtimeServerEvent.AUDIO_DELTA:
130
+ if pcm := conn.audio_delta_bytes(event):
131
+ speaker.play(pcm)
132
+ elif kind == RealtimeServerEvent.AUDIO_TRANSCRIPT_DELTA:
133
+ delta = event.get("delta", "")
134
+ if not user_shown:
135
+ held.append(delta)
136
+ continue
137
+ if not speaking:
138
+ console.print("[cyan]assistant:[/cyan] ", end="")
139
+ speaking = True
140
+ sys.stdout.write(delta)
141
+ sys.stdout.flush()
142
+ elif kind == RealtimeServerEvent.RESPONSE_CREATED:
143
+ if prepaid:
144
+ prepaid = False # transcript already printed for this turn
145
+ else:
146
+ user_shown = False
147
+ elif kind == RealtimeServerEvent.SPEECH_STARTED: # hands-free VAD only
148
+ speaker.clear() # barge-in: drop queued assistant audio
149
+ flush_held()
150
+ if speaking:
151
+ sys.stdout.write("\n")
152
+ speaking = False
153
+ elif kind == RealtimeServerEvent.INPUT_TRANSCRIPT_COMPLETED:
154
+ text = (event.get("transcript") or "").strip()
155
+ if speaking:
156
+ sys.stdout.write("\n")
157
+ speaking = False
158
+ if text:
159
+ console.print(f"[green]you:[/green] {text}")
160
+ if user_shown:
161
+ prepaid = True # transcript beat its response.created
162
+ else:
163
+ user_shown = True
164
+ flush_held()
165
+ elif kind == RealtimeServerEvent.RESPONSE_DONE:
166
+ flush_held() # transcript never arrived; don't lose the assistant text
167
+ user_shown = True
168
+ if speaking:
169
+ sys.stdout.write("\n")
170
+ speaking = False
171
+ if push_to_talk:
172
+ console.print("[dim]· hold Space to talk[/dim]")
173
+ elif kind == RealtimeServerEvent.ERROR:
174
+ console.print(f"\n[dim][error] {event.get('error')}[/dim]")
175
+
176
+
177
+ async def _converse(
178
+ conn: RealtimeConnection, sd, speaker: Speaker, *, push_to_talk: bool
179
+ ) -> None:
180
+ """Pump mic → model and run the event loop, in push-to-talk or hands-free mode."""
181
+ loop = asyncio.get_running_loop()
182
+ mic_q: queue.Queue[bytes] = queue.Queue()
183
+ recording = threading.Event()
184
+ if not push_to_talk:
185
+ recording.set() # hands-free: stream continuously, server VAD picks turns
186
+
187
+ def mic_callback(indata, _frames, _time, _status) -> None: # PortAudio thread
188
+ if recording.is_set():
189
+ mic_q.put(bytes(indata))
190
+
191
+ mic = sd.RawInputStream(
192
+ samplerate=SR,
193
+ channels=CHANNELS,
194
+ dtype=DTYPE,
195
+ blocksize=BLOCK,
196
+ callback=mic_callback,
197
+ )
198
+
199
+ draining = False
200
+ sent_bytes = 0
201
+
202
+ async def pump_mic() -> None:
203
+ nonlocal draining, sent_bytes
204
+ # Timed get so the executor thread exits shortly after cancellation
205
+ # instead of blocking threading shutdown at exit.
206
+ blocking_get = functools.partial(mic_q.get, True, 0.25)
207
+ while True:
208
+ try:
209
+ chunk = await loop.run_in_executor(None, blocking_get)
210
+ except queue.Empty:
211
+ continue
212
+ # The transport lets roughly one send through per read-poll tick,
213
+ # so batch everything captured while we waited for the socket.
214
+ parts = [chunk]
215
+ while True:
216
+ try:
217
+ parts.append(mic_q.get_nowait())
218
+ except queue.Empty:
219
+ break
220
+ draining = True
221
+ try:
222
+ joined = b"".join(parts)
223
+ await conn.append_audio(joined)
224
+ sent_bytes += len(joined)
225
+ finally:
226
+ draining = False
227
+
228
+ turn_task: asyncio.Task | None = None
229
+ last_space = 0.0
230
+ turn_started_at = 0.0
231
+
232
+ async def start_turn() -> None:
233
+ nonlocal turn_started_at, sent_bytes
234
+ with mic_q.mutex:
235
+ mic_q.queue.clear() # drop audio captured before the press
236
+ speaker.clear() # stop the assistant if it was still talking
237
+ await conn.send_event({"type": "input_audio_buffer.clear"})
238
+ recording.set()
239
+ turn_started_at = loop.time()
240
+ sent_bytes = 0
241
+ console.print("[red]● recording…[/red] [dim]release Space to send[/dim]")
242
+
243
+ async def end_turn() -> None:
244
+ recording.clear()
245
+ # Wait until everything captured actually reached the socket, so the
246
+ # commit doesn't clip the tail of the turn.
247
+ deadline = loop.time() + 3
248
+ while (not mic_q.empty() or draining) and loop.time() < deadline:
249
+ await asyncio.sleep(0.05)
250
+ if loop.time() - turn_started_at < _MIN_TURN_SECS:
251
+ await conn.send_event({"type": "input_audio_buffer.clear"})
252
+ console.print("[dim](too short — hold Space while speaking)[/dim]")
253
+ return
254
+ await conn.commit_audio()
255
+ await conn.create_response()
256
+ console.print(f"[dim](sent {sent_bytes / (SR * 2):.1f}s of audio)[/dim]")
257
+
258
+ def _spawn_turn(coro_fn) -> None:
259
+ nonlocal turn_task
260
+ if turn_task is not None and not turn_task.done():
261
+ return # previous transition still in flight
262
+ turn_task = asyncio.create_task(coro_fn())
263
+
264
+ def on_key() -> None: # called by the event loop when stdin is readable
265
+ nonlocal last_space
266
+ try:
267
+ data = os.read(sys.stdin.fileno(), 1)
268
+ except OSError:
269
+ return
270
+ if data == b" ":
271
+ # Held Space shows up as auto-repeated spaces; each one refreshes
272
+ # the hold. The release watcher ends the turn once they stop.
273
+ last_space = loop.time()
274
+ if not recording.is_set():
275
+ _spawn_turn(start_turn)
276
+ elif data in (b"\r", b"\n") and recording.is_set():
277
+ _spawn_turn(end_turn) # manual send fallback
278
+
279
+ async def watch_release() -> None:
280
+ while True:
281
+ await asyncio.sleep(0.1)
282
+ if recording.is_set() and loop.time() - last_space > _HOLD_RELEASE_GAP:
283
+ _spawn_turn(end_turn)
284
+
285
+ use_keys = push_to_talk and sys.stdin.isatty()
286
+ with mic:
287
+ sender = asyncio.create_task(pump_mic())
288
+ watcher = asyncio.create_task(watch_release()) if use_keys else None
289
+ fd = sys.stdin.fileno()
290
+ try:
291
+ if use_keys:
292
+ with _cbreak(fd):
293
+ loop.add_reader(fd, on_key)
294
+ try:
295
+ await _handle_events(conn, speaker, push_to_talk=True)
296
+ finally:
297
+ loop.remove_reader(fd)
298
+ else:
299
+ await _handle_events(conn, speaker, push_to_talk=False)
300
+ finally:
301
+ sender.cancel()
302
+ if watcher is not None:
303
+ watcher.cancel()
304
+ if turn_task is not None:
305
+ turn_task.cancel()
306
+
307
+
308
+ async def talk_command(
309
+ voice: str = Option("marin", "--voice", help="Realtime voice (marin, cedar, …)"),
310
+ model: str = Option("gpt-realtime", "-m", "--model", help="Realtime model"),
311
+ instructions: str | None = Option(
312
+ None, "--instructions", help="System prompt for the voice assistant"
313
+ ),
314
+ hands_free: bool = Option(
315
+ False, "--hands-free", help="Auto voice detection instead of push-to-talk"
316
+ ),
317
+ check: bool = Option(
318
+ False, "--check", help="List audio devices and key status, then exit"
319
+ ),
320
+ ) -> None:
321
+ """Talk with a real-time voice assistant (speech-to-speech)."""
322
+ # A live mic/speaker session needs the whole terminal; the Textual TUI owns it,
323
+ # so this command only works headless. Fail loudly instead of hanging silently.
324
+ if get_tui_context().is_tui:
325
+ console.print(
326
+ "[yellow]`talk` is a full-terminal voice session and can't run inside "
327
+ "the TUI.[/yellow]\n[dim]Run it headless:[/dim] [bold]uv run padwan-talk"
328
+ "[/bold] [dim](or `python -m padwan_cli talk`).[/dim]"
329
+ )
330
+ return
331
+
332
+ sd = _import_sounddevice()
333
+
334
+ if check:
335
+ console.print(sd.query_devices())
336
+ console.print(f"\nDefault (input, output): {sd.default.device}")
337
+ try:
338
+ RealtimeClient(model=model)
339
+ key_status = "found"
340
+ except LLMError:
341
+ key_status = "[red]MISSING[/red]"
342
+ console.print(f"OPENAI_API_KEY: {key_status}")
343
+ console.print(f"model={model} voice={voice}")
344
+ return
345
+
346
+ push_to_talk = not hands_free
347
+ if push_to_talk and not sys.stdin.isatty():
348
+ console.print(
349
+ "[yellow]push-to-talk needs a terminal; using --hands-free.[/yellow]"
350
+ )
351
+ push_to_talk = False
352
+
353
+ prompt = instructions or _DEFAULT_INSTRUCTIONS
354
+ # Push-to-talk disables server VAD so we commit each turn ourselves.
355
+ turn_detection = NO_TURN_DETECTION if push_to_talk else None
356
+ # The client validates the API key itself (LLMError when unset).
357
+ try:
358
+ client = RealtimeClient(
359
+ model=model,
360
+ instructions=prompt,
361
+ voice=voice,
362
+ turn_detection=turn_detection,
363
+ )
364
+ except LLMError as e:
365
+ console.print(f"[red]{e}[/red] [dim]— export it or add it to .env[/dim]")
366
+ return
367
+ speaker = Speaker(sd)
368
+ console.print(f"[dim]connecting to {model} (voice: {voice})…[/dim]")
369
+ # Route Ctrl-C through task cancellation: a raw KeyboardInterrupt tears the
370
+ # loop down without unwinding the session/mic/speaker, leaking pending tasks.
371
+ loop = asyncio.get_running_loop()
372
+ task = asyncio.current_task()
373
+ if task is not None:
374
+ loop.add_signal_handler(signal.SIGINT, task.cancel)
375
+ try:
376
+ async with client as conn:
377
+ with speaker.stream:
378
+ if push_to_talk:
379
+ console.print(
380
+ "[green]Ready.[/green] [dim]Hold Space while talking, "
381
+ "release to send. Ctrl-C to quit.[/dim]\n"
382
+ )
383
+ else:
384
+ console.print(
385
+ "[green]Go ahead.[/green] [dim](hands-free — just speak. "
386
+ "Ctrl-C to quit.)[/dim]\n"
387
+ )
388
+ await _converse(conn, sd, speaker, push_to_talk=push_to_talk)
389
+ except asyncio.CancelledError:
390
+ console.print("\n[dim]Bye! 👋[/dim]")
391
+ finally:
392
+ if task is not None:
393
+ loop.remove_signal_handler(signal.SIGINT)
394
+
395
+
396
+ def main() -> None:
397
+ """Headless entry point (`padwan-talk`): run the voice chat without the TUI."""
398
+ # Deferred import avoids a circular import (run imports talk at module load).
399
+ from .run import cli
400
+
401
+ cli.run_with_args("talk", *sys.argv[1:])
@@ -1,8 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
4
6
  from typing import TYPE_CHECKING, Any, cast
5
7
 
8
+ from rich import box
9
+ from rich.columns import Columns
10
+ from rich.console import Group, RenderableType
11
+ from rich.panel import Panel
6
12
  from rich.text import Text
7
13
  from textual.widgets import Static
8
14
 
@@ -17,6 +23,75 @@ ASSISTANT_COLOR = "#98fb98"
17
23
  TOOL_CALL_COLOR = "#ffa500"
18
24
  THOUGHT_COLOR = "#9370db"
19
25
  ERROR_COLOR = "#ff6b6b"
26
+ ATTACHMENT_COLOR = USER_COLOR
27
+ WARN_COLOR = "#fbbf24"
28
+ CHIP_BG = "#1f2230"
29
+ CHIP_BORDER = "#3a3f55"
30
+
31
+
32
+ @dataclass
33
+ class Attachment:
34
+ """A file dropped into the chat, queued to send with the next message."""
35
+
36
+ path: Path
37
+ name: str
38
+ size: int
39
+ is_image: bool
40
+ supported: bool # whether the active model can use it (images need vision)
41
+
42
+
43
+ def human_size(num_bytes: int) -> str:
44
+ """Format a byte count as a short human-readable string."""
45
+ size = float(num_bytes)
46
+ for unit in ("B", "KB", "MB", "GB"):
47
+ if size < 1024 or unit == "GB":
48
+ return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
49
+ size /= 1024
50
+ return f"{size:.1f} GB"
51
+
52
+
53
+ def _chip_panel(a: Attachment) -> Panel:
54
+ """Build one bordered pill chip for an attachment.
55
+
56
+ Unsupported files get an amber border (the "which"); the reason — a
57
+ model-level fact — is carried once by the warning bar and hint.
58
+ """
59
+ label = Text()
60
+ label.append(f"📎 {a.name}", style=ATTACHMENT_COLOR)
61
+ label.append(f" {human_size(a.size)}", style="dim")
62
+ return Panel(
63
+ label,
64
+ box=box.ROUNDED,
65
+ border_style=WARN_COLOR if not a.supported else CHIP_BORDER,
66
+ style=f"on {CHIP_BG}",
67
+ padding=(0, 1),
68
+ expand=False,
69
+ )
70
+
71
+
72
+ def render_chips(attachments: list[Attachment]) -> Columns:
73
+ """Render queued attachments as a row of bordered pill chips."""
74
+ return Columns([_chip_panel(a) for a in attachments], padding=(0, 1), expand=False)
75
+
76
+
77
+ def render_attachments(
78
+ attachments: list[Attachment], *, warning: str | None = None
79
+ ) -> RenderableType:
80
+ """Render the attachment block: an optional amber warning line, then chips."""
81
+ chips = render_chips(attachments)
82
+ if warning:
83
+ return Group(Text(f"⚠ {warning}", style=WARN_COLOR), chips)
84
+ return chips
85
+
86
+
87
+ class AttachmentBadge(Static):
88
+ """Bordered pill chips shown under a sent user message."""
89
+
90
+ def __init__(
91
+ self, attachments: list[Attachment], *, warning: str | None = None, **kwargs
92
+ ) -> None:
93
+ super().__init__(render_attachments(attachments, warning=warning), **kwargs)
94
+
20
95
 
21
96
  # Colors for batch job states
22
97
  STATE_COLORS = {
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "padwan-cli"
3
+ version = "0.5.0"
4
+ description = "CLI and TUI for padwan-llm"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "padwan-llm[realtime]>=0.8.0",
9
+ "piou[tui]>=0.36.0",
10
+ "tracktolib>=0.74.0",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ email = "julien.brayere@polarsen.com"
15
+
16
+ [project.optional-dependencies]
17
+ voice = ["sounddevice>=0.5"]
18
+
19
+ [project.scripts]
20
+ padwan-cli = "padwan_cli:main_tui"
21
+ padwan-talk = "padwan_cli.talk:main"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.9.28,<0.10.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.uv.build-backend]
28
+ module-root = ""
29
+
30
+ [tool.commitizen]
31
+ name = "cz_conventional_commits"
32
+ version_provider = "pep621"
33
+ tag_format = "$version"
34
+ update_changelog_on_bump = true
35
+ bump_message = "chore(release): release $current_version → $new_version [skip ci]"
36
+
37
+ [tool.pytest.ini_options]
38
+ asyncio_mode = "auto"
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "piou[tui-reload]>=0.36.0",
43
+ "pyright>=1.1.408",
44
+ "pytest>=8.0.0",
45
+ "pytest-asyncio>=1.3.0",
46
+ "pytest-cov>=7.0.0",
47
+ "ruff>=0.11.0",
48
+ ]
49
+ docs = ["zensical>=0.0.31"]
50
+ bump = ["commitizen>=4.0.0"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "padwan-cli"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "CLI and TUI for padwan-llm"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,12 +8,18 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.14"
10
10
  dependencies = [
11
- "padwan-llm>=0.6.0",
12
- "piou[tui]>=0.34.1",
11
+ "padwan-llm[realtime]>=0.8.0",
12
+ "piou[tui]>=0.36.0",
13
+ "tracktolib>=0.74.0",
13
14
  ]
14
15
 
16
+ [project.optional-dependencies]
17
+ # `talk` command audio I/O; also needs the PortAudio system lib (libportaudio2).
18
+ voice = ["sounddevice>=0.5"]
19
+
15
20
  [project.scripts]
16
21
  padwan-cli = "padwan_cli:main_tui"
22
+ padwan-talk = "padwan_cli.talk:main"
17
23
 
18
24
  [build-system]
19
25
  requires = ["uv_build>=0.9.28,<0.10.0"]
@@ -22,12 +28,9 @@ build-backend = "uv_build"
22
28
  [tool.uv.build-backend]
23
29
  module-root = ""
24
30
 
25
- #[tool.uv.sources]
26
- #padwan-llm = { path = "../padwan-llm", editable = true }
27
-
28
31
  [dependency-groups]
29
32
  dev = [
30
- "piou[tui-reload]>=0.33.0",
33
+ "piou[tui-reload]>=0.36.0",
31
34
  "pyright>=1.1.408",
32
35
  "pytest>=8.0.0",
33
36
  "pytest-asyncio>=1.3.0",
File without changes