gemcode 0.3.11__tar.gz → 0.3.14__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 (109) hide show
  1. {gemcode-0.3.11/src/gemcode.egg-info → gemcode-0.3.14}/PKG-INFO +2 -4
  2. {gemcode-0.3.11 → gemcode-0.3.14}/pyproject.toml +2 -2
  3. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/cli.py +15 -26
  4. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tui/scrollback.py +20 -82
  5. gemcode-0.3.14/src/gemcode/tui/spinner.py +150 -0
  6. gemcode-0.3.14/src/gemcode/tui/startup_screen.py +196 -0
  7. gemcode-0.3.14/src/gemcode/tui/welcome_banner.py +127 -0
  8. gemcode-0.3.14/src/gemcode/tui/welcome_rich.py +40 -0
  9. {gemcode-0.3.11 → gemcode-0.3.14/src/gemcode.egg-info}/PKG-INFO +2 -4
  10. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode.egg-info/SOURCES.txt +4 -1
  11. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode.egg-info/requires.txt +1 -4
  12. gemcode-0.3.11/src/gemcode/tui/app.py +0 -927
  13. {gemcode-0.3.11 → gemcode-0.3.14}/LICENSE +0 -0
  14. {gemcode-0.3.11 → gemcode-0.3.14}/MANIFEST.in +0 -0
  15. {gemcode-0.3.11 → gemcode-0.3.14}/README.md +0 -0
  16. {gemcode-0.3.11 → gemcode-0.3.14}/setup.cfg +0 -0
  17. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/__init__.py +0 -0
  18. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/__main__.py +0 -0
  19. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/agent.py +0 -0
  20. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/audit.py +0 -0
  21. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/autocompact.py +0 -0
  22. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/callbacks.py +0 -0
  23. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/capability_routing.py +0 -0
  24. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/compaction.py +0 -0
  25. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/computer_use/__init__.py +0 -0
  26. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/computer_use/browser_computer.py +0 -0
  27. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/config.py +0 -0
  28. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/context_budget.py +0 -0
  29. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/context_warning.py +0 -0
  30. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/credentials.py +0 -0
  31. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/hitl_session.py +0 -0
  32. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/interactions.py +0 -0
  33. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/invoke.py +0 -0
  34. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/kairos_daemon.py +0 -0
  35. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/limits.py +0 -0
  36. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/live_audio_engine.py +0 -0
  37. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/logging_config.py +0 -0
  38. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/mcp_loader.py +0 -0
  39. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/memory/__init__.py +0 -0
  40. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/memory/embedding_memory_service.py +0 -0
  41. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/memory/file_memory_service.py +0 -0
  42. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/modality_tools.py +0 -0
  43. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/model_errors.py +0 -0
  44. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/model_routing.py +0 -0
  45. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/paths.py +0 -0
  46. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/permissions.py +0 -0
  47. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/plugins/__init__.py +0 -0
  48. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  49. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  50. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/prompt_suggestions.py +0 -0
  51. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/query/__init__.py +0 -0
  52. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/query/config.py +0 -0
  53. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/query/deps.py +0 -0
  54. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/query/engine.py +0 -0
  55. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/query/stop_hooks.py +0 -0
  56. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/query/token_budget.py +0 -0
  57. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/query/transitions.py +0 -0
  58. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/repl_commands.py +0 -0
  59. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/repl_slash.py +0 -0
  60. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/session_runtime.py +0 -0
  61. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/slash_commands.py +0 -0
  62. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/thinking.py +0 -0
  63. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tool_prompt_manifest.py +0 -0
  64. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tool_registry.py +0 -0
  65. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools/__init__.py +0 -0
  66. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools/edit.py +0 -0
  67. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools/filesystem.py +0 -0
  68. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools/search.py +0 -0
  69. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools/shell.py +0 -0
  70. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools/shell_gate.py +0 -0
  71. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools/todo.py +0 -0
  72. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/tools_inspector.py +0 -0
  73. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/trust.py +0 -0
  74. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/version.py +0 -0
  75. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/vertex.py +0 -0
  76. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/web/__init__.py +0 -0
  77. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/web/claude_sse_adapter.py +0 -0
  78. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/web/terminal_repl.py +0 -0
  79. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode/workspace_hints.py +0 -0
  80. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode.egg-info/dependency_links.txt +0 -0
  81. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode.egg-info/entry_points.txt +0 -0
  82. {gemcode-0.3.11 → gemcode-0.3.14}/src/gemcode.egg-info/top_level.txt +0 -0
  83. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_agent_instruction.py +0 -0
  84. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_autocompact.py +0 -0
  85. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_capability_routing.py +0 -0
  86. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_claude_web_adapter_sse.py +0 -0
  87. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_cli_init.py +0 -0
  88. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_computer_use_permissions.py +0 -0
  89. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_context_budget.py +0 -0
  90. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_context_warning.py +0 -0
  91. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_credentials.py +0 -0
  92. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_interactive_permission_ask.py +0 -0
  93. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_kairos_scheduler.py +0 -0
  94. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_modality_tools.py +0 -0
  95. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_model_error_retry.py +0 -0
  96. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_model_errors.py +0 -0
  97. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_model_routing.py +0 -0
  98. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_paths.py +0 -0
  99. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_permissions.py +0 -0
  100. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_prompt_suggestions.py +0 -0
  101. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_repl_commands.py +0 -0
  102. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_repl_slash.py +0 -0
  103. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_slash_commands.py +0 -0
  104. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_thinking_config.py +0 -0
  105. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_token_budget.py +0 -0
  106. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_tool_context_circulation.py +0 -0
  107. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_tools.py +0 -0
  108. {gemcode-0.3.11 → gemcode-0.3.14}/tests/test_tools_inspector.py +0 -0
  109. {gemcode-0.3.11 → gemcode-0.3.14}/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.14
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -178,14 +178,12 @@ License-File: LICENSE
178
178
  Requires-Dist: google-adk>=1.0.0
179
179
  Requires-Dist: google-genai>=1.0.0
180
180
  Requires-Dist: python-dotenv>=1.0.0
181
+ Requires-Dist: rich>=13.0.0
181
182
  Provides-Extra: dev
182
183
  Requires-Dist: pytest>=8.0.0; extra == "dev"
183
184
  Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
184
185
  Provides-Extra: mcp
185
186
  Requires-Dist: mcp>=1.0.0; extra == "mcp"
186
- Provides-Extra: tui
187
- Requires-Dist: prompt_toolkit>=3.0.0; extra == "tui"
188
- Requires-Dist: rich>=13.0.0; extra == "tui"
189
187
  Dynamic: license-file
190
188
 
191
189
  # GemCode
@@ -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.14"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -30,6 +30,7 @@ dependencies = [
30
30
  "google-adk>=1.0.0",
31
31
  "google-genai>=1.0.0",
32
32
  "python-dotenv>=1.0.0",
33
+ "rich>=13.0.0",
33
34
  ]
34
35
 
35
36
  [project.urls]
@@ -40,7 +41,6 @@ Issues = "https://github.com/spiderdev27/GemCode/issues"
40
41
  [project.optional-dependencies]
41
42
  dev = ["pytest>=8.0.0", "pytest-asyncio>=0.24.0"]
42
43
  mcp = ["mcp>=1.0.0"]
43
- tui = ["prompt_toolkit>=3.0.0", "rich>=13.0.0"]
44
44
 
45
45
  [project.scripts]
46
46
  gemcode = "gemcode.cli:main"
@@ -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
@@ -225,11 +229,10 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
225
229
  except EOFError:
226
230
  pass
227
231
 
228
- # Optional TUI. Claude-like default is "scrollback" (no internal scrolling).
232
+ # Optional terminal UI: single scrollback-style REPL (terminal history, no alt-screen app).
229
233
  tui_enabled = os.environ.get("GEMCODE_TUI", "1").lower() in ("1", "true", "yes", "on")
230
234
  if tui_enabled:
231
235
  term = (os.environ.get("TERM") or "").strip().lower()
232
- # Guardrails: Prompt Toolkit needs a real interactive terminal.
233
236
  if not sys.stdin.isatty() or not sys.stdout.isatty() or term in ("", "dumb", "unknown"):
234
237
  print(
235
238
  f"[tui] disabled (stdin/stdout isatty={sys.stdin.isatty()}/{sys.stdout.isatty()}, TERM={term or '<unset>'}); using plain REPL",
@@ -237,33 +240,19 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
237
240
  )
238
241
  else:
239
242
  try:
240
- style = os.environ.get("GEMCODE_TUI_STYLE", "scrollback").strip().lower()
241
- if style in ("scrollback", "claude", "claude-like"):
242
- from gemcode.tui.scrollback import run_gemcode_scrollback_tui
243
-
244
- await run_gemcode_scrollback_tui(
245
- cfg=cfg,
246
- runner=runner,
247
- session_id=session_id,
248
- extra_tools=extra or None,
249
- )
250
- else:
251
- from gemcode.tui.app import run_gemcode_tui
252
-
253
- await run_gemcode_tui(
254
- cfg=cfg,
255
- runner=runner,
256
- session_id=session_id,
257
- extra_tools=extra or None,
258
- )
243
+ from gemcode.tui.scrollback import run_gemcode_scrollback_tui
244
+
245
+ await run_gemcode_scrollback_tui(
246
+ cfg=cfg,
247
+ runner=runner,
248
+ session_id=session_id,
249
+ extra_tools=extra or None,
250
+ )
259
251
  return
260
252
  except Exception as e:
261
- # Dependency missing or terminal doesn't support full-screen.
262
- # Print one line so users know how to fix it.
263
253
  print(
264
- f"[tui] failed to start: {type(e).__name__}: {e} (falling back to plain REPL). "
265
- "Install extras with: pip install 'gemcode[tui]'",
266
- file=sys.stderr,
254
+ f"[tui] failed to start: {type(e).__name__}: {e} (falling back to plain REPL).",
255
+ file=sys.stderr,
267
256
  )
