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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connect screen — point this client at a daemon.
|
|
3
|
+
|
|
4
|
+
`--daemonless` is what this exists for: the agent runs somewhere else, and this terminal
|
|
5
|
+
is only a window onto it. Nothing starts here, so a failed connection is a question —
|
|
6
|
+
*which daemon?* — rather than an error.
|
|
7
|
+
|
|
8
|
+
It returns an `Address` and nothing else, and switching transport swaps which pair of
|
|
9
|
+
fields is shown rather than offering four of which two are ignored.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import ClassVar
|
|
16
|
+
|
|
17
|
+
from textual import on
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.binding import BindingType
|
|
20
|
+
from textual.containers import Horizontal, Vertical
|
|
21
|
+
from textual.screen import ModalScreen
|
|
22
|
+
from textual.widgets import Button, Input, Label, Select, Static
|
|
23
|
+
|
|
24
|
+
from stcode.cli import labels
|
|
25
|
+
from stcode.cli.services.client import Address
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConnectScreen(ModalScreen[Address | None]):
|
|
29
|
+
"""Ask where the daemon is."""
|
|
30
|
+
|
|
31
|
+
BINDINGS: ClassVar[list[BindingType]] = [("escape", "cancel", "Cancel")]
|
|
32
|
+
|
|
33
|
+
CSS_PATH = "connect.tcss"
|
|
34
|
+
|
|
35
|
+
def __init__(self, settings: Address, *, reason: str = "") -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._settings = settings
|
|
38
|
+
self._reason = reason
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
with Vertical(id="connect-dialog"):
|
|
42
|
+
yield Static(labels.CONNECT_TITLE, id="connect-title")
|
|
43
|
+
yield Static(self._reason or labels.CONNECT_INTRO, id="connect-intro")
|
|
44
|
+
|
|
45
|
+
yield Label(labels.FIELD_TRANSPORT, classes="field-label")
|
|
46
|
+
yield Select(
|
|
47
|
+
labels.transport_options(),
|
|
48
|
+
value=self._settings.transport,
|
|
49
|
+
allow_blank=False,
|
|
50
|
+
id="transport",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
yield Label(labels.FIELD_SOCKET, classes="field-label", id="socket-label")
|
|
54
|
+
yield Input(value=str(self._settings.socket), id="socket")
|
|
55
|
+
|
|
56
|
+
yield Label(labels.FIELD_HOST, classes="field-label", id="host-label")
|
|
57
|
+
yield Input(
|
|
58
|
+
value=self._settings.host,
|
|
59
|
+
placeholder=labels.HOST_PLACEHOLDER,
|
|
60
|
+
id="host",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
yield Label(labels.FIELD_PORT, classes="field-label", id="port-label")
|
|
64
|
+
yield Input(value=str(self._settings.port), id="port")
|
|
65
|
+
|
|
66
|
+
with Horizontal(id="connect-buttons"):
|
|
67
|
+
yield Button(labels.BUTTON_CANCEL, id="cancel")
|
|
68
|
+
yield Button(labels.BUTTON_CONNECT, variant="primary", id="connect")
|
|
69
|
+
|
|
70
|
+
def on_mount(self) -> None:
|
|
71
|
+
self._show_fields_for(self._settings.transport)
|
|
72
|
+
self.query_one("#connect", Button).focus()
|
|
73
|
+
|
|
74
|
+
def _show_fields_for(self, transport: str) -> None:
|
|
75
|
+
"""Show only the pair the chosen transport actually uses."""
|
|
76
|
+
unix = transport == "unix"
|
|
77
|
+
for widget_id in ("#socket-label", "#socket"):
|
|
78
|
+
self.query_one(widget_id).display = unix
|
|
79
|
+
for widget_id in ("#host-label", "#host", "#port-label", "#port"):
|
|
80
|
+
self.query_one(widget_id).display = not unix
|
|
81
|
+
|
|
82
|
+
@on(Select.Changed, "#transport")
|
|
83
|
+
def _transport_changed(self, event: Select.Changed) -> None:
|
|
84
|
+
self._show_fields_for(str(event.value))
|
|
85
|
+
|
|
86
|
+
@on(Input.Submitted)
|
|
87
|
+
def _submit_on_enter(self) -> None:
|
|
88
|
+
self._connect()
|
|
89
|
+
|
|
90
|
+
@on(Button.Pressed, "#connect")
|
|
91
|
+
def _connect_pressed(self) -> None:
|
|
92
|
+
self._connect()
|
|
93
|
+
|
|
94
|
+
@on(Button.Pressed, "#cancel")
|
|
95
|
+
def _cancel_pressed(self) -> None:
|
|
96
|
+
self.action_cancel()
|
|
97
|
+
|
|
98
|
+
def action_cancel(self) -> None:
|
|
99
|
+
self.dismiss(None)
|
|
100
|
+
|
|
101
|
+
def _connect(self) -> None:
|
|
102
|
+
transport = str(self.query_one("#transport", Select).value)
|
|
103
|
+
port_text = self.query_one("#port", Input).value.strip()
|
|
104
|
+
try:
|
|
105
|
+
port = int(port_text) if port_text else self._settings.port
|
|
106
|
+
except ValueError:
|
|
107
|
+
# A typo in the port is worth one sentence, not a dismissed dialog that
|
|
108
|
+
# silently connects somewhere else.
|
|
109
|
+
self.query_one("#connect-intro", Static).update(labels.bad_port(port_text))
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self.dismiss(
|
|
113
|
+
Address(
|
|
114
|
+
transport=transport,
|
|
115
|
+
socket=Path(
|
|
116
|
+
self.query_one("#socket", Input).value.strip()
|
|
117
|
+
or self._settings.socket
|
|
118
|
+
).expanduser(),
|
|
119
|
+
host=self.query_one("#host", Input).value.strip()
|
|
120
|
+
or self._settings.host,
|
|
121
|
+
port=port,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["ConnectScreen"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
ConnectScreen {
|
|
2
|
+
align: center middle;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
#connect-dialog {
|
|
6
|
+
width: 72;
|
|
7
|
+
max-width: 96%;
|
|
8
|
+
height: auto;
|
|
9
|
+
padding: 1 2;
|
|
10
|
+
border: none;
|
|
11
|
+
background: $surface;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#connect-title {
|
|
15
|
+
text-style: bold;
|
|
16
|
+
color: $accent;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#connect-intro {
|
|
20
|
+
color: $text-muted;
|
|
21
|
+
margin-bottom: 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#connect-dialog Input, #connect-dialog Select, #connect-dialog SelectCurrent {
|
|
25
|
+
height: 1;
|
|
26
|
+
border: none;
|
|
27
|
+
padding: 0 1;
|
|
28
|
+
background: $boost;
|
|
29
|
+
margin-bottom: 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#connect-dialog Input:focus, #connect-dialog Select:focus SelectCurrent {
|
|
33
|
+
background: $primary 30%;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.field-label {
|
|
37
|
+
text-style: bold;
|
|
38
|
+
color: $accent;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#connect-buttons {
|
|
42
|
+
height: auto;
|
|
43
|
+
margin-top: 1;
|
|
44
|
+
align-horizontal: right;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#connect-buttons Button {
|
|
48
|
+
margin-left: 2;
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
TrustScreen, SessionsScreen {
|
|
2
|
+
align: center middle;
|
|
3
|
+
background: $background 70%;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
#dialog {
|
|
7
|
+
width: 80;
|
|
8
|
+
max-width: 92%;
|
|
9
|
+
height: auto;
|
|
10
|
+
max-height: 80%;
|
|
11
|
+
padding: 1 2;
|
|
12
|
+
border: none;
|
|
13
|
+
background: $surface;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#dialog-title {
|
|
17
|
+
text-style: bold;
|
|
18
|
+
color: $text-primary;
|
|
19
|
+
margin-bottom: 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#dialog-body {
|
|
23
|
+
height: auto;
|
|
24
|
+
margin-bottom: 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#dialog-buttons {
|
|
28
|
+
height: auto;
|
|
29
|
+
align-horizontal: right;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#dialog-buttons Button {
|
|
33
|
+
margin-left: 2;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#session-tree {
|
|
37
|
+
height: auto;
|
|
38
|
+
max-height: 16;
|
|
39
|
+
margin-bottom: 1;
|
|
40
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar, Sequence
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding, BindingType
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Button, Label, Static, Tree
|
|
12
|
+
from textual.widgets.tree import TreeNode
|
|
13
|
+
|
|
14
|
+
from stcode.cli import labels
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionsScreen(ModalScreen[str]):
|
|
18
|
+
"""Pick an earlier session to resume. Returns its id, or "" if nothing was picked.
|
|
19
|
+
|
|
20
|
+
A tree, because sub-agent sessions are rows in the same listing: `parent` and
|
|
21
|
+
`agent_name` are already in every `meta` line, so the shape is a group-by and not
|
|
22
|
+
an index.
|
|
23
|
+
|
|
24
|
+
**Only a parent is selectable.** A sub-agent's session is a transcript to read, not
|
|
25
|
+
a conversation to continue — there is no user on the other end of it, and it was
|
|
26
|
+
built to answer exactly one question.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
CSS_PATH = "dialog.tcss"
|
|
30
|
+
|
|
31
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "cancel", "Cancel")]
|
|
32
|
+
|
|
33
|
+
def __init__(self, rows: Sequence[dict[str, Any]]) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self._rows = list(rows)
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
with Vertical(id="dialog"):
|
|
39
|
+
yield Label(labels.SESSIONS_TITLE, id="dialog-title")
|
|
40
|
+
if not self._rows:
|
|
41
|
+
yield Static(labels.SESSIONS_EMPTY, id="dialog-body", markup=False)
|
|
42
|
+
else:
|
|
43
|
+
yield Static(
|
|
44
|
+
Text(labels.SESSIONS_INTRO, style="dim"),
|
|
45
|
+
id="dialog-body",
|
|
46
|
+
markup=False,
|
|
47
|
+
)
|
|
48
|
+
yield self._tree()
|
|
49
|
+
with Horizontal(id="dialog-buttons"):
|
|
50
|
+
yield Button(labels.BUTTON_CANCEL, id="cancel")
|
|
51
|
+
|
|
52
|
+
def _tree(self) -> Tree[dict[str, Any]]:
|
|
53
|
+
tree: Tree[dict[str, Any]] = Tree("sessions", id="session-tree")
|
|
54
|
+
tree.show_root = False
|
|
55
|
+
tree.guide_depth = 3
|
|
56
|
+
|
|
57
|
+
children: dict[str, list[dict[str, Any]]] = {}
|
|
58
|
+
for row in self._rows:
|
|
59
|
+
parent = str(row.get("parent", ""))
|
|
60
|
+
if parent:
|
|
61
|
+
children.setdefault(parent, []).append(row)
|
|
62
|
+
|
|
63
|
+
known = {str(row.get("id", "")) for row in self._rows}
|
|
64
|
+
for row in self._rows:
|
|
65
|
+
parent = str(row.get("parent", ""))
|
|
66
|
+
# An orphan — its parent has been pruned — is shown at the top level rather
|
|
67
|
+
# than hidden. A transcript you cannot find is a transcript you have lost.
|
|
68
|
+
if parent and parent in known:
|
|
69
|
+
continue
|
|
70
|
+
node = tree.root.add(self._label(row), data=row, expand=True)
|
|
71
|
+
for child in children.get(str(row.get("id", "")), []):
|
|
72
|
+
node.add_leaf(self._child_label(child), data=child)
|
|
73
|
+
return tree
|
|
74
|
+
|
|
75
|
+
def _label(self, row: dict[str, Any]) -> Text:
|
|
76
|
+
text = Text()
|
|
77
|
+
text.append(labels.session_line(row), style="bold" if row.get("live") else "")
|
|
78
|
+
summary = str(row.get("summary", ""))
|
|
79
|
+
if summary:
|
|
80
|
+
text.append(f" {summary}", style="dim")
|
|
81
|
+
return text
|
|
82
|
+
|
|
83
|
+
def _child_label(self, row: dict[str, Any]) -> Text:
|
|
84
|
+
return Text(labels.subsession_line(row), style="dim")
|
|
85
|
+
|
|
86
|
+
def on_mount(self) -> None:
|
|
87
|
+
if self._rows:
|
|
88
|
+
self.query_one("#session-tree", Tree).focus()
|
|
89
|
+
else:
|
|
90
|
+
self.query_one("#cancel", Button).focus()
|
|
91
|
+
|
|
92
|
+
@on(Tree.NodeSelected)
|
|
93
|
+
def _selected(self, event: Tree.NodeSelected[dict[str, Any]]) -> None:
|
|
94
|
+
node: TreeNode[dict[str, Any]] = event.node
|
|
95
|
+
row = node.data or {}
|
|
96
|
+
if row.get("parent"):
|
|
97
|
+
return # A sub-agent's session is readable, not resumable.
|
|
98
|
+
self.dismiss(str(row.get("id", "")))
|
|
99
|
+
|
|
100
|
+
@on(Button.Pressed, "#cancel")
|
|
101
|
+
def _cancelled(self) -> None:
|
|
102
|
+
self.action_cancel()
|
|
103
|
+
|
|
104
|
+
def action_cancel(self) -> None:
|
|
105
|
+
self.dismiss("")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings screen — the first-run wizard and the `/model` page.
|
|
3
|
+
|
|
4
|
+
One screen for both, so they cannot drift apart. It shows the daemon's `config` frame
|
|
5
|
+
and dismisses with a `set_config` patch (or `None` if the user backed out); the app
|
|
6
|
+
sends it and the daemon writes its own file. Nothing here touches a disk.
|
|
7
|
+
|
|
8
|
+
Three fields: provider, key, model. Routing, base URLs and concurrency caps live in the
|
|
9
|
+
daemon's `config.toml`. The key field is disabled when the frame says keys are not
|
|
10
|
+
editable — a daemon reached over TCP accepts none.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, ClassVar
|
|
16
|
+
|
|
17
|
+
from textual import on
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.binding import BindingType
|
|
20
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
21
|
+
from textual.screen import ModalScreen
|
|
22
|
+
from textual.widgets import Button, Input, Label, Select, Static
|
|
23
|
+
|
|
24
|
+
from stcode.cli import labels
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SettingsScreen(ModalScreen[dict[str, Any] | None]):
|
|
28
|
+
"""Edit the daemon's default provider, its key, and its model."""
|
|
29
|
+
|
|
30
|
+
BINDINGS: ClassVar[list[BindingType]] = [("escape", "cancel", "Cancel")]
|
|
31
|
+
|
|
32
|
+
CSS_PATH = "settings.tcss"
|
|
33
|
+
|
|
34
|
+
# Textual focuses this once the screen's children exist.
|
|
35
|
+
AUTO_FOCUS = "#model"
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: dict[str, Any], *, first_run: bool = False) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self._config = config
|
|
40
|
+
self._first_run = first_run
|
|
41
|
+
self._keys_editable = bool(config.get("keys_editable"))
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
provider = str(self._config.get("provider", ""))
|
|
45
|
+
with Vertical(id="settings-dialog"):
|
|
46
|
+
yield Static(
|
|
47
|
+
labels.SETTINGS_TITLE_FIRST_RUN
|
|
48
|
+
if self._first_run
|
|
49
|
+
else labels.SETTINGS_TITLE,
|
|
50
|
+
id="settings-title",
|
|
51
|
+
)
|
|
52
|
+
yield Static(
|
|
53
|
+
labels.SETTINGS_INTRO_FIRST_RUN
|
|
54
|
+
if self._first_run
|
|
55
|
+
else labels.SETTINGS_INTRO,
|
|
56
|
+
id="settings-intro",
|
|
57
|
+
)
|
|
58
|
+
with VerticalScroll(id="settings-body"):
|
|
59
|
+
yield Label(labels.FIELD_PROVIDER, classes="field-label")
|
|
60
|
+
options = labels.provider_options(self._config)
|
|
61
|
+
yield Select(
|
|
62
|
+
options,
|
|
63
|
+
value=provider
|
|
64
|
+
if any(value == provider for _, value in options)
|
|
65
|
+
else Select.BLANK,
|
|
66
|
+
allow_blank=not options,
|
|
67
|
+
id="provider",
|
|
68
|
+
)
|
|
69
|
+
yield Label(labels.FIELD_API_KEY, classes="field-label")
|
|
70
|
+
yield Input(
|
|
71
|
+
password=True, id="api-key", disabled=not self._keys_editable
|
|
72
|
+
)
|
|
73
|
+
yield Static("", id="key-hint", classes="field-hint")
|
|
74
|
+
yield Label(labels.FIELD_MODEL, classes="field-label")
|
|
75
|
+
yield Input(id="model")
|
|
76
|
+
with Horizontal(id="settings-buttons"):
|
|
77
|
+
yield Button(
|
|
78
|
+
labels.BUTTON_SKIP if self._first_run else labels.BUTTON_CANCEL,
|
|
79
|
+
id="cancel",
|
|
80
|
+
)
|
|
81
|
+
yield Button(labels.BUTTON_SAVE, variant="primary", id="save")
|
|
82
|
+
|
|
83
|
+
def on_mount(self) -> None:
|
|
84
|
+
self._show(str(self._config.get("provider", "")))
|
|
85
|
+
|
|
86
|
+
def _entry(self, name: str) -> tuple[dict[str, Any], dict[str, str]]:
|
|
87
|
+
"""The configured entry for `name` (empty when it would be new), and its library."""
|
|
88
|
+
entry = dict(self._config.get("providers", {}).get(name, {}))
|
|
89
|
+
library = str(entry.get("provider") or name)
|
|
90
|
+
return entry, dict(self._config.get("libraries", {}).get(library, {}))
|
|
91
|
+
|
|
92
|
+
def _show(self, name: str) -> None:
|
|
93
|
+
"""Fill the key and model fields for one provider entry."""
|
|
94
|
+
entry, library = self._entry(name)
|
|
95
|
+
has_key = bool(entry.get("has_key"))
|
|
96
|
+
key_input = self.query_one("#api-key", Input)
|
|
97
|
+
key_input.value = ""
|
|
98
|
+
key_input.placeholder = labels.key_placeholder(has_key)
|
|
99
|
+
self.query_one("#key-hint", Static).update(
|
|
100
|
+
labels.key_hint(
|
|
101
|
+
library.get("key_env", ""),
|
|
102
|
+
has_key=has_key,
|
|
103
|
+
editable=self._keys_editable,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
model_input = self.query_one("#model", Input)
|
|
107
|
+
model_input.value = str(entry.get("model", ""))
|
|
108
|
+
model_input.placeholder = labels.model_placeholder(
|
|
109
|
+
library.get("default_model", "")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@on(Select.Changed, "#provider")
|
|
113
|
+
def _provider_changed(self, event: Select.Changed) -> None:
|
|
114
|
+
if event.value is not Select.BLANK:
|
|
115
|
+
self._show(str(event.value))
|
|
116
|
+
|
|
117
|
+
@on(Input.Submitted)
|
|
118
|
+
@on(Button.Pressed, "#save")
|
|
119
|
+
def _save(self) -> None:
|
|
120
|
+
provider = self.query_one("#provider", Select).value
|
|
121
|
+
if provider is Select.BLANK:
|
|
122
|
+
return
|
|
123
|
+
patch: dict[str, Any] = {
|
|
124
|
+
"provider": str(provider),
|
|
125
|
+
"model": self.query_one("#model", Input).value.strip(),
|
|
126
|
+
}
|
|
127
|
+
key = self.query_one("#api-key", Input).value.strip()
|
|
128
|
+
if key:
|
|
129
|
+
patch["api_key"] = key
|
|
130
|
+
self.dismiss(patch)
|
|
131
|
+
|
|
132
|
+
@on(Button.Pressed, "#cancel")
|
|
133
|
+
def action_cancel(self) -> None:
|
|
134
|
+
self.dismiss(None)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = ["SettingsScreen"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
SettingsScreen {
|
|
2
|
+
align: center middle;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/* Fixed height with the fields in their own scroller, so the buttons stay on
|
|
6
|
+
screen on a short terminal instead of sliding below the fold. */
|
|
7
|
+
#settings-dialog {
|
|
8
|
+
width: 72;
|
|
9
|
+
max-width: 96%;
|
|
10
|
+
height: 90%;
|
|
11
|
+
max-height: 32;
|
|
12
|
+
padding: 1 2;
|
|
13
|
+
border: none;
|
|
14
|
+
background: $surface;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#settings-body {
|
|
18
|
+
height: 1fr;
|
|
19
|
+
scrollbar-size-vertical: 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#settings-title {
|
|
23
|
+
text-style: bold;
|
|
24
|
+
color: $accent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#settings-intro {
|
|
28
|
+
color: $text-muted;
|
|
29
|
+
margin-bottom: 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Single-row fields: bordered inputs are prettier but cost 3 rows each, which
|
|
33
|
+
pushes Model and Base URL off a short terminal. Focus reads off the tinted
|
|
34
|
+
background instead of a border. Select wraps its value in a SelectCurrent that
|
|
35
|
+
carries its own border, so that has to be flattened too or the field renders
|
|
36
|
+
as an empty box. */
|
|
37
|
+
#settings-body Input, #settings-body Select, #settings-body SelectCurrent {
|
|
38
|
+
height: 1;
|
|
39
|
+
border: none;
|
|
40
|
+
padding: 0 1;
|
|
41
|
+
background: $boost;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#settings-body Input:focus, #settings-body Select:focus SelectCurrent {
|
|
45
|
+
background: $primary 30%;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#settings-body Input, #settings-body Select {
|
|
49
|
+
margin-bottom: 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.field-label {
|
|
53
|
+
text-style: bold;
|
|
54
|
+
color: $accent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.field-hint {
|
|
58
|
+
color: $text-muted;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#settings-buttons {
|
|
62
|
+
height: auto;
|
|
63
|
+
margin-top: 1;
|
|
64
|
+
align-horizontal: right;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#settings-buttons Button {
|
|
68
|
+
margin-left: 2;
|
|
69
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding, BindingType
|
|
8
|
+
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Button, Label, Static
|
|
11
|
+
|
|
12
|
+
from stcode.cli import labels
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TrustScreen(ModalScreen[bool]):
|
|
16
|
+
"""Ask whether the agent may work in this folder. Cancel means leave.
|
|
17
|
+
|
|
18
|
+
No third "read-only for a bit" answer: that is `--mode plan` wearing a disguise,
|
|
19
|
+
and a wall with a side door is a wall people learn to walk around.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
CSS_PATH = "dialog.tcss"
|
|
23
|
+
|
|
24
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "cancel", "Cancel")]
|
|
25
|
+
|
|
26
|
+
def __init__(self, path: object) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._path = str(path)
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
with Vertical(id="dialog"):
|
|
32
|
+
yield Label(labels.TRUST_TITLE, id="dialog-title")
|
|
33
|
+
body = Text()
|
|
34
|
+
body.append(self._path + "\n\n", style="bold")
|
|
35
|
+
body.append(labels.TRUST_BODY)
|
|
36
|
+
yield Static(body, id="dialog-body", markup=False)
|
|
37
|
+
with Horizontal(id="dialog-buttons"):
|
|
38
|
+
yield Button(labels.TRUST_NO, id="cancel")
|
|
39
|
+
yield Button(labels.TRUST_YES, variant="primary", id="trust")
|
|
40
|
+
|
|
41
|
+
def on_mount(self) -> None:
|
|
42
|
+
# Focus lands on Cancel: the reflexive Enter must not be how a folder gets
|
|
43
|
+
# trusted, for the same reason approval defaults to deny.
|
|
44
|
+
self.query_one("#cancel", Button).focus()
|
|
45
|
+
|
|
46
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
47
|
+
self.dismiss(event.button.id == "trust")
|
|
48
|
+
|
|
49
|
+
def action_cancel(self) -> None:
|
|
50
|
+
self.dismiss(False)
|