kiwi-code 0.0.9__tar.gz → 0.0.11__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 (33) hide show
  1. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_runtime/main.py +1 -1
  4. kiwi_code-0.0.11/src/kiwi_tui/inline_file_picker.py +163 -0
  5. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/main.py +2 -1
  6. kiwi_code-0.0.11/src/kiwi_tui/screens/attach_content.py +111 -0
  7. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/dashboard.py +268 -28
  8. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/login.py +54 -31
  9. kiwi_code-0.0.11/src/kiwi_tui/screens/slash_picker.py +95 -0
  10. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/widgets.py +74 -2
  11. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/uv.lock +1 -1
  12. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/.github/workflows/publish.yml +0 -0
  13. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/.gitignore +0 -0
  14. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/.python-version +0 -0
  15. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/CLAUDE.md +0 -0
  16. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/Makefile +0 -0
  17. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/README.md +0 -0
  18. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/__init__.py +0 -0
  19. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/auth.py +0 -0
  20. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/cli.py +0 -0
  21. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/client.py +0 -0
  22. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/commands.py +0 -0
  23. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/config.py +0 -0
  24. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/logger.py +0 -0
  25. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/models.py +0 -0
  26. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/runtime_manager.py +0 -0
  27. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_runtime/__init__.py +0 -0
  28. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_runtime/__main__.py +0 -0
  29. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/__init__.py +0 -0
  30. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/__init__.py +0 -0
  31. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/file_browser.py +0 -0
  32. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  33. {kiwi_code-0.0.9 → kiwi_code-0.0.11}/test_hello.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.9
3
+ Version: 0.0.11
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.9"
3
+ version = "0.0.11"
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"
@@ -117,7 +117,7 @@ TEXT = [
117
117
  "╚═╝ ╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝╚═╝",
118
118
  ]
119
119
 
120
- TAGLINE = "Terminal Agent for AI-Powered Automation"
120
+ TAGLINE = "Runtime for AI-Powered Automation"
121
121
 
122
122
 
123
123
  def print_banner():
@@ -0,0 +1,163 @@
1
+ """Inline file picker dropdown that appears when user types @ in chat input."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Vertical
8
+ from textual.message import Message
9
+ from textual.widget import Widget
10
+ from textual.widgets import OptionList, Static
11
+ from textual.widgets.option_list import Option
12
+ from rich.text import Text
13
+
14
+
15
+ class InlineFilePicker(Widget):
16
+ """Dropdown file picker that shows above the input bar when @ is typed.
17
+
18
+ Displays files/dirs in CWD with '+' prefix, filterable by typing after @.
19
+ """
20
+
21
+ DEFAULT_CSS = """
22
+ InlineFilePicker {
23
+ dock: bottom;
24
+ height: auto;
25
+ max-height: 14;
26
+ width: 100%;
27
+ background: $surface;
28
+ border-top: solid #444444;
29
+ padding-bottom: 2;
30
+ display: none;
31
+ }
32
+
33
+ InlineFilePicker.visible {
34
+ display: block;
35
+ }
36
+
37
+ InlineFilePicker #file-option-list {
38
+ height: auto;
39
+ max-height: 12;
40
+ width: 100%;
41
+ background: $surface;
42
+ scrollbar-size: 1 1;
43
+ border: none;
44
+ }
45
+
46
+ InlineFilePicker #file-option-list > .option-list--option-highlighted {
47
+ background: $primary-background;
48
+ color: #00ffff;
49
+ text-style: bold;
50
+ }
51
+
52
+ InlineFilePicker #file-picker-header {
53
+ height: 1;
54
+ width: 100%;
55
+ padding: 0 1;
56
+ color: #888888;
57
+ text-style: italic;
58
+ }
59
+ """
60
+
61
+ class FileSelected(Message):
62
+ """Posted when a file is selected from the picker."""
63
+ def __init__(self, path: str) -> None:
64
+ super().__init__()
65
+ self.path = path
66
+
67
+ class Cancelled(Message):
68
+ """Posted when the picker is dismissed without selection."""
69
+ pass
70
+
71
+ def __init__(self, **kwargs):
72
+ super().__init__(**kwargs)
73
+ self._base_path = os.getcwd()
74
+ self._all_entries: list[tuple[str, str]] = [] # (display_name, full_path)
75
+
76
+ def compose(self) -> ComposeResult:
77
+ yield Static("", id="file-picker-header")
78
+ yield OptionList(id="file-option-list")
79
+
80
+ def show_picker(self, filter_text: str = "") -> None:
81
+ """Show the picker with files from CWD, optionally filtered."""
82
+ self._base_path = os.getcwd()
83
+ self._refresh_entries(filter_text)
84
+ self.add_class("visible")
85
+
86
+ def hide_picker(self) -> None:
87
+ """Hide the picker."""
88
+ self.remove_class("visible")
89
+
90
+ @property
91
+ def is_visible(self) -> bool:
92
+ return self.has_class("visible")
93
+
94
+ def _refresh_entries(self, filter_text: str = "") -> None:
95
+ """Refresh the file list, optionally filtered."""
96
+ option_list = self.query_one("#file-option-list", OptionList)
97
+ header = self.query_one("#file-picker-header", Static)
98
+ option_list.clear_options()
99
+ self._all_entries = []
100
+
101
+ try:
102
+ entries = sorted(Path(self._base_path).iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
103
+ except PermissionError:
104
+ header.update(" Permission denied")
105
+ return
106
+
107
+ filter_lower = filter_text.lower()
108
+
109
+ for entry in entries:
110
+ name = entry.name
111
+ if filter_lower and filter_lower not in name.lower():
112
+ continue
113
+
114
+ if entry.is_dir():
115
+ display = f"+ {name}/"
116
+ else:
117
+ display = f"+ {name}"
118
+
119
+ self._all_entries.append((display, str(entry)))
120
+ option_list.add_option(Option(Text(display, style=""), id=str(entry)))
121
+
122
+ if self._all_entries:
123
+ header.update(f" Files in {self._base_path}")
124
+ option_list.highlighted = 0
125
+ else:
126
+ header.update(f" No matches in {self._base_path}")
127
+
128
+ def filter(self, text: str) -> None:
129
+ """Filter entries by text."""
130
+ self._refresh_entries(text)
131
+
132
+ def _handle_selection(self, full_path: str) -> None:
133
+ """Handle selection: navigate into directories, select files."""
134
+ p = Path(full_path)
135
+ if p.is_dir():
136
+ self._base_path = str(p)
137
+ self._refresh_entries("")
138
+ else:
139
+ self.post_message(self.FileSelected(full_path))
140
+
141
+ def select_highlighted(self) -> None:
142
+ """Select the currently highlighted entry."""
143
+ option_list = self.query_one("#file-option-list", OptionList)
144
+ if option_list.highlighted is not None and self._all_entries:
145
+ idx = option_list.highlighted
146
+ if 0 <= idx < len(self._all_entries):
147
+ _, full_path = self._all_entries[idx]
148
+ self._handle_selection(full_path)
149
+
150
+ def move_highlight(self, delta: int) -> None:
151
+ """Move the highlight up or down."""
152
+ option_list = self.query_one("#file-option-list", OptionList)
153
+ if option_list.highlighted is None:
154
+ option_list.highlighted = 0
155
+ else:
156
+ new_idx = option_list.highlighted + delta
157
+ if 0 <= new_idx < len(self._all_entries):
158
+ option_list.highlighted = new_idx
159
+
160
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
161
+ """Handle click on an option."""
162
+ if event.option.id:
163
+ self._handle_selection(event.option.id)
@@ -3,6 +3,7 @@
3
3
  import collections
4
4
  import os
5
5
  import subprocess
6
+ from pathlib import Path
6
7
 
7
8
  from textual.app import App
8
9
  from textual.binding import Binding
@@ -40,7 +41,7 @@ class AutobotsTUI(App):
40
41
  """
41
42
 
42
43
  TITLE = "Kiwi Code"
43
- SUB_TITLE = ""
44
+ SUB_TITLE = str("~" / Path.cwd().relative_to(Path.home())) if Path.cwd().is_relative_to(Path.home()) else str(Path.cwd())
44
45
 
45
46
  SCREENS = {
46
47
  "login": LoginScreen,
@@ -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({})
@@ -2,22 +2,26 @@
2
2
 
3
3
  from textual.app import ComposeResult
4
4
  from textual.screen import Screen
5
- from textual.widgets import Header, Footer, Input, Static, Button
5
+ from textual.widgets import Header, Footer, Input, Static, Button, Markdown
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
11
+ from kiwi_tui.inline_file_picker import InlineFilePicker
9
12
  from textual.worker import Worker, WorkerState
10
13
  from loguru import logger
11
14
  import json
12
15
  import asyncio
13
16
  import re
14
17
  import html
18
+ from pathlib import Path
15
19
 
16
20
 
17
21
  class DashboardScreen(Screen):
18
22
  """Main dashboard screen showing overview and stats."""
19
23
 
20
- DEFAULT_ACTION_ID = "69295914ccbcd104b2e7446f"
24
+ DEFAULT_ACTION_ID = "69c2180355a89324a9926bc6"
21
25
 
22
26
  def __init__(self, *args, **kwargs):
23
27
  """Initialize the dashboard screen."""
@@ -28,6 +32,7 @@ class DashboardScreen(Screen):
28
32
  self._last_listed_ids: list[str] = [] # Index for #N shortcuts
29
33
  self._pending_urls: list[str] = [] # File URLs to attach to next message
30
34
  self._metadata: dict = {} # Persistent metadata for all subsequent runs
35
+ self._is_streaming: bool = False # Track streaming state for send/stop toggle
31
36
 
32
37
  CSS = """
33
38
  DashboardScreen {
@@ -52,6 +57,13 @@ class DashboardScreen(Screen):
52
57
 
53
58
  .assistant-message {
54
59
  color: $text;
60
+ margin: 0;
61
+ padding: 0 1;
62
+ }
63
+
64
+ .assistant-message Markdown {
65
+ margin: 0;
66
+ padding: 0;
55
67
  }
56
68
 
57
69
  .error-message {
@@ -63,8 +75,14 @@ class DashboardScreen(Screen):
63
75
  text-style: italic;
64
76
  }
65
77
 
66
- #input-bar {
78
+ #bottom-area {
67
79
  dock: bottom;
80
+ height: auto;
81
+ max-height: 24;
82
+ width: 100%;
83
+ }
84
+
85
+ #input-bar {
68
86
  height: 7;
69
87
  width: 100%;
70
88
  padding: 1 1 2 1;
@@ -108,6 +126,23 @@ class DashboardScreen(Screen):
108
126
  color: #00ffff;
109
127
  }
110
128
 
129
+ #slash-btn {
130
+ width: 5;
131
+ min-width: 5;
132
+ height: 3;
133
+ margin: 0 1 0 0;
134
+ background: #006666;
135
+ color: #00ffff;
136
+ border: solid #00d4d4;
137
+ text-align: center;
138
+ content-align: center middle;
139
+ }
140
+
141
+ #slash-btn:hover {
142
+ background: #008888;
143
+ color: #00ffff;
144
+ }
145
+
111
146
  #send-btn {
112
147
  width: 10;
113
148
  min-width: 10;
@@ -125,6 +160,16 @@ class DashboardScreen(Screen):
125
160
  color: #00ffff;
126
161
  }
127
162
 
163
+ #send-btn.streaming {
164
+ background: #660000;
165
+ color: #ff5555;
166
+ border: solid #ff5555;
167
+ }
168
+
169
+ #send-btn.streaming:hover {
170
+ background: #880000;
171
+ }
172
+
128
173
  .action-row {
129
174
  height: auto;
130
175
  width: 100%;
@@ -163,12 +208,15 @@ class DashboardScreen(Screen):
163
208
  with VerticalScroll(id="messages"):
164
209
  pass
165
210
 
166
- with Vertical(id="input-bar"):
167
- yield Static("", id="pending-files-bar")
168
- with Horizontal(id="input-row"):
169
- yield Button("+", id="upload-btn", variant="default")
170
- yield ChatInput(placeholder="Message...", id="chat-input")
171
- yield Button("Send", id="send-btn", variant="default")
211
+ with Vertical(id="bottom-area"):
212
+ yield InlineFilePicker(id="inline-file-picker")
213
+ with Vertical(id="input-bar"):
214
+ yield Static("", id="pending-files-bar")
215
+ with Horizontal(id="input-row"):
216
+ yield Button("+", id="upload-btn", variant="default")
217
+ yield Button("/", id="slash-btn", variant="default")
218
+ yield ChatInput(placeholder="Message... (@ for files, / for commands)", id="chat-input")
219
+ yield Button("Send", id="send-btn", variant="default")
172
220
 
173
221
  yield Footer()
174
222
 
@@ -176,6 +224,73 @@ class DashboardScreen(Screen):
176
224
  """Called when screen is mounted."""
177
225
  self.query_one("#chat-input", ChatInput).focus()
178
226
 
227
+ def on_chat_input_at_triggered(self, event: ChatInput.AtTriggered) -> None:
228
+ """Handle @ key — open inline file picker."""
229
+ chat_input = self.query_one("#chat-input", ChatInput)
230
+ picker = self.query_one("#inline-file-picker", InlineFilePicker)
231
+ chat_input.picker_active = True
232
+ picker.show_picker("")
233
+
234
+ def on_chat_input_at_filter_changed(self, event: ChatInput.AtFilterChanged) -> None:
235
+ """Update inline file picker filter."""
236
+ picker = self.query_one("#inline-file-picker", InlineFilePicker)
237
+ if picker.is_visible:
238
+ picker.filter(event.filter_text)
239
+
240
+ def on_chat_input_at_picker_navigate(self, event: ChatInput.AtPickerNavigate) -> None:
241
+ """Navigate inline file picker."""
242
+ picker = self.query_one("#inline-file-picker", InlineFilePicker)
243
+ if picker.is_visible:
244
+ picker.move_highlight(event.delta)
245
+
246
+ def on_chat_input_at_picker_select(self, event: ChatInput.AtPickerSelect) -> None:
247
+ """Select file from inline picker."""
248
+ picker = self.query_one("#inline-file-picker", InlineFilePicker)
249
+ if picker.is_visible:
250
+ picker.select_highlighted()
251
+
252
+ def on_chat_input_at_picker_cancel(self, event: ChatInput.AtPickerCancel) -> None:
253
+ """Cancel inline file picker."""
254
+ self._close_inline_picker()
255
+
256
+ def on_inline_file_picker_file_selected(self, event: InlineFilePicker.FileSelected) -> None:
257
+ """Handle file selection from inline picker — upload the file."""
258
+ self._close_inline_picker()
259
+ file_path = event.path
260
+ self._on_files_selected([file_path])
261
+
262
+ def on_inline_file_picker_cancelled(self, event: InlineFilePicker.Cancelled) -> None:
263
+ """Handle inline picker cancellation."""
264
+ self._close_inline_picker()
265
+
266
+ def _close_inline_picker(self) -> None:
267
+ """Close the inline file picker and clean up @ text from input."""
268
+ picker = self.query_one("#inline-file-picker", InlineFilePicker)
269
+ chat_input = self.query_one("#chat-input", ChatInput)
270
+ picker.hide_picker()
271
+ chat_input.picker_active = False
272
+ # Remove the @... text from the input
273
+ val = chat_input.value
274
+ at_pos = val.rfind("@")
275
+ if at_pos != -1:
276
+ chat_input.value = val[:at_pos]
277
+ chat_input.focus()
278
+
279
+ def on_input_changed(self, event: Input.Changed) -> None:
280
+ """Track text changes to update inline file picker filter."""
281
+ chat_input = self.query_one("#chat-input", ChatInput)
282
+ if not chat_input.picker_active:
283
+ return
284
+ val = event.value
285
+ at_pos = val.rfind("@")
286
+ if at_pos == -1:
287
+ # @ was deleted — close picker
288
+ self._close_inline_picker()
289
+ return
290
+ filter_text = val[at_pos + 1:]
291
+ picker = self.query_one("#inline-file-picker", InlineFilePicker)
292
+ picker.filter(filter_text)
293
+
179
294
  def on_input_submitted(self, event: Input.Submitted) -> None:
180
295
  """Handle message submission."""
181
296
  message = event.value.strip()
@@ -217,7 +332,8 @@ class DashboardScreen(Screen):
217
332
  success, urls, message = self.app.autobots_client.upload_files(args)
218
333
  if success:
219
334
  self._pending_urls.extend(urls)
220
- self.add_message(f"Uploaded {len(urls)} file(s). Attached to your next message.", "info")
335
+ uploaded_names = [Path(u.rsplit("/", 1)[-1]).name for u in urls]
336
+ self.add_message(f"Uploaded: {', '.join(uploaded_names)}. Attached to your next message.", "info")
221
337
  self._update_pending_files_bar()
222
338
  else:
223
339
  self.add_message(f"Upload failed: {message}", "error")
@@ -360,6 +476,8 @@ class DashboardScreen(Screen):
360
476
  return
361
477
  self.current_run_id = run_id
362
478
  self.add_message("\n".join(["Continuing run:"] + lines), "info")
479
+ # Load conversation history
480
+ self._load_conversation_history(run_id)
363
481
  return
364
482
 
365
483
  if cmd == "/runtime":
@@ -656,10 +774,22 @@ class DashboardScreen(Screen):
656
774
  btn_id = event.button.id or ""
657
775
 
658
776
  if btn_id == "upload-btn":
659
- self.app.push_screen(FileBrowserScreen(), callback=self._on_files_selected)
777
+ self.app.push_screen(AttachContentScreen(), callback=self._on_attach_result)
778
+ return
779
+
780
+ if btn_id == "slash-btn":
781
+ self.app.push_screen(SlashPickerScreen(), callback=self._on_slash_selected)
660
782
  return
661
783
 
662
784
  if btn_id == "send-btn":
785
+ if self._is_streaming:
786
+ # Stop: cancel active workers
787
+ for worker in self.workers:
788
+ if not worker.is_finished:
789
+ worker.cancel()
790
+ self._set_streaming(False)
791
+ self.add_message("Cancelled active request.", "info")
792
+ return
663
793
  chat_input = self.query_one("#chat-input", ChatInput)
664
794
  message = chat_input.value.strip()
665
795
  if not message:
@@ -709,6 +839,7 @@ class DashboardScreen(Screen):
709
839
  return
710
840
  self.current_run_id = run_id
711
841
  self.add_message("\n".join(["Continuing run:"] + lines), "info")
842
+ self._load_conversation_history(run_id)
712
843
 
713
844
  elif btn_id.startswith("graph-run-select-"):
714
845
  # Continue this graph run
@@ -726,25 +857,79 @@ class DashboardScreen(Screen):
726
857
 
727
858
  self.query_one("#chat-input", ChatInput).focus()
728
859
 
860
+ @staticmethod
861
+ def _prepare_markdown(text: str) -> str:
862
+ """Convert image markdown to links since terminals can't render images."""
863
+ return re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', r'[\1](\2)', text)
864
+
729
865
  def add_message(self, text: str, msg_type: str = "assistant") -> None:
730
866
  """Add a message to the chat."""
731
867
  messages = self.query_one("#messages", VerticalScroll)
732
868
  css_class = f"message {msg_type}-message"
733
- messages.mount(Static(text, classes=css_class))
869
+ if msg_type == "assistant":
870
+ widget = Markdown(self._prepare_markdown(text), classes=css_class)
871
+ else:
872
+ widget = Static(text, classes=css_class)
873
+ messages.mount(widget)
734
874
  messages.scroll_end(animate=False)
735
875
 
876
+ def _set_streaming(self, streaming: bool) -> None:
877
+ """Toggle the send button between Send and Stop states."""
878
+ self._is_streaming = streaming
879
+ send_btn = self.query_one("#send-btn", Button)
880
+ if streaming:
881
+ send_btn.label = "Stop"
882
+ send_btn.add_class("streaming")
883
+ else:
884
+ send_btn.label = "Send"
885
+ send_btn.remove_class("streaming")
886
+
887
+ def _on_attach_result(self, result: dict) -> None:
888
+ """Callback from AttachContentScreen."""
889
+ if not result:
890
+ self.query_one("#chat-input", ChatInput).focus()
891
+ return
892
+ if result.get("type") == "files":
893
+ self.app.push_screen(FileBrowserScreen(), callback=self._on_files_selected)
894
+ elif result.get("type") == "url":
895
+ url = result["url"]
896
+ self._pending_urls.append(url)
897
+ name = Path(url.rsplit("/", 1)[-1]).name if "/" in url else url
898
+ self.add_message(f"Attached: {name}", "info")
899
+ self._update_pending_files_bar()
900
+ self.query_one("#chat-input", ChatInput).focus()
901
+
902
+ def _on_slash_selected(self, command: str) -> None:
903
+ """Callback from SlashPickerScreen."""
904
+ if not command:
905
+ self.query_one("#chat-input", ChatInput).focus()
906
+ return
907
+ # Commands ending with space need args — insert into input
908
+ if command.endswith(" "):
909
+ chat_input = self.query_one("#chat-input", ChatInput)
910
+ chat_input.value = command
911
+ chat_input.cursor_position = len(command)
912
+ chat_input.focus()
913
+ else:
914
+ # No args needed — execute immediately
915
+ self.handle_slash_command(command)
916
+ self.query_one("#chat-input", ChatInput).focus()
917
+
736
918
  def _on_files_selected(self, file_paths: list[str]) -> None:
737
919
  """Callback from FileBrowserScreen — upload selected files."""
738
920
  if not file_paths:
921
+ self.query_one("#chat-input", ChatInput).focus()
739
922
  return
740
923
  if not hasattr(self.app, 'autobots_client'):
741
924
  self.add_message("Error: Client not initialized", "error")
742
925
  return
743
- self.add_message(f"> Uploading {len(file_paths)} file(s)...", "user")
926
+ names = [Path(p).name for p in file_paths]
927
+ self.add_message(f"> Uploading: {', '.join(names)}", "user")
744
928
  success, urls, message = self.app.autobots_client.upload_files(file_paths)
745
929
  if success:
746
930
  self._pending_urls.extend(urls)
747
- self.add_message(f"Uploaded {len(urls)} file(s). Attached to your next message.", "info")
931
+ uploaded_names = [Path(u.rsplit("/", 1)[-1]).name for u in urls]
932
+ self.add_message(f"Uploaded: {', '.join(uploaded_names)}. Attached to your next message.", "info")
748
933
  self._update_pending_files_bar()
749
934
  else:
750
935
  self.add_message(f"Upload failed: {message}", "error")
@@ -754,7 +939,6 @@ class DashboardScreen(Screen):
754
939
  """Update the pending-files bar to show queued file URLs or hide when empty."""
755
940
  bar = self.query_one("#pending-files-bar", Static)
756
941
  if self._pending_urls:
757
- from pathlib import Path
758
942
  names = [Path(url.rsplit("/", 1)[-1]).name for url in self._pending_urls]
759
943
  bar.update(f"Attached: {', '.join(names)}")
760
944
  else:
@@ -771,37 +955,46 @@ class DashboardScreen(Screen):
771
955
 
772
956
  def run_action_with_polling(self, user_input: str) -> None:
773
957
  """Run action and stream results via SSE."""
958
+ # Kick off as a worker so the UI renders the user message first
959
+ self.run_worker(self._run_action_worker(user_input), exclusive=True, group="stream")
960
+
961
+ async def _run_action_worker(self, user_input: str) -> None:
962
+ """Async worker: send the action request then stream results."""
774
963
  client = self.app.autobots_client
775
964
 
776
- # Send the message to the action
777
- # If continuing a conversation, pass the current_run_id as action_result_id
778
965
  # Attach any pending file URLs and clear them
779
966
  urls = self._pending_urls.copy()
780
967
  self._pending_urls.clear()
781
968
  self._update_pending_files_bar()
782
969
 
783
- success, run_id, message = client.run_action_async(
784
- self.current_action_id,
785
- user_input,
786
- action_result_id=self.current_run_id,
787
- urls=urls if urls else None,
788
- metadata=self._metadata if self._metadata else None,
970
+ # Run the blocking HTTP call in a thread so the UI stays responsive
971
+ loop = asyncio.get_event_loop()
972
+ success, run_id, message = await loop.run_in_executor(
973
+ None,
974
+ lambda: client.run_action_async(
975
+ self.current_action_id,
976
+ user_input,
977
+ action_result_id=self.current_run_id,
978
+ urls=urls if urls else None,
979
+ metadata=self._metadata if self._metadata else None,
980
+ ),
789
981
  )
790
982
 
791
983
  if not success:
792
984
  self.add_message(f"Error starting action: {message}", "error")
793
985
  return
794
986
 
987
+ self._set_streaming(True)
988
+
795
989
  # Check if this is continuing an existing conversation
796
990
  if self.current_run_id and run_id == self.current_run_id:
797
991
  logger.info(f"Continuing conversation with run_id: {run_id}")
798
992
  else:
799
- # New conversation started
800
993
  self.current_run_id = run_id
801
994
  logger.info(f"Started new conversation with run_id: {run_id}")
802
995
 
803
- # exclusive=True cancels any previous stream worker entirely
804
- self.run_worker(self.stream_results(run_id), exclusive=True, group="stream")
996
+ # Continue with streaming in the same worker
997
+ await self.stream_results(run_id)
805
998
 
806
999
  async def stream_results(self, run_id: str) -> None:
807
1000
  """Stream action status and display final result from results array.
@@ -940,17 +1133,20 @@ class DashboardScreen(Screen):
940
1133
  status_task.cancel()
941
1134
  poll_task.cancel()
942
1135
  _remove_status_widget()
1136
+ self._set_streaming(False)
943
1137
  return
944
1138
  except asyncio.TimeoutError:
945
1139
  logger.warning(f"SSE timeout for {run_id}")
946
1140
  poll_task.cancel()
947
1141
  _remove_status_widget()
948
1142
  self.add_message("Action timed out waiting for response", "error")
1143
+ self._set_streaming(False)
949
1144
  return
950
1145
  except asyncio.CancelledError:
951
1146
  logger.info(f"Stream worker cancelled for {run_id}")
952
1147
  poll_task.cancel()
953
1148
  _remove_status_widget()
1149
+ self._set_streaming(False)
954
1150
  return
955
1151
  except Exception as e:
956
1152
  logger.error(f"SSE error for {run_id}: {e}")
@@ -967,6 +1163,8 @@ class DashboardScreen(Screen):
967
1163
  if not got_final_result:
968
1164
  self.add_message("Could not get result. Use /new to start over.", "error")
969
1165
 
1166
+ self._set_streaming(False)
1167
+
970
1168
  def update_streaming_message(self, output: any, widget_ref: any = None) -> Static:
971
1169
  """Update or create a streaming message widget with new output.
972
1170
 
@@ -978,15 +1176,16 @@ class DashboardScreen(Screen):
978
1176
 
979
1177
  messages = self.query_one("#messages", VerticalScroll)
980
1178
 
1179
+ text_content = self._prepare_markdown(text_content)
981
1180
  if widget_ref is None:
982
- widget_ref = Static(text_content, classes="message assistant-message", markup=False, expand=True)
1181
+ widget_ref = Markdown(text_content, classes="message assistant-message")
983
1182
  messages.mount(widget_ref)
984
1183
  else:
985
1184
  try:
986
1185
  widget_ref.update(text_content)
987
1186
  except Exception as e:
988
1187
  logger.warning(f"Failed to update widget: {e}")
989
- widget_ref = Static(text_content, classes="message assistant-message", markup=False, expand=True)
1188
+ widget_ref = Markdown(text_content, classes="message assistant-message")
990
1189
  messages.mount(widget_ref)
991
1190
 
992
1191
  messages.scroll_end(animate=False)
@@ -1033,6 +1232,47 @@ class DashboardScreen(Screen):
1033
1232
 
1034
1233
  return text.strip()
1035
1234
 
1235
+ def _load_conversation_history(self, run_id: str) -> None:
1236
+ """Load and display conversation history for a continued run."""
1237
+ if not hasattr(self.app, 'autobots_client'):
1238
+ logger.warning("No autobots_client, cannot load history")
1239
+ return
1240
+ success, result, msg = self.app.autobots_client.get_action_result(run_id)
1241
+ logger.info(f"Load history: success={success}, msg={msg}")
1242
+ if not success or not result:
1243
+ return
1244
+
1245
+ logger.info(f"Result keys: {result.keys()}")
1246
+ action_doc = result.get("result", {})
1247
+ if not isinstance(action_doc, dict):
1248
+ logger.warning(f"result['result'] is not a dict: {type(action_doc)}")
1249
+ return
1250
+ logger.info(f"ActionDoc keys: {action_doc.keys()}")
1251
+ results_list = action_doc.get("results", [])
1252
+ logger.info(f"results_list type={type(results_list)}, len={len(results_list) if isinstance(results_list, list) else 'N/A'}")
1253
+ if not isinstance(results_list, list) or not results_list:
1254
+ return
1255
+
1256
+ self.add_message("--- Conversation History ---", "info")
1257
+ for i, item in enumerate(results_list):
1258
+ if not isinstance(item, dict):
1259
+ logger.warning(f"Result item {i} is not a dict: {type(item)}")
1260
+ continue
1261
+ logger.info(f"Result item {i} keys: {item.keys()}")
1262
+ # Show user input
1263
+ input_data = item.get("input")
1264
+ if input_data:
1265
+ input_text = self.extract_text_from_output(input_data) if isinstance(input_data, dict) else str(input_data)
1266
+ if input_text:
1267
+ self.add_message(f"You: {input_text}", "user")
1268
+ # Show assistant output
1269
+ output_data = item.get("output")
1270
+ if output_data:
1271
+ output_text = self.extract_text_from_output(output_data) if isinstance(output_data, dict) else str(output_data)
1272
+ if output_text:
1273
+ self.add_message(output_text, "assistant")
1274
+ self.add_message("--- End of History ---", "info")
1275
+
1036
1276
  def display_final_result(self, result: dict) -> None:
1037
1277
  """Display the final result from results array, showing only the latest output.
1038
1278
 
@@ -8,6 +8,8 @@ from textual.validation import Length
8
8
  from textual.message import Message
9
9
  from loguru import logger
10
10
 
11
+ from pathlib import Path
12
+
11
13
  from kiwi_cli.models import LoginCredentials
12
14
  from kiwi_tui.widgets import ActionButton
13
15
 
@@ -45,21 +47,6 @@ class LoginScreen(Screen):
45
47
  width: auto;
46
48
  }
47
49
 
48
- #logo-text-container {
49
- margin-left: 2;
50
- height: auto;
51
- }
52
-
53
- #logo-text {
54
- color: #5fffff;
55
- text-style: bold;
56
- height: auto;
57
- }
58
-
59
- #logo-subtitle {
60
- color: #5fffff;
61
- height: auto;
62
- }
63
50
 
64
51
  .divider {
65
52
  width: 100%;
@@ -157,22 +144,49 @@ class LoginScreen(Screen):
157
144
 
158
145
  with Container(id="login-container"):
159
146
  # Logo Section
160
- with Horizontal(id="logo-container"):
161
- logo_ascii = (
162
- " #\n"
163
- " #####\n"
164
- " #########\n"
165
- " ###########\n"
166
- "#############\n"
167
- " ###########\n"
168
- " #########\n"
169
- " #####\n"
170
- " #"
171
- )
172
- yield Static(logo_ascii, id="logo")
173
- with Vertical(id="logo-text-container"):
174
- yield Static("KIWI AI", id="logo-text")
175
- yield Static("Terminal Agent for AI-Powered Automation", id="logo-subtitle")
147
+ with Vertical(id="logo-container"):
148
+ logo_lines = [
149
+ " ▄██▄ ",
150
+ " ████ ",
151
+ " ▄██▄▄ ████ ▄▄██▄ ",
152
+ " ▀█████▄ █▀▀█ ▄█████▀ ",
153
+ " ▀███▀▀█ ████ █▀▀███▀ ",
154
+ " ▀█▄ █ ████ █ ▄█▀ ",
155
+ " ▀▀▄██████▄▀▀ ",
156
+ "▄██████▀▀█▄██████████▄█▀▀██████▄",
157
+ "▀██████▄▄█▀██████████▀█▄▄██████▀",
158
+ " ▄▄▀██████▀▄▄ ",
159
+ " ▄█▀ █ ████ █ ▀█▄ ",
160
+ " ▄███▄▄█ ████ █▄▄███▄ ",
161
+ " ▄█████▀ █▄▄█ ▀█████▄ ",
162
+ " ▀██▀▀ ████ ▀▀██▀ ",
163
+ " ████ ",
164
+ " ▀██▀ ",
165
+ ]
166
+ text_lines = [
167
+ "██╗ ██╗██╗██╗ ██╗██╗ █████╗ ██╗",
168
+ "██║ ██╔╝██║██║ ██║██║ ██╔══██╗██║",
169
+ "█████╔╝ ██║██║ █╗ ██║██║ ███████║██║",
170
+ "██╔═██╗ ██║██║███╗██║██║ ██╔══██║██║",
171
+ "██║ ██╗██║╚███╔███╔╝██║ ██║ ██║██║",
172
+ "╚═╝ ╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝╚═╝",
173
+ ]
174
+ tagline = "Think fast. Code smarter. Automate everything."
175
+ gap = " "
176
+ logo_w = 33
177
+ text_start = 5
178
+ banner_rows = []
179
+ for i, logo_line in enumerate(logo_lines):
180
+ padded_logo = logo_line.ljust(logo_w)
181
+ text_idx = i - text_start
182
+ if 0 <= text_idx < len(text_lines):
183
+ row = f"{padded_logo}{gap}{text_lines[text_idx]}"
184
+ elif text_idx == len(text_lines) + 1:
185
+ row = f"{padded_logo}{gap}{tagline}"
186
+ else:
187
+ row = padded_logo
188
+ banner_rows.append(row)
189
+ yield Static("\n".join(banner_rows), id="logo")
176
190
 
177
191
  yield Static("", classes="divider")
178
192
 
@@ -185,6 +199,15 @@ class LoginScreen(Screen):
185
199
  yield Static("> Mode:", classes="info-label")
186
200
  yield Static("restricted", classes="info-value")
187
201
 
202
+ cwd = Path.cwd()
203
+ try:
204
+ display_path = "~" / cwd.relative_to(Path.home())
205
+ except ValueError:
206
+ display_path = cwd
207
+ with Horizontal(classes="info-row"):
208
+ yield Static("> Working Dir:", classes="info-label")
209
+ yield Static(str(display_path), classes="info-value")
210
+
188
211
  yield Static("Authentication", classes="section-header")
189
212
 
190
213
  with Horizontal(classes="form-group"):
@@ -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,16 +44,41 @@ _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 open inline file picker."""
53
+ pass
54
+
55
+ class AtFilterChanged(Message):
56
+ """Posted when the filter text after @ changes."""
57
+ def __init__(self, filter_text: str) -> None:
58
+ super().__init__()
59
+ self.filter_text = filter_text
60
+
61
+ class AtPickerNavigate(Message):
62
+ """Posted when user presses up/down while picker is open."""
63
+ def __init__(self, delta: int) -> None:
64
+ super().__init__()
65
+ self.delta = delta
66
+
67
+ class AtPickerSelect(Message):
68
+ """Posted when user presses Enter while picker is open."""
69
+ pass
70
+
71
+ class AtPickerCancel(Message):
72
+ """Posted when user presses Escape while picker is open."""
73
+ pass
74
+
50
75
  def __init__(self, *args, **kwargs):
51
76
  kwargs.setdefault("suggester", SuggestFromList(_SLASH_COMMANDS, case_sensitive=False))
52
77
  super().__init__(*args, **kwargs)
53
78
  self._history: list[str] = []
54
79
  self._history_index: int = -1
55
80
  self._draft: str = "" # saves current text when browsing history
81
+ self.picker_active: bool = False # True when inline file picker is showing
56
82
 
57
83
  def record(self, value: str) -> None:
58
84
  """Add a submitted value to history."""
@@ -68,8 +94,54 @@ class ChatInput(Input):
68
94
  self._history_index = -1
69
95
  self._draft = ""
70
96
 
97
+ def _get_at_filter(self) -> str | None:
98
+ """Extract filter text after the last '@' if picker is active."""
99
+ if not self.picker_active:
100
+ return None
101
+ val = self.value
102
+ at_pos = val.rfind("@")
103
+ if at_pos == -1:
104
+ return None
105
+ return val[at_pos + 1:]
106
+
71
107
  async def _on_key(self, event) -> None:
72
- if event.key == "up":
108
+ if self.picker_active:
109
+ # While picker is open, intercept navigation keys
110
+ if event.key == "up":
111
+ event.prevent_default()
112
+ event.stop()
113
+ self.post_message(self.AtPickerNavigate(-1))
114
+ return
115
+ elif event.key == "down":
116
+ event.prevent_default()
117
+ event.stop()
118
+ self.post_message(self.AtPickerNavigate(1))
119
+ return
120
+ elif event.key == "enter":
121
+ event.prevent_default()
122
+ event.stop()
123
+ self.post_message(self.AtPickerSelect())
124
+ return
125
+ elif event.key == "escape":
126
+ event.prevent_default()
127
+ event.stop()
128
+ self.post_message(self.AtPickerCancel())
129
+ return
130
+ elif event.key == "tab":
131
+ event.prevent_default()
132
+ event.stop()
133
+ self.post_message(self.AtPickerSelect())
134
+ return
135
+ # For other keys (typing), let them through and we'll update filter via on_changed
136
+ await super()._on_key(event)
137
+ return
138
+
139
+ if event.key == "at_sign" or event.character == "@":
140
+ # Let @ be typed into the input, then trigger the picker
141
+ await super()._on_key(event)
142
+ self.post_message(self.AtTriggered())
143
+ return
144
+ elif event.key == "up":
73
145
  if not self._history:
74
146
  return
75
147
  event.prevent_default()
@@ -319,7 +319,7 @@ wheels = [
319
319
 
320
320
  [[package]]
321
321
  name = "kiwi-code"
322
- version = "0.0.9"
322
+ version = "0.0.11"
323
323
  source = { editable = "." }
324
324
  dependencies = [
325
325
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes