kiwi-code 0.0.40__tar.gz → 0.0.41__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.41}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_runtime/main.py +226 -0
  4. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/runtime_agent.py +27 -0
  5. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/dashboard.py +12 -3
  6. kiwi_code-0.0.41/src/kiwi_tui/screens/runtime_logs.py +473 -0
  7. kiwi_code-0.0.41/tests/test_tui_interactive_runtime.py +387 -0
  8. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/uv.lock +1 -1
  9. kiwi_code-0.0.40/src/kiwi_tui/screens/runtime_logs.py +0 -186
  10. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/.github/workflows/publish.yml +0 -0
  11. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/.github/workflows/test.yml +0 -0
  12. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/.gitignore +0 -0
  13. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/.python-version +0 -0
  14. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/CLAUDE.md +0 -0
  15. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/Makefile +0 -0
  16. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/README.md +0 -0
  17. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/__init__.py +0 -0
  18. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/auth.py +0 -0
  19. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/cli.py +0 -0
  20. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/client.py +0 -0
  21. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/commands.py +0 -0
  22. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/logger.py +0 -0
  23. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/models.py +0 -0
  24. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/runtime_manager.py +0 -0
  25. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/server.py +0 -0
  26. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_cli/terminal_mode.py +0 -0
  27. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_runtime/__init__.py +0 -0
  28. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_runtime/__main__.py +0 -0
  29. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  30. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  31. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/__init__.py +0 -0
  32. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/inline_file_picker.py +0 -0
  33. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/main.py +0 -0
  34. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/random_words.py +0 -0
  35. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/__init__.py +0 -0
  36. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/attach_content.py +0 -0
  37. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/command_result.py +0 -0
  38. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/file_browser.py +0 -0
  39. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/help.py +0 -0
  40. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/id_picker.py +0 -0
  41. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/login.py +0 -0
  42. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  43. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/screens/slash_picker.py +0 -0
  44. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/slash_commands.py +0 -0
  45. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/status_words.py +0 -0
  46. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/src/kiwi_tui/widgets.py +0 -0
  47. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/test_hello.py +0 -0
  48. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/__init__.py +0 -0
  49. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/conftest.py +0 -0
  50. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_cli_help.py +0 -0
  51. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_imports.py +0 -0
  52. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_reexec_kiwi.py +0 -0
  53. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_runtime_log_trimming.py +0 -0
  54. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_slash_commands.py +0 -0
  55. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_terminal_mode.py +0 -0
  56. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_tokens.py +0 -0
  57. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/tests/test_tui_headless.py +0 -0
  58. {kiwi_code-0.0.40 → kiwi_code-0.0.41}/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.41
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.41"
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
@@ -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()
@@ -638,23 +638,32 @@ class DashboardScreen(Screen):
638
638
  def open_runtime_logs(self) -> None:
639
639
  """Open the runtime logs screen for the current run (or pending runtime)."""
640
640
  from kiwi_tui.screens.runtime_logs import RuntimeLogsScreen
641
- from kiwi_tui.runtime_agent import log_path_for_run, log_path_for_pending
641
+ from kiwi_tui.runtime_agent import (
642
+ control_dir_from_meta,
643
+ log_path_for_pending,
644
+ log_path_for_run,
645
+ meta_path_for_pending,
646
+ meta_path_for_run,
647
+ )
642
648
 
643
649
  lp = None
650
+ control_dir = None
644
651
  title = "CLI logs"
645
652
  if self.current_run_id:
646
653
  lp = log_path_for_run(self.current_run_id)
654
+ control_dir = control_dir_from_meta(meta_path_for_run(self.current_run_id))
647
655
  title = f"CLI logs — run {self.current_run_id}"
648
656
  else:
649
657
  pending_id = getattr(self.app, "pending_runtime_id", None)
650
658
  if pending_id:
651
659
  lp = log_path_for_pending(pending_id)
660
+ control_dir = control_dir_from_meta(meta_path_for_pending(pending_id))
652
661
  title = f"CLI logs — pending {pending_id}"
653
662
 
654
- if not lp:
663
+ if not lp and not control_dir:
655
664
  self.notify("No CLI runtime logs available (use /connect-cli first).", severity="warning")
656
665
  return
657
- self.app.push_screen(RuntimeLogsScreen(lp, title=title))
666
+ self.app.push_screen(RuntimeLogsScreen(lp, title=title, control_dir=control_dir))
658
667
 
659
668
 
660
669
  def on_chat_input_at_triggered(self, event: ChatInput.AtTriggered) -> None: