gemcode 0.3.11__tar.gz → 0.3.12__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 (107) hide show
  1. {gemcode-0.3.11/src/gemcode.egg-info → gemcode-0.3.12}/PKG-INFO +1 -1
  2. {gemcode-0.3.11 → gemcode-0.3.12}/pyproject.toml +1 -1
  3. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/cli.py +7 -1
  4. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tui/app.py +83 -42
  5. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tui/scrollback.py +18 -12
  6. gemcode-0.3.12/src/gemcode/tui/spinner.py +150 -0
  7. gemcode-0.3.12/src/gemcode/tui/startup_screen.py +196 -0
  8. {gemcode-0.3.11 → gemcode-0.3.12/src/gemcode.egg-info}/PKG-INFO +1 -1
  9. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode.egg-info/SOURCES.txt +2 -0
  10. {gemcode-0.3.11 → gemcode-0.3.12}/LICENSE +0 -0
  11. {gemcode-0.3.11 → gemcode-0.3.12}/MANIFEST.in +0 -0
  12. {gemcode-0.3.11 → gemcode-0.3.12}/README.md +0 -0
  13. {gemcode-0.3.11 → gemcode-0.3.12}/setup.cfg +0 -0
  14. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/__init__.py +0 -0
  15. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/__main__.py +0 -0
  16. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/agent.py +0 -0
  17. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/audit.py +0 -0
  18. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/autocompact.py +0 -0
  19. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/callbacks.py +0 -0
  20. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/capability_routing.py +0 -0
  21. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/compaction.py +0 -0
  22. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/computer_use/__init__.py +0 -0
  23. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/computer_use/browser_computer.py +0 -0
  24. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/config.py +0 -0
  25. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/context_budget.py +0 -0
  26. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/context_warning.py +0 -0
  27. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/credentials.py +0 -0
  28. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/hitl_session.py +0 -0
  29. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/interactions.py +0 -0
  30. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/invoke.py +0 -0
  31. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/kairos_daemon.py +0 -0
  32. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/limits.py +0 -0
  33. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/live_audio_engine.py +0 -0
  34. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/logging_config.py +0 -0
  35. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/mcp_loader.py +0 -0
  36. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/memory/__init__.py +0 -0
  37. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/memory/embedding_memory_service.py +0 -0
  38. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/memory/file_memory_service.py +0 -0
  39. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/modality_tools.py +0 -0
  40. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/model_errors.py +0 -0
  41. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/model_routing.py +0 -0
  42. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/paths.py +0 -0
  43. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/permissions.py +0 -0
  44. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/plugins/__init__.py +0 -0
  45. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  46. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  47. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/prompt_suggestions.py +0 -0
  48. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/query/__init__.py +0 -0
  49. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/query/config.py +0 -0
  50. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/query/deps.py +0 -0
  51. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/query/engine.py +0 -0
  52. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/query/stop_hooks.py +0 -0
  53. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/query/token_budget.py +0 -0
  54. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/query/transitions.py +0 -0
  55. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/repl_commands.py +0 -0
  56. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/repl_slash.py +0 -0
  57. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/session_runtime.py +0 -0
  58. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/slash_commands.py +0 -0
  59. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/thinking.py +0 -0
  60. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tool_prompt_manifest.py +0 -0
  61. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tool_registry.py +0 -0
  62. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools/__init__.py +0 -0
  63. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools/edit.py +0 -0
  64. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools/filesystem.py +0 -0
  65. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools/search.py +0 -0
  66. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools/shell.py +0 -0
  67. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools/shell_gate.py +0 -0
  68. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools/todo.py +0 -0
  69. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/tools_inspector.py +0 -0
  70. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/trust.py +0 -0
  71. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/version.py +0 -0
  72. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/vertex.py +0 -0
  73. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/web/__init__.py +0 -0
  74. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/web/claude_sse_adapter.py +0 -0
  75. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/web/terminal_repl.py +0 -0
  76. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode/workspace_hints.py +0 -0
  77. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode.egg-info/dependency_links.txt +0 -0
  78. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode.egg-info/entry_points.txt +0 -0
  79. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode.egg-info/requires.txt +0 -0
  80. {gemcode-0.3.11 → gemcode-0.3.12}/src/gemcode.egg-info/top_level.txt +0 -0
  81. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_agent_instruction.py +0 -0
  82. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_autocompact.py +0 -0
  83. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_capability_routing.py +0 -0
  84. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_claude_web_adapter_sse.py +0 -0
  85. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_cli_init.py +0 -0
  86. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_computer_use_permissions.py +0 -0
  87. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_context_budget.py +0 -0
  88. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_context_warning.py +0 -0
  89. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_credentials.py +0 -0
  90. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_interactive_permission_ask.py +0 -0
  91. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_kairos_scheduler.py +0 -0
  92. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_modality_tools.py +0 -0
  93. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_model_error_retry.py +0 -0
  94. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_model_errors.py +0 -0
  95. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_model_routing.py +0 -0
  96. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_paths.py +0 -0
  97. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_permissions.py +0 -0
  98. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_prompt_suggestions.py +0 -0
  99. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_repl_commands.py +0 -0
  100. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_repl_slash.py +0 -0
  101. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_slash_commands.py +0 -0
  102. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_thinking_config.py +0 -0
  103. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_token_budget.py +0 -0
  104. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_tool_context_circulation.py +0 -0
  105. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_tools.py +0 -0
  106. {gemcode-0.3.11 → gemcode-0.3.12}/tests/test_tools_inspector.py +0 -0
  107. {gemcode-0.3.11 → gemcode-0.3.12}/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.11
