kiwi-code 0.0.35__tar.gz → 0.0.36__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.36}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/widgets.py +96 -2
  4. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_tui_headless.py +140 -0
  5. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/uv.lock +1 -1
  6. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.github/workflows/publish.yml +0 -0
  7. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.github/workflows/test.yml +0 -0
  8. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.gitignore +0 -0
  9. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.python-version +0 -0
  10. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/CLAUDE.md +0 -0
  11. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/Makefile +0 -0
  12. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/README.md +0 -0
  13. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/__init__.py +0 -0
  14. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/auth.py +0 -0
  15. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/cli.py +0 -0
  16. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/client.py +0 -0
  17. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/commands.py +0 -0
  18. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/logger.py +0 -0
  19. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/models.py +0 -0
  20. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/runtime_manager.py +0 -0
  21. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/server.py +0 -0
  22. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/__init__.py +0 -0
  23. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/__main__.py +0 -0
  24. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/main.py +0 -0
  25. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  26. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  27. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/__init__.py +0 -0
  28. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/inline_file_picker.py +0 -0
  29. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/main.py +0 -0
  30. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/random_words.py +0 -0
  31. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/runtime_agent.py +0 -0
  32. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/__init__.py +0 -0
  33. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/attach_content.py +0 -0
  34. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/command_result.py +0 -0
  35. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/dashboard.py +0 -0
  36. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/file_browser.py +0 -0
  37. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/help.py +0 -0
  38. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/id_picker.py +0 -0
  39. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/login.py +0 -0
  40. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  41. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  42. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/slash_picker.py +0 -0
  43. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/slash_commands.py +0 -0
  44. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/status_words.py +0 -0
  45. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/test_hello.py +0 -0
  46. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/__init__.py +0 -0
  47. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/conftest.py +0 -0
  48. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_cli_help.py +0 -0
  49. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_imports.py +0 -0
  50. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_reexec_kiwi.py +0 -0
  51. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_runtime_log_trimming.py +0 -0
  52. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_tokens.py +0 -0
  53. {kiwi_code-0.0.35 → kiwi_code-0.0.36}/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.36
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.36"
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"
@@ -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.36"
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