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.
Files changed (63) hide show
  1. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/__init__.py +1 -1
  4. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_runtime/__init__.py +1 -1
  5. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/__init__.py +1 -1
  6. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/main.py +69 -8
  7. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/dashboard.py +95 -16
  8. kiwi_code-0.0.439/src/kiwi_tui/screens/detach_files.py +146 -0
  9. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_cleanup.py +52 -13
  10. kiwi_code-0.0.439/src/kiwi_tui/screens/term_dashboard.py +3996 -0
  11. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/slash_commands.py +2 -0
  12. kiwi_code-0.0.439/src/kiwi_tui/term_app.py +9 -0
  13. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/widgets.py +20 -0
  14. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_cli_help.py +9 -0
  15. kiwi_code-0.0.439/tests/test_term_dashboard_ui.py +433 -0
  16. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_terminal_mode.py +33 -3
  17. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/uv.lock +1 -1
  18. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.github/workflows/publish.yml +0 -0
  19. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.github/workflows/test.yml +0 -0
  20. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.gitignore +0 -0
  21. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/.python-version +0 -0
  22. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/CLAUDE.md +0 -0
  23. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/Makefile +0 -0
  24. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/README.md +0 -0
  25. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/auth.py +0 -0
  26. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/checkpoints.py +0 -0
  27. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/cli.py +0 -0
  28. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/client.py +0 -0
  29. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/commands.py +0 -0
  30. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/logger.py +0 -0
  31. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/models.py +0 -0
  32. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/runtime_manager.py +0 -0
  33. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/server.py +0 -0
  34. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_cli/terminal_mode.py +0 -0
  35. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_runtime/__main__.py +0 -0
  36. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_runtime/main.py +0 -0
  37. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/inline_file_picker.py +0 -0
  38. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/random_words.py +0 -0
  39. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/runtime_agent.py +0 -0
  40. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/__init__.py +0 -0
  41. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/attach_content.py +0 -0
  42. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/command_result.py +0 -0
  43. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/file_browser.py +0 -0
  44. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/help.py +0 -0
  45. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/id_picker.py +0 -0
  46. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/login.py +0 -0
  47. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  48. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/screens/slash_picker.py +0 -0
  49. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/status_words.py +0 -0
  50. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/src/kiwi_tui/worktrees.py +0 -0
  51. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/test_hello.py +0 -0
  52. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/__init__.py +0 -0
  53. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/conftest.py +0 -0
  54. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_checkpoints.py +0 -0
  55. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_imports.py +0 -0
  56. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_reexec_kiwi.py +0 -0
  57. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_runtime_log_trimming.py +0 -0
  58. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_slash_commands.py +0 -0
  59. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tokens.py +0 -0
  60. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tui_headless.py +0 -0
  61. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tui_interactive_runtime.py +0 -0
  62. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_tui_palette.py +0 -0
  63. {kiwi_code-0.0.437.dev1 → kiwi_code-0.0.439}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.437.dev1
3
+ Version: 0.0.439
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.437.dev1"
3
+ version = "0.0.439"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -1,3 +1,3 @@
1
1
  """Kiwi CLI - command-line interface and shared infrastructure modules."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.0.439"
@@ -1,3 +1,3 @@
1
1
  """Kiwi Runtime — terminal agent that connects to the server via WebSocket."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.0.439"
@@ -1,3 +1,3 @@
1
1
  """Autobots TUI - A textual-based terminal user interface."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.0.439"
@@ -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 _serve_tui(port: int = 8566):
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
- server = Server(f"{sys.executable} -m kiwi_tui.main", port=port)
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
- _run_tui(runtime_args=runtime_args)
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
- [Kiwi] <verb> <spinner>
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"[Kiwi] {self._verb}", classes="assistant-name", markup=False)
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: $assistant-stream-bg;
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 terminal paste/drop events that land on the screen instead of the input."""
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 not file_paths:
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
- self._close_inline_picker()
825
+ chat_input.focus()
803
826
  except Exception:
804
827
  pass
805
- self.notify(
806
- f"Detected {len(file_paths)} file{'s' if len(file_paths) != 1 else ''}. Uploading...",
807
- title="Attachments",
808
- severity="information",
809
- timeout=2.5,
810
- markup=False,
811
- )
812
- self._on_files_selected(file_paths)
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 + [Kiwi] header + spinner) and animate it until we
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
- Binding("escape", "cancel_exit", "Exit (keep all)", show=True),
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
- class RuntimeCleanupScreen(ModalScreen[list[int]]):
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
- Binding("escape", "cancel", "Exit (keep all)", show=False),
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, Enter to confirm & exit, Esc to keep all.",
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
- kill = "YES" if r.kill else "NO"
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"Kill={kill}", style=f"bold {cyan}")
194
- row.append(f" {name}", style="bold")
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)