kiwi-code 0.0.33__tar.gz → 0.0.35__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 (54) hide show
  1. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/main.py +40 -33
  4. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/attach_content.py +1 -1
  5. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/command_result.py +1 -1
  6. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/dashboard.py +243 -44
  7. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/file_browser.py +23 -1
  8. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/help.py +2 -2
  9. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/id_picker.py +11 -11
  10. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/login.py +1 -1
  11. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/runtime_cleanup.py +31 -7
  12. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/runtime_logs.py +28 -0
  13. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/slash_picker.py +2 -1
  14. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/widgets.py +98 -6
  15. kiwi_code-0.0.35/tests/test_tui_headless.py +677 -0
  16. kiwi_code-0.0.35/tests/test_tui_palette.py +68 -0
  17. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/uv.lock +1 -1
  18. kiwi_code-0.0.33/tests/test_tui_headless.py +0 -327
  19. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/.github/workflows/publish.yml +0 -0
  20. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/.github/workflows/test.yml +0 -0
  21. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/.gitignore +0 -0
  22. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/.python-version +0 -0
  23. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/CLAUDE.md +0 -0
  24. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/Makefile +0 -0
  25. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/README.md +0 -0
  26. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/__init__.py +0 -0
  27. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/auth.py +0 -0
  28. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/cli.py +0 -0
  29. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/client.py +0 -0
  30. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/commands.py +0 -0
  31. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/logger.py +0 -0
  32. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/models.py +0 -0
  33. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/runtime_manager.py +0 -0
  34. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_cli/server.py +0 -0
  35. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_runtime/__init__.py +0 -0
  36. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_runtime/__main__.py +0 -0
  37. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_runtime/main.py +0 -0
  38. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  39. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  40. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/__init__.py +0 -0
  41. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/inline_file_picker.py +0 -0
  42. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/random_words.py +0 -0
  43. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/runtime_agent.py +0 -0
  44. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/screens/__init__.py +0 -0
  45. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/slash_commands.py +0 -0
  46. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/src/kiwi_tui/status_words.py +0 -0
  47. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/test_hello.py +0 -0
  48. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/tests/__init__.py +0 -0
  49. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/tests/conftest.py +0 -0
  50. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/tests/test_cli_help.py +0 -0
  51. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/tests/test_imports.py +0 -0
  52. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/tests/test_reexec_kiwi.py +0 -0
  53. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/tests/test_runtime_log_trimming.py +0 -0
  54. {kiwi_code-0.0.33 → kiwi_code-0.0.35}/tests/test_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.33
3
+ Version: 0.0.35
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.33"
3
+ version = "0.0.35"
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"
@@ -235,8 +235,8 @@ class AutobotsTUI(App):
235
235
  # Brand color for the kiwi cyan, kept readable on both dark and
236
236
  # light themes. Exposed to CSS as `$brand-cyan` via
237
237
  # `get_css_variables` below.
238
- BRAND_CYAN_DARK = "#5fffff" # bright cyan — pops on dark backgrounds
239
- BRAND_CYAN_LIGHT = "#006d8a" # deeper teal readable on light backgrounds
238
+ BRAND_CYAN_DARK = "#63d8dc" # softened cyan — same feel, less eye strain on dark backgrounds
239
+ BRAND_CYAN_LIGHT = "#0b7084" # slightly softened teal for light backgrounds
240
240
 
241
241
  def _is_dark_theme(self) -> bool:
242
242
  """Best-effort detection of whether the active theme is dark."""
@@ -249,65 +249,72 @@ class AutobotsTUI(App):
249
249
  return bool(getattr(self, "dark", True))
250
250
 
251
251
  def get_css_variables(self) -> dict[str, str]:
252
- """Inject theme-aware brand color and override palette to match kiwi-code UX.
253
-
254
- UX requirement: use a minimal palette (white + kiwi cyan) for UI text and
255
- accents across screens.
256
- """
252
+ """Inject theme-aware brand color and a softer neutral palette."""
257
253
  variables = super().get_css_variables()
258
- cyan = self.BRAND_CYAN_DARK if self._is_dark_theme() else self.BRAND_CYAN_LIGHT
254
+ dark = self._is_dark_theme()
255
+ cyan = self.BRAND_CYAN_DARK if dark else self.BRAND_CYAN_LIGHT
259
256
  variables["brand-cyan"] = cyan
260
257
 
261
- # Two-color palette: foreground text + cyan accents. Keep readability in light theme.
262
- foreground = "#ffffff" if self._is_dark_theme() else "#000000"
258
+ if dark:
259
+ # Match the reference terminal theme more closely:
260
+ # - global background around #1E1E1E
261
+ # - user prompts on a lighter gray chip around #3A3A3A
262
+ bg = "#1E1E1E"
263
+ surface = "#252526"
264
+ raised = "#2D2D2D"
265
+ panel = "#333333"
266
+ foreground = "#F3F3F3"
267
+ user_msg_bg = "#3A3A3A"
268
+ assistant_stream_bg = "#252526"
269
+ else:
270
+ bg = "#f6f7f8"
271
+ surface = "#ffffff"
272
+ raised = "#eef1f3"
273
+ panel = "#dbe7ea"
274
+ foreground = "#101213"
275
+ user_msg_bg = "#f1f3f4"
276
+ assistant_stream_bg = "#f4f6f7"
277
+
278
+ variables["background"] = bg
263
279
  variables["foreground"] = foreground
264
280
  variables["text"] = foreground
265
281
 
266
- # Map most semantic colors to the brand cyan (removes scattered colors).
282
+ # Keep the kiwi cyan for accents while softening the surrounding surfaces.
267
283
  for k in ("primary", "secondary", "accent", "error", "warning", "success"):
268
284
  variables[k] = cyan
269
285
 
270
- # Also align text-* semantic variants used by Markdown, notifications, etc.
271
286
  for k in ("text-warning", "text-error", "text-success"):
272
287
  variables[k] = cyan
273
288
 
274
- # Surfaces and borders: keep background dark, use cyan borders.
275
- bg = variables.get("background", "#000000")
276
- variables["surface"] = bg
277
- variables["boost"] = bg
289
+ variables["surface"] = surface
290
+ variables["boost"] = raised
278
291
  variables["primary-background"] = bg
279
- variables["panel"] = cyan
280
- variables["border"] = cyan
281
- variables["border-blurred"] = cyan
292
+ variables["panel"] = panel
293
+ variables["border"] = panel
294
+ variables["block-hover-background"] = surface
295
+ variables["border-blurred"] = raised
282
296
 
297
+ variables["user-msg-bg"] = user_msg_bg
298
+ variables["assistant-stream-bg"] = assistant_stream_bg
283
299
 
284
- # Chat UX: full-width highlight color for user-message rows.
285
- # Keep it subtle and theme-aware.
286
- variables["user-msg-bg"] = "#333333" if self._is_dark_theme() else "#e8e8e8"
287
- # Chat UX: subtle highlight for the *active streaming* assistant message.
288
- variables["assistant-stream-bg"] = "#222222" if self._is_dark_theme() else "#f2f2f2"
289
- # Scrollbars: cyan thumb, dark track.
290
300
  variables["scrollbar"] = cyan
291
301
  variables["scrollbar-hover"] = cyan
292
302
  variables["scrollbar-active"] = cyan
293
- variables["scrollbar-background"] = bg
294
- variables["scrollbar-background-hover"] = bg
295
- variables["scrollbar-background-active"] = bg
303
+ variables["scrollbar-background"] = surface
304
+ variables["scrollbar-background-hover"] = surface
305
+ variables["scrollbar-background-active"] = raised
296
306
  variables["scrollbar-corner-color"] = bg
297
307
 
298
- # Footer palette variables (Textual 8 uses these).
299
- variables["footer-background"] = bg
308
+ variables["footer-background"] = surface
300
309
  variables["footer-foreground"] = foreground
301
- variables["footer-description-background"] = bg
310
+ variables["footer-description-background"] = surface
302
311
  variables["footer-description-foreground"] = foreground
303
312
 
304
- # Cursor / selection highlight: cyan text on background (no extra colors).
305
313
  variables["block-cursor-foreground"] = cyan
306
314
  variables["block-cursor-background"] = bg
307
315
  variables["block-cursor-blurred-foreground"] = cyan
308
316
  variables["block-cursor-blurred-background"] = bg
309
317
  return variables
310
-
311
318
  def watch_theme(self, theme: str) -> None:
312
319
  """Re-resolve palette variables whenever the user switches theme."""
313
320
  try:
@@ -27,7 +27,7 @@ class AttachContentScreen(ModalScreen[dict]):
27
27
  width: 60;
28
28
  height: auto;
29
29
  max-height: 20;
30
- background: $surface;
30
+ background: $primary-background;
31
31
  border: solid $accent;
32
32
  padding: 1 2;
33
33
  }
@@ -30,7 +30,7 @@ class CommandResultScreen(ModalScreen[None]):
30
30
  #cmdres-container {
31
31
  width: 90;
32
32
  height: 80%;
33
- background: $surface;
33
+ background: $primary-background;
34
34
  border: solid $accent;
35
35
  padding: 1 2;
36
36
  }
@@ -6,6 +6,7 @@ from typing import Callable
6
6
  from textual.app import ComposeResult
7
7
  from textual.binding import Binding
8
8
  from textual.screen import Screen
9
+ from textual import events
9
10
  from textual.widgets import Header, Footer, Input, Static, Button, Markdown, LoadingIndicator
10
11
  from textual.containers import Vertical, VerticalScroll, Horizontal, Center
11
12
  from kiwi_tui.screens.file_browser import FileBrowserScreen
@@ -159,6 +160,7 @@ class DashboardScreen(Screen):
159
160
  self.current_run_kind: str | None = None # 'action' or 'graph' (best-effort)
160
161
  self._last_listed_ids: list[str] = [] # Index for #N shortcuts
161
162
  self._pending_urls: list[str] = [] # File URLs to attach to next message
163
+ self._upload_status_text: str = "" # Transient upload status shown above the input box
162
164
  self._metadata: dict = {} # Persistent metadata for all subsequent runs
163
165
  self._is_streaming: bool = False # Track streaming state for send/stop toggle
164
166
 
@@ -260,7 +262,7 @@ class DashboardScreen(Screen):
260
262
  }
261
263
 
262
264
 
263
- #bottom-area, #input-bar, #input-row, #pending-files-bar {
265
+ #bottom-area, #input-bar, #input-row, #pending-files-row, #pending-files-bar {
264
266
  background: $background;
265
267
  }
266
268
 
@@ -377,13 +379,34 @@ class DashboardScreen(Screen):
377
379
  padding: 0;
378
380
  }
379
381
 
380
- #pending-files-bar {
382
+ #pending-files-row {
381
383
  height: auto;
382
384
  width: 100%;
385
+ }
386
+
387
+ #pending-files-bar {
388
+ height: auto;
389
+ width: 1fr;
383
390
  padding: 0 1;
384
391
  color: $secondary;
385
392
  }
386
393
 
394
+ #detach-files-btn {
395
+ width: auto;
396
+ height: 1;
397
+ min-width: 8;
398
+ margin: 0 1 0 0;
399
+ padding: 0 1;
400
+ background: transparent;
401
+ color: $warning;
402
+ border: none;
403
+ display: none;
404
+ }
405
+
406
+ #detach-files-btn.visible {
407
+ display: block;
408
+ }
409
+
387
410
  #input-row {
388
411
  height: auto;
389
412
  width: 100%;
@@ -539,7 +562,9 @@ class DashboardScreen(Screen):
539
562
  with Horizontal(id="activity-bar"):
540
563
  yield LoadingIndicator(id="activity-spinner")
541
564
  yield Static("", id="activity-text", markup=False)
542
- yield Static("", id="pending-files-bar", markup=False)
565
+ with Horizontal(id="pending-files-row"):
566
+ yield Static("", id="pending-files-bar", markup=False)
567
+ yield Button("Detach", id="detach-files-btn", variant="default")
543
568
  with Horizontal(id="input-row"):
544
569
  yield ChatInput(placeholder="Message... (@ for files, / for commands)", id="chat-input")
545
570
  with Horizontal(id="button-row"):
@@ -655,10 +680,8 @@ class DashboardScreen(Screen):
655
680
  pass
656
681
  chat_input.picker_active = False
657
682
 
658
- val = chat_input.value
659
- at_pos = val.rfind("@")
660
- if at_pos != -1:
661
- chat_input.value = val[:at_pos]
683
+ if chat_input.value.startswith("@"):
684
+ chat_input.value = ""
662
685
 
663
686
  # Open the full file browser; it returns list[str] of selected files.
664
687
  self.app.push_screen(FileBrowserScreen(), callback=self._on_files_selected)
@@ -701,11 +724,9 @@ class DashboardScreen(Screen):
701
724
  chat_input = self.query_one("#chat-input", ChatInput)
702
725
  picker.hide_picker()
703
726
  chat_input.picker_active = False
704
- # Remove the @... text from the input
705
- val = chat_input.value
706
- at_pos = val.rfind("@")
707
- if at_pos != -1:
708
- chat_input.value = val[:at_pos]
727
+ # Remove the leading @-picker text from the input.
728
+ if chat_input.value.startswith("@"):
729
+ chat_input.value = ""
709
730
  chat_input.focus()
710
731
 
711
732
  def on_chat_input_changed(self, event: ChatInput.Changed) -> None:
@@ -714,19 +735,60 @@ class DashboardScreen(Screen):
714
735
  if not chat_input.picker_active:
715
736
  return
716
737
  val = chat_input.value
717
- at_pos = val.rfind("@")
718
- if at_pos == -1:
719
- # @ was deleted — close picker
738
+ if not val.startswith("@"):
739
+ # The leading @ was removed or text was inserted before it.
720
740
  self._close_inline_picker()
721
741
  return
722
- filter_text = val[at_pos + 1 :]
742
+ filter_text = val[1:]
723
743
  picker = self.query_one("#inline-file-picker", InlineFilePicker)
724
744
  picker.filter(filter_text)
725
745
 
746
+
747
+ def on_paste(self, event: events.Paste) -> None:
748
+ """Handle terminal paste/drop events that land on the screen instead of the input."""
749
+ try:
750
+ chat_input = self.query_one("#chat-input", ChatInput)
751
+ except Exception:
752
+ return
753
+
754
+ file_paths = chat_input._extract_pasted_file_paths(event.text)
755
+ if not file_paths:
756
+ return
757
+
758
+ event.prevent_default()
759
+ event.stop()
760
+ try:
761
+ self._close_inline_picker()
762
+ except Exception:
763
+ pass
764
+ self.notify(
765
+ f"Detected {len(file_paths)} file{'s' if len(file_paths) != 1 else ''}. Uploading...",
766
+ title="Attachments",
767
+ severity="information",
768
+ timeout=2.5,
769
+ markup=False,
770
+ )
771
+ self._on_files_selected(file_paths)
772
+
726
773
  def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
727
774
  """Handle message submission (Enter)."""
728
775
  self._do_send()
729
776
 
777
+ def on_chat_input_file_paths_pasted(self, event: ChatInput.FilePathsPasted) -> None:
778
+ """Handle terminal drag/drop file pastes via the normal upload flow."""
779
+ try:
780
+ self._close_inline_picker()
781
+ except Exception:
782
+ pass
783
+ self.notify(
784
+ f"Detected {len(event.file_paths)} file{'s' if len(event.file_paths) != 1 else ''}. Uploading...",
785
+ title="Attachments",
786
+ severity="information",
787
+ timeout=2.5,
788
+ markup=False,
789
+ )
790
+ self._on_files_selected(event.file_paths)
791
+
730
792
  def handle_slash_command(self, command: str) -> None:
731
793
  """Schedule a slash command to run without blocking the UI."""
732
794
  # If a command is already running, ignore (prevents the "hung" feeling / double execution).
@@ -831,9 +893,7 @@ class DashboardScreen(Screen):
831
893
  return
832
894
 
833
895
  if cmd == "/clear-files":
834
- count = len(self._pending_urls)
835
- self._pending_urls.clear()
836
- self._update_pending_files_bar()
896
+ count = self._clear_pending_files()
837
897
  show(f"Cleared {count} pending file(s).")
838
898
  return
839
899
 
@@ -2095,6 +2155,30 @@ class DashboardScreen(Screen):
2095
2155
  self._do_send()
2096
2156
  return
2097
2157
 
2158
+ if btn_id == "detach-files-btn":
2159
+ if len(self._pending_urls) <= 1:
2160
+ count = self._clear_pending_files()
2161
+ self.notify(
2162
+ f"Detached {count} file{'s' if count != 1 else ''}.",
2163
+ title="Attachments",
2164
+ severity="information",
2165
+ timeout=2.5,
2166
+ markup=False,
2167
+ )
2168
+ try:
2169
+ self.query_one("#chat-input", ChatInput).focus()
2170
+ except Exception:
2171
+ pass
2172
+ else:
2173
+ self.app.push_screen(
2174
+ IdPickerScreen(
2175
+ "Detach pending file",
2176
+ options=self._pending_file_picker_options(),
2177
+ ),
2178
+ callback=self._on_pending_file_detach_selected,
2179
+ )
2180
+ return
2181
+
2098
2182
  api_client = self._get_api_client()
2099
2183
  if not api_client:
2100
2184
  return
@@ -2228,9 +2312,11 @@ class DashboardScreen(Screen):
2228
2312
  except Exception:
2229
2313
  pass
2230
2314
  # Disable auxiliary buttons while busy.
2231
- for btn_id in ("#upload-btn", "#slash-btn", "#copy-run-id"):
2315
+ for btn_id in ("#upload-btn", "#slash-btn", "#copy-run-id", "#detach-files-btn"):
2232
2316
  try:
2233
- self.query_one(btn_id, Button).disabled = disabled or (btn_id == "#copy-run-id" and not bool(self.current_run_id))
2317
+ is_copy_run = btn_id == "#copy-run-id" and not bool(self.current_run_id)
2318
+ is_detach = btn_id == "#detach-files-btn" and (not bool(self._pending_urls) or bool(self._upload_status_text))
2319
+ self.query_one(btn_id, Button).disabled = disabled or is_copy_run or is_detach
2234
2320
  except Exception:
2235
2321
  pass
2236
2322
  self._update_run_status_bar()
@@ -2402,6 +2488,11 @@ class DashboardScreen(Screen):
2402
2488
  self.handle_slash_command(command)
2403
2489
  self.query_one("#chat-input", ChatInput).focus()
2404
2490
 
2491
+ def _set_upload_status(self, text: str) -> None:
2492
+ """Update the transient upload status shown immediately above the text box."""
2493
+ self._upload_status_text = text
2494
+ self._update_pending_files_bar()
2495
+
2405
2496
  def _on_files_selected(self, file_paths: list[str]) -> None:
2406
2497
  """Callback from FileBrowserScreen — upload selected files."""
2407
2498
  if not file_paths:
@@ -2411,40 +2502,148 @@ class DashboardScreen(Screen):
2411
2502
  self.add_message("Error: Client not initialized", "error")
2412
2503
  return
2413
2504
  names = [Path(p).name for p in file_paths]
2414
- self.add_message(f"> Uploading: {', '.join(names)}", "user")
2415
- # Ensure the token is fresh before upload (important for long-running sessions).
2416
- try:
2417
- self.app._refresh_token_if_needed()
2418
- except Exception:
2419
- pass
2505
+ self._set_upload_status(f"Uploading: {', '.join(names)}")
2506
+ self.notify(
2507
+ f"Uploading {len(file_paths)} file{'s' if len(file_paths) != 1 else ''}: {', '.join(names)}",
2508
+ title="Attachments",
2509
+ severity="information",
2510
+ timeout=3,
2511
+ markup=False,
2512
+ )
2513
+ self.run_worker(
2514
+ self._upload_files_async(file_paths),
2515
+ group="upload",
2516
+ exclusive=False,
2517
+ exit_on_error=False,
2518
+ )
2519
+ self.query_one("#chat-input", ChatInput).focus()
2420
2520
 
2421
- success, urls, message = self.app.autobots_client.upload_files(file_paths)
2422
- if (not success) and ("HTTP 401" in message or "HTTP 403" in message):
2423
- # Token may have expired or been rotated; force a refresh and retry once.
2424
- refreshed = False
2521
+ async def _upload_files_async(self, file_paths: list[str]) -> None:
2522
+ """Upload files without blocking the UI so the upload bar renders immediately."""
2523
+ try:
2524
+ # Ensure the token is fresh before upload (important for long-running sessions).
2425
2525
  try:
2426
- refreshed = bool(self.app._refresh_token_if_needed(force=True))
2526
+ await asyncio.to_thread(self.app._refresh_token_if_needed)
2427
2527
  except Exception:
2528
+ pass
2529
+
2530
+ success, urls, message = await asyncio.to_thread(
2531
+ lambda: self.app.autobots_client.upload_files(file_paths)
2532
+ )
2533
+ if (not success) and ("HTTP 401" in message or "HTTP 403" in message):
2428
2534
  refreshed = False
2429
- if refreshed:
2430
- success, urls, message = self.app.autobots_client.upload_files(file_paths)
2431
- if success:
2432
- self._pending_urls.extend(urls)
2433
- uploaded_names = [Path(u.rsplit("/", 1)[-1]).name for u in urls]
2434
- self.add_message(f"Uploaded: {', '.join(uploaded_names)}. Attached to your next message.", "info")
2435
- self._update_pending_files_bar()
2436
- else:
2437
- self.add_message(f"Upload failed: {message}", "error")
2438
- self.query_one("#chat-input", ChatInput).focus()
2535
+ try:
2536
+ refreshed = bool(await asyncio.to_thread(self.app._refresh_token_if_needed, True))
2537
+ except Exception:
2538
+ refreshed = False
2539
+ if refreshed:
2540
+ success, urls, message = await asyncio.to_thread(
2541
+ lambda: self.app.autobots_client.upload_files(file_paths)
2542
+ )
2439
2543
 
2544
+ if success:
2545
+ self._pending_urls.extend(urls)
2546
+ self._set_upload_status("")
2547
+ self.notify(
2548
+ f"Attached {len(urls)} file{'s' if len(urls) != 1 else ''} to your next message.",
2549
+ title="Attachments",
2550
+ severity="information",
2551
+ timeout=3.5,
2552
+ markup=False,
2553
+ )
2554
+ self._update_pending_files_bar()
2555
+ else:
2556
+ self._set_upload_status("")
2557
+ self.add_message(f"Upload failed: {message}", "error")
2558
+ self.notify(
2559
+ f"Upload failed: {message}",
2560
+ title="Attachments",
2561
+ severity="error",
2562
+ timeout=4.5,
2563
+ markup=False,
2564
+ )
2565
+ finally:
2566
+ try:
2567
+ self.query_one("#chat-input", ChatInput).focus()
2568
+ except Exception:
2569
+ pass
2570
+
2571
+ def _clear_pending_files(self) -> int:
2572
+ """Detach all pending files from the next message."""
2573
+ count = len(self._pending_urls)
2574
+ self._pending_urls.clear()
2575
+ self._set_upload_status("")
2576
+ self._update_pending_files_bar()
2577
+ return count
2578
+
2579
+
2580
+ def _detach_pending_file(self, url: str) -> bool:
2581
+ """Detach a single pending file by URL."""
2582
+ try:
2583
+ self._pending_urls.remove(url)
2584
+ except ValueError:
2585
+ return False
2586
+ self._update_pending_files_bar()
2587
+ return True
2588
+
2589
+ def _pending_file_picker_options(self) -> list[tuple[str, str]]:
2590
+ """Build picker options for detaching one or all pending files."""
2591
+ options: list[tuple[str, str]] = [("__all__", "Detach all files")]
2592
+ for url in self._pending_urls:
2593
+ name = Path(url.rsplit("/", 1)[-1]).name if "/" in url else url
2594
+ options.append((url, name))
2595
+ return options
2596
+
2597
+ def _on_pending_file_detach_selected(self, selection: str) -> None:
2598
+ """Detach a selected pending file from the next message."""
2599
+ if not selection:
2600
+ try:
2601
+ self.query_one("#chat-input", ChatInput).focus()
2602
+ except Exception:
2603
+ pass
2604
+ return
2605
+
2606
+ if selection == "__all__":
2607
+ count = self._clear_pending_files()
2608
+ self.notify(
2609
+ f"Detached {count} file{'s' if count != 1 else ''}.",
2610
+ title="Attachments",
2611
+ severity="information",
2612
+ timeout=2.5,
2613
+ markup=False,
2614
+ )
2615
+ else:
2616
+ removed = self._detach_pending_file(selection)
2617
+ if removed:
2618
+ name = Path(selection.rsplit("/", 1)[-1]).name if "/" in selection else selection
2619
+ self.notify(
2620
+ f"Detached: {name}",
2621
+ title="Attachments",
2622
+ severity="information",
2623
+ timeout=2.5,
2624
+ markup=False,
2625
+ )
2626
+ try:
2627
+ self.query_one("#chat-input", ChatInput).focus()
2628
+ except Exception:
2629
+ pass
2440
2630
  def _update_pending_files_bar(self) -> None:
2441
- """Update the pending-files bar to show queued file URLs or hide when empty."""
2631
+ """Update the bar above the input to show upload progress or attached files."""
2442
2632
  bar = self.query_one("#pending-files-bar", Static)
2443
- if self._pending_urls:
2633
+ detach_btn = self.query_one("#detach-files-btn", Button)
2634
+ if self._upload_status_text:
2635
+ bar.update(self._upload_status_text)
2636
+ detach_btn.remove_class("visible")
2637
+ detach_btn.disabled = True
2638
+ elif self._pending_urls:
2444
2639
  names = [Path(url.rsplit("/", 1)[-1]).name for url in self._pending_urls]
2445
2640
  bar.update(f"Attached: {', '.join(names)}")
2641
+ detach_btn.add_class("visible")
2642
+ detach_btn.disabled = False
2446
2643
  else:
2447
2644
  bar.update("")
2645
+ detach_btn.remove_class("visible")
2646
+ detach_btn.disabled = True
2448
2647
 
2449
2648
  def process_message(self, message: str) -> None:
2450
2649
  """Process user message by running action."""
@@ -29,7 +29,7 @@ class FileBrowserScreen(ModalScreen[list[str]]):
29
29
  #file-browser-container {
30
30
  width: 96%;
31
31
  height: 95%;
32
- background: $surface;
32
+ background: $primary-background;
33
33
  border: solid $accent;
34
34
  padding: 0 1;
35
35
  }
@@ -75,10 +75,32 @@ class FileBrowserScreen(ModalScreen[list[str]]):
75
75
  #dir-tree {
76
76
  height: 1fr;
77
77
  width: 100%;
78
+ background: $primary-background;
78
79
  border: solid $panel;
