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.
Files changed (53) hide show
  1. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/auth.py +14 -13
  4. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/cli.py +4 -1
  5. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/client.py +3 -2
  6. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/main.py +29 -4
  7. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/main.py +74 -33
  8. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/widgets.py +96 -2
  9. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_tui_headless.py +140 -0
  10. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/uv.lock +1 -1
  11. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.github/workflows/publish.yml +0 -0
  12. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.github/workflows/test.yml +0 -0
  13. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.gitignore +0 -0
  14. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/.python-version +0 -0
  15. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/CLAUDE.md +0 -0
  16. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/Makefile +0 -0
  17. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/README.md +0 -0
  18. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/__init__.py +0 -0
  19. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/commands.py +0 -0
  20. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/logger.py +0 -0
  21. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/models.py +0 -0
  22. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/runtime_manager.py +0 -0
  23. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_cli/server.py +0 -0
  24. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/__init__.py +0 -0
  25. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/__main__.py +0 -0
  26. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  27. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  28. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/__init__.py +0 -0
  29. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/inline_file_picker.py +0 -0
  30. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/random_words.py +0 -0
  31. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/runtime_agent.py +0 -0
  32. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/__init__.py +0 -0
  33. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/attach_content.py +0 -0
  34. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/command_result.py +0 -0
  35. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/dashboard.py +0 -0
  36. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/file_browser.py +0 -0
  37. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/help.py +0 -0
  38. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/id_picker.py +0 -0
  39. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/login.py +0 -0
  40. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  41. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  42. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/screens/slash_picker.py +0 -0
  43. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/slash_commands.py +0 -0
  44. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/src/kiwi_tui/status_words.py +0 -0
  45. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/test_hello.py +0 -0
  46. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/__init__.py +0 -0
  47. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/conftest.py +0 -0
  48. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_cli_help.py +0 -0
  49. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_imports.py +0 -0
  50. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_reexec_kiwi.py +0 -0
  51. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_runtime_log_trimming.py +0 -0
  52. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/tests/test_tokens.py +0 -0
  53. {kiwi_code-0.0.35 → kiwi_code-0.0.37}/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.35
3
+ Version: 0.0.37
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.35"
3
+ version = "0.0.37"
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"
@@ -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._tokens = None
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
- if self.token_path.exists():
124
- try:
125
- self.token_path.unlink()
126
- logger.info("Authentication tokens cleared")
127
- except Exception as e:
128
- logger.error(f"Failed to clear tokens: {e}")
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
- Returns:
135
- Current AuthTokens or None
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
- if self._tokens is None:
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=getattr(tokens, "access_token", None),
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
- # Best-effort: send the (possibly expired) access token for anomaly tracking.
187
- # The backend refresh endpoint must not *require* it.
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(http_base_url: str, refresh_token: str) -> dict[str, Any]:
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(url, params={"refresh_token": refresh_token})
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(http_base_url, refresh2)
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
- temp_client = AutobotsClientWrapper(base_url=self.config.backend_url)
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
- self.set_interval(interval_min * 60, self._refresh_token_if_needed)
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
- """Check token expiry and refresh if needed.
606
+ """Synchronize auth state from disk and refresh only when needed.
600
607
 
601
608
  Args:
602
- force: If True, attempt refresh even if the token doesn't look expired.
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 (not force) and (not tokens.is_expired()):
611
- logger.debug("Token still valid, skipping refresh")
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
- if not path.exists() or not path.is_file():
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
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.35"
400
+ version = "0.0.37"
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