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.
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/PKG-INFO +1 -1
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/pyproject.toml +1 -1
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/__init__.py +1 -1
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_runtime/__init__.py +1 -1
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_runtime/main.py +48 -2
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/__init__.py +1 -1
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/main.py +169 -62
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/runtime_agent.py +10 -21
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/dashboard.py +50 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/term_dashboard.py +65 -4
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/slash_commands.py +35 -1
- kiwi_code-0.0.445/src/kiwi_tui/ui_settings.py +40 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_term_dashboard_ui.py +20 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/uv.lock +1 -1
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.gitignore +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/.python-version +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/CLAUDE.md +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/Makefile +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/README.md +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/test_hello.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/__init__.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/conftest.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tui_headless.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.444 → kiwi_code-0.0.445}/tests/test_worktrees.py +0 -0
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
@@ -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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
variables[
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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(
|
|
2065
|
+
filtered = list(commands)
|
|
2064
2066
|
else:
|
|
2065
2067
|
filtered = [
|
|
2066
|
-
cmd for cmd in
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|