gemcode 0.3.19__tar.gz → 0.3.21__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.
Files changed (112) hide show
  1. {gemcode-0.3.19/src/gemcode.egg-info → gemcode-0.3.21}/PKG-INFO +1 -1
  2. {gemcode-0.3.19 → gemcode-0.3.21}/pyproject.toml +1 -1
  3. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/repl_commands.py +5 -0
  4. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/repl_slash.py +90 -0
  5. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/thinking.py +21 -11
  6. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tui/input_handler.py +1 -0
  7. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tui/scrollback.py +111 -51
  8. {gemcode-0.3.19 → gemcode-0.3.21/src/gemcode.egg-info}/PKG-INFO +1 -1
  9. {gemcode-0.3.19 → gemcode-0.3.21}/LICENSE +0 -0
  10. {gemcode-0.3.19 → gemcode-0.3.21}/MANIFEST.in +0 -0
  11. {gemcode-0.3.19 → gemcode-0.3.21}/README.md +0 -0
  12. {gemcode-0.3.19 → gemcode-0.3.21}/setup.cfg +0 -0
  13. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/__init__.py +0 -0
  14. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/__main__.py +0 -0
  15. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/agent.py +0 -0
  16. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/audit.py +0 -0
  17. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/autocompact.py +0 -0
  18. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/callbacks.py +0 -0
  19. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/capability_routing.py +0 -0
  20. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/cli.py +0 -0
  21. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/compaction.py +0 -0
  22. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/computer_use/__init__.py +0 -0
  23. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/computer_use/browser_computer.py +0 -0
  24. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/config.py +0 -0
  25. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/context_budget.py +0 -0
  26. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/context_warning.py +0 -0
  27. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/credentials.py +0 -0
  28. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/hitl_session.py +0 -0
  29. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/interactions.py +0 -0
  30. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/invoke.py +0 -0
  31. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/kairos_daemon.py +0 -0
  32. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/limits.py +0 -0
  33. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/live_audio_engine.py +0 -0
  34. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/logging_config.py +0 -0
  35. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/mcp_loader.py +0 -0
  36. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/memory/__init__.py +0 -0
  37. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/memory/embedding_memory_service.py +0 -0
  38. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/memory/file_memory_service.py +0 -0
  39. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/modality_tools.py +0 -0
  40. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/model_errors.py +0 -0
  41. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/model_routing.py +0 -0
  42. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/paths.py +0 -0
  43. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/permissions.py +0 -0
  44. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/plugins/__init__.py +0 -0
  45. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  46. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  47. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/prompt_suggestions.py +0 -0
  48. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/query/__init__.py +0 -0
  49. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/query/config.py +0 -0
  50. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/query/deps.py +0 -0
  51. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/query/engine.py +0 -0
  52. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/query/stop_hooks.py +0 -0
  53. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/query/token_budget.py +0 -0
  54. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/query/transitions.py +0 -0
  55. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/session_runtime.py +0 -0
  56. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/slash_commands.py +0 -0
  57. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tool_prompt_manifest.py +0 -0
  58. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tool_registry.py +0 -0
  59. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/__init__.py +0 -0
  60. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/bash.py +0 -0
  61. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/edit.py +0 -0
  62. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/filesystem.py +0 -0
  63. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/search.py +0 -0
  64. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/shell.py +0 -0
  65. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/shell_gate.py +0 -0
  66. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/subtask.py +0 -0
  67. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/think.py +0 -0
  68. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/todo.py +0 -0
  69. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools/web.py +0 -0
  70. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tools_inspector.py +0 -0
  71. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/trust.py +0 -0
  72. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tui/spinner.py +0 -0
  73. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tui/welcome_banner.py +0 -0
  74. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/tui/welcome_rich.py +0 -0
  75. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/version.py +0 -0
  76. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/vertex.py +0 -0
  77. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/web/__init__.py +0 -0
  78. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/web/claude_sse_adapter.py +0 -0
  79. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/web/terminal_repl.py +0 -0
  80. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode/workspace_hints.py +0 -0
  81. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode.egg-info/SOURCES.txt +0 -0
  82. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode.egg-info/dependency_links.txt +0 -0
  83. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode.egg-info/entry_points.txt +0 -0
  84. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode.egg-info/requires.txt +0 -0
  85. {gemcode-0.3.19 → gemcode-0.3.21}/src/gemcode.egg-info/top_level.txt +0 -0
  86. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_agent_instruction.py +0 -0
  87. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_autocompact.py +0 -0
  88. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_capability_routing.py +0 -0
  89. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_claude_web_adapter_sse.py +0 -0
  90. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_cli_init.py +0 -0
  91. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_computer_use_permissions.py +0 -0
  92. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_context_budget.py +0 -0
  93. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_context_warning.py +0 -0
  94. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_credentials.py +0 -0
  95. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_interactive_permission_ask.py +0 -0
  96. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_kairos_scheduler.py +0 -0
  97. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_modality_tools.py +0 -0
  98. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_model_error_retry.py +0 -0
  99. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_model_errors.py +0 -0
  100. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_model_routing.py +0 -0
  101. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_paths.py +0 -0
  102. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_permissions.py +0 -0
  103. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_prompt_suggestions.py +0 -0
  104. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_repl_commands.py +0 -0
  105. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_repl_slash.py +0 -0
  106. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_slash_commands.py +0 -0
  107. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_thinking_config.py +0 -0
  108. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_token_budget.py +0 -0
  109. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_tool_context_circulation.py +0 -0
  110. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_tools.py +0 -0
  111. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_tools_inspector.py +0 -0
  112. {gemcode-0.3.19 → gemcode-0.3.21}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.19
