kiwi-code 0.0.8__tar.gz → 0.0.10__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 (32) hide show
  1. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/models.py +1 -1
  4. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_runtime/main.py +7 -0
  5. kiwi_code-0.0.10/src/kiwi_tui/screens/attach_content.py +111 -0
  6. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/dashboard.py +106 -6
  7. kiwi_code-0.0.10/src/kiwi_tui/screens/slash_picker.py +95 -0
  8. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/widgets.py +13 -2
  9. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/uv.lock +608 -609
  10. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/.github/workflows/publish.yml +0 -0
  11. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/.gitignore +0 -0
  12. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/.python-version +0 -0
  13. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/CLAUDE.md +0 -0
  14. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/Makefile +0 -0
  15. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/README.md +0 -0
  16. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/__init__.py +0 -0
  17. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/auth.py +0 -0
  18. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/cli.py +0 -0
  19. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/client.py +0 -0
  20. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/commands.py +0 -0
  21. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/config.py +0 -0
  22. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/logger.py +0 -0
  23. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/runtime_manager.py +0 -0
  24. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_runtime/__init__.py +0 -0
  25. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_runtime/__main__.py +0 -0
  26. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/__init__.py +0 -0
  27. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/main.py +0 -0
  28. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/__init__.py +0 -0
  29. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/file_browser.py +0 -0
  30. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/login.py +0 -0
  31. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  32. {kiwi_code-0.0.8 → kiwi_code-0.0.10}/test_hello.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.8
3
+ Version: 0.0.10
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.8"
3
+ version = "0.0.10"
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.13,<4.0"
@@ -78,7 +78,7 @@ class LoginCredentials(BaseModel):
78
78
 
79
79
  class AppConfig(BaseModel):
80
80
  """Application configuration."""
81
- backend_url: str = "https://dev.api.myautobots.com"#"https://api.meetkiwi.ai"
81
+ backend_url: str = "https://api.meetkiwi.ai"
82
82
  api_key: Optional[str] = None
83
83
  log_level: str = "INFO"
84
84
  theme: str = "dark"
@@ -1081,6 +1081,13 @@ async def connect(
1081
1081
  f"{GREEN}OK{RESET}" if success
1082
1082
  else f"{RED}FAILED (exit {exit_code}){RESET}"
1083
1083
  )
1084
+ # Print command output to runtime console
1085
+ if result.get("stdout"):
1086
+ for line in result["stdout"].splitlines():
1087
+ print_cmd_log(request_id, line)
1088
+ if result.get("stderr"):
1089
+ for line in result["stderr"].splitlines():
1090
+ print_cmd_log(request_id, f"{RED}{line}{RESET}")
1084
1091
  print_cmd_log(request_id, status_text, success)
1085
1092
 
1086
1093
  await ws.send(json.dumps({
@@ -0,0 +1,111 @@
1
+ """Attach content modal screen — file browser or URL input."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.screen import ModalScreen
5
+ from textual.widgets import Static, Button, Input
6
+ from textual.containers import Vertical, Horizontal
7
+ from textual.binding import Binding
8
+
9
+
10
+ class AttachContentScreen(ModalScreen[dict]):
11
+ """Modal for attaching content: browse files or add a URL.
12
+
13
+ Returns a dict: {"type": "files"} to open file browser,
14
+ {"type": "url", "url": "<value>"} for a URL, or {} if cancelled.
15
+ """
16
+
17
+ BINDINGS = [
18
+ Binding("escape", "cancel", "Cancel", show=True),
19
+ ]
20
+
21
+ CSS = """
22
+ AttachContentScreen {
23
+ align: center middle;
24
+ }
25
+
26
+ #attach-container {
27
+ width: 60;
28
+ height: auto;
29
+ max-height: 20;
30
+ background: $surface;
31
+ border: solid #00d4d4;
32
+ padding: 1 2;
33
+ }
34
+
35
+ #attach-title {
36
+ width: 100%;
37
+ text-align: center;
38
+ text-style: bold;
39
+ color: #00ffff;
40
+ height: 1;
41
+ margin-bottom: 1;
42
+ }
43
+
44
+ #url-input {
45
+ width: 100%;
46
+ border: solid #00d4d4;
47
+ margin-bottom: 1;
48
+ }
49
+
50
+ #url-input:focus {
51
+ border: solid #00ffff;
52
+ }
53
+
54
+ #attach-buttons {
55
+ height: 3;
56
+ width: 100%;
57
+ align: center middle;
58
+ }
59
+
60
+ .attach-btn {
61
+ margin: 0 1;
62
+ background: #006666;
63
+ color: #00ffff;
64
+ border: solid #00d4d4;
65
+ }
66
+
67
+ .attach-btn:hover {
68
+ background: #008888;
69
+ }
70
+
71
+ #cancel-attach-btn {
72
+ background: $surface;
73
+ color: $text;
74
+ border: solid #444444;
75
+ }
76
+
77
+ #cancel-attach-btn:hover {
78
+ background: #333333;
79
+ }
80
+ """
81
+
82
+ def compose(self) -> ComposeResult:
83
+ with Vertical(id="attach-container"):
84
+ yield Static("Attach Content", id="attach-title")
85
+ yield Input(placeholder="Paste a URL...", id="url-input")
86
+ with Horizontal(id="attach-buttons"):
87
+ yield Button("Add URL", id="add-url-btn", classes="attach-btn")
88
+ yield Button("Browse Files", id="browse-files-btn", classes="attach-btn")
89
+ yield Button("Cancel", id="cancel-attach-btn")
90
+
91
+ def on_button_pressed(self, event: Button.Pressed) -> None:
92
+ btn_id = event.button.id or ""
93
+ if btn_id == "add-url-btn":
94
+ url = self.query_one("#url-input", Input).value.strip()
95
+ if url:
96
+ self.dismiss({"type": "url", "url": url})
97
+ else:
98
+ self.query_one("#url-input", Input).focus()
99
+ elif btn_id == "browse-files-btn":
100
+ self.dismiss({"type": "files"})
101
+ elif btn_id == "cancel-attach-btn":
102
+ self.dismiss({})
103
+
104
+ def on_input_submitted(self, event: Input.Submitted) -> None:
105
+ if event.input.id == "url-input":
106
+ url = event.value.strip()
107
+ if url:
108
+ self.dismiss({"type": "url", "url": url})
109
+
110
+ def action_cancel(self) -> None:
111
+ self.dismiss({})
@@ -5,6 +5,8 @@ from textual.screen import Screen
5
5
  from textual.widgets import Header, Footer, Input, Static, Button
6
6
  from textual.containers import Vertical, VerticalScroll, Horizontal
7
7
  from kiwi_tui.screens.file_browser import FileBrowserScreen
8
+ from kiwi_tui.screens.attach_content import AttachContentScreen
9
+ from kiwi_tui.screens.slash_picker import SlashPickerScreen
8
10
  from kiwi_tui.widgets import ChatInput
9
11
  from textual.worker import Worker, WorkerState
10
12
  from loguru import logger
@@ -12,6 +14,7 @@ import json
12
14
  import asyncio
13
15
  import re
14
16
  import html
17
+ from pathlib import Path
15
18
 
16
19
 
17
20
  class DashboardScreen(Screen):
@@ -28,6 +31,7 @@ class DashboardScreen(Screen):
28
31
  self._last_listed_ids: list[str] = [] # Index for #N shortcuts
29
32
  self._pending_urls: list[str] = [] # File URLs to attach to next message
30
33
  self._metadata: dict = {} # Persistent metadata for all subsequent runs
34
+ self._is_streaming: bool = False # Track streaming state for send/stop toggle
31
35
 
32
36
  CSS = """
33
37
  DashboardScreen {
@@ -108,6 +112,23 @@ class DashboardScreen(Screen):
108
112
  color: #00ffff;
109
113
  }
110
114
 
115
+ #slash-btn {
116
+ width: 5;
117
+ min-width: 5;
118
+ height: 3;
119
+ margin: 0 1 0 0;
120
+ background: #006666;
121
+ color: #00ffff;
122
+ border: solid #00d4d4;
123
+ text-align: center;
124
+ content-align: center middle;
125
+ }
126
+
127
+ #slash-btn:hover {
128
+ background: #008888;
129
+ color: #00ffff;
130
+ }
131
+
111
132
  #send-btn {
112
133
  width: 10;
113
134
  min-width: 10;
@@ -125,6 +146,16 @@ class DashboardScreen(Screen):
125
146
  color: #00ffff;
126
147
  }
127
148
 
149
+ #send-btn.streaming {
150
+ background: #660000;
151
+ color: #ff5555;
152
+ border: solid #ff5555;
153
+ }
154
+
155
+ #send-btn.streaming:hover {
156
+ background: #880000;
157
+ }
158
+
128
159
  .action-row {
129
160
  height: auto;
130
161
  width: 100%;
@@ -167,7 +198,8 @@ class DashboardScreen(Screen):
167
198
  yield Static("", id="pending-files-bar")
168
199
  with Horizontal(id="input-row"):
169
200
  yield Button("+", id="upload-btn", variant="default")
170
- yield ChatInput(placeholder="Message...", id="chat-input")
201
+ yield Button("/", id="slash-btn", variant="default")
202
+ yield ChatInput(placeholder="Message... (@ for files, / for commands)", id="chat-input")
171
203
  yield Button("Send", id="send-btn", variant="default")
172
204
 
173
205
  yield Footer()
@@ -176,6 +208,10 @@ class DashboardScreen(Screen):
176
208
  """Called when screen is mounted."""
177
209
  self.query_one("#chat-input", ChatInput).focus()
178
210
 
211
+ def on_chat_input_at_triggered(self, event: ChatInput.AtTriggered) -> None:
212
+ """Handle @ key — open file browser for workspace file selection."""
213
+ self.app.push_screen(FileBrowserScreen(), callback=self._on_files_selected)
214
+
179
215
  def on_input_submitted(self, event: Input.Submitted) -> None:
180
216
  """Handle message submission."""
181
217
  message = event.value.strip()
@@ -217,7 +253,8 @@ class DashboardScreen(Screen):
217
253
  success, urls, message = self.app.autobots_client.upload_files(args)
218
254
  if success:
219
255
  self._pending_urls.extend(urls)
220
- self.add_message(f"Uploaded {len(urls)} file(s). Attached to your next message.", "info")
256
+ uploaded_names = [Path(u.rsplit("/", 1)[-1]).name for u in urls]
257
+ self.add_message(f"Uploaded: {', '.join(uploaded_names)}. Attached to your next message.", "info")
221
258
  self._update_pending_files_bar()
222
259
  else:
223
260
  self.add_message(f"Upload failed: {message}", "error")
@@ -656,10 +693,22 @@ class DashboardScreen(Screen):
656
693
  btn_id = event.button.id or ""
657
694
 
658
695
  if btn_id == "upload-btn":
659
- self.app.push_screen(FileBrowserScreen(), callback=self._on_files_selected)
696
+ self.app.push_screen(AttachContentScreen(), callback=self._on_attach_result)
697
+ return
698
+
699
+ if btn_id == "slash-btn":
700
+ self.app.push_screen(SlashPickerScreen(), callback=self._on_slash_selected)
660
701
  return
661
702
 
662
703
  if btn_id == "send-btn":
704
+ if self._is_streaming:
705
+ # Stop: cancel active workers
706
+ for worker in self.workers:
707
+ if not worker.is_finished:
708
+ worker.cancel()
709
+ self._set_streaming(False)
710
+ self.add_message("Cancelled active request.", "info")
711
+ return
663
712
  chat_input = self.query_one("#chat-input", ChatInput)
664
713
  message = chat_input.value.strip()
665
714
  if not message:
@@ -733,18 +782,63 @@ class DashboardScreen(Screen):
733
782
  messages.mount(Static(text, classes=css_class))
734
783
  messages.scroll_end(animate=False)
735
784
 
785
+ def _set_streaming(self, streaming: bool) -> None:
786
+ """Toggle the send button between Send and Stop states."""
787
+ self._is_streaming = streaming
788
+ send_btn = self.query_one("#send-btn", Button)
789
+ if streaming:
790
+ send_btn.label = "Stop"
791
+ send_btn.add_class("streaming")
792
+ else:
793
+ send_btn.label = "Send"
794
+ send_btn.remove_class("streaming")
795
+
796
+ def _on_attach_result(self, result: dict) -> None:
797
+ """Callback from AttachContentScreen."""
798
+ if not result:
799
+ self.query_one("#chat-input", ChatInput).focus()
800
+ return
801
+ if result.get("type") == "files":
802
+ self.app.push_screen(FileBrowserScreen(), callback=self._on_files_selected)
803
+ elif result.get("type") == "url":
804
+ url = result["url"]
805
+ self._pending_urls.append(url)
806
+ name = Path(url.rsplit("/", 1)[-1]).name if "/" in url else url
807
+ self.add_message(f"Attached: {name}", "info")
808
+ self._update_pending_files_bar()
809
+ self.query_one("#chat-input", ChatInput).focus()
810
+
811
+ def _on_slash_selected(self, command: str) -> None:
812
+ """Callback from SlashPickerScreen."""
813
+ if not command:
814
+ self.query_one("#chat-input", ChatInput).focus()
815
+ return
816
+ # Commands ending with space need args — insert into input
817
+ if command.endswith(" "):
818
+ chat_input = self.query_one("#chat-input", ChatInput)
819
+ chat_input.value = command
820
+ chat_input.cursor_position = len(command)
821
+ chat_input.focus()
822
+ else:
823
+ # No args needed — execute immediately
824
+ self.handle_slash_command(command)
825
+ self.query_one("#chat-input", ChatInput).focus()
826
+
736
827
  def _on_files_selected(self, file_paths: list[str]) -> None:
737
828
  """Callback from FileBrowserScreen — upload selected files."""
738
829
  if not file_paths:
830
+ self.query_one("#chat-input", ChatInput).focus()
739
831
  return
740
832
  if not hasattr(self.app, 'autobots_client'):
741
833
  self.add_message("Error: Client not initialized", "error")
742
834
  return
743
- self.add_message(f"> Uploading {len(file_paths)} file(s)...", "user")
835
+ names = [Path(p).name for p in file_paths]
836
+ self.add_message(f"> Uploading: {', '.join(names)}", "user")
744
837
  success, urls, message = self.app.autobots_client.upload_files(file_paths)
745
838
  if success:
746
839
  self._pending_urls.extend(urls)
747
- self.add_message(f"Uploaded {len(urls)} file(s). Attached to your next message.", "info")
840
+ uploaded_names = [Path(u.rsplit("/", 1)[-1]).name for u in urls]
841
+ self.add_message(f"Uploaded: {', '.join(uploaded_names)}. Attached to your next message.", "info")
748
842
  self._update_pending_files_bar()
749
843
  else:
750
844
  self.add_message(f"Upload failed: {message}", "error")
@@ -754,7 +848,6 @@ class DashboardScreen(Screen):
754
848
  """Update the pending-files bar to show queued file URLs or hide when empty."""
755
849
  bar = self.query_one("#pending-files-bar", Static)
756
850
  if self._pending_urls:
757
- from pathlib import Path
758
851
  names = [Path(url.rsplit("/", 1)[-1]).name for url in self._pending_urls]
759
852
  bar.update(f"Attached: {', '.join(names)}")
760
853
  else:
@@ -792,6 +885,8 @@ class DashboardScreen(Screen):
792
885
  self.add_message(f"Error starting action: {message}", "error")
793
886
  return
794
887
 
888
+ self._set_streaming(True)
889
+
795
890
  # Check if this is continuing an existing conversation
796
891
  if self.current_run_id and run_id == self.current_run_id:
797
892
  logger.info(f"Continuing conversation with run_id: {run_id}")
@@ -940,17 +1035,20 @@ class DashboardScreen(Screen):
940
1035
  status_task.cancel()
941
1036
  poll_task.cancel()
942
1037
  _remove_status_widget()
1038
+ self._set_streaming(False)
943
1039
  return
944
1040
  except asyncio.TimeoutError:
945
1041
  logger.warning(f"SSE timeout for {run_id}")
946
1042
  poll_task.cancel()
947
1043
  _remove_status_widget()
948
1044
  self.add_message("Action timed out waiting for response", "error")
1045
+ self._set_streaming(False)
949
1046
  return
950
1047
  except asyncio.CancelledError:
951
1048
  logger.info(f"Stream worker cancelled for {run_id}")
952
1049
  poll_task.cancel()
953
1050
  _remove_status_widget()
1051
+ self._set_streaming(False)
954
1052
  return
955
1053
  except Exception as e:
956
1054
  logger.error(f"SSE error for {run_id}: {e}")
@@ -967,6 +1065,8 @@ class DashboardScreen(Screen):
967
1065
  if not got_final_result:
968
1066
  self.add_message("Could not get result. Use /new to start over.", "error")
969
1067
 
1068
+ self._set_streaming(False)
1069
+
970
1070
  def update_streaming_message(self, output: any, widget_ref: any = None) -> Static:
971
1071
  """Update or create a streaming message widget with new output.
972
1072
 
@@ -0,0 +1,95 @@
1
+ """Slash command picker modal screen."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.screen import ModalScreen
5
+ from textual.widgets import Static, Button, OptionList
6
+ from textual.widgets.option_list import Option
7
+ from textual.containers import Vertical
8
+ from textual.binding import Binding
9
+
10
+
11
+ # Commands and their descriptions
12
+ _COMMANDS = [
13
+ ("/help", "Show help"),
14
+ ("/new", "Start new conversation"),
15
+ ("/status", "Show current action & run"),
16
+ ("/cancel", "Cancel active request"),
17
+ ("/actions list", "List recent actions"),
18
+ ("/runs list", "List recent runs"),
19
+ ("/graphs list", "List recent graphs"),
20
+ ("/graph-runs list", "List recent graph runs"),
21
+ ("/use ", "Switch action by ID"),
22
+ ("/continue ", "Continue a run by ID"),
23
+ ("/upload ", "Upload file(s) by path"),
24
+ ("/files", "Show pending files"),
25
+ ("/clear-files", "Clear pending files"),
26
+ ("/login", "Log in"),
27
+ ("/logout", "Log out"),
28
+ ("/metadata", "Show metadata"),
29
+ ("/metadata set ", "Set metadata key"),
30
+ ("/metadata remove ", "Remove metadata key"),
31
+ ("/metadata clear", "Clear all metadata"),
32
+ ("/runtime status", "Runtime status"),
33
+ ("/runtime start", "Start runtime"),
34
+ ("/runtime stop", "Stop runtime"),
35
+ ("/runtime restart", "Restart runtime"),
36
+ ("/runtime list", "List all runtimes"),
37
+ ("/runtime logs", "Show runtime logs"),
38
+ ]
39
+
40
+
41
+ class SlashPickerScreen(ModalScreen[str]):
42
+ """Modal that lets the user pick a slash command.
43
+
44
+ Returns the command string, or empty string if cancelled.
45
+ Commands that don't need arguments (no trailing space) are returned as-is
46
+ for immediate execution.
47
+ """
48
+
49
+ BINDINGS = [
50
+ Binding("escape", "cancel", "Cancel", show=True),
51
+ ]
52
+
53
+ CSS = """
54
+ SlashPickerScreen {
55
+ align: center middle;
56
+ }
57
+
58
+ #slash-container {
59
+ width: 70;
60
+ height: 80%;
61
+ background: $surface;
62
+ border: solid #00d4d4;
63
+ padding: 1 2;
64
+ }
65
+
66
+ #slash-title {
67
+ width: 100%;
68
+ text-align: center;
69
+ text-style: bold;
70
+ color: #00ffff;
71
+ height: 1;
72
+ margin-bottom: 1;
73
+ }
74
+
75
+ #slash-list {
76
+ height: 1fr;
77
+ width: 100%;
78
+ border: solid #444444;
79
+ }
80
+ """
81
+
82
+ def compose(self) -> ComposeResult:
83
+ with Vertical(id="slash-container"):
84
+ yield Static("Select a command", id="slash-title")
85
+ option_list = OptionList(id="slash-list")
86
+ for cmd, desc in _COMMANDS:
87
+ option_list.add_option(Option(f"{cmd:<25} {desc}", id=cmd))
88
+ yield option_list
89
+
90
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
91
+ cmd = event.option.id or ""
92
+ self.dismiss(cmd)
93
+
94
+ def action_cancel(self) -> None:
95
+ self.dismiss("")
@@ -5,6 +5,7 @@ from textual.containers import Container, Vertical, Horizontal
5
5
  from textual.widgets import Static, Button, Label, DataTable, Input
6
6
  from textual.reactive import reactive
7
7
  from textual.suggester import SuggestFromList
8
+ from textual.message import Message
8
9
  from rich.text import Text
9
10
 
10
11
 
@@ -43,10 +44,14 @@ _SLASH_COMMANDS = [
43
44
 
44
45
 
45
46
  class ChatInput(Input):
46
- """Input with command history (up/down arrows) and slash command autocomplete."""
47
+ """Input with command history (up/down arrows), slash command autocomplete, and @ file picker."""
47
48
 
48
49
  _MAX_HISTORY = 200
49
50
 
51
+ class AtTriggered(Message):
52
+ """Posted when user types @ to request file picker."""
53
+ pass
54
+
50
55
  def __init__(self, *args, **kwargs):
51
56
  kwargs.setdefault("suggester", SuggestFromList(_SLASH_COMMANDS, case_sensitive=False))
52
57
  super().__init__(*args, **kwargs)
@@ -69,7 +74,13 @@ class ChatInput(Input):
69
74
  self._draft = ""
70
75
 
71
76
  async def _on_key(self, event) -> None:
72
- if event.key == "up":
77
+ if event.key == "at_sign" or event.character == "@":
78
+ # Intercept @ to open file picker instead of typing it
79
+ event.prevent_default()
80
+ event.stop()
81
+ self.post_message(self.AtTriggered())
82
+ return
83
+ elif event.key == "up":
73
84
  if not self._history:
74
85
  return
75
86
  event.prevent_default()