kiwi-code 0.0.34__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.34 → kiwi_code-0.0.36}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/main.py +40 -33
  4. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/attach_content.py +1 -1
  5. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/command_result.py +1 -1
  6. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/file_browser.py +23 -1
  7. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/help.py +2 -2
  8. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/id_picker.py +11 -11
  9. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/login.py +1 -1
  10. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_cleanup.py +31 -7
  11. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_logs.py +28 -0
  12. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/slash_picker.py +2 -1
  13. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/widgets.py +96 -2
  14. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_tui_headless.py +140 -0
  15. kiwi_code-0.0.36/tests/test_tui_palette.py +68 -0
  16. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/uv.lock +1 -1
  17. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.github/workflows/publish.yml +0 -0
  18. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.github/workflows/test.yml +0 -0
  19. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.gitignore +0 -0
  20. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.python-version +0 -0
  21. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/CLAUDE.md +0 -0
  22. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/Makefile +0 -0
  23. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/README.md +0 -0
  24. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/__init__.py +0 -0
  25. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/auth.py +0 -0
  26. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/cli.py +0 -0
  27. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/client.py +0 -0
  28. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/commands.py +0 -0
  29. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/logger.py +0 -0
  30. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/models.py +0 -0
  31. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/runtime_manager.py +0 -0
  32. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/server.py +0 -0
  33. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/__init__.py +0 -0
  34. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/__main__.py +0 -0
  35. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/main.py +0 -0
  36. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  37. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  38. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/__init__.py +0 -0
  39. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/inline_file_picker.py +0 -0
  40. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/random_words.py +0 -0
  41. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/runtime_agent.py +0 -0
  42. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/__init__.py +0 -0
  43. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/dashboard.py +0 -0
  44. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/slash_commands.py +0 -0
  45. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/status_words.py +0 -0
  46. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/test_hello.py +0 -0
  47. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/__init__.py +0 -0
  48. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/conftest.py +0 -0
  49. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_cli_help.py +0 -0
  50. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_imports.py +0 -0
  51. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_reexec_kiwi.py +0 -0
  52. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_runtime_log_trimming.py +0 -0
  53. {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.34
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.34"
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"
@@ -235,8 +235,8 @@ class AutobotsTUI(App):
235
235
  # Brand color for the kiwi cyan, kept readable on both dark and
236
236
  # light themes. Exposed to CSS as `$brand-cyan` via
237
237
  # `get_css_variables` below.
238
- BRAND_CYAN_DARK = "#5fffff" # bright cyan — pops on dark backgrounds
239
- BRAND_CYAN_LIGHT = "#006d8a" # deeper teal readable on light backgrounds
238
+ BRAND_CYAN_DARK = "#63d8dc" # softened cyan — same feel, less eye strain on dark backgrounds
239
+ BRAND_CYAN_LIGHT = "#0b7084" # slightly softened teal for light backgrounds
240
240
 
241
241
  def _is_dark_theme(self) -> bool:
242
242
  """Best-effort detection of whether the active theme is dark."""
@@ -249,65 +249,72 @@ class AutobotsTUI(App):
249
249
  return bool(getattr(self, "dark", True))
250
250
 
251
251
  def get_css_variables(self) -> dict[str, str]:
252
- """Inject theme-aware brand color and override palette to match kiwi-code UX.
253
-
254
- UX requirement: use a minimal palette (white + kiwi cyan) for UI text and
255
- accents across screens.
256
- """
252
+ """Inject theme-aware brand color and a softer neutral palette."""
257
253
  variables = super().get_css_variables()
258
- cyan = self.BRAND_CYAN_DARK if self._is_dark_theme() else self.BRAND_CYAN_LIGHT
254
+ dark = self._is_dark_theme()
255
+ cyan = self.BRAND_CYAN_DARK if dark else self.BRAND_CYAN_LIGHT
259
256
  variables["brand-cyan"] = cyan
260
257
 
261
- # Two-color palette: foreground text + cyan accents. Keep readability in light theme.
262
- foreground = "#ffffff" if self._is_dark_theme() else "#000000"
258
+ if dark:
259
+ # Match the reference terminal theme more closely:
260
+ # - global background around #1E1E1E
261
+ # - user prompts on a lighter gray chip around #3A3A3A
262
+ bg = "#1E1E1E"
263
+ surface = "#252526"
264
+ raised = "#2D2D2D"
265
+ panel = "#333333"
266
+ foreground = "#F3F3F3"
267
+ user_msg_bg = "#3A3A3A"
268
+ assistant_stream_bg = "#252526"
269
+ else:
270
+ bg = "#f6f7f8"
271
+ surface = "#ffffff"
272
+ raised = "#eef1f3"
273
+ panel = "#dbe7ea"
274
+ foreground = "#101213"
275
+ user_msg_bg = "#f1f3f4"
276
+ assistant_stream_bg = "#f4f6f7"
277
+
278
+ variables["background"] = bg
263
279
  variables["foreground"] = foreground
264
280
  variables["text"] = foreground
265
281
 
266
- # Map most semantic colors to the brand cyan (removes scattered colors).
282
+ # Keep the kiwi cyan for accents while softening the surrounding surfaces.
267
283
  for k in ("primary", "secondary", "accent", "error", "warning", "success"):
268
284
  variables[k] = cyan
269
285
 
270
- # Also align text-* semantic variants used by Markdown, notifications, etc.
271
286
  for k in ("text-warning", "text-error", "text-success"):
272
287
  variables[k] = cyan
273
288
 
274
- # Surfaces and borders: keep background dark, use cyan borders.
275
- bg = variables.get("background", "#000000")
276
- variables["surface"] = bg
277
- variables["boost"] = bg
289
+ variables["surface"] = surface
290
+ variables["boost"] = raised
278
291
  variables["primary-background"] = bg
279
- variables["panel"] = cyan
280
- variables["border"] = cyan
281
- variables["border-blurred"] = cyan
292
+ variables["panel"] = panel
293
+ variables["border"] = panel
294
+ variables["block-hover-background"] = surface
295
+ variables["border-blurred"] = raised
282
296
 
297
+ variables["user-msg-bg"] = user_msg_bg
298
+ variables["assistant-stream-bg"] = assistant_stream_bg
283
299
 
284
- # Chat UX: full-width highlight color for user-message rows.
285
- # Keep it subtle and theme-aware.
286
- variables["user-msg-bg"] = "#333333" if self._is_dark_theme() else "#e8e8e8"
287
- # Chat UX: subtle highlight for the *active streaming* assistant message.
288
- variables["assistant-stream-bg"] = "#222222" if self._is_dark_theme() else "#f2f2f2"
289
- # Scrollbars: cyan thumb, dark track.
290
300
  variables["scrollbar"] = cyan
291
301
  variables["scrollbar-hover"] = cyan
292
302
  variables["scrollbar-active"] = cyan
293
- variables["scrollbar-background"] = bg
294
- variables["scrollbar-background-hover"] = bg
295
- variables["scrollbar-background-active"] = bg
303
+ variables["scrollbar-background"] = surface
304
+ variables["scrollbar-background-hover"] = surface
305
+ variables["scrollbar-background-active"] = raised
296
306
  variables["scrollbar-corner-color"] = bg
297
307
 
298
- # Footer palette variables (Textual 8 uses these).
299
- variables["footer-background"] = bg
308
+ variables["footer-background"] = surface
300
309
  variables["footer-foreground"] = foreground
301
- variables["footer-description-background"] = bg
310
+ variables["footer-description-background"] = surface
302
311
  variables["footer-description-foreground"] = foreground
303
312
 
304
- # Cursor / selection highlight: cyan text on background (no extra colors).
305
313
  variables["block-cursor-foreground"] = cyan
306
314
  variables["block-cursor-background"] = bg
307
315
  variables["block-cursor-blurred-foreground"] = cyan
308
316
  variables["block-cursor-blurred-background"] = bg
309
317
  return variables
310
-
311
318
  def watch_theme(self, theme: str) -> None:
312
319
  """Re-resolve palette variables whenever the user switches theme."""
313
320
  try:
@@ -27,7 +27,7 @@ class AttachContentScreen(ModalScreen[dict]):
27
27
  width: 60;
28
28
  height: auto;
29
29
  max-height: 20;
30
- background: $surface;
30
+ background: $primary-background;
31
31
  border: solid $accent;
32
32
  padding: 1 2;
33
33
  }