3
+ Version: 0.3.21
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.3.19"
7
+ version = "0.3.21"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -176,6 +176,11 @@ def slash_help_lines() -> list[str]:
176
176
  " /model Show model routing info",
177
177
  " /model use <id> Override model for this REPL session",
178
178
  " /model list List available Gemini model IDs",
179
+ " /thinking Show current thinking config",
180
+ " /thinking off Disable model thinking",
181
+ " /thinking on Re-enable thinking (auto budget/level)",
182
+ " /thinking budget <N> Set thinking token budget (Gemini 2.5, 0=off, -1=dynamic)",
183
+ " /thinking level <L> Set thinking level: minimal|low|medium|high (Gemini 3.x)",
179
184
  " /permissions Show permission / HITL settings",
180
185
  " /memory Show persistent memory settings",
181
186
  " /hooks Show post-turn hook configuration",
@@ -39,6 +39,7 @@ class ReplSlashResult:
39
39
  new_session_id: str | None = None
40
40
  skip_model_turn: bool = False
41
41
  model_prompt: str | None = None
42
+ force_rebuild_runner: bool = False # True when agent config changed (thinking, etc.)
42
43
 
43
44
 
44
45
  def _parse_tail_n(args: str, *, default: int = 40) -> int:
@@ -270,6 +271,95 @@ async def process_repl_slash(
270
271
  if name in ("exit", "quit"):
271
272
  return ReplSlashResult(exit_repl=True)
272
273
 
274
+ if name == "thinking":
275
+ model_id = getattr(cfg, "model", "") or ""
276
+ is_25 = "2.5" in model_id
277
+ args = (sc.args or "").strip()
278
+
279
+ if not args:
280
+ # Show current thinking config.
281
+ disable = bool(getattr(cfg, "disable_thinking", False))
282
+ level = getattr(cfg, "thinking_level", None)
283
+ budget = getattr(cfg, "thinking_budget", None)
284
+ out("Thinking config:")
285
+ out(f" model: {model_id or '(default)'}")
286
+ out(f" disable_thinking: {disable}")
287
+ if is_25:
288
+ out(f" thinking_budget: {budget if budget is not None else '(auto / dynamic)'}")
289
+ out()
290
+ out("Gemini 2.5 commands:")
291
+ out(" /thinking off — disable thinking")
292
+ out(" /thinking on — re-enable with auto budget")
293
+ out(" /thinking budget <0-24576> — set exact token budget (0 = off)")
294
+ else:
295
+ out(f" thinking_level: {level if level is not None else '(auto)'}")
296
+ out()
297
+ out("Gemini 3.x commands:")
298
+ out(" /thinking off — use minimal thinking")
299
+ out(" /thinking on — re-enable auto level")
300
+ out(" /thinking level <minimal|low|medium|high>")
301
+ out()
302
+ return ReplSlashResult(skip_model_turn=True)
303
+
304
+ parts = args.split()
305
+ sub = parts[0].lower()
306
+
307
+ if sub == "off":
308
+ setattr(cfg, "disable_thinking", True)
309
+ out("thinking: disabled (runner will rebuild on next turn)")
310
+ out()
311
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
312
+
313
+ if sub in ("on", "auto"):
314
+ setattr(cfg, "disable_thinking", False)
315
+ setattr(cfg, "thinking_level", None)
316
+ setattr(cfg, "thinking_budget", None)
317
+ out("thinking: auto (runner will rebuild on next turn)")
318
+ out()
319
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
320
+
321
+ if sub == "budget":
322
+ if len(parts) < 2:
323
+ out("Usage: /thinking budget <N> e.g. /thinking budget 8192")
324
+ out()
325
+ return ReplSlashResult(skip_model_turn=True)
326
+ try:
327
+ budget = int(parts[1])
328
+ except ValueError:
329
+ out(f"Invalid budget '{parts[1]}' — must be an integer (0–24576, or -1 for dynamic)")
330
+ out()
331
+ return ReplSlashResult(skip_model_turn=True)
332
+ setattr(cfg, "thinking_budget", budget)
333
+ setattr(cfg, "disable_thinking", False)
334
+ out(f"thinking: budget={budget} tokens (runner will rebuild on next turn)")
335
+ out()
336
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
337
+
338
+ if sub == "level":
339
+ if len(parts) < 2:
340
+ out("Usage: /thinking level <minimal|low|medium|high>")
341
+ out()
342
+ return ReplSlashResult(skip_model_turn=True)
343
+ level = parts[1].lower()
344
+ valid = ("minimal", "low", "medium", "high")
345
+ if level not in valid:
346
+ out(f"Unknown level '{level}'. Choose from: {', '.join(valid)}")
347
+ out()
348
+ return ReplSlashResult(skip_model_turn=True)
349
+ setattr(cfg, "thinking_level", level)
350
+ setattr(cfg, "disable_thinking", False)
351
+ out(f"thinking: level={level} (runner will rebuild on next turn)")
352
+ out()
353
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
354
+
355
+ out(f"Unknown /thinking subcommand: {sub}")
356
+ if is_25:
357
+ out("Usage: /thinking [off | on | budget <N>]")
358
+ else:
359
+ out("Usage: /thinking [off | on | level <minimal|low|medium|high>]")
360
+ out()
361
+ return ReplSlashResult(skip_model_turn=True)
362
+
273
363
  out(f"Unknown command: /{sc.command_name}")
274
364
  out("Try /help")
275
365
  out()
@@ -25,6 +25,13 @@ def _is_gemini_3_pro(model_id: str) -> bool:
25
25
  return "3.1-pro" in (model_id or "") or "3-pro" in (model_id or "")
26
26
 
27
27
 
28
+ def _supports_thinking(model_id: str) -> bool:
29
+ """True for Gemini 2.5+ and Gemini 3.x which return thought tokens."""
30
+ m = model_id or ""
31
+ return "2.5" in m or "2.0-flash-thinking" in m or "gemini-3" in m or \
32
+ "-3." in m or "-3-" in m
33
+
34
+
28
35
  def build_thinking_config(cfg: GemCodeConfig) -> Optional[types.ThinkingConfig]:
29
36
  """
30
37
  Returns a `types.ThinkingConfig` to pass to ADK's LlmAgent.generate_content_config.
@@ -96,7 +103,11 @@ def build_thinking_config(cfg: GemCodeConfig) -> Optional[types.ThinkingConfig]:
96
103
  # Otherwise: Claude-like auto mapping based on model_mode.
97
104
  mode = (getattr(cfg, "model_mode", "auto") or "auto").lower()
98
105
  if mode == "auto":
99
- # Let Gemini choose its dynamic/adaptive thinking.
106
+ # Use Gemini's adaptive thinking budget but request thought tokens in the
107
+ # response so the TUI can display them. Models that don't support thinking
108
+ # ignore include_thoughts gracefully; for others we fall back to None.
109
+ if _supports_thinking(model_id):
110
+ return types.ThinkingConfig(include_thoughts=True)
100
111
  return None
101
112
 
102
113
  if not is_25:
@@ -104,33 +115,32 @@ def build_thinking_config(cfg: GemCodeConfig) -> Optional[types.ThinkingConfig]:
104
115
  if mode == "fast":
105
116
  return types.ThinkingConfig(
106
117
  thinking_level="low" if _is_gemini_3_pro(model_id) else "minimal",
107
- include_thoughts=None,
118
+ include_thoughts=True,
108
119
  )
109
120
  if mode == "balanced":
110
121
  return types.ThinkingConfig(
111
122
  thinking_level="medium",
112
- include_thoughts=None,
123
+ include_thoughts=True,
113
124
  )
114
125
  # quality
115
126
  return types.ThinkingConfig(
116
127
  thinking_level="high",
117
- include_thoughts=None,
128
+ include_thoughts=True,
118
129
  )
119
130
 
120
131
  # Gemini 2.5 thinkingBudget mapping.
121
132
  if mode == "fast":
122
133
  if "2.5-pro" in model_id:
123
- return types.ThinkingConfig(thinking_budget=512, include_thoughts=None)
134
+ return types.ThinkingConfig(thinking_budget=512, include_thoughts=True)
124
135
  # flash/flash-preview/flash-lite
125
136
  return types.ThinkingConfig(
126
- thinking_budget=0 if "flash-lite" in model_id else 0, include_thoughts=None
137
+ thinking_budget=0 if "flash-lite" in model_id else 1024, include_thoughts=True
127
138
  )
128
139
  if mode == "balanced":
129
140
  if "2.5-pro" in model_id:
130
- return types.ThinkingConfig(thinking_budget=4096, include_thoughts=None)
131
- return types.ThinkingConfig(thinking_budget=1024, include_thoughts=None)
141
+ return types.ThinkingConfig(thinking_budget=4096, include_thoughts=True)
142
+ return types.ThinkingConfig(thinking_budget=2048, include_thoughts=True)
132
143
 
133
- # quality
134
- # For 2.5 Pro this remains dynamic because disable isn't supported.
135
- return types.ThinkingConfig(thinking_budget=-1, include_thoughts=None)
144
+ # quality — dynamic budget (model decides how much to think)
145
+ return types.ThinkingConfig(thinking_budget=-1, include_thoughts=True)
136
146
 
@@ -49,6 +49,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
49
49
  ("compact", "Summarise conversation history to reclaim context space"),
50
50
  ("config", "Show active configuration and environment variables"),
51
51
  ("permissions", "Show current permission mode (default / strict / yes)"),
52
+ ("thinking", "View or change thinking config · /thinking level <low|medium|high> · /thinking budget <N>"),
52
53
  ("audit", "Show recent audit log · /audit [N lines]"),
53
54
  ("memory", "Show memory / storage settings"),
54
55
  ("doctor", "Run diagnostics and validate the environment"),
@@ -9,6 +9,8 @@ from dataclasses import dataclass
9
9
  from google.adk.agents.run_config import RunConfig
10
10
  from google.genai import types
11
11
  from rich.console import Console
12
+ from rich.markdown import Markdown as _RichMarkdown
13
+ from rich.padding import Padding as _RichPadding
12
14
 
13
15
  from gemcode.capability_routing import apply_capability_routing
14
16
  from gemcode.config import load_cli_environment
@@ -236,22 +238,62 @@ async def run_gemcode_scrollback_tui(
236
238
  pass
237
239
  return tool_name, hint
238
240
 
239
- # ── Live status indicator ─────────────────────────────────────────────────
240
- # spinner_active[0] = True while "⟳ Working..." is on the current line.
241
- # _clear_spinner() uses \r + ANSI erase to overwrite it before real output.
242
- spinner_active = [False]
241
+ # ── Live animated status indicator ───────────────────────────────────────
242
+ # An asyncio.Task drives a braille spinner that rewrites the current line
243
+ # every 150 ms with the elapsed time. Because the spinner runs concurrently
244
+ # with `async for ev in runner.run_async(...)`, it updates while we await
245
+ # the next event — giving the user live timing feedback at every phase:
246
+ # "Thinking…" → model generating first response
247
+ # "Running…" → tool executing (can be 30–120 s for npx / cargo / pytest)
248
+ # "Querying…" → model re-querying after tool results
249
+ _anim_task: list = [None] # asyncio.Task | None
250
+ _anim_active: list = [False] # True while spinner text is on stdout
251
+
252
+ async def _spinner_loop(msg: str) -> None:
253
+ frames = "⣾⣽⣻⢿⡿⣟⣯⣷"
254
+ t0 = asyncio.get_running_loop().time()
255
+ i = 0
256
+ try:
257
+ while True:
258
+ elapsed = asyncio.get_running_loop().time() - t0
259
+ frame = frames[i % len(frames)]
260
+ sys.stdout.write(
261
+ f"\r {ansi.dim}{frame} {msg} ({elapsed:.0f}s){ansi.reset} "
262
+ )
263
+ sys.stdout.flush()
264
+ i += 1
265
+ await asyncio.sleep(0.15)
266
+ except asyncio.CancelledError:
267
+ pass # _stop_anim() handles clearing the line
268
+
269
+ def _start_anim(msg: str) -> None:
270
+ """Start (or restart) the spinner with a new status message."""
271
+ _stop_anim()
272
+ if not ansi.enabled:
273
+ return
274
+ try:
275
+ loop = asyncio.get_event_loop()
276
+ if loop.is_running():
277
+ _anim_task[0] = loop.create_task(_spinner_loop(msg))
278
+ _anim_active[0] = True
279
+ except Exception:
280
+ pass
243
281
 
244
- def _clear_spinner() -> None:
245
- if spinner_active[0]:
282
+ def _stop_anim() -> None:
283
+ """Cancel the spinner task and erase its line from stdout."""
284
+ task = _anim_task[0]
285
+ if task is not None:
286
+ task.cancel()
287
+ _anim_task[0] = None
288
+ if _anim_active[0]:
246
289
  sys.stdout.write("\r\033[K")
247
290
  sys.stdout.flush()
248
- spinner_active[0] = False
291
+ _anim_active[0] = False
249
292
 
250
293
  def _fmt_tool_result(resp: object) -> str:
251
294
  """One-line summary of a tool execution result."""
252
295
  try:
253
296
  d = resp if isinstance(resp, dict) else {}
254
- # ADK may wrap the return value; try both the raw dict and a nested "result" key.
255
297
  inner = d.get("result", d)
256
298
  if not isinstance(inner, dict):
257
299
  inner = d
@@ -280,7 +322,7 @@ async def run_gemcode_scrollback_tui(
280
322
  return ""
281
323
 
282
324
  def _render_tool_results(ev) -> None:
283
- """Print a brief one-line summary when a tool finishes executing."""
325
+ """Show a one-line result summary; transition spinner to 'Querying…'."""
284
326
  try:
285
327
  frs: list = []
286
328
  try:
@@ -292,28 +334,38 @@ async def run_gemcode_scrollback_tui(
292
334
  fr = getattr(part, "function_response", None)
293
335
  if fr is not None:
294
336
  frs.append(fr)
295
- for fr in frs:
296
- name = getattr(fr, "name", "") or ""
297
- if name == _ADK_REQUEST_CONFIRMATION:
298
- continue
299
- _clear_spinner()
337
+ real_frs = [
338
+ fr for fr in frs
339
+ if getattr(fr, "name", "") not in ("", _ADK_REQUEST_CONFIRMATION)
340
+ ]
341
+ if not real_frs:
342
+ return # nothing to show; keep spinner running
343
+ _stop_anim() # clear "Running…" before printing results
344
+ for fr in real_frs:
300
345
  resp = getattr(fr, "response", {}) or {}
301
346
  summary = _fmt_tool_result(resp)
302
347
  if summary:
303
348
  print(f" ⎿ {ansi.dim}\u21b3 {summary}{ansi.reset}")
349
+ # Restart spinner while model re-queries with the tool outputs.
350
+ _start_anim("Querying\u2026")
304
351
  except Exception:
305
352
  pass
306
353
 
307
354
  def _render_tool_calls(ev) -> None:
308
- _clear_spinner()
355
+ """Print tool-call lines; transition spinner to 'Running…'."""
309
356
  try:
310
357
  fcs = ev.get_function_calls() or []
311
358
  except Exception:
312
359
  fcs = []
313
- for fc in fcs:
360
+ real_fcs = [
361
+ fc for fc in fcs
362
+ if getattr(fc, "name", "") not in ("", _ADK_REQUEST_CONFIRMATION)
363
+ ]
364
+ if not real_fcs:
365
+ return # no visible tool calls; leave spinner as-is
366
+ _stop_anim() # clear "Thinking…" or "Querying…"
367
+ for fc in real_fcs:
314
368
  name = getattr(fc, "name", "") or ""
315
- if name == _ADK_REQUEST_CONFIRMATION:
316
- continue
317
369
  extra = format_tool_call_extras(fc)
318
370
  if extra:
319
371
  print(
@@ -322,6 +374,7 @@ async def run_gemcode_scrollback_tui(
322
374
  )
323
375
  else:
324
376
  print(f" ⎿ {ansi.blue_tool}[tool]{ansi.reset} {ansi.bold}{name}{ansi.reset}")
377
+ _start_anim("Running\u2026") # spinner while tool actually executes
325
378
 
326
379
  run_config = (
327
380
  RunConfig(max_llm_calls=cfg.max_llm_calls)
@@ -359,11 +412,16 @@ async def run_gemcode_scrollback_tui(
359
412
  current_session_id = slash.new_session_id
360
413
  _current_session_id_holder[0] = current_session_id
361
414
  if slash.skip_model_turn:
362
- # Runner binds the model at creation time (LlmAgent(model=...)),
363
- # so rebuild it when the user overrides the model mid-session.
415
+ # Runner holds the LlmAgent which bakes in model + thinking config at
416
+ # construction time. Rebuild whenever the model or thinking changes.
364
417
  new_model = getattr(cfg, "model", "")
365
418
  new_model_overridden = bool(getattr(cfg, "model_overridden", False))
366
- if new_model != old_model or new_model_overridden != old_model_overridden:
419
+ needs_rebuild = (
420
+ new_model != old_model
421
+ or new_model_overridden != old_model_overridden
422
+ or slash.force_rebuild_runner
423
+ )
424
+ if needs_rebuild:
367
425
  try:
368
426
  close_fn = getattr(runner, "close", None)
369
427
  if close_fn:
@@ -400,13 +458,10 @@ async def run_gemcode_scrollback_tui(
400
458
  kwargs["run_config"] = run_config
401
459
  # (We don't handle token budget reset here; full-screen TUI does.)
402
460
 
403
- # Show a live "Working…" indicator so the user never wonders if the
404
- # agent froze. _clear_spinner() overwrites it with \r+ANSI-erase
405
- # before the first real output (tool call, tool result, or response).
406
- if ansi.enabled:
407
- spinner_active[0] = True
408
- sys.stdout.write(f" {ansi.dim}\u27f3 Working\u2026{ansi.reset}")
409
- sys.stdout.flush()
461
+ # Animated spinner starts immediately so the user always knows the
462
+ # agent is active. It transitions: Thinking… Running… → Querying…
463
+ # as different phases of the turn complete.
464
+ _start_anim("Thinking\u2026")
410
465
 
411
466
  async for ev in runner.run_async(**kwargs):
412
467
  events.append(ev)
@@ -429,7 +484,7 @@ async def run_gemcode_scrollback_tui(
429
484
  except Exception:
430
485
  continue
431
486
 
432
- _clear_spinner() # ensure "Working…" is gone before any response line
487
+ _stop_anim() # ensure spinner is gone before printing the response
433
488
 
434
489
  if not assistant_wrote_text and _events_had_non_confirmation_tools(events):
435
490
  await typewrite(
@@ -439,28 +494,33 @@ async def run_gemcode_scrollback_tui(
439
494
 
440
495
  confirmation_fcs = _get_confirmation_fcs(events)
441
496
  if not confirmation_fcs:
442
- # Render buffered thinking and final response separately.
443
- thought_text = "".join(buffered_thought)
444
- final_text = "".join(buffered_final)
445
- if buffered_thought:
446
- if buffered_final and _normalize_ws(thought_text) == _normalize_ws(final_text):
447
- print(
448
- f" ⎿ {ansi.dim}{ansi.bold}\u2234 Thinking{ansi.reset}: "
449
- f"{ansi.reset}(omitted: identical to final response)"
450
- )
451
- print("")
452
- else:
453
- sys.stdout.write(
454
- f" ⎿ {ansi.dim}{ansi.bold}\u2234 Thinking{ansi.reset}: "
455
- )
456
- sys.stdout.flush()
457
- await typewrite(thought_text)
458
- sys.stdout.write("\n")
459
- sys.stdout.flush()
460
- if buffered_final:
461
- sys.stdout.write(f" ⎿ {ansi.bold}GemCode{ansi.reset}: ")
462
- sys.stdout.flush()
463
- await typewrite(final_text)
497
+ thought_text = "".join(buffered_thought).strip()
498
+ final_text = "".join(buffered_final).strip()
499
+
500
+ # ── Thinking indicator (collapsed, never full dump) ───────────────
501
+ # Show only a brief one-line excerpt so the user knows thinking
502
+ # happened without drowning in the raw internal monologue.
503
+ if thought_text and not (
504
+ buffered_final and _normalize_ws(thought_text) == _normalize_ws(final_text)
505
+ ):
506
+ excerpt = thought_text.replace("\n", " ").strip()
507
+ if len(excerpt) > 100:
508
+ excerpt = excerpt[:97] + "\u2026"
509
+ print(
510
+ f" \u23bf {ansi.dim}\u2234 Thinking{ansi.reset}"
511
+ f"{ansi.dim} {excerpt}{ansi.reset}"
512
+ )
513
+ print("")
514
+
515
+ # ── Response — rendered as Rich Markdown ──────────────────────────
516
+ # Pipes the text through Rich's Markdown renderer so **bold**,
517
+ # *italic*, `code`, bullet lists and fenced code blocks all display
518
+ # correctly instead of showing raw asterisks and backticks.
519
+ if final_text:
520
+ print(f" \u23bf {ansi.bold}GemCode{ansi.reset}:")
521
+ console.print(
522
+ _RichPadding(_RichMarkdown(final_text), (0, 0, 0, 4)),
523
+ )
464
524
  break
465
525
 
466
526
  interactive_enabled = bool(getattr(cfg, "interactive_permission_ask", False))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.19
3
+ Version: 0.3.21
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes