kiwi-code 0.0.437.dev1__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.437.dev1 → kiwi_code-0.0.439}/PKG-INFO +1 -1
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/pyproject.toml +1 -1
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/__init__.py +1 -1
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_runtime/__init__.py +1 -1
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/__init__.py +1 -1
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/main.py +69 -8
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/dashboard.py +95 -16
- kiwi_code-0.0.439/src/kiwi_tui/screens/detach_files.py +146 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_cleanup.py +52 -13
- kiwi_code-0.0.439/src/kiwi_tui/screens/term_dashboard.py +3996 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/slash_commands.py +2 -0
- kiwi_code-0.0.439/src/kiwi_tui/term_app.py +9 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/widgets.py +20 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_cli_help.py +9 -0
- kiwi_code-0.0.439/tests/test_term_dashboard_ui.py +433 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_terminal_mode.py +33 -3
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/uv.lock +1 -1
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.gitignore +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.python-version +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/CLAUDE.md +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/Makefile +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/README.md +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/test_hello.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/__init__.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/conftest.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tui_headless.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.437.dev1 → 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))
|
|
@@ -962,15 +975,47 @@ def _run_tui(runtime_args: RuntimeConnectArgs | None = None):
|
|
|
962
975
|
logger.info("Autobots TUI terminated")
|
|
963
976
|
|
|
964
977
|
|
|
965
|
-
def
|
|
978
|
+
def _run_term_tui(runtime_args: RuntimeConnectArgs | None = None) -> None:
|
|
979
|
+
"""Run the *term* (terminal-first) TUI.
|
|
980
|
+
|
|
981
|
+
This is the default UI for `kiwi`. Use `kiwi alt` for the legacy UI.
|
|
982
|
+
"""
|
|
983
|
+
from kiwi_tui.term_app import TermApp
|
|
984
|
+
|
|
985
|
+
config = AppConfig()
|
|
986
|
+
|
|
987
|
+
# If the user passed `--server`, use the same value for the TUI HTTP backend.
|
|
988
|
+
if runtime_args and runtime_args.server:
|
|
989
|
+
try:
|
|
990
|
+
config.backend_url = http_url_from_server(runtime_args.server)
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
|
|
994
|
+
setup_logging(log_level=config.log_level)
|
|
995
|
+
|
|
996
|
+
logger.info("=" * 60)
|
|
997
|
+
logger.info("Starting Kiwi Term TUI")
|
|
998
|
+
logger.info("=" * 60)
|
|
999
|
+
|
|
1000
|
+
app = TermApp(config=config, runtime_args=runtime_args)
|
|
1001
|
+
app.run()
|
|
1002
|
+
|
|
1003
|
+
logger.info("Kiwi Term TUI terminated")
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _serve_tui(port: int = 8566, extra_argv: list[str] | None = None) -> None:
|
|
966
1007
|
"""Serve the TUI in the browser via textual serve."""
|
|
1008
|
+
import shlex
|
|
967
1009
|
import sys
|
|
1010
|
+
|
|
968
1011
|
from textual_serve.server import Server
|
|
969
1012
|
|
|
970
|
-
|
|
1013
|
+
cmd = [sys.executable, "-m", "kiwi_tui.main", *(extra_argv or [])]
|
|
1014
|
+
server = Server(shlex.join(cmd), port=port)
|
|
971
1015
|
server.serve()
|
|
972
1016
|
|
|
973
1017
|
|
|
1018
|
+
|
|
974
1019
|
def _reexec_as_kiwi() -> None:
|
|
975
1020
|
"""Re-exec via a symlink named ``kiwi`` so the kernel-reported
|
|
976
1021
|
process name (``ps -o comm`` / VS Code's ``${process}``) becomes
|
|
@@ -1126,6 +1171,19 @@ def main() -> int:
|
|
|
1126
1171
|
sys.stdout.flush()
|
|
1127
1172
|
|
|
1128
1173
|
argv = sys.argv[1:]
|
|
1174
|
+
|
|
1175
|
+
# UI selector (defaults to term UI).
|
|
1176
|
+
# - `kiwi` => term UI
|
|
1177
|
+
# - `kiwi alt` => legacy UI
|
|
1178
|
+
# - `kiwi term` => explicit term UI (compat)
|
|
1179
|
+
ui_mode = "term"
|
|
1180
|
+
if argv and argv[0] == "alt":
|
|
1181
|
+
ui_mode = "legacy"
|
|
1182
|
+
argv = argv[1:]
|
|
1183
|
+
elif argv and argv[0] == "term":
|
|
1184
|
+
ui_mode = "term"
|
|
1185
|
+
argv = argv[1:]
|
|
1186
|
+
|
|
1129
1187
|
if argv and argv[0] in {"login", "logout", "whoami"}:
|
|
1130
1188
|
return _run_auth_command(argv)
|
|
1131
1189
|
|
|
@@ -1137,14 +1195,14 @@ def main() -> int:
|
|
|
1137
1195
|
"--port", "-p", type=int, default=8566, help="Port to serve on (default: 8566)"
|
|
1138
1196
|
)
|
|
1139
1197
|
serve_args = serve_parser.parse_args(argv[1:])
|
|
1140
|
-
_serve_tui(port=serve_args.port)
|
|
1198
|
+
_serve_tui(port=serve_args.port, extra_argv=["alt"] if ui_mode == "legacy" else None)
|
|
1141
1199
|
return 0
|
|
1142
1200
|
|
|
1143
1201
|
parser = argparse.ArgumentParser(
|
|
1144
|
-
prog="kiwi",
|
|
1202
|
+
prog="kiwi alt" if ui_mode == "legacy" else "kiwi",
|
|
1145
1203
|
description="Kiwi Code",
|
|
1146
1204
|
epilog=(
|
|
1147
|
-
"Examples: `kiwi` (TUI), `kiwi --terminal \"Hi\"`, "
|
|
1205
|
+
"Examples: `kiwi` (TUI), `kiwi alt` (legacy TUI), `kiwi --terminal \"Hi\"`, "
|
|
1148
1206
|
"`kiwi login`, `kiwi whoami`, `kiwi serve`."
|
|
1149
1207
|
),
|
|
1150
1208
|
)
|
|
@@ -1241,7 +1299,10 @@ def main() -> int:
|
|
|
1241
1299
|
)
|
|
1242
1300
|
)
|
|
1243
1301
|
|
|
1244
|
-
|
|
1302
|
+
if ui_mode == "legacy":
|
|
1303
|
+
_run_tui(runtime_args=runtime_args)
|
|
1304
|
+
else:
|
|
1305
|
+
_run_term_tui(runtime_args=runtime_args)
|
|
1245
1306
|
return 0
|
|
1246
1307
|
|
|
1247
1308
|
|
|
@@ -49,7 +49,7 @@ class AssistantMessageRow(Horizontal):
|
|
|
49
49
|
"""A single assistant message rendered with a left dot + markdown body.
|
|
50
50
|
|
|
51
51
|
While streaming, shows a footer line under the message:
|
|
52
|
-
|
|
52
|
+
<verb> <spinner>
|
|
53
53
|
|
|
54
54
|
Requirement: the footer is visible only while streaming and disappears when
|
|
55
55
|
the final output is available.
|
|
@@ -80,7 +80,7 @@ class AssistantMessageRow(Horizontal):
|
|
|
80
80
|
|
|
81
81
|
# Footer comes after the body, and is only visible while streaming.
|
|
82
82
|
with Horizontal(classes="assistant-footer"):
|
|
83
|
-
yield Static(f"
|
|
83
|
+
yield Static(f"{self._verb}", classes="assistant-name", markup=False)
|
|
84
84
|
yield Static("⣾", classes="assistant-spinner", markup=False)
|
|
85
85
|
|
|
86
86
|
def update_markdown(self, markdown_text: str) -> None:
|
|
@@ -299,7 +299,8 @@ class DashboardScreen(Screen):
|
|
|
299
299
|
|
|
300
300
|
/* Grayish background to indicate "in progress" assistant output. */
|
|
301
301
|
.assistant-message.streaming {
|
|
302
|
-
background
|
|
302
|
+
/* Match the normal background so the streaming row doesn't look like a separate "block". */
|
|
303
|
+
background: $background;
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
.assistant-dot {
|
|
@@ -786,31 +787,52 @@ class DashboardScreen(Screen):
|
|
|
786
787
|
|
|
787
788
|
|
|
788
789
|
def on_paste(self, event: events.Paste) -> None:
|
|
789
|
-
"""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
|
+
"""
|
|
790
796
|
try:
|
|
791
797
|
chat_input = self.query_one("#chat-input", ChatInput)
|
|
792
798
|
except Exception:
|
|
793
799
|
return
|
|
794
800
|
|
|
795
801
|
file_paths = chat_input._extract_pasted_file_paths(event.text)
|
|
796
|
-
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:
|
|
797
820
|
return
|
|
798
821
|
|
|
799
822
|
event.prevent_default()
|
|
800
823
|
event.stop()
|
|
801
824
|
try:
|
|
802
|
-
|
|
825
|
+
chat_input.focus()
|
|
803
826
|
except Exception:
|
|
804
827
|
pass
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
|
814
836
|
def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
|
|
815
837
|
"""Handle message submission (Enter)."""
|
|
816
838
|
self._do_send()
|
|
@@ -836,6 +858,22 @@ class DashboardScreen(Screen):
|
|
|
836
858
|
if self._cmd_running:
|
|
837
859
|
return
|
|
838
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
|
+
|
|
839
877
|
# /help is a UI-first experience (picker + copy). Don't run it through the CLI dispatcher.
|
|
840
878
|
if command.strip().lower() == "/help":
|
|
841
879
|
self.app.push_screen(HelpScreen())
|
|
@@ -937,6 +975,47 @@ class DashboardScreen(Screen):
|
|
|
937
975
|
show(body)
|
|
938
976
|
return
|
|
939
977
|
|
|
978
|
+
if cmd == "/detach":
|
|
979
|
+
if not self._pending_urls:
|
|
980
|
+
toast("No files attached.", title="/detach", severity="warning")
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
from kiwi_tui.screens.detach_files import PendingFilesDetachScreen
|
|
984
|
+
|
|
985
|
+
pending_snapshot = list(self._pending_urls)
|
|
986
|
+
|
|
987
|
+
def _on_detach_result(result: list[str] | None) -> None:
|
|
988
|
+
if not result:
|
|
989
|
+
return
|
|
990
|
+
if "__all__" in result:
|
|
991
|
+
removed = self._clear_pending_files()
|
|
992
|
+
if removed:
|
|
993
|
+
toast(
|
|
994
|
+
f"Detached {removed} file{'s' if removed != 1 else ''}.",
|
|
995
|
+
title="Attachments",
|
|
996
|
+
timeout=4,
|
|
997
|
+
)
|
|
998
|
+
return
|
|
999
|
+
|
|
1000
|
+
removed_urls: list[str] = []
|
|
1001
|
+
for u in result:
|
|
1002
|
+
if self._detach_pending_file(u):
|
|
1003
|
+
removed_urls.append(u)
|
|
1004
|
+
|
|
1005
|
+
if removed_urls:
|
|
1006
|
+
names = [Path(u.rsplit('/', 1)[-1]).name for u in removed_urls]
|
|
1007
|
+
toast(
|
|
1008
|
+
f"Detached {len(removed_urls)} file{'s' if len(removed_urls) != 1 else ''}: {', '.join(names)}",
|
|
1009
|
+
title="Attachments",
|
|
1010
|
+
timeout=4,
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
self.app.push_screen(
|
|
1014
|
+
PendingFilesDetachScreen(pending_snapshot),
|
|
1015
|
+
callback=_on_detach_result,
|
|
1016
|
+
)
|
|
1017
|
+
return
|
|
1018
|
+
|
|
940
1019
|
if cmd == "/clear-files":
|
|
941
1020
|
count = self._clear_pending_files()
|
|
942
1021
|
show(f"Cleared {count} pending file(s).")
|
|
@@ -2874,7 +2953,7 @@ class DashboardScreen(Screen):
|
|
|
2874
2953
|
"""Run action and stream results via SSE.
|
|
2875
2954
|
|
|
2876
2955
|
UX requirement: as soon as the user hits Enter, show an "in progress"
|
|
2877
|
-
assistant row (dot +
|
|
2956
|
+
assistant row (dot + header + spinner) and animate it until we
|
|
2878
2957
|
have a terminal result (success/error), regardless of whether SSE is
|
|
2879
2958
|
currently emitting tokens.
|
|
2880
2959
|
"""
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Modal UI for detaching one or more pending file attachments.
|
|
2
|
+
|
|
3
|
+
Used by both DashboardScreen (stable) and TermDashboardScreen (term mode).
|
|
4
|
+
|
|
5
|
+
The modal returns:
|
|
6
|
+
- None when cancelled
|
|
7
|
+
- ["__all__"] when user chose to detach all
|
|
8
|
+
- list[str] of attachment URLs selected for detaching
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Horizontal, Vertical
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
from textual.widgets import Button, SelectionList, Static
|
|
20
|
+
from textual.widgets.selection_list import Selection
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PendingFilesDetachScreen(ModalScreen[list[str] | None]):
|
|
24
|
+
"""Multi-select detach UI for pending attachments."""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [
|
|
27
|
+
Binding("escape", "cancel", "Cancel", show=True),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
CSS = """
|
|
31
|
+
PendingFilesDetachScreen {
|
|
32
|
+
align: center middle;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#detach-container {
|
|
36
|
+
width: 90;
|
|
37
|
+
height: 70%;
|
|
38
|
+
background: $primary-background;
|
|
39
|
+
border: solid $accent;
|
|
40
|
+
padding: 1 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#detach-title {
|
|
44
|
+
width: 100%;
|
|
45
|
+
text-style: bold;
|
|
46
|
+
color: $primary;
|
|
47
|
+
height: 1;
|
|
48
|
+
content-align: center middle;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#detach-subtitle {
|
|
52
|
+
width: 100%;
|
|
53
|
+
height: 1;
|
|
54
|
+
color: $foreground;
|
|
55
|
+
opacity: 0.75;
|
|
56
|
+
content-align: center middle;
|
|
57
|
+
margin-bottom: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#detach-list {
|
|
61
|
+
height: 1fr;
|
|
62
|
+
width: 100%;
|
|
63
|
+
border: none;
|
|
64
|
+
background: $primary-background;
|
|
65
|
+
color: $foreground;
|
|
66
|
+
scrollbar-size-vertical: 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#detach-buttons {
|
|
70
|
+
width: 100%;
|
|
71
|
+
height: auto;
|
|
72
|
+
margin-top: 1;
|
|
73
|
+
content-align: center middle;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#detach-buttons Button {
|
|
77
|
+
margin: 0 1;
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, pending_urls: list[str]):
|
|
82
|
+
super().__init__()
|
|
83
|
+
self._pending_urls = list(pending_urls)
|
|
84
|
+
|
|
85
|
+
def compose(self) -> ComposeResult:
|
|
86
|
+
with Vertical(id="detach-container"):
|
|
87
|
+
yield Static("Detach attachments", id="detach-title", markup=False)
|
|
88
|
+
yield Static("Select file(s) to detach", id="detach-subtitle", markup=False)
|
|
89
|
+
|
|
90
|
+
# Deduplicate display names to avoid confusing duplicates.
|
|
91
|
+
name_counts: dict[str, int] = {}
|
|
92
|
+
selections: list[Selection] = []
|
|
93
|
+
for url in self._pending_urls:
|
|
94
|
+
name = Path(str(url).rsplit("/", 1)[-1]).name or "(unnamed)"
|
|
95
|
+
count = name_counts.get(name, 0) + 1
|
|
96
|
+
name_counts[name] = count
|
|
97
|
+
label = name if count == 1 else f"{name} ({count})"
|
|
98
|
+
selections.append(Selection(label, url, initial_state=False))
|
|
99
|
+
|
|
100
|
+
yield SelectionList(*selections, id="detach-list")
|
|
101
|
+
|
|
102
|
+
with Horizontal(id="detach-buttons"):
|
|
103
|
+
yield Button("Detach selected", id="detach-selected", variant="default")
|
|
104
|
+
yield Button("Detach all", id="detach-all", variant="default")
|
|
105
|
+
yield Button("Cancel", id="cancel", variant="default")
|
|
106
|
+
|
|
107
|
+
def on_mount(self) -> None:
|
|
108
|
+
try:
|
|
109
|
+
self.query_one("#detach-list", SelectionList).focus()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def action_cancel(self) -> None:
|
|
114
|
+
self.dismiss(None)
|
|
115
|
+
|
|
116
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
117
|
+
btn_id = (event.button.id or "").strip()
|
|
118
|
+
if btn_id == "cancel":
|
|
119
|
+
self.dismiss(None)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
if btn_id == "detach-all":
|
|
123
|
+
self.dismiss(["__all__"])
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if btn_id == "detach-selected":
|
|
127
|
+
try:
|
|
128
|
+
selected = list(self.query_one("#detach-list", SelectionList).selected)
|
|
129
|
+
except Exception:
|
|
130
|
+
selected = []
|
|
131
|
+
|
|
132
|
+
if not selected:
|
|
133
|
+
try:
|
|
134
|
+
self.app.notify(
|
|
135
|
+
"No files selected.",
|
|
136
|
+
title="Detach",
|
|
137
|
+
severity="warning",
|
|
138
|
+
timeout=2.5,
|
|
139
|
+
markup=False,
|
|
140
|
+
)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
self.dismiss(selected)
|
|
146
|
+
return
|
|
@@ -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)
|