@@ -30,7 +30,7 @@ class CommandResultScreen(ModalScreen[None]):
30
30
  #cmdres-container {
31
31
  width: 90;
32
32
  height: 80%;
33
- background: $surface;
33
+ background: $primary-background;
34
34
  border: solid $accent;
35
35
  padding: 1 2;
36
36
  }
@@ -29,7 +29,7 @@ class FileBrowserScreen(ModalScreen[list[str]]):
29
29
  #file-browser-container {
30
30
  width: 96%;
31
31
  height: 95%;
32
- background: $surface;
32
+ background: $primary-background;
33
33
  border: solid $accent;
34
34
  padding: 0 1;
35
35
  }
@@ -75,10 +75,32 @@ class FileBrowserScreen(ModalScreen[list[str]]):
75
75
  #dir-tree {
76
76
  height: 1fr;
77
77
  width: 100%;
78
+ background: $primary-background;
78
79
  border: solid $panel;
79
80
  scrollbar-size: 1 1;
81
+ #dir-tree:focus {
82
+ background-tint: 0%;
83
+ }
84
+
85
+ #dir-tree > .tree--highlight-line {
86
+ background: $surface;
87
+ }
88
+
89
+ #dir-tree > .tree--cursor {
90
+ background: $surface;
91
+ color: $foreground;
92
+ text-style: none;
80
93
  }
81
94
 
95
+ #dir-tree:focus > .tree--cursor {
96
+ background: $boost;
97
+ color: $foreground;
98
+ text-style: bold;
99
+ }
100
+
101
+ }
102
+
103
+
82
104
  #selected-files {
83
105
  height: auto;
84
106
  max-height: 3;
@@ -29,7 +29,7 @@ class HelpScreen(ModalScreen[None]):
29
29
  #help-container {
30
30
  width: 96;
31
31
  height: 80%;
32
- background: $surface;
32
+ background: $primary-background;
33
33
  border: solid $accent;
34
34
  /* Reduce top padding so the title/hint sit closer to the border. */
35
35
  padding: 0 3 1 3;
@@ -58,7 +58,7 @@ class HelpScreen(ModalScreen[None]):
58
58
  height: 1fr;
59
59
  width: 100%;
60
60
  border: solid $panel;
61
- background: $surface;
61
+ background: $primary-background;
62
62
  scrollbar-size: 1 1;
63
63
  /* Keep the list away from the container border for consistent margins. */
64
64
  margin: 0 1;
@@ -101,7 +101,7 @@ class IdPickerScreen(ModalScreen[str]):
101
101
  #idpicker-container {
102
102
  width: 90;
103
103
  height: 90%;
104
- background: $surface;
104
+ background: $primary-background;
105
105
  border: solid $accent;
106
106
  padding: 0 1;
107
107
  }
@@ -111,7 +111,7 @@ class IdPickerScreen(ModalScreen[str]):
111
111
  text-align: center;
112
112
  text-style: bold;
113
113
  color: $primary;
114
- background: $surface;
114
+ background: $primary-background;
115
115
  height: 1;
116
116
  margin-bottom: 0;
117
117
  }
@@ -119,17 +119,17 @@ class IdPickerScreen(ModalScreen[str]):
119
119
  #idpicker-table {
120
120
  height: 1fr;
121
121
  width: 100%;
122
- background: $surface;
122
+ background: $primary-background;
123
123
  /* Slightly dim the row text so it's easier on the eyes. */
124
124
  color: $foreground 80%;
125
125
  border: none;
126
126
  overflow-x: auto;
127
127
  overflow-y: auto;
128
128
  scrollbar-gutter: auto;
129
- scrollbar-background: $surface;
130
- scrollbar-background-hover: $surface;
131
- scrollbar-background-active: $surface;
132
- scrollbar-corner-color: $surface;
129
+ scrollbar-background: $primary-background;
130
+ scrollbar-background-hover: $primary-background;
131
+ scrollbar-background-active: $primary-background;
132
+ scrollbar-corner-color: $primary-background;
133
133
  scrollbar-color: $panel;
134
134
  scrollbar-color-hover: $primary;
135
135
  scrollbar-color-active: $primary;
@@ -163,10 +163,10 @@ class IdPickerScreen(ModalScreen[str]):
163
163
  overflow-x: auto;
164
164
  overflow-y: auto;
165
165
  scrollbar-gutter: auto;
166
- scrollbar-background: $surface;
167
- scrollbar-background-hover: $surface;
168
- scrollbar-background-active: $surface;
169
- scrollbar-corner-color: $surface;
166
+ scrollbar-background: $primary-background;
167
+ scrollbar-background-hover: $primary-background;
168
+ scrollbar-background-active: $primary-background;
169
+ scrollbar-corner-color: $primary-background;
170
170
  scrollbar-color: $panel;
171
171
  scrollbar-color-hover: $primary;
172
172
  scrollbar-color-active: $primary;
@@ -22,7 +22,7 @@ class LoginScreen(Screen):
22
22
 
23
23
  CSS = """
24
24
  LoginScreen {
25
- background: $surface;
25
+ background: $background;
26
26
  layout: vertical;
27
27
  }
28
28
 
@@ -19,6 +19,7 @@ import json
19
19
  from typing import Any
20
20
 
21
21
  from loguru import logger
22
+ from rich.text import Text
22
23
  from textual import events
23
24
  from textual.binding import Binding
24
25
  from textual.screen import ModalScreen
@@ -121,13 +122,29 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
121
122
 
122
123
  #runtime-cleanup-help {
123
124
  padding: 1 2;
124
- color: $text;
125
- opacity: 0.9;
125
+ background: $background;
126
+ color: $brand-cyan;
127
+ text-style: bold;
128
+ opacity: 1;
126
129
  }
127
130
 
128
131
  #runtime-list {
129
132
  height: 1fr;
130
133
  width: 100%;
134
+ background: $background;
135
+ color: $foreground;
136
+ border: none;
137
+ }
138
+
139
+ #runtime-list > .option-list--option {
140
+ background: $background;
141
+ color: $foreground;
142
+ }
143
+
144
+ #runtime-list > .option-list--option-highlighted {
145
+ background: $panel;
146
+ color: $brand-cyan;
147
+ text-style: bold;
131
148
  }
132
149
  """
133
150
 
@@ -139,8 +156,8 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
139
156
  def compose(self):
140
157
  yield Header(icon="❊")
141
158
  yield Static(
142
- "Select runtimes to kill before exiting.\n"
143
- "Space = toggle kill, Enter = confirm & exit, Esc = keep all.",
159
+ "Interactive runtime cleanup\n"
160
+ "Use ↑/↓ to move, Space to toggle kill, Enter to confirm & exit, Esc to keep all.",
144
161
  id="runtime-cleanup-help",
145
162
  )
146
163
  yield RuntimeCleanupList(id="runtime-list")
@@ -164,12 +181,19 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
164
181
  def _option_id_for_row(self, r: RuntimeRow) -> str:
165
182
  return f"{r.kind}:{r.runtime_id}:{r.pid}"
166
183
 
167
- def _format_row(self, r: RuntimeRow) -> str:
184
+ def _format_row(self, r: RuntimeRow) -> Text:
168
185
  kill = "YES" if r.kill else "NO"
169
186
  # Note: kinds come from runtime_agent.list_known_runtimes(): "by-run" or "pending".
170
187
  name = r.name or ("(pending)" if r.kind == "pending" else "(unknown)")
171
- # Avoid Rich markup confusion ("[...]") by not using square brackets.
172
- return f"Kill={kill} {name} | {r.runtime_id} | pid {r.pid} | {r.kind}"
188
+ row = Text()
189
+ try:
190
+ cyan = self.app.get_css_variables().get("brand-cyan", "#63d8dc")
191
+ except Exception:
192
+ cyan = "#63d8dc"
193
+ row.append(f"Kill={kill}", style=f"bold {cyan}")
194
+ row.append(f" {name}", style="bold")
195
+ row.append(f" | {r.runtime_id} | pid {r.pid} | {r.kind}")
196
+ return row
173
197
 
174
198
  def _selected_id(self) -> str | None:
175
199
  lst = self.query_one("#runtime-list", RuntimeCleanupList)
@@ -63,14 +63,34 @@ class RuntimeLogsScreen(Screen):
63
63
  background: $background;
64
64
  }
65
65
 
66
+ RichLog {
67
+ background: $background;
68
+ color: $foreground;
69
+ background-tint: 0%;
70
+ }
71
+
72
+ RichLog:focus {
73
+ background: $background;
74
+ color: $foreground;
75
+ background-tint: 0%;
76
+ }
77
+
66
78
  #runtime-log {
67
79
  height: 1fr;
68
80
  width: 100%;
81
+ background: $background;
82
+ color: $foreground;
83
+ background-tint: 0%;
69
84
  scrollbar-size: 1 1;
85
+ scrollbar-background: $background;
86
+ scrollbar-background-hover: $background;
87
+ scrollbar-background-active: $background;
88
+ scrollbar-corner-color: $background;
70
89
  }
71
90
 
72
91
  #runtime-log-missing {
73
92
  padding: 2 4;
93
+ background: $background;
74
94
  color: $error;
75
95
  }
76
96
  """
@@ -109,6 +129,14 @@ class RuntimeLogsScreen(Screen):
109
129
  except Exception:
110
130
  return
111
131
 
132
+ try:
133
+ bg = self.app.get_css_variables().get("background", "#1E1E1E")
134
+ fg = self.app.get_css_variables().get("foreground", "#F3F3F3")
135
+ log.styles.background = bg
136
+ log.styles.color = fg
137
+ except Exception:
138
+ pass
139
+
112
140
  for line in _tail_lines(self._log_path, max_lines=2000):
113
141
  log.write(Text.from_ansi(line))
114
142
 
@@ -35,7 +35,7 @@ class SlashPickerScreen(ModalScreen[str]):
35
35
  #slash-container {
36
36
  width: 70;
37
37
  height: 80%;
38
- background: $surface;
38
+ background: $primary-background;
39
39
  border: solid $accent;
40
40
  padding: 1 2;
41
41
  }
@@ -53,6 +53,7 @@ class SlashPickerScreen(ModalScreen[str]):
53
53
  height: 1fr;
54
54
  width: 100%;
55
55
  border: solid $panel;
56
+ background: $primary-background;
56
57
  }
57
58
  """
58
59
 
@@ -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
@@ -0,0 +1,68 @@
1
+ from textual.css.stylesheet import Stylesheet
2
+ from pathlib import Path
3
+
4
+ from kiwi_tui.main import AutobotsTUI
5
+ from kiwi_tui.screens.attach_content import AttachContentScreen
6
+ from kiwi_tui.screens.command_result import CommandResultScreen
7
+ from kiwi_tui.screens.file_browser import FileBrowserScreen
8
+ from kiwi_tui.screens.help import HelpScreen
9
+ from kiwi_tui.screens.id_picker import IdPickerScreen
10
+ from kiwi_tui.screens.slash_picker import SlashPickerScreen
11
+
12
+
13
+ def test_tui_dark_palette_vars(isolated_home: Path, monkeypatch) -> None:
14
+ app = AutobotsTUI()
15
+ monkeypatch.setattr(app, "_is_dark_theme", lambda: True)
16
+
17
+ variables = app.get_css_variables()
18
+
19
+ assert variables["background"] == "#1E1E1E"
20
+ assert variables["surface"] == "#252526"
21
+ assert variables["boost"] == "#2D2D2D"
22
+ assert variables["panel"] == "#333333"
23
+ assert variables["foreground"] == "#F3F3F3"
24
+ assert variables["user-msg-bg"] == "#3A3A3A"
25
+ assert variables["assistant-stream-bg"] == "#252526"
26
+
27
+
28
+ def test_tui_light_palette_vars(isolated_home: Path, monkeypatch) -> None:
29
+ app = AutobotsTUI()
30
+ monkeypatch.setattr(app, "_is_dark_theme", lambda: False)
31
+
32
+ variables = app.get_css_variables()
33
+
34
+ assert variables["background"] == "#f6f7f8"
35
+ assert variables["surface"] == "#ffffff"
36
+ assert variables["boost"] == "#eef1f3"
37
+ assert variables["panel"] == "#dbe7ea"
38
+ assert variables["foreground"] == "#101213"
39
+ assert variables["user-msg-bg"] == "#f1f3f4"
40
+ assert variables["assistant-stream-bg"] == "#f4f6f7"
41
+
42
+
43
+ def test_slash_related_modals_use_primary_background() -> None:
44
+ css_blocks = {
45
+ "slash_picker": SlashPickerScreen.CSS,
46
+ "help": HelpScreen.CSS,
47
+ "id_picker": IdPickerScreen.CSS,
48
+ "command_result": CommandResultScreen.CSS,
49
+ "attach_content": AttachContentScreen.CSS,
50
+ "file_browser": FileBrowserScreen.CSS,
51
+ }
52
+
53
+ for name, css in css_blocks.items():
54
+ assert "$primary-background" in css, f"{name} should use the shared modal background"
55
+
56
+
57
+ def test_file_browser_css_parses(isolated_home: Path) -> None:
58
+ app = AutobotsTUI()
59
+ sheet = Stylesheet(variables=app.get_css_variables())
60
+ sheet.add_source(FileBrowserScreen.CSS, read_from=("file_browser.py", "FileBrowserScreen.CSS"))
61
+ sheet.parse()
62
+
63
+
64
+ def test_file_browser_tree_hover_styling_is_softened() -> None:
65
+ css = FileBrowserScreen.CSS
66
+ assert "#dir-tree > .tree--highlight-line" in css
67
+ assert "#dir-tree > .tree--cursor" in css
68
+ assert "background-tint: 0%" in css
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.34"
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