79
80
  scrollbar-size: 1 1;
81
+ #dir-tree:focus {
82
+ background-tint: 0%;
83
+ }
84
+
85
+ #dir-tree > .tree--highlight-line {
86
+ background: $surface;
87
+ }
88
+
89
+ #dir-tree > .tree--cursor {
90
+ background: $surface;
91
+ color: $foreground;
92
+ text-style: none;
80
93
  }
81
94
 
95
+ #dir-tree:focus > .tree--cursor {
96
+ background: $boost;
97
+ color: $foreground;
98
+ text-style: bold;
99
+ }
100
+
101
+ }
102
+
103
+
82
104
  #selected-files {
83
105
  height: auto;
84
106
  max-height: 3;
@@ -29,7 +29,7 @@ class HelpScreen(ModalScreen[None]):
29
29
  #help-container {
30
30
  width: 96;
31
31
  height: 80%;
32
- background: $surface;
32
+ background: $primary-background;
33
33
  border: solid $accent;
34
34
  /* Reduce top padding so the title/hint sit closer to the border. */
35
35
  padding: 0 3 1 3;
@@ -58,7 +58,7 @@ class HelpScreen(ModalScreen[None]):
58
58
  height: 1fr;
59
59
  width: 100%;
60
60
  border: solid $panel;
61
- background: $surface;
61
+ background: $primary-background;
62
62
  scrollbar-size: 1 1;
63
63
  /* Keep the list away from the container border for consistent margins. */
64
64
  margin: 0 1;
@@ -101,7 +101,7 @@ class IdPickerScreen(ModalScreen[str]):
101
101
  #idpicker-container {
102
102
  width: 90;
103
103
  height: 90%;
104
- background: $surface;
104
+ background: $primary-background;
105
105
  border: solid $accent;
106
106
  padding: 0 1;
107
107
  }
@@ -111,7 +111,7 @@ class IdPickerScreen(ModalScreen[str]):
111
111
  text-align: center;
112
112
  text-style: bold;
113
113
  color: $primary;
114
- background: $surface;
114
+ background: $primary-background;
115
115
  height: 1;
116
116
  margin-bottom: 0;
117
117
  }
@@ -119,17 +119,17 @@ class IdPickerScreen(ModalScreen[str]):
119
119
  #idpicker-table {
120
120
  height: 1fr;
121
121
  width: 100%;
122
- background: $surface;
122
+ background: $primary-background;
123
123
  /* Slightly dim the row text so it's easier on the eyes. */
124
124
  color: $foreground 80%;
125
125
  border: none;
126
126
  overflow-x: auto;
127
127
  overflow-y: auto;
128
128
  scrollbar-gutter: auto;
129
- scrollbar-background: $surface;
130
- scrollbar-background-hover: $surface;
131
- scrollbar-background-active: $surface;
132
- scrollbar-corner-color: $surface;
129
+ scrollbar-background: $primary-background;
130
+ scrollbar-background-hover: $primary-background;
131
+ scrollbar-background-active: $primary-background;
132
+ scrollbar-corner-color: $primary-background;
133
133
  scrollbar-color: $panel;
134
134
  scrollbar-color-hover: $primary;
135
135
  scrollbar-color-active: $primary;
@@ -163,10 +163,10 @@ class IdPickerScreen(ModalScreen[str]):
163
163
  overflow-x: auto;
164
164
  overflow-y: auto;
165
165
  scrollbar-gutter: auto;
166
- scrollbar-background: $surface;
167
- scrollbar-background-hover: $surface;
168
- scrollbar-background-active: $surface;
169
- scrollbar-corner-color: $surface;
166
+ scrollbar-background: $primary-background;
167
+ scrollbar-background-hover: $primary-background;
168
+ scrollbar-background-active: $primary-background;
169
+ scrollbar-corner-color: $primary-background;
170
170
  scrollbar-color: $panel;
171
171
  scrollbar-color-hover: $primary;
172
172
  scrollbar-color-active: $primary;