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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. 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)