localcoder 0.1.0__py3-none-any.whl

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,583 @@
1
+ """
2
+ gemma4coder_display.py — Memory-safe animation and display system for gemma4coder.
3
+
4
+ Uses rich.live for all animations (no background threads).
5
+ All functions are standalone and can be dropped into the main script.
6
+
7
+ Requirements: Python 3.10+, rich >= 13.0
8
+ """
9
+
10
+ import time
11
+ from contextlib import contextmanager
12
+ from typing import Optional
13
+
14
+ from rich.console import Console
15
+ from rich.live import Live
16
+ from rich.panel import Panel
17
+ from rich.progress import (
18
+ BarColumn,
19
+ Progress,
20
+ SpinnerColumn,
21
+ TaskID,
22
+ TextColumn,
23
+ )
24
+ from rich.spinner import Spinner
25
+ from rich.style import Style
26
+ from rich.syntax import Syntax
27
+ from rich.table import Table
28
+ from rich.text import Text
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # 1. THINKING SPINNER — shows during LLM inference
32
+ # ---------------------------------------------------------------------------
33
+
34
+ class ThinkingSpinner:
35
+ """Single-line spinner that shows elapsed time, token count, and tok/s.
36
+
37
+ Usage:
38
+ spinner = ThinkingSpinner(console)
39
+ spinner.start()
40
+ # ... in your streaming loop ...
41
+ spinner.update(tokens=42, tps=18.5)
42
+ # ... when done ...
43
+ spinner.stop(total_tokens=120, tps=22.0, elapsed=5.3)
44
+
45
+ Or as a context manager:
46
+ with ThinkingSpinner(console) as sp:
47
+ sp.update(tokens=10)
48
+ """
49
+
50
+ def __init__(self, console: Console) -> None:
51
+ self._console = console
52
+ self._live: Optional[Live] = None
53
+ self._start_time: float = 0.0
54
+ self._tokens: int = 0
55
+ self._tps: float = 0.0
56
+
57
+ # -- rendering ----------------------------------------------------------
58
+
59
+ def _render(self) -> Text:
60
+ elapsed = time.time() - self._start_time
61
+ if elapsed < 60:
62
+ time_str = f"{elapsed:.0f}s"
63
+ else:
64
+ m, s = divmod(elapsed, 60)
65
+ time_str = f"{m:.0f}m {s:.0f}s"
66
+
67
+ line = Text()
68
+ line.append(" ")
69
+ # spinner glyph — cycle through 4 frames based on elapsed
70
+ frames = "◐◓◑◒"
71
+ frame = frames[int(elapsed * 4) % len(frames)]
72
+ line.append(frame, style="bold magenta")
73
+ line.append(" ")
74
+ line.append("Thinking…", style="italic magenta")
75
+ line.append(f" {time_str}", style="dim")
76
+
77
+ if self._tokens > 0:
78
+ line.append(f" ↓ {self._tokens} tokens", style="dim")
79
+ if self._tps > 0:
80
+ line.append(f" {self._tps:.0f} tok/s", style="dim cyan")
81
+
82
+ return line
83
+
84
+ # -- public API ---------------------------------------------------------
85
+
86
+ def start(self) -> None:
87
+ self._start_time = time.time()
88
+ self._tokens = 0
89
+ self._tps = 0.0
90
+ self._live = Live(
91
+ self._render(),
92
+ console=self._console,
93
+ refresh_per_second=4,
94
+ transient=True,
95
+ )
96
+ self._live.start()
97
+
98
+ def update(self, tokens: int = 0, tps: float = 0.0) -> None:
99
+ """Update token count and speed. Call as often as you like."""
100
+ if tokens > 0:
101
+ self._tokens = tokens
102
+ if tps > 0:
103
+ self._tps = tps
104
+ if self._live is not None:
105
+ self._live.update(self._render())
106
+
107
+ def stop(
108
+ self,
109
+ total_tokens: int = 0,
110
+ tps: float = 0.0,
111
+ elapsed: Optional[float] = None,
112
+ ) -> None:
113
+ """Stop the spinner and print a final summary line."""
114
+ try:
115
+ if self._live is not None:
116
+ self._live.stop()
117
+ self._live = None
118
+ except Exception:
119
+ pass
120
+
121
+ if elapsed is None:
122
+ elapsed = time.time() - self._start_time
123
+ if elapsed < 60:
124
+ t = f"{elapsed:.0f}s"
125
+ else:
126
+ m, s = divmod(elapsed, 60)
127
+ t = f"{m:.0f}m {s:.0f}s"
128
+
129
+ tok = total_tokens or self._tokens
130
+ speed = tps or self._tps
131
+ self._console.print(
132
+ f"\n [dim]✦ {t} · {tok} tokens · {speed:.0f} tok/s[/]"
133
+ )
134
+
135
+ def __enter__(self) -> "ThinkingSpinner":
136
+ self.start()
137
+ return self
138
+
139
+ def __exit__(self, *exc) -> None:
140
+ try:
141
+ if self._live is not None:
142
+ self._live.stop()
143
+ self._live = None
144
+ except Exception:
145
+ pass
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # 2. STARTUP ANIMATION — gradient banner + connect/ready transition
150
+ # ---------------------------------------------------------------------------
151
+
152
+ _GRADIENT_COLORS = [
153
+ "#ff6ec7", "#d16bff", "#9b6bff", "#6b8bff", "#6bcfff",
154
+ "#6bffcf", "#6bff8b", "#b5ff6b", "#ffef6b", "#ffb86b",
155
+ ]
156
+
157
+
158
+ def _gradient_text(text: str, colors: list[str]) -> Text:
159
+ """Apply a gradient across characters of *text*."""
160
+ result = Text()
161
+ n = max(len(colors), 1)
162
+ for i, ch in enumerate(text):
163
+ color = colors[i % n]
164
+ result.append(ch, style=Style(color=color, bold=True))
165
+ return result
166
+
167
+
168
+ def show_startup_animation(console: Console, backend_info: dict) -> None:
169
+ """Elegant startup: gradient banner -> connecting -> ready.
170
+
171
+ Total animation time < 500 ms. Uses Live for smooth updates.
172
+ """
173
+ banner_text = "gemma4coder"
174
+ model_name = backend_info.get("model_name", "unknown")
175
+ backend = backend_info.get("backend", "unknown")
176
+ ctx = backend_info.get("ctx", "")
177
+ quant = backend_info.get("quant", "")
178
+ size = backend_info.get("size", "")
179
+
180
+ model_label = f"Gemma 4 {size}" if size else model_name
181
+ if quant:
182
+ model_label += f" {quant}"
183
+
184
+ # Phase 1: gradient banner + "connecting..."
185
+ def _frame_connecting() -> Panel:
186
+ title = Text()
187
+ title.append("◆ ", style="bold magenta")
188
+ title.append_text(_gradient_text(banner_text, _GRADIENT_COLORS))
189
+
190
+ inner = Text()
191
+ inner.append(f" {model_label} ", style="bold white on rgb(60,20,80)")
192
+ inner.append(" ", style="dim")
193
+ inner.append("connecting…", style="italic yellow")
194
+
195
+ return Panel(
196
+ inner,
197
+ title=title,
198
+ title_align="left",
199
+ border_style="magenta",
200
+ padding=(0, 1),
201
+ )
202
+
203
+ # Phase 2: gradient banner + ready info
204
+ def _frame_ready() -> Panel:
205
+ title = Text()
206
+ title.append("◆ ", style="bold magenta")
207
+ title.append_text(_gradient_text(banner_text, _GRADIENT_COLORS))
208
+
209
+ inner = Text()
210
+ inner.append(f" {model_label} ", style="bold white on rgb(60,20,80)")
211
+ inner.append(" ", style="dim")
212
+ inner.append(backend, style="green")
213
+ inner.append(" (local)", style="dim green")
214
+ if ctx:
215
+ inner.append(" ", style="dim")
216
+ inner.append(ctx, style="bold green")
217
+ inner.append(" ", style="dim")
218
+ inner.append("$0.00", style="bold green")
219
+
220
+ return Panel(
221
+ inner,
222
+ title=title,
223
+ title_align="left",
224
+ border_style="magenta",
225
+ padding=(0, 1),
226
+ )
227
+
228
+ console.print()
229
+ try:
230
+ with Live(
231
+ _frame_connecting(),
232
+ console=console,
233
+ refresh_per_second=10,
234
+ transient=True,
235
+ ) as live:
236
+ time.sleep(0.25)
237
+ live.update(_frame_ready())
238
+ time.sleep(0.15)
239
+ except KeyboardInterrupt:
240
+ pass
241
+
242
+ # Print the final frame permanently
243
+ console.print(_frame_ready())
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # 3. TOOL CALL ANIMATIONS — single-line, non-blocking
248
+ # ---------------------------------------------------------------------------
249
+
250
+ def show_tool_animation(
251
+ console: Console,
252
+ tool_name: str,
253
+ args: dict,
254
+ ) -> None:
255
+ """Display a tool call indicator. Single line, returns immediately.
256
+
257
+ Supported tools: bash, write_file, read_file, edit_file, web_search, fetch_url.
258
+ """
259
+ if tool_name == "bash":
260
+ cmd = args.get("command", "")[:120]
261
+ # Brief syntax-highlighted command display
262
+ try:
263
+ syn = Syntax(
264
+ f"$ {cmd}",
265
+ "bash",
266
+ theme="monokai",
267
+ line_numbers=False,
268
+ word_wrap=True,
269
+ )
270
+ console.print(
271
+ Panel(
272
+ syn,
273
+ title="[bold yellow]⚙ bash[/]",
274
+ title_align="left",
275
+ border_style="yellow",
276
+ padding=(0, 1),
277
+ )
278
+ )
279
+ except Exception:
280
+ console.print(
281
+ Panel(
282
+ Text(f"$ {cmd}", style="cyan"),
283
+ title="[bold yellow]⚙ bash[/]",
284
+ title_align="left",
285
+ border_style="yellow",
286
+ padding=(0, 1),
287
+ )
288
+ )
289
+
290
+ elif tool_name == "write_file":
291
+ path = args.get("path", "?")
292
+ sz = len(args.get("content", ""))
293
+ line = Text()
294
+ line.append(" ← ", style="bold green")
295
+ line.append("Writing ", style="green")
296
+ line.append(path, style="bold white")
297
+ line.append(f" ({sz} chars)", style="dim")
298
+ console.print(line)
299
+
300
+ elif tool_name == "read_file":
301
+ path = args.get("path", "?")
302
+ line = Text()
303
+ line.append(" → ", style="bold blue")
304
+ line.append("Reading ", style="blue")
305
+ line.append(path, style="bold white")
306
+ console.print(line)
307
+
308
+ elif tool_name == "edit_file":
309
+ path = args.get("path", "?")
310
+ line = Text()
311
+ line.append(" ← ", style="bold green")
312
+ line.append("Editing ", style="green")
313
+ line.append(path, style="bold white")
314
+ console.print(line)
315
+
316
+ elif tool_name == "web_search":
317
+ query = args.get("query", "")
318
+ line = Text()
319
+ line.append(" 🔍 ", style="bold magenta")
320
+ line.append("Searching ", style="magenta")
321
+ line.append(f'"{query}"', style="bold white")
322
+ line.append(" ···", style="dim magenta")
323
+ console.print(line)
324
+
325
+ elif tool_name == "fetch_url":
326
+ url = args.get("url", "")[:80]
327
+ line = Text()
328
+ line.append(" 🌐 ", style="bold blue")
329
+ line.append("Fetching ", style="blue")
330
+ line.append(url, style="dim")
331
+ console.print(line)
332
+
333
+ else:
334
+ console.print(f" [yellow]⚡ {tool_name}[/]")
335
+
336
+
337
+ @contextmanager
338
+ def tool_running_indicator(console: Console, tool_name: str):
339
+ """Context manager: shows a brief 'running...' indicator while a tool executes.
340
+
341
+ Usage:
342
+ with tool_running_indicator(console, "bash"):
343
+ result = exec_tool("bash", args)
344
+ """
345
+ labels = {
346
+ "bash": "running…",
347
+ "write_file": "writing…",
348
+ "read_file": "reading…",
349
+ "edit_file": "editing…",
350
+ "web_search": "searching…",
351
+ "fetch_url": "fetching…",
352
+ }
353
+ label = labels.get(tool_name, "running…")
354
+
355
+ spinner_text = Text()
356
+ spinner_text.append(" ")
357
+ spinner_text.append_text(Spinner("dots").render(time.time()))
358
+ spinner_text.append(f" {label}", style="dim italic")
359
+
360
+ live = Live(
361
+ spinner_text,
362
+ console=console,
363
+ refresh_per_second=8,
364
+ transient=True,
365
+ )
366
+ try:
367
+ live.start()
368
+ yield
369
+ finally:
370
+ try:
371
+ live.stop()
372
+ except Exception:
373
+ pass
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # 4. TOKEN STREAM DISPLAY — "generating..." then full markdown render
378
+ # ---------------------------------------------------------------------------
379
+
380
+ @contextmanager
381
+ def generating_indicator(console: Console):
382
+ """Shows a live 'generating...' indicator. Stop it, then render markdown.
383
+
384
+ Usage:
385
+ with generating_indicator(console):
386
+ response = chat_api(messages)
387
+ show_response(console, response_text)
388
+ """
389
+ line = Text()
390
+ line.append(" ")
391
+ line.append("◐", style="bold magenta")
392
+ line.append(" generating…", style="italic dim")
393
+
394
+ live = Live(
395
+ line,
396
+ console=console,
397
+ refresh_per_second=4,
398
+ transient=True,
399
+ )
400
+ try:
401
+ live.start()
402
+ yield live
403
+ finally:
404
+ try:
405
+ live.stop()
406
+ except Exception:
407
+ pass
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # 5. CONTEXT USAGE BAR
412
+ # ---------------------------------------------------------------------------
413
+
414
+ def context_usage_bar(
415
+ console: Console,
416
+ used_tokens: int,
417
+ max_tokens: int,
418
+ ) -> None:
419
+ """Print a thin context-usage bar. Green <50%, yellow 50-80%, red >80%.
420
+
421
+ Example output: ▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 4.2K / 128K tokens
422
+ """
423
+ if max_tokens <= 0:
424
+ return
425
+
426
+ ratio = min(used_tokens / max_tokens, 1.0)
427
+ pct = ratio * 100
428
+
429
+ if pct < 50:
430
+ bar_style = "green"
431
+ elif pct < 80:
432
+ bar_style = "yellow"
433
+ else:
434
+ bar_style = "red"
435
+
436
+ # 30-char bar
437
+ bar_width = 30
438
+ filled = int(ratio * bar_width)
439
+ empty = bar_width - filled
440
+
441
+ bar = Text()
442
+ bar.append(" ")
443
+ bar.append("▰" * filled, style=bar_style)
444
+ bar.append("▱" * empty, style="dim")
445
+ bar.append(" ", style="dim")
446
+
447
+ # Format token counts: 4200 -> "4.2K", 128000 -> "128K"
448
+ def _fmt(n: int) -> str:
449
+ if n >= 1000:
450
+ k = n / 1000
451
+ return f"{k:.1f}K" if k < 100 else f"{k:.0f}K"
452
+ return str(n)
453
+
454
+ bar.append(f"{_fmt(used_tokens)} / {_fmt(max_tokens)} tokens", style="dim")
455
+ bar.append(f" ({pct:.0f}%)", style=f"dim {bar_style}")
456
+
457
+ console.print(bar)
458
+
459
+
460
+ def context_usage_bar_compact(
461
+ used_tokens: int,
462
+ max_tokens: int,
463
+ ) -> Text:
464
+ """Return a Text renderable (for embedding in panels/toolbars)."""
465
+ if max_tokens <= 0:
466
+ return Text("? / ? tokens", style="dim")
467
+
468
+ ratio = min(used_tokens / max_tokens, 1.0)
469
+ pct = ratio * 100
470
+
471
+ if pct < 50:
472
+ bar_style = "green"
473
+ elif pct < 80:
474
+ bar_style = "yellow"
475
+ else:
476
+ bar_style = "red"
477
+
478
+ bar_width = 15
479
+ filled = int(ratio * bar_width)
480
+ empty = bar_width - filled
481
+
482
+ def _fmt(n: int) -> str:
483
+ if n >= 1000:
484
+ k = n / 1000
485
+ return f"{k:.1f}K" if k < 100 else f"{k:.0f}K"
486
+ return str(n)
487
+
488
+ result = Text()
489
+ result.append("▰" * filled, style=bar_style)
490
+ result.append("▱" * empty, style="dim")
491
+ result.append(f" {_fmt(used_tokens)}/{_fmt(max_tokens)}", style="dim")
492
+ return result
493
+
494
+
495
+ # ---------------------------------------------------------------------------
496
+ # 6. CONVENIENCE: drop-in replacements for existing gemma4coder functions
497
+ # ---------------------------------------------------------------------------
498
+
499
+ def print_thinking_live(
500
+ console: Console,
501
+ tokens: int = 0,
502
+ tps: float = 0.0,
503
+ start_time: float = 0.0,
504
+ ) -> Text:
505
+ """Return a renderable for the thinking state (for use with Live)."""
506
+ elapsed = time.time() - start_time if start_time else 0
507
+ if elapsed < 60:
508
+ time_str = f"{elapsed:.0f}s"
509
+ else:
510
+ m, s = divmod(elapsed, 60)
511
+ time_str = f"{m:.0f}m {s:.0f}s"
512
+
513
+ frames = "◐◓◑◒"
514
+ frame = frames[int(elapsed * 4) % len(frames)]
515
+
516
+ line = Text()
517
+ line.append(" ")
518
+ line.append(frame, style="bold magenta")
519
+ line.append(" ")
520
+ line.append("Thinking…", style="italic magenta")
521
+ line.append(f" {time_str}", style="dim")
522
+ if tokens > 0:
523
+ line.append(f" ↓ {tokens} tokens", style="dim")
524
+ if tps > 0:
525
+ line.append(f" {tps:.0f} tok/s", style="dim cyan")
526
+ return line
527
+
528
+
529
+ # ---------------------------------------------------------------------------
530
+ # DEMO / SELF-TEST
531
+ # ---------------------------------------------------------------------------
532
+
533
+ def _demo() -> None:
534
+ """Run a visual demo of all components."""
535
+ c = Console()
536
+
537
+ c.print("\n[bold underline]1. Startup Animation[/]\n")
538
+ show_startup_animation(c, {
539
+ "backend": "llama.cpp",
540
+ "model_name": "gemma-4-26B-A4B-it-UD-Q3_K_XL",
541
+ "quant": "Q3_K_XL",
542
+ "size": "26B",
543
+ "ctx": "32K",
544
+ })
545
+
546
+ c.print("\n[bold underline]2. Thinking Spinner[/]\n")
547
+ with ThinkingSpinner(c) as sp:
548
+ for i in range(12):
549
+ time.sleep(0.15)
550
+ sp.update(tokens=i * 8, tps=18.0 + i * 0.5)
551
+ sp.stop(total_tokens=96, tps=24.0)
552
+
553
+ c.print("\n[bold underline]3. Tool Call Animations[/]\n")
554
+ show_tool_animation(c, "bash", {"command": "find . -name '*.py' | head -20"})
555
+ show_tool_animation(c, "write_file", {"path": "src/app.py", "content": "x" * 1420})
556
+ show_tool_animation(c, "read_file", {"path": "package.json"})
557
+ show_tool_animation(c, "edit_file", {"path": "main.rs"})
558
+ show_tool_animation(c, "web_search", {"query": "rust async trait 2025"})
559
+ show_tool_animation(c, "fetch_url", {"url": "https://docs.rs/tokio/latest/tokio/"})
560
+
561
+ c.print("\n[bold underline]4. Tool Running Indicator[/]\n")
562
+ with tool_running_indicator(c, "bash"):
563
+ time.sleep(0.6)
564
+ c.print(" [dim]done[/]")
565
+
566
+ c.print("\n[bold underline]5. Generating Indicator[/]\n")
567
+ with generating_indicator(c):
568
+ time.sleep(0.8)
569
+ c.print(" [dim](response would render here)[/]")
570
+
571
+ c.print("\n[bold underline]6. Context Usage Bar[/]\n")
572
+ context_usage_bar(c, 2100, 131072) # green
573
+ context_usage_bar(c, 78000, 131072) # yellow
574
+ context_usage_bar(c, 118000, 131072) # red
575
+
576
+ c.print("\n[bold underline]7. Compact Bar (for toolbar)[/]\n")
577
+ c.print(Text(" "), context_usage_bar_compact(4200, 131072))
578
+
579
+ c.print()
580
+
581
+
582
+ if __name__ == "__main__":
583
+ _demo()