kiwi-code 0.0.23__tar.gz → 0.0.25__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.23 → kiwi_code-0.0.25}/PKG-INFO +1 -1
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/pyproject.toml +1 -1
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/dashboard.py +83 -19
- kiwi_code-0.0.25/src/kiwi_tui/screens/help.py +219 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/slash_picker.py +13 -32
- kiwi_code-0.0.25/src/kiwi_tui/slash_commands.py +60 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/widgets.py +4 -28
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/uv.lock +1 -1
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/.gitignore +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/.python-version +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/CLAUDE.md +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/Makefile +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/README.md +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/test_hello.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/tests/__init__.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/tests/conftest.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.23 → kiwi_code-0.0.25}/tests/test_tui_headless.py +0 -0
|
@@ -6,12 +6,13 @@ from textual.app import ComposeResult
|
|
|
6
6
|
from textual.binding import Binding
|
|
7
7
|
from textual.screen import Screen
|
|
8
8
|
from textual.widgets import Header, Footer, Input, Static, Button, Markdown, LoadingIndicator
|
|
9
|
-
from textual.containers import Vertical, VerticalScroll, Horizontal
|
|
9
|
+
from textual.containers import Vertical, VerticalScroll, Horizontal, Center
|
|
10
10
|
from kiwi_tui.screens.file_browser import FileBrowserScreen
|
|
11
11
|
from kiwi_tui.screens.attach_content import AttachContentScreen
|
|
12
12
|
from kiwi_tui.screens.slash_picker import SlashPickerScreen
|
|
13
13
|
from kiwi_tui.screens.id_picker import IdPickerScreen
|
|
14
14
|
from kiwi_tui.screens.command_result import CommandResultScreen
|
|
15
|
+
from kiwi_tui.screens.help import HelpScreen
|
|
15
16
|
from kiwi_tui.widgets import ChatInput
|
|
16
17
|
from kiwi_tui.inline_file_picker import InlineFilePicker
|
|
17
18
|
from textual.worker import Worker, WorkerState
|
|
@@ -38,6 +39,28 @@ class UserMessageRow(Horizontal):
|
|
|
38
39
|
yield Static(self._text, classes="user-body", markup=False)
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
|
|
43
|
+
class AssistantMessageRow(Horizontal):
|
|
44
|
+
"""A single assistant message rendered with a left dot + markdown body."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, markdown_text: str, *, classes: str = "") -> None:
|
|
47
|
+
super().__init__(classes=classes)
|
|
48
|
+
self._markdown_text = markdown_text
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
# Solid dot marker on the left side of the model response.
|
|
52
|
+
yield Static("●", classes="assistant-dot", markup=False)
|
|
53
|
+
yield Markdown(self._markdown_text, classes="assistant-body")
|
|
54
|
+
|
|
55
|
+
def update_markdown(self, markdown_text: str) -> None:
|
|
56
|
+
"""Update the markdown content in-place."""
|
|
57
|
+
self._markdown_text = markdown_text
|
|
58
|
+
try:
|
|
59
|
+
self.query_one(".assistant-body", Markdown).update(markdown_text)
|
|
60
|
+
except Exception:
|
|
61
|
+
# Defensive: if the widget tree isn't ready or the child was removed.
|
|
62
|
+
pass
|
|
63
|
+
|
|
41
64
|
# Footer hint for inserting a newline differs by OS:
|
|
42
65
|
# - macOS terminals often don't forward modified Enter combos to terminal apps reliably, so we advertise Ctrl+N.
|
|
43
66
|
# - Windows terminals tend to support Shift+Enter for multi-line input.
|
|
@@ -95,7 +118,7 @@ class DashboardScreen(Screen):
|
|
|
95
118
|
|
|
96
119
|
#run-status-bar {
|
|
97
120
|
width: 100%;
|
|
98
|
-
height:
|
|
121
|
+
height: 5;
|
|
99
122
|
background: $background;
|
|
100
123
|
}
|
|
101
124
|
|
|
@@ -129,12 +152,14 @@ class DashboardScreen(Screen):
|
|
|
129
152
|
}
|
|
130
153
|
#copy-run-id {
|
|
131
154
|
width: auto;
|
|
132
|
-
height
|
|
155
|
+
/* Explicit height so the full border (top+bottom) is visible. */
|
|
156
|
+
height: 3;
|
|
133
157
|
padding: 0 1;
|
|
134
|
-
margin: 0
|
|
158
|
+
margin: 0;
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
|
|
162
|
+
|
|
138
163
|
#activity-bar {
|
|
139
164
|
width: 100%;
|
|
140
165
|
height: 1;
|
|
@@ -168,7 +193,8 @@ class DashboardScreen(Screen):
|
|
|
168
193
|
width: 100%;
|
|
169
194
|
height: auto;
|
|
170
195
|
padding: 0 1;
|
|
171
|
-
|
|
196
|
+
/* Add breathing room between all messages (user/assistant/info/error/etc.). */
|
|
197
|
+
margin: 1 0 0 0;
|
|
172
198
|
}
|
|
173
199
|
|
|
174
200
|
.user-message {
|
|
@@ -190,13 +216,25 @@ class DashboardScreen(Screen):
|
|
|
190
216
|
|
|
191
217
|
.assistant-message {
|
|
192
218
|
color: $text;
|
|
193
|
-
margin: 0;
|
|
194
219
|
padding: 0 1;
|
|
195
220
|
}
|
|
196
221
|
|
|
222
|
+
.assistant-dot {
|
|
223
|
+
width: 2;
|
|
224
|
+
padding: 0 1 0 0;
|
|
225
|
+
color: $brand-cyan;
|
|
226
|
+
text-style: bold;
|
|
227
|
+
content-align: center top;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.assistant-body {
|
|
231
|
+
width: 1fr;
|
|
232
|
+
}
|
|
233
|
+
|
|
197
234
|
.assistant-message Markdown {
|
|
198
235
|
margin: 0;
|
|
199
236
|
padding: 0;
|
|
237
|
+
width: 1fr;
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
.error-message {
|
|
@@ -372,8 +410,10 @@ class DashboardScreen(Screen):
|
|
|
372
410
|
with Vertical(id="status-action-col"):
|
|
373
411
|
yield Static("", id="status-action", markup=False)
|
|
374
412
|
with Vertical(id="status-run-col"):
|
|
375
|
-
|
|
376
|
-
|
|
413
|
+
with Center():
|
|
414
|
+
yield Static("", id="status-run", markup=False)
|
|
415
|
+
with Center():
|
|
416
|
+
yield Button("Copy", id="copy-run-id", variant="default")
|
|
377
417
|
|
|
378
418
|
with Vertical(id="input-bar"):
|
|
379
419
|
with Horizontal(id="activity-bar"):
|
|
@@ -534,6 +574,16 @@ class DashboardScreen(Screen):
|
|
|
534
574
|
# If a command is already running, ignore (prevents the "hung" feeling / double execution).
|
|
535
575
|
if self._cmd_running:
|
|
536
576
|
return
|
|
577
|
+
|
|
578
|
+
# /help is a UI-first experience (picker + copy). Don't run it through the CLI dispatcher.
|
|
579
|
+
if command.strip().lower() == "/help":
|
|
580
|
+
self.app.push_screen(HelpScreen())
|
|
581
|
+
try:
|
|
582
|
+
self.query_one("#chat-input", ChatInput).focus()
|
|
583
|
+
except Exception:
|
|
584
|
+
pass
|
|
585
|
+
return
|
|
586
|
+
|
|
537
587
|
self._set_command_running(True, f"Running {command} ...")
|
|
538
588
|
self.run_worker(
|
|
539
589
|
self._handle_slash_command_async(command),
|
|
@@ -1520,8 +1570,8 @@ class DashboardScreen(Screen):
|
|
|
1520
1570
|
messages = self.query_one("#messages", VerticalScroll)
|
|
1521
1571
|
css_class = f"message {msg_type}-message"
|
|
1522
1572
|
if msg_type == "assistant":
|
|
1523
|
-
|
|
1524
|
-
messages.mount(
|
|
1573
|
+
prepared = self._prepare_markdown(text)
|
|
1574
|
+
messages.mount(AssistantMessageRow(prepared, classes=css_class))
|
|
1525
1575
|
elif msg_type == "user":
|
|
1526
1576
|
# Render a styled "YOU" label without enabling markup (keeps input safe).
|
|
1527
1577
|
messages.mount(UserMessageRow(str(text), classes=css_class))
|
|
@@ -1942,7 +1992,11 @@ class DashboardScreen(Screen):
|
|
|
1942
1992
|
|
|
1943
1993
|
self._set_streaming(False)
|
|
1944
1994
|
|
|
1945
|
-
def update_streaming_message(
|
|
1995
|
+
def update_streaming_message(
|
|
1996
|
+
self,
|
|
1997
|
+
output: any,
|
|
1998
|
+
widget_ref: Markdown | AssistantMessageRow | None = None,
|
|
1999
|
+
) -> Markdown | AssistantMessageRow | None:
|
|
1946
2000
|
"""Update or create a streaming message widget with new output.
|
|
1947
2001
|
|
|
1948
2002
|
Returns the widget being updated/created for future updates.
|
|
@@ -1953,21 +2007,24 @@ class DashboardScreen(Screen):
|
|
|
1953
2007
|
|
|
1954
2008
|
messages = self.query_one("#messages", VerticalScroll)
|
|
1955
2009
|
|
|
1956
|
-
|
|
2010
|
+
markdown_text = self._prepare_markdown(text_content)
|
|
1957
2011
|
if widget_ref is None:
|
|
1958
|
-
widget_ref =
|
|
2012
|
+
widget_ref = AssistantMessageRow(markdown_text, classes="message assistant-message")
|
|
1959
2013
|
messages.mount(widget_ref)
|
|
1960
2014
|
else:
|
|
1961
2015
|
try:
|
|
1962
|
-
widget_ref
|
|
2016
|
+
if isinstance(widget_ref, AssistantMessageRow):
|
|
2017
|
+
widget_ref.update_markdown(markdown_text)
|
|
2018
|
+
else:
|
|
2019
|
+
# Backward-compat: if an older ref is a Markdown widget.
|
|
2020
|
+
widget_ref.update(markdown_text)
|
|
1963
2021
|
except Exception as e:
|
|
1964
2022
|
logger.warning(f"Failed to update widget: {e}")
|
|
1965
|
-
widget_ref =
|
|
2023
|
+
widget_ref = AssistantMessageRow(markdown_text, classes="message assistant-message")
|
|
1966
2024
|
messages.mount(widget_ref)
|
|
1967
2025
|
|
|
1968
2026
|
messages.scroll_end(animate=False)
|
|
1969
2027
|
return widget_ref
|
|
1970
|
-
|
|
1971
2028
|
def extract_text_from_output(self, output: any) -> str:
|
|
1972
2029
|
"""Extract text content from output blocks structure and clean it for display."""
|
|
1973
2030
|
if not isinstance(output, dict):
|
|
@@ -2157,10 +2214,17 @@ class DashboardScreen(Screen):
|
|
|
2157
2214
|
messages = self.query_one("#messages", VerticalScroll)
|
|
2158
2215
|
assistant_messages = messages.query(".assistant-message")
|
|
2159
2216
|
|
|
2217
|
+
prepared = self._prepare_markdown(text)
|
|
2218
|
+
|
|
2160
2219
|
if assistant_messages:
|
|
2161
|
-
# Update the last assistant message
|
|
2162
2220
|
last_msg = assistant_messages[-1]
|
|
2163
|
-
last_msg
|
|
2221
|
+
if isinstance(last_msg, AssistantMessageRow):
|
|
2222
|
+
last_msg.update_markdown(prepared)
|
|
2223
|
+
else:
|
|
2224
|
+
# Backward-compat if we have an older Markdown widget in the tree.
|
|
2225
|
+
try:
|
|
2226
|
+
last_msg.update(prepared)
|
|
2227
|
+
except Exception:
|
|
2228
|
+
self.add_message(text, "assistant")
|
|
2164
2229
|
else:
|
|
2165
|
-
# No existing message, create new one
|
|
2166
2230
|
self.add_message(text, "assistant")
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import OptionList, Static
|
|
11
|
+
from textual.widgets.option_list import Option
|
|
12
|
+
|
|
13
|
+
from kiwi_tui.slash_commands import SLASH_COMMANDS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HelpScreen(ModalScreen[None]):
|
|
17
|
+
"""Modal that lists available slash commands and allows copying."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("escape", "close", "Close", show=True),
|
|
21
|
+
Binding("c", "copy", "Copy", show=True),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
CSS = """
|
|
25
|
+
HelpScreen {
|
|
26
|
+
align: center middle;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#help-container {
|
|
30
|
+
width: 96;
|
|
31
|
+
height: 80%;
|
|
32
|
+
background: $surface;
|
|
33
|
+
border: solid $accent;
|
|
34
|
+
/* Reduce top padding so the title/hint sit closer to the border. */
|
|
35
|
+
padding: 0 3 1 3;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#help-title {
|
|
39
|
+
width: 100%;
|
|
40
|
+
text-align: center;
|
|
41
|
+
text-style: bold;
|
|
42
|
+
color: $primary;
|
|
43
|
+
height: 1;
|
|
44
|
+
/* Tighten vertical spacing at the top of the modal. */
|
|
45
|
+
margin-bottom: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#help-hint {
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: 1;
|
|
51
|
+
padding: 0 1;
|
|
52
|
+
color: $text-muted;
|
|
53
|
+
text-style: italic;
|
|
54
|
+
margin-bottom: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#help-list {
|
|
58
|
+
height: 1fr;
|
|
59
|
+
width: 100%;
|
|
60
|
+
border: solid $panel;
|
|
61
|
+
background: $surface;
|
|
62
|
+
scrollbar-size: 1 1;
|
|
63
|
+
/* Keep the list away from the container border for consistent margins. */
|
|
64
|
+
margin: 0 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#help-list > .option-list--option-highlighted {
|
|
68
|
+
background: $primary-background;
|
|
69
|
+
color: $primary;
|
|
70
|
+
text-style: bold;
|
|
71
|
+
}
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self._last_copy_notify_at: float = 0.0
|
|
77
|
+
self._last_highlight_index: int | None = None
|
|
78
|
+
self._highlight_skip_guard: bool = False
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
# Match the UX of the inline @ picker: focus the list and highlight the first command.
|
|
82
|
+
option_list = self.query_one("#help-list", OptionList)
|
|
83
|
+
try:
|
|
84
|
+
for idx, opt in enumerate(option_list.options):
|
|
85
|
+
if not getattr(opt, "disabled", False) and opt.id:
|
|
86
|
+
option_list.highlighted = idx
|
|
87
|
+
break
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
option_list.focus()
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
# Column widths tuned for a typical terminal width; the OptionList will clip if narrower.
|
|
94
|
+
cmd_col_width = 38
|
|
95
|
+
with Vertical(id="help-container"):
|
|
96
|
+
yield Static("Slash Commands", id="help-title")
|
|
97
|
+
yield Static("Enter/click: copy C: copy highlighted Esc: close", id="help-hint")
|
|
98
|
+
|
|
99
|
+
option_list = OptionList(id="help-list")
|
|
100
|
+
|
|
101
|
+
last_category: str | None = None
|
|
102
|
+
for cmd in SLASH_COMMANDS:
|
|
103
|
+
if cmd.category != last_category:
|
|
104
|
+
# Add a blank spacer row between sections for readability.
|
|
105
|
+
if last_category is not None:
|
|
106
|
+
option_list.add_option(Option(Text(""), id=None, disabled=True))
|
|
107
|
+
|
|
108
|
+
last_category = cmd.category
|
|
109
|
+
option_list.add_option(
|
|
110
|
+
Option(Text(f" {cmd.category.upper()}", style="bold cyan"), id=None, disabled=True)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Option id is the command template so we can copy exactly what is shown.
|
|
114
|
+
label = f" {cmd.template:<{cmd_col_width}} {cmd.description}"
|
|
115
|
+
option_list.add_option(Option(Text(label), id=cmd.template))
|
|
116
|
+
|
|
117
|
+
yield option_list
|
|
118
|
+
|
|
119
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
120
|
+
"""Skip spacer/category rows while navigating with arrow keys."""
|
|
121
|
+
if self._highlight_skip_guard:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Track previous position so we can infer direction.
|
|
125
|
+
prev = self._last_highlight_index
|
|
126
|
+
|
|
127
|
+
# Textual versions differ slightly in attribute names.
|
|
128
|
+
idx = getattr(event, "index", None)
|
|
129
|
+
if idx is None:
|
|
130
|
+
idx = getattr(event, "option_index", None)
|
|
131
|
+
if idx is None:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
self._last_highlight_index = idx
|
|
135
|
+
|
|
136
|
+
option = event.option
|
|
137
|
+
if option.id and not getattr(option, "disabled", False):
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
option_list = event.option_list
|
|
141
|
+
try:
|
|
142
|
+
direction = -1 if (prev is not None and idx < prev) else 1
|
|
143
|
+
n = len(option_list.options)
|
|
144
|
+
self._highlight_skip_guard = True
|
|
145
|
+
cur = idx
|
|
146
|
+
while 0 <= cur < n:
|
|
147
|
+
cur += direction
|
|
148
|
+
if not (0 <= cur < n):
|
|
149
|
+
break
|
|
150
|
+
opt = option_list.options[cur]
|
|
151
|
+
if opt.id and not getattr(opt, "disabled", False):
|
|
152
|
+
option_list.highlighted = cur
|
|
153
|
+
self._last_highlight_index = cur
|
|
154
|
+
break
|
|
155
|
+
finally:
|
|
156
|
+
self._highlight_skip_guard = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _notify_once(self, msg: str, *, severity: str) -> None:
|
|
161
|
+
now = time.monotonic()
|
|
162
|
+
if now - self._last_copy_notify_at < 1.0:
|
|
163
|
+
return
|
|
164
|
+
self._last_copy_notify_at = now
|
|
165
|
+
self.app.notify(msg, severity=severity)
|
|
166
|
+
|
|
167
|
+
def _copy_to_clipboard(self, text: str) -> bool:
|
|
168
|
+
"""Copy text to the system clipboard. Returns True if it likely worked."""
|
|
169
|
+
copied = False
|
|
170
|
+
|
|
171
|
+
# Prefer native OS clipboard tools (more reliable in terminal apps).
|
|
172
|
+
try:
|
|
173
|
+
if sys.platform == "darwin":
|
|
174
|
+
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
|
|
175
|
+
copied = True
|
|
176
|
+
elif sys.platform.startswith("linux"):
|
|
177
|
+
subprocess.run(
|
|
178
|
+
["xclip", "-selection", "clipboard"],
|
|
179
|
+
input=text.encode("utf-8"),
|
|
180
|
+
check=True,
|
|
181
|
+
)
|
|
182
|
+
copied = True
|
|
183
|
+
elif sys.platform.startswith("win"):
|
|
184
|
+
subprocess.run(["clip"], input=text.encode("utf-8"), check=True)
|
|
185
|
+
copied = True
|
|
186
|
+
except Exception:
|
|
187
|
+
copied = False
|
|
188
|
+
|
|
189
|
+
# Fallback to Textual's clipboard integration.
|
|
190
|
+
if not copied:
|
|
191
|
+
try:
|
|
192
|
+
self.app.copy_to_clipboard(text)
|
|
193
|
+
copied = True
|
|
194
|
+
except Exception:
|
|
195
|
+
copied = False
|
|
196
|
+
|
|
197
|
+
return copied
|
|
198
|
+
|
|
199
|
+
def _copy_selected(self, template: str) -> None:
|
|
200
|
+
if not template.strip():
|
|
201
|
+
return
|
|
202
|
+
if self._copy_to_clipboard(template):
|
|
203
|
+
self._notify_once(f"Copied: {template}", severity="information")
|
|
204
|
+
else:
|
|
205
|
+
self._notify_once("Failed to copy to clipboard", severity="error")
|
|
206
|
+
|
|
207
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
208
|
+
template = event.option.id
|
|
209
|
+
if template:
|
|
210
|
+
self._copy_selected(str(template))
|
|
211
|
+
|
|
212
|
+
def action_copy(self) -> None:
|
|
213
|
+
option_list = self.query_one("#help-list", OptionList)
|
|
214
|
+
opt = option_list.highlighted_option
|
|
215
|
+
if opt and opt.id:
|
|
216
|
+
self._copy_selected(str(opt.id))
|
|
217
|
+
|
|
218
|
+
def action_close(self) -> None:
|
|
219
|
+
self.dismiss(None)
|
|
@@ -1,45 +1,26 @@
|
|
|
1
1
|
"""Slash command picker modal screen."""
|
|
2
2
|
|
|
3
3
|
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.containers import Vertical
|
|
4
6
|
from textual.screen import ModalScreen
|
|
5
|
-
from textual.widgets import
|
|
7
|
+
from textual.widgets import OptionList, Static
|
|
6
8
|
from textual.widgets.option_list import Option
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
+
|
|
10
|
+
from kiwi_tui.slash_commands import SLASH_COMMANDS
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
# Commands and their descriptions
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
("/connect-cli", "Tell the agent to connect to the local CLI"),
|
|
33
|
-
("/show-logs", "Show local CLI (runtime) logs"),
|
|
34
|
-
]
|
|
13
|
+
# Commands and their descriptions (single source of truth).
|
|
14
|
+
# We show the template in the picker, and return insert_text on selection.
|
|
15
|
+
_COMMANDS = [(c.insert_text, c.template, c.description) for c in SLASH_COMMANDS]
|
|
35
16
|
|
|
36
17
|
|
|
37
18
|
class SlashPickerScreen(ModalScreen[str]):
|
|
38
19
|
"""Modal that lets the user pick a slash command.
|
|
39
20
|
|
|
40
|
-
Returns the command string, or empty string if cancelled.
|
|
41
|
-
Commands that don't need arguments
|
|
42
|
-
|
|
21
|
+
Returns the command string (insert_text), or empty string if cancelled.
|
|
22
|
+
Commands that don't need arguments are returned as-is for immediate execution.
|
|
23
|
+
Commands that expect args end with a trailing space ("/use ").
|
|
43
24
|
"""
|
|
44
25
|
|
|
45
26
|
BINDINGS = [
|
|
@@ -79,8 +60,8 @@ class SlashPickerScreen(ModalScreen[str]):
|
|
|
79
60
|
with Vertical(id="slash-container"):
|
|
80
61
|
yield Static("Select a command", id="slash-title")
|
|
81
62
|
option_list = OptionList(id="slash-list")
|
|
82
|
-
for
|
|
83
|
-
option_list.add_option(Option(f"{
|
|
63
|
+
for insert_text, template, desc in _COMMANDS:
|
|
64
|
+
option_list.add_option(Option(f"{template:<32} {desc}", id=insert_text))
|
|
84
65
|
yield option_list
|
|
85
66
|
|
|
86
67
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass(frozen=True)
|
|
4
|
+
class SlashCommand:
|
|
5
|
+
category: str
|
|
6
|
+
template: str
|
|
7
|
+
description: str
|
|
8
|
+
insert_text: str
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def needs_args(self) -> bool:
|
|
12
|
+
return self.insert_text.endswith(" ")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# NOTE: Keep this aligned with DashboardScreen's TUI command handler.
|
|
16
|
+
SLASH_COMMANDS: list[SlashCommand] = [
|
|
17
|
+
# Session
|
|
18
|
+
SlashCommand("Session", "/help", "Show all slash commands", "/help"),
|
|
19
|
+
SlashCommand("Session", "/new", "Start new conversation", "/new"),
|
|
20
|
+
SlashCommand("Session", "/status", "Show current action & run", "/status"),
|
|
21
|
+
SlashCommand("Session", "/cancel", "Cancel active request", "/cancel"),
|
|
22
|
+
SlashCommand("Session", "/use <action_id>", "Switch to a different action", "/use "),
|
|
23
|
+
SlashCommand("Session", "/continue <run_id>", "Continue an existing run", "/continue "),
|
|
24
|
+
|
|
25
|
+
# Files
|
|
26
|
+
SlashCommand("Files", "/upload <path> [path2 ...]", "Upload file(s) and attach to next message", "/upload "),
|
|
27
|
+
SlashCommand("Files", "/files", "Show pending attachments", "/files"),
|
|
28
|
+
SlashCommand("Files", "/clear-files", "Clear pending attachments", "/clear-files"),
|
|
29
|
+
|
|
30
|
+
# Auth
|
|
31
|
+
SlashCommand("Auth", "/login", "Log in", "/login"),
|
|
32
|
+
SlashCommand("Auth", "/logout", "Log out", "/logout"),
|
|
33
|
+
|
|
34
|
+
# Metadata
|
|
35
|
+
SlashCommand("Metadata", "/metadata", "Show effective metadata", "/metadata"),
|
|
36
|
+
SlashCommand("Metadata", "/metadata set <key> <value>", "Set a metadata field", "/metadata set "),
|
|
37
|
+
SlashCommand("Metadata", "/metadata remove <key>", "Remove a metadata field", "/metadata remove "),
|
|
38
|
+
SlashCommand("Metadata", "/metadata clear", "Clear all metadata overrides", "/metadata clear"),
|
|
39
|
+
|
|
40
|
+
# Runtime (local CLI agent)
|
|
41
|
+
SlashCommand("Runtime", "/connect-cli", "Tell the agent to connect to the local CLI", "/connect-cli"),
|
|
42
|
+
SlashCommand("Runtime", "/show-logs", "Show local CLI (runtime) logs", "/show-logs"),
|
|
43
|
+
SlashCommand("Runtime", "/runtime", "Runtime commands (currently disabled)", "/runtime"),
|
|
44
|
+
|
|
45
|
+
# Query (API)
|
|
46
|
+
SlashCommand("Query", "/actions list", "List recent actions", "/actions list"),
|
|
47
|
+
SlashCommand("Query", "/actions get <id>", "Get an action by id", "/actions get "),
|
|
48
|
+
SlashCommand("Query", "/runs list", "List recent runs", "/runs list"),
|
|
49
|
+
SlashCommand("Query", "/runs get <id>", "Get a run by id", "/runs get "),
|
|
50
|
+
SlashCommand("Query", "/graphs list", "List recent graphs", "/graphs list"),
|
|
51
|
+
SlashCommand("Query", "/graphs get <id>", "Get a graph by id", "/graphs get "),
|
|
52
|
+
SlashCommand("Query", "/graph-runs list", "List recent graph runs", "/graph-runs list"),
|
|
53
|
+
SlashCommand("Query", "/graph-runs get <id>", "Get a graph run by id", "/graph-runs get "),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def autocomplete_commands() -> list[str]:
|
|
58
|
+
"""Return strings used for chat input autocomplete."""
|
|
59
|
+
# We keep the insert_text version for consistency with the actual command parser.
|
|
60
|
+
return [c.insert_text for c in SLASH_COMMANDS]
|
|
@@ -12,34 +12,10 @@ from textual.widgets._text_area import TextArea
|
|
|
12
12
|
from rich.text import Text
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
# Slash commands for autocomplete
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"/actions get ",
|
|
20
|
-
"/runs list",
|
|
21
|
-
"/runs get ",
|
|
22
|
-
"/graphs list",
|
|
23
|
-
"/graphs get ",
|
|
24
|
-
"/graph-runs list",
|
|
25
|
-
"/graph-runs get ",
|
|
26
|
-
"/use ",
|
|
27
|
-
"/continue ",
|
|
28
|
-
"/new",
|
|
29
|
-
"/status",
|
|
30
|
-
"/cancel",
|
|
31
|
-
"/upload ",
|
|
32
|
-
"/files",
|
|
33
|
-
"/clear-files",
|
|
34
|
-
"/login",
|
|
35
|
-
"/logout",
|
|
36
|
-
"/metadata",
|
|
37
|
-
"/metadata set ",
|
|
38
|
-
"/metadata remove ",
|
|
39
|
-
"/metadata clear",
|
|
40
|
-
"/connect-cli",
|
|
41
|
-
"/show-logs",
|
|
42
|
-
]
|
|
15
|
+
# Slash commands for autocomplete (single source of truth)
|
|
16
|
+
from kiwi_tui.slash_commands import autocomplete_commands
|
|
17
|
+
|
|
18
|
+
_SLASH_COMMANDS = autocomplete_commands()
|
|
43
19
|
|
|
44
20
|
|
|
45
21
|
class ChatInput(TextArea):
|
|
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
|
|
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
|