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.
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/PKG-INFO +1 -1
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/pyproject.toml +1 -1
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/widgets.py +96 -2
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_tui_headless.py +140 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/uv.lock +1 -1
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.gitignore +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/.python-version +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/CLAUDE.md +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/Makefile +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/README.md +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/dashboard.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/test_hello.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/__init__.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/conftest.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.35 → kiwi_code-0.0.36}/tests/test_tui_palette.py +0 -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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|