kiwi-code 0.0.41__tar.gz → 0.0.43__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 (57) hide show
  1. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/main.py +54 -4
  4. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/dashboard.py +240 -62
  5. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_tui_headless.py +253 -27
  6. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/uv.lock +1 -1
  7. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.github/workflows/publish.yml +0 -0
  8. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.github/workflows/test.yml +0 -0
  9. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.gitignore +0 -0
  10. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/.python-version +0 -0
  11. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/CLAUDE.md +0 -0
  12. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/Makefile +0 -0
  13. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/README.md +0 -0
  14. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/__init__.py +0 -0
  15. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/auth.py +0 -0
  16. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/cli.py +0 -0
  17. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/client.py +0 -0
  18. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/commands.py +0 -0
  19. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/logger.py +0 -0
  20. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/models.py +0 -0
  21. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/runtime_manager.py +0 -0
  22. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/server.py +0 -0
  23. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_cli/terminal_mode.py +0 -0
  24. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/__init__.py +0 -0
  25. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/__main__.py +0 -0
  26. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/main.py +0 -0
  27. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  28. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  29. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/__init__.py +0 -0
  30. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/inline_file_picker.py +0 -0
  31. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/random_words.py +0 -0
  32. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/runtime_agent.py +0 -0
  33. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/__init__.py +0 -0
  34. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/attach_content.py +0 -0
  35. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/command_result.py +0 -0
  36. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/file_browser.py +0 -0
  37. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/help.py +0 -0
  38. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/id_picker.py +0 -0
  39. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/login.py +0 -0
  40. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  41. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  42. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/screens/slash_picker.py +0 -0
  43. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/slash_commands.py +0 -0
  44. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/status_words.py +0 -0
  45. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/src/kiwi_tui/widgets.py +0 -0
  46. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/test_hello.py +0 -0
  47. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/__init__.py +0 -0
  48. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/conftest.py +0 -0
  49. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_cli_help.py +0 -0
  50. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_imports.py +0 -0
  51. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_reexec_kiwi.py +0 -0
  52. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_runtime_log_trimming.py +0 -0
  53. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_slash_commands.py +0 -0
  54. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_terminal_mode.py +0 -0
  55. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_tokens.py +0 -0
  56. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/tests/test_tui_interactive_runtime.py +0 -0
  57. {kiwi_code-0.0.41 → kiwi_code-0.0.43}/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.41
3
+ Version: 0.0.43
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.41"
3
+ version = "0.0.43"
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"
@@ -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")
@@ -138,6 +138,7 @@ class DashboardScreen(Screen):
138
138
  "69c2180355a89324a9926bc6",
139
139
  "6a0625b8ab5f80ba8ac5d012",
140
140
  "69e4be0d70c2d7a89197e66e",
141
+ "6a1c0773c519da8a0fa9b8a4",
141
142
  ]
142
143
 