3
+ Version: 0.3.12
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.11"
7
+ version = "0.3.12"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -183,6 +183,10 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
183
183
  _maybe_prompt_google_api_key()
184
184
  require_google_api_key()
185
185
  _initialize_gemcode_project(cfg)
186
+
187
+ # Show beautiful startup screen before TUI loads
188
+ from gemcode.tui.startup_screen import print_startup_screen
189
+ print_startup_screen()
186
190
  extra: list = []
187
191
  if use_mcp:
188
192
  from gemcode.mcp_loader import load_mcp_toolsets
@@ -237,7 +241,9 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
237
241
  )
238
242
  else:
239
243
  try:
240
- style = os.environ.get("GEMCODE_TUI_STYLE", "scrollback").strip().lower()
244
+ # Default to the full-screen TUI (OpenClaude-like). Scrollback is still
245
+ # available for terminals that can't handle alternate screen UIs.
246
+ style = os.environ.get("GEMCODE_TUI_STYLE", "full").strip().lower()
241
247
  if style in ("scrollback", "claude", "claude-like"):
242
248
  from gemcode.tui.scrollback import run_gemcode_scrollback_tui
243
249
 
@@ -77,6 +77,12 @@ async def run_gemcode_tui(
77
77
 
78
78
  interrupted = {"flag": False}
79
79
 
80
+ # Import spinner utilities
81
+ from gemcode.tui.spinner import SpinnerState, format_spinner_line, format_idle_status
82
+
83
+ # Initialize spinner state
84
+ spinner_state = SpinnerState()
85
+
80
86
  def append(text: str) -> None:
81
87
  output.buffer.insert_text(text)
82
88
  if not text.endswith("\n"):
@@ -124,25 +130,36 @@ async def run_gemcode_tui(
124
130
 
125
131
  def header_text():
126
132
  model = getattr(cfg, "model", "") or ""
133
+ # Clean up model display
134
+ if "gemini-" in model:
135
+ model_display = model.replace("gemini-", "Gemini ").replace("-", " ").title()
136
+ else:
137
+ model_display = model or "Gemini 2.0 Flash"
138
+
127
139
  mode = (
128
- "yes"
140
+ "auto"
129
141
  if getattr(cfg, "yes_to_all", False)
130
142
  else "ask"
131
143
  if getattr(cfg, "interactive_permission_ask", False)
132
- else "ro"
144
+ else "read-only"
133
145
  )
134
146
  root = str(getattr(cfg, "project_root", "") or "")
147
+ # Shorten root path if too long
148
+ if len(root) > 40:
149
+ root = "..." + root[-37:]
150
+
135
151
  now = datetime.now().strftime("%a %b %d %H:%M")
136
152
  # Shift+Enter isn't reliably distinguishable across terminals, so we
137
153
  # provide a portable newline binding (Ctrl+J).
138
154
  tips = "Enter=send | Ctrl+J=newline | Esc=interrupt | Ctrl+D=exit"
139
155
  return [
140
- ("class:brand", " GemCode "),
141
- ("", f" model={model or '<auto>'} perm={mode} root={root} {now}\n"),
156
+ ("class:brand", " GemCode "),
157
+ ("", f" {model_display} · perm={mode} · {now}\n"),
158
+ ("class:muted", f" {root}\n"),
142
159
  ("class:muted", f" {tips}"),
143
160
  ]
144
161
 
145
- header = Window(height=2, content=FormattedTextControl(header_text), dont_extend_height=True)
162
+ header = Window(height=3, content=FormattedTextControl(header_text), dont_extend_height=True)
146
163
 
147
164
  _git_cache = {"t": 0.0, "branch": ""}
148
165
 
@@ -218,55 +235,62 @@ async def run_gemcode_tui(
218
235
  tool = str(pending_confirm.get("tool") or "tool")
219
236
  return [
220
237
  ("class:muted", " "),
221
- ("class:pill", f"Permission: {tool}"),
238
+ ("class:pill", f"Permission: {tool}"),
222
239
  ("class:muted", " "),
223
240
  ("class:accent", "y=approve"),
224
241
  ("class:muted", " "),
225
242
  ("class:accent", "n=deny"),
226
243
  ("class:muted", " "),
227
- ("class:muted", "(Esc cancels)"),
244
+ ("class:muted", "(Esc to cancel)"),
228
245
  ]
229
246
  if assistant_busy.get("value"):
247
+ # Calculate elapsed time
248
+ elapsed_s = max(0, int(time.time() - float(thinking_start_ts.get("value", 0.0) or 0.0)))
249
+
230
250
  if not show_thinking.get("value") and thinking_active.get("value"):
231
- elapsed_s = max(1, int(time.time() - float(thinking_start_ts.get("value", 0.0) or 0.0)))
251
+ # Thinking but hidden - show duration and hint
232
252
  return [
233
253
  ("class:muted", " "),
234
- ("class:pill", f"Thought for {elapsed_s}s (ctrl+o to show thinking)"),
254
+ ("class:pill", f"💭 Thinking for {elapsed_s}s"),
235
255
  ("class:muted", " "),
236
- ("class:muted", "Tip: Esc=interrupt"),
256
+ ("class:accent", "Ctrl+O to show"),
257
+ ("class:muted", " · "),
258
+ ("class:muted", "Esc=interrupt"),
237
259
  ]
238
- frames = ["|", "/", "-", "\\"]
239
- verbs = ["Thinking", "Analyzing", "Planning", "Writing", "Checking", "Reviewing"]
240
- i = int(spinner_idx.get("value", 0) or 0)
241
- fr = frames[i % len(frames)]
242
- verb = verbs[i % len(verbs)]
260
+
261
+ # Active spinner - use OpenClaude-style Braille frames
262
+ frame = spinner_state.get_frame()
263
+ verb = spinner_state.verb
243
264
  return [
244
265
  ("class:muted", " "),
245
- ("class:pill", f"{verb} {fr}"),
246
- ("class:muted", " "),
247
- ("class:muted", "Tip: Esc=interrupt"),
266
+ ("class:accent", f"{frame}"),
267
+ ("class:muted", f" {verb}…"),
268
+ ("class:muted", " · "),
269
+ ("class:muted", "Esc=interrupt"),
248
270
  ]
271
+
272
+ # Idle status - use OpenClaude format
273
+ git_br = _git_branch()
249
274
  return [
250
275
  ("class:muted", " "),
251
- ("class:pill", f"🌿 {_git_branch()}" if _git_branch() else "📁 no-git"),
252
- ("class:muted", " "),
253
- ("class:muted", "Tip: Ctrl+J=newline Esc=interrupt Ctrl+D=exit"),
276
+ ("class:pill", f"🌿 {git_br}" if git_br else "📁 workspace"),
277
+ ("class:muted", " · "),
278
+ ("class:muted", "Ready · Ctrl+K=home · Ctrl+O=thinking"),
254
279
  ]
255
280
 
256
281
  status.content = FormattedTextControl(_status_text)
257
282
  _set_input_prompt()
258
283
 
259
284
  async def _spin_status() -> None:
260
- frames = ["|", "/", "-", "\\"]
261
- i = 0
285
+ """Animate spinner matching OpenClaude's exact behavior."""
262
286
  while assistant_busy.get("value"):
263
- spinner_idx["value"] = i
264
- i += 1
287
+ spinner_state.advance()
288
+ spinner_idx["value"] = spinner_state.frame_index
265
289
  try:
266
290
  app.invalidate()
267
291
  except Exception:
268
292
  pass
269
- await asyncio.sleep(0.12)
293
+ await asyncio.sleep(0.12) # 120ms like OpenClaude
270
294
 
271
295
  input_help = Window(
272
296
  height=1,
@@ -295,8 +319,11 @@ async def run_gemcode_tui(
295
319
  def _model_display() -> str:
296
320
  m = getattr(cfg, "model", "") or ""
297
321
  if not m:
298
- return "GemCode"
299
- return m.replace("gemini-", "Gemini ").replace("-", ".")
322
+ return "Gemini 2.0 Flash"
323
+ # Clean up model name for display
324
+ if "gemini-" in m:
325
+ return m.replace("gemini-", "Gemini ").replace("-", " ").title()
326
+ return m
300
327
 
301
328
  def _render_home_text():
302
329
  # Recompute with current terminal width for a "dashboard" feel.
@@ -328,10 +355,10 @@ async def run_gemcode_tui(
328
355
 
329
356
  # Tiny "gem" ASCII mark (kept simple for terminal portability).
330
357
  art = [
331
- " ▄▄▄▄ ",
332
- " ▐█ █▌ ",
333
- " ▐█ █▌ ",
334
- " ▀▀▀▀ ",
358
+ " ▄▄▄▄▄▄▄ ",
359
+ " ▐█████████▌ ",
360
+ " ▐█████████▌ ",
361
+ " ▀▀▀▀▀▀▀ ",
335
362
  ]
336
363
  art_w = max(len(x) for x in art)
337
364
 
@@ -350,8 +377,9 @@ async def run_gemcode_tui(
350
377
 
351
378
  tips = [
352
379
  "Tips for getting started",
353
- "First run: trust folder, API key, then .gemcode/",
354
- "Note: Use perm=ask to approve tools",
380
+ "Use /help to see all commands",
381
+ "Ctrl+O toggles thinking display",
382
+ "Ctrl+K toggles this home screen",
355
383
  ]
356
384
  activity = ["Recent activity", "No recent activity"]
357
385
 
@@ -375,9 +403,9 @@ async def run_gemcode_tui(
375
403
  lines.append("│ " + l + " │ " + r + " │")
376
404
  lines.append("│" + (" " * (width - 2)) + "│")
377
405
  lines.append("└" + ("─" * (width - 2)) + "┘")
378
- lines.append("↑ GemCode Pro now supports larger contexts · faster streaming")
406
+ lines.append("↑ GemCode now supports larger contexts · faster streaming · better tools")
379
407
  lines.append("")
380
- lines.append(" ? for shortcuts".ljust(max(0, width - 12)) + "Ctrl+O home")
408
+ lines.append(" ? for shortcuts".ljust(max(0, width - 12)) + "Ctrl+K home")
381
409
 
382
410
  # Prevent overflow: clamp to available rows (leave space for header/input/status).
383
411
  max_lines = max(6, min(len(lines), max(6, rows - 7)))
@@ -630,6 +658,17 @@ async def run_gemcode_tui(
630
658
  app.invalidate()
631
659
  except Exception:
632
660
  pass
661
+ # Make the start of a turn visually obvious in the transcript, like
662
+ # OpenClaude's "thinking" row next to the spinner. We still keep the
663
+ # *content* of the thought hidden until Ctrl+O is pressed; this is just
664
+ # a dim/italic label so the user always sees that the model is busy.
665
+ append("\033[2m\033[3m⎿ ∴ Thinking…\033[0m")
666
+
667
+ # Mark thinking start for spinner (OpenClaude-style)
668
+ spinner_state.start_thinking()
669
+ thinking_active["value"] = True
670
+ thinking_start_ts["value"] = time.time()
671
+
633
672
  spinner_task = asyncio.create_task(_spin_status())
634
673
 
635
674
  try:
@@ -874,6 +913,8 @@ async def run_gemcode_tui(
874
913
  append(f"GemCode: error: {e}\n")
875
914
  finally:
876
915
  assistant_busy["value"] = False
916
+ thinking_active["value"] = False
917
+ spinner_state.stop_thinking() # Mark thinking end (OpenClaude-style)
877
918
  try:
878
919
  spinner_task.cancel()
879
920
  except Exception:
@@ -903,12 +944,12 @@ async def run_gemcode_tui(
903
944
 
904
945
  style = Style.from_dict(
905
946
  {
906
- "brand": "bold #60a5fa",
907
- "accent": "bold #3b82f6",
908
- "muted": "#6b7280",
909
- "sep": "#1f2937",
910
- "pill": "bold #93c5fd",
911
- "inputframe": "bg:#071426 #e5e7eb",
947
+ "brand": "bold #f0945c", # Sunset orange
948
+ "accent": "bold #f09464", # Warm accent
949
+ "muted": "#786452", # Warm gray
950
+ "sep": "#64503d", # Dark border
951
+ "pill": "bold #d97757", # Warm pill
952
+ "inputframe": "bg:#1a0f0a #e5d5c5", # Warm dark bg with cream text
912
953
  }
913
954
  )
914
955
 
@@ -117,25 +117,28 @@ class _Ansi:
117
117
 
118
118
  @property
119
119
  def blue(self) -> str:
120
- # ANSI 256-color bright-ish blue.
121
- return self.esc("38;5;75")
120
+ # Sunset orange/warm color instead of blue
121
+ return self.esc("38;5;215")
122
122
 
123
123
  @property
124
124
  def blue2(self) -> str:
125
- # Slightly deeper blue for secondary accents.
126
- return self.esc("38;5;33")
125
+ # Deeper warm color
126
+ return self.esc("38;5;209")
127
127
 
128
128
  @property
129
129
  def blue_ok(self) -> str:
130
- return self.esc("38;5;81")
130
+ # Warm green for success
131
+ return self.esc("38;5;150")
131
132
 
132
133
  @property
133
134
  def blue_warn(self) -> str:
134
- return self.esc("38;5;39")
135
+ # Warm amber for warnings
136
+ return self.esc("38;5;221")
135
137
 
136
138
  @property
137
139
  def blue_tool(self) -> str:
138
- return self.esc("38;5;69")
140
+ # Warm tool color
141
+ return self.esc("38;5;180")
139
142
 
140
143
 
141
144
  def _term_width(default: int = 100) -> int:
@@ -176,16 +179,18 @@ def _dashboard(cfg) -> str:
176
179
  "",
177
180
  f"Welcome back {user}!",
178
181
  "",
179
- " ▐▛███▜▌",
180
- " ▝▜█████▛▘",
181
- " ▘▘ ▝▝",
182
+ " ▄▄▄▄▄▄▄",
183
+ " ▐█████████▌",
184
+ " ▐█████████▌",
185
+ " ▀▀▀▀▀▀▀",
182
186
  "",
183
- f"{model or 'GemCode'} · Local session",
187
+ f"{model or 'Gemini 2.0 Flash'} · Local session",
184
188
  root,
185
189
  ]
186
190
  right = [
187
191
  "Tips for getting started",
188
192
  "First run creates .gemcode/ (trust + API key)",
193
+ "Use /help to see available commands",
189
194
  "",
190
195
  "Recent activity",
191
196
  "No recent activity",
@@ -202,7 +207,7 @@ def _dashboard(cfg) -> str:
202
207
  lines.append("│" + pad(f" {nt}", w - 2) + "│")
203
208
  lines.append(box_bot)
204
209
  lines.append("")
205
- lines.append(" ↑ GemCode Pro now supports larger contexts · faster streaming")
210
+ lines.append(" ↑ GemCode now supports larger contexts · faster streaming · better tools")
206
211
  lines.append("")
207
212
  return "\n".join(lines)
208
213
 
@@ -247,6 +252,7 @@ async def run_gemcode_scrollback_tui(
247
252
  print(dash)
248
253
 
249
254
  print(f"{ansi.dim} ? for shortcuts{ansi.reset}")
255
+ print(f"{ansi.dim} (Tip: set GEMCODE_TUI_STYLE=full for spinner/status bar){ansi.reset}")
250
256
  print("")
251
257
 
252
258
  char_delay_ms = int(os.environ.get("GEMCODE_TUI_CHAR_DELAY_MS", "0") or "0")
@@ -0,0 +1,150 @@
1
+ """
2
+ Spinner component matching OpenClaude's exact implementation.
3
+ Includes thinking status, tips, and animated spinner.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import random
10
+ import time
11
+ from typing import Optional
12
+
13
+ # Spinner frames - Braille patterns like OpenClaude
14
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
15
+
16
+ # Spinner verbs - matching OpenClaude's variety
17
+ SPINNER_VERBS = [
18
+ "Thinking",
19
+ "Analyzing",
20
+ "Planning",
21
+ "Writing",
22
+ "Checking",
23
+ "Reviewing",
24
+ "Processing",
25
+ "Evaluating",
26
+ "Considering",
27
+ "Examining",
28
+ ]
29
+
30
+ # Tips - matching OpenClaude's helpful hints
31
+ SPINNER_TIPS = [
32
+ "Use /help to see all available commands",
33
+ "Press Ctrl+O to toggle thinking display",
34
+ "Press Ctrl+K to toggle home screen",
35
+ "Use /clear to start fresh when switching topics",
36
+ "Type /tools to see available tools",
37
+ "Use Esc to interrupt the current operation",
38
+ ]
39
+
40
+
41
+ class SpinnerState:
42
+ """Manages spinner animation state."""
43
+
44
+ def __init__(self):
45
+ self.frame_index = 0
46
+ self.verb = random.choice(SPINNER_VERBS)
47
+ self.thinking_start: Optional[float] = None
48
+ self.thinking_duration: Optional[float] = None
49
+ self.thinking_display_until: Optional[float] = None
50
+ self.last_update = time.time()
51
+
52
+ def get_frame(self) -> str:
53
+ """Get current spinner frame."""
54
+ return SPINNER_FRAMES[self.frame_index % len(SPINNER_FRAMES)]
55
+
56
+ def advance(self) -> None:
57
+ """Advance to next frame."""
58
+ now = time.time()
59
+ # Update every 120ms like OpenClaude
60
+ if now - self.last_update >= 0.12:
61
+ self.frame_index += 1
62
+ self.last_update = now
63
+
64
+ def start_thinking(self) -> None:
65
+ """Mark start of thinking phase."""
66
+ if self.thinking_start is None:
67
+ self.thinking_start = time.time()
68
+ self.thinking_duration = None
69
+ self.thinking_display_until = None
70
+
71
+ def stop_thinking(self) -> None:
72
+ """Mark end of thinking phase."""
73
+ if self.thinking_start is not None:
74
+ duration = time.time() - self.thinking_start
75
+ self.thinking_duration = duration
76
+ # Display "thought for Xs" for minimum 2 seconds
77
+ self.thinking_display_until = time.time() + 2.0
78
+ self.thinking_start = None
79
+
80
+ def get_thinking_status(self) -> Optional[str]:
81
+ """Get thinking status text."""
82
+ now = time.time()
83
+
84
+ # Currently thinking
85
+ if self.thinking_start is not None:
86
+ elapsed = int(now - self.thinking_start)
87
+ if elapsed >= 1:
88
+ return f"💭 Thinking for {elapsed}s"
89
+ return "💭 Thinking"
90
+
91
+ # Show duration after thinking (for 2s minimum)
92
+ if self.thinking_duration is not None and self.thinking_display_until is not None:
93
+ if now < self.thinking_display_until:
94
+ duration = int(self.thinking_duration)
95
+ return f"Thought for {duration}s"
96
+ # Clear after display period
97
+ self.thinking_duration = None
98
+ self.thinking_display_until = None
99
+
100
+ return None
101
+
102
+ def should_show_tip(self, elapsed_seconds: float) -> bool:
103
+ """Determine if we should show a tip based on elapsed time."""
104
+ # Show tips after 30 seconds like OpenClaude
105
+ return elapsed_seconds > 30
106
+
107
+ def get_tip(self) -> str:
108
+ """Get a random tip."""
109
+ return random.choice(SPINNER_TIPS)
110
+
111
+
112
+ def format_spinner_line(
113
+ spinner_state: SpinnerState,
114
+ mode: str = "working",
115
+ elapsed_seconds: float = 0,
116
+ show_tip: bool = True,
117
+ ) -> tuple[str, Optional[str]]:
118
+ """
119
+ Format spinner line matching OpenClaude's exact style.
120
+
121
+ Returns:
122
+ Tuple of (main_line, tip_line)
123
+ """
124
+ frame = spinner_state.get_frame()
125
+ verb = spinner_state.verb
126
+
127
+ # Check for thinking status
128
+ thinking_status = spinner_state.get_thinking_status()
129
+
130
+ if thinking_status:
131
+ # Show thinking status instead of regular spinner
132
+ main_line = f" {thinking_status} · Esc=interrupt"
133
+ else:
134
+ # Regular spinner with verb
135
+ main_line = f" {frame} {verb}… · Esc=interrupt"
136
+
137
+ # Tip line (shown after 30s)
138
+ tip_line = None
139
+ if show_tip and spinner_state.should_show_tip(elapsed_seconds):
140
+ tip = spinner_state.get_tip()
141
+ tip_line = f" Tip: {tip}"
142
+
143
+ return main_line, tip_line
144
+
145
+
146
+ def format_idle_status(git_branch: Optional[str] = None) -> str:
147
+ """Format idle status line matching OpenClaude."""
148
+ if git_branch:
149
+ return f" 🌿 {git_branch} · Ready · Ctrl+K=home · Ctrl+O=thinking"
150
+ return " 📁 workspace · Ready · Ctrl+K=home · Ctrl+O=thinking"
@@ -0,0 +1,196 @@
1
+ """
2
+ GemCode startup screen with gradient ASCII art logo.
3
+ Inspired by OpenClaude's beautiful startup experience.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from typing import NamedTuple
11
+
12
+
13
+ class RGB(NamedTuple):
14
+ r: int
15
+ g: int
16
+ b: int
17
+
18
+
19
+ ESC = "\x1b["
20
+ RESET = f"{ESC}0m"
21
+ DIM = f"{ESC}2m"
22
+
23
+
24
+ def rgb(r: int, g: int, b: int) -> str:
25
+ return f"{ESC}38;2;{r};{g};{b}m"
26
+
27
+
28
+ def lerp(a: RGB, b: RGB, t: float) -> RGB:
29
+ return RGB(
30
+ round(a.r + (b.r - a.r) * t),
31
+ round(a.g + (b.g - a.g) * t),
32
+ round(a.b + (b.b - a.b) * t),
33
+ )
34
+
35
+
36
+ def grad_at(stops: list[RGB], t: float) -> RGB:
37
+ c = max(0.0, min(1.0, t))
38
+ s = c * (len(stops) - 1)
39
+ i = int(s)
40
+ if i >= len(stops) - 1:
41
+ return stops[-1]
42
+ return lerp(stops[i], stops[i + 1], s - i)
43
+
44
+
45
+ def paint_line(text: str, stops: list[RGB], line_t: float) -> str:
46
+ out = ""
47
+ for i, ch in enumerate(text):
48
+ t = line_t * 0.5 + (i / (len(text) - 1)) * 0.5 if len(text) > 1 else line_t
49
+ color = grad_at(stops, t)
50
+ out += f"{rgb(color.r, color.g, color.b)}{ch}"
51
+ return out + RESET
52
+
53
+
54
+ # Color palette - sunset gradient
55
+ SUNSET_GRAD: list[RGB] = [
56
+ RGB(255, 180, 100),
57
+ RGB(240, 140, 80),
58
+ RGB(217, 119, 87),
59
+ RGB(193, 95, 60),
60
+ RGB(160, 75, 55),
61
+ RGB(130, 60, 50),
62
+ ]
63
+
64
+ ACCENT = RGB(240, 148, 100)
65
+ CREAM = RGB(220, 195, 170)
66
+ DIMCOL = RGB(120, 100, 82)
67
+ BORDER = RGB(100, 80, 65)
68
+
69
+ # Filled block text logo
70
+ LOGO_GEM = [
71
+ " ██████╗ ███████╗███╗ ███╗",
72
+ " ██╔════╝ ██╔════╝████╗ ████║",
73
+ " ██║ ███╗█████╗ ██╔████╔██║",
74
+ " ██║ ██║██╔══╝ ██║╚██╔╝██║",
75
+ " ╚██████╔╝███████╗██║ ╚═╝ ██║",
76
+ " ╚═════╝ ╚══════╝╚═╝ ╚═╝",
77
+ ]
78
+
79
+ LOGO_CODE = [
80
+ " ██████╗ ██████╗ ██████╗ ███████╗",
81
+ " ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
82
+ " ██║ ██║ ██║██║ ██║█████╗ ",
83
+ " ██║ ██║ ██║██║ ██║██╔══╝ ",
84
+ " ╚██████╗╚██████╔╝██████╔╝███████╗",
85
+ " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
86
+ ]
87
+
88
+
89
+ def detect_provider() -> dict[str, str | bool]:
90
+ """Detect current provider configuration."""
91
+ # Check for Gemini (default for GemCode)
92
+ api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
93
+ model = os.environ.get("GEMINI_MODEL") or os.environ.get("GEMCODE_MODEL") or "gemini-2.0-flash-exp"
94
+ base_url = os.environ.get("GEMINI_BASE_URL") or "https://generativelanguage.googleapis.com"
95
+
96
+ return {
97
+ "name": "Google Gemini",
98
+ "model": model,
99
+ "base_url": base_url,
100
+ "is_local": False,
101
+ "has_key": bool(api_key),
102
+ }
103
+
104
+
105
+ def box_row(content: str, width: int, raw_len: int) -> str:
106
+ """Create a box row with proper padding."""
107
+ pad = max(0, width - 2 - raw_len)
108
+ return f"{rgb(BORDER.r, BORDER.g, BORDER.b)}│{RESET}{content}{' ' * pad}{rgb(BORDER.r, BORDER.g, BORDER.b)}│{RESET}"
109
+
110
+
111
+ def print_startup_screen() -> None:
112
+ """Print the beautiful gradient startup screen."""
113
+ # Skip in non-interactive / CI environments
114
+ if os.environ.get("CI") or not sys.stdout.isatty():
115
+ return
116
+
117
+ # Skip if explicitly disabled
118
+ if os.environ.get("GEMCODE_NO_STARTUP_SCREEN", "").lower() in ("1", "true", "yes", "on"):
119
+ return
120
+
121
+ p = detect_provider()
122
+ W = 62
123
+ out: list[str] = []
124
+
125
+ out.append("")
126
+
127
+ # Gradient logo
128
+ all_logo = LOGO_GEM + [""] + LOGO_CODE
129
+ total = len(all_logo)
130
+ for i, line in enumerate(all_logo):
131
+ t = i / (total - 1) if total > 1 else 0
132
+ if line == "":
133
+ out.append("")
134
+ else:
135
+ out.append(paint_line(line, SUNSET_GRAD, t))
136
+
137
+ out.append("")
138
+
139
+ # Tagline
140
+ out.append(
141
+ f" {rgb(ACCENT.r, ACCENT.g, ACCENT.b)}✦{RESET} "
142
+ f"{rgb(CREAM.r, CREAM.g, CREAM.b)}Gemini-powered coding agent. Fast. Capable. Local.{RESET} "
143
+ f"{rgb(ACCENT.r, ACCENT.g, ACCENT.b)}✦{RESET}"
144
+ )
145
+ out.append("")
146
+
147
+ # Provider info box
148
+ out.append(f"{rgb(BORDER.r, BORDER.g, BORDER.b)}╔{'═' * (W - 2)}╗{RESET}")
149
+
150
+ def lbl(k: str, v: str, c: RGB = CREAM) -> tuple[str, int]:
151
+ pad_k = k.ljust(9)
152
+ return (
153
+ f" {DIM}{rgb(DIMCOL.r, DIMCOL.g, DIMCOL.b)}{pad_k}{RESET} {rgb(c.r, c.g, c.b)}{v}{RESET}",
154
+ len(f" {pad_k} {v}"),
155
+ )
156
+
157
+ prov_c = RGB(130, 175, 130) if p["is_local"] else ACCENT
158
+ r, l = lbl("Provider", str(p["name"]), prov_c)
159
+ out.append(box_row(r, W, l))
160
+
161
+ r, l = lbl("Model", str(p["model"]))
162
+ out.append(box_row(r, W, l))
163
+
164
+ ep = str(p["base_url"])
165
+ if len(ep) > 38:
166
+ ep = ep[:35] + "..."
167
+ r, l = lbl("Endpoint", ep)
168
+ out.append(box_row(r, W, l))
169
+
170
+ out.append(f"{rgb(BORDER.r, BORDER.g, BORDER.b)}╠{'═' * (W - 2)}╣{RESET}")
171
+
172
+ # Status line
173
+ s_c = RGB(130, 175, 130) if p["is_local"] else ACCENT
174
+ s_l = "local" if p["is_local"] else "cloud"
175
+ status_text = (
176
+ f" {rgb(s_c.r, s_c.g, s_c.b)}●{RESET} "
177
+ f"{DIM}{rgb(DIMCOL.r, DIMCOL.g, DIMCOL.b)}{s_l}{RESET} "
178
+ f"{DIM}{rgb(DIMCOL.r, DIMCOL.g, DIMCOL.b)}Ready — type {RESET}"
179
+ f"{rgb(ACCENT.r, ACCENT.g, ACCENT.b)}/help{RESET}"
180
+ f"{DIM}{rgb(DIMCOL.r, DIMCOL.g, DIMCOL.b)} to begin{RESET}"
181
+ )
182
+ s_len = len(f" ● {s_l} Ready — type /help to begin")
183
+ out.append(box_row(status_text, W, s_len))
184
+
185
+ out.append(f"{rgb(BORDER.r, BORDER.g, BORDER.b)}╚{'═' * (W - 2)}╝{RESET}")
186
+
187
+ # Version
188
+ from gemcode.version import get_version
189
+ version = os.environ.get("GEMCODE_VERSION", get_version())
190
+ out.append(
191
+ f" {DIM}{rgb(DIMCOL.r, DIMCOL.g, DIMCOL.b)}gemcode {RESET}"
192
+ f"{rgb(ACCENT.r, ACCENT.g, ACCENT.b)}v{version}{RESET}"
193
+ )
194
+ out.append("")
195
+
196
+ print("\n".join(out), flush=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.11
3
+ Version: 0.3.12
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -71,6 +71,8 @@ src/gemcode/tools/shell_gate.py
71
71
  src/gemcode/tools/todo.py
72
72
  src/gemcode/tui/app.py
73
73
  src/gemcode/tui/scrollback.py
74
+ src/gemcode/tui/spinner.py
75
+ src/gemcode/tui/startup_screen.py
74
76
  src/gemcode/web/__init__.py
75
77
  src/gemcode/web/claude_sse_adapter.py
76
78
  src/gemcode/web/terminal_repl.py
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