gemcode 0.2.2__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.
Files changed (58) hide show
  1. gemcode/__init__.py +3 -0
  2. gemcode/__main__.py +3 -0
  3. gemcode/agent.py +146 -0
  4. gemcode/audit.py +16 -0
  5. gemcode/callbacks.py +473 -0
  6. gemcode/capability_routing.py +137 -0
  7. gemcode/cli.py +658 -0
  8. gemcode/compaction.py +35 -0
  9. gemcode/computer_use/__init__.py +0 -0
  10. gemcode/computer_use/browser_computer.py +275 -0
  11. gemcode/config.py +247 -0
  12. gemcode/interactions.py +15 -0
  13. gemcode/invoke.py +151 -0
  14. gemcode/kairos_daemon.py +221 -0
  15. gemcode/limits.py +83 -0
  16. gemcode/live_audio_engine.py +124 -0
  17. gemcode/mcp_loader.py +57 -0
  18. gemcode/memory/__init__.py +0 -0
  19. gemcode/memory/embedding_memory_service.py +292 -0
  20. gemcode/memory/file_memory_service.py +176 -0
  21. gemcode/modality_tools.py +216 -0
  22. gemcode/model_routing.py +179 -0
  23. gemcode/paths.py +29 -0
  24. gemcode/permissions.py +5 -0
  25. gemcode/plugins/__init__.py +0 -0
  26. gemcode/plugins/terminal_hooks_plugin.py +168 -0
  27. gemcode/plugins/tool_recovery_plugin.py +135 -0
  28. gemcode/prompt_suggestions.py +80 -0
  29. gemcode/query/__init__.py +36 -0
  30. gemcode/query/config.py +35 -0
  31. gemcode/query/deps.py +20 -0
  32. gemcode/query/engine.py +55 -0
  33. gemcode/query/stop_hooks.py +63 -0
  34. gemcode/query/token_budget.py +109 -0
  35. gemcode/query/transitions.py +41 -0
  36. gemcode/session_runtime.py +81 -0
  37. gemcode/thinking.py +136 -0
  38. gemcode/tool_prompt_manifest.py +118 -0
  39. gemcode/tool_registry.py +50 -0
  40. gemcode/tools/__init__.py +25 -0
  41. gemcode/tools/edit.py +53 -0
  42. gemcode/tools/filesystem.py +73 -0
  43. gemcode/tools/search.py +85 -0
  44. gemcode/tools/shell.py +73 -0
  45. gemcode/tools_inspector.py +132 -0
  46. gemcode/trust.py +54 -0
  47. gemcode/tui/app.py +697 -0
  48. gemcode/tui/scrollback.py +312 -0
  49. gemcode/vertex.py +22 -0
  50. gemcode/web/__init__.py +2 -0
  51. gemcode/web/claude_sse_adapter.py +282 -0
  52. gemcode/web/terminal_repl.py +147 -0
  53. gemcode-0.2.2.dist-info/METADATA +440 -0
  54. gemcode-0.2.2.dist-info/RECORD +58 -0
  55. gemcode-0.2.2.dist-info/WHEEL +5 -0
  56. gemcode-0.2.2.dist-info/entry_points.txt +2 -0
  57. gemcode-0.2.2.dist-info/licenses/LICENSE +151 -0
  58. gemcode-0.2.2.dist-info/top_level.txt +1 -0
gemcode/tui/app.py ADDED
@@ -0,0 +1,697 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import subprocess
6
+ import time
7
+ import warnings
8
+ from datetime import datetime
9
+
10
+ from gemcode.capability_routing import apply_capability_routing
11
+ from gemcode.model_routing import pick_effective_model
12
+
13
+
14
+ async def run_gemcode_tui(*, cfg, runner, session_id: str) -> None:
15
+ """
16
+ Minimal full-screen TUI using Prompt Toolkit:
17
+ - Header: status + key hints
18
+ - Body: scrollback
19
+ - Footer: fixed multi-line input
20
+
21
+ This intentionally focuses on Claude-like *interaction ergonomics* first.
22
+ """
23
+ from prompt_toolkit.application import Application
24
+ from prompt_toolkit.key_binding import KeyBindings
25
+ from prompt_toolkit.layout import Dimension as D
26
+ from prompt_toolkit.layout import Layout
27
+ from prompt_toolkit.layout.containers import ConditionalContainer, HSplit, Window
28
+ from prompt_toolkit.layout.controls import FormattedTextControl
29
+ from prompt_toolkit.filters import Condition
30
+ from prompt_toolkit.styles import Style
31
+ from prompt_toolkit.widgets import Frame, TextArea
32
+ from google.adk.agents.run_config import RunConfig
33
+ from google.genai import types
34
+
35
+ # Signal other parts of GemCode (callbacks) that a full-screen TUI is active.
36
+ # Prevents stray stderr prints from corrupting the alternate screen.
37
+ os.environ["GEMCODE_TUI_ACTIVE"] = "1"
38
+
39
+ # Some upstream libraries emit noisy warnings to stderr which can corrupt a TUI.
40
+ warnings.filterwarnings(
41
+ "ignore",
42
+ message=r"^Warning: there are non-text parts in the response: .*",
43
+ category=UserWarning,
44
+ )
45
+
46
+ # Note: we need to append streaming text into this buffer; Prompt Toolkit
47
+ # raises EditReadOnlyBuffer if we try to insert into a read-only buffer.
48
+ # Keep it unfocusable so the user can't type into it.
49
+ output = TextArea(
50
+ text="",
51
+ read_only=False,
52
+ scrollbar=True,
53
+ focusable=False,
54
+ wrap_lines=True,
55
+ # Critical: keep transcript in its own scrollable pane.
56
+ # Without this, TextArea can grow with content and overlap the input panel.
57
+ height=D(weight=1),
58
+ )
59
+ input_box = TextArea(
60
+ prompt="> ",
61
+ multiline=True,
62
+ wrap_lines=True,
63
+ height=D(min=3, max=6, preferred=3),
64
+ )
65
+
66
+ interrupted = {"flag": False}
67
+
68
+ def append(text: str) -> None:
69
+ output.buffer.insert_text(text)
70
+ if not text.endswith("\n"):
71
+ output.buffer.insert_text("\n")
72
+ output.buffer.cursor_position = len(output.text)
73
+ # Force a redraw; some terminals won't repaint correctly until resize.
74
+ try:
75
+ app.invalidate()
76
+ except Exception:
77
+ pass
78
+
79
+ def append_inline(text: str) -> None:
80
+ """Append without forcing a newline (for streaming deltas)."""
81
+ output.buffer.insert_text(text)
82
+ output.buffer.cursor_position = len(output.text)
83
+ try:
84
+ app.invalidate()
85
+ except Exception:
86
+ pass
87
+
88
+ # Character-by-character rendering (Claude-like feel), even if upstream deltas
89
+ # arrive as full sentences.
90
+ #
91
+ # - GEMCODE_TUI_CHAR_DELAY_MS: per-character delay (default 0ms)
92
+ # - GEMCODE_TUI_CHAR_YIELD_EVERY: yield to event loop after N chars (default 1)
93
+ _delay_ms = int(os.environ.get("GEMCODE_TUI_CHAR_DELAY_MS", "0") or "0")
94
+ _yield_every = max(1, int(os.environ.get("GEMCODE_TUI_CHAR_YIELD_EVERY", "1") or "1"))
95
+
96
+ async def typewrite(text: str) -> None:
97
+ if not text:
98
+ return
99
+ n = 0
100
+ for ch in text:
101
+ append_inline(ch)
102
+ n += 1
103
+ # Force a render tick.
104
+ try:
105
+ app.invalidate()
106
+ except Exception:
107
+ pass
108
+ if _delay_ms > 0:
109
+ await asyncio.sleep(_delay_ms / 1000.0)
110
+ elif n % _yield_every == 0:
111
+ await asyncio.sleep(0)
112
+
113
+ def header_text():
114
+ model = getattr(cfg, "model", "") or ""
115
+ mode = (
116
+ "yes"
117
+ if getattr(cfg, "yes_to_all", False)
118
+ else "ask"
119
+ if getattr(cfg, "interactive_permission_ask", False)
120
+ else "ro"
121
+ )
122
+ root = str(getattr(cfg, "project_root", "") or "")
123
+ now = datetime.now().strftime("%a %b %d %H:%M")
124
+ # Shift+Enter isn't reliably distinguishable across terminals, so we
125
+ # provide a portable newline binding (Ctrl+J).
126
+ tips = "Enter=send | Ctrl+J=newline | Esc=interrupt | Ctrl+D=exit"
127
+ return [
128
+ ("class:brand", " GemCode "),
129
+ ("", f" model={model or '<auto>'} perm={mode} root={root} {now}\n"),
130
+ ("class:muted", f" {tips}"),
131
+ ]
132
+
133
+ header = Window(height=2, content=FormattedTextControl(header_text), dont_extend_height=True)
134
+
135
+ _git_cache = {"t": 0.0, "branch": ""}
136
+
137
+ def _git_branch() -> str:
138
+ # Claude shows git branch in status line; do a tiny cached call here.
139
+ now = time.time()
140
+ if now - _git_cache["t"] < 5 and _git_cache["branch"]:
141
+ return _git_cache["branch"]
142
+ try:
143
+ p = subprocess.run(
144
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
145
+ cwd=str(getattr(cfg, "project_root", "") or "."),
146
+ stdout=subprocess.PIPE,
147
+ stderr=subprocess.DEVNULL,
148
+ text=True,
149
+ timeout=0.15,
150
+ )
151
+ b = (p.stdout or "").strip()
152
+ if b and b != "HEAD":
153
+ _git_cache["branch"] = b
154
+ _git_cache["t"] = now
155
+ return b
156
+ except Exception:
157
+ pass
158
+ _git_cache["t"] = now
159
+ _git_cache["branch"] = ""
160
+ return ""
161
+
162
+ status = Window(
163
+ height=1,
164
+ dont_extend_height=True,
165
+ content=FormattedTextControl(lambda: [("class:muted", " loading… ")]),
166
+ )
167
+
168
+ # Non-modal permission prompt state. Modal dialogs can corrupt a full-screen TUI.
169
+ pending_confirm: dict[str, object] = {"future": None, "tool": "", "hint": ""}
170
+
171
+ def _set_input_prompt() -> None:
172
+ if pending_confirm.get("future") is not None:
173
+ input_box.prompt = "perm> "
174
+ else:
175
+ input_box.prompt = "> "
176
+
177
+ def _input_help_text():
178
+ if pending_confirm.get("future") is not None:
179
+ tool = str(pending_confirm.get("tool") or "tool")
180
+ return [
181
+ ("class:muted", " "),
182
+ ("class:accent", f"Permission needed for "),
183
+ ("class:pill", tool),
184
+ ("class:muted", ". Type "),
185
+ ("class:accent", "y"),
186
+ ("class:muted", " or "),
187
+ ("class:accent", "n"),
188
+ ("class:muted", " in the input below and press Enter."),
189
+ ]
190
+ return [
191
+ ("class:muted", " "),
192
+ ("class:muted", "Type your message below. Enter=send · Ctrl+J=newline · Ctrl+O=home"),
193
+ ]
194
+
195
+ def _status_text():
196
+ fut = pending_confirm.get("future")
197
+ if fut is not None:
198
+ tool = str(pending_confirm.get("tool") or "tool")
199
+ return [
200
+ ("class:muted", " "),
201
+ ("class:pill", f"Permission: {tool}"),
202
+ ("class:muted", " "),
203
+ ("class:accent", "y=approve"),
204
+ ("class:muted", " "),
205
+ ("class:accent", "n=deny"),
206
+ ("class:muted", " "),
207
+ ("class:muted", "(Esc cancels)"),
208
+ ]
209
+ return [
210
+ ("class:muted", " "),
211
+ ("class:pill", f"🌿 {_git_branch()}" if _git_branch() else "📁 no-git"),
212
+ ("class:muted", " "),
213
+ ("class:muted", "Tip: Ctrl+J=newline Esc=interrupt Ctrl+D=exit"),
214
+ ]
215
+
216
+ status.content = FormattedTextControl(_status_text)
217
+ _set_input_prompt()
218
+
219
+ input_help = Window(
220
+ height=1,
221
+ dont_extend_height=True,
222
+ content=FormattedTextControl(_input_help_text),
223
+ )
224
+
225
+ # Home dashboard behavior:
226
+ # - Default: stay visible until user toggles it off (more like Claude's home screen)
227
+ # - Optional: hide on first send via GEMCODE_TUI_HOME_HIDE_ON_SEND=1
228
+ show_home = {"value": True}
229
+ hide_home_on_send = os.environ.get("GEMCODE_TUI_HOME_HIDE_ON_SEND", "0").lower() in (
230
+ "1",
231
+ "true",
232
+ "yes",
233
+ "on",
234
+ )
235
+
236
+ def _uname() -> str:
237
+ for k in ("USER", "LOGNAME"):
238
+ v = (os.environ.get(k) or "").strip()
239
+ if v:
240
+ return v
241
+ return "there"
242
+
243
+ def _model_display() -> str:
244
+ m = getattr(cfg, "model", "") or ""
245
+ if not m:
246
+ return "GemCode"
247
+ return m.replace("gemini-", "Gemini ").replace("-", ".")
248
+
249
+ def _render_home_text():
250
+ # Recompute with current terminal width for a "dashboard" feel.
251
+ cols = 80
252
+ rows = 24
253
+ try:
254
+ cols = app.output.get_size().columns
255
+ rows = app.output.get_size().rows
256
+ except Exception:
257
+ pass
258
+ width = max(60, min(cols - 2, 120))
259
+ left_w = (width - 4) * 2 // 3
260
+ right_w = (width - 4) - left_w
261
+
262
+ def pad(s: str, w: int) -> str:
263
+ if len(s) > w:
264
+ return s[: max(0, w - 1)] + "…"
265
+ return s + (" " * (w - len(s)))
266
+
267
+ mid_title = "│" + pad(f" GemCode v{os.environ.get('GEMCODE_VERSION', '0.1.0')}", width - 2) + "│"
268
+
269
+ welcome = f"Welcome back {_uname()}!"
270
+ bot = [
271
+ f"{_model_display()} · Local session · {str(getattr(cfg, 'project_root', '') or '')}",
272
+ ]
273
+
274
+ # Tiny "gem" ASCII mark (kept simple for terminal portability).
275
+ art = [
276
+ " ▄▄▄▄ ",
277
+ " ▐█ █▌ ",
278
+ " ▐█ █▌ ",
279
+ " ▀▀▀▀ ",
280
+ ]
281
+ art_w = max(len(x) for x in art)
282
+
283
+ left_lines: list[str] = []
284
+ left_lines.append("")
285
+ left_lines.append(" " + welcome)
286
+ left_lines.append("")
287
+ # Center the art in left pane.
288
+ left_lines.append(" " * ((left_w - art_w) // 2) + art[0])
289
+ left_lines.append(" " * ((left_w - art_w) // 2) + art[1])
290
+ left_lines.append(" " * ((left_w - art_w) // 2) + art[2])
291
+ left_lines.append(" " * ((left_w - art_w) // 2) + art[3])
292
+ left_lines.append("")
293
+ for b in bot:
294
+ left_lines.append(" " + b)
295
+
296
+ tips = [
297
+ "Tips for getting started",
298
+ "Run /init to create a .gemcode config",
299
+ "Note: Use perm=ask to approve tools",
300
+ ]
301
+ activity = ["Recent activity", "No recent activity"]
302
+
303
+ right_lines: list[str] = []
304
+ right_lines.append("")
305
+ right_lines.extend([f" {tips[0]}", f" {tips[1]}", f" {tips[2]}"])
306
+ right_lines.append("")
307
+ right_lines.extend([f" {activity[0]}", f" {activity[1]}"])
308
+
309
+ # Normalize heights
310
+ h = max(len(left_lines), len(right_lines))
311
+ left_lines += [""] * (h - len(left_lines))
312
+ right_lines += [""] * (h - len(right_lines))
313
+
314
+ lines: list[str] = []
315
+ lines.append(mid_title)
316
+ lines.append("│" + (" " * (width - 2)) + "│")
317
+ for i in range(h):
318
+ l = pad(left_lines[i], left_w)
319
+ r = pad(right_lines[i], right_w)
320
+ lines.append("│ " + l + " │ " + r + " │")
321
+ lines.append("│" + (" " * (width - 2)) + "│")
322
+ lines.append("└" + ("─" * (width - 2)) + "┘")
323
+ lines.append("↑ GemCode Pro now supports larger contexts · faster streaming")
324
+ lines.append("")
325
+ lines.append(" ? for shortcuts".ljust(max(0, width - 12)) + "Ctrl+O home")
326
+
327
+ # Prevent overflow: clamp to available rows (leave space for header/input/status).
328
+ max_lines = max(6, min(len(lines), max(6, rows - 7)))
329
+ lines = lines[:max_lines]
330
+
331
+ # Return as formatted text with subtle coloring.
332
+ out = []
333
+ for ln in lines:
334
+ if "GemCode v" in ln:
335
+ out.append(("class:brand", ln + "\n"))
336
+ elif "Tips for getting started" in ln or "Recent activity" in ln:
337
+ out.append(("class:accent", ln + "\n"))
338
+ else:
339
+ out.append(("", ln + "\n"))
340
+ return out
341
+
342
+ home = ConditionalContainer(
343
+ content=Window(
344
+ # Allow the home dashboard to shrink on small terminals.
345
+ height=D(min=6, max=16, preferred=16),
346
+ dont_extend_height=True,
347
+ content=FormattedTextControl(_render_home_text),
348
+ ),
349
+ filter=Condition(lambda: bool(show_home["value"])),
350
+ )
351
+
352
+ kb = KeyBindings()
353
+
354
+ @kb.add("c-d")
355
+ def _exit(event) -> None:
356
+ event.app.exit()
357
+
358
+ @kb.add("escape")
359
+ def _interrupt(event) -> None:
360
+ # If awaiting permission, Esc denies (keeps UI stable).
361
+ fut = pending_confirm.get("future")
362
+ if fut is not None and hasattr(fut, "done") and not fut.done(): # type: ignore[attr-defined]
363
+ try:
364
+ fut.set_result(False) # type: ignore[union-attr]
365
+ except Exception:
366
+ pass
367
+ pending_confirm["future"] = None
368
+ try:
369
+ event.app.invalidate()
370
+ except Exception:
371
+ pass
372
+ return
373
+ interrupted["flag"] = True
374
+ append("\n[interrupt] (best-effort) cancelling current turn…\n")
375
+
376
+ # Note: do NOT bind y/n globally. Permission answers are typed into the
377
+ # input field (perm>) and submitted with Enter, Claude-style.
378
+ @kb.add("c-o")
379
+ def _toggle_home(event) -> None:
380
+ show_home["value"] = not show_home["value"]
381
+ try:
382
+ event.app.invalidate()
383
+ except Exception:
384
+ pass
385
+
386
+ @kb.add("c-j")
387
+ def _newline(event) -> None:
388
+ input_box.buffer.insert_text("\n")
389
+
390
+ def _scroll_output(lines: int) -> None:
391
+ """
392
+ Scroll the transcript pane without changing focus.
393
+ Positive = down, Negative = up.
394
+ """
395
+ try:
396
+ # In many terminals PgUp/PgDn never reaches the app, so we also bind
397
+ # Alt+Up/Down. Clamp to 0 to avoid weird negative scroll states.
398
+ output.window.vertical_scroll = max(0, output.window.vertical_scroll + int(lines))
399
+ except Exception:
400
+ pass
401
+ try:
402
+ app.invalidate()
403
+ except Exception:
404
+ pass
405
+
406
+ @kb.add("pageup")
407
+ def _page_up(event) -> None:
408
+ _scroll_output(-10)
409
+
410
+ @kb.add("pagedown")
411
+ def _page_down(event) -> None:
412
+ _scroll_output(10)
413
+
414
+ @kb.add("c-up")
415
+ def _scroll_up(event) -> None:
416
+ _scroll_output(-3)
417
+
418
+ @kb.add("c-down")
419
+ def _scroll_down(event) -> None:
420
+ _scroll_output(3)
421
+
422
+ # VS Code terminal reliably forwards these.
423
+ @kb.add("escape", "up")
424
+ def _alt_up(event) -> None:
425
+ _scroll_output(-3)
426
+
427
+ @kb.add("escape", "down")
428
+ def _alt_down(event) -> None:
429
+ _scroll_output(3)
430
+
431
+ @kb.add("escape", "pageup")
432
+ def _alt_page_up(event) -> None:
433
+ _scroll_output(-10)
434
+
435
+ @kb.add("escape", "pagedown")
436
+ def _alt_page_down(event) -> None:
437
+ _scroll_output(10)
438
+
439
+ async def _send_current() -> None:
440
+ prompt = (input_box.text or "").strip()
441
+ input_box.text = ""
442
+ input_box.buffer.cursor_position = 0
443
+ if not prompt:
444
+ return
445
+
446
+ # If a permission confirmation is pending, interpret user input as the answer
447
+ # (Claude-like: user types y/n in the main input line).
448
+ fut = pending_confirm.get("future")
449
+ if fut is not None and hasattr(fut, "done") and not fut.done(): # type: ignore[attr-defined]
450
+ ans = prompt.strip().lower()
451
+ ok = ans in ("y", "yes")
452
+ deny = ans in ("n", "no", "")
453
+ if ok or deny:
454
+ # Echo the user's permission answer so it doesn't feel like input vanished.
455
+ append(f"\nperm> {prompt}\n")
456
+ try:
457
+ fut.set_result(bool(ok)) # type: ignore[union-attr]
458
+ except Exception:
459
+ pass
460
+ pending_confirm["future"] = None
461
+ _set_input_prompt()
462
+ try:
463
+ app.invalidate()
464
+ except Exception:
465
+ pass
466
+ return
467
+ # If user typed something else, keep waiting; show a hint inline.
468
+ _box("permission", ["Please answer with y/yes or n/no."])
469
+ return
470
+
471
+ interrupted["flag"] = False
472
+ if hide_home_on_send:
473
+ show_home["value"] = False
474
+ append(f"\nYou: {prompt}\n")
475
+
476
+ apply_capability_routing(cfg, prompt, context="prompt")
477
+ cfg.model = pick_effective_model(cfg, prompt)
478
+
479
+ try:
480
+ REQUEST_CONFIRMATION_FC = "adk_request_confirmation"
481
+ # Terminal width for stable box rendering.
482
+ try:
483
+ cols = app.output.get_size().columns
484
+ except Exception:
485
+ cols = 80
486
+ box_inner = max(30, min(cols - 4, 100))
487
+
488
+ def _box(top_label: str, body_lines: list[str]) -> None:
489
+ inner = box_inner
490
+ label = f" {top_label} "
491
+ top = "┌" + label + ("─" * max(0, inner - len(label))) + "┐"
492
+ bot = "└" + ("─" * inner) + "┘"
493
+ append(top)
494
+ for ln in body_lines:
495
+ ln = (ln or "").replace("\n", " ")
496
+ if len(ln) > inner:
497
+ ln = ln[: max(0, inner - 1)] + "…"
498
+ append("│" + ln.ljust(inner) + "│")
499
+ append(bot)
500
+
501
+ def _get_confirmation_fcs(events: list) -> list[types.FunctionCall]:
502
+ out: list[types.FunctionCall] = []
503
+ for ev in events:
504
+ try:
505
+ for fc in ev.get_function_calls() or []:
506
+ if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC:
507
+ out.append(fc)
508
+ except Exception:
509
+ continue
510
+ return out
511
+
512
+ def _extract_tool_and_hint(fc: types.FunctionCall) -> tuple[str, str]:
513
+ tool_name = "unknown_tool"
514
+ hint = ""
515
+ try:
516
+ args = getattr(fc, "args", None) or {}
517
+ orig = args.get("originalFunctionCall") or {}
518
+ tool_name = orig.get("name") or tool_name
519
+ tc = args.get("toolConfirmation") or {}
520
+ hint = tc.get("hint") or ""
521
+ except Exception:
522
+ pass
523
+ return tool_name, hint
524
+
525
+ def _render_tool_calls(ev) -> None:
526
+ try:
527
+ fcs = ev.get_function_calls() or []
528
+ except Exception:
529
+ fcs = []
530
+ for fc in fcs:
531
+ name = getattr(fc, "name", "") or ""
532
+ if name == REQUEST_CONFIRMATION_FC:
533
+ continue
534
+ _box("tool", [name])
535
+
536
+ # Token-budget reset matches invoke.run_turn behavior.
537
+ state_delta = None
538
+ if getattr(cfg, "token_budget", None):
539
+ from gemcode.config import token_budget_invocation_reset
540
+
541
+ state_delta = token_budget_invocation_reset()
542
+
543
+ run_config = (
544
+ RunConfig(max_llm_calls=cfg.max_llm_calls)
545
+ if getattr(cfg, "max_llm_calls", None) is not None
546
+ else None
547
+ )
548
+
549
+ current_message = types.Content(role="user", parts=[types.Part(text=prompt)])
550
+ do_reset = True
551
+
552
+ assistant_started = False
553
+
554
+ while True:
555
+ # Stream events from ADK runner.
556
+ events: list = []
557
+ # Buffer assistant text for this pass. If a confirmation is requested,
558
+ # we discard buffered text to avoid the noisy "rerun with --yes" spiel.
559
+ buffered: list[str] = []
560
+ kwargs = dict(user_id="local", session_id=session_id, new_message=current_message)
561
+ if run_config is not None:
562
+ kwargs["run_config"] = run_config
563
+ if do_reset and state_delta is not None:
564
+ kwargs["state_delta"] = state_delta
565
+
566
+ async for ev in runner.run_async(**kwargs):
567
+ events.append(ev)
568
+ if interrupted["flag"]:
569
+ # Best-effort: stop rendering more output; runner may still finish in background.
570
+ continue
571
+
572
+ _render_tool_calls(ev)
573
+
574
+ # Stream assistant text deltas as they arrive.
575
+ try:
576
+ if not ev.content or not ev.content.parts:
577
+ continue
578
+ if not getattr(ev, "author", None) or ev.author == "user":
579
+ continue
580
+ for part in ev.content.parts:
581
+ delta = getattr(part, "text", None)
582
+ if not delta:
583
+ continue
584
+ assistant_started = True
585
+ buffered.append(delta)
586
+ except Exception:
587
+ continue
588
+
589
+ if interrupted["flag"]:
590
+ append("\n[interrupt] Turn interrupted (best-effort).\n")
591
+ return
592
+
593
+ # Handle in-TUI tool confirmations (HITL) Claude-style.
594
+ confirmation_fcs = _get_confirmation_fcs(events)
595
+ if not confirmation_fcs:
596
+ # Now that we know no confirmation is needed, render buffered text.
597
+ if buffered:
598
+ append_inline("GemCode: ")
599
+ await typewrite("".join(buffered))
600
+ break
601
+
602
+ interactive_enabled = bool(getattr(cfg, "interactive_permission_ask", False))
603
+ parts: list[types.Part] = []
604
+ for fc in confirmation_fcs:
605
+ tool_name, hint = _extract_tool_and_hint(fc)
606
+ if interactive_enabled:
607
+ msg = f"Approve tool call '{tool_name}'?"
608
+ if hint:
609
+ msg += f"\n\nHint:\n{hint}"
610
+ # Also echo a compact card in the transcript for clarity.
611
+ _box("permission", [f"Approve: {tool_name}", (hint or "").strip()])
612
+ fut = asyncio.get_running_loop().create_future()
613
+ pending_confirm["future"] = fut
614
+ pending_confirm["tool"] = tool_name
615
+ pending_confirm["hint"] = hint
616
+ _set_input_prompt()
617
+ try:
618
+ app.invalidate()
619
+ except Exception:
620
+ pass
621
+ ok = bool(await fut)
622
+ _set_input_prompt()
623
+ else:
624
+ ok = False
625
+ _box(
626
+ "permission",
627
+ [
628
+ f"Blocked: {tool_name}",
629
+ "Permission mode is not 'ask' (use --interactive-ask or choose perm=ask).",
630
+ ],
631
+ )
632
+
633
+ parts.append(
634
+ types.Part(
635
+ function_response=types.FunctionResponse(
636
+ name=REQUEST_CONFIRMATION_FC,
637
+ id=getattr(fc, "id", None),
638
+ response={"confirmed": bool(ok)},
639
+ )
640
+ )
641
+ )
642
+
643
+ current_message = types.Content(role="user", parts=parts)
644
+ do_reset = False
645
+
646
+ if not assistant_started:
647
+ append_inline("(no text output)")
648
+ append("") # newline after assistant turn
649
+ except Exception as e:
650
+ append(f"GemCode: error: {e}\n")
651
+
652
+ @kb.add("enter")
653
+ def _enter(event) -> None:
654
+ # Enter always sends (Claude-like). Use Ctrl+J for newlines.
655
+ event.app.create_background_task(_send_current())
656
+
657
+ root_container = HSplit(
658
+ [
659
+ header,
660
+ Window(height=1, char="-", style="class:sep"),
661
+ home,
662
+ output,
663
+ Window(height=1, char="-", style="class:sep"),
664
+ input_help,
665
+ Frame(
666
+ input_box,
667
+ title=lambda: " Input (permission)" if pending_confirm.get("future") is not None else " Input ",
668
+ style="class:inputframe",
669
+ ),
670
+ status,
671
+ ]
672
+ )
673
+
674
+ style = Style.from_dict(
675
+ {
676
+ "brand": "bold #60a5fa",
677
+ "accent": "bold #3b82f6",
678
+ "muted": "#6b7280",
679
+ "sep": "#1f2937",
680
+ "pill": "bold #93c5fd",
681
+ "inputframe": "bg:#071426 #e5e7eb",
682
+ }
683
+ )
684
+
685
+ app = Application(
686
+ layout=Layout(root_container, focused_element=input_box),
687
+ key_bindings=kb,
688
+ style=style,
689
+ full_screen=True,
690
+ mouse_support=True,
691
+ # Keep repainting (Ink-like). Prevents input frame artifacts mid-tool-run.
692
+ refresh_interval=0.05,
693
+ )
694
+
695
+ append("GemCode TUI ready. Type your prompt and press Enter.\n")
696
+ await app.run_async()
697
+