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.
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/PKG-INFO +1 -1
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/pyproject.toml +1 -1
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_runtime/main.py +1 -1
- kiwi_code-0.0.11/src/kiwi_tui/inline_file_picker.py +163 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/main.py +2 -1
- kiwi_code-0.0.11/src/kiwi_tui/screens/attach_content.py +111 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/dashboard.py +268 -28
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/login.py +54 -31
- kiwi_code-0.0.11/src/kiwi_tui/screens/slash_picker.py +95 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/widgets.py +74 -2
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/uv.lock +1 -1
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/.gitignore +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/.python-version +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/CLAUDE.md +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/Makefile +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/README.md +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/config.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.9 → kiwi_code-0.0.11}/test_hello.py +0 -0
|
@@ -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 = "
|
|
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
|
-
#
|
|
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="
|
|
167
|
-
yield
|
|
168
|
-
with
|
|
169
|
-
yield
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
#
|
|
804
|
-
self.
|
|
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 =
|
|
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 =
|
|
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
|
|
161
|
-
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"
|
|
169
|
-
"
|
|
170
|
-
"
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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)
|
|
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
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|