268
257
 
269
258
  print(
@@ -8,13 +8,13 @@ from dataclasses import dataclass
8
8
 
9
9
  from google.adk.agents.run_config import RunConfig
10
10
  from google.genai import types
11
+ from rich.console import Console
11
12
 
12
13
  from gemcode.capability_routing import apply_capability_routing
13
14
  from gemcode.config import load_cli_environment
14
15
  from gemcode.model_routing import pick_effective_model
15
16
  from gemcode.repl_slash import process_repl_slash
16
- from gemcode.version import get_version
17
- from gemcode.workspace_hints import narrow_workspace_tip
17
+ from gemcode.tui.welcome_rich import print_shortcuts_hint, print_welcome_dashboard
18
18
 
19
19
  _ADK_REQUEST_CONFIRMATION = "adk_request_confirmation"
20
20
 
@@ -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:
@@ -151,62 +154,6 @@ def _hr(ch: str = "─") -> str:
151
154
  return ch * _term_width()
152
155
 
153
156
 
154
- def _dashboard(cfg) -> str:
155
- w = _term_width()
156
- title = f" GemCode v{os.environ.get('GEMCODE_VERSION', get_version())} "
157
- left_w = (w - 4) * 2 // 3
158
- right_w = (w - 4) - left_w
159
-
160
- def pad(s: str, ww: int) -> str:
161
- s = s.replace("\n", " ")
162
- if len(s) > ww:
163
- return s[: ww - 1] + "…"
164
- return s + (" " * (ww - len(s)))
165
-
166
- user = (os.environ.get("USER") or os.environ.get("LOGNAME") or "there").strip()
167
- model = getattr(cfg, "model", "") or ""
168
- root = str(getattr(cfg, "project_root", "") or "")
169
-
170
- box_top = "╭" + ("─" * (w - 2)) + "╮"
171
- box_bot = "╰" + ("─" * (w - 2)) + "╯"
172
- lines: list[str] = [box_top]
173
- lines.append("│" + pad(title, w - 2) + "│")
174
- lines.append("│" + (" " * (w - 2)) + "│")
175
- left = [
176
- "",
177
- f"Welcome back {user}!",
178
- "",
179
- " ▐▛███▜▌",
180
- " ▝▜█████▛▘",
181
- " ▘▘ ▝▝",
182
- "",
183
- f"{model or 'GemCode'} · Local session",
184
- root,
185
- ]
186
- right = [
187
- "Tips for getting started",
188
- "First run creates .gemcode/ (trust + API key)",
189
- "",
190
- "Recent activity",
191
- "No recent activity",
192
- ]
193
- h = max(len(left), len(right))
194
- left += [""] * (h - len(left))
195
- right += [""] * (h - len(right))
196
- for i in range(h):
197
- lines.append(
198
- "│ " + pad(left[i], left_w) + " │ " + pad(right[i], right_w) + " │"
199
- )
200
- nt = narrow_workspace_tip(getattr(cfg, "project_root"))
201
- if nt:
202
- lines.append("│" + pad(f" {nt}", w - 2) + "│")
203
- lines.append(box_bot)
204
- lines.append("")
205
- lines.append(" ↑ GemCode Pro now supports larger contexts · faster streaming")
206
- lines.append("")
207
- return "\n".join(lines)
208
-
209
-
210
157
  async def run_gemcode_scrollback_tui(
211
158
  *, cfg, runner, session_id: str, extra_tools=None
212
159
  ) -> None:
@@ -229,24 +176,15 @@ async def run_gemcode_scrollback_tui(
229
176
  )
230
177
  )
231
178
 
179
+ console = Console(
180
+ width=_term_width(),
181
+ force_terminal=bool(sys.stdout.isatty()),
182
+ no_color=not ansi.enabled,
183
+ highlight=False,
184
+ )
232
185
  if os.environ.get("GEMCODE_TUI_SHOW_DASHBOARD", "1").lower() in ("1", "true", "yes", "on"):
233
- dash = _dashboard(cfg)
234
- if ansi.enabled:
235
- # Color title + the ASCII mark.
236
- lines = dash.splitlines()
237
- if len(lines) >= 2:
238
- lines[1] = (
239
- lines[1]
240
- .replace("GemCode", f"{ansi.blue}{ansi.bold}GemCode{ansi.reset}")
241
- .replace("v", f"{ansi.dim}v{ansi.reset}")
242
- )
243
- for i, ln in enumerate(lines):
244
- if "▐▛███▜▌" in ln or "▝▜█████▛▘" in ln or "▘▘ ▝▝" in ln:
245
- lines[i] = f"{ansi.blue2}{ln}{ansi.reset}"
246
- dash = "\n".join(lines)
247
- print(dash)
248
-
249
- print(f"{ansi.dim} ? for shortcuts{ansi.reset}")
186
+ print_welcome_dashboard(cfg, console=console)
187
+ print_shortcuts_hint(console=console)
250
188
  print("")
251
189
 
252
190
  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)