kiwi-code 0.0.35__tar.gz → 0.0.37__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.35 → kiwi_code-0.0.37}/PKG-INFO +1 -1
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/pyproject.toml +1 -1
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/auth.py +14 -13
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/cli.py +4 -1
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/client.py +3 -2
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/main.py +29 -4
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/main.py +74 -33
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/widgets.py +96 -2
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_tui_headless.py +140 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/uv.lock +1 -1
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.gitignore +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.python-version +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/CLAUDE.md +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/Makefile +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/README.md +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/dashboard.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/test_hello.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/conftest.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_tui_palette.py +0 -0
|
@@ -117,25 +117,26 @@ class TokenManager:
|
|
|
117
117
|
return None
|
|
118
118
|
|
|
119
119
|
def clear_tokens(self) -> None:
|
|
120
|
-
"""Clear stored tokens."""
|
|
121
|
-
self.
|
|
120
|
+
"""Clear stored tokens under the same lock used for refresh/write flows."""
|
|
121
|
+
with self.file_lock():
|
|
122
|
+
self._tokens = None
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
if self.token_path.exists():
|
|
125
|
+
try:
|
|
126
|
+
self.token_path.unlink()
|
|
127
|
+
logger.info("Authentication tokens cleared")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error(f"Failed to clear tokens: {e}")
|
|
129
130
|
|
|
130
131
|
@property
|
|
131
132
|
def tokens(self) -> Optional[AuthTokens]:
|
|
132
|
-
"""Get current tokens.
|
|
133
|
+
"""Get current tokens from disk.
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
In multiprocess mode the shared token file may change at any time, so
|
|
136
|
+
callers should observe the latest on-disk state rather than a long-lived
|
|
137
|
+
in-memory cache.
|
|
136
138
|
"""
|
|
137
|
-
|
|
138
|
-
self._tokens = self.load_tokens()
|
|
139
|
+
self._tokens = self.load_tokens()
|
|
139
140
|
return self._tokens
|
|
140
141
|
|
|
141
142
|
def is_authenticated(self) -> bool:
|
|
@@ -82,13 +82,16 @@ def _force_refresh_tokens(tm: TokenManager) -> bool:
|
|
|
82
82
|
tokens = tm.load_tokens()
|
|
83
83
|
if not tokens:
|
|
84
84
|
return False
|
|
85
|
+
access = getattr(tokens, "access_token", None)
|
|
86
|
+
if access and not tokens.is_expired():
|
|
87
|
+
return True
|
|
85
88
|
refresh = getattr(tokens, "refresh_token", None)
|
|
86
89
|
if not refresh:
|
|
87
90
|
return False
|
|
88
91
|
|
|
89
92
|
wrapper = AutobotsClientWrapper(
|
|
90
93
|
base_url=_backend_url(),
|
|
91
|
-
access_token=
|
|
94
|
+
access_token=access,
|
|
92
95
|
)
|
|
93
96
|
ok, new_tokens, _msg = wrapper.refresh_token(str(refresh))
|
|
94
97
|
if ok and new_tokens:
|
|
@@ -183,8 +183,9 @@ class AutobotsClientWrapper:
|
|
|
183
183
|
# even if `refresh_token` is provided as a query param.
|
|
184
184
|
url = f"{self.base_url.rstrip('/')}/v1/auth/session/refresh"
|
|
185
185
|
with httpx.Client(timeout=30) as client:
|
|
186
|
-
#
|
|
187
|
-
#
|
|
186
|
+
# Send the current access token alongside the refresh token.
|
|
187
|
+
# In multi-process setups callers must ensure this wrapper has been
|
|
188
|
+
# synchronized with the latest on-disk access token before refresh.
|
|
188
189
|
headers: dict[str, str] = {}
|
|
189
190
|
if self.access_token:
|
|
190
191
|
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
@@ -175,12 +175,19 @@ class _TokensLock:
|
|
|
175
175
|
self.fp = None
|
|
176
176
|
|
|
177
177
|
|
|
178
|
-
async def _refresh_tokens(
|
|
178
|
+
async def _refresh_tokens(
|
|
179
|
+
http_base_url: str, refresh_token: str, access_token: str | None = None
|
|
180
|
+
) -> dict[str, Any]:
|
|
179
181
|
"""Refresh tokens via the backend and return tokens.json-compatible dict."""
|
|
180
182
|
from datetime import datetime, timedelta
|
|
181
183
|
url = f"{http_base_url.rstrip('/')}/v1/auth/session/refresh"
|
|
184
|
+
headers: dict[str, str] = {}
|
|
185
|
+
if access_token:
|
|
186
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
182
187
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
183
|
-
resp = await client.post(
|
|
188
|
+
resp = await client.post(
|
|
189
|
+
url, params={"refresh_token": refresh_token}, headers=headers
|
|
190
|
+
)
|
|
184
191
|
if resp.status_code != 200:
|
|
185
192
|
raise Exception(f"Token refresh failed (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
186
193
|
body = resp.json()
|
|
@@ -206,7 +213,6 @@ async def _refresh_tokens(http_base_url: str, refresh_token: str) -> dict[str, A
|
|
|
206
213
|
out["expires_at"] = expires_at
|
|
207
214
|
return out
|
|
208
215
|
|
|
209
|
-
|
|
210
216
|
async def _get_valid_access_token(http_base_url: str, fallback_token: str) -> str:
|
|
211
217
|
"""Return a non-expired access token, preferring ~/.kiwi/tokens.json when available.
|
|
212
218
|
|
|
@@ -235,7 +241,9 @@ async def _get_valid_access_token(http_base_url: str, fallback_token: str) -> st
|
|
|
235
241
|
return access2
|
|
236
242
|
# Still expired -> attempt refresh. If refresh fails, fall back to the best token we have.
|
|
237
243
|
try:
|
|
238
|
-
new_data = await _refresh_tokens(
|
|
244
|
+
new_data = await _refresh_tokens(
|
|
245
|
+
http_base_url, str(refresh2), str(access2) if access2 else None
|
|
246
|
+
)
|
|
239
247
|
_atomic_write_tokens(TOKENS_PATH, new_data)
|
|
240
248
|
return str(new_data.get("access_token"))
|
|
241
249
|
except Exception:
|
|
@@ -253,6 +261,23 @@ async def _force_refresh_access_token(http_base_url: str) -> dict[str, Any] | No
|
|
|
253
261
|
data = _load_tokens_file(TOKENS_PATH) or {}
|
|
254
262
|
with _TokensLock():
|
|
255
263
|
data2 = _load_tokens_file(TOKENS_PATH) or data or {}
|
|
264
|
+
access_token = data2.get("access_token")
|
|
265
|
+
expires_at = _parse_expires_at(data2.get("expires_at"))
|
|
266
|
+
if access_token and not _token_is_expired(access_token=str(access_token), expires_at=expires_at):
|
|
267
|
+
return data2
|
|
268
|
+
refresh_token = data2.get("refresh_token")
|
|
269
|
+
if not refresh_token:
|
|
270
|
+
return None
|
|
271
|
+
try:
|
|
272
|
+
new_data = await _refresh_tokens(
|
|
273
|
+
http_base_url, str(refresh_token), str(access_token) if access_token else None
|
|
274
|
+
)
|
|
275
|
+
_atomic_write_tokens(TOKENS_PATH, new_data)
|
|
276
|
+
return new_data
|
|
277
|
+
except Exception:
|
|
278
|
+
# If refresh failed, keep existing auth state and let the caller decide.
|
|
279
|
+
return None
|
|
280
|
+
data2 = _load_tokens_file(TOKENS_PATH) or data or {}
|
|
256
281
|
refresh_token = data2.get("refresh_token")
|
|
257
282
|
if not refresh_token:
|
|
258
283
|
return None
|
|
@@ -167,6 +167,7 @@ class AutobotsTUI(App):
|
|
|
167
167
|
self.pending_runtime_id: str | None = None
|
|
168
168
|
|
|
169
169
|
self._kiwi_token = None # Set after auth for kiwi CLI session
|
|
170
|
+
self._token_refresh_timer = None # Periodic token refresh handle
|
|
170
171
|
# self._kiwi_pid = None # PID of the runtime process
|
|
171
172
|
# self._log_fp = None # File pointer for tailing runtime log
|
|
172
173
|
self._kiwi_log_lines = collections.deque(maxlen=self._KIWI_LOG_BUFFER_SIZE)
|
|
@@ -179,11 +180,14 @@ class AutobotsTUI(App):
|
|
|
179
180
|
with self.token_manager.file_lock():
|
|
180
181
|
tokens = self.token_manager.load_tokens()
|
|
181
182
|
if tokens:
|
|
182
|
-
# Check if token needs refresh
|
|
183
183
|
if tokens.is_expired():
|
|
184
184
|
logger.info("Access token expired, attempting refresh")
|
|
185
|
-
# Try to refresh token
|
|
186
|
-
|
|
185
|
+
# Try to refresh token using the latest access token loaded
|
|
186
|
+
# from disk, not stale in-memory client state.
|
|
187
|
+
temp_client = AutobotsClientWrapper(
|
|
188
|
+
base_url=self.config.backend_url,
|
|
189
|
+
access_token=tokens.access_token,
|
|
190
|
+
)
|
|
187
191
|
success, new_tokens, message = temp_client.refresh_token(tokens.refresh_token)
|
|
188
192
|
|
|
189
193
|
if success and new_tokens:
|
|
@@ -578,16 +582,19 @@ class AutobotsTUI(App):
|
|
|
578
582
|
# self._kiwi_pid = None
|
|
579
583
|
# self._close_log_tail()
|
|
580
584
|
# self._kiwi_log_lines.append("--- Runtime disconnected ---")
|
|
581
|
-
|
|
582
|
-
# ------------------------------------------------------------------
|
|
583
|
-
# Periodic token refresh
|
|
584
|
-
# ------------------------------------------------------------------
|
|
585
|
-
|
|
586
585
|
def _start_token_refresh(self) -> None:
|
|
587
586
|
"""Schedule periodic token refresh (every `refresh_interval` minutes)."""
|
|
588
587
|
interval_min = self.config.refresh_interval # default 5
|
|
589
588
|
logger.info(f"Token refresh scheduled every {interval_min} minutes")
|
|
590
|
-
|
|
589
|
+
existing_timer = getattr(self, "_token_refresh_timer", None)
|
|
590
|
+
if existing_timer is not None:
|
|
591
|
+
try:
|
|
592
|
+
existing_timer.stop()
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
self._token_refresh_timer = self.set_interval(
|
|
596
|
+
interval_min * 60, self._refresh_token_if_needed
|
|
597
|
+
)
|
|
591
598
|
# Write current tokens to shared files so runtime can read them
|
|
592
599
|
# if self._kiwi_token:
|
|
593
600
|
# runtime_manager.save_token(self._kiwi_token)
|
|
@@ -596,10 +603,11 @@ class AutobotsTUI(App):
|
|
|
596
603
|
# runtime_manager.save_refresh_token(tokens.refresh_token)
|
|
597
604
|
|
|
598
605
|
def _refresh_token_if_needed(self, force: bool = False) -> bool:
|
|
599
|
-
"""
|
|
606
|
+
"""Synchronize auth state from disk and refresh only when needed.
|
|
600
607
|
|
|
601
608
|
Args:
|
|
602
|
-
force: If True,
|
|
609
|
+
force: If True, re-check disk state immediately and refresh only if
|
|
610
|
+
the shared on-disk access token is still expired.
|
|
603
611
|
"""
|
|
604
612
|
with self.token_manager.file_lock():
|
|
605
613
|
tokens = self.token_manager.load_tokens()
|
|
@@ -607,14 +615,21 @@ class AutobotsTUI(App):
|
|
|
607
615
|
logger.debug("No tokens to refresh")
|
|
608
616
|
return False
|
|
609
617
|
|
|
610
|
-
if
|
|
611
|
-
logger.debug("Token
|
|
618
|
+
if not tokens.is_expired():
|
|
619
|
+
logger.debug("Token on disk is valid; synchronizing in-memory auth state")
|
|
620
|
+
if getattr(tokens, "access_token", None):
|
|
621
|
+
self.autobots_client.update_token(tokens.access_token)
|
|
622
|
+
self._kiwi_token = tokens.access_token
|
|
612
623
|
return True
|
|
613
624
|
|
|
614
625
|
if not getattr(tokens, "refresh_token", None):
|
|
615
626
|
logger.warning("No refresh token available; cannot refresh access token")
|
|
616
627
|
return False
|
|
617
628
|
|
|
629
|
+
if getattr(tokens, "access_token", None):
|
|
630
|
+
self.autobots_client.update_token(tokens.access_token)
|
|
631
|
+
self._kiwi_token = tokens.access_token
|
|
632
|
+
|
|
618
633
|
logger.info("Refreshing access token...")
|
|
619
634
|
success, new_tokens, message = self.autobots_client.refresh_token(tokens.refresh_token)
|
|
620
635
|
|
|
@@ -652,26 +667,6 @@ class AutobotsTUI(App):
|
|
|
652
667
|
# if self._kiwi_pid:
|
|
653
668
|
# self._stop_runtime()
|
|
654
669
|
# logger.info("Runtime stopped for restart")
|
|
655
|
-
# self._start_kiwi_session()
|
|
656
|
-
# if self._kiwi_pid:
|
|
657
|
-
# logger.info("Runtime restarted by user")
|
|
658
|
-
# self.notify("Runtime restarted", severity="information")
|
|
659
|
-
# else:
|
|
660
|
-
# self.notify("Failed to restart runtime", severity="error")
|
|
661
|
-
|
|
662
|
-
# def action_runtime_logs(self) -> None:
|
|
663
|
-
# """Show the runtime log stream."""
|
|
664
|
-
# self.push_screen("runtime_logs")
|
|
665
|
-
|
|
666
|
-
def action_toggle_dark(self) -> None:
|
|
667
|
-
"""Toggle dark mode."""
|
|
668
|
-
self.dark = not self.dark
|
|
669
|
-
theme = "dark" if self.dark else "light"
|
|
670
|
-
# Runtime-only: do not persist theme to disk.
|
|
671
|
-
self.config.theme = theme
|
|
672
|
-
logger.info(f"Theme changed to {theme}")
|
|
673
|
-
self.notify(f"Theme: {theme}", severity="information")
|
|
674
|
-
|
|
675
670
|
def action_show_logs(self) -> None:
|
|
676
671
|
"""Global keyboard shortcut to open CLI logs (if dashboard is active)."""
|
|
677
672
|
try:
|
|
@@ -689,8 +684,17 @@ class AutobotsTUI(App):
|
|
|
689
684
|
# self._close_log_tail()
|
|
690
685
|
# self._kiwi_pid = None
|
|
691
686
|
|
|
687
|
+
timer = getattr(self, "_token_refresh_timer", None)
|
|
688
|
+
if timer is not None:
|
|
689
|
+
try:
|
|
690
|
+
timer.stop()
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
self._token_refresh_timer = None
|
|
694
|
+
|
|
692
695
|
# Clear tokens
|
|
693
696
|
self.token_manager.clear_tokens()
|
|
697
|
+
self._kiwi_token = None
|
|
694
698
|
|
|
695
699
|
# Reset client to unauthenticated
|
|
696
700
|
self.autobots_client = AutobotsClientWrapper(
|
|
@@ -766,6 +770,43 @@ class AutobotsTUI(App):
|
|
|
766
770
|
def action_quit(self) -> None:
|
|
767
771
|
"""Quit the application."""
|
|
768
772
|
self.request_quit()
|
|
773
|
+
try:
|
|
774
|
+
all_rt = list_known_runtimes()
|
|
775
|
+
except Exception:
|
|
776
|
+
all_rt = []
|
|
777
|
+
|
|
778
|
+
alive = [r for r in all_rt if r.get("alive") and r.get("pid")]
|
|
779
|
+
if not alive:
|
|
780
|
+
self._final_exit()
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
rows: list[RuntimeRow] = []
|
|
784
|
+
for r in alive:
|
|
785
|
+
try:
|
|
786
|
+
meta = r.get("meta") or {}
|
|
787
|
+
name = None
|
|
788
|
+
if isinstance(meta, dict):
|
|
789
|
+
name = meta.get("run_name")
|
|
790
|
+
rows.append(
|
|
791
|
+
RuntimeRow(
|
|
792
|
+
kind=str(r.get("kind")),
|
|
793
|
+
runtime_id=str(r.get("id")),
|
|
794
|
+
pid=int(r.get("pid")),
|
|
795
|
+
alive=True,
|
|
796
|
+
log_path=str(r.get("log_path")),
|
|
797
|
+
name=name if isinstance(name, str) else None,
|
|
798
|
+
kill=False,
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
except Exception:
|
|
802
|
+
continue
|
|
803
|
+
|
|
804
|
+
# Show interactive prompt; the callback will exit.
|
|
805
|
+
self.push_screen(RuntimeCleanupScreen(rows), callback=self._on_runtime_cleanup_done)
|
|
806
|
+
|
|
807
|
+
def action_quit(self) -> None:
|
|
808
|
+
"""Quit the application."""
|
|
809
|
+
self.request_quit()
|
|
769
810
|
|
|
770
811
|
|
|
771
812
|
def _run_tui(runtime_args: RuntimeConnectArgs | None = None):
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import shlex
|
|
4
4
|
import sys
|
|
5
|
+
import time
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from urllib.parse import unquote, urlparse
|
|
7
8
|
|
|
@@ -94,6 +95,11 @@ class ChatInput(TextArea):
|
|
|
94
95
|
self._history_index: int = -1
|
|
95
96
|
self._draft: str = "" # saves current text when browsing history
|
|
96
97
|
self.picker_active: bool = False # True when inline file picker is showing
|
|
98
|
+
self._pending_clipboard_paste: str | None = None
|
|
99
|
+
self._recent_paste_text: str = ""
|
|
100
|
+
self._recent_paste_at: float = 0.0
|
|
101
|
+
self._last_value_snapshot: str = self.text
|
|
102
|
+
self._normalizing_duplicate_paste: bool = False
|
|
97
103
|
|
|
98
104
|
# Ensure a sensible initial height. This will be refined after the first layout.
|
|
99
105
|
self._adjust_height()
|
|
@@ -160,16 +166,69 @@ class ChatInput(TextArea):
|
|
|
160
166
|
resolved_paths = []
|
|
161
167
|
break
|
|
162
168
|
path = Path(normalized_token).expanduser()
|
|
163
|
-
|
|
169
|
+
try:
|
|
170
|
+
if not path.exists() or not path.is_file():
|
|
171
|
+
resolved_paths = []
|
|
172
|
+
break
|
|
173
|
+
resolved_paths.append(str(path.resolve()))
|
|
174
|
+
except OSError:
|
|
164
175
|
resolved_paths = []
|
|
165
176
|
break
|
|
166
|
-
resolved_paths.append(str(path.resolve()))
|
|
167
177
|
if resolved_paths:
|
|
168
178
|
# Preserve order while removing duplicates.
|
|
169
179
|
return list(dict.fromkeys(resolved_paths))
|
|
170
180
|
|
|
171
181
|
return None
|
|
172
182
|
|
|
183
|
+
def _remember_recent_paste(self, text: str) -> None:
|
|
184
|
+
"""Remember the latest paste payload to avoid duplicate insertions."""
|
|
185
|
+
if not text:
|
|
186
|
+
return
|
|
187
|
+
self._recent_paste_text = text
|
|
188
|
+
self._recent_paste_at = time.monotonic()
|
|
189
|
+
|
|
190
|
+
def _is_recent_duplicate_paste(self, text: str, *, window_sec: float = 0.25) -> bool:
|
|
191
|
+
"""Return True when the same paste payload was just handled."""
|
|
192
|
+
if not text:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def _flush_pending_clipboard_paste(self, expected_text: str) -> None:
|
|
196
|
+
"""Insert a deferred clipboard paste if no terminal paste event arrived."""
|
|
197
|
+
if self.read_only:
|
|
198
|
+
self._pending_clipboard_paste = None
|
|
199
|
+
return
|
|
200
|
+
if self._pending_clipboard_paste != expected_text:
|
|
201
|
+
return
|
|
202
|
+
self._pending_clipboard_paste = None
|
|
203
|
+
if result := self._replace_via_keyboard(expected_text, *self.selection):
|
|
204
|
+
self.move_cursor(result.end_location)
|
|
205
|
+
self.focus()
|
|
206
|
+
|
|
207
|
+
def _collapse_duplicate_append(self, previous: str, current: str) -> str | None:
|
|
208
|
+
"""Collapse accidental duplicate text appended to the end of the input.
|
|
209
|
+
|
|
210
|
+
Some terminals appear to deliver a single paste twice. In the observed
|
|
211
|
+
failure mode, the duplicated text is appended contiguously at the cursor
|
|
212
|
+
position, so the new value becomes `previous + chunk + chunk`.
|
|
213
|
+
"""
|
|
214
|
+
if current == previous:
|
|
215
|
+
return None
|
|
216
|
+
if not current.startswith(previous):
|
|
217
|
+
return None
|
|
218
|
+
tail = current[len(previous):]
|
|
219
|
+
if not tail or len(tail) % 2 != 0:
|
|
220
|
+
return None
|
|
221
|
+
half = len(tail) // 2
|
|
222
|
+
if half < 3:
|
|
223
|
+
return None
|
|
224
|
+
left = tail[:half]
|
|
225
|
+
right = tail[half:]
|
|
226
|
+
if left != right:
|
|
227
|
+
return None
|
|
228
|
+
# Avoid collapsing legitimately long repeated-character pastes.
|
|
229
|
+
if len(set(left)) == 1:
|
|
230
|
+
return None
|
|
231
|
+
return previous + left
|
|
173
232
|
def _move_cursor_to_end(self) -> None:
|
|
174
233
|
try:
|
|
175
234
|
self.move_cursor(self.document.end)
|
|
@@ -218,6 +277,22 @@ class ChatInput(TextArea):
|
|
|
218
277
|
|
|
219
278
|
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
220
279
|
# Keep height in sync with content changes.
|
|
280
|
+
if self._normalizing_duplicate_paste:
|
|
281
|
+
self._last_value_snapshot = self.value
|
|
282
|
+
self._adjust_height()
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
previous = self._last_value_snapshot
|
|
286
|
+
current = self.value
|
|
287
|
+
collapsed = self._collapse_duplicate_append(previous, current)
|
|
288
|
+
if collapsed is not None:
|
|
289
|
+
self._normalizing_duplicate_paste = True
|
|
290
|
+
try:
|
|
291
|
+
self.value = collapsed
|
|
292
|
+
current = collapsed
|
|
293
|
+
finally:
|
|
294
|
+
self._normalizing_duplicate_paste = False
|
|
295
|
+
self._last_value_snapshot = current
|
|
221
296
|
self._adjust_height()
|
|
222
297
|
|
|
223
298
|
def update_suggestion(self) -> None:
|
|
@@ -357,8 +432,16 @@ class ChatInput(TextArea):
|
|
|
357
432
|
if self.disabled or self.read_only:
|
|
358
433
|
return
|
|
359
434
|
|
|
435
|
+
if self._pending_clipboard_paste == event.text:
|
|
436
|
+
self._pending_clipboard_paste = None
|
|
437
|
+
elif self._is_recent_duplicate_paste(event.text):
|
|
438
|
+
event.prevent_default()
|
|
439
|
+
event.stop()
|
|
440
|
+
return
|
|
441
|
+
|
|
360
442
|
file_paths = self._extract_pasted_file_paths(event.text)
|
|
361
443
|
if file_paths:
|
|
444
|
+
self._remember_recent_paste(event.text)
|
|
362
445
|
event.prevent_default()
|
|
363
446
|
event.stop()
|
|
364
447
|
self.post_message(self.FilePathsPasted(file_paths))
|
|
@@ -366,6 +449,17 @@ class ChatInput(TextArea):
|
|
|
366
449
|
return
|
|
367
450
|
|
|
368
451
|
await super()._on_paste(event)
|
|
452
|
+
self._remember_recent_paste(event.text)
|
|
453
|
+
|
|
454
|
+
def action_paste(self) -> None:
|
|
455
|
+
"""Rely on terminal/native paste delivery to avoid duplicate textbox inserts.
|
|
456
|
+
|
|
457
|
+
In some terminals, invoking the widget paste action and receiving a terminal
|
|
458
|
+
paste event both happen for a single user paste gesture, which duplicates the
|
|
459
|
+
inserted text. We therefore make the explicit TextArea paste action a no-op
|
|
460
|
+
and let the terminal-delivered `Paste` event be the single source of truth.
|
|
461
|
+
"""
|
|
462
|
+
return
|
|
369
463
|
|
|
370
464
|
class StatusBadge(Static):
|
|
371
465
|
"""A colored status badge widget."""
|
|
@@ -199,6 +199,146 @@ async def test_tui_drag_drop_paste_uploads_file_paths(
|
|
|
199
199
|
assert chat_input.value == ""
|
|
200
200
|
assert screen._pending_urls == ["https://example.com/uploaded/drag-file.txt"]
|
|
201
201
|
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
async def test_tui_chat_input_dedupes_clipboard_paste_echo(
|
|
204
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
205
|
+
) -> None:
|
|
206
|
+
"""A local clipboard paste followed by a terminal paste event should insert once."""
|
|
207
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
208
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
tokens_path.write_text(
|
|
210
|
+
json.dumps(
|
|
211
|
+
{
|
|
212
|
+
"access_token": "test-access-token",
|
|
213
|
+
"refresh_token": "test-refresh-token",
|
|
214
|
+
"token_type": "Bearer",
|
|
215
|
+
"expires_at": None,
|
|
216
|
+
}
|
|
217
|
+
),
|
|
218
|
+
encoding="utf-8",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
222
|
+
monkeypatch.setattr(
|
|
223
|
+
get_action_v1_actions_id_get,
|
|
224
|
+
"sync_detailed",
|
|
225
|
+
lambda *, id, client: SimpleNamespace(
|
|
226
|
+
status_code=200,
|
|
227
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
from textual import events
|
|
232
|
+
from kiwi_tui.main import AutobotsTUI
|
|
233
|
+
from kiwi_tui.widgets import ChatInput
|
|
234
|
+
|
|
235
|
+
app = AutobotsTUI()
|
|
236
|
+
app._clipboard = "run-123" # type: ignore[attr-defined]
|
|
237
|
+
async with app.run_test() as pilot:
|
|
238
|
+
await pilot.pause()
|
|
239
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
240
|
+
chat_input = app.screen.query_one("#chat-input", ChatInput)
|
|
241
|
+
|
|
242
|
+
chat_input.action_paste()
|
|
243
|
+
await chat_input._on_paste(events.Paste("run-123"))
|
|
244
|
+
await pilot.pause()
|
|
245
|
+
|
|
246
|
+
assert chat_input.value == "run-123"
|
|
247
|
+
|
|
248
|
+
@pytest.mark.asyncio
|
|
249
|
+
async def test_tui_long_text_paste_is_not_treated_as_file_path(
|
|
250
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Long pasted text should not crash path detection and should remain normal text."""
|
|
253
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
254
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
tokens_path.write_text(
|
|
256
|
+
json.dumps(
|
|
257
|
+
{
|
|
258
|
+
"access_token": "test-access-token",
|
|
259
|
+
"refresh_token": "test-refresh-token",
|
|
260
|
+
"token_type": "Bearer",
|
|
261
|
+
"expires_at": None,
|
|
262
|
+
}
|
|
263
|
+
),
|
|
264
|
+
encoding="utf-8",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
268
|
+
monkeypatch.setattr(
|
|
269
|
+
get_action_v1_actions_id_get,
|
|
270
|
+
"sync_detailed",
|
|
271
|
+
lambda *, id, client: SimpleNamespace(
|
|
272
|
+
status_code=200,
|
|
273
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
from textual import events
|
|
278
|
+
from kiwi_tui.main import AutobotsTUI
|
|
279
|
+
from kiwi_tui.widgets import ChatInput
|
|
280
|
+
|
|
281
|
+
app = AutobotsTUI()
|
|
282
|
+
async with app.run_test() as pilot:
|
|
283
|
+
await pilot.pause()
|
|
284
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
285
|
+
chat_input = app.screen.query_one("#chat-input", ChatInput)
|
|
286
|
+
|
|
287
|
+
long_text = "A" * 5000
|
|
288
|
+
await chat_input._on_paste(events.Paste(long_text))
|
|
289
|
+
await pilot.pause()
|
|
290
|
+
|
|
291
|
+
assert chat_input.value == long_text
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@pytest.mark.asyncio
|
|
295
|
+
async def test_tui_chat_input_collapses_duplicate_pasted_text(
|
|
296
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
297
|
+
) -> None:
|
|
298
|
+
"""If the terminal inserts the same pasted text twice, collapse it back to one copy."""
|
|
299
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
300
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
tokens_path.write_text(
|
|
302
|
+
json.dumps(
|
|
303
|
+
{
|
|
304
|
+
"access_token": "test-access-token",
|
|
305
|
+
"refresh_token": "test-refresh-token",
|
|
306
|
+
"token_type": "Bearer",
|
|
307
|
+
"expires_at": None,
|
|
308
|
+
}
|
|
309
|
+
),
|
|
310
|
+
encoding="utf-8",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
314
|
+
monkeypatch.setattr(
|
|
315
|
+
get_action_v1_actions_id_get,
|
|
316
|
+
"sync_detailed",
|
|
317
|
+
lambda *, id, client: SimpleNamespace(
|
|
318
|
+
status_code=200,
|
|
319
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
from kiwi_tui.main import AutobotsTUI
|
|
324
|
+
from kiwi_tui.widgets import ChatInput
|
|
325
|
+
|
|
326
|
+
app = AutobotsTUI()
|
|
327
|
+
async with app.run_test() as pilot:
|
|
328
|
+
await pilot.pause()
|
|
329
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
330
|
+
chat_input = app.screen.query_one("#chat-input", ChatInput)
|
|
331
|
+
|
|
332
|
+
duplicated = "Action: AutoCodeAction: AutoCode"
|
|
333
|
+
chat_input.value = duplicated
|
|
334
|
+
chat_input.on_text_area_changed(ChatInput.Changed(chat_input))
|
|
335
|
+
await pilot.pause()
|
|
336
|
+
|
|
337
|
+
assert chat_input.value == "Action: AutoCode"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
202
342
|
@pytest.mark.asyncio
|
|
203
343
|
async def test_tui_screen_level_paste_uploads_file_paths(
|
|
204
344
|
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
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
|