kiwi-code 0.0.40__tar.gz → 0.0.42__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 (58) hide show
  1. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/main.py +226 -0
  4. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/main.py +54 -4
  5. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/runtime_agent.py +27 -0
  6. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/dashboard.py +251 -65
  7. kiwi_code-0.0.42/src/kiwi_tui/screens/runtime_logs.py +473 -0
  8. kiwi_code-0.0.42/tests/test_tui_interactive_runtime.py +387 -0
  9. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/uv.lock +1 -1
  10. kiwi_code-0.0.40/src/kiwi_tui/screens/runtime_logs.py +0 -186
  11. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.github/workflows/publish.yml +0 -0
  12. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.github/workflows/test.yml +0 -0
  13. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.gitignore +0 -0
  14. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.python-version +0 -0
  15. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/CLAUDE.md +0 -0
  16. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/Makefile +0 -0
  17. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/README.md +0 -0
  18. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/__init__.py +0 -0
  19. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/auth.py +0 -0
  20. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/cli.py +0 -0
  21. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/client.py +0 -0
  22. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/commands.py +0 -0
  23. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/logger.py +0 -0
  24. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/models.py +0 -0
  25. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/runtime_manager.py +0 -0
  26. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/server.py +0 -0
  27. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/terminal_mode.py +0 -0
  28. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/__init__.py +0 -0
  29. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/__main__.py +0 -0
  30. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  31. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  32. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/__init__.py +0 -0
  33. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/inline_file_picker.py +0 -0
  34. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/random_words.py +0 -0
  35. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/__init__.py +0 -0
  36. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/attach_content.py +0 -0
  37. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/command_result.py +0 -0
  38. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/file_browser.py +0 -0
  39. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/help.py +0 -0
  40. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/id_picker.py +0 -0
  41. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/login.py +0 -0
  42. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  43. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/slash_picker.py +0 -0
  44. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/slash_commands.py +0 -0
  45. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/status_words.py +0 -0
  46. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/widgets.py +0 -0
  47. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/test_hello.py +0 -0
  48. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/__init__.py +0 -0
  49. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/conftest.py +0 -0
  50. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_cli_help.py +0 -0
  51. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_imports.py +0 -0
  52. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_reexec_kiwi.py +0 -0
  53. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_runtime_log_trimming.py +0 -0
  54. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_slash_commands.py +0 -0
  55. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_terminal_mode.py +0 -0
  56. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_tokens.py +0 -0
  57. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_tui_headless.py +0 -0
  58. {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_tui_palette.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.40
3
+ Version: 0.0.42
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.40"
3
+ version = "0.0.42"
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"
@@ -1713,6 +1713,7 @@ async def run_interactive_command(
1713
1713
  command: str,
1714
1714
  mode: str = "restricted",
1715
1715
  allowed_dirs: list[str] | None = None,
1716
+ request_id: str | None = None,
1716
1717
  ) -> dict:
1717
1718
  """Execute a command with the user's terminal attached for interactive I/O."""
1718
1719
  if mode == "restricted" and allowed_dirs:
@@ -1720,6 +1721,16 @@ async def run_interactive_command(
1720
1721
  if not ok:
1721
1722
  return {"stdout": "", "stderr": reason, "exit_code": 1}
1722
1723
 
1724
+ control_dir = _tui_managed_control_dir()
1725
+ if control_dir is not None:
1726
+ return await run_tui_managed_interactive_command(
1727
+ command,
1728
+ request_id=request_id or str(uuid.uuid4()),
1729
+ control_dir=control_dir,
1730
+ mode=mode,
1731
+ allowed_dirs=allowed_dirs,
1732
+ )
1733
+
1723
1734
  try:
1724
1735
  cwd = allowed_dirs[0] if allowed_dirs else None
1725
1736
  print(f"\n {CB}{'─' * 50}{RESET}")
@@ -2262,6 +2273,220 @@ def _ensure_windows_persistent_completion(data: str) -> str:
2262
2273
 
2263
2274
  return f"{base} & echo {_SENTINEL}!ERRORLEVEL!{_SENTINEL}\r\n"
2264
2275
 
2276
+
2277
+ def _tui_managed_control_dir() -> Path | None:
2278
+ raw_flag = os.environ.get("KIWI_TUI_MANAGED", "").strip().lower()
2279
+ if raw_flag not in {"1", "true", "yes", "on"}:
2280
+ return None
2281
+ raw_path = os.environ.get("KIWI_RUNTIME_CONTROL_DIR", "").strip()
2282
+ if not raw_path:
2283
+ return None
2284
+ return Path(raw_path)
2285
+
2286
+
2287
+ class _LocalInteractivePTYBridge:
2288
+ def __init__(self, *, control_dir: Path, request_id: str, command: str) -> None:
2289
+ self.control_dir = control_dir
2290
+ self.request_id = request_id
2291
+ self.command = command
2292
+ self.session_dir = control_dir / "interactive"
2293
+ self.status_path = self.session_dir / "status.json"
2294
+ self.input_path = self.session_dir / "input.jsonl"
2295
+ self.output_path = self.session_dir / "output.log"
2296
+ self._input_offset = 0
2297
+ self._input_buffer = ""
2298
+ self.exit_code: int | None = None
2299
+ self.error: str | None = None
2300
+ self._done = asyncio.Event()
2301
+
2302
+ def activate(self) -> None:
2303
+ self.session_dir.mkdir(parents=True, exist_ok=True)
2304
+ self.output_path.write_text("", encoding="utf-8")
2305
+ self.input_path.write_text("", encoding="utf-8")
2306
+ self._input_offset = 0
2307
+ self._input_buffer = ""
2308
+ self._write_status(
2309
+ active=True,
2310
+ request_id=self.request_id,
2311
+ command=self.command,
2312
+ output_path=str(self.output_path),
2313
+ input_path=str(self.input_path),
2314
+ started_at=datetime.now().isoformat(),
2315
+ ended_at=None,
2316
+ exit_code=None,
2317
+ error=None,
2318
+ )
2319
+
2320
+ def _write_status(self, **updates: Any) -> None:
2321
+ status: dict[str, Any] = {}
2322
+ if self.status_path.exists():
2323
+ try:
2324
+ status = json.loads(self.status_path.read_text(encoding="utf-8") or "{}")
2325
+ except Exception:
2326
+ status = {}
2327
+ status.update(updates)
2328
+ self.status_path.write_text(json.dumps(status, indent=2, default=str), encoding="utf-8")
2329
+
2330
+ def append_output(self, data: str) -> None:
2331
+ if not data:
2332
+ return
2333
+ with open(self.output_path, "a", encoding="utf-8", errors="replace") as fp:
2334
+ fp.write(data)
2335
+ fp.flush()
2336
+
2337
+ async def send(self, payload: str) -> None:
2338
+ msg = json.loads(payload)
2339
+ msg_type = msg.get("type")
2340
+ if msg_type == "pty_started":
2341
+ self._write_status(active=True, pty_started=True, session_id=msg.get("session_id"))
2342
+ return
2343
+ if msg_type == "pty_output":
2344
+ self.append_output(str(msg.get("data", "")))
2345
+ return
2346
+ if msg_type == "pty_exited":
2347
+ try:
2348
+ self.exit_code = int(msg.get("exit_code", -1))
2349
+ except Exception:
2350
+ self.exit_code = -1
2351
+ self._write_status(
2352
+ active=False,
2353
+ ended_at=datetime.now().isoformat(),
2354
+ exit_code=self.exit_code,
2355
+ )
2356
+ self._done.set()
2357
+ return
2358
+ if msg_type == "pty_error":
2359
+ self.error = str(msg.get("error", ""))
2360
+ if self.error:
2361
+ self.append_output(f"\r\n[PTY error] {self.error}\r\n")
2362
+ self._write_status(
2363
+ active=False,
2364
+ ended_at=datetime.now().isoformat(),
2365
+ exit_code=-1,
2366
+ error=self.error,
2367
+ )
2368
+ self._done.set()
2369
+
2370
+ def _read_new_events(self) -> list[dict[str, Any]]:
2371
+ if not self.input_path.exists():
2372
+ return []
2373
+ try:
2374
+ with open(self.input_path, "r", encoding="utf-8", errors="replace") as fp:
2375
+ fp.seek(self._input_offset)
2376
+ chunk = fp.read()
2377
+ self._input_offset = fp.tell()
2378
+ except Exception:
2379
+ return []
2380
+
2381
+ if not chunk:
2382
+ return []
2383
+
2384
+ events: list[dict[str, Any]] = []
2385
+ data = self._input_buffer + chunk
2386
+ self._input_buffer = ""
2387
+ for line in data.splitlines(keepends=True):
2388
+ if not line.endswith("\n"):
2389
+ self._input_buffer = line
2390
+ continue
2391
+ raw = line.strip()
2392
+ if not raw:
2393
+ continue
2394
+ try:
2395
+ parsed = json.loads(raw)
2396
+ except Exception:
2397
+ continue
2398
+ if isinstance(parsed, dict):
2399
+ events.append(parsed)
2400
+ return events
2401
+
2402
+ async def pump_inputs(self, pty_proc: Any) -> None:
2403
+ while not self._done.is_set():
2404
+ if getattr(getattr(pty_proc, "proc", None), "returncode", None) is not None:
2405
+ break
2406
+ for event in self._read_new_events():
2407
+ event_type = str(event.get("type", "")).strip().lower()
2408
+ if event_type == "input":
2409
+ data = str(event.get("data", ""))
2410
+ if data:
2411
+ await pty_proc.send_input(data)
2412
+ elif event_type == "interrupt":
2413
+ await pty_proc.interrupt()
2414
+ elif event_type == "close":
2415
+ await pty_proc.close()
2416
+ await asyncio.sleep(0.1)
2417
+
2418
+ async def wait(self) -> int:
2419
+ await self._done.wait()
2420
+ return self.exit_code if self.exit_code is not None else -1
2421
+
2422
+
2423
+ async def run_tui_managed_interactive_command(
2424
+ command: str,
2425
+ *,
2426
+ request_id: str,
2427
+ control_dir: Path,
2428
+ mode: str = "restricted",
2429
+ allowed_dirs: list[str] | None = None,
2430
+ ) -> dict:
2431
+ if mode == "restricted" and allowed_dirs:
2432
+ ok, reason = validate_command(command, allowed_dirs)
2433
+ if not ok:
2434
+ return {"stdout": "", "stderr": reason, "exit_code": 1}
2435
+
2436
+ cwd = allowed_dirs[0] if allowed_dirs else None
2437
+ bridge = _LocalInteractivePTYBridge(control_dir=control_dir, request_id=request_id, command=command)
2438
+ bridge.activate()
2439
+ bridge.append_output(f"$ {command}\r\n")
2440
+
2441
+ session_id = request_id or str(uuid.uuid4())
2442
+ input_task: asyncio.Task[Any] | None = None
2443
+ pty_proc = None
2444
+ try:
2445
+ if IS_WINDOWS:
2446
+ pty_proc = PipeProcess(
2447
+ session_id,
2448
+ command,
2449
+ 120,
2450
+ 40,
2451
+ mode=mode,
2452
+ allowed_dirs=allowed_dirs,
2453
+ )
2454
+ else:
2455
+ pty_proc = PTYProcess(session_id, command, 120, 40)
2456
+ await pty_proc.start(bridge, cwd=cwd)
2457
+ input_task = asyncio.create_task(bridge.pump_inputs(pty_proc))
2458
+ exit_code = await bridge.wait()
2459
+ return {
2460
+ "stdout": "(interactive session completed)",
2461
+ "stderr": "",
2462
+ "exit_code": exit_code,
2463
+ }
2464
+ except Exception as e:
2465
+ message = str(e)
2466
+ bridge.append_output(f"\r\n[Interactive error] {message}\r\n")
2467
+ bridge._write_status(
2468
+ active=False,
2469
+ ended_at=datetime.now().isoformat(),
2470
+ exit_code=-1,
2471
+ error=message,
2472
+ )
2473
+ return {"stdout": "", "stderr": message, "exit_code": -1}
2474
+ finally:
2475
+ if input_task and not input_task.done():
2476
+ input_task.cancel()
2477
+ try:
2478
+ await input_task
2479
+ except asyncio.CancelledError:
2480
+ pass
2481
+ except Exception:
2482
+ pass
2483
+ if pty_proc is not None:
2484
+ try:
2485
+ await pty_proc.close()
2486
+ except Exception:
2487
+ pass
2488
+
2489
+
2265
2490
  def _zip_directory(resolved, tmp_path, mode, allowed_dirs):
2266
2491
  """Synchronous helper to zip a directory (run via asyncio.to_thread)."""
2267
2492
  import zipfile
@@ -2520,6 +2745,7 @@ async def connect(
2520
2745
  command,
2521
2746
  mode=mode,
2522
2747
  allowed_dirs=allowed_dirs,
2748
+ request_id=request_id,
2523
2749
  )
2524
2750
  exit_code = result["exit_code"]
2525
2751
  success = exit_code == 0
@@ -610,11 +610,12 @@ class AutobotsTUI(App):
610
610
  # runtime_manager.save_refresh_token(tokens.refresh_token)
611
611
 
612
612
  def _refresh_token_if_needed(self, force: bool = False) -> bool:
613
- """Synchronize auth state from disk and refresh only when needed.
613
+ """Synchronize auth state from disk and refresh when needed.
614
614
 
615
615
  Args:
616
- force: If True, re-check disk state immediately and refresh only if
617
- the shared on-disk access token is still expired.
616
+ force: If True, attempt a refresh-token exchange immediately even if
617
+ the current access token does not appear expired on disk. This is
618
+ used after an HTTP 401/403 from the server.
618
619
  """
619
620
  with self.token_manager.file_lock():
620
621
  tokens = self.token_manager.load_tokens()
@@ -622,7 +623,7 @@ class AutobotsTUI(App):
622
623
  logger.debug("No tokens to refresh")
623
624
  return False
624
625
 
625
- if not tokens.is_expired():
626
+ if not force and not tokens.is_expired():
626
627
  logger.debug("Token on disk is valid; synchronizing in-memory auth state")
627
628
  if getattr(tokens, "access_token", None):
628
629
  self.autobots_client.update_token(tokens.access_token)
@@ -683,6 +684,55 @@ class AutobotsTUI(App):
683
684
  except Exception:
684
685
  pass
685
686
 
687
+ def _handle_auth_expired(self, reason: str | None = None) -> None:
688
+ """Clear auth state and return the user to the login screen.
689
+
690
+ Used when the server rejects the current session (401/403) and a forced
691
+ token refresh cannot recover it.
692
+ """
693
+ logger.warning(f"Authentication expired; redirecting to login. Reason: {reason or 'unknown'}")
694
+
695
+ try:
696
+ screen = self.screen
697
+ if hasattr(screen, "reset_for_auth_expired"):
698
+ screen.reset_for_auth_expired()
699
+ except Exception:
700
+ pass
701
+
702
+ timer = getattr(self, "_token_refresh_timer", None)
703
+ if timer is not None:
704
+ try:
705
+ timer.stop()
706
+ except Exception:
707
+ pass
708
+ self._token_refresh_timer = None
709
+
710
+ self.pending_runtime_id = None
711
+ self.token_manager.clear_tokens()
712
+ self._kiwi_token = None
713
+
714
+ self.autobots_client = AutobotsClientWrapper(
715
+ base_url=self.config.backend_url,
716
+ api_key=self.config.api_key,
717
+ )
718
+
719
+ try:
720
+ already_on_login = type(self.screen).__name__ == "LoginScreen"
721
+ except Exception:
722
+ already_on_login = False
723
+
724
+ if not already_on_login:
725
+ try:
726
+ self.switch_screen("login")
727
+ except IndexError:
728
+ self.push_screen("login")
729
+
730
+ self.notify(
731
+ reason or "Your session expired. Please log in again.",
732
+ severity="error",
733
+ title="Authentication required",
734
+ )
735
+
686
736
  def action_logout(self) -> None:
687
737
  """Logout user and return to login screen."""
688
738
  logger.info("User logout requested")
@@ -57,6 +57,7 @@ class RuntimeConnectArgs:
57
57
  RUNTIMES_DIR = Path.home() / ".kiwi" / "runtimes"
58
58
  BY_RUN_DIR = RUNTIMES_DIR / "by-run"
59
59
  PENDING_DIR = RUNTIMES_DIR / "pending"
60
+ CONTROL_DIR = RUNTIMES_DIR / "control"
60
61
 
61
62
 
62
63
  # Runtime log truncation
@@ -282,6 +283,11 @@ def _runtime_dir_for_pending(pending_id: str) -> Path:
282
283
  d.mkdir(parents=True, exist_ok=True)
283
284
  return d
284
285
 
286
+ def _control_dir_for_runtime(control_id: str) -> Path:
287
+ d = CONTROL_DIR / control_id
288
+ d.mkdir(parents=True, exist_ok=True)
289
+ return d
290
+
285
291
 
286
292
  def pid_path_for_run(run_id: str) -> Path:
287
293
  return _runtime_dir_for_run(run_id) / "pid"
@@ -336,6 +342,19 @@ def meta_path_for_pending(pending_id: str) -> Path:
336
342
  return _runtime_dir_for_pending(pending_id) / "meta.json"
337
343
 
338
344
 
345
+ def control_dir_from_meta(meta_path: Path) -> Path | None:
346
+ if not meta_path.exists():
347
+ return None
348
+ try:
349
+ meta = json.loads(meta_path.read_text(encoding="utf-8") or "{}")
350
+ except Exception:
351
+ return None
352
+
353
+ control_dir = meta.get("control_dir")
354
+ if isinstance(control_dir, str) and control_dir.strip():
355
+ return Path(control_dir)
356
+ return None
357
+
339
358
  def get_running_pid_for_run(run_id: str) -> int | None:
340
359
  pid = _read_pid(pid_path_for_run(run_id))
341
360
  if not pid:
@@ -434,6 +453,11 @@ def _spawn_runtime(
434
453
  env.setdefault("PYTHONUTF8", "1")
435
454
  env.setdefault("PYTHONIOENCODING", "utf-8:replace")
436
455
 
456
+ control_id = str(uuid.uuid4())
457
+ control_dir = _control_dir_for_runtime(control_id)
458
+ env["KIWI_TUI_MANAGED"] = "1"
459
+ env["KIWI_RUNTIME_CONTROL_DIR"] = str(control_dir)
460
+
437
461
  try:
438
462
  log_path.parent.mkdir(parents=True, exist_ok=True)
439
463
  log_fp = open(log_path, "a", encoding="utf-8", errors="replace")
@@ -471,6 +495,9 @@ def _spawn_runtime(
471
495
  "allow": list(args.allow_dirs),
472
496
  "email": args.email,
473
497
  "pid": proc.pid,
498
+ "tui_managed": True,
499
+ "control_id": control_id,
500
+ "control_dir": str(control_dir),
474
501
  }
475
502
  try:
476
503
  meta["create_time"] = psutil.Process(proc.pid).create_time()