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.
- gemcode/__init__.py +3 -0
- gemcode/__main__.py +3 -0
- gemcode/agent.py +146 -0
- gemcode/audit.py +16 -0
- gemcode/callbacks.py +473 -0
- gemcode/capability_routing.py +137 -0
- gemcode/cli.py +658 -0
- gemcode/compaction.py +35 -0
- gemcode/computer_use/__init__.py +0 -0
- gemcode/computer_use/browser_computer.py +275 -0
- gemcode/config.py +247 -0
- gemcode/interactions.py +15 -0
- gemcode/invoke.py +151 -0
- gemcode/kairos_daemon.py +221 -0
- gemcode/limits.py +83 -0
- gemcode/live_audio_engine.py +124 -0
- gemcode/mcp_loader.py +57 -0
- gemcode/memory/__init__.py +0 -0
- gemcode/memory/embedding_memory_service.py +292 -0
- gemcode/memory/file_memory_service.py +176 -0
- gemcode/modality_tools.py +216 -0
- gemcode/model_routing.py +179 -0
- gemcode/paths.py +29 -0
- gemcode/permissions.py +5 -0
- gemcode/plugins/__init__.py +0 -0
- gemcode/plugins/terminal_hooks_plugin.py +168 -0
- gemcode/plugins/tool_recovery_plugin.py +135 -0
- gemcode/prompt_suggestions.py +80 -0
- gemcode/query/__init__.py +36 -0
- gemcode/query/config.py +35 -0
- gemcode/query/deps.py +20 -0
- gemcode/query/engine.py +55 -0
- gemcode/query/stop_hooks.py +63 -0
- gemcode/query/token_budget.py +109 -0
- gemcode/query/transitions.py +41 -0
- gemcode/session_runtime.py +81 -0
- gemcode/thinking.py +136 -0
- gemcode/tool_prompt_manifest.py +118 -0
- gemcode/tool_registry.py +50 -0
- gemcode/tools/__init__.py +25 -0
- gemcode/tools/edit.py +53 -0
- gemcode/tools/filesystem.py +73 -0
- gemcode/tools/search.py +85 -0
- gemcode/tools/shell.py +73 -0
- gemcode/tools_inspector.py +132 -0
- gemcode/trust.py +54 -0
- gemcode/tui/app.py +697 -0
- gemcode/tui/scrollback.py +312 -0
- gemcode/vertex.py +22 -0
- gemcode/web/__init__.py +2 -0
- gemcode/web/claude_sse_adapter.py +282 -0
- gemcode/web/terminal_repl.py +147 -0
- gemcode-0.2.2.dist-info/METADATA +440 -0
- gemcode-0.2.2.dist-info/RECORD +58 -0
- gemcode-0.2.2.dist-info/WHEEL +5 -0
- gemcode-0.2.2.dist-info/entry_points.txt +2 -0
- gemcode-0.2.2.dist-info/licenses/LICENSE +151 -0
- 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
|
+
|