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/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
@@ -0,0 +1,3 @@
1
+ Screen {
2
+ background: $background;
3
+ }
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"]