kiwi-code 0.0.434__tar.gz → 0.0.436__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/PKG-INFO +1 -1
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/pyproject.toml +1 -1
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/auth.py +22 -16
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/client.py +28 -6
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/models.py +16 -13
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/main.py +72 -3
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/dashboard.py +34 -1
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/runtime_cleanup.py +5 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_tui_headless.py +0 -49
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/uv.lock +1 -1
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/.gitignore +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/.python-version +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/CLAUDE.md +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/Makefile +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/README.md +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/test_hello.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/__init__.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/conftest.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.434 → kiwi_code-0.0.436}/tests/test_worktrees.py +0 -0
|
@@ -6,6 +6,9 @@ import time
|
|
|
6
6
|
from contextlib import contextmanager
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Optional
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from .models import AuthTokens
|
|
11
|
+
|
|
9
12
|
def _lock_path_for(token_path: Path) -> Path:
|
|
10
13
|
return token_path.with_suffix(".lock")
|
|
11
14
|
|
|
@@ -51,11 +54,6 @@ def _atomic_write_json(path: Path, data: dict) -> None:
|
|
|
51
54
|
if sys.platform != "win32":
|
|
52
55
|
path.chmod(0o600)
|
|
53
56
|
|
|
54
|
-
from loguru import logger
|
|
55
|
-
|
|
56
|
-
from .models import AuthTokens
|
|
57
|
-
|
|
58
|
-
|
|
59
57
|
|
|
60
58
|
class TokenManager:
|
|
61
59
|
"""Manages authentication tokens with secure storage."""
|
|
@@ -102,17 +100,25 @@ class TokenManager:
|
|
|
102
100
|
logger.debug("No saved tokens found")
|
|
103
101
|
return None
|
|
104
102
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
last_err: Exception | None = None
|
|
104
|
+
for attempt in range(5):
|
|
105
|
+
try:
|
|
106
|
+
with open(self.token_path, "r", encoding="utf-8") as f:
|
|
107
|
+
data = json.load(f)
|
|
108
|
+
self._tokens = AuthTokens(**data)
|
|
109
|
+
logger.info("Authentication tokens loaded")
|
|
110
|
+
return self._tokens
|
|
111
|
+
except Exception as e:
|
|
112
|
+
last_err = e
|
|
113
|
+
if attempt < 4:
|
|
114
|
+
time.sleep(0.05)
|
|
115
|
+
continue
|
|
116
|
+
logger.error(f"Failed to load tokens: {e}")
|
|
117
|
+
return None
|
|
118
|
+
if last_err:
|
|
119
|
+
logger.error(f"Failed to load tokens: {last_err}")
|
|
120
|
+
return None
|
|
121
|
+
|
|
116
122
|
def clear_tokens(self) -> None:
|
|
117
123
|
"""Clear stored tokens under the same lock used for refresh/write flows."""
|
|
118
124
|
with self.file_lock():
|
|
@@ -71,7 +71,7 @@ class AutobotsClientWrapper:
|
|
|
71
71
|
self.access_token = access_token
|
|
72
72
|
self.client = AuthenticatedClient(
|
|
73
73
|
base_url=self.base_url,
|
|
74
|
-
token=access_token,
|
|
74
|
+
token=self.access_token,
|
|
75
75
|
raise_on_unexpected_status=False,
|
|
76
76
|
)
|
|
77
77
|
logger.info("Client updated with new access token")
|
|
@@ -598,6 +598,17 @@ class AutobotsClientWrapper:
|
|
|
598
598
|
logger.error(f"Error processing SSE data: {e}")
|
|
599
599
|
return False
|
|
600
600
|
|
|
601
|
+
pending_blank = False
|
|
602
|
+
|
|
603
|
+
def _is_field_line(s: str) -> bool:
|
|
604
|
+
return (
|
|
605
|
+
s.startswith("data:")
|
|
606
|
+
or s.startswith(":")
|
|
607
|
+
or s.startswith("event:")
|
|
608
|
+
or s.startswith("id:")
|
|
609
|
+
or s.startswith("retry:")
|
|
610
|
+
)
|
|
611
|
+
|
|
601
612
|
async for chunk in response.aiter_bytes():
|
|
602
613
|
text_chunk = decoder.decode(chunk, final=False)
|
|
603
614
|
buffer += text_chunk
|
|
@@ -606,14 +617,26 @@ class AutobotsClientWrapper:
|
|
|
606
617
|
line, buffer = buffer.split("\n", 1)
|
|
607
618
|
line = line.rstrip("\r")
|
|
608
619
|
|
|
609
|
-
|
|
620
|
+
if pending_blank:
|
|
621
|
+
if line == "":
|
|
622
|
+
pending_blank = False
|
|
623
|
+
if _flush_event():
|
|
624
|
+
return
|
|
625
|
+
continue
|
|
626
|
+
if _is_field_line(line):
|
|
627
|
+
pending_blank = False
|
|
628
|
+
if _flush_event():
|
|
629
|
+
return
|
|
630
|
+
else:
|
|
631
|
+
event_data_lines.append("")
|
|
632
|
+
pending_blank = False
|
|
633
|
+
|
|
610
634
|
if line == "":
|
|
611
|
-
if
|
|
612
|
-
|
|
635
|
+
if event_data_lines:
|
|
636
|
+
pending_blank = True
|
|
613
637
|
continue
|
|
614
638
|
|
|
615
639
|
if line.startswith(":"):
|
|
616
|
-
# keep-alive comments
|
|
617
640
|
continue
|
|
618
641
|
|
|
619
642
|
if line.startswith("data:"):
|
|
@@ -626,7 +649,6 @@ class AutobotsClientWrapper:
|
|
|
626
649
|
if event_data_lines:
|
|
627
650
|
event_data_lines.append(line)
|
|
628
651
|
|
|
629
|
-
# Flush any remaining bytes in decoder + any pending event data.
|
|
630
652
|
decoder.decode(b"", final=True)
|
|
631
653
|
_flush_event()
|
|
632
654
|
except Exception as e:
|
|
@@ -65,14 +65,7 @@ class AuthTokens(BaseModel):
|
|
|
65
65
|
token_type: str = "Bearer"
|
|
66
66
|
expires_at: Optional[datetime] = None
|
|
67
67
|
|
|
68
|
-
def
|
|
69
|
-
"""Check if access token is expired.
|
|
70
|
-
|
|
71
|
-
Notes:
|
|
72
|
-
- Prefer `expires_at` when present.
|
|
73
|
-
- If `expires_at` is missing (common when the backend doesn't return expires_in),
|
|
74
|
-
fall back to the JWT `exp` claim (if the access token is a JWT).
|
|
75
|
-
"""
|
|
68
|
+
def effective_expires_at(self) -> datetime | None:
|
|
76
69
|
def _jwt_exp(token: str) -> datetime | None:
|
|
77
70
|
try:
|
|
78
71
|
parts = token.split(".")
|
|
@@ -93,16 +86,26 @@ class AuthTokens(BaseModel):
|
|
|
93
86
|
jwt_expires_at = _jwt_exp(self.access_token)
|
|
94
87
|
effective_expires_at = self.expires_at or jwt_expires_at
|
|
95
88
|
if self.expires_at and jwt_expires_at:
|
|
96
|
-
# Be conservative if they disagree.
|
|
97
89
|
effective_expires_at = min(self.expires_at, jwt_expires_at)
|
|
90
|
+
return effective_expires_at
|
|
98
91
|
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
def is_expiring_within(self, seconds: int) -> bool:
|
|
93
|
+
exp = self.effective_expires_at()
|
|
94
|
+
if not exp:
|
|
95
|
+
return False
|
|
96
|
+
try:
|
|
97
|
+
return datetime.now() >= (exp - timedelta(seconds=max(0, int(seconds))))
|
|
98
|
+
except Exception:
|
|
101
99
|
return False
|
|
102
100
|
|
|
101
|
+
def is_expired(self) -> bool:
|
|
102
|
+
"""Check if access token is expired (with a small safety buffer)."""
|
|
103
|
+
exp = self.effective_expires_at()
|
|
104
|
+
if not exp:
|
|
105
|
+
# No expiry info; assume valid (server will decide).
|
|
106
|
+
return False
|
|
103
107
|
# Add 60 second buffer before expiry
|
|
104
|
-
return datetime.now() >= (
|
|
105
|
-
|
|
108
|
+
return datetime.now() >= (exp - timedelta(seconds=60))
|
|
106
109
|
|
|
107
110
|
class LoginCredentials(BaseModel):
|
|
108
111
|
"""Login credentials."""
|
|
@@ -602,6 +602,11 @@ class AutobotsTUI(App):
|
|
|
602
602
|
self._token_refresh_timer = self.set_interval(
|
|
603
603
|
interval_min * 60, self._refresh_token_if_needed
|
|
604
604
|
)
|
|
605
|
+
try:
|
|
606
|
+
self._refresh_token_if_needed(force=False)
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
|
|
605
610
|
# Write current tokens to shared files so runtime can read them
|
|
606
611
|
# if self._kiwi_token:
|
|
607
612
|
# runtime_manager.save_token(self._kiwi_token)
|
|
@@ -609,6 +614,38 @@ class AutobotsTUI(App):
|
|
|
609
614
|
# if tokens and tokens.refresh_token:
|
|
610
615
|
# runtime_manager.save_refresh_token(tokens.refresh_token)
|
|
611
616
|
|
|
617
|
+
def _sync_autobots_client_from_disk(self) -> bool:
|
|
618
|
+
try:
|
|
619
|
+
with self.token_manager.file_lock():
|
|
620
|
+
tokens = self.token_manager.load_tokens()
|
|
621
|
+
except Exception:
|
|
622
|
+
tokens = None
|
|
623
|
+
access = getattr(tokens, "access_token", None) if tokens else None
|
|
624
|
+
try:
|
|
625
|
+
access_str = str(access)
|
|
626
|
+
log_full = os.environ.get("KIWI_LOG_TOKENS") == "1"
|
|
627
|
+
if log_full:
|
|
628
|
+
logger.debug(f"Loaded access token from disk: {access_str}")
|
|
629
|
+
else:
|
|
630
|
+
redacted = access_str
|
|
631
|
+
if len(access_str) > 24:
|
|
632
|
+
redacted = f"{access_str[:12]}...{access_str[-6:]}"
|
|
633
|
+
logger.debug(
|
|
634
|
+
f"Loaded access token from disk: {redacted} (len={len(access_str)})"
|
|
635
|
+
)
|
|
636
|
+
except Exception:
|
|
637
|
+
# Best-effort only; never block requests on logging.
|
|
638
|
+
pass
|
|
639
|
+
|
|
640
|
+
if not access:
|
|
641
|
+
return False
|
|
642
|
+
try:
|
|
643
|
+
self.autobots_client.update_token(str(access))
|
|
644
|
+
return True
|
|
645
|
+
except Exception:
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
|
|
612
649
|
def _refresh_token_if_needed(self, force: bool = False) -> bool:
|
|
613
650
|
"""Synchronize auth state from disk and refresh when needed.
|
|
614
651
|
|
|
@@ -623,7 +660,28 @@ class AutobotsTUI(App):
|
|
|
623
660
|
logger.debug("No tokens to refresh")
|
|
624
661
|
return False
|
|
625
662
|
|
|
626
|
-
|
|
663
|
+
expired = False
|
|
664
|
+
try:
|
|
665
|
+
expired = bool(tokens.is_expired())
|
|
666
|
+
except Exception:
|
|
667
|
+
expired = False
|
|
668
|
+
|
|
669
|
+
threshold_sec = 60
|
|
670
|
+
try:
|
|
671
|
+
threshold_sec = max(60, int(self.config.refresh_interval) * 60 + 120)
|
|
672
|
+
except Exception:
|
|
673
|
+
threshold_sec = 360
|
|
674
|
+
|
|
675
|
+
expiring_soon = False
|
|
676
|
+
try:
|
|
677
|
+
fn = getattr(tokens, "is_expiring_within", None)
|
|
678
|
+
expiring_soon = bool(fn and fn(threshold_sec))
|
|
679
|
+
except Exception:
|
|
680
|
+
expiring_soon = False
|
|
681
|
+
|
|
682
|
+
needs_refresh = bool(force or expired or expiring_soon)
|
|
683
|
+
|
|
684
|
+
if not needs_refresh:
|
|
627
685
|
logger.debug("Token on disk is valid; synchronizing in-memory auth state")
|
|
628
686
|
if getattr(tokens, "access_token", None):
|
|
629
687
|
self.autobots_client.update_token(tokens.access_token)
|
|
@@ -631,8 +689,19 @@ class AutobotsTUI(App):
|
|
|
631
689
|
return True
|
|
632
690
|
|
|
633
691
|
if not getattr(tokens, "refresh_token", None):
|
|
634
|
-
|
|
635
|
-
|
|
692
|
+
if force or expired:
|
|
693
|
+
logger.warning("No refresh token available; cannot refresh access token")
|
|
694
|
+
return False
|
|
695
|
+
logger.warning(
|
|
696
|
+
f"Access token expires within {threshold_sec}s but no refresh token; continuing with existing token"
|
|
697
|
+
)
|
|
698
|
+
if getattr(tokens, "access_token", None):
|
|
699
|
+
self.autobots_client.update_token(tokens.access_token)
|
|
700
|
+
self._kiwi_token = tokens.access_token
|
|
701
|
+
return True
|
|
702
|
+
|
|
703
|
+
if expiring_soon and not expired and not force:
|
|
704
|
+
logger.info(f"Access token expires within {threshold_sec}s; refreshing early")
|
|
636
705
|
|
|
637
706
|
if getattr(tokens, "access_token", None):
|
|
638
707
|
self.autobots_client.update_token(tokens.access_token)
|
|
@@ -627,6 +627,11 @@ class DashboardScreen(Screen):
|
|
|
627
627
|
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
628
628
|
|
|
629
629
|
requested_action_id = self.current_action_id
|
|
630
|
+
try:
|
|
631
|
+
self.app._sync_autobots_client_from_disk()
|
|
632
|
+
except Exception:
|
|
633
|
+
pass
|
|
634
|
+
|
|
630
635
|
api_client = getattr(getattr(self.app, "autobots_client", None), "client", None)
|
|
631
636
|
if api_client is None:
|
|
632
637
|
return
|
|
@@ -855,6 +860,10 @@ class DashboardScreen(Screen):
|
|
|
855
860
|
so the Textual UI stays responsive and can render the loading indicator.
|
|
856
861
|
"""
|
|
857
862
|
try:
|
|
863
|
+
try:
|
|
864
|
+
self.app._sync_autobots_client_from_disk()
|
|
865
|
+
except Exception:
|
|
866
|
+
pass
|
|
858
867
|
parts = command.strip().split()
|
|
859
868
|
if not parts:
|
|
860
869
|
return
|
|
@@ -1821,6 +1830,11 @@ class DashboardScreen(Screen):
|
|
|
1821
1830
|
self.add_message("Error: Client not initialized", "error")
|
|
1822
1831
|
return None
|
|
1823
1832
|
|
|
1833
|
+
try:
|
|
1834
|
+
self.app._sync_autobots_client_from_disk()
|
|
1835
|
+
except Exception:
|
|
1836
|
+
pass
|
|
1837
|
+
|
|
1824
1838
|
api_client = self.app.autobots_client.client
|
|
1825
1839
|
if not isinstance(api_client, AuthenticatedClient):
|
|
1826
1840
|
self.add_message("Error: Not authenticated", "error")
|
|
@@ -1834,6 +1848,11 @@ class DashboardScreen(Screen):
|
|
|
1834
1848
|
def _get_api_client_for_command(self, title: str):
|
|
1835
1849
|
"""Get API client for slash commands without writing errors into chat."""
|
|
1836
1850
|
from autobots_client import AuthenticatedClient
|
|
1851
|
+
try:
|
|
1852
|
+
self.app._sync_autobots_client_from_disk()
|
|
1853
|
+
except Exception:
|
|
1854
|
+
pass
|
|
1855
|
+
|
|
1837
1856
|
|
|
1838
1857
|
if not hasattr(self.app, 'autobots_client'):
|
|
1839
1858
|
self._show_command_result(title, "Error: Client not initialized", is_error=True)
|
|
@@ -3214,7 +3233,11 @@ class DashboardScreen(Screen):
|
|
|
3214
3233
|
pass
|
|
3215
3234
|
|
|
3216
3235
|
async def _run_action_worker(self, user_input: str) -> None:
|
|
3217
|
-
|
|
3236
|
+
try:
|
|
3237
|
+
self.app._sync_autobots_client_from_disk()
|
|
3238
|
+
except Exception:
|
|
3239
|
+
pass
|
|
3240
|
+
|
|
3218
3241
|
client = self.app.autobots_client
|
|
3219
3242
|
|
|
3220
3243
|
checkpoint_run_dir: Path | None = None
|
|
@@ -3402,6 +3425,11 @@ class DashboardScreen(Screen):
|
|
|
3402
3425
|
|
|
3403
3426
|
async def _cache_run_name_worker(self, run_id: str) -> None:
|
|
3404
3427
|
"""Best-effort: cache the run's name in ~/.kiwi/runtimes so the quit prompt can show it."""
|
|
3428
|
+
try:
|
|
3429
|
+
self.app._sync_autobots_client_from_disk()
|
|
3430
|
+
except Exception:
|
|
3431
|
+
pass
|
|
3432
|
+
|
|
3405
3433
|
try:
|
|
3406
3434
|
wrapper = self.app.autobots_client
|
|
3407
3435
|
except Exception:
|
|
@@ -3449,6 +3477,11 @@ class DashboardScreen(Screen):
|
|
|
3449
3477
|
Runs SSE streaming and result-polling concurrently. Whichever
|
|
3450
3478
|
detects a terminal state first (success *or* error) wins.
|
|
3451
3479
|
"""
|
|
3480
|
+
try:
|
|
3481
|
+
self.app._sync_autobots_client_from_disk()
|
|
3482
|
+
except Exception:
|
|
3483
|
+
pass
|
|
3484
|
+
|
|
3452
3485
|
client = self.app.autobots_client
|
|
3453
3486
|
got_final_result = False
|
|
3454
3487
|
|
|
@@ -244,6 +244,11 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
|
|
|
244
244
|
if not by_run:
|
|
245
245
|
return
|
|
246
246
|
|
|
247
|
+
try:
|
|
248
|
+
self.app._sync_autobots_client_from_disk()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
247
252
|
wrapper = getattr(self.app, "autobots_client", None)
|
|
248
253
|
if not wrapper:
|
|
249
254
|
return
|
|
@@ -852,55 +852,6 @@ async def test_tui_streaming_requests_autofollow_when_viewport_is_near_bottom(
|
|
|
852
852
|
)
|
|
853
853
|
await pilot.pause()
|
|
854
854
|
assert calls == [{"after_refresh": True}]
|
|
855
|
-
@pytest.mark.asyncio
|
|
856
|
-
async def test_tui_streaming_does_not_force_scroll_when_user_reads_older_messages(
|
|
857
|
-
isolated_home: Path,
|
|
858
|
-
) -> None:
|
|
859
|
-
"""Streaming updates should not yank the viewport back to the bottom."""
|
|
860
|
-
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
861
|
-
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
862
|
-
tokens_path.write_text(
|
|
863
|
-
json.dumps(
|
|
864
|
-
{
|
|
865
|
-
"access_token": "test-access-token",
|
|
866
|
-
"refresh_token": "test-refresh-token",
|
|
867
|
-
"token_type": "Bearer",
|
|
868
|
-
"expires_at": None,
|
|
869
|
-
}
|
|
870
|
-
),
|
|
871
|
-
encoding="utf-8",
|
|
872
|
-
)
|
|
873
|
-
|
|
874
|
-
from kiwi_tui.main import AutobotsTUI
|
|
875
|
-
|
|
876
|
-
app = AutobotsTUI()
|
|
877
|
-
async with app.run_test(size=(80, 24)) as pilot:
|
|
878
|
-
await pilot.pause()
|
|
879
|
-
assert type(app.screen).__name__ == "DashboardScreen"
|
|
880
|
-
screen = app.screen
|
|
881
|
-
|
|
882
|
-
calls: list[dict] = []
|
|
883
|
-
monkeypatch = pytest.MonkeyPatch()
|
|
884
|
-
monkeypatch.setattr(screen, "_messages_is_near_bottom", lambda *args, **kwargs: False)
|
|
885
|
-
monkeypatch.setattr(
|
|
886
|
-
screen,
|
|
887
|
-
"_scroll_messages_to_end",
|
|
888
|
-
lambda *args, **kwargs: calls.append(dict(kwargs)),
|
|
889
|
-
)
|
|
890
|
-
try:
|
|
891
|
-
widget = screen.update_streaming_message({"blocks": [{"text": "stream start"}]})
|
|
892
|
-
await pilot.pause()
|
|
893
|
-
assert widget is not None
|
|
894
|
-
assert calls == []
|
|
895
|
-
|
|
896
|
-
screen.update_streaming_message(
|
|
897
|
-
{"blocks": [{"text": "\n".join(f"stream line {i}" for i in range(60))}]},
|
|
898
|
-
widget,
|
|
899
|
-
)
|
|
900
|
-
await pilot.pause()
|
|
901
|
-
assert calls == []
|
|
902
|
-
finally:
|
|
903
|
-
monkeypatch.undo()
|
|
904
855
|
|
|
905
856
|
@pytest.mark.asyncio
|
|
906
857
|
async def test_tui_quits_cleanly(isolated_home: Path) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|