kiwi-code 0.0.438__tar.gz → 0.0.439__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.438 → kiwi_code-0.0.439}/PKG-INFO +1 -1
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/pyproject.toml +1 -1
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/__init__.py +1 -1
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_runtime/__init__.py +1 -1
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/__init__.py +1 -1
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/main.py +15 -2
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/dashboard.py +49 -12
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_cleanup.py +52 -13
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/term_dashboard.py +107 -9
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/slash_commands.py +1 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/widgets.py +20 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_term_dashboard_ui.py +137 -6
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/uv.lock +1 -1
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.gitignore +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.python-version +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/CLAUDE.md +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/Makefile +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/README.md +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/test_hello.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/__init__.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/conftest.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tui_headless.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_worktrees.py +0 -0
|
@@ -841,8 +841,21 @@ class AutobotsTUI(App):
|
|
|
841
841
|
pass
|
|
842
842
|
self.exit()
|
|
843
843
|
|
|
844
|
-
def _on_runtime_cleanup_done(self, pids_to_kill: list[int]) -> None:
|
|
845
|
-
"""Callback from RuntimeCleanupScreen.
|
|
844
|
+
def _on_runtime_cleanup_done(self, pids_to_kill: list[int] | None) -> None:
|
|
845
|
+
"""Callback from RuntimeCleanupScreen.
|
|
846
|
+
|
|
847
|
+
- None: user chose to go back to Kiwi Code (cancel quit).
|
|
848
|
+
- []: exit without killing anything.
|
|
849
|
+
- [pid, ...]: kill selected pids and exit.
|
|
850
|
+
"""
|
|
851
|
+
if pids_to_kill is None:
|
|
852
|
+
# Quit canceled; return to the TUI.
|
|
853
|
+
try:
|
|
854
|
+
self.notify("Quit canceled", severity="information")
|
|
855
|
+
except Exception:
|
|
856
|
+
pass
|
|
857
|
+
return
|
|
858
|
+
|
|
846
859
|
for pid in pids_to_kill:
|
|
847
860
|
try:
|
|
848
861
|
kill_pid(int(pid))
|
|
@@ -787,31 +787,52 @@ class DashboardScreen(Screen):
|
|
|
787
787
|
|
|
788
788
|
|
|
789
789
|
def on_paste(self, event: events.Paste) -> None:
|
|
790
|
-
"""Handle
|
|
790
|
+
"""Handle paste events that land on the screen rather than the input.
|
|
791
|
+
|
|
792
|
+
- If the paste looks like local file paths (terminal drag/drop), route it
|
|
793
|
+
through the upload flow.
|
|
794
|
+
- Otherwise forward the paste to the chat input if it isn't focused.
|
|
795
|
+
"""
|
|
791
796
|
try:
|
|
792
797
|
chat_input = self.query_one("#chat-input", ChatInput)
|
|
793
798
|
except Exception:
|
|
794
799
|
return
|
|
795
800
|
|
|
796
801
|
file_paths = chat_input._extract_pasted_file_paths(event.text)
|
|
797
|
-
if
|
|
802
|
+
if file_paths:
|
|
803
|
+
event.prevent_default()
|
|
804
|
+
event.stop()
|
|
805
|
+
try:
|
|
806
|
+
self._close_inline_picker()
|
|
807
|
+
except Exception:
|
|
808
|
+
pass
|
|
809
|
+
self.notify(
|
|
810
|
+
f"Detected {len(file_paths)} file{'s' if len(file_paths) != 1 else ''}. Uploading...",
|
|
811
|
+
title="Attachments",
|
|
812
|
+
severity="information",
|
|
813
|
+
timeout=2.5,
|
|
814
|
+
markup=False,
|
|
815
|
+
)
|
|
816
|
+
self._on_files_selected(file_paths)
|
|
817
|
+
return
|
|
818
|
+
|
|
819
|
+
if chat_input.has_focus:
|
|
798
820
|
return
|
|
799
821
|
|
|
800
822
|
event.prevent_default()
|
|
801
823
|
event.stop()
|
|
802
824
|
try:
|
|
803
|
-
|
|
825
|
+
chat_input.focus()
|
|
804
826
|
except Exception:
|
|
805
827
|
pass
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
828
|
+
try:
|
|
829
|
+
if result := chat_input._replace_via_keyboard(event.text, *chat_input.selection):
|
|
830
|
+
chat_input.move_cursor(result.end_location)
|
|
831
|
+
except Exception:
|
|
832
|
+
try:
|
|
833
|
+
chat_input.insert(event.text)
|
|
834
|
+
except Exception:
|
|
835
|
+
pass
|
|
815
836
|
def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
|
|
816
837
|
"""Handle message submission (Enter)."""
|
|
817
838
|
self._do_send()
|
|
@@ -837,6 +858,22 @@ class DashboardScreen(Screen):
|
|
|
837
858
|
if self._cmd_running:
|
|
838
859
|
return
|
|
839
860
|
|
|
861
|
+
lower = command.strip().lower()
|
|
862
|
+
if lower in {"/quit"}:
|
|
863
|
+
# Mirror ctrl+q behavior (App action 'quit').
|
|
864
|
+
try:
|
|
865
|
+
quit_action = getattr(self.app, "action_quit", None)
|
|
866
|
+
if callable(quit_action):
|
|
867
|
+
quit_action()
|
|
868
|
+
else:
|
|
869
|
+
self.app.exit()
|
|
870
|
+
except Exception:
|
|
871
|
+
try:
|
|
872
|
+
self.app.exit()
|
|
873
|
+
except Exception:
|
|
874
|
+
pass
|
|
875
|
+
return
|
|
876
|
+
|
|
840
877
|
# /help is a UI-first experience (picker + copy). Don't run it through the CLI dispatcher.
|
|
841
878
|
if command.strip().lower() == "/help":
|
|
842
879
|
self.app.push_screen(HelpScreen())
|
|
@@ -41,17 +41,22 @@ class RuntimeRow:
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class RuntimeCleanupList(OptionList):
|
|
44
|
-
"""OptionList with explicit key handling so toggling always works."""
|
|
45
|
-
|
|
46
44
|
BINDINGS = [
|
|
47
|
-
|
|
48
|
-
Binding("ctrl+c", "cancel_exit", "Exit (keep all)", show=False),
|
|
49
|
-
Binding("enter", "confirm_exit", "Confirm & exit", show=True),
|
|
50
|
-
Binding("ctrl+s", "confirm_exit", "Confirm & exit", show=False),
|
|
45
|
+
# Navigation / selection
|
|
51
46
|
Binding("space", "toggle_kill", "Toggle kill", show=True),
|
|
52
47
|
Binding("t", "toggle_kill", "Toggle kill", show=False),
|
|
53
48
|
Binding("y", "mark_kill", "Mark kill", show=False),
|
|
54
49
|
Binding("n", "unmark_kill", "Unmark kill", show=False),
|
|
50
|
+
# Confirm / exit
|
|
51
|
+
Binding("enter", "confirm_exit", "Exit (kill selected)", show=True),
|
|
52
|
+
Binding("ctrl+s", "confirm_exit", "Exit (kill selected)", show=False),
|
|
53
|
+
# Cancel quit (go back to main UI)
|
|
54
|
+
Binding("escape", "back", "Back to Kiwi Code", show=True),
|
|
55
|
+
Binding("b", "back", "Back to Kiwi Code", show=False),
|
|
56
|
+
Binding("ctrl+c", "back", "Back to Kiwi Code", show=False),
|
|
57
|
+
# Exit without killing anything
|
|
58
|
+
Binding("ctrl+q", "cancel_exit", "Exit (keep all)", show=True),
|
|
59
|
+
Binding("q", "cancel_exit", "Exit (keep all)", show=False),
|
|
55
60
|
]
|
|
56
61
|
|
|
57
62
|
def _cleanup_screen(self) -> "RuntimeCleanupScreen | None":
|
|
@@ -83,6 +88,11 @@ class RuntimeCleanupList(OptionList):
|
|
|
83
88
|
if screen:
|
|
84
89
|
screen.action_confirm()
|
|
85
90
|
|
|
91
|
+
def action_back(self) -> None:
|
|
92
|
+
screen = self._cleanup_screen()
|
|
93
|
+
if screen:
|
|
94
|
+
screen.action_back()
|
|
95
|
+
|
|
86
96
|
def action_cancel_exit(self) -> None:
|
|
87
97
|
screen = self._cleanup_screen()
|
|
88
98
|
if screen:
|
|
@@ -90,7 +100,6 @@ class RuntimeCleanupList(OptionList):
|
|
|
90
100
|
|
|
91
101
|
def on_key(self, event: events.Key) -> None:
|
|
92
102
|
"""Normalize some terminal key variants (notably space)."""
|
|
93
|
-
key = (event.key or "").lower()
|
|
94
103
|
char = (event.character or "")
|
|
95
104
|
|
|
96
105
|
# Some terminals send printable space without mapping to "space".
|
|
@@ -106,12 +115,35 @@ class RuntimeCleanupList(OptionList):
|
|
|
106
115
|
# key binding handling.
|
|
107
116
|
return
|
|
108
117
|
|
|
109
|
-
|
|
118
|
+
|
|
119
|
+
def on_click(self, event: events.Click) -> None:
|
|
120
|
+
"""Mouse support: click an item to toggle [ ] / [x].
|
|
121
|
+
|
|
122
|
+
We intentionally *don't* render boxed buttons in this CLI-style UI, so
|
|
123
|
+
clicking a row should behave like pressing Space on that row (similar to
|
|
124
|
+
the /detach panel).
|
|
125
|
+
"""
|
|
126
|
+
# Let OptionList's own click handling run (so the highlight follows the
|
|
127
|
+
# mouse), then toggle the currently highlighted row on the next tick.
|
|
128
|
+
try:
|
|
129
|
+
self.app.call_later(self.action_toggle_kill)
|
|
130
|
+
except Exception:
|
|
131
|
+
self.action_toggle_kill()
|
|
132
|
+
|
|
133
|
+
class RuntimeCleanupScreen(ModalScreen[list[int] | None]):
|
|
110
134
|
"""Prompt the user to select which runtimes to terminate."""
|
|
111
135
|
|
|
112
136
|
# Fallback bindings if focus leaves the list.
|
|
113
137
|
BINDINGS = [
|
|
114
|
-
|
|
138
|
+
# Back to Kiwi Code (cancel quit)
|
|
139
|
+
Binding("escape", "back", "Back", show=True),
|
|
140
|
+
Binding("b", "back", "Back", show=False),
|
|
141
|
+
Binding("ctrl+c", "back", "Back", show=False),
|
|
142
|
+
|
|
143
|
+
# Exit choices
|
|
144
|
+
Binding("ctrl+q", "cancel", "Exit (keep all)", show=True),
|
|
145
|
+
Binding("q", "cancel", "Exit (keep all)", show=False),
|
|
146
|
+
Binding("enter", "confirm", "Exit (kill selected)", show=True),
|
|
115
147
|
Binding("ctrl+s", "confirm", "Exit (kill selected)", show=False),
|
|
116
148
|
]
|
|
117
149
|
|
|
@@ -146,6 +178,7 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
|
|
|
146
178
|
color: $brand-cyan;
|
|
147
179
|
text-style: bold;
|
|
148
180
|
}
|
|
181
|
+
|
|
149
182
|
"""
|
|
150
183
|
|
|
151
184
|
def __init__(self, rows: list[RuntimeRow]):
|
|
@@ -157,7 +190,8 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
|
|
|
157
190
|
yield Header(icon="❊")
|
|
158
191
|
yield Static(
|
|
159
192
|
"Interactive runtime cleanup\n"
|
|
160
|
-
"Use ↑/↓ to move, Space to toggle kill
|
|
193
|
+
"Use ↑/↓ to move, Space to toggle kill.\n"
|
|
194
|
+
"Enter = exit (kill selected) • Ctrl+Q = exit (keep all) • Esc = back to Kiwi Code.",
|
|
161
195
|
id="runtime-cleanup-help",
|
|
162
196
|
)
|
|
163
197
|
yield RuntimeCleanupList(id="runtime-list")
|
|
@@ -178,11 +212,12 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
|
|
|
178
212
|
# Best-effort: fetch missing run names in background (doesn't block exit).
|
|
179
213
|
self.run_worker(self._fetch_missing_names(), exclusive=True)
|
|
180
214
|
|
|
215
|
+
|
|
181
216
|
def _option_id_for_row(self, r: RuntimeRow) -> str:
|
|
182
217
|
return f"{r.kind}:{r.runtime_id}:{r.pid}"
|
|
183
218
|
|
|
184
219
|
def _format_row(self, r: RuntimeRow) -> Text:
|
|
185
|
-
|
|
220
|
+
marker = "[x]" if r.kill else "[ ]"
|
|
186
221
|
# Note: kinds come from runtime_agent.list_known_runtimes(): "by-run" or "pending".
|
|
187
222
|
name = r.name or ("(pending)" if r.kind == "pending" else "(unknown)")
|
|
188
223
|
row = Text()
|
|
@@ -190,8 +225,8 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
|
|
|
190
225
|
cyan = self.app.get_css_variables().get("brand-cyan", "#63d8dc")
|
|
191
226
|
except Exception:
|
|
192
227
|
cyan = "#63d8dc"
|
|
193
|
-
row.append(f"
|
|
194
|
-
row.append(f"
|
|
228
|
+
row.append(f" {marker}", style=f"bold {cyan}")
|
|
229
|
+
row.append(f" {name}", style="bold")
|
|
195
230
|
row.append(f" | {r.runtime_id} | pid {r.pid} | {r.kind}")
|
|
196
231
|
return row
|
|
197
232
|
|
|
@@ -298,6 +333,10 @@ class RuntimeCleanupScreen(ModalScreen[list[int]]):
|
|
|
298
333
|
except Exception as e:
|
|
299
334
|
logger.debug(f"Runtime cleanup name fetch failed: {e}")
|
|
300
335
|
|
|
336
|
+
def action_back(self) -> None:
|
|
337
|
+
"""Cancel quit and return to the TUI."""
|
|
338
|
+
self.dismiss(None)
|
|
339
|
+
|
|
301
340
|
def action_confirm(self) -> None:
|
|
302
341
|
pids = [r.pid for r in self._rows if r.kill and r.pid]
|
|
303
342
|
self.dismiss(pids)
|
|
@@ -85,6 +85,43 @@ class TermTopBar(Static):
|
|
|
85
85
|
return
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
class TermHintAction(Static):
|
|
89
|
+
"""Clickable shortcut hint (e.g. ^n newline) shown below the prompt."""
|
|
90
|
+
|
|
91
|
+
class Activated(Message):
|
|
92
|
+
def __init__(self, action: str) -> None:
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.action = action
|
|
95
|
+
|
|
96
|
+
def __init__(self, key_hint: str, label: str, action: str, *, id: str | None = None) -> None:
|
|
97
|
+
super().__init__("", classes="term-hint-item", id=id, markup=False)
|
|
98
|
+
self._key_hint = key_hint
|
|
99
|
+
self._label = label
|
|
100
|
+
self._action = action
|
|
101
|
+
self.can_focus = True
|
|
102
|
+
|
|
103
|
+
def render(self) -> Text: # type: ignore[override]
|
|
104
|
+
t = Text()
|
|
105
|
+
try:
|
|
106
|
+
cyan = self.app.get_css_variables().get("brand-cyan", "#63d8dc")
|
|
107
|
+
except Exception:
|
|
108
|
+
cyan = "#63d8dc"
|
|
109
|
+
t.append(self._key_hint, style=f"bold {cyan}")
|
|
110
|
+
t.append(f" {self._label}")
|
|
111
|
+
return t
|
|
112
|
+
|
|
113
|
+
def _activate(self) -> None:
|
|
114
|
+
self.post_message(self.Activated(self._action))
|
|
115
|
+
|
|
116
|
+
def on_click(self, _event: events.Click) -> None:
|
|
117
|
+
self._activate()
|
|
118
|
+
|
|
119
|
+
def on_key(self, event: events.Key) -> None:
|
|
120
|
+
if event.key in ("enter", "space"):
|
|
121
|
+
event.stop()
|
|
122
|
+
self._activate()
|
|
123
|
+
return
|
|
124
|
+
|
|
88
125
|
class TermUserMessageRow(Horizontal):
|
|
89
126
|
"""User message with a '>' indicator on the left — Claude Code style."""
|
|
90
127
|
|
|
@@ -1210,7 +1247,7 @@ class TermDashboardScreen(Screen):
|
|
|
1210
1247
|
markup=False,
|
|
1211
1248
|
)
|
|
1212
1249
|
yield Static(
|
|
1213
|
-
"Shortcuts: ^o-logs ^q-quit",
|
|
1250
|
+
"Shortcuts: ^n-newline ^o-logs ^q-quit",
|
|
1214
1251
|
id="term-welcome-shortcuts",
|
|
1215
1252
|
markup=False,
|
|
1216
1253
|
)
|
|
@@ -1233,10 +1270,6 @@ class TermDashboardScreen(Screen):
|
|
|
1233
1270
|
# Activity row (shown/hidden)
|
|
1234
1271
|
yield Static("", id="term-activity-bar", markup=False)
|
|
1235
1272
|
|
|
1236
|
-
# Thin keyboard-hint row (keep only the model indicator here).
|
|
1237
|
-
with Horizontal(id="term-hint-bar"):
|
|
1238
|
-
yield Static("", id="term-hint-model")
|
|
1239
|
-
|
|
1240
1273
|
# Pending attachments row (shown/hidden) — appears above the prompt.
|
|
1241
1274
|
yield Static("", id="term-pending-files", markup=False)
|
|
1242
1275
|
|
|
@@ -1253,6 +1286,14 @@ class TermDashboardScreen(Screen):
|
|
|
1253
1286
|
with Vertical(id="term-slash-autocomplete-body"):
|
|
1254
1287
|
pass
|
|
1255
1288
|
|
|
1289
|
+
|
|
1290
|
+
# Keyboard shortcuts (always shown just below the prompt). Clickable, too.
|
|
1291
|
+
with Horizontal(id="term-hint-bar"):
|
|
1292
|
+
yield TermHintAction("^n", "newline", "newline", id="term-hint-newline")
|
|
1293
|
+
yield TermHintAction("^o", "logs", "logs", id="term-hint-logs")
|
|
1294
|
+
yield TermHintAction("^q", "quit", "quit", id="term-hint-quit")
|
|
1295
|
+
yield Static("", id="term-hint-model")
|
|
1296
|
+
|
|
1256
1297
|
# Inline file picker is a floating overlay – lives outside the scroll
|
|
1257
1298
|
# macOS terminals can leave right-edge artifacts when Textual renders a 1-cell scrollbar.
|
|
1258
1299
|
# Hide the scrollbar on macOS to avoid stray cyan pixels / partial redraw issues while
|
|
@@ -1688,6 +1729,23 @@ class TermDashboardScreen(Screen):
|
|
|
1688
1729
|
pass
|
|
1689
1730
|
|
|
1690
1731
|
|
|
1732
|
+
def on_term_hint_action_activated(self, event: TermHintAction.Activated) -> None:
|
|
1733
|
+
"""Handle clicks/activations from the shortcut hint row."""
|
|
1734
|
+
try:
|
|
1735
|
+
if event.action == "newline":
|
|
1736
|
+
self.action_hint_newline()
|
|
1737
|
+
elif event.action == "logs":
|
|
1738
|
+
self.action_show_logs()
|
|
1739
|
+
elif event.action == "quit":
|
|
1740
|
+
# Use the app-level quit flow so we still prompt for runtime cleanup if needed.
|
|
1741
|
+
try:
|
|
1742
|
+
self.app.action_quit()
|
|
1743
|
+
except Exception:
|
|
1744
|
+
self.app.exit()
|
|
1745
|
+
except Exception:
|
|
1746
|
+
pass
|
|
1747
|
+
|
|
1748
|
+
|
|
1691
1749
|
def action_dismiss_or_clear(self) -> None:
|
|
1692
1750
|
"""ESC → dismiss autocomplete, cmd result, or clear slash input."""
|
|
1693
1751
|
# Priority 1: hide slash autocomplete
|
|
@@ -2072,21 +2130,45 @@ class TermDashboardScreen(Screen):
|
|
|
2072
2130
|
self._on_files_selected(event.file_paths)
|
|
2073
2131
|
|
|
2074
2132
|
def on_paste(self, event: events.Paste) -> None:
|
|
2133
|
+
"""Handle paste events that land on the screen rather than the input.
|
|
2134
|
+
|
|
2135
|
+
- If the paste looks like one or more local file paths (terminal drag/drop),
|
|
2136
|
+
route it through the upload flow.
|
|
2137
|
+
- Otherwise, forward the paste to the chat input *if it isn't focused* so
|
|
2138
|
+
paste still works when focus is on a list / panel.
|
|
2139
|
+
"""
|
|
2075
2140
|
try:
|
|
2076
2141
|
chat_input = self.query_one("#term-chat-input", ChatInput)
|
|
2077
2142
|
except Exception:
|
|
2078
2143
|
return
|
|
2079
2144
|
file_paths = chat_input._extract_pasted_file_paths(event.text)
|
|
2080
|
-
if
|
|
2145
|
+
if file_paths:
|
|
2146
|
+
event.prevent_default()
|
|
2147
|
+
event.stop()
|
|
2148
|
+
try:
|
|
2149
|
+
self._close_inline_picker()
|
|
2150
|
+
except Exception:
|
|
2151
|
+
pass
|
|
2152
|
+
self._on_files_selected(file_paths)
|
|
2153
|
+
return
|
|
2154
|
+
# If the input is focused, let TextArea's paste handling do its normal thing.
|
|
2155
|
+
if chat_input.has_focus:
|
|
2081
2156
|
return
|
|
2157
|
+
# Forward paste to the input when focus is elsewhere (prevents "paste does nothing").
|
|
2082
2158
|
event.prevent_default()
|
|
2083
2159
|
event.stop()
|
|
2084
2160
|
try:
|
|
2085
|
-
|
|
2161
|
+
chat_input.focus()
|
|
2086
2162
|
except Exception:
|
|
2087
2163
|
pass
|
|
2088
|
-
|
|
2089
|
-
|
|
2164
|
+
try:
|
|
2165
|
+
if result := chat_input._replace_via_keyboard(event.text, *chat_input.selection):
|
|
2166
|
+
chat_input.move_cursor(result.end_location)
|
|
2167
|
+
except Exception:
|
|
2168
|
+
try:
|
|
2169
|
+
chat_input.insert(event.text)
|
|
2170
|
+
except Exception:
|
|
2171
|
+
pass
|
|
2090
2172
|
# Send
|
|
2091
2173
|
def _do_send(self) -> None:
|
|
2092
2174
|
# Always dismiss autocomplete before sending
|
|
@@ -2148,6 +2230,22 @@ class TermDashboardScreen(Screen):
|
|
|
2148
2230
|
"""Route a slash command — non-blocking (worker)."""
|
|
2149
2231
|
if self._cmd_running:
|
|
2150
2232
|
return
|
|
2233
|
+
lower = command.strip().lower()
|
|
2234
|
+
if lower in {"/quit"}:
|
|
2235
|
+
# Mirror ctrl+q behavior (App action 'quit').
|
|
2236
|
+
try:
|
|
2237
|
+
quit_action = getattr(self.app, "action_quit", None)
|
|
2238
|
+
if callable(quit_action):
|
|
2239
|
+
quit_action()
|
|
2240
|
+
else:
|
|
2241
|
+
self.app.exit()
|
|
2242
|
+
except Exception:
|
|
2243
|
+
try:
|
|
2244
|
+
self.app.exit()
|
|
2245
|
+
except Exception:
|
|
2246
|
+
pass
|
|
2247
|
+
return
|
|
2248
|
+
|
|
2151
2249
|
if command.strip().lower() == "/help":
|
|
2152
2250
|
from kiwi_tui.slash_commands import SLASH_COMMANDS
|
|
2153
2251
|
categories: dict[str, list] = {}
|
|
@@ -18,6 +18,7 @@ SLASH_COMMANDS: list[SlashCommand] = [
|
|
|
18
18
|
SlashCommand("Session", "/help", "Show all slash commands", "/help"),
|
|
19
19
|
SlashCommand("Session", "/new", "Start new conversation", "/new"),
|
|
20
20
|
SlashCommand("Session", "/status", "Show current action & run", "/status"),
|
|
21
|
+
SlashCommand("Session", "/quit", "Quit Kiwi Code", "/quit"),
|
|
21
22
|
SlashCommand("Session", "/use <action_id>", "Switch to a different action", "/use "),
|
|
22
23
|
SlashCommand("Session", "/autocode-select", "Choose the version of AutoCode you wish to use", "/autocode-select"),
|
|
23
24
|
SlashCommand("Session", "/name <new name>", "Rename the current run (action run or graph run)", "/name "),
|
|
@@ -412,6 +412,26 @@ class ChatInput(TextArea):
|
|
|
412
412
|
self.insert("\n")
|
|
413
413
|
return
|
|
414
414
|
|
|
415
|
+
# Select-all + copy-all (requested UX).
|
|
416
|
+
# - Windows/Linux: Ctrl+A
|
|
417
|
+
# - macOS (best-effort; only works if the terminal forwards the key combo): Cmd+A variants
|
|
418
|
+
copy_all_keys = {"ctrl+a"}
|
|
419
|
+
if sys.platform == "darwin":
|
|
420
|
+
copy_all_keys.update({"cmd+a", "command+a", "meta+a", "super+a"})
|
|
421
|
+
if event.key in copy_all_keys:
|
|
422
|
+
event.prevent_default()
|
|
423
|
+
event.stop()
|
|
424
|
+
try:
|
|
425
|
+
self.select_all()
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
try:
|
|
429
|
+
self.app.copy_to_clipboard(self.value)
|
|
430
|
+
except Exception:
|
|
431
|
+
# Worst case: still keep selection updated.
|
|
432
|
+
pass
|
|
433
|
+
return
|
|
434
|
+
|
|
415
435
|
# Enter submits.
|
|
416
436
|
if event.key in {"enter", "return"}:
|
|
417
437
|
event.prevent_default()
|
|
@@ -56,6 +56,34 @@ async def test_term_slash_autocomplete_is_visible_when_typing_slash() -> None:
|
|
|
56
56
|
assert len(list(screen.query(".term-slash-row"))) > 0
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_term_slash_autocomplete_includes_quits_command() -> None:
|
|
61
|
+
app = _TermDashboardTestApp()
|
|
62
|
+
async with app.run_test() as pilot:
|
|
63
|
+
await pilot.pause(0.05)
|
|
64
|
+
# Type '/q' to filter to quit commands.
|
|
65
|
+
await pilot.press("/")
|
|
66
|
+
await pilot.press("q")
|
|
67
|
+
await pilot.pause(0.05)
|
|
68
|
+
screen = app.screen
|
|
69
|
+
items = list(screen.query(".term-slash-row"))
|
|
70
|
+
assert any(getattr(it, "_command", None) and it._command.template == "/quit" for it in items)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_term_slash_autocomplete_includes_quits_command() -> None:
|
|
75
|
+
app = _TermDashboardTestApp()
|
|
76
|
+
async with app.run_test() as pilot:
|
|
77
|
+
await pilot.pause(0.05)
|
|
78
|
+
# Type '/q' to filter to quit commands.
|
|
79
|
+
await pilot.press("/")
|
|
80
|
+
await pilot.press("q")
|
|
81
|
+
await pilot.pause(0.05)
|
|
82
|
+
screen = app.screen
|
|
83
|
+
items = list(screen.query(".term-slash-row"))
|
|
84
|
+
assert any(getattr(it, "_command", None) and it._command.template == "/quit" for it in items)
|
|
85
|
+
|
|
86
|
+
|
|
59
87
|
@pytest.mark.asyncio
|
|
60
88
|
async def test_term_prompt_arrow_is_present_next_to_input() -> None:
|
|
61
89
|
app = _TermDashboardTestApp()
|
|
@@ -67,6 +95,78 @@ async def test_term_prompt_arrow_is_present_next_to_input() -> None:
|
|
|
67
95
|
assert arrow.content == ">"
|
|
68
96
|
|
|
69
97
|
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
async def test_term_ctrl_a_copies_entire_prompt() -> None:
|
|
100
|
+
app = _TermDashboardTestApp()
|
|
101
|
+
async with app.run_test() as pilot:
|
|
102
|
+
await pilot.pause(0.05)
|
|
103
|
+
screen = app.screen
|
|
104
|
+
chat_input = screen.query_one("#term-chat-input")
|
|
105
|
+
chat_input.value = "hello world"
|
|
106
|
+
await pilot.pause(0.01)
|
|
107
|
+
await pilot.press("ctrl+a")
|
|
108
|
+
await pilot.pause(0.05)
|
|
109
|
+
assert app.clipboard == "hello world"
|
|
110
|
+
assert chat_input.selected_text == "hello world"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.mark.asyncio
|
|
114
|
+
async def test_term_cmd_a_copies_entire_prompt_on_macos(monkeypatch) -> None:
|
|
115
|
+
"""Best-effort: if the terminal forwards Cmd+A, we copy-all like Ctrl+A."""
|
|
116
|
+
from textual import events
|
|
117
|
+
import kiwi_tui.widgets as widgets_mod
|
|
118
|
+
|
|
119
|
+
monkeypatch.setattr(widgets_mod.sys, "platform", "darwin")
|
|
120
|
+
app = _TermDashboardTestApp()
|
|
121
|
+
async with app.run_test() as pilot:
|
|
122
|
+
await pilot.pause(0.05)
|
|
123
|
+
screen = app.screen
|
|
124
|
+
chat_input = screen.query_one("#term-chat-input")
|
|
125
|
+
chat_input.value = "hello world"
|
|
126
|
+
await pilot.pause(0.01)
|
|
127
|
+
# Simulate a Cmd+A key event (Textual key string).
|
|
128
|
+
await chat_input._on_key(events.Key("cmd+a", None))
|
|
129
|
+
await pilot.pause(0.05)
|
|
130
|
+
assert app.clipboard == "hello world"
|
|
131
|
+
assert chat_input.selected_text == "hello world"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_term_ctrl_a_copies_entire_prompt() -> None:
|
|
136
|
+
app = _TermDashboardTestApp()
|
|
137
|
+
async with app.run_test() as pilot:
|
|
138
|
+
await pilot.pause(0.05)
|
|
139
|
+
screen = app.screen
|
|
140
|
+
chat_input = screen.query_one("#term-chat-input")
|
|
141
|
+
chat_input.value = "hello world"
|
|
142
|
+
await pilot.pause(0.01)
|
|
143
|
+
await pilot.press("ctrl+a")
|
|
144
|
+
await pilot.pause(0.05)
|
|
145
|
+
assert app.clipboard == "hello world"
|
|
146
|
+
assert chat_input.selected_text == "hello world"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_term_cmd_a_copies_entire_prompt_on_macos(monkeypatch) -> None:
|
|
151
|
+
"""Best-effort: if the terminal forwards Cmd+A, we copy-all like Ctrl+A."""
|
|
152
|
+
from textual import events
|
|
153
|
+
import kiwi_tui.widgets as widgets_mod
|
|
154
|
+
|
|
155
|
+
monkeypatch.setattr(widgets_mod.sys, "platform", "darwin")
|
|
156
|
+
app = _TermDashboardTestApp()
|
|
157
|
+
async with app.run_test() as pilot:
|
|
158
|
+
await pilot.pause(0.05)
|
|
159
|
+
screen = app.screen
|
|
160
|
+
chat_input = screen.query_one("#term-chat-input")
|
|
161
|
+
chat_input.value = "hello world"
|
|
162
|
+
await pilot.pause(0.01)
|
|
163
|
+
# Simulate a Cmd+A key event (Textual key string).
|
|
164
|
+
await chat_input._on_key(events.Key("cmd+a", None))
|
|
165
|
+
await pilot.pause(0.05)
|
|
166
|
+
assert app.clipboard == "hello world"
|
|
167
|
+
assert chat_input.selected_text == "hello world"
|
|
168
|
+
|
|
169
|
+
|
|
70
170
|
@pytest.mark.asyncio
|
|
71
171
|
async def test_term_runs_list_keyboard_navigation() -> None:
|
|
72
172
|
app = _TermDashboardTestApp()
|
|
@@ -78,8 +178,8 @@ async def test_term_runs_list_keyboard_navigation() -> None:
|
|
|
78
178
|
screen._show_runs_result(
|
|
79
179
|
"Runs",
|
|
80
180
|
[
|
|
81
|
-
{"label": " 1.
|
|
82
|
-
{"label": " 2.
|
|
181
|
+
{"label": " 1. processing First", "run_id": "run-1"},
|
|
182
|
+
{"label": " 2. completed Second", "run_id": "run-2"},
|
|
83
183
|
],
|
|
84
184
|
)
|
|
85
185
|
await pilot.pause(0.1)
|
|
@@ -246,7 +346,7 @@ async def test_welcome_header_does_not_disappear_when_conversation_starts() -> N
|
|
|
246
346
|
|
|
247
347
|
@pytest.mark.asyncio
|
|
248
348
|
@pytest.mark.asyncio
|
|
249
|
-
async def
|
|
349
|
+
async def test_term_welcome_shows_shortcuts_and_hint_bar_shows_commands() -> None:
|
|
250
350
|
app = _TermDashboardTestApp()
|
|
251
351
|
async with app.run_test(size=(120, 60)) as pilot:
|
|
252
352
|
await pilot.pause(0.05)
|
|
@@ -260,9 +360,10 @@ async def test_term_welcome_shows_shortcuts_and_hint_bar_has_no_commands() -> No
|
|
|
260
360
|
assert "^q" in text
|
|
261
361
|
assert "quit" in text
|
|
262
362
|
|
|
263
|
-
# Hint bar
|
|
264
|
-
|
|
265
|
-
|
|
363
|
+
# Hint bar shows the always-visible shortcut row below the prompt.
|
|
364
|
+
screen.query_one("#term-hint-newline")
|
|
365
|
+
screen.query_one("#term-hint-logs")
|
|
366
|
+
screen.query_one("#term-hint-quit")
|
|
266
367
|
|
|
267
368
|
|
|
268
369
|
@pytest.mark.asyncio
|
|
@@ -273,6 +374,8 @@ async def test_term_welcome_shortcuts_are_visible_in_screenshot() -> None:
|
|
|
273
374
|
await pilot.pause(0.1)
|
|
274
375
|
svg = app.export_screenshot()
|
|
275
376
|
assert "Shortcuts:" in svg
|
|
377
|
+
assert "^n" in svg
|
|
378
|
+
assert "newline" in svg
|
|
276
379
|
assert "^o" in svg
|
|
277
380
|
assert "logs" in svg
|
|
278
381
|
assert "^q" in svg
|
|
@@ -300,3 +403,31 @@ async def test_welcome_header_shows_action_and_run_id() -> None:
|
|
|
300
403
|
|
|
301
404
|
assert str(action_w.content) == "Action: My Action"
|
|
302
405
|
assert str(run_w.content) == "Run: run-123"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@pytest.mark.asyncio
|
|
409
|
+
async def test_term_paste_forwarded_to_input_when_focus_elsewhere() -> None:
|
|
410
|
+
"""If the screen receives a Paste event (focus not on the input), we still paste into the input."""
|
|
411
|
+
from textual import events
|
|
412
|
+
|
|
413
|
+
app = _TermDashboardTestApp()
|
|
414
|
+
async with app.run_test() as pilot:
|
|
415
|
+
await pilot.pause(0.05)
|
|
416
|
+
|
|
417
|
+
screen = app.screen
|
|
418
|
+
# Create a focusable widget (runs list item) and focus it.
|
|
419
|
+
screen._show_runs_result(
|
|
420
|
+
"Runs",
|
|
421
|
+
[
|
|
422
|
+
{"label": " 1. processing First", "run_id": "run-1"},
|
|
423
|
+
],
|
|
424
|
+
)
|
|
425
|
+
await pilot.pause(0.1)
|
|
426
|
+
|
|
427
|
+
chat_input = screen.query_one("#term-chat-input")
|
|
428
|
+
assert not chat_input.has_focus
|
|
429
|
+
|
|
430
|
+
screen.on_paste(events.Paste("hello"))
|
|
431
|
+
await pilot.pause(0.05)
|
|
432
|
+
|
|
433
|
+
assert "hello" in chat_input.value
|
|
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
|
|
File without changes
|
|
File without changes
|