143
144
  BINDINGS = [
@@ -600,6 +601,27 @@ class DashboardScreen(Screen):
600
601
  action_w.update(f"Action: {action_label}")
601
602
  run_w.update(f"Run: {run_id}")
602
603
 
604
+
605
+ def _refresh_auth_or_redirect(self, code: int | None, *, context: str) -> bool:
606
+ """Try one forced auth refresh after a 401/403.
607
+
608
+ Returns True only when a forced refresh succeeded and the caller should
609
+ retry the original request once. If refresh itself fails, auth is already
610
+ unrecoverable for this request and the user is redirected to login.
611
+ """
612
+ if code not in (401, 403):
613
+ return False
614
+
615
+ try:
616
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
617
+ except Exception:
618
+ refreshed = False
619
+
620
+ if not refreshed:
621
+ self.app._handle_auth_expired(
622
+ f"Authentication expired while {context} (HTTP {code}). Please log in again."
623
+ )
624
+ return refreshed
603
625
  async def _refresh_current_action_name_async(self) -> None:
604
626
  """Best-effort fetch of the current action name for the status bar."""
605
627
  from autobots_client.api.actions import get_action_v1_actions_id_get
@@ -614,13 +636,18 @@ class DashboardScreen(Screen):
614
636
 
615
637
  resp = await asyncio.to_thread(lambda: _fetch(api_client))
616
638
  if resp.status_code in (401, 403):
617
- try:
618
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
619
- except Exception:
620
- refreshed = False
639
+ refreshed = self._refresh_auth_or_redirect(
640
+ resp.status_code,
641
+ context="refreshing the current action name",
642
+ )
621
643
  if refreshed:
622
644
  api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
623
645
  resp = await asyncio.to_thread(lambda: _fetch(api_client))
646
+ if resp.status_code in (401, 403):
647
+ self.app._handle_auth_expired(
648
+ f"Authentication expired while refreshing the current action name (HTTP {resp.status_code}). Please log in again."
649
+ )
650
+ return
624
651
 
625
652
  if requested_action_id != self.current_action_id:
626
653
  return
@@ -1261,14 +1288,27 @@ class DashboardScreen(Screen):
1261
1288
  except Exception:
1262
1289
  code = None
1263
1290
  if code in (401, 403):
1264
- try:
1265
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
1266
- except Exception:
1267
- refreshed = False
1291
+ refreshed = self._refresh_auth_or_redirect(
1292
+ code,
1293
+ context="loading conversation history",
1294
+ )
1268
1295
  if refreshed:
1269
1296
  success, result, msg = await asyncio.to_thread(
1270
1297
  lambda: self.app.autobots_client.get_action_result(run_id)
1271
1298
  )
1299
+ if (not success) and msg:
1300
+ retry_code = None
1301
+ try:
1302
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
1303
+ if m:
1304
+ retry_code = int(m.group(1))
1305
+ except Exception:
1306
+ retry_code = None
1307
+ if retry_code in (401, 403):
1308
+ self.app._handle_auth_expired(
1309
+ f"Authentication expired while loading conversation history (HTTP {retry_code}). Please log in again."
1310
+ )
1311
+ return
1272
1312
  if not success or not result:
1273
1313
  return
1274
1314
  self._update_action_context_from_action_result(result)
@@ -1309,10 +1349,10 @@ class DashboardScreen(Screen):
1309
1349
  for action_id in self.AUTOCODE_ACTION_IDS
1310
1350
  ]
1311
1351
  if any(resp.status_code in (401, 403) for _, resp in responses):
1312
- try:
1313
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
1314
- except Exception:
1315
- refreshed = False
1352
+ refreshed = self._refresh_auth_or_redirect(
1353
+ 401,
1354
+ context="loading the available AutoCode actions",
1355
+ )
1316
1356
  if refreshed:
1317
1357
  api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1318
1358
 
@@ -1325,6 +1365,11 @@ class DashboardScreen(Screen):
1325
1365
  (action_id, await _fetch_action_refreshed(action_id))
1326
1366
  for action_id in self.AUTOCODE_ACTION_IDS
1327
1367
  ]
1368
+ if any(resp.status_code in (401, 403) for _, resp in responses):
1369
+ self.app._handle_auth_expired(
1370
+ "Authentication expired while loading the available AutoCode actions. Please log in again."
1371
+ )
1372
+ return
1328
1373
 
1329
1374
  rows: list[tuple[str, list[str]]] = []
1330
1375
  unavailable: list[str] = []
@@ -1377,10 +1422,10 @@ class DashboardScreen(Screen):
1377
1422
  )
1378
1423
  if resp.status_code in (401, 403):
1379
1424
  # Reactive auth guard: force refresh and retry once.
1380
- try:
1381
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
1382
- except Exception:
1383
- refreshed = False
1425
+ refreshed = self._refresh_auth_or_redirect(
1426
+ resp.status_code,
1427
+ context="listing actions",
1428
+ )
1384
1429
  if refreshed:
1385
1430
  api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1386
1431
  resp = await asyncio.to_thread(
@@ -1391,6 +1436,11 @@ class DashboardScreen(Screen):
1391
1436
  offset=offset,
1392
1437
  )
1393
1438
  )
1439
+ if resp.status_code in (401, 403):
1440
+ self.app._handle_auth_expired(
1441
+ f"Authentication expired while listing actions (HTTP {resp.status_code}). Please log in again."
1442
+ )
1443
+ return
1394
1444
  if resp.status_code != 200 or not resp.parsed:
1395
1445
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1396
1446
  return
@@ -1446,10 +1496,10 @@ class DashboardScreen(Screen):
1446
1496
  )
1447
1497
  )
1448
1498
  if resp.status_code in (401, 403):
1449
- try:
1450
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
1451
- except Exception:
1452
- refreshed = False
1499
+ refreshed = self._refresh_auth_or_redirect(
1500
+ resp.status_code,
1501
+ context="listing runs",
1502
+ )
1453
1503
  if refreshed:
1454
1504
  api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1455
