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.
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/PKG-INFO +1 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/pyproject.toml +1 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/main.py +40 -33
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/attach_content.py +1 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/command_result.py +1 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/file_browser.py +23 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/help.py +2 -2
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/id_picker.py +11 -11
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/login.py +1 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_cleanup.py +31 -7
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/runtime_logs.py +28 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/slash_picker.py +2 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/widgets.py +96 -2
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_tui_headless.py +140 -0
- kiwi_code-0.0.36/tests/test_tui_palette.py +68 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/uv.lock +1 -1
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.gitignore +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/.python-version +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/CLAUDE.md +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/Makefile +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/README.md +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/screens/dashboard.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/test_hello.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/__init__.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/conftest.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.34 → kiwi_code-0.0.36}/tests/test_tokens.py +0 -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 = "#
|
|
239
|
-
BRAND_CYAN_LIGHT = "#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
275
|
-
|
|
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"] =
|
|
280
|
-
variables["border"] =
|
|
281
|
-
variables["
|
|
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"] =
|
|
294
|
-
variables["scrollbar-background-hover"] =
|
|
295
|
-
variables["scrollbar-background-active"] =
|
|
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
|
-
|
|
299
|
-
variables["footer-background"] = bg
|
|
308
|
+
variables["footer-background"] = surface
|
|
300
309
|
variables["footer-foreground"] = foreground
|
|
301
|
-
variables["footer-description-background"] =
|
|
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:
|
|
@@ -29,7 +29,7 @@ class FileBrowserScreen(ModalScreen[list[str]]):
|
|
|
29
29
|
#file-browser-container {
|
|
30
30
|
width: 96%;
|
|
31
31
|
height: 95%;
|
|
32
|
-
background: $
|
|
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: $
|
|
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: $
|
|
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: $
|
|
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: $
|
|
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: $
|
|
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: $
|
|
130
|
-
scrollbar-background-hover: $
|
|
131
|
-
scrollbar-background-active: $
|
|
132
|
-
scrollbar-corner-color: $
|
|
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: $
|
|
167
|
-
scrollbar-background-hover: $
|
|
168
|
-
scrollbar-background-active: $
|
|
169
|
-
scrollbar-corner-color: $
|
|
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;
|
|
@@ -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
|
-
|
|
125
|
-
|
|
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
|
-
"
|
|
143
|
-
"Space
|
|
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) ->
|
|
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
|
-
|
|
172
|
-
|
|
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: $
|
|
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
|
-
|
|
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
|
|
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
|