kiwi-code 0.0.444__tar.gz → 0.0.445__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 (69) hide show
  1. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/__init__.py +1 -1
  4. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_runtime/__init__.py +1 -1
  5. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_runtime/main.py +48 -2
  6. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/__init__.py +1 -1
  7. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/main.py +169 -62
  8. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/runtime_agent.py +10 -21
  9. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/dashboard.py +50 -0
  10. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/term_dashboard.py +65 -4
  11. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/slash_commands.py +35 -1
  12. kiwi_code-0.0.445/src/kiwi_tui/ui_settings.py +40 -0
  13. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_term_dashboard_ui.py +20 -0
  14. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/uv.lock +1 -1
  15. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.github/workflows/publish.yml +0 -0
  16. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.github/workflows/test.yml +0 -0
  17. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.gitignore +0 -0
  18. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.python-version +0 -0
  19. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/CLAUDE.md +0 -0
  20. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/Makefile +0 -0
  21. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/README.md +0 -0
  22. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/auth.py +0 -0
  23. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/checkpoints.py +0 -0
  24. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/cli.py +0 -0
  25. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/client.py +0 -0
  26. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/commands.py +0 -0
  27. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/logger.py +0 -0
  28. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/models.py +0 -0
  29. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/runtime_manager.py +0 -0
  30. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/server.py +0 -0
  31. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/terminal_mode.py +0 -0
  32. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_runtime/__main__.py +0 -0
  33. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/inline_file_picker.py +0 -0
  34. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/random_words.py +0 -0
  35. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/__init__.py +0 -0
  36. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/attach_content.py +0 -0
  37. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/command_result.py +0 -0
  38. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/detach_files.py +0 -0
  39. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/file_browser.py +0 -0
  40. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/help.py +0 -0
  41. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/id_picker.py +0 -0
  42. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/login.py +0 -0
  43. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  44. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  45. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/slash_picker.py +0 -0
  46. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/status_words.py +0 -0
  47. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/term_app.py +0 -0
  48. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/widgets.py +0 -0
  49. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/worktrees.py +0 -0
  50. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/test_hello.py +0 -0
  51. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/__init__.py +0 -0
  52. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/conftest.py +0 -0
  53. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_batch_fs_tool.py +0 -0
  54. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_checkpoints.py +0 -0
  55. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_cli_help.py +0 -0
  56. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_copy_path_fs_tool.py +0 -0
  57. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_imports.py +0 -0
  58. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_read_file_streaming.py +0 -0
  59. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_reexec_kiwi.py +0 -0
  60. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_runtime_log_trimming.py +0 -0
  61. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_search_in_files_fs_tool.py +0 -0
  62. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_slash_commands.py +0 -0
  63. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_terminal_mode.py +0 -0
  64. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tokens.py +0 -0
  65. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tui_headless.py +0 -0
  66. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tui_interactive_runtime.py +0 -0
  67. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tui_palette.py +0 -0
  68. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_unified_diff.py +0 -0
  69. {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.444
3
+ Version: 0.0.445
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.444"
3
+ version = "0.0.445"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -1,3 +1,3 @@
1
1
  """Kiwi CLI - command-line interface and shared infrastructure modules."""
2
2
 
3
- __version__ = "0.0.444"
3
+ __version__ = "0.0.445"
@@ -1,3 +1,3 @@
1
1
  """Kiwi Runtime — terminal agent that connects to the server via WebSocket."""
2
2
 
3
- __version__ = "0.0.444"
3
+ __version__ = "0.0.445"
@@ -56,6 +56,11 @@ TOKEN_EXPIRY_SKEW_SEC = 60 # refresh a bit early to avoid edge cases
56
56
  KIWI_TMP_DIR = Path.home() / ".kiwi" / "tmp"
57
57
 
58
58
 
59
+ class _AuthRetry(Exception):
60
+ def __init__(self, token: str, reason: str | None = None) -> None:
61
+ super().__init__(reason or "auth_retry")
62
+ self.token = token
63
+
59
64
  def _parse_expires_at(value: Any) -> datetime | None:
60
65
  """Parse expires_at from tokens.json.
61
66
 
@@ -4227,6 +4232,8 @@ async def connect(
4227
4232
  allowed_dirs: list[str] | None = None,
4228
4233
  agent_id: str | None = None,
4229
4234
  consumer_id: str | None = None,
4235
+ relogin_email: str | None = None,
4236
+ relogin_password: str | None = None,
4230
4237
  *,
4231
4238
  max_retries: int = 12,
4232
4239
  ):
@@ -4313,6 +4320,8 @@ async def connect(
4313
4320
  allowed_dirs=allowed_dirs,
4314
4321
  agent_id=agent_id,
4315
4322
  consumer_id=consumer_id,
4323
+ relogin_email=relogin_email,
4324
+ relogin_password=relogin_password,
4316
4325
  command_queue=command_queue,
4317
4326
  pending_results=pending_results,
4318
4327
  pending_lock=pending_lock,
@@ -4322,6 +4331,9 @@ async def connect(
4322
4331
  return
4323
4332
  except asyncio.CancelledError:
4324
4333
  raise
4334
+ except _AuthRetry as e:
4335
+ token = str(e.token or token)
4336
+ continue
4325
4337
  except websockets.exceptions.ConnectionClosed as e:
4326
4338
  print()
4327
4339
  print_status("!", f"Connection closed: {e}", YELLOW)
@@ -4359,6 +4371,8 @@ async def _connect_once(
4359
4371
  allowed_dirs: list[str] | None = None,
4360
4372
  agent_id: str | None = None,
4361
4373
  consumer_id: str | None = None,
4374
+ relogin_email: str | None = None,
4375
+ relogin_password: str | None = None,
4362
4376
  *,
4363
4377
  command_queue: asyncio.Queue[tuple[str, str]],
4364
4378
  pending_results: deque[dict[str, Any]],
@@ -4396,7 +4410,22 @@ async def _connect_once(
4396
4410
  await ws.send(json.dumps(auth_msg))
4397
4411
  auth_resp = json.loads(await ws.recv())
4398
4412
  if auth_resp.get("type") == "error":
4399
- print_status("x", f"Auth failed: {auth_resp.get('message')}", RED)
4413
+ err_msg = str(auth_resp.get("message") or "").strip()
4414
+ print_status("x", f"Auth failed: {err_msg}", RED)
4415
+
4416
+ should_relogin = (
4417
+ bool(relogin_email)
4418
+ and bool(relogin_password)
4419
+ and any(x in err_msg.lower() for x in ("expired", "invalid"))
4420
+ )
4421
+ if should_relogin:
4422
+ try:
4423
+ new_token = await login(http_base_url, str(relogin_email), str(relogin_password))
4424
+ raise _AuthRetry(new_token, reason=err_msg)
4425
+ except _AuthRetry:
4426
+ raise
4427
+ except Exception as e:
4428
+ print_status("x", f"Re-authentication failed: {e}", RED)
4400
4429
  return
4401
4430
 
4402
4431
  if auth_resp.get("type") == "auth_ok":
@@ -4958,6 +4987,10 @@ def main():
4958
4987
  loop = asyncio.new_event_loop()
4959
4988
  agent_id = str(uuid.uuid4())
4960
4989
 
4990
+
4991
+ relogin_email: str | None = None
4992
+ relogin_password: str | None = None
4993
+
4961
4994
  provided_token = args.token or os.environ.get("KIWI_AUTH_TOKEN")
4962
4995
  if provided_token:
4963
4996
  # Use provided token — skip interactive login
@@ -4973,6 +5006,9 @@ def main():
4973
5006
  else:
4974
5007
  email = input(f" {C}>{RESET} Email: ")
4975
5008
  password = getpass.getpass(f" {C}>{RESET} Password: ")
5009
+ # Keep credentials in-memory so we can re-authenticate on token expiry.
5010
+ relogin_email = email
5011
+ relogin_password = password
4976
5012
  print()
4977
5013
  print_status(">", f"Agent ID: {BOLD}{agent_id}{RESET}", GREY)
4978
5014
 
@@ -5009,7 +5045,17 @@ def main():
5009
5045
  signal.signal(signal.SIGINT, lambda *_: shutdown())
5010
5046
 
5011
5047
  try:
5012
- loop.run_until_complete(connect(ws_url, token, http_base_url=http_url, mode=mode, allowed_dirs=allowed_dirs, agent_id=agent_id, consumer_id=args.run_id,))
5048
+ loop.run_until_complete(connect(
5049
+ ws_url,
5050
+ token,
5051
+ http_base_url=http_url,
5052
+ mode=mode,
5053
+ allowed_dirs=allowed_dirs,
5054
+ agent_id=agent_id,
5055
+ consumer_id=args.run_id,
5056
+ relogin_email=relogin_email,
5057
+ relogin_password=relogin_password,
5058
+ ))
5013
5059
  except asyncio.CancelledError:
5014
5060
  pass
5015
5061
  finally:
@@ -1,3 +1,3 @@
1
1
  """Autobots TUI - A textual-based terminal user interface."""
2
2
 
3
- __version__ = "0.0.444"
3
+ __version__ = "0.0.445"
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from textual.app import App
11
11
  from textual.binding import Binding
12
+ from textual.theme import Theme
12
13
  from loguru import logger
13
14
 
14
15
  from kiwi_cli.logger import setup_logging
@@ -37,6 +38,8 @@ from kiwi_tui.runtime_agent import (
37
38
  kill_pid,
38
39
  )
39
40
 
41
+ from kiwi_tui.ui_settings import load_settings as load_ui_settings, update_settings as update_ui_settings
42
+
40
43
 
41
44
 
42
45
  class AutobotsTUI(App):
@@ -170,6 +173,15 @@ class AutobotsTUI(App):
170
173
  super().__init__()
171
174
  self.config: AppConfig = config or AppConfig()
172
175
 
176
+ try:
177
+ self._register_kiwi_theme()
178
+ preferred = self._resolve_initial_theme()
179
+ ok, _msg = self.set_theme_preference(preferred, persist=False)
180
+ if not ok:
181
+ self.set_theme_preference(self.DEFAULT_THEME, persist=False)
182
+ except Exception:
183
+ pass
184
+
173
185
  # Runtime flags for this TUI session (mirrors `kiwi-runtime connect ...`).
174
186
  # Note: runtime processes themselves are tracked per *run_id* (see runtime_agent.py).
175
187
  self.runtime_args: RuntimeConnectArgs = runtime_args or RuntimeConnectArgs()
@@ -270,6 +282,81 @@ class AutobotsTUI(App):
270
282
  BRAND_CYAN_DARK = "#63d8dc" # softened cyan — same feel, less eye strain on dark backgrounds
271
283
  BRAND_CYAN_LIGHT = "#0b7084" # slightly softened teal for light backgrounds
272
284
 
285
+
286
+ DEFAULT_THEME = "kiwi-black"
287
+
288
+ def _register_kiwi_theme(self) -> None:
289
+ """Register Kiwi's default theme with Textual's theme system."""
290
+ try:
291
+ self.register_theme(
292
+ Theme(
293
+ name=self.DEFAULT_THEME,
294
+ primary=self.BRAND_CYAN_DARK,
295
+ secondary=self.BRAND_CYAN_DARK,
296
+ accent=self.BRAND_CYAN_DARK,
297
+ warning=self.BRAND_CYAN_DARK,
298
+ error=self.BRAND_CYAN_DARK,
299
+ success=self.BRAND_CYAN_DARK,
300
+ foreground="#F3F3F3",
301
+ background="#1E1E1E",
302
+ surface="#252526",
303
+ panel="#333333",
304
+ boost="#2D2D2D",
305
+ dark=True,
306
+ variables={
307
+ # Used by our custom CSS blocks across screens.
308
+ "user-msg-bg": "#3A3A3A",
309
+ "assistant-stream-bg": "#252526",
310
+ },
311
+ )
312
+ )
313
+ except Exception:
314
+ # Theme registration is best-effort; Textual versions differ slightly.
315
+ pass
316
+
317
+ def _resolve_initial_theme(self) -> str:
318
+ """Resolve the initial theme (persisted setting > config default)."""
319
+ settings = load_ui_settings()
320
+ stored = settings.get("theme") if isinstance(settings, dict) else None
321
+ if isinstance(stored, str) and stored.strip():
322
+ return stored.strip()
323
+
324
+ t = str(getattr(self.config, "theme", "") or "").strip()
325
+ tl = t.lower()
326
+ if tl in {"dark", "black", "default"}:
327
+ return self.DEFAULT_THEME
328
+ if tl in {"light"}:
329
+ return "textual-light"
330
+ return t or self.DEFAULT_THEME
331
+
332
+ def set_theme_preference(self, theme_name: str, *, persist: bool = True) -> tuple[bool, str]:
333
+ """Set the active theme and (optionally) persist it to ~/.kiwi/settings.json."""
334
+ raw = str(theme_name or "").strip()
335
+ if not raw:
336
+ return False, "Usage: /theme set <name>"
337
+
338
+ norm = raw
339
+ nl = raw.lower()
340
+ if nl in {"dark", "black", "default"}:
341
+ norm = self.DEFAULT_THEME
342
+ elif nl in {"light"}:
343
+ norm = "textual-light"
344
+
345
+ available = getattr(self, "available_themes", {}) or {}
346
+ if isinstance(available, dict) and available and norm not in available:
347
+ return False, f"Unknown theme: {norm}. Use /theme list."
348
+
349
+ try:
350
+ self.theme = norm
351
+ except Exception as exc:
352
+ return False, f"Failed to set theme: {exc}"
353
+
354
+ if persist:
355
+ try:
356
+ update_ui_settings({"theme": norm})
357
+ except Exception:
358
+ pass
359
+ return True, f"Theme set to: {norm}"
273
360
  def _is_dark_theme(self) -> bool:
274
361
  """Best-effort detection of whether the active theme is dark."""
275
362
  # Newer Textual exposes registered themes via `available_themes`.
@@ -281,72 +368,85 @@ class AutobotsTUI(App):
281
368
  return bool(getattr(self, "dark", True))
282
369
 
283
370
  def get_css_variables(self) -> dict[str, str]:
284
- """Inject theme-aware brand color and a softer neutral palette."""
285
371
  variables = super().get_css_variables()
372
+ theme_name = str(getattr(self, "theme", "") or "").strip()
286
373
  dark = self._is_dark_theme()
287
374
  cyan = self.BRAND_CYAN_DARK if dark else self.BRAND_CYAN_LIGHT
288
375
  variables["brand-cyan"] = cyan
289
376
 
290
- if dark:
291
- # Match the reference terminal theme more closely:
292
- # - global background around #1E1E1E
293
- # - user prompts on a lighter gray chip around #3A3A3A
294
- bg = "#1E1E1E"
295
- surface = "#252526"
296
- raised = "#2D2D2D"
297
- panel = "#333333"
298
- foreground = "#F3F3F3"
299
- user_msg_bg = "#3A3A3A"
300
- assistant_stream_bg = "#252526"
301
- else:
302
- bg = "#f6f7f8"
303
- surface = "#ffffff"
304
- raised = "#eef1f3"
305
- panel = "#dbe7ea"
306
- foreground = "#101213"
307
- user_msg_bg = "#f1f3f4"
308
- assistant_stream_bg = "#f4f6f7"
309
-
310
- variables["background"] = bg
311
- variables["foreground"] = foreground
312
- variables["text"] = foreground
313
-
314
- # Keep the kiwi cyan for accents while softening the surrounding surfaces.
315
- for k in ("primary", "secondary", "accent", "error", "warning", "success"):
316
- variables[k] = cyan
317
-
318
- for k in ("text-warning", "text-error", "text-success"):
319
- variables[k] = cyan
320
-
321
- variables["surface"] = surface
322
- variables["boost"] = raised
323
- variables["primary-background"] = bg
324
- variables["panel"] = panel
325
- variables["border"] = panel
326
- variables["block-hover-background"] = surface
327
- variables["border-blurred"] = raised
328
-
329
- variables["user-msg-bg"] = user_msg_bg
330
- variables["assistant-stream-bg"] = assistant_stream_bg
331
-
332
- variables["scrollbar"] = cyan
333
- variables["scrollbar-hover"] = cyan
334
- variables["scrollbar-active"] = cyan
335
- variables["scrollbar-background"] = surface
336
- variables["scrollbar-background-hover"] = surface
337
- variables["scrollbar-background-active"] = raised
338
- variables["scrollbar-corner-color"] = bg
339
-
340
- variables["footer-background"] = surface
341
- variables["footer-foreground"] = foreground
342
- variables["footer-description-background"] = surface
343
- variables["footer-description-foreground"] = foreground
344
-
345
- variables["block-cursor-foreground"] = cyan
346
- variables["block-cursor-background"] = bg
347
- variables["block-cursor-blurred-foreground"] = cyan
348
- variables["block-cursor-blurred-background"] = bg
377
+ use_kiwi_palette = (not theme_name) or (theme_name == self.DEFAULT_THEME)
378
+ if use_kiwi_palette:
379
+ if dark:
380
+ bg = "#1E1E1E"
381
+ surface = "#252526"
382
+ raised = "#2D2D2D"
383
+ panel = "#333333"
384
+ foreground = "#F3F3F3"
385
+ user_msg_bg = "#3A3A3A"
386
+ assistant_stream_bg = "#252526"
387
+ else:
388
+ bg = "#f6f7f8"
389
+ surface = "#ffffff"
390
+ raised = "#eef1f3"
391
+ panel = "#dbe7ea"
392
+ foreground = "#101213"
393
+ user_msg_bg = "#f1f3f4"
394
+ assistant_stream_bg = "#f4f6f7"
395
+
396
+ variables["background"] = bg
397
+ variables["foreground"] = foreground
398
+ variables["text"] = foreground
399
+
400
+ for k in ("primary", "secondary", "accent", "error", "warning", "success"):
401
+ variables[k] = cyan
402
+
403
+ for k in ("text-warning", "text-error", "text-success"):
404
+ variables[k] = cyan
405
+
406
+ variables["surface"] = surface
407
+ variables["boost"] = raised
408
+ variables["primary-background"] = bg
409
+ variables["panel"] = panel
410
+ variables["border"] = panel
411
+ variables["block-hover-background"] = surface
412
+ variables["border-blurred"] = raised
413
+
414
+ variables["user-msg-bg"] = user_msg_bg
415
+ variables["assistant-stream-bg"] = assistant_stream_bg
416
+
417
+ variables["scrollbar"] = cyan
418
+ variables["scrollbar-hover"] = cyan
419
+ variables["scrollbar-active"] = cyan
420
+ variables["scrollbar-background"] = surface
421
+ variables["scrollbar-background-hover"] = surface
422
+ variables["scrollbar-background-active"] = raised
423
+ variables["scrollbar-corner-color"] = bg
424
+
425
+ variables["footer-background"] = surface
426
+ variables["footer-foreground"] = foreground
427
+ variables["footer-description-background"] = surface
428
+ variables["footer-description-foreground"] = foreground
429
+
430
+ variables["block-cursor-foreground"] = cyan
431
+ variables["block-cursor-background"] = bg
432
+ variables["block-cursor-blurred-foreground"] = cyan
433
+ variables["block-cursor-blurred-background"] = bg
434
+ return variables
435
+
436
+ if not variables.get("user-msg-bg"):
437
+ variables["user-msg-bg"] = (
438
+ variables.get("boost")
439
+ or variables.get("surface")
440
+ or variables.get("background")
441
+ or "#3A3A3A"
442
+ )
443
+ if not variables.get("assistant-stream-bg"):
444
+ variables["assistant-stream-bg"] = (
445
+ variables.get("surface") or variables.get("background") or "#252526"
446
+ )
349
447
  return variables
448
+
449
+
350
450
  def watch_theme(self, theme: str) -> None:
351
451
  """Re-resolve palette variables whenever the user switches theme."""
352
452
  try:
@@ -459,8 +559,15 @@ class AutobotsTUI(App):
459
559
  logger.info("Autobots TUI on_mount called")
460
560
  logger.info(f"Backend URL: {self.config.backend_url}")
461
561
 
462
- # Set theme
463
- self.dark = self.config.theme == "dark"
562
+ self._register_kiwi_theme()
563
+ preferred = self._resolve_initial_theme()
564
+ ok, _msg = self.set_theme_preference(preferred, persist=False)
565
+ if not ok:
566
+ self.set_theme_preference(self.DEFAULT_THEME, persist=False)
567
+ try:
568
+ update_ui_settings({"theme": self.DEFAULT_THEME})
569
+ except Exception:
570
+ pass
464
571
 
465
572
  # Check if user is authenticated
466
573
  if self.token_manager.is_authenticated():
@@ -200,35 +200,24 @@ def _pid_is_kiwi_runtime(pid: int) -> bool:
200
200
  # Typical: <python> -m kiwi_runtime.main connect ...
201
201
  return ("kiwi_runtime.main" in joined) and (" connect" in f" {joined} ")
202
202
 
203
-
204
203
  def _log_indicates_disconnected(log_path: Path) -> bool:
205
- """Heuristic: if recent runtime logs show disconnection, treat it as invalid."""
204
+ """Return True if the last ~200 characters contain the shutdown message."""
206
205
  try:
207
206
  if not log_path.exists():
208
207
  return False
209
208
 
210
- # Read a tail chunk (avoid loading huge logs).
211
- max_bytes = 65536
212
- with open(log_path, "rb") as f:
213
- try:
214
- f.seek(0, 2)
215
- size = f.tell()
216
- f.seek(max(0, size - max_bytes), 0)
217
- except Exception:
218
- pass
219
- tail = f.read(max_bytes)
220
-
221
- text = tail.decode("utf-8", errors="ignore")
222
- markers = [
223
- "disconnected. goodbye!",
224
- "connection closed:",
225
- "service restart",
226
- ]
227
- return any(m in text.lower() for m in markers)
209
+ with log_path.open("rb") as f:
210
+ f.seek(0, 2)
211
+ size = f.tell()
212
+
213
+ f.seek(max(0, size - 500))
214
+ text = f.read().decode("utf-8", errors="ignore")
215
+
216
+ return "disconnected. goodbye!" in text[-200:].lower()
217
+
228
218
  except Exception:
229
219
  return False
230
220
 
231
-
232
221
  def _pid_matches_create_time(pid: int, expected_create_time: float | None) -> bool:
233
222
  if expected_create_time is None:
234
223
  return True
@@ -1308,6 +1308,56 @@ class DashboardScreen(Screen):
1308
1308
  show("\n".join(info))
1309
1309
  return
1310
1310
 
1311
+ if cmd == "/theme":
1312
+ if not args:
1313
+ current = getattr(self.app, "theme", None) or "(unknown)"
1314
+ show(
1315
+ "\n".join(
1316
+ [
1317
+ f"Current theme: {current}",
1318
+ "",
1319
+ "Usage:",
1320
+ " /theme list",
1321
+ " /theme set <name>",
1322
+ " /theme reset",
1323
+ " /theme <name>",
1324
+ ]
1325
+ )
1326
+ )
1327
+ return
1328
+
1329
+ sub = args[0].strip().lower()
1330
+ if sub == "list":
1331
+ themes = sorted(getattr(self.app, "available_themes", {}).keys())
1332
+ current = getattr(self.app, "theme", None)
1333
+ lines = ["Available themes:", ""]
1334
+ for t in themes:
1335
+ prefix = "*" if current == t else " "
1336
+ lines.append(f"{prefix} {t}")
1337
+ lines.append("")
1338
+ lines.append("Tip: use /theme set <name> or /theme <name>.")
1339
+ show("\n".join(lines))
1340
+ return
1341
+
1342
+ if sub == "set":
1343
+ if len(args) < 2:
1344
+ toast("Usage: /theme set <name>", title="/theme", severity="warning")
1345
+ return
1346
+ theme_name = args[1]
1347
+ elif sub == "reset":
1348
+ theme_name = "kiwi-black"
1349
+ else:
1350
+ theme_name = args[0]
1351
+
1352
+ ok, msg = getattr(self.app, "set_theme_preference", lambda *_a, **_k: (False, "Theme switch not supported"))(
1353
+ theme_name
1354
+ )
1355
+ if ok:
1356
+ toast(msg, title="/theme")
1357
+ else:
1358
+ toast(msg, title="/theme", severity="error", timeout=4)
1359
+ return
1360
+
1311
1361
  if cmd == "/runtime":
1312
1362
  show("Runtime commands are currently disabled.", is_error=True)
1313
1363
  return
@@ -25,7 +25,7 @@ from textual.widget import Widget
25
25
  from textual.widgets import Static, Button, Input, TextArea
26
26
 
27
27
  from kiwi_tui.widgets import ChatInput
28
- from kiwi_tui.slash_commands import SLASH_COMMANDS, SlashCommand
28
+ from kiwi_tui.slash_commands import SLASH_COMMANDS, SlashCommand, autocomplete_slash_commands
29
29
  from kiwi_tui.inline_file_picker import InlineFilePicker
30
30
  from kiwi_tui.random_words import random_verb
31
31
  from kiwi_tui.screens.file_browser import FileBrowserScreen
@@ -650,6 +650,7 @@ class TermDashboardScreen(Screen):
650
650
  "6a0625b8ab5f80ba8ac5d012",
651
651
  "69e4be0d70c2d7a89197e66e",
652
652
  "6a1c0773c519da8a0fa9b8a4",
653
+ "6a3f8a13f9db0b809d348854",
653
654
  ]
654
655
 
655
656
  BINDINGS = [
@@ -2059,11 +2060,12 @@ class TermDashboardScreen(Screen):
2059
2060
  def _show_slash_autocomplete(self, text: str) -> None:
2060
2061
  """Filter SLASH_COMMANDS by text and rebuild the autocomplete panel."""
2061
2062
  lower = text.strip().lower()
2063
+ commands = autocomplete_slash_commands()
2062
2064
  if lower == "/":
2063
- filtered = list(SLASH_COMMANDS)
2065
+ filtered = list(commands)
2064
2066
  else:
2065
2067
  filtered = [
2066
- cmd for cmd in SLASH_COMMANDS
2068
+ cmd for cmd in commands
2067
2069
  if cmd.template.lower().startswith(lower)
2068
2070
  or lower in cmd.template.lower()
2069
2071
  ]
@@ -2612,7 +2614,16 @@ class TermDashboardScreen(Screen):
2612
2614
  return Text("", style=normal_style)
2613
2615
  if len(blocks) == 1:
2614
2616
  return blocks[0]
2615
- return Group(*blocks)
2617
+
2618
+ combined = Text()
2619
+ for i, block in enumerate(blocks):
2620
+ if i:
2621
+ combined.append("\n")
2622
+ if isinstance(block, Text):
2623
+ combined.append_text(block)
2624
+ else:
2625
+ combined.append(str(block))
2626
+ return combined
2616
2627
 
2617
2628
  def _window_end_turn(self) -> int:
2618
2629
  return min(len(self._turns), int(self._window_start_turn + self._window_size_turns))
@@ -3039,6 +3050,56 @@ class TermDashboardScreen(Screen):
3039
3050
  show("\n".join(info_lines))
3040
3051
  return
3041
3052
 
3053
+ if cmd == "/theme":
3054
+ if not args:
3055
+ current = getattr(self.app, "theme", None) or "(unknown)"
3056
+ show(
3057
+ "\n".join(
3058
+ [
3059
+ f"Current theme: {current}",
3060
+ "",
3061
+ "Usage:",
3062
+ " /theme list",
3063
+ " /theme set <name>",
3064
+ " /theme reset",
3065
+ " /theme <name>",
3066
+ ]
3067
+ )
3068
+ )
3069
+ return
3070
+
3071
+ sub = args[0].strip().lower()
3072
+ if sub == "list":
3073
+ themes = sorted(getattr(self.app, "available_themes", {}).keys())
3074
+ current = getattr(self.app, "theme", None)
3075
+ lines = ["Available themes:", ""]
3076
+ for t in themes:
3077
+ prefix = "*" if current == t else " "
3078
+ lines.append(f"{prefix} {t}")
3079
+ lines.append("")
3080
+ lines.append("Tip: use /theme set <name> or /theme <name>.")
3081
+ show("\n".join(lines))
3082
+ return
3083
+
3084
+ if sub == "set":
3085
+ if len(args) < 2:
3086
+ toast("Usage: /theme set <name>", title="/theme", severity="warning")
3087
+ return
3088
+ theme_name = args[1]
3089
+ elif sub == "reset":
3090
+ theme_name = "kiwi-black"
3091
+ else:
3092
+ theme_name = args[0]
3093
+
3094
+ ok, msg = getattr(self.app, "set_theme_preference", lambda *_a, **_k: (False, "Theme switch not supported"))(
3095
+ theme_name
3096
+ )
3097
+ if ok:
3098
+ toast(msg, title="/theme")
3099
+ else:
3100
+ toast(msg, title="/theme", severity="error", timeout=4)
3101
+ return
3102
+
3042
3103
  if cmd == "/use":
3043
3104
  if not args:
3044
3105
  toast("Usage: /use <action_id>", title="/use", severity="warning")
@@ -47,6 +47,12 @@ SLASH_COMMANDS: list[SlashCommand] = [
47
47
  SlashCommand("Runtime", "/show-logs", "Show local CLI (runtime) logs", "/show-logs"),
48
48
  SlashCommand("Runtime", "/runtime", "Runtime commands (currently disabled)", "/runtime"),
49
49
 
50
+ # Theme
51
+ SlashCommand("Theme", "/theme", "Show current theme / usage", "/theme"),
52
+ SlashCommand("Theme", "/theme list", "List available themes", "/theme list"),
53
+ SlashCommand("Theme", "/theme set <name>", "Set & persist theme", "/theme set "),
54
+ SlashCommand("Theme", "/theme reset", "Reset to Kiwi default theme", "/theme reset"),
55
+
50
56
  # Query (API)
51
57
  SlashCommand("Query", "/actions list", "List recent actions", "/actions list"),
52
58
  SlashCommand("Query", "/actions get <id>", "Get an action by id", "/actions get "),
@@ -57,5 +63,33 @@ SLASH_COMMANDS: list[SlashCommand] = [
57
63
 
58
64
  def autocomplete_commands() -> list[str]:
59
65
  """Return strings used for chat input autocomplete."""
60
- # We keep the insert_text version for consistency with the actual command parser.
61
66
  return [c.insert_text for c in SLASH_COMMANDS]
67
+
68
+
69
+ def autocomplete_slash_commands() -> list[SlashCommand]:
70
+ cmds = list(SLASH_COMMANDS)
71
+ try:
72
+ from textual.theme import BUILTIN_THEMES
73
+
74
+ for name in sorted(BUILTIN_THEMES.keys()):
75
+ cmds.append(
76
+ SlashCommand(
77
+ category="Theme",
78
+ template=f"/theme {name}",
79
+ description=f"Set theme: {name}",
80
+ insert_text=f"/theme {name}",
81
+ )
82
+ )
83
+ except Exception:
84
+ pass
85
+
86
+ cmds.append(
87
+ SlashCommand(
88
+ category="Theme",
89
+ template="/theme kiwi-black",
90
+ description="Set theme: kiwi-black",
91
+ insert_text="/theme kiwi-black",
92
+ )
93
+ )
94
+
95
+ return cmds
@@ -0,0 +1,40 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from kiwi_cli.checkpoints import kiwi_home_dir
6
+
7
+
8
+ def settings_path(*, kiwi_home: Path | None = None) -> Path:
9
+ home = kiwi_home_dir(override=kiwi_home) if kiwi_home is not None else kiwi_home_dir()
10
+ return home / "settings.json"
11
+
12
+
13
+ def load_settings(*, kiwi_home: Path | None = None) -> dict[str, Any]:
14
+ path = settings_path(kiwi_home=kiwi_home)
15
+ try:
16
+ if not path.exists():
17
+ return {}
18
+ data = json.loads(path.read_text(encoding="utf-8"))
19
+ return data if isinstance(data, dict) else {}
20
+ except Exception:
21
+ return {}
22
+
23
+
24
+ def save_settings(settings: dict[str, Any], *, kiwi_home: Path | None = None) -> None:
25
+ path = settings_path(kiwi_home=kiwi_home)
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+
28
+ tmp = path.with_suffix(path.suffix + ".tmp")
29
+ tmp.write_text(
30
+ json.dumps(settings, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
31
+ encoding="utf-8",
32
+ )
33
+ os.replace(tmp, path)
34
+
35
+
36
+ def update_settings(patch: dict[str, Any], *, kiwi_home: Path | None = None) -> dict[str, Any]:
37
+ settings = load_settings(kiwi_home=kiwi_home)
38
+ settings.update(patch)
39
+ save_settings(settings, kiwi_home=kiwi_home)
40
+ return settings
@@ -573,3 +573,23 @@ async def test_term_open_run_history_lands_at_bottom() -> None:
573
573
 
574
574
  assert root.max_scroll_y > 0
575
575
  assert _at_bottom()
576
+
577
+
578
+ @pytest.mark.asyncio
579
+ async def test_term_assistant_renderable_is_text_for_mixed_code_blocks() -> None:
580
+ from rich.text import Text
581
+
582
+ app = _TermDashboardTestApp()
583
+ async with app.run_test(size=(120, 60)) as pilot:
584
+ await pilot.pause(0.05)
585
+ screen = app.screen
586
+
587
+ body = screen._assistant_renderable(
588
+ "Hello\n\n```py\nprint(1)\n```\n\nWorld",
589
+ role="assistant",
590
+ )
591
+ assert isinstance(body, Text)
592
+ s = str(body)
593
+ assert "Hello" in s
594
+ assert "print(1)" in s
595
+ assert "World" in s
@@ -396,7 +396,7 @@ wheels = [
396
396
 
397
397
  [[package]]
398
398
  name = "kiwi-code"
399
- version = "0.0.444"
399
+ version = "0.0.445"
400
400
  source = { editable = "." }
401
401
  dependencies = [
402
402
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes