kiwi-code 0.0.4__tar.gz → 0.0.5__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 (30) hide show
  1. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/Makefile +6 -1
  2. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/PKG-INFO +1 -1
  3. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/pyproject.toml +1 -1
  4. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_runtime/main.py +203 -21
  5. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/main.py +81 -6
  6. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/runtime_manager.py +46 -0
  7. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/screens/login.py +1 -0
  8. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/uv.lock +567 -568
  9. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/.github/workflows/publish.yml +0 -0
  10. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/.gitignore +0 -0
  11. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/.python-version +0 -0
  12. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/README.md +0 -0
  13. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/poetry.lock +0 -0
  14. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_runtime/__init__.py +0 -0
  15. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_runtime/__main__.py +0 -0
  16. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/__init__.py +0 -0
  17. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/auth.py +0 -0
  18. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/cli.py +0 -0
  19. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/client.py +0 -0
  20. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/commands.py +0 -0
  21. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/config.py +0 -0
  22. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/logger.py +0 -0
  23. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/models.py +0 -0
  24. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/screens/__init__.py +0 -0
  25. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/screens/actions.py +0 -0
  26. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/screens/autobots.py +0 -0
  27. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/screens/dashboard.py +0 -0
  28. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  29. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/src/kiwi_tui/widgets.py +0 -0
  30. {kiwi_code-0.0.4 → kiwi_code-0.0.5}/test_hello.py +0 -0
@@ -1,4 +1,4 @@
1
- .PHONY: run dev cli runtime test-hello install sync clean help
1
+ .PHONY: run dev serve cli runtime test-hello install sync clean help
2
2
 
3
3
  # Run the Kiwi TUI application
4
4
  run:
@@ -8,6 +8,10 @@ run:
8
8
  dev:
9
9
  uv run --dev kiwi
10
10
 
11
+ # Serve the Kiwi TUI in the browser via textual serve (e.g. make serve PORT=8566)
12
+ serve:
13
+ uv run textual serve --port $(or $(PORT),8566) -c "python -m kiwi_tui.main"
14
+
11
15
  # Run the Kiwi CLI
12
16
  cli:
13
17
  uv run kiwicli
@@ -53,6 +57,7 @@ help:
53
57
  @echo "Commands:"
54
58
  @echo " run - Run the Kiwi TUI application (kiwi)"
55
59
  @echo " dev - Run TUI in development mode (with live reload)"
60
+ @echo " serve - Serve the TUI in the browser (PORT=8566 make serve)"
56
61
  @echo " cli - Run the Kiwi CLI (kiwicli)"
57
62
  @echo " runtime - Run the Kiwi Runtime agent (kiwi-runtime)"
58
63
  @echo " e.g. make runtime ARGS=\"connect --server app\""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.4
3
+ Version: 0.0.5
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.4"
3
+ version = "0.0.5"
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.13,<4.0"
@@ -370,9 +370,12 @@ def _is_within_allowed(resolved_path: str, allowed_dirs: list[str]) -> bool:
370
370
  # Core logic
371
371
  # ---------------------------------------------------------------------------
372
372
 
373
- async def login(http_base_url: str, email: str, password: str) -> str:
374
- """Authenticate with email/password and return the JWT access token."""
375
- url = f"{http_base_url}/v1/auth/token"
373
+ async def login(http_base_url: str, email: str, password: str) -> dict:
374
+ """Authenticate with email/password via session API.
375
+
376
+ Returns dict with access_token, refresh_token, and expires_in.
377
+ """
378
+ url = f"{http_base_url}/v1/auth"
376
379
  async with httpx.AsyncClient(timeout=30) as client:
377
380
  resp = await client.post(
378
381
  url,
@@ -388,10 +391,41 @@ async def login(http_base_url: str, email: str, password: str) -> str:
388
391
  raise Exception(f"Login failed (HTTP {resp.status_code}): {detail}")
389
392
 
390
393
  body = resp.json()
391
- token = body.get("access_token")
392
- if not token:
393
- raise Exception(f"No access_token in response: {body}")
394
- return token
394
+ session = body.get("session")
395
+ if not session or not session.get("access_token"):
396
+ raise Exception(f"No session in response: {body}")
397
+ return {
398
+ "access_token": session["access_token"],
399
+ "refresh_token": session.get("refresh_token"),
400
+ "expires_in": session.get("expires_in"),
401
+ }
402
+
403
+
404
+ async def refresh_access_token(http_base_url: str, refresh_token: str) -> dict:
405
+ """Use a refresh token to obtain a new access token.
406
+
407
+ Returns dict with access_token, refresh_token, and expires_in.
408
+ """
409
+ url = f"{http_base_url}/v1/auth/session/refresh"
410
+ async with httpx.AsyncClient(timeout=30) as client:
411
+ resp = await client.post(url, params={"refresh_token": refresh_token})
412
+ if resp.status_code != 200:
413
+ detail = resp.text
414
+ try:
415
+ detail = resp.json().get("detail", resp.text)
416
+ except Exception:
417
+ pass
418
+ raise Exception(f"Token refresh failed (HTTP {resp.status_code}): {detail}")
419
+
420
+ body = resp.json()
421
+ session = body.get("session")
422
+ if not session or not session.get("access_token"):
423
+ raise Exception(f"No session in refresh response: {body}")
424
+ return {
425
+ "access_token": session["access_token"],
426
+ "refresh_token": session.get("refresh_token"),
427
+ "expires_in": session.get("expires_in"),
428
+ }
395
429
 
396
430
 
397
431
  async def run_command(
@@ -668,13 +702,86 @@ class PipeProcess:
668
702
  pass
669
703
 
670
704
 
705
+ def _get_shared_token_path() -> str | None:
706
+ """Return the path to the shared token file written by the TUI."""
707
+ try:
708
+ from kiwi_tui.runtime_manager import token_path
709
+ return str(token_path())
710
+ except ImportError:
711
+ return None
712
+
713
+
714
+ def _get_shared_refresh_token_path() -> str | None:
715
+ """Return the path to the shared refresh token file written by the TUI."""
716
+ try:
717
+ from kiwi_tui.runtime_manager import refresh_token_path
718
+ return str(refresh_token_path())
719
+ except ImportError:
720
+ return None
721
+
722
+
723
+ def _read_file(path: str | None) -> str | None:
724
+ """Read a token from a file path, returning None on failure."""
725
+ if not path:
726
+ return None
727
+ try:
728
+ val = open(path).read().strip()
729
+ return val or None
730
+ except OSError:
731
+ return None
732
+
733
+
734
+ def _write_file(path: str | None, value: str) -> None:
735
+ """Write a value to a file with restricted permissions."""
736
+ if not path:
737
+ return
738
+ try:
739
+ with open(path, "w") as f:
740
+ f.write(value)
741
+ os.chmod(path, 0o600)
742
+ except OSError:
743
+ pass
744
+
745
+
746
+ async def _watch_token_file(ws, current_token: str, token_file: str | None):
747
+ """Periodically check the shared token file for updates.
748
+
749
+ When the TUI refreshes the token it writes the new value to disk.
750
+ This coroutine detects the change and sends a fresh auth message.
751
+ """
752
+ if not token_file:
753
+ return
754
+
755
+ last_token = current_token
756
+ while True:
757
+ await asyncio.sleep(30) # check every 30 seconds
758
+ try:
759
+ new_token = open(token_file).read().strip()
760
+ if new_token and new_token != last_token:
761
+ last_token = new_token
762
+ print_status("~", "Token refreshed by TUI, re-authenticating...", C)
763
+ await ws.send(json.dumps({"type": "auth", "token": new_token}))
764
+ resp = json.loads(await ws.recv())
765
+ if resp.get("type") == "auth_ok":
766
+ print_status(">", "Re-authenticated with refreshed token", GREEN)
767
+ else:
768
+ print_status("!", f"Re-auth response: {resp.get('type')}", YELLOW)
769
+ except (OSError, json.JSONDecodeError):
770
+ pass
771
+ except Exception as e:
772
+ print_status("!", f"Token watch error: {e}", YELLOW)
773
+
774
+
671
775
  async def connect(
672
776
  ws_url: str,
673
777
  token: str,
674
778
  mode: str = "restricted",
675
779
  allowed_dirs: list[str] | None = None,
676
- ):
677
- """Connect to the server WebSocket and process commands."""
780
+ ) -> str:
781
+ """Connect to the server WebSocket and process commands.
782
+
783
+ Returns a status string: "ok", "auth_failed", or "error".
784
+ """
678
785
  ws_endpoint = f"{ws_url}/v1/terminal/ws/connect"
679
786
  print_status("~", f"Connecting to {BOLD}{ws_endpoint}{RESET}", C)
680
787
 
@@ -685,14 +792,14 @@ async def connect(
685
792
 
686
793
  if auth_resp.get("type") == "error":
687
794
  print_status("x", f"Auth failed: {auth_resp.get('message')}", RED)
688
- return
795
+ return "auth_failed"
689
796
 
690
797
  if auth_resp.get("type") == "auth_ok":
691
798
  user_id = auth_resp.get('user_id', 'unknown')
692
799
  print_status(">", f"Connected as {BOLD}{user_id}{RESET}", GREEN)
693
800
  else:
694
801
  print_status("x", f"Unexpected response: {auth_resp}", RED)
695
- return
802
+ return "auth_failed"
696
803
 
697
804
  print()
698
805
  if mode == "restricted" and allowed_dirs:
@@ -708,6 +815,12 @@ async def connect(
708
815
 
709
816
  active_pty_sessions: dict[str, PTYProcess] = {}
710
817
 
818
+ # Start background task to watch for token refreshes from TUI
819
+ token_file = _get_shared_token_path()
820
+ token_watcher = asyncio.create_task(
821
+ _watch_token_file(ws, token, token_file)
822
+ )
823
+
711
824
  try:
712
825
  async for message in ws:
713
826
  msg = json.loads(message)
@@ -817,6 +930,7 @@ async def connect(
817
930
  else:
818
931
  print_status("?", f"Unknown message: {msg_type}", YELLOW)
819
932
  finally:
933
+ token_watcher.cancel()
820
934
  for sid, pty_proc in active_pty_sessions.items():
821
935
  try:
822
936
  await pty_proc.close()
@@ -826,12 +940,17 @@ async def connect(
826
940
  except websockets.exceptions.ConnectionClosed as e:
827
941
  print()
828
942
  print_status("!", f"Connection closed: {e}", YELLOW)
943
+ return "error"
829
944
  except ConnectionRefusedError:
830
945
  print()
831
946
  print_status("x", f"Could not connect to {ws_endpoint}. Is the server running?", RED)
947
+ return "error"
832
948
  except Exception as e:
833
949
  print()
834
950
  print_status("x", f"Error: {e}", RED)
951
+ return "error"
952
+
953
+ return "ok"
835
954
 
836
955
 
837
956
  def main():
@@ -929,7 +1048,12 @@ def main():
929
1048
 
930
1049
  loop = asyncio.new_event_loop()
931
1050
 
1051
+ token_file = _get_shared_token_path()
1052
+ refresh_file = _get_shared_refresh_token_path()
1053
+
932
1054
  provided_token = args.token or os.environ.get("KIWI_AUTH_TOKEN")
1055
+ refresh_token = _read_file(refresh_file) # May have been saved by TUI
1056
+
933
1057
  if provided_token:
934
1058
  # Use provided token — skip interactive login
935
1059
  token = provided_token
@@ -947,8 +1071,14 @@ def main():
947
1071
 
948
1072
  print_status("~", f"Authenticating with {BOLD}{http_url}{RESET}...", C)
949
1073
  try:
950
- token = loop.run_until_complete(login(http_url, email, password))
1074
+ session = loop.run_until_complete(login(http_url, email, password))
1075
+ token = session["access_token"]
1076
+ refresh_token = session.get("refresh_token")
951
1077
  print_status(">", "Login successful!", GREEN)
1078
+ # Save tokens to shared files
1079
+ _write_file(token_file, token)
1080
+ if refresh_token:
1081
+ _write_file(refresh_file, refresh_token)
952
1082
  except Exception as e:
953
1083
  print_status("x", f"Login failed: {e}", RED)
954
1084
  loop.close()
@@ -970,16 +1100,68 @@ def main():
970
1100
 
971
1101
  signal.signal(signal.SIGINT, lambda *_: shutdown())
972
1102
 
973
- try:
974
- loop.run_until_complete(connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs))
975
- except asyncio.CancelledError:
976
- pass
977
- finally:
978
- loop.close()
979
- print()
980
- print(f" {GREY}{'─' * 50}{RESET}")
981
- print_status(">", "Disconnected. Goodbye!", C)
1103
+ # Auto-reconnect loop with token refresh
1104
+ backoff = 5
1105
+ max_backoff = 60
1106
+
1107
+ while not _shutting_down:
1108
+ # Read latest tokens from shared files before each attempt
1109
+ fresh_token = _read_file(token_file)
1110
+ if fresh_token:
1111
+ token = fresh_token
1112
+ fresh_refresh = _read_file(refresh_file)
1113
+ if fresh_refresh:
1114
+ refresh_token = fresh_refresh
1115
+
1116
+ try:
1117
+ status = loop.run_until_complete(
1118
+ connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs)
1119
+ )
1120
+ except asyncio.CancelledError:
1121
+ break
1122
+ except Exception:
1123
+ status = "error"
1124
+
1125
+ if _shutting_down:
1126
+ break
1127
+
1128
+ # On auth failure, try to refresh the token before reconnecting
1129
+ if status == "auth_failed" and refresh_token:
1130
+ print_status("~", "Access token expired, refreshing...", C)
1131
+ try:
1132
+ session = loop.run_until_complete(
1133
+ refresh_access_token(http_url, refresh_token)
1134
+ )
1135
+ token = session["access_token"]
1136
+ if session.get("refresh_token"):
1137
+ refresh_token = session["refresh_token"]
1138
+ print_status(">", "Token refreshed successfully", GREEN)
1139
+ # Save refreshed tokens to shared files
1140
+ _write_file(token_file, token)
1141
+ if refresh_token:
1142
+ _write_file(refresh_file, refresh_token)
1143
+ backoff = 2 # Quick retry after successful refresh
1144
+ except Exception as e:
1145
+ print_status("x", f"Token refresh failed: {e}", RED)
1146
+
1147
+ # Reset backoff after a successful session
1148
+ if status == "ok":
1149
+ backoff = 5
1150
+
982
1151
  print()
1152
+ print_section("Reconnecting")
1153
+ print_status("~", f"Reconnecting in {backoff}s...", C)
1154
+ try:
1155
+ loop.run_until_complete(asyncio.sleep(backoff))
1156
+ except asyncio.CancelledError:
1157
+ break
1158
+ backoff = min(backoff * 2, max_backoff)
1159
+
1160
+ loop.close()
1161
+ print()
1162
+ print(f" {GREY}{'─' * 50}{RESET}")
1163
+ print_status(">", "Disconnected. Goodbye!", C)
1164
+ print()
983
1165
  else:
984
1166
  print_banner()
985
1167
  parser.print_help()
@@ -131,6 +131,7 @@ class AutobotsTUI(App):
131
131
  if self.token_manager.is_authenticated():
132
132
  logger.info("User is authenticated, showing dashboard")
133
133
  self._start_kiwi_session()
134
+ self._start_token_refresh()
134
135
  try:
135
136
  self.switch_screen("dashboard")
136
137
  except IndexError:
@@ -270,6 +271,53 @@ class AutobotsTUI(App):
270
271
  self._kiwi_pid = None
271
272
  self._close_log_tail()
272
273
 
274
+ # ------------------------------------------------------------------
275
+ # Periodic token refresh
276
+ # ------------------------------------------------------------------
277
+
278
+ def _start_token_refresh(self) -> None:
279
+ """Schedule periodic token refresh (every `refresh_interval` minutes)."""
280
+ interval_min = self.config.refresh_interval # default 5
281
+ logger.info(f"Token refresh scheduled every {interval_min} minutes")
282
+ self.set_interval(interval_min * 60, self._refresh_token_if_needed)
283
+ # Write current tokens to shared files so runtime can read them
284
+ if self._kiwi_token:
285
+ runtime_manager.save_token(self._kiwi_token)
286
+ tokens = self.token_manager.load_tokens()
287
+ if tokens and tokens.refresh_token:
288
+ runtime_manager.save_refresh_token(tokens.refresh_token)
289
+
290
+ def _refresh_token_if_needed(self) -> None:
291
+ """Check token expiry and refresh if needed."""
292
+ tokens = self.token_manager.load_tokens()
293
+ if not tokens:
294
+ logger.debug("No tokens to refresh")
295
+ return
296
+
297
+ if not tokens.is_expired():
298
+ logger.debug("Token still valid, skipping refresh")
299
+ return
300
+
301
+ logger.info("Token expired or near expiry, refreshing...")
302
+ success, new_tokens, message = self.autobots_client.refresh_token(
303
+ tokens.refresh_token
304
+ )
305
+
306
+ if success and new_tokens:
307
+ logger.info("Token refreshed successfully")
308
+ # Save to disk
309
+ self.token_manager.save_tokens(new_tokens)
310
+ # Update TUI client
311
+ self.autobots_client.update_token(new_tokens.access_token)
312
+ # Update shared state
313
+ self._kiwi_token = new_tokens.access_token
314
+ # Write to runtime directory so the runtime process can pick it up
315
+ runtime_manager.save_token(new_tokens.access_token)
316
+ if new_tokens.refresh_token:
317
+ runtime_manager.save_refresh_token(new_tokens.refresh_token)
318
+ else:
319
+ logger.error(f"Token refresh failed: {message}")
320
+
273
321
  # ------------------------------------------------------------------
274
322
 
275
323
  def action_runtime_logs(self) -> None:
@@ -315,23 +363,50 @@ class AutobotsTUI(App):
315
363
  self.exit()
316
364
 
317
365
 
318
- def main():
319
- """Run the Autobots TUI application."""
320
- # Setup logging
366
+ def _run_tui():
367
+ """Run the TUI in the terminal."""
321
368
  config_manager = ConfigManager()
322
369
  config = config_manager.load()
323
370
  setup_logging(log_level=config.log_level)
324
371
 
325
- logger.info("="*60)
372
+ logger.info("=" * 60)
326
373
  logger.info("Starting Autobots TUI")
327
- logger.info("="*60)
374
+ logger.info("=" * 60)
328
375
 
329
- # Run app
330
376
  app = AutobotsTUI(config_manager=config_manager)
331
377
  app.run()
332
378
 
333
379
  logger.info("Autobots TUI terminated")
334
380
 
335
381
 
382
+ def _serve_tui(port: int = 8566):
383
+ """Serve the TUI in the browser via textual serve."""
384
+ import sys
385
+ from textual_serve.server import Server
386
+
387
+ server = Server(f"{sys.executable} -m kiwi_tui.main", port=port)
388
+ server.serve()
389
+
390
+
391
+ def main():
392
+ """Entry point for the kiwi command."""
393
+ import argparse
394
+
395
+ parser = argparse.ArgumentParser(prog="kiwi", description="Kiwi Code TUI")
396
+ subparsers = parser.add_subparsers(dest="command")
397
+
398
+ serve_parser = subparsers.add_parser("serve", help="Serve the TUI in the browser")
399
+ serve_parser.add_argument(
400
+ "--port", "-p", type=int, default=8566, help="Port to serve on (default: 8566)"
401
+ )
402
+
403
+ args = parser.parse_args()
404
+
405
+ if args.command == "serve":
406
+ _serve_tui(port=args.port)
407
+ else:
408
+ _run_tui()
409
+
410
+
336
411
  if __name__ == "__main__":
337
412
  main()
@@ -58,6 +58,52 @@ def log_path(cwd: str | None = None) -> Path:
58
58
  return runtime_dir(cwd) / "log"
59
59
 
60
60
 
61
+ def token_path(cwd: str | None = None) -> Path:
62
+ """Return the shared token file path for a directory's runtime."""
63
+ return runtime_dir(cwd) / "token"
64
+
65
+
66
+ def refresh_token_path(cwd: str | None = None) -> Path:
67
+ """Return the shared refresh token file path for a directory's runtime."""
68
+ return runtime_dir(cwd) / "refresh_token"
69
+
70
+
71
+ def save_token(token: str, cwd: str | None = None) -> None:
72
+ """Write a refreshed access token so the runtime can pick it up."""
73
+ tp = token_path(cwd)
74
+ tp.write_text(token)
75
+ tp.chmod(0o600)
76
+
77
+
78
+ def save_refresh_token(token: str, cwd: str | None = None) -> None:
79
+ """Write the refresh token so the runtime can refresh on its own."""
80
+ tp = refresh_token_path(cwd)
81
+ tp.write_text(token)
82
+ tp.chmod(0o600)
83
+
84
+
85
+ def read_token(cwd: str | None = None) -> str | None:
86
+ """Read the shared access token (returns None if missing)."""
87
+ tp = token_path(cwd)
88
+ if not tp.exists():
89
+ return None
90
+ try:
91
+ return tp.read_text().strip() or None
92
+ except OSError:
93
+ return None
94
+
95
+
96
+ def read_refresh_token(cwd: str | None = None) -> str | None:
97
+ """Read the shared refresh token (returns None if missing)."""
98
+ tp = refresh_token_path(cwd)
99
+ if not tp.exists():
100
+ return None
101
+ try:
102
+ return tp.read_text().strip() or None
103
+ except OSError:
104
+ return None
105
+
106
+
61
107
  def stop_runtime(cwd: str | None = None) -> bool:
62
108
  """Stop the runtime for a directory. Returns True if a process was stopped."""
63
109
  pid = get_running_pid(cwd)
@@ -280,6 +280,7 @@ class LoginScreen(Screen):
280
280
  # Share token with kiwi CLI session and start it
281
281
  self.app._kiwi_token = tokens.access_token
282
282
  self.app._start_kiwi_session()
283
+ self.app._start_token_refresh()
283
284
 
284
285
  # Notify app of successful login
285
286
  self.app.post_message(LoginSuccess(tokens))