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.
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/PKG-INFO +1 -1
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/pyproject.toml +1 -1
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/models.py +1 -1
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_runtime/main.py +7 -0
- kiwi_code-0.0.10/src/kiwi_tui/screens/attach_content.py +111 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/dashboard.py +106 -6
- kiwi_code-0.0.10/src/kiwi_tui/screens/slash_picker.py +95 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/widgets.py +13 -2
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/uv.lock +608 -609
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/.gitignore +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/.python-version +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/CLAUDE.md +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/Makefile +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/README.md +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/config.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.8 → kiwi_code-0.0.10}/test_hello.py +0 -0
|
@@ -78,7 +78,7 @@ class LoginCredentials(BaseModel):
|
|
|
78
78
|
|
|
79
79
|
class AppConfig(BaseModel):
|
|
80
80
|
"""Application configuration."""
|
|
81
|
-
backend_url: str = "https://
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 == "
|
|
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()
|