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.
Files changed (63) hide show
  1. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/__init__.py +1 -1
  4. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_runtime/__init__.py +1 -1
  5. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/__init__.py +1 -1
  6. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/main.py +15 -2
  7. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/dashboard.py +49 -12
  8. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_cleanup.py +52 -13
  9. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/term_dashboard.py +107 -9
  10. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/slash_commands.py +1 -0
  11. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/widgets.py +20 -0
  12. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_term_dashboard_ui.py +137 -6
  13. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/uv.lock +1 -1
  14. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.github/workflows/publish.yml +0 -0
  15. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.github/workflows/test.yml +0 -0
  16. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.gitignore +0 -0
  17. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/.python-version +0 -0
  18. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/CLAUDE.md +0 -0
  19. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/Makefile +0 -0
  20. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/README.md +0 -0
  21. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/auth.py +0 -0
  22. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/checkpoints.py +0 -0
  23. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/cli.py +0 -0
  24. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/client.py +0 -0
  25. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/commands.py +0 -0
  26. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/logger.py +0 -0
  27. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/models.py +0 -0
  28. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/runtime_manager.py +0 -0
  29. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/server.py +0 -0
  30. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_cli/terminal_mode.py +0 -0
  31. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_runtime/__main__.py +0 -0
  32. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_runtime/main.py +0 -0
  33. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/inline_file_picker.py +0 -0
  34. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/random_words.py +0 -0
  35. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/runtime_agent.py +0 -0
  36. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/__init__.py +0 -0
  37. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/attach_content.py +0 -0
  38. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/command_result.py +0 -0
  39. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/detach_files.py +0 -0
  40. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/file_browser.py +0 -0
  41. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/help.py +0 -0
  42. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/id_picker.py +0 -0
  43. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/login.py +0 -0
  44. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  45. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/screens/slash_picker.py +0 -0
  46. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/status_words.py +0 -0
  47. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/term_app.py +0 -0
  48. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/src/kiwi_tui/worktrees.py +0 -0
  49. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/test_hello.py +0 -0
  50. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/__init__.py +0 -0
  51. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/conftest.py +0 -0
  52. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_checkpoints.py +0 -0
  53. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_cli_help.py +0 -0
  54. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_imports.py +0 -0
  55. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_reexec_kiwi.py +0 -0
  56. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_runtime_log_trimming.py +0 -0
  57. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_slash_commands.py +0 -0
  58. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_terminal_mode.py +0 -0
  59. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tokens.py +0 -0
  60. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tui_headless.py +0 -0
  61. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tui_interactive_runtime.py +0 -0
  62. {kiwi_code-0.0.438 → kiwi_code-0.0.439}/tests/test_tui_palette.py +0 -0
  63. {kiwi_code-0.0.438 → 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.438
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.438"
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.0.438"
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.0.438"
3
+ __version__ = "0.0.439"
@@ -1,3 +1,3 @@
1
1
  """Autobots TUI - A textual-based terminal user interface."""
2
2
 
3
- __version__ = "0.0.438"
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))
@@ -787,31 +787,52 @@ class DashboardScreen(Screen):
787
787
 
788
788
 
789
789
  def on_paste(self, event: events.Paste) -> None:
790
- """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
+ """
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 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:
798
820
  return
799
821
 
800
822
  event.prevent_default()
801
823
  event.stop()
802
824
  try:
803
- self._close_inline_picker()
825
+ chat_input.focus()
804
826
  except Exception:
805
827
  pass
806
- self.notify(
807
- f"Detected {len(file_paths)} file{'s' if len(file_paths) != 1 else ''}. Uploading...",
808
- title="Attachments",
809
- severity="information",
810
- timeout=2.5,
811
- markup=False,
812
- )
813
- self._on_files_selected(file_paths)
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
- 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)
@@ -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 not file_paths:
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
- self._close_inline_picker()
2161
+ chat_input.focus()
2086
2162
  except Exception:
2087
2163
  pass
2088
- self._on_files_selected(file_paths)
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. processing First", "run_id": "run-1"},
82
- {"label": " 2. completed Second", "run_id": "run-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 test_term_welcome_shows_shortcuts_and_hint_bar_has_no_commands() -> None:
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 should no longer show shortcut commands; it only hosts the model indicator now.
264
- assert len(list(screen.query(".term-hint-key"))) == 0
265
- assert len(list(screen.query(".term-hint-item"))) == 0
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
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.438"
400
+ version = "0.0.439"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes