stcode 0.0.1a0__py3-none-any.whl
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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
stcode/cli/ui/app.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Application lifecycle and the app-owned daemon stream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from contextlib import aclosing
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, ClassVar
|
|
9
|
+
|
|
10
|
+
from textual import work
|
|
11
|
+
from textual.app import App
|
|
12
|
+
from textual.binding import Binding, BindingType
|
|
13
|
+
|
|
14
|
+
from stcode.cli import labels
|
|
15
|
+
from stcode.cli.logic.connection import Connection
|
|
16
|
+
from stcode.cli.logic.state import ClientState
|
|
17
|
+
from stcode.cli.models import StartOptions, ThemePreference, UiPrefs
|
|
18
|
+
from stcode.cli.ui.screens.chat.screen import ChatScreen
|
|
19
|
+
from stcode.cli.ui.screens.connect import ConnectScreen
|
|
20
|
+
from stcode.cli.ui.screens.trust import TrustScreen
|
|
21
|
+
from stcode.cli.ui.theme import THEMES, TerminalMode, theme_name_for
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StcodeApp(App[None]):
|
|
25
|
+
TITLE = "stcode"
|
|
26
|
+
ENABLE_COMMAND_PALETTE = False
|
|
27
|
+
CSS_PATH = "app.tcss"
|
|
28
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
29
|
+
Binding("ctrl+q", "quit", "Quit", priority=True)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
connection: Connection,
|
|
35
|
+
state: ClientState,
|
|
36
|
+
options: StartOptions,
|
|
37
|
+
prefs: UiPrefs,
|
|
38
|
+
prefs_path: Path,
|
|
39
|
+
*,
|
|
40
|
+
save_preferences: Callable[[], None],
|
|
41
|
+
restart: bool = False,
|
|
42
|
+
terminal_mode: TerminalMode = "dark",
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.connection, self.state, self.options = connection, state, options
|
|
46
|
+
self.prefs = prefs
|
|
47
|
+
self._save_preferences = save_preferences
|
|
48
|
+
self._restart = restart
|
|
49
|
+
self._terminal_mode = terminal_mode
|
|
50
|
+
self.chat = ChatScreen(
|
|
51
|
+
connection,
|
|
52
|
+
state,
|
|
53
|
+
options,
|
|
54
|
+
prefs,
|
|
55
|
+
prefs_path,
|
|
56
|
+
request_connection=self.connect,
|
|
57
|
+
choose_theme=self.set_theme,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def get_default_screen(self) -> ChatScreen:
|
|
61
|
+
return self.chat
|
|
62
|
+
|
|
63
|
+
def on_mount(self) -> None:
|
|
64
|
+
for theme in THEMES:
|
|
65
|
+
self.register_theme(theme)
|
|
66
|
+
self.theme = theme_name_for(self.prefs.theme, detected=self._terminal_mode)
|
|
67
|
+
self._start()
|
|
68
|
+
|
|
69
|
+
@work
|
|
70
|
+
async def _start(self) -> None:
|
|
71
|
+
if not self.options.daemonless and not self.prefs.is_trusted(self.options.cwd):
|
|
72
|
+
if not await self.push_screen_wait(TrustScreen(self.options.cwd)):
|
|
73
|
+
self.exit()
|
|
74
|
+
return
|
|
75
|
+
self.prefs.trust(self.options.cwd)
|
|
76
|
+
self._save_preferences()
|
|
77
|
+
self.connect()
|
|
78
|
+
|
|
79
|
+
def set_theme(self, preference: ThemePreference) -> None:
|
|
80
|
+
self.prefs.theme = preference
|
|
81
|
+
self.theme = theme_name_for(preference, detected=self._terminal_mode)
|
|
82
|
+
self._save_preferences()
|
|
83
|
+
|
|
84
|
+
async def on_unmount(self) -> None:
|
|
85
|
+
with contextlib.suppress(Exception):
|
|
86
|
+
await self.connection.aclose()
|
|
87
|
+
|
|
88
|
+
@work(exclusive=True, group="daemon")
|
|
89
|
+
async def connect(self, ask: bool = False) -> None:
|
|
90
|
+
"""Keep the pump alive across modal screens; reconnect cancels its predecessor."""
|
|
91
|
+
await self.connection.close_client()
|
|
92
|
+
self.state.session_id = ""
|
|
93
|
+
try:
|
|
94
|
+
if ask or self.options.daemonless:
|
|
95
|
+
reason = labels.CONNECT_INTRO
|
|
96
|
+
while True:
|
|
97
|
+
address = await self.push_screen_wait(
|
|
98
|
+
ConnectScreen(self.connection.address, reason=reason)
|
|
99
|
+
)
|
|
100
|
+
if address is None:
|
|
101
|
+
raise ConnectionError(labels.DAEMONLESS_CANCELLED)
|
|
102
|
+
try:
|
|
103
|
+
client = await self.connection.open(address=address)
|
|
104
|
+
except OSError:
|
|
105
|
+
reason = labels.CONNECT_RETRY
|
|
106
|
+
continue
|
|
107
|
+
break
|
|
108
|
+
else:
|
|
109
|
+
client = await self.connection.open(restart=self._restart)
|
|
110
|
+
self._restart = False
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
self.chat.presenter.error(labels.daemon_failed(exc))
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
self.chat.presenter.note(
|
|
116
|
+
labels.daemon_connected(self.connection.address, self.connection.started)
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
self.state.config = await client.get_config()
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
self.chat.presenter.error(labels.daemon_failed(exc))
|
|
122
|
+
try:
|
|
123
|
+
info = await self.chat.sessions.open()
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
self.chat.presenter.error(labels.daemon_failed(exc))
|
|
126
|
+
return
|
|
127
|
+
self.chat._adopt_session(info)
|
|
128
|
+
self.chat.presenter.note(labels.session_started(info.get("cwd", "")))
|
|
129
|
+
if not self.state.has_credential:
|
|
130
|
+
self.chat._open_settings(first_run=True)
|
|
131
|
+
self.chat._load_info()
|
|
132
|
+
|
|
133
|
+
# Close the iterator on cancellation while the event loop still exists.
|
|
134
|
+
async with aclosing(client.events()) as frames:
|
|
135
|
+
async for frame in frames:
|
|
136
|
+
self.chat.handle_frame(frame)
|
stcode/cli/ui/app.tcss
ADDED
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Banner — the ASCII wordmark, centred on an empty transcript and gone once there is a
|
|
3
|
+
conversation.
|
|
4
|
+
|
|
5
|
+
It is a greeting, not furniture: the screen exists for the conversation, and a wordmark
|
|
6
|
+
that stays pinned to the top of a long one is six rows of nothing. `Banner.follow()`
|
|
7
|
+
takes the decision from the transcript rather than counting messages — "does it scroll
|
|
8
|
+
yet" is the honest test of "is this long", at any terminal height.
|
|
9
|
+
|
|
10
|
+
Lines are padded to a common width at import time rather than carrying trailing spaces
|
|
11
|
+
in the source (linters strip those), because `text-align: center` centers each line
|
|
12
|
+
independently and ragged lines render as a crooked wordmark.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from textual.events import Resize
|
|
18
|
+
from textual.widgets import Static
|
|
19
|
+
|
|
20
|
+
_FULL_LINES = [
|
|
21
|
+
"███████╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗",
|
|
22
|
+
"██╔════╝╚══██╔══╝██╔════╝██╔═══██╗██╔══██╗██╔════╝",
|
|
23
|
+
"███████╗ ██║ ██║ ██║ ██║██║ ██║█████╗",
|
|
24
|
+
"╚════██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝",
|
|
25
|
+
"███████║ ██║ ╚██████╗╚██████╔╝██████╔╝███████╗",
|
|
26
|
+
"╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_COMPACT_LINES = [
|
|
30
|
+
"╔═╗╔╦╗╔═╗╔═╗╔╦╗╔═╗",
|
|
31
|
+
"╚═╗ ║ ║ ║ ║ ║║║╣",
|
|
32
|
+
"╚═╝ ╩ ╚═╝╚═╝═╩╝╚═╝",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _padded(lines: list[str]) -> str:
|
|
37
|
+
width = max(len(line) for line in lines)
|
|
38
|
+
return "\n".join(line.ljust(width) for line in lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
BANNER_FULL = _padded(_FULL_LINES)
|
|
42
|
+
BANNER_COMPACT = _padded(_COMPACT_LINES)
|
|
43
|
+
|
|
44
|
+
BANNER_FULL_WIDTH = max(len(line) for line in _FULL_LINES)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def banner_for_width(width: int) -> str:
|
|
48
|
+
"""The widest wordmark that fits, so a narrow terminal degrades instead of clipping."""
|
|
49
|
+
return BANNER_FULL if width >= BANNER_FULL_WIDTH + 4 else BANNER_COMPACT
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Banner(Static):
|
|
53
|
+
"""The wordmark, swapped for a compact one when the terminal gets narrow."""
|
|
54
|
+
|
|
55
|
+
DEFAULT_CSS = """
|
|
56
|
+
Banner {
|
|
57
|
+
height: auto;
|
|
58
|
+
padding: 1 2 1 2;
|
|
59
|
+
color: $primary;
|
|
60
|
+
text-align: center;
|
|
61
|
+
}
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, *, id: str | None = None) -> None: # noqa: A002 — textual's name
|
|
65
|
+
super().__init__("", id=id, markup=False)
|
|
66
|
+
|
|
67
|
+
def on_mount(self) -> None:
|
|
68
|
+
self._render_for(self.size.width)
|
|
69
|
+
|
|
70
|
+
def on_resize(self, event: Resize) -> None:
|
|
71
|
+
self._render_for(event.size.width)
|
|
72
|
+
|
|
73
|
+
def follow(self, *, transcript_scrolls: bool) -> None:
|
|
74
|
+
"""Hide once there is more transcript than screen."""
|
|
75
|
+
self.display = not transcript_scrolls
|
|
76
|
+
|
|
77
|
+
def _render_for(self, width: int) -> None:
|
|
78
|
+
self.update(banner_for_width(width))
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cards — a list above the input, never a modal over the transcript.
|
|
3
|
+
|
|
4
|
+
Everything that used to be a dialog is one of these: the help, the command list, files
|
|
5
|
+
to mention, the mode and theme and effort pickers, an approval request, a question.
|
|
6
|
+
|
|
7
|
+
**Why not a modal.** A modal covers the transcript, which is the thing you need in
|
|
8
|
+
order to answer — *why* is it running this? A card leaves it on screen, and the turn
|
|
9
|
+
keeps streaming behind it.
|
|
10
|
+
|
|
11
|
+
**The input keeps focus.** A card is a list the keyboard drives from outside: the app
|
|
12
|
+
forwards ↑↓ and enter to it and everything else goes into the prompt, which is what
|
|
13
|
+
makes "type more to filter" work without a second text field to tab into.
|
|
14
|
+
|
|
15
|
+
`token_trigger` is the other half — which of `/` and `@` is being typed right now, and
|
|
16
|
+
what the filter is. `?` is not in there: it is a bare toggle on an empty input, not the
|
|
17
|
+
start of a token.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Iterable, Sequence
|
|
23
|
+
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
from textual.app import ComposeResult
|
|
26
|
+
from textual.containers import Vertical
|
|
27
|
+
from textual.message import Message
|
|
28
|
+
from textual.widgets import OptionList, Static
|
|
29
|
+
from textual.widgets.option_list import Option
|
|
30
|
+
|
|
31
|
+
from stcode.cli.labels import Row
|
|
32
|
+
from stcode.cli.ui.theme import brand_text
|
|
33
|
+
|
|
34
|
+
VISIBLE_ROWS = 6
|
|
35
|
+
"""How many rows a card shows before it scrolls. Six fits above the input on a short
|
|
36
|
+
terminal and is enough to see that there is more."""
|
|
37
|
+
|
|
38
|
+
LABEL_WIDTH = 13
|
|
39
|
+
"""Wide enough for `shift+enter`, so the descriptions line up in one column."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Card(Vertical):
|
|
43
|
+
"""A titled list above the input. At most one is open at a time."""
|
|
44
|
+
|
|
45
|
+
DEFAULT_CSS = """
|
|
46
|
+
Card {
|
|
47
|
+
height: auto;
|
|
48
|
+
padding: 0 1;
|
|
49
|
+
border: none;
|
|
50
|
+
background: $surface;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Card OptionList {
|
|
54
|
+
height: auto;
|
|
55
|
+
max-height: 6;
|
|
56
|
+
background: $surface;
|
|
57
|
+
border: none;
|
|
58
|
+
padding: 0;
|
|
59
|
+
scrollbar-size-vertical: 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.card-body {
|
|
63
|
+
height: auto;
|
|
64
|
+
max-height: 11;
|
|
65
|
+
color: $text-muted;
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
class Chosen(Message):
|
|
70
|
+
"""A row was picked. `kind` says which card, `token` carries a correlation id."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, kind: str, value: str, token: str = "") -> None:
|
|
73
|
+
super().__init__()
|
|
74
|
+
self.kind = kind
|
|
75
|
+
self.value = value
|
|
76
|
+
self.token = token
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
kind: str,
|
|
81
|
+
rows: Sequence[Row],
|
|
82
|
+
*,
|
|
83
|
+
title: str = "",
|
|
84
|
+
footer: str = "",
|
|
85
|
+
body: str = "",
|
|
86
|
+
token: str = "",
|
|
87
|
+
highlighted: int = 0,
|
|
88
|
+
selectable: bool = True,
|
|
89
|
+
dismissable: bool = True,
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(classes="card")
|
|
92
|
+
self.kind = kind
|
|
93
|
+
self.token = token
|
|
94
|
+
"""Correlation id for the request this card answers — an `execution_id` for an
|
|
95
|
+
approval or a question, empty for everything else."""
|
|
96
|
+
|
|
97
|
+
self.selectable = selectable
|
|
98
|
+
self.dismissable = dismissable
|
|
99
|
+
"""Whether ++escape++ and ++backspace++ may close this card.
|
|
100
|
+
|
|
101
|
+
False for the two that are **asking** rather than offering: an approval and a
|
|
102
|
+
question. By the time one is on screen its request has already left the queue,
|
|
103
|
+
and the turn behind it is parked on an answer only this card can give — so
|
|
104
|
+
dismissing it left the session waiting on a reply with nowhere to come from, a
|
|
105
|
+
hung agent with a working keyboard. The keystroke that did it was usually
|
|
106
|
+
++backspace++ on an already-empty input, aimed at a character that was not there.
|
|
107
|
+
|
|
108
|
+
Deny is the way out, and it costs one turn: the tool is told the user declined.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
self._title = title or kind
|
|
112
|
+
self._footer = footer
|
|
113
|
+
self._body = body
|
|
114
|
+
self._rows = list(rows)
|
|
115
|
+
self._highlighted = highlighted
|
|
116
|
+
self._list = OptionList()
|
|
117
|
+
|
|
118
|
+
# ---- layout ----
|
|
119
|
+
|
|
120
|
+
def compose(self) -> ComposeResult:
|
|
121
|
+
title = Text(
|
|
122
|
+
self._title, style=f"bold {brand_text(self.app.current_theme.dark)}"
|
|
123
|
+
)
|
|
124
|
+
header = Static(title, markup=False)
|
|
125
|
+
header.add_class("card-title")
|
|
126
|
+
yield header
|
|
127
|
+
if self._body:
|
|
128
|
+
body = Static(Text(self._body), markup=False)
|
|
129
|
+
body.add_class("card-body")
|
|
130
|
+
yield body
|
|
131
|
+
yield self._list
|
|
132
|
+
if self._footer:
|
|
133
|
+
footer = Static(Text(self._footer, style="dim italic"), markup=False)
|
|
134
|
+
footer.add_class("card-footer")
|
|
135
|
+
yield footer
|
|
136
|
+
|
|
137
|
+
def on_mount(self) -> None:
|
|
138
|
+
self._fill(self._rows)
|
|
139
|
+
|
|
140
|
+
# ---- content ----
|
|
141
|
+
|
|
142
|
+
def show(self, rows: Sequence[Row]) -> None:
|
|
143
|
+
"""Replace the rows — what filtering does on every keystroke."""
|
|
144
|
+
self._rows = list(rows)
|
|
145
|
+
self._fill(self._rows)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def rows(self) -> list[Row]:
|
|
149
|
+
return list(self._rows)
|
|
150
|
+
|
|
151
|
+
def _fill(self, rows: Iterable[Row]) -> None:
|
|
152
|
+
self._list.clear_options()
|
|
153
|
+
options = [
|
|
154
|
+
Option(self._render_row(label, detail), id=label) for label, detail in rows
|
|
155
|
+
]
|
|
156
|
+
self._list.add_options(options)
|
|
157
|
+
if options:
|
|
158
|
+
self._list.highlighted = min(self._highlighted, len(options) - 1)
|
|
159
|
+
# Height in rows, so the card is as tall as it needs to be up to the cap, and
|
|
160
|
+
# the transcript keeps everything else.
|
|
161
|
+
self._list.styles.height = min(max(len(options), 1), VISIBLE_ROWS)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _render_row(label: str, detail: str) -> Text:
|
|
165
|
+
text = Text()
|
|
166
|
+
text.append(label.ljust(LABEL_WIDTH) if detail else label, style="bold")
|
|
167
|
+
if detail:
|
|
168
|
+
text.append(f" {detail}", style="dim")
|
|
169
|
+
return text
|
|
170
|
+
|
|
171
|
+
# ---- driven from the prompt, which keeps focus ----
|
|
172
|
+
|
|
173
|
+
def move(self, delta: int) -> None:
|
|
174
|
+
if not self._rows:
|
|
175
|
+
return
|
|
176
|
+
current = self._list.highlighted or 0
|
|
177
|
+
self._list.highlighted = max(0, min(current + delta, len(self._rows) - 1))
|
|
178
|
+
|
|
179
|
+
def choose(self) -> bool:
|
|
180
|
+
"""Pick the highlighted row. False when there is nothing to pick, which is the
|
|
181
|
+
signal to let ++enter++ mean "send" instead."""
|
|
182
|
+
if not (self.selectable and self._rows):
|
|
183
|
+
return False
|
|
184
|
+
index = self._list.highlighted or 0
|
|
185
|
+
self.post_message(self.Chosen(self.kind, self._rows[index][0], self.token))
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
def choose_value(self, value: str) -> None:
|
|
189
|
+
"""Pick a named row directly — what `y` and `n` do on an approval card."""
|
|
190
|
+
self.post_message(self.Chosen(self.kind, value, self.token))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class CardZone(Vertical):
|
|
194
|
+
"""The strip between the transcript and the input. Holds one card, or nothing.
|
|
195
|
+
|
|
196
|
+
**`current` is a field, not a query.** Textual removes children on the message pump,
|
|
197
|
+
so the DOM still holds the old card for a tick after `clear()` — and the caller that
|
|
198
|
+
clears one card in order to show the next runs inside that tick. Asking the DOM
|
|
199
|
+
there answers "yes, a card is open" about a card that is already on its way out, and
|
|
200
|
+
the next one never gets shown: the second of two parallel approval requests sits in
|
|
201
|
+
the queue forever while the turn waits on an answer nobody can give.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
DEFAULT_CSS = """
|
|
205
|
+
CardZone { height: auto; max-height: 18; display: none; }
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, *, id: str | None = None) -> None: # noqa: A002 — textual's name
|
|
209
|
+
super().__init__(id=id)
|
|
210
|
+
self._current: Card | None = None
|
|
211
|
+
|
|
212
|
+
def show(self, card: Card) -> Card:
|
|
213
|
+
self.remove_children()
|
|
214
|
+
self.mount(card)
|
|
215
|
+
self._current = card
|
|
216
|
+
self.display = True
|
|
217
|
+
return card
|
|
218
|
+
|
|
219
|
+
def clear(self) -> None:
|
|
220
|
+
self.remove_children()
|
|
221
|
+
self._current = None
|
|
222
|
+
self.display = False
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def current(self) -> Card | None:
|
|
226
|
+
return self._current
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
__all__ = ["LABEL_WIDTH", "VISIBLE_ROWS", "Card", "CardZone"]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The prompt — a text area that keeps focus while a card is open.
|
|
3
|
+
|
|
4
|
+
Two jobs beyond editing text:
|
|
5
|
+
|
|
6
|
+
**Send on enter, newline on ctrl+j.** A `TextArea` does the opposite by default, and the
|
|
7
|
+
default is wrong here: sending is the common act.
|
|
8
|
+
|
|
9
|
+
The newline key is the part worth being careful about. ++shift+enter++ and
|
|
10
|
+
++alt+enter++ only exist on the wire when the terminal speaks the **kitty keyboard
|
|
11
|
+
protocol** — Textual asks for it on start-up, and a terminal that does not answer sends
|
|
12
|
+
plain `CR` for shift+enter and `ESC CR` for alt+enter, both of which arrive here as a
|
|
13
|
+
bare `enter`. So on those terminals the two documented newline keys *send the message*,
|
|
14
|
+
which is the worst possible failure for a key whose job is to not send it.
|
|
15
|
+
|
|
16
|
+
++ctrl+j++ is the one that always works: it is `LF`, a different byte from `CR`, on
|
|
17
|
+
every terminal there is. It is the documented answer; the other two are kept for the
|
|
18
|
+
terminals that do report them, along with the `modifyOtherKeys` spellings xterm uses.
|
|
19
|
+
|
|
20
|
+
**Driving the open card without giving up focus.** ↑↓, enter, escape and — on an empty
|
|
21
|
+
input — backspace are handed to the app while a card is open; everything else is
|
|
22
|
+
editing, and the app re-reads the token afterwards to filter. That is what makes "type
|
|
23
|
+
more to filter" work with no second field to tab into.
|
|
24
|
+
|
|
25
|
+
`?` is the one character that does not insert itself: on an empty input it opens the
|
|
26
|
+
help card instead. Backspace closes that card, and because nothing was inserted, typing
|
|
27
|
+
`?` again inserts it — which is what you meant the second time.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import ClassVar
|
|
33
|
+
|
|
34
|
+
from textual.binding import Binding, BindingType
|
|
35
|
+
from textual.events import Key
|
|
36
|
+
from textual.message import Message
|
|
37
|
+
from textual.widgets import TextArea
|
|
38
|
+
|
|
39
|
+
MAX_ROWS = 8
|
|
40
|
+
"""How tall the input grows before it scrolls. Past this, you are writing a document,
|
|
41
|
+
and the transcript is worth more screen than the draft."""
|
|
42
|
+
|
|
43
|
+
NEWLINE_KEYS = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"ctrl+j",
|
|
46
|
+
"shift+enter",
|
|
47
|
+
"alt+enter",
|
|
48
|
+
# `modifyOtherKeys`, which xterm and a few others speak instead of the kitty
|
|
49
|
+
# protocol. Textual names these after the character rather than the key.
|
|
50
|
+
"shift+\r",
|
|
51
|
+
"alt+\r",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
"""Every spelling of "newline, do not send". ++ctrl+j++ is the one that works
|
|
55
|
+
everywhere; the rest depend on what the terminal reports. → this module's docstring."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Prompt(TextArea):
|
|
59
|
+
"""The input line. Grows to `MAX_ROWS`, then scrolls."""
|
|
60
|
+
|
|
61
|
+
DEFAULT_CSS = """
|
|
62
|
+
Prompt {
|
|
63
|
+
height: auto;
|
|
64
|
+
max-height: 10;
|
|
65
|
+
border: none;
|
|
66
|
+
border-left: thick $primary;
|
|
67
|
+
padding: 1 1;
|
|
68
|
+
background: $surface;
|
|
69
|
+
}
|
|
70
|
+
Prompt:focus {
|
|
71
|
+
border: none;
|
|
72
|
+
border-left: thick $primary;
|
|
73
|
+
}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
77
|
+
Binding("ctrl+j", "newline", "New line", show=False),
|
|
78
|
+
Binding("alt+enter", "newline", "New line", show=False),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
class Submitted(Message):
|
|
82
|
+
def __init__(self, text: str) -> None:
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.text = text
|
|
85
|
+
|
|
86
|
+
class HelpRequested(Message):
|
|
87
|
+
"""`?` on an empty input."""
|
|
88
|
+
|
|
89
|
+
class CardMove(Message):
|
|
90
|
+
def __init__(self, delta: int) -> None:
|
|
91
|
+
super().__init__()
|
|
92
|
+
self.delta = delta
|
|
93
|
+
|
|
94
|
+
class CardChoose(Message):
|
|
95
|
+
"""++enter++ with a card open. The app falls back to sending if nothing is
|
|
96
|
+
highlighted, so this never swallows a message."""
|
|
97
|
+
|
|
98
|
+
class CardClose(Message):
|
|
99
|
+
"""++escape++, or ++backspace++ on an empty input, with a card open."""
|
|
100
|
+
|
|
101
|
+
class CardHotkey(Message):
|
|
102
|
+
"""A single key the open card claimed — ++y++ and ++n++ on an approval."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, value: str) -> None:
|
|
105
|
+
super().__init__()
|
|
106
|
+
self.value = value
|
|
107
|
+
|
|
108
|
+
class Interrupted(Message):
|
|
109
|
+
"""++escape++ with no card open."""
|
|
110
|
+
|
|
111
|
+
class ModeCycle(Message):
|
|
112
|
+
"""++shift+tab++. Handled here rather than as an app binding because a focused
|
|
113
|
+
`TextArea` claims tab and shift+tab for focus movement before one is reached,
|
|
114
|
+
and the prompt has focus the entire time the app is usable."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, placeholder: str = "") -> None:
|
|
117
|
+
super().__init__(
|
|
118
|
+
id="prompt",
|
|
119
|
+
soft_wrap=True,
|
|
120
|
+
show_line_numbers=False,
|
|
121
|
+
placeholder=placeholder,
|
|
122
|
+
)
|
|
123
|
+
self.card_open = False
|
|
124
|
+
"""Set by the app. The prompt does not know what a card *is* — only that its
|
|
125
|
+
arrow keys belong to something else while one is up."""
|
|
126
|
+
|
|
127
|
+
self.card_hotkeys: dict[str, str] = {}
|
|
128
|
+
"""Single keys the open card has claimed, as key -> the row it picks. Empty for
|
|
129
|
+
every card but an approval: a letter that silently means something is a letter
|
|
130
|
+
you cannot type, so only a card that says so up front gets one."""
|
|
131
|
+
|
|
132
|
+
# ---- what the app needs to filter on ----
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def cursor_offset(self) -> int:
|
|
136
|
+
"""The cursor as an index into `text`, which is what `token_trigger` wants."""
|
|
137
|
+
return self.document.get_index_from_location(self.cursor_location)
|
|
138
|
+
|
|
139
|
+
def clear_text(self) -> str:
|
|
140
|
+
"""Empty the input and hand back what was in it."""
|
|
141
|
+
text = self.text
|
|
142
|
+
self.text = ""
|
|
143
|
+
return text
|
|
144
|
+
|
|
145
|
+
def insert_token(self, symbol: str, value: str) -> None:
|
|
146
|
+
"""Replace the `symbol`-token at the cursor with `value` — how a chosen file
|
|
147
|
+
mention lands. The symbol goes too: the path is the payload."""
|
|
148
|
+
offset = self.cursor_offset
|
|
149
|
+
start = offset
|
|
150
|
+
while start > 0 and not self.text[start - 1].isspace():
|
|
151
|
+
start -= 1
|
|
152
|
+
before, after = self.text[:start], self.text[offset:]
|
|
153
|
+
self.text = f"{before}{value} {after}"
|
|
154
|
+
self.move_cursor(
|
|
155
|
+
self.document.get_location_from_index(len(before) + len(value) + 1)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# ---- keys ----
|
|
159
|
+
|
|
160
|
+
def action_newline(self) -> None:
|
|
161
|
+
self.insert("\n")
|
|
162
|
+
|
|
163
|
+
async def _on_key(self, event: Key) -> None:
|
|
164
|
+
"""Intercept before `TextArea`'s own bindings see it.
|
|
165
|
+
|
|
166
|
+
`_on_key` rather than `on_key`: `TextArea` binds ++enter++ to insert a newline,
|
|
167
|
+
and a handler that runs after that binding is a handler that runs too late.
|
|
168
|
+
"""
|
|
169
|
+
key = event.key
|
|
170
|
+
if self.card_open:
|
|
171
|
+
if key in ("up", "down"):
|
|
172
|
+
event.prevent_default()
|
|
173
|
+
event.stop()
|
|
174
|
+
self.post_message(self.CardMove(-1 if key == "up" else 1))
|
|
175
|
+
return
|
|
176
|
+
if key == "enter":
|
|
177
|
+
event.prevent_default()
|
|
178
|
+
event.stop()
|
|
179
|
+
self.post_message(self.CardChoose())
|
|
180
|
+
return
|
|
181
|
+
if key == "escape" or (key == "backspace" and not self.text):
|
|
182
|
+
event.prevent_default()
|
|
183
|
+
event.stop()
|
|
184
|
+
self.post_message(self.CardClose())
|
|
185
|
+
return
|
|
186
|
+
if key in self.card_hotkeys:
|
|
187
|
+
event.prevent_default()
|
|
188
|
+
event.stop()
|
|
189
|
+
self.post_message(self.CardHotkey(self.card_hotkeys[key]))
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if key == "question_mark" and not self.text and not self.card_open:
|
|
193
|
+
event.prevent_default()
|
|
194
|
+
event.stop()
|
|
195
|
+
self.post_message(self.HelpRequested())
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
if key == "shift+tab":
|
|
199
|
+
event.prevent_default()
|
|
200
|
+
event.stop()
|
|
201
|
+
self.post_message(self.ModeCycle())
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if key in NEWLINE_KEYS:
|
|
205
|
+
event.prevent_default()
|
|
206
|
+
event.stop()
|
|
207
|
+
self.insert("\n")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if key == "enter":
|
|
211
|
+
event.prevent_default()
|
|
212
|
+
event.stop()
|
|
213
|
+
self.post_message(self.Submitted(self.text))
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
if key == "escape":
|
|
217
|
+
event.prevent_default()
|
|
218
|
+
event.stop()
|
|
219
|
+
self.post_message(self.Interrupted())
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
await super()._on_key(event)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
__all__ = ["MAX_ROWS", "NEWLINE_KEYS", "Prompt"]
|