1505
  resp = await asyncio.to_thread(
@@ -1462,6 +1512,11 @@ class DashboardScreen(Screen):
1462
1512
  offset=int(opts.get("offset", "0")),
1463
1513
  )
1464
1514
  )
1515
+ if resp.status_code in (401, 403):
1516
+ self.app._handle_auth_expired(
1517
+ f"Authentication expired while listing runs (HTTP {resp.status_code}). Please log in again."
1518
+ )
1519
+ return
1465
1520
  if resp.status_code != 200 or not resp.parsed:
1466
1521
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1467
1522
  return
@@ -1504,10 +1559,10 @@ class DashboardScreen(Screen):
1504
1559
  )
1505
1560
  )
1506
1561
  if resp.status_code in (401, 403):
1507
- try:
1508
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
1509
- except Exception:
1510
- refreshed = False
1562
+ refreshed = self._refresh_auth_or_redirect(
1563
+ resp.status_code,
1564
+ context="listing graphs",
1565
+ )
1511
1566
  if refreshed:
1512
1567
  api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1513
1568
  resp = await asyncio.to_thread(
@@ -1518,6 +1573,11 @@ class DashboardScreen(Screen):
1518
1573
  offset=offset,
1519
1574
  )
1520
1575
  )
1576
+ if resp.status_code in (401, 403):
1577
+ self.app._handle_auth_expired(
1578
+ f"Authentication expired while listing graphs (HTTP {resp.status_code}). Please log in again."
1579
+ )
1580
+ return
1521
1581
  if resp.status_code != 200 or not resp.parsed:
1522
1582
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1523
1583
  return
@@ -1573,10 +1633,10 @@ class DashboardScreen(Screen):
1573
1633
  )
1574
1634
  )
1575
1635
  if resp.status_code in (401, 403):
1576
- try:
1577
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
1578
- except Exception:
1579
- refreshed = False
1636
+ refreshed = self._refresh_auth_or_redirect(
1637
+ resp.status_code,
1638
+ context="listing graph runs",
1639
+ )
1580
1640
  if refreshed:
1581
1641
  api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1582
1642
  resp = await asyncio.to_thread(
@@ -1589,6 +1649,11 @@ class DashboardScreen(Screen):
1589
1649
  offset=int(opts.get("offset", "0")),
1590
1650
  )
1591
1651
  )
1652
+ if resp.status_code in (401, 403):
1653
+ self.app._handle_auth_expired(
1654
+ f"Authentication expired while listing graph runs (HTTP {resp.status_code}). Please log in again."
1655
+ )
1656
+ return
1592
1657
  if resp.status_code != 200 or not resp.parsed:
1593
1658
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1594
1659
  return
@@ -1678,16 +1743,21 @@ class DashboardScreen(Screen):
1678
1743
  lines = await asyncio.to_thread(lambda: fn(api_client))
1679
1744
  code = self._http_status_from_lines(lines)
1680
1745
  if code in (401, 403):
1681
- refreshed = False
1682
- try:
1683
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
1684
- except Exception:
1685
- refreshed = False
1746
+ refreshed = self._refresh_auth_or_redirect(
1747
+ code,
1748
+ context=f"running {title}",
1749
+ )
1686
1750
  if refreshed:
1687
1751
  api_client = self._get_api_client_for_command(title)
1688
1752
  if not api_client:
1689
1753
  return lines
1690
1754
  lines = await asyncio.to_thread(lambda: fn(api_client))
1755
+ retry_code = self._http_status_from_lines(lines)
1756
+ if retry_code in (401, 403):
1757
+ self.app._handle_auth_expired(
1758
+ f"Authentication expired while running {title} (HTTP {retry_code}). Please log in again."
1759
+ )
1760
+ return lines
1691
1761
  return lines
1692
1762
 
1693
1763
  def _parse_command_opts(self, command: str, skip: int = 2) -> dict[str, str]:
@@ -2282,6 +2352,50 @@ class DashboardScreen(Screen):
2282
2352
  pass
2283
2353
  messages.scroll_end(animate=False)
2284
2354
 
2355
+
2356
+ def reset_for_auth_expired(self) -> None:
2357
+ """Clear transient dashboard state before redirecting to login."""
2358
+ try:
2359
+ for worker in self.workers:
2360
+ if not worker.is_finished:
2361
+ worker.cancel()
2362
+ except Exception:
2363
+ pass
2364
+
2365
+ try:
2366
+ self._set_streaming(False)
2367
+ except Exception:
2368
+ pass
2369
+
2370
+ try:
2371
+ w = getattr(self, "_streaming_widget_ref", None)
2372
+ if w:
2373
+ w.remove()
2374
+ except Exception:
2375
+ pass
2376
+ self._streaming_widget_ref = None
2377
+
2378
+ self.current_run_id = None
2379
+ self.current_run_kind = None
2380
+ self._pending_urls.clear()
2381
+ self._upload_status_text = ""
2382
+ try:
2383
+ self._update_pending_files_bar()
2384
+ except Exception:
2385
+ pass
2386
+ try:
2387
+ self._update_run_status_bar()
2388
+ except Exception:
2389
+ pass
2390
+
2391
+ try:
2392
+ chat_input = self.query_one("#chat-input", ChatInput)
2393
+ chat_input.value = ""
2394
+ chat_input.focus()
2395
+ except Exception:
2396
+ pass
2397
+
2398
+ self._clear_chat_messages()
2285
2399
  def add_message(self, text: str, msg_type: str = "assistant") -> None:
2286
2400
  """Add a message to the chat."""
2287
2401
  messages = self.query_one("#messages", VerticalScroll)
@@ -2581,10 +2695,21 @@ class DashboardScreen(Screen):
2581
2695
  refreshed = bool(await asyncio.to_thread(self.app._refresh_token_if_needed, True))
2582
2696
  except Exception:
2583
2697
  refreshed = False
2584
- if refreshed:
2585
- success, urls, message = await asyncio.to_thread(
2586
- lambda: self.app.autobots_client.upload_files(file_paths)
2698
+ if not refreshed:
2699
+ self.app._handle_auth_expired(
2700
+ "Authentication expired while uploading files. Please log in again."
2701
+ )
2702
+ self._set_upload_status("")
2703
+ return
2704
+ success, urls, message = await asyncio.to_thread(
2705
+ lambda: self.app.autobots_client.upload_files(file_paths)
2706
+ )
2707
+ if (not success) and ("HTTP 401" in message or "HTTP 403" in message):
2708
+ self.app._handle_auth_expired(
2709
+ "Authentication expired while uploading files. Please log in again."
2587
2710
  )
2711
+ self._set_upload_status("")
2712
+ return
2588
2713
 
2589
2714
  if success:
2590
2715
  self._pending_urls.extend(urls)
@@ -2779,25 +2904,58 @@ class DashboardScreen(Screen):
2779
2904
  except Exception:
2780
2905
  code = None
2781
2906
 
2907
+ retried_after_auth = False
2782
2908
  if code in (401, 403):
2909
+ refreshed = self._refresh_auth_or_redirect(
2910
+ code,
2911
+ context="starting an action",
2912
+ )
2913
+ if not refreshed:
2914
+ try:
2915
+ w = getattr(self, "_streaming_widget_ref", None)
2916
+ if w:
2917
+ w.remove()
2918
+ except Exception:
2919
+ pass
2920
+ self._streaming_widget_ref = None
2921
+ self._set_streaming(False)
2922
+ return
2923
+ retried_after_auth = True
2783
2924
  try:
2784
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
2925
+ success, run_id, message = await loop.run_in_executor(
2926
+ None,
2927
+ lambda: client.run_action_async(
2928
+ self.current_action_id,
2929
+ user_input,
2930
+ action_result_id=self.current_run_id,
2931
+ urls=urls if urls else None,
2932
+ metadata=self._metadata if self._metadata else None,
2933
+ ),
2934
+ )
2785
2935
  except Exception:
2786
- refreshed = False
2787
- if refreshed:
2936
+ pass
2937
+
2938
+ if retried_after_auth and not success:
2939
+ retry_code = None
2940
+ try:
2941
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
2942
+ if m:
2943
+ retry_code = int(m.group(1))
2944
+ except Exception:
2945
+ retry_code = None
2946
+ if retry_code in (401, 403):
2947
+ self.app._handle_auth_expired(
2948
+ f"Authentication expired while starting an action (HTTP {retry_code}). Please log in again."
2949
+ )
2788
2950
  try:
2789
- success, run_id, message = await loop.run_in_executor(
2790
- None,
2791
- lambda: client.run_action_async(
2792
- self.current_action_id,
2793
- user_input,
2794
- action_result_id=self.current_run_id,
2795
- urls=urls if urls else None,
2796
- metadata=self._metadata if self._metadata else None,
2797
- ),
2798
- )
2951
+ w = getattr(self, "_streaming_widget_ref", None)
2952
+ if w:
2953
+ w.remove()
2799
2954
  except Exception:
2800
2955
  pass
2956
+ self._streaming_widget_ref = None
2957
+ self._set_streaming(False)
2958
+ return
2801
2959
 
2802
2960
  if not success:
2803
2961
  self.add_message(f"Error starting action: {message}", "error")
@@ -2944,25 +3102,32 @@ class DashboardScreen(Screen):
2944
3102
  code = None
2945
3103
 
2946
3104
  if code in (401, 403):
2947
- try:
2948
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
2949
- except Exception:
2950
- refreshed = False
3105
+ refreshed = self._refresh_auth_or_redirect(
3106
+ code,
3107
+ context="fetching the final action result",
3108
+ )
2951
3109
  if refreshed:
2952
3110
  success, final_result, message = await asyncio.to_thread(
2953
3111
  client.get_action_result, run_id
2954
3112
  )
2955
- if (not success) and (code in (401, 403)):
2956
- auth_failed_code = code
3113
+ if not success:
3114
+ retry_code = None
3115
+ try:
3116
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
3117
+ if m:
3118
+ retry_code = int(m.group(1))
3119
+ except Exception:
3120
+ retry_code = None
3121
+ if retry_code in (401, 403):
3122
+ auth_failed_code = retry_code
2957
3123
  except Exception as e:
2958
3124
  logger.warning(f"Poll error: {e}")
2959
3125
  return False
2960
3126
 
2961
3127
 
2962
3128
  if auth_failed_code in (401, 403):
2963
- self.add_message(
2964
- f"Authentication error while fetching result (HTTP {auth_failed_code}). Please /login again.",
2965
- "error",
3129
+ self.app._handle_auth_expired(
3130
+ f"Authentication expired while fetching the result (HTTP {auth_failed_code}). Please log in again."
2966
3131
  )
2967
3132
  got_final_result = True
2968
3133
  # Stop spinner/blink immediately when we have a terminal state.
@@ -3271,12 +3436,25 @@ class DashboardScreen(Screen):
3271
3436
  except Exception:
3272
3437
  code = None
3273
3438
  if code in (401, 403):
3274
- try:
3275
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
3276
- except Exception:
3277
- refreshed = False
3439
+ refreshed = self._refresh_auth_or_redirect(
3440
+ code,
3441
+ context="refreshing displayed conversation history",
3442
+ )
3278
3443
  if refreshed:
3279
3444
  success, result, msg = self.app.autobots_client.get_action_result(run_id)
3445
+ if (not success) and msg:
3446
+ retry_code = None
3447
+ try:
3448
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
3449
+ if m:
3450
+ retry_code = int(m.group(1))
3451
+ except Exception:
3452
+ retry_code = None
3453
+ if retry_code in (401, 403):
3454
+ self.app._handle_auth_expired(
3455
+ f"Authentication expired while refreshing displayed conversation history (HTTP {retry_code}). Please log in again."
3456
+ )
3457
+ return
3280
3458
  if not success or not result:
3281
3459
  return
3282
3460
  self._render_conversation_history_from_action_result(result)
@@ -701,7 +701,7 @@ async def test_tui_continue_run_updates_action_name_from_run(
701
701
  async def test_tui_autocode_select_shows_only_special_action_names(
702
702
  isolated_home: Path, monkeypatch: pytest.MonkeyPatch
703
703
  ) -> None:
704
- """/autocode-select should open the picker with only the 3 special actions."""
704
+ """/autocode-select should open the picker with only the 4 special actions."""
705
705
  tokens_path = isolated_home / ".kiwi" / "tokens.json"
706
706
  tokens_path.parent.mkdir(parents=True, exist_ok=True)
707
707
  tokens_path.write_text(
@@ -720,6 +720,7 @@ async def test_tui_autocode_select_shows_only_special_action_names(
720
720
  "69c2180355a89324a9926bc6": "AutoCode Alpha",
721
721
  "6a0625b8ab5f80ba8ac5d012": "AutoCode Beta",
722
722
  "69e4be0d70c2d7a89197e66e": "AutoCode Gamma",
723
+ "6a1c0773c519da8a0fa9b8a4": "AutoCode Delta",
723
724
  }
724
725
 
725
726
  from kiwi_tui.screens.dashboard import DashboardScreen
@@ -753,10 +754,11 @@ async def test_tui_autocode_select_shows_only_special_action_names(
753
754
 
754
755
  assert type(app.screen).__name__ == "IdPickerScreen"
755
756
  table = app.screen.query_one("#idpicker-table", DataTable)
756
- assert table.row_count == 3
757
+ assert table.row_count == 4
757
758
  assert list(table.get_row_at(0)) == ["1", "AutoCode Alpha"]
758
759
  assert list(table.get_row_at(1)) == ["2", "AutoCode Beta"]
759
760
  assert list(table.get_row_at(2)) == ["3", "AutoCode Gamma"]
761
+ assert list(table.get_row_at(3)) == ["4", "AutoCode Delta"]
760
762
 
761
763
 
762
764
 
@@ -853,8 +855,6 @@ async def test_tui_streaming_requests_autofollow_when_viewport_is_near_bottom(
853
855
  )
854
856
  await pilot.pause()
855
857
  assert calls == [{"after_refresh": True}]
856
-
857
-
858
858
  @pytest.mark.asyncio
859
859
  async def test_tui_streaming_does_not_force_scroll_when_user_reads_older_messages(
860
860
  isolated_home: Path,
@@ -881,45 +881,271 @@ async def test_tui_streaming_does_not_force_scroll_when_user_reads_older_message
881
881
  await pilot.pause()
882
882
  assert type(app.screen).__name__ == "DashboardScreen"
883
883
  screen = app.screen
884
- messages = screen.query_one("#messages")
885
884
 
886
- for i in range(30):
887
- screen.add_message(
888
- "\n".join(f"history {i} line {j}" for j in range(4)),
889
- "assistant",
885
+ calls: list[dict] = []
886
+ monkeypatch = pytest.MonkeyPatch()
887
+ monkeypatch.setattr(screen, "_messages_is_near_bottom", lambda *args, **kwargs: False)
888
+ monkeypatch.setattr(
889
+ screen,
890
+ "_scroll_messages_to_end",
891
+ lambda *args, **kwargs: calls.append(dict(kwargs)),
892
+ )
893
+ try:
894
+ widget = screen.update_streaming_message({"blocks": [{"text": "stream start"}]})
895
+ await pilot.pause()
896
+ assert widget is not None
897
+ assert calls == []
898
+
899
+ screen.update_streaming_message(
900
+ {"blocks": [{"text": "\n".join(f"stream line {i}" for i in range(60))}]},
901
+ widget,
890
902
  )
903
+ await pilot.pause()
904
+ assert calls == []
905
+ finally:
906
+ monkeypatch.undo()
907
+
908
+ @pytest.mark.asyncio
909
+ async def test_tui_quits_cleanly(isolated_home: Path) -> None:
910
+ from kiwi_tui.main import AutobotsTUI
911
+
912
+ app = AutobotsTUI()
913
+ async with app.run_test() as pilot:
891
914
  await pilot.pause()
892
- assert messages.max_scroll_y > 0
915
+ await pilot.press("ctrl+c")
916
+ await pilot.pause()
917
+ # Reaching here without an exception is the assertion.
893
918
 
894
- widget = screen.update_streaming_message({"blocks": [{"text": "stream start"}]})
919
+
920
+ @pytest.mark.asyncio
921
+ async def test_tui_redirects_to_login_when_auth_expires(isolated_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
922
+ tokens_path = isolated_home / ".kiwi" / "tokens.json"
923
+ tokens_path.parent.mkdir(parents=True, exist_ok=True)
924
+ tokens_path.write_text(
925
+ json.dumps(
926
+ {
927
+ "access_token": "test-access-token",
928
+ "refresh_token": "test-refresh-token",
929
+ "token_type": "Bearer",
930
+ "expires_at": None,
931
+ }
932
+ ),
933
+ encoding="utf-8",
934
+ )
935
+
936
+ from autobots_client.api.actions import get_action_v1_actions_id_get
937
+ monkeypatch.setattr(
938
+ get_action_v1_actions_id_get,
939
+ "sync_detailed",
940
+ lambda *, id, client: SimpleNamespace(
941
+ status_code=200,
942
+ parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
943
+ ),
944
+ )
945
+
946
+ from kiwi_tui.main import AutobotsTUI
947
+
948
+ app = AutobotsTUI()
949
+ async with app.run_test() as pilot:
895
950
  await pilot.pause()
896
- assert widget is not None
951
+ assert type(app.screen).__name__ == "DashboardScreen"
897
952
 
898
- target_y = max(0, int(messages.max_scroll_y) - 5)
899
- messages.scroll_to(y=target_y, animate=False, immediate=True)
953
+ app._handle_auth_expired("Session expired during test.")
900
954
  await pilot.pause()
901
- y_before = int(messages.scroll_offset.y)
902
- target_before = int(messages.scroll_target_y)
903
- assert y_before == target_before
904
- assert y_before < int(messages.max_scroll_y)
905
955
 
906
- screen.update_streaming_message(
907
- {"blocks": [{"text": "\n".join(f"stream line {i}" for i in range(60))}]},
908
- widget,
956
+ assert type(app.screen).__name__ == "LoginScreen"
957
+ assert not tokens_path.exists()
958
+
959
+
960
+ @pytest.mark.asyncio
961
+ async def test_tui_run_action_403_redirects_to_login_when_refresh_cannot_run(
962
+ isolated_home: Path, monkeypatch: pytest.MonkeyPatch
963
+ ) -> None:
964
+ tokens_path = isolated_home / ".kiwi" / "tokens.json"
965
+ tokens_path.parent.mkdir(parents=True, exist_ok=True)
966
+ tokens_path.write_text(
967
+ json.dumps(
968
+ {
969
+ "access_token": "test-access-token",
970
+ "refresh_token": "test-refresh-token",
971
+ "token_type": "Bearer",
972
+ "expires_at": None,
973
+ }
974
+ ),
975
+ encoding="utf-8",
976
+ )
977
+
978
+ from autobots_client.api.actions import get_action_v1_actions_id_get
979
+ monkeypatch.setattr(
980
+ get_action_v1_actions_id_get,
981
+ "sync_detailed",
982
+ lambda *, id, client: SimpleNamespace(
983
+ status_code=200,
984
+ parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
985
+ ),
986
+ )
987
+
988
+ from kiwi_tui.main import AutobotsTUI
989
+
990
+ app = AutobotsTUI()
991
+ async with app.run_test() as pilot:
992
+ await pilot.pause()
993
+ assert type(app.screen).__name__ == "DashboardScreen"
994
+ screen = app.screen
995
+
996
+ monkeypatch.setattr(app, "_refresh_token_if_needed", lambda force=False: False)
997
+ monkeypatch.setattr(
998
+ app.autobots_client,
999
+ "run_action_async",
1000
+ lambda *args, **kwargs: (False, None, "Failed to start action: status 403"),
909
1001
  )
1002
+
1003
+ screen.run_action_with_polling("hello")
1004
+ await pilot.pause(0.3)
1005
+ await pilot.pause(0.3)
1006
+
1007
+ assert type(app.screen).__name__ == "LoginScreen"
1008
+ assert not tokens_path.exists()
1009
+
1010
+
1011
+ @pytest.mark.asyncio
1012
+ async def test_tui_run_action_403_redirects_to_login_on_second_failure(
1013
+ isolated_home: Path, monkeypatch: pytest.MonkeyPatch
1014
+ ) -> None:
1015
+ tokens_path = isolated_home / ".kiwi" / "tokens.json"
1016
+ tokens_path.parent.mkdir(parents=True, exist_ok=True)
1017
+ tokens_path.write_text(
1018
+ json.dumps(
1019
+ {
1020
+ "access_token": "test-access-token",
1021
+ "refresh_token": "test-refresh-token",
1022
+ "token_type": "Bearer",
1023
+ "expires_at": None,
1024
+ }
1025
+ ),
1026
+ encoding="utf-8",
1027
+ )
1028
+
1029
+ from autobots_client.api.actions import get_action_v1_actions_id_get
1030
+ monkeypatch.setattr(
1031
+ get_action_v1_actions_id_get,
1032
+ "sync_detailed",
1033
+ lambda *, id, client: SimpleNamespace(
1034
+ status_code=200,
1035
+ parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
1036
+ ),
1037
+ )
1038
+
1039
+ from kiwi_tui.main import AutobotsTUI
1040
+
1041
+ app = AutobotsTUI()
1042
+ async with app.run_test() as pilot:
910
1043
  await pilot.pause()
1044
+ assert type(app.screen).__name__ == "DashboardScreen"
1045
+ screen = app.screen
1046
+
1047
+ monkeypatch.setattr(app, "_refresh_token_if_needed", lambda force=False: True)
1048
+ monkeypatch.setattr(
1049
+ app.autobots_client,
1050
+ "run_action_async",
1051
+ lambda *args, **kwargs: (False, None, "Failed to start action: status 403"),
1052
+ )
1053
+
1054
+ screen.run_action_with_polling("hello")
1055
+ await pilot.pause(0.3)
1056
+ await pilot.pause(0.3)
1057
+
1058
+ assert type(app.screen).__name__ == "LoginScreen"
1059
+ assert not tokens_path.exists()
1060
+
1061
+
1062
+ def test_tui_force_refresh_uses_refresh_token_even_when_access_token_not_expired(
1063
+ isolated_home: Path, monkeypatch: pytest.MonkeyPatch
1064
+ ) -> None:
1065
+ tokens_path = isolated_home / ".kiwi" / "tokens.json"
1066
+ tokens_path.parent.mkdir(parents=True, exist_ok=True)
1067
+ tokens_path.write_text(
1068
+ json.dumps(
1069
+ {
1070
+ "access_token": "test-access-token",
1071
+ "refresh_token": "test-refresh-token",
1072
+ "token_type": "Bearer",
1073
+ "expires_at": "2999-01-01T00:00:00",
1074
+ }
1075
+ ),
1076
+ encoding="utf-8",
1077
+ )
1078
+
1079
+ from kiwi_cli.models import AuthTokens
1080
+ from kiwi_tui.main import AutobotsTUI
1081
+
1082
+ app = AutobotsTUI()
1083
+
1084
+ calls: list[str] = []
1085
+
1086
+ def _fake_refresh(refresh_token: str):
1087
+ calls.append(refresh_token)
1088
+ return True, AuthTokens(
1089
+ access_token="new-access-token",
1090
+ refresh_token="new-refresh-token",
1091
+ token_type="Bearer",
1092
+ expires_at=None,
1093
+ ), "ok"
1094
+
1095
+ monkeypatch.setattr(app.autobots_client, "refresh_token", _fake_refresh)
1096
+
1097
+ assert app._refresh_token_if_needed(force=True) is True
1098
+ assert calls == ["test-refresh-token"]
911
1099
 
912
- assert int(messages.scroll_offset.y) == y_before
913
- assert int(messages.scroll_target_y) == target_before
914
- assert int(messages.scroll_offset.y) < int(messages.max_scroll_y)
915
1100
 
916
1101
  @pytest.mark.asyncio
917
- async def test_tui_quits_cleanly(isolated_home: Path) -> None:
1102
+ async def test_tui_auth_expired_clears_dashboard_draft_and_messages(
1103
+ isolated_home: Path, monkeypatch: pytest.MonkeyPatch
1104
+ ) -> None:
1105
+ tokens_path = isolated_home / ".kiwi" / "tokens.json"
1106
+ tokens_path.parent.mkdir(parents=True, exist_ok=True)
1107
+ tokens_path.write_text(
1108
+ json.dumps(
1109
+ {
1110
+ "access_token": "test-access-token",
1111
+ "refresh_token": "test-refresh-token",
1112
+ "token_type": "Bearer",
1113
+ "expires_at": None,
1114
+ }
1115
+ ),
1116
+ encoding="utf-8",
1117
+ )
1118
+
1119
+ from autobots_client.api.actions import get_action_v1_actions_id_get
1120
+ monkeypatch.setattr(
1121
+ get_action_v1_actions_id_get,
1122
+ "sync_detailed",
1123
+ lambda *, id, client: SimpleNamespace(
1124
+ status_code=200,
1125
+ parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
1126
+ ),
1127
+ )
1128
+
1129
+ from textual.containers import VerticalScroll
918
1130
  from kiwi_tui.main import AutobotsTUI
1131
+ from kiwi_tui.widgets import ChatInput
919
1132
 
920
1133
  app = AutobotsTUI()
921
1134
  async with app.run_test() as pilot:
922
1135
  await pilot.pause()
923
- await pilot.press("ctrl+c")
1136
+ assert type(app.screen).__name__ == "DashboardScreen"
1137
+ dashboard = app.screen
1138
+ chat_input = dashboard.query_one("#chat-input", ChatInput)
1139
+ messages = dashboard.query_one("#messages", VerticalScroll)
1140
+
1141
+ chat_input.value = "hi"
1142
+ dashboard.add_message("hi", "user")
924
1143
  await pilot.pause()
925
- # Reaching here without an exception is the assertion.
1144
+ assert len(list(messages.children)) > 0
1145
+
1146
+ app._handle_auth_expired("Session expired during test.")
1147
+ await pilot.pause()
1148
+
1149
+ assert chat_input.value == ""
1150
+ assert len(list(messages.children)) == 0
1151
+ assert type(app.screen).__name__ == "LoginScreen"
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.41"
400
+ version = "0.0.43"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes