cw-relay 0.1.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.
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ build/
9
+ dist/
10
+ .venv/
11
+ venv/
12
+ env/
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .tox/
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+
23
+ # OS
24
+ .DS_Store
25
+ Thumbs.db
26
+
27
+ # Local config — never commit a real API key
28
+ config.json
29
+ .env
cw_relay-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Casper White
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: cw-relay
3
+ Version: 0.1.0
4
+ Summary: A Claude Code-style terminal chatbot powered by Gemini, with file and shell access.
5
+ Project-URL: Homepage, https://github.com/casperwhite-commits/relay
6
+ Project-URL: Issues, https://github.com/casperwhite-commits/relay/issues
7
+ Author: Casper White
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,assistant,chat,cli,gemini,terminal
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: google-genai>=0.3.0
24
+ Requires-Dist: rich>=13.0.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # relay
28
+
29
+ A Claude Code-style terminal chatbot, powered by Google Gemini, with direct file and shell access.
30
+
31
+ ```
32
+ ┌─────────────────────────────────────────┐
33
+ │ relay · terminal chat (Gemini) │
34
+ └─────────────────────────────────────────┘
35
+ ```
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install relay-cli
41
+ ```
42
+
43
+ Python 3.10+ on macOS, Linux, or Windows.
44
+
45
+ ## Setup
46
+
47
+ You need a Google Gemini API key — grab a free one at [aistudio.google.com](https://aistudio.google.com/app/apikey).
48
+
49
+ ```bash
50
+ relay --set-key
51
+ ```
52
+
53
+ (Paste at the hidden prompt — Cmd+V / Ctrl+V works even though nothing visibly happens.)
54
+
55
+ Or set the `GEMINI_API_KEY` environment variable, which takes precedence over the stored config.
56
+
57
+ ## Use
58
+
59
+ ```bash
60
+ relay
61
+ ```
62
+
63
+ Type to chat. Relay can read files, search your filesystem, run shell commands, and write files on its own when the conversation calls for it.
64
+
65
+ ### Commands
66
+
67
+ | command | action |
68
+ |---|---|
69
+ | `/help` | show command list |
70
+ | `/exit`, `/quit` | leave |
71
+ | `/clear` | reset the conversation |
72
+ | `/cls` | clear the screen, keep the conversation |
73
+ | `/model <name>` | switch model (e.g. `gemini-2.5-pro`) |
74
+ | `/system <text>` | replace the system instruction |
75
+ | `/cwd <path>` | change working directory |
76
+ | `/save <path>` | save the transcript to a file |
77
+
78
+ ### Tools the assistant uses automatically
79
+
80
+ `list_directory` · `read_file` · `glob_files` · `grep_files` · `write_file` · `run_shell`
81
+
82
+ ## Safety
83
+
84
+ Relay gives the model direct shell access on your machine. It will run commands and modify files without asking. Don't point it at machines you don't own, and consider reviewing the conversation as it runs.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,62 @@
1
+ # relay
2
+
3
+ A Claude Code-style terminal chatbot, powered by Google Gemini, with direct file and shell access.
4
+
5
+ ```
6
+ ┌─────────────────────────────────────────┐
7
+ │ relay · terminal chat (Gemini) │
8
+ └─────────────────────────────────────────┘
9
+ ```
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install relay-cli
15
+ ```
16
+
17
+ Python 3.10+ on macOS, Linux, or Windows.
18
+
19
+ ## Setup
20
+
21
+ You need a Google Gemini API key — grab a free one at [aistudio.google.com](https://aistudio.google.com/app/apikey).
22
+
23
+ ```bash
24
+ relay --set-key
25
+ ```
26
+
27
+ (Paste at the hidden prompt — Cmd+V / Ctrl+V works even though nothing visibly happens.)
28
+
29
+ Or set the `GEMINI_API_KEY` environment variable, which takes precedence over the stored config.
30
+
31
+ ## Use
32
+
33
+ ```bash
34
+ relay
35
+ ```
36
+
37
+ Type to chat. Relay can read files, search your filesystem, run shell commands, and write files on its own when the conversation calls for it.
38
+
39
+ ### Commands
40
+
41
+ | command | action |
42
+ |---|---|
43
+ | `/help` | show command list |
44
+ | `/exit`, `/quit` | leave |
45
+ | `/clear` | reset the conversation |
46
+ | `/cls` | clear the screen, keep the conversation |
47
+ | `/model <name>` | switch model (e.g. `gemini-2.5-pro`) |
48
+ | `/system <text>` | replace the system instruction |
49
+ | `/cwd <path>` | change working directory |
50
+ | `/save <path>` | save the transcript to a file |
51
+
52
+ ### Tools the assistant uses automatically
53
+
54
+ `list_directory` · `read_file` · `glob_files` · `grep_files` · `write_file` · `run_shell`
55
+
56
+ ## Safety
57
+
58
+ Relay gives the model direct shell access on your machine. It will run commands and modify files without asking. Don't point it at machines you don't own, and consider reviewing the conversation as it runs.
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cw-relay"
7
+ version = "0.1.0"
8
+ description = "A Claude Code-style terminal chatbot powered by Gemini, with file and shell access."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Casper White" }]
13
+ keywords = ["cli", "chat", "gemini", "ai", "terminal", "assistant"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Utilities",
26
+ ]
27
+ dependencies = [
28
+ "google-genai>=0.3.0",
29
+ "rich>=13.0.0",
30
+ ]
31
+
32
+ [project.scripts]
33
+ relay = "relay.cli:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/casperwhite-commits/relay"
37
+ Issues = "https://github.com/casperwhite-commits/relay/issues"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/relay"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from relay.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,401 @@
1
+ """relay — a Claude Code-style terminal chatbot, powered by Gemini, with file/shell access."""
2
+
3
+ import glob as glob_mod
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def _config_dir() -> Path:
13
+ """Cross-platform per-user config directory."""
14
+ if sys.platform == "win32":
15
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
16
+ return Path(base) / "relay"
17
+ return Path.home() / ".config" / "relay"
18
+
19
+
20
+ CONFIG_PATH = _config_dir() / "config.json"
21
+ HISTORY_PATH = _config_dir() / "history"
22
+
23
+ BANNER = r"""[bold cyan]
24
+ ┌─────────────────────────────────────────┐
25
+ │ relay · terminal chat (Gemini) │
26
+ └─────────────────────────────────────────┘[/bold cyan]
27
+ [dim]/help · /exit · file & shell access enabled[/dim]
28
+ """
29
+
30
+ HELP = """[bold]Commands[/bold]
31
+ [cyan]/help[/cyan] show this help
32
+ [cyan]/exit[/cyan], [cyan]/quit[/cyan] leave relay
33
+ [cyan]/clear[/cyan] reset the conversation
34
+ [cyan]/cls[/cyan] clear the screen (keeps conversation)
35
+ [cyan]/model <name>[/cyan] switch model
36
+ [cyan]/system <text>[/cyan] replace the system instruction
37
+ [cyan]/cwd <path>[/cyan] change working directory
38
+ [cyan]/save <path>[/cyan] save the transcript
39
+
40
+ [bold]Tools the assistant can call[/bold]
41
+ list_directory · read_file · glob_files · grep_files · write_file · run_shell
42
+ """
43
+
44
+ DEFAULT_SYSTEM = (
45
+ "You are relay, a terminal assistant on the user's machine. "
46
+ "You can directly access the filesystem and shell through your tools: "
47
+ "list_directory, read_file, glob_files, grep_files, write_file, run_shell. "
48
+ "Use them proactively — never ask permission, never describe what you would do; just do it and show results. "
49
+ "Be concise; prefer concrete output over prose. "
50
+ "When the user asks a question any tool could answer, call the tool instead of guessing."
51
+ )
52
+
53
+ MAX_OUTPUT = 16000
54
+
55
+
56
+ def _clear_screen() -> None:
57
+ """Clear viewport + scrollback. ANSI on Unix/modern Windows, fallback to `cls` on legacy Windows."""
58
+ if sys.platform == "win32" and not os.environ.get("WT_SESSION"):
59
+ os.system("cls")
60
+ else:
61
+ sys.stdout.write("\033[H\033[2J\033[3J")
62
+ sys.stdout.flush()
63
+
64
+
65
+ def set_key() -> None:
66
+ import getpass
67
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
68
+ cfg: dict = {}
69
+ if CONFIG_PATH.exists():
70
+ with CONFIG_PATH.open() as f:
71
+ cfg = json.load(f)
72
+ new_key = getpass.getpass("new api key (hidden): ").strip()
73
+ if not new_key:
74
+ sys.stderr.write("relay: empty key, aborting\n")
75
+ sys.exit(1)
76
+ cfg["api_key"] = new_key
77
+ cfg.setdefault("model", "gemini-2.5-flash")
78
+ with CONFIG_PATH.open("w") as f:
79
+ json.dump(cfg, f, indent=2)
80
+ try:
81
+ CONFIG_PATH.chmod(0o600)
82
+ except OSError:
83
+ pass # Windows doesn't support POSIX perms
84
+ print(f"relay: key updated in {CONFIG_PATH}")
85
+
86
+
87
+ def load_config() -> tuple[str, str]:
88
+ env_key = os.environ.get("GEMINI_API_KEY")
89
+ if CONFIG_PATH.exists():
90
+ with CONFIG_PATH.open() as f:
91
+ cfg = json.load(f)
92
+ else:
93
+ cfg = {}
94
+ key = env_key or cfg.get("api_key")
95
+ if not key:
96
+ sys.stderr.write(
97
+ "relay: no API key found.\n"
98
+ f"Run `relay --set-key` to save one, or set GEMINI_API_KEY in your environment.\n"
99
+ )
100
+ sys.exit(1)
101
+ return key, cfg.get("model", "gemini-2.5-flash")
102
+
103
+
104
+ def _truncate(s: str, limit: int = MAX_OUTPUT) -> str:
105
+ if len(s) <= limit:
106
+ return s
107
+ return s[:limit] + f"\n\n[... {len(s) - limit} chars truncated ...]"
108
+
109
+
110
+ # ─── tools exposed to the model ────────────────────────────────────────────
111
+
112
+ def list_directory(path: str = ".") -> str:
113
+ """List files and subdirectories under `path`.
114
+
115
+ Args:
116
+ path: Filesystem path. Relative paths resolve against relay's current working dir.
117
+ """
118
+ p = Path(path).expanduser()
119
+ if not p.exists():
120
+ return f"error: {p} does not exist"
121
+ if not p.is_dir():
122
+ return f"error: {p} is not a directory"
123
+ rows = []
124
+ for entry in sorted(p.iterdir()):
125
+ kind = "dir " if entry.is_dir() else "file"
126
+ try:
127
+ size = str(entry.stat().st_size) if entry.is_file() else "-"
128
+ except OSError:
129
+ size = "?"
130
+ rows.append(f"{kind} {size:>10} {entry.name}")
131
+ return _truncate("\n".join(rows) if rows else "(empty)")
132
+
133
+
134
+ def read_file(path: str) -> str:
135
+ """Read a text file's contents (capped at ~16KB). Refuses binary files.
136
+
137
+ Args:
138
+ path: Path to the file to read.
139
+ """
140
+ p = Path(path).expanduser()
141
+ if not p.exists():
142
+ return f"error: {p} does not exist"
143
+ if not p.is_file():
144
+ return f"error: {p} is not a regular file"
145
+ try:
146
+ data = p.read_bytes()
147
+ if b"\x00" in data[:2048]:
148
+ return f"error: {p} appears to be binary"
149
+ return _truncate(data.decode("utf-8", errors="replace"))
150
+ except OSError as e:
151
+ return f"error: {e}"
152
+
153
+
154
+ def glob_files(pattern: str) -> str:
155
+ """Find files/dirs matching a glob pattern (supports ** for recursive).
156
+
157
+ Args:
158
+ pattern: Glob pattern, e.g. '**/*.py' or 'src/*.ts'.
159
+ """
160
+ try:
161
+ matches = sorted(glob_mod.glob(pattern, recursive=True))
162
+ return _truncate("\n".join(matches)) if matches else "(no matches)"
163
+ except Exception as e:
164
+ return f"error: {e}"
165
+
166
+
167
+ def grep_files(pattern: str, path: str = ".") -> str:
168
+ """Recursively search for a regex pattern in files under `path`. Returns file:line:text.
169
+
170
+ Args:
171
+ pattern: Python regular expression.
172
+ path: Directory or file to search.
173
+ """
174
+ try:
175
+ regex = re.compile(pattern)
176
+ except re.error as e:
177
+ return f"error: invalid regex: {e}"
178
+ root = Path(path).expanduser()
179
+ if not root.exists():
180
+ return f"error: {root} does not exist"
181
+ files = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()]
182
+ matches: list[str] = []
183
+ for f in files:
184
+ try:
185
+ with f.open("r", errors="replace") as fh:
186
+ for i, line in enumerate(fh, 1):
187
+ if regex.search(line):
188
+ matches.append(f"{f}:{i}:{line.rstrip()}")
189
+ if len(matches) >= 200:
190
+ matches.append("[... 200-match limit reached ...]")
191
+ return _truncate("\n".join(matches))
192
+ except (OSError, UnicodeError):
193
+ continue
194
+ return _truncate("\n".join(matches)) if matches else "(no matches)"
195
+
196
+
197
+ def write_file(path: str, content: str) -> str:
198
+ """Create or overwrite a file at `path` with `content`. Creates parent dirs if needed.
199
+
200
+ Args:
201
+ path: Destination file path.
202
+ content: Text to write.
203
+ """
204
+ p = Path(path).expanduser()
205
+ try:
206
+ p.parent.mkdir(parents=True, exist_ok=True)
207
+ p.write_text(content)
208
+ return f"wrote {len(content)} chars to {p}"
209
+ except OSError as e:
210
+ return f"error: {e}"
211
+
212
+
213
+ def run_shell(command: str) -> str:
214
+ """Execute a shell command in the OS default shell. 60-second timeout.
215
+
216
+ Args:
217
+ command: Shell command string.
218
+ """
219
+ try:
220
+ r = subprocess.run(
221
+ command, shell=True, capture_output=True, text=True, timeout=60,
222
+ )
223
+ out = (r.stdout or "") + (r.stderr or "")
224
+ head = f"[exit {r.returncode}]\n" if r.returncode != 0 else ""
225
+ return _truncate(head + out) if out else f"[exit {r.returncode}] (no output)"
226
+ except subprocess.TimeoutExpired:
227
+ return "error: command timed out after 60s"
228
+ except Exception as e:
229
+ return f"error: {e}"
230
+
231
+
232
+ TOOLS = [list_directory, read_file, glob_files, grep_files, write_file, run_shell]
233
+
234
+
235
+ def _short(v) -> str:
236
+ if isinstance(v, str):
237
+ s = v.replace("\n", "\\n")
238
+ return repr(s if len(s) <= 60 else s[:57] + "...")
239
+ return repr(v)
240
+
241
+
242
+ def main() -> None:
243
+ if len(sys.argv) > 1:
244
+ if sys.argv[1] in ("--set-key", "-k"):
245
+ set_key()
246
+ return
247
+ if sys.argv[1] in ("--version", "-V"):
248
+ from relay import __version__
249
+ print(f"relay {__version__}")
250
+ return
251
+ if sys.argv[1] in ("--help", "-h"):
252
+ print("Usage: relay [--set-key | --version | --help]\n\nRun `relay` with no args to start the chat.")
253
+ return
254
+
255
+ api_key, model = load_config()
256
+
257
+ try:
258
+ from google import genai
259
+ from google.genai import types as genai_types
260
+ except ImportError:
261
+ sys.stderr.write("relay: missing `google-genai`. Reinstall: pip install --upgrade relay-cli\n")
262
+ sys.exit(1)
263
+ try:
264
+ from rich.console import Console
265
+ from rich.markdown import Markdown
266
+ from rich.live import Live
267
+ except ImportError:
268
+ sys.stderr.write("relay: missing `rich`. Reinstall: pip install --upgrade relay-cli\n")
269
+ sys.exit(1)
270
+
271
+ try:
272
+ import readline
273
+ HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
274
+ if HISTORY_PATH.exists():
275
+ readline.read_history_file(str(HISTORY_PATH))
276
+ readline.set_history_length(1000)
277
+ except Exception:
278
+ readline = None # readline not available on Windows by default; that's fine
279
+
280
+ _clear_screen()
281
+
282
+ console = Console()
283
+ client = genai.Client(api_key=api_key)
284
+ system_instruction = DEFAULT_SYSTEM
285
+
286
+ def make_chat():
287
+ return client.chats.create(
288
+ model=model,
289
+ config=genai_types.GenerateContentConfig(
290
+ tools=TOOLS,
291
+ system_instruction=system_instruction,
292
+ ),
293
+ )
294
+
295
+ chat = make_chat()
296
+
297
+ def show_banner():
298
+ console.print(BANNER)
299
+ console.print(f"[dim]model:[/dim] [magenta]{model}[/magenta] [dim]cwd:[/dim] [magenta]{os.getcwd()}[/magenta]\n")
300
+
301
+ show_banner()
302
+
303
+ def save_transcript(path_str: str) -> None:
304
+ path = Path(path_str).expanduser()
305
+ try:
306
+ with path.open("w") as out:
307
+ out.write(f"# system\n{system_instruction}\n\n")
308
+ for msg in chat.get_history():
309
+ role = msg.role
310
+ text = "".join((p.text or "") for p in (msg.parts or []) if hasattr(p, "text"))
311
+ if text:
312
+ out.write(f"# {role}\n{text}\n\n")
313
+ console.print(f"[dim]saved transcript to {path}[/dim]\n")
314
+ except OSError as e:
315
+ console.print(f"[red]save failed:[/red] {e}\n")
316
+
317
+ while True:
318
+ try:
319
+ user_input = console.input("[bold green]›[/bold green] ")
320
+ except (EOFError, KeyboardInterrupt):
321
+ console.print("\n[dim]bye.[/dim]")
322
+ break
323
+ if not user_input.strip():
324
+ continue
325
+
326
+ if user_input.startswith("/"):
327
+ parts = user_input.strip().split(maxsplit=1)
328
+ cmd, arg = parts[0].lower(), (parts[1] if len(parts) > 1 else "")
329
+ if cmd in ("/exit", "/quit"):
330
+ console.print("[dim]bye.[/dim]")
331
+ break
332
+ elif cmd == "/help":
333
+ console.print(HELP)
334
+ elif cmd == "/clear":
335
+ chat = make_chat()
336
+ _clear_screen()
337
+ show_banner()
338
+ elif cmd == "/cls":
339
+ _clear_screen()
340
+ elif cmd == "/model":
341
+ if not arg:
342
+ console.print(f"[dim]current model:[/dim] [magenta]{model}[/magenta]\n")
343
+ else:
344
+ model = arg.strip()
345
+ chat = make_chat()
346
+ console.print(f"[dim]model →[/dim] [magenta]{model}[/magenta] [dim](conversation reset)[/dim]\n")
347
+ elif cmd == "/system":
348
+ system_instruction = arg or DEFAULT_SYSTEM
349
+ chat = make_chat()
350
+ console.print("[dim]system instruction updated (conversation reset)[/dim]\n")
351
+ elif cmd == "/cwd":
352
+ target = Path(arg or str(Path.home())).expanduser()
353
+ try:
354
+ os.chdir(target)
355
+ console.print(f"[dim]cwd →[/dim] [magenta]{os.getcwd()}[/magenta]\n")
356
+ except OSError as e:
357
+ console.print(f"[red]cd failed:[/red] {e}\n")
358
+ elif cmd == "/save":
359
+ if not arg:
360
+ console.print("[red]usage:[/red] /save <path>\n")
361
+ else:
362
+ save_transcript(arg)
363
+ else:
364
+ console.print(f"[red]unknown command:[/red] {cmd} (try /help)\n")
365
+ continue
366
+
367
+ buf = ""
368
+ try:
369
+ stream = chat.send_message_stream(user_input)
370
+ with Live(Markdown(""), console=console, refresh_per_second=20, vertical_overflow="visible") as live:
371
+ for chunk in stream:
372
+ for cand in (chunk.candidates or []):
373
+ content = getattr(cand, "content", None)
374
+ if not content or not getattr(content, "parts", None):
375
+ continue
376
+ for part in content.parts:
377
+ text = getattr(part, "text", None)
378
+ if text:
379
+ buf += text
380
+ live.update(Markdown(buf))
381
+ fc = getattr(part, "function_call", None)
382
+ if fc and fc.name:
383
+ args = dict(fc.args or {})
384
+ args_str = ", ".join(f"{k}={_short(v)}" for k, v in args.items())
385
+ live.console.print(f"[dim cyan] ↳ {fc.name}({args_str})[/dim cyan]")
386
+ except KeyboardInterrupt:
387
+ console.print("\n[dim]— interrupted —[/dim]\n")
388
+ except Exception as e:
389
+ console.print(f"[red]error:[/red] {e}\n")
390
+ continue
391
+
392
+ console.print()
393
+ if readline:
394
+ try:
395
+ readline.write_history_file(str(HISTORY_PATH))
396
+ except Exception:
397
+ pass
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()