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
stcode/cli/labels.py ADDED
@@ -0,0 +1,621 @@
1
+ """
2
+ Labels — every string the user reads, in one place.
3
+
4
+ Screens compose widgets; this module decides what they say. Keeping the copy together
5
+ makes it reviewable in one pass, and keeps presentation-only tables (mode colours,
6
+ provider display names, the command list) out of `core/`, which should not know a UI
7
+ exists.
8
+
9
+ Anything conditional is a function rather than a constant, so the *choice* of wording
10
+ lives here too instead of leaking back into the screens as an `if`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any, Sequence
17
+
18
+ from stcode.cli.models import APPROVAL_MODES, ApprovalMode, REASONING_EFFORTS, Row
19
+ from stcode.cli.logic.commands import (
20
+ next_approval_mode as next_approval_mode,
21
+ parse_approval_mode as parse_approval_mode,
22
+ )
23
+
24
+ # --------------------------------------------------------------------------- providers
25
+
26
+ PROVIDER_LABELS: dict[str, str] = {
27
+ "openai": "OpenAI (also any OpenAI-compatible endpoint)",
28
+ "anthropic": "Anthropic",
29
+ "google": "Google Gemini",
30
+ }
31
+
32
+
33
+ def provider_options(config: dict[str, Any]) -> list[tuple[str, str]]:
34
+ """(label, value) pairs for the provider Select: the daemon's configured entries,
35
+ then each library it could add an entry for."""
36
+ providers: dict[str, Any] = config.get("providers", {})
37
+ options = [
38
+ (
39
+ name
40
+ if name == entry.get("provider")
41
+ else f"{name} ({entry.get('provider')})",
42
+ name,
43
+ )
44
+ for name, entry in providers.items()
45
+ ]
46
+ options += [
47
+ (f"{PROVIDER_LABELS.get(library, library)} — new", library)
48
+ for library in config.get("libraries", {})
49
+ if library not in providers
50
+ ]
51
+ return options
52
+
53
+
54
+ def provider_short_name(provider: str) -> str:
55
+ """The vendor name alone, for use mid-sentence."""
56
+ return PROVIDER_LABELS.get(provider, provider).split()[0]
57
+
58
+
59
+ # ---------------------------------------------------------------------- approval modes
60
+
61
+ APPROVAL_MODE_HELP: dict[ApprovalMode, str] = {
62
+ "plan": "read-only — no writes, no commands",
63
+ "suggest": "ask before every write and command",
64
+ "auto-edit": "auto-approve file edits, ask before commands",
65
+ "full-auto": "approve everything — containerize before using",
66
+ }
67
+
68
+ # Rendered next to the mode name in the status line; escalating warmth = escalating risk.
69
+ APPROVAL_MODE_COLOR: dict[ApprovalMode, str] = {
70
+ "plan": "cyan",
71
+ "suggest": "green",
72
+ "auto-edit": "yellow",
73
+ "full-auto": "red",
74
+ }
75
+
76
+
77
+ def mode_rows() -> list[Row]:
78
+ return [(mode, APPROVAL_MODE_HELP[mode]) for mode in APPROVAL_MODES]
79
+
80
+
81
+ def mode_changed(mode: ApprovalMode) -> str:
82
+ return f"mode → {mode} — {APPROVAL_MODE_HELP[mode]}"
83
+
84
+
85
+ def unknown_mode(argument: str) -> str:
86
+ return f"Unknown mode {argument!r}. One of: {', '.join(APPROVAL_MODES)}"
87
+
88
+
89
+ # ------------------------------------------------------------------------ the screen
90
+
91
+ PROMPT_PLACEHOLDER = "Ask stcode… ? for help / for commands @ for files"
92
+
93
+ THEME_ROWS: list[Row] = [
94
+ ("auto", "follow the terminal's own background"),
95
+ ("dark", "always dark"),
96
+ ("light", "always light"),
97
+ ]
98
+
99
+ EFFORT_HELP: dict[str, str] = {
100
+ "none": "no thinking at all",
101
+ "minimal": "the least the model offers",
102
+ "low": "quick, for mechanical work",
103
+ "medium": "the usual",
104
+ "high": "for work that needs real reasoning",
105
+ "xhigh": "more than high, where a provider has it",
106
+ "max": "as much as the provider allows",
107
+ }
108
+
109
+ EFFORT_CLAMPS: dict[str, dict[str, str]] = {
110
+ "anthropic": {"minimal": "sent as low"},
111
+ "google": {"xhigh": "sent as high", "max": "sent as high"},
112
+ }
113
+ """Where a provider's own scale is narrower than the union.
114
+
115
+ Shown, not hidden: a rung that quietly does what the one below it does is worth one
116
+ parenthesis, and dropping it from the list would make the same card offer different
117
+ things on different days for reasons nobody could see. → `core/providers/types.py`
118
+ """
119
+
120
+
121
+ def effort_rows(provider: str = "") -> list[Row]:
122
+ """Every rung, with the clamp named for the provider currently in force.
123
+
124
+ `provider` is the library (`openai`, …), which is what the clamps depend on.
125
+ """
126
+ clamps = EFFORT_CLAMPS.get(provider, {})
127
+ rows: list[Row] = []
128
+ for rung in REASONING_EFFORTS:
129
+ detail = EFFORT_HELP.get(rung, "")
130
+ clamp = clamps.get(rung)
131
+ rows.append((rung, f"{detail} ({clamp})" if clamp else detail))
132
+ return rows
133
+
134
+
135
+ EFFORT_ROWS: list[Row] = effort_rows()
136
+ """The unqualified list, for callers with no provider in hand."""
137
+
138
+ COMMANDS: list[Row] = [
139
+ ("/model", "provider, key, model and routing"),
140
+ ("/effort", "how hard the model should think"),
141
+ ("/mode", "approval mode"),
142
+ ("/theme", "auto, dark or light"),
143
+ ("/token", "what this session has spent"),
144
+ ("/sessions", "pick up an earlier session"),
145
+ ("/connect", "point this client at a different daemon"),
146
+ ("/mcp", "the MCP servers this session connected to"),
147
+ ("/skills", "the skills this session found"),
148
+ ("/clear", "end this session and start a fresh one"),
149
+ ("/help", "the help card"),
150
+ ("/quit", "leave — the agent keeps working"),
151
+ ]
152
+
153
+ DAEMONLESS_ONLY = {"/connect"}
154
+ """Commands that only mean something when the agent is somewhere else. `stcode` on its
155
+ own started the daemon it is talking to, and offering to move the terminal off it would
156
+ leave that daemon running with nothing attached — which is a thing to do deliberately,
157
+ with `--daemonless`, not a thing to find in a list."""
158
+
159
+
160
+ def commands_for(*, daemonless: bool, skills: Sequence[str] = ()) -> list[Row]:
161
+ """The `/` card: the built-in commands, then this session's skills.
162
+
163
+ Skills are listed as commands because that is how they are reached — `/pdf` is one
164
+ keystroke and one enter, where "please use the pdf skill" is a sentence to compose.
165
+ They come last and are marked, so a skill can never be mistaken for something the
166
+ UI itself does, and a skill named after a command cannot take it over.
167
+ """
168
+ rows = (
169
+ list(COMMANDS)
170
+ if daemonless
171
+ else [row for row in COMMANDS if row[0] not in DAEMONLESS_ONLY]
172
+ )
173
+ taken = {row[0] for row in rows}
174
+ return rows + [row for row in skill_commands(skills) if row[0] not in taken]
175
+
176
+
177
+ def skill_commands(skills: Sequence[str]) -> list[Row]:
178
+ return [(skill_command(name), SKILL_COMMAND_HINT) for name in skills if name]
179
+
180
+
181
+ def skill_command(name: str) -> str:
182
+ return f"/{name.strip().lstrip('/')}"
183
+
184
+
185
+ SKILL_COMMAND_HINT = "skill — loads its instructions, then does the work"
186
+
187
+
188
+ def daemonless_only(name: str) -> str:
189
+ return f"/{name} needs --daemonless — this stcode is attached to the daemon it started."
190
+
191
+
192
+ CARD_HELP = "help"
193
+ CARD_COMMANDS = "commands"
194
+ CARD_FILES = "files"
195
+ CARD_MODE = "approval mode"
196
+ CARD_THEME = "theme"
197
+ CARD_EFFORT = "reasoning effort"
198
+ CARD_TOKENS = "tokens"
199
+ CARD_MCP = "mcp"
200
+ CARD_SKILLS = "skills"
201
+ CARD_APPROVE = "approve?"
202
+ CARD_QUESTION = "the agent needs a decision"
203
+
204
+ TOKEN_CARDS = (CARD_COMMANDS, CARD_FILES)
205
+ """The only two cards the text under the cursor opens and closes.
206
+
207
+ Everything else — the pickers, the help, an approval — is opened by a command or by the
208
+ agent and keeps the floor until it is answered or dismissed. Without this distinction
209
+ the `Changed` message that `/effort` itself produces (submitting clears the input) finds
210
+ a card open with no token under the cursor, and closes the card that command just
211
+ opened. That is the whole "nothing happens when I press /effort" bug.
212
+ """
213
+
214
+ SHORTCUT_ROWS: list[Row] = [
215
+ ("?", "this card — backspace closes it"),
216
+ ("/", "commands — type more to filter"),
217
+ ("@", "files to mention — type more to filter"),
218
+ ("!", "run a shell command on the daemon — never sent to the agent"),
219
+ ("enter", "send"),
220
+ ("ctrl+j", "newline (shift+enter and alt+enter where the terminal reports them)"),
221
+ ("shift+tab", "change approval mode"),
222
+ ("esc", "interrupt the turn, or close a card"),
223
+ ("↑ ↓", "move through an open card"),
224
+ ]
225
+
226
+
227
+ def help_rows(**paths: str) -> list[Row]:
228
+ """The shortcuts, then where things are on disk.
229
+
230
+ The paths are here rather than printed at startup because that is the trade: a
231
+ config path nobody asked for is noise on every run, and a config path you cannot
232
+ find when you need it is worse. `?` is where you ask.
233
+ """
234
+ rows = list(SHORTCUT_ROWS)
235
+ rows += [(name, value) for name, value in paths.items() if value]
236
+ return rows
237
+
238
+
239
+ CARD_FOOTER_CHOOSE = "↑↓ then enter · esc to close"
240
+ CARD_FOOTER_CLOSE = "backspace or esc to close"
241
+ CARD_FOOTER_APPROVE = "y approve · n deny · it stays until you answer"
242
+ """No escape hatch listed, because there is not one. An approval card keeps the floor
243
+ until it is answered — dismissing it parked the turn on a reply with nowhere to come
244
+ from. → `cards.Card.dismissable`"""
245
+ CARD_FOOTER_QUESTION = "↑↓ then enter, or just type your own answer"
246
+
247
+ FILES_LOADING = "listing the workspace…"
248
+ NO_FILES_HERE = "Nothing to mention — this workspace is on the daemon's machine."
249
+ NO_MCP_SERVERS = "No MCP servers. Add a .mcp.json to the workspace."
250
+ NO_SKILLS = "No skills found. See docs/guide/skills.md."
251
+
252
+
253
+ def file_rows(paths: list[str]) -> list[Row]:
254
+ return [(path, "") for path in paths]
255
+
256
+
257
+ def mcp_rows(servers: list[dict[str, object]]) -> list[Row]:
258
+ rows: list[Row] = []
259
+ for server in servers:
260
+ tools = server.get("tools") or []
261
+ names = (
262
+ ", ".join(str(tool) for tool in tools) if isinstance(tools, list) else ""
263
+ )
264
+ rows.append(
265
+ (str(server.get("name", "?")), names or "connected, no tools advertised")
266
+ )
267
+ return rows
268
+
269
+
270
+ def skill_rows(skills: list[dict[str, str]]) -> list[Row]:
271
+ return [(skill.get("name", "?"), skill.get("description", "")) for skill in skills]
272
+
273
+
274
+ def skill_request(name: str, argument: str = "") -> str:
275
+ """`/pdf split this in two` as the message the agent receives.
276
+
277
+ A message, not a new protocol verb. The agent already has `skill(name)` and the
278
+ catalogue that says what each one is for; what `/name` adds is saying *which* one
279
+ without a sentence. Anything typed after the name is the task, and it is passed
280
+ through untouched — it is the half only you can write.
281
+ """
282
+ task = argument.strip()
283
+ opening = f'Use the "{name}" skill.'
284
+ return f"{opening} {task}" if task else opening
285
+
286
+
287
+ # ----------------------------------------------------------------------------- tokens
288
+
289
+ NO_TOKENS_YET = "Nothing spent yet — this session has not called a model."
290
+
291
+ TOKEN_FOOTER = "Counted from this session's own usage records. esc to close."
292
+
293
+
294
+ def token_rows(totals: dict[str, int]) -> list[Row]:
295
+ """What the session has spent, as a card.
296
+
297
+ Input and output are the bill. The two cache lines are shown whenever they are
298
+ non-zero because they are the *evidence* for prompt caching — "turn 2 is cheaper" is
299
+ otherwise something you have to take on faith. `calls` is there because the
300
+ interesting number is often per-call, and dividing is easier than counting boxes.
301
+ """
302
+ calls = totals.get("calls", 0)
303
+ if not calls:
304
+ return [(NO_TOKENS_YET, "")]
305
+ written = totals.get("cache_creation_input_tokens", 0)
306
+ read = totals.get("cache_read_input_tokens", 0)
307
+ rows: list[Row] = [
308
+ ("input", f"{totals.get('input_tokens', 0):,}"),
309
+ ("output", f"{totals.get('output_tokens', 0):,}"),
310
+ ]
311
+ if written:
312
+ rows.append(("cache write", f"{written:,}"))
313
+ if read:
314
+ rows.append(("cache read", f"{read:,} — charged at a fraction of input"))
315
+ rows.append(
316
+ ("total", f"{totals.get('input_tokens', 0) + totals.get('output_tokens', 0):,}")
317
+ )
318
+ rows.append(("model calls", f"{calls:,}"))
319
+ return rows
320
+
321
+
322
+ # ------------------------------------------------------------------------------ shell
323
+
324
+ SHELL_PREFIX = "!"
325
+
326
+ SHELL_RUNNING = "running…"
327
+
328
+
329
+ def shell_timed_out(seconds: float) -> str:
330
+ return (
331
+ f"timed out after {seconds:g}s. Raise `shell_timeout` in ui.toml (max 120), or "
332
+ "ask the agent to run it so it can wait."
333
+ )
334
+
335
+
336
+ def shell_failed(exc: Exception) -> str:
337
+ return f"could not run it: {type(exc).__name__}: {exc}"
338
+
339
+
340
+ SHELL_NO_COMMAND = "! on its own does nothing — type a command after it."
341
+
342
+
343
+ # --------------------------------------------------------------------- platform notes
344
+ #
345
+ # Everything the platform did, as one dim rule across the screen. Short, because a rule
346
+ # that wraps is two rules.
347
+
348
+
349
+ def session_started(cwd: object) -> str:
350
+ return f"session in {_tilde(cwd)}"
351
+
352
+
353
+ def session_resumed(session_id: str) -> str:
354
+ return f"resumed {session_id}"
355
+
356
+
357
+ def session_cleared() -> str:
358
+ return "new session — the last one is in /sessions"
359
+
360
+
361
+ def daemon_connected(address: object, embedded: bool) -> str:
362
+ return f"daemon started on {address}" if embedded else f"attached to {address}"
363
+
364
+
365
+ def model_changed(model: str) -> str:
366
+ return f"model → {model}"
367
+
368
+
369
+ def effort_changed(effort: str) -> str:
370
+ return f"effort → {effort}"
371
+
372
+
373
+ def theme_changed(theme: str) -> str:
374
+ return f"theme → {theme}"
375
+
376
+
377
+ def turn_usage(usage: dict[str, object]) -> str:
378
+ """Cache reads are shown because "turn 2 is cheaper" is otherwise unverifiable."""
379
+ cached = int(usage.get("cache_read_input_tokens", 0) or 0)
380
+ tail = f" · {cached} cached" if cached else ""
381
+ return (
382
+ f"{usage.get('input_tokens', 0)} in / {usage.get('output_tokens', 0)} out{tail}"
383
+ )
384
+
385
+
386
+ def supervisor_nudge(text: str) -> str:
387
+ """A redirection, shown in full.
388
+
389
+ Not shortened, unlike a tool preview: the agent is about to change direction and
390
+ this is the only place the reason appears. A session that silently changes course
391
+ is the debuggability problem the docs warn about.
392
+ """
393
+ return f"◆ supervisor — {' '.join(text.split())}"
394
+
395
+
396
+ def inbox_message(sender: str, subject: str, refs: list[str]) -> str:
397
+ """A message from a teammate, as one line in the transcript."""
398
+ tail = f" → {', '.join(refs)}" if refs else ""
399
+ return f"✉ from {sender or 'unknown'}: {subject}{tail}"
400
+
401
+
402
+ STREAM_STOPPED = "stopped"
403
+
404
+ SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
405
+ """The braille spinner in the status line while a turn is in flight.
406
+
407
+ A spinner rather than a word, because the thing it answers is "did my message land?" —
408
+ and a static "working" cannot tell you whether the screen is alive. It goes in the
409
+ status line, directly under the input, rather than into the transcript: the transcript
410
+ is the record of what happened, and a row that appears and disappears is not that.
411
+ """
412
+
413
+ STATUS_WORKING = "working"
414
+
415
+
416
+ def working_status(frame: str) -> str:
417
+ return f"{frame} {STATUS_WORKING} "
418
+
419
+
420
+ # ------------------------------------------------------------------------- warnings
421
+
422
+ STATUS_NO_MODEL = "—"
423
+ SETUP_SKIPPED = "Setup skipped — nothing written. /model sets it up any time."
424
+
425
+
426
+ def no_key_error(provider: str) -> str:
427
+ return f"No API key for {provider}. /model adds one."
428
+
429
+
430
+ def daemon_failed(exc: Exception) -> str:
431
+ return f"Could not reach the agent daemon: {type(exc).__name__}: {exc}"
432
+
433
+
434
+ def unknown_command(name: str) -> str:
435
+ return f"Unknown command /{name} — ? for the list."
436
+
437
+
438
+ def not_connected() -> str:
439
+ return "Not connected to a daemon yet."
440
+
441
+
442
+ APPROVAL_DENIED = "denied"
443
+
444
+ # --------------------------------------------------------------------------- trust
445
+
446
+ TRUST_TITLE = "trust this folder?"
447
+ TRUST_BODY = (
448
+ "The agent will read, write and run commands here with your privileges.\n"
449
+ "Cancel leaves — there is nothing it can usefully do in a folder you have not "
450
+ "approved."
451
+ )
452
+ TRUST_YES = "Trust"
453
+ TRUST_NO = "Cancel"
454
+
455
+ # ------------------------------------------------------------------------- sessions
456
+
457
+ SESSIONS_TITLE = "sessions"
458
+ SESSIONS_EMPTY = "No sessions yet. A session file appears when you say something."
459
+ SESSIONS_INTRO = (
460
+ "Only a parent session can be resumed — a sub-agent's is a transcript to read."
461
+ )
462
+ SESSIONS_RESUME = "Resume"
463
+
464
+
465
+ def session_line(row: dict[str, object]) -> str:
466
+ """One session as a line: id, where, and the first thing that was said."""
467
+ started = str(row.get("ts", ""))[:16].replace("T", " ")
468
+ return f"{row.get('id', '?')} {started} {_tilde(row.get('cwd', ''))}"
469
+
470
+
471
+ def subsession_line(row: dict[str, object]) -> str:
472
+ """A sub-agent's row: its name, then its id.
473
+
474
+ The id is here because the name is not unique — two turns can both spawn an
475
+ `api-scout` — and the id is what you need in order to open the transcript or grep
476
+ the sessions directory for it.
477
+ """
478
+ return f" └ {row.get('agent_name') or 'sub-agent'} {row.get('id', '')}"
479
+
480
+
481
+ def _tilde(path: object) -> str:
482
+ """`~/w/stcode` rather than `/home/you/w/stcode`. The prefix is never the
483
+ interesting part and it is always the longest."""
484
+ text = str(path or "")
485
+ home = os.path.expanduser("~")
486
+ return f"~{text[len(home) :]}" if home and text.startswith(home) else text
487
+
488
+
489
+ # --------------------------------------------------------------------------- connect
490
+
491
+ CONNECT_TITLE = "Connect to an agent daemon"
492
+ CONNECT_INTRO = "Where is the daemon? A container usually exposes TCP on 7717."
493
+ CONNECT_RETRY = "Nothing is listening there. Check the address, or start a daemon."
494
+
495
+ FIELD_TRANSPORT = "Transport"
496
+ FIELD_SOCKET = "Socket path"
497
+ FIELD_HOST = "Host"
498
+ FIELD_PORT = "Port"
499
+
500
+ HOST_PLACEHOLDER = "the container's address, e.g. 10.0.0.4"
501
+ BUTTON_CONNECT = "Connect"
502
+
503
+ TRANSPORT_LABELS: dict[str, str] = {
504
+ "unix": "unix socket (this machine)",
505
+ "tcp": "tcp (a container, or another host)",
506
+ }
507
+
508
+
509
+ def transport_options() -> list[tuple[str, str]]:
510
+ return [(TRANSPORT_LABELS[name], name) for name in ("unix", "tcp")]
511
+
512
+
513
+ def bad_port(value: str) -> str:
514
+ return f"{value!r} is not a port number."
515
+
516
+
517
+ def no_daemon_here(address: object) -> str:
518
+ """`--daemonless` says never start one, so a missing daemon is a question."""
519
+ return f"No daemon is listening on {address}."
520
+
521
+
522
+ DAEMONLESS_CANCELLED = "Not connected. /connect to try another address."
523
+
524
+ # ------------------------------------------------------------------------- approvals
525
+
526
+
527
+ APPROVAL_BODY_LINES = 10
528
+ """How many lines of what you are approving the card shows.
529
+
530
+ Bounded because the card is laid out top to bottom — title, body, the two answers,
531
+ the footer — and a body that grows without limit pushes **deny** and **approve** off
532
+ the bottom of the screen. A forty-line `repl` cell did exactly that, leaving a card
533
+ that asks a question and shows no way to answer it.
534
+
535
+ Ten because the head and the tail of a cell are where its intent is: the imports and
536
+ the first statement say what it is going to do, the last line says what it leaves
537
+ behind. The middle is where a long cell is most repetitive.
538
+ """
539
+
540
+
541
+ def approval_summary(
542
+ tool: str,
543
+ permission: str,
544
+ arguments: dict[str, object],
545
+ *,
546
+ lines: int = APPROVAL_BODY_LINES,
547
+ ) -> str:
548
+ """What is about to happen, in the terms the tool works in.
549
+
550
+ The engine sends validated arguments rather than a sentence precisely so this can
551
+ show a command as a command and a path as a path (`ApprovalRequest`'s docstring).
552
+ """
553
+ if tool in ("bash", "repl"):
554
+ # `command` is `bash`'s parameter and `code` is `repl`'s. Getting this wrong is
555
+ # invisible in review and fatal in use: the card renders with an empty body and
556
+ # asks you to approve running nothing in particular.
557
+ body = str(arguments.get("command") or arguments.get("code") or "")
558
+ elif "path" in arguments:
559
+ body = str(arguments["path"])
560
+ else:
561
+ body = ", ".join(
562
+ f"{key}={_short(str(value))}" for key, value in arguments.items()
563
+ )
564
+ return f"{tool} ({permission})\n{_elide_lines(body, lines)}"
565
+
566
+
567
+ def _elide_lines(text: str, limit: int) -> str:
568
+ """Keep the head and the tail, and say how much is missing.
569
+
570
+ The same discipline as a tool result (invariant 1): elided, never summarised, and
571
+ the count is in the marker so "is that the whole cell?" has an answer on the card.
572
+ """
573
+ rows = text.splitlines()
574
+ if len(rows) <= limit:
575
+ return text
576
+ head = limit - 3
577
+ hidden = len(rows) - head - 2
578
+ return "\n".join([*rows[:head], f"… {hidden} more lines …", *rows[-2:]])
579
+
580
+
581
+ def _short(text: str, limit: int = 60) -> str:
582
+ flat = " ".join(text.split())
583
+ return flat if len(flat) <= limit else flat[: limit - 1] + "…"
584
+
585
+
586
+ # --------------------------------------------------------------------- settings screen
587
+
588
+ SETTINGS_TITLE_FIRST_RUN = "Welcome to stcode — one-time setup"
589
+ SETTINGS_TITLE = "Model settings"
590
+
591
+ SETTINGS_INTRO_FIRST_RUN = (
592
+ "Pick a provider and paste a key — skippable, /model changes it later."
593
+ )
594
+ SETTINGS_INTRO = "Saved to the daemon's config. The key field is write-only; blank keeps the stored one."
595
+
596
+ FIELD_PROVIDER = "Provider"
597
+ FIELD_API_KEY = "API key"
598
+ FIELD_MODEL = "Model"
599
+
600
+ BUTTON_SKIP = "Skip"
601
+ BUTTON_CANCEL = "Cancel"
602
+ BUTTON_SAVE = "Save"
603
+
604
+
605
+ def model_placeholder(default_model: str) -> str:
606
+ return f"blank — {default_model}" if default_model else "the model to use"
607
+
608
+
609
+ def key_placeholder(has_key: bool) -> str:
610
+ return "•••••••• set — type to replace" if has_key else "paste your API key"
611
+
612
+
613
+ def key_hint(key_env: str, *, has_key: bool, editable: bool) -> str:
614
+ """Say where the key comes from, and why the field may be locked."""
615
+ if not editable:
616
+ return "Keys can only be changed from the daemon's own machine (unix socket)."
617
+ if has_key:
618
+ return "A key resolves for this provider. Typing one stores it in the config file (0600)."
619
+ if key_env:
620
+ return f"Or leave blank and export ${key_env} for the daemon."
621
+ return "Stored in the daemon's config file (0600)."
File without changes
@@ -0,0 +1,36 @@
1
+ """Slash-command parsing and client vocabulary helpers."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+ from stcode.cli.models import APPROVAL_MODES, ApprovalMode
6
+
7
+
8
+ def next_approval_mode(current: str) -> ApprovalMode:
9
+ """The next mode in `APPROVAL_MODES`, wrapping around at the end."""
10
+ if current not in APPROVAL_MODES:
11
+ return "suggest"
12
+ return APPROVAL_MODES[(APPROVAL_MODES.index(current) + 1) % len(APPROVAL_MODES)] # type: ignore[arg-type]
13
+
14
+
15
+ def parse_approval_mode(value: str) -> ApprovalMode | None:
16
+ """A typed mode name (`/mode auto`) by exact name or unique prefix, else None."""
17
+ normalized = value.strip().lower().replace("_", "-")
18
+ if normalized in APPROVAL_MODES:
19
+ return normalized # type: ignore[return-value]
20
+ matches = [mode for mode in APPROVAL_MODES if mode.startswith(normalized)]
21
+ return matches[0] if len(matches) == 1 and normalized else None
22
+
23
+
24
+ def parse_command(raw: str) -> tuple[str, str]:
25
+ name, _, argument = raw[1:].partition(" ")
26
+ return name.lower(), argument.strip()
27
+
28
+
29
+ def skill_names(info: dict[str, Any]) -> dict[str, str]:
30
+ """Match case-insensitively while preserving the registry's exact spelling."""
31
+ found: dict[str, str] = {}
32
+ for skill in info.get("skills", []):
33
+ name = str(skill.get("name", ""))
34
+ if name:
35
+ found.setdefault(name.lower(), name)
36
+ return found