rockycode 0.1.0__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 (83) hide show
  1. rockycode/__init__.py +1 -0
  2. rockycode/banner.py +37 -0
  3. rockycode/cli.py +1386 -0
  4. rockycode/config.py +178 -0
  5. rockycode/dream/__init__.py +9 -0
  6. rockycode/dream/core.py +523 -0
  7. rockycode/dream/judge.py +134 -0
  8. rockycode/dream/mining.py +152 -0
  9. rockycode/dream/proposals.py +440 -0
  10. rockycode/engine/__init__.py +10 -0
  11. rockycode/engine/artifact.py +367 -0
  12. rockycode/engine/budget.py +90 -0
  13. rockycode/engine/checks.py +157 -0
  14. rockycode/engine/compaction.py +181 -0
  15. rockycode/engine/container.py +225 -0
  16. rockycode/engine/effort.py +46 -0
  17. rockycode/engine/events.py +101 -0
  18. rockycode/engine/explore.py +592 -0
  19. rockycode/engine/goal.py +541 -0
  20. rockycode/engine/goal_review.py +161 -0
  21. rockycode/engine/goal_session.py +259 -0
  22. rockycode/engine/headless.py +481 -0
  23. rockycode/engine/loop.py +711 -0
  24. rockycode/engine/lsp.py +473 -0
  25. rockycode/engine/mcp.py +364 -0
  26. rockycode/engine/modes.py +123 -0
  27. rockycode/engine/outcome.py +81 -0
  28. rockycode/engine/permission.py +198 -0
  29. rockycode/engine/planmode.py +249 -0
  30. rockycode/engine/providers.py +196 -0
  31. rockycode/engine/redact.py +83 -0
  32. rockycode/engine/safety.py +139 -0
  33. rockycode/engine/sandbox.py +219 -0
  34. rockycode/engine/server.py +431 -0
  35. rockycode/engine/skills.py +178 -0
  36. rockycode/engine/titler.py +46 -0
  37. rockycode/engine/tools.py +479 -0
  38. rockycode/engine/trajectory.py +131 -0
  39. rockycode/engine/web.py +431 -0
  40. rockycode/engine/worktree.py +128 -0
  41. rockycode/memory/__init__.py +7 -0
  42. rockycode/memory/index.py +260 -0
  43. rockycode/memory/store.py +331 -0
  44. rockycode/modes/learn/learn.md +46 -0
  45. rockycode/modes/research/deep-research.md +53 -0
  46. rockycode/modes/research/paper-reading.md +49 -0
  47. rockycode/modes/research/prove.md +60 -0
  48. rockycode/modes/research/whiteboard.md +64 -0
  49. rockycode/onboarding.py +332 -0
  50. rockycode/palette.py +15 -0
  51. rockycode/pricing.py +178 -0
  52. rockycode/prompts/__init__.py +0 -0
  53. rockycode/prompts/rocky.py +257 -0
  54. rockycode/routines.py +287 -0
  55. rockycode/runners/__init__.py +0 -0
  56. rockycode/runners/agent.py +273 -0
  57. rockycode/runners/data.py +61 -0
  58. rockycode/runners/raw.py +176 -0
  59. rockycode/score.py +114 -0
  60. rockycode/session.py +298 -0
  61. rockycode/skills/architecture-viz/SKILL.md +71 -0
  62. rockycode/skills/architecture-viz/template.html +87 -0
  63. rockycode/skills/lean-prover/SKILL.md +155 -0
  64. rockycode/skills/lean-prover/torchlean-api.md +85 -0
  65. rockycode/tui/__init__.py +1 -0
  66. rockycode/tui/app.py +2450 -0
  67. rockycode/tui/exitsheet.py +181 -0
  68. rockycode/tui/goal_screen.py +315 -0
  69. rockycode/tui/mdterm.py +232 -0
  70. rockycode/tui/mdview.py +99 -0
  71. rockycode/tui/modepicker.py +103 -0
  72. rockycode/tui/permission.py +154 -0
  73. rockycode/tui/plangate.py +110 -0
  74. rockycode/tui/prompt_history.py +77 -0
  75. rockycode/tui/proposalcard.py +126 -0
  76. rockycode/tui/resume.py +142 -0
  77. rockycode/tui/rocky_pet.py +96 -0
  78. rockycode/tui/routinecard.py +123 -0
  79. rockycode-0.1.0.dist-info/METADATA +488 -0
  80. rockycode-0.1.0.dist-info/RECORD +83 -0
  81. rockycode-0.1.0.dist-info/WHEEL +4 -0
  82. rockycode-0.1.0.dist-info/entry_points.txt +2 -0
  83. rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,232 @@
1
+ """Markdown → terminal-friendly markdown, applied just before Textual's
2
+ Markdown widget parses it (ported from a research branch).
3
+
4
+ Two failure modes this fixes:
5
+ - Textual intentionally disables auto-links inside fenced code blocks, and
6
+ VS Code's terminal link detector only sees one visual row at a time — a long
7
+ local path that soft-wraps LOOKS complete but clicking captures only the
8
+ first row. So absolute-path link targets are rewritten to real file:// URIs
9
+ (clickable anywhere), and each code fence gets a compact "path links:" line
10
+ appended after it for the paths trapped inside.
11
+ - Long tree/path lines inside fences don't soft-wrap (code blocks clip), so
12
+ tree output loses its tail. Path-looking lines are wrapped at cell width
13
+ (CJK-aware), breaking at / _ - with a continuation indent; code-looking
14
+ lines are left alone (wrapping code would corrupt it).
15
+
16
+ Pure functions, no Textual imports — unit-tested in tests/smoke_mdterm.py.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+ from urllib.parse import urlsplit
24
+ from urllib.request import url2pathname
25
+
26
+ from markdown_it import MarkdownIt
27
+ from rich.cells import cell_len
28
+
29
+ # Absolute paths under the roots real user files live in (macOS + Linux +
30
+ # common containers) — anchored so URL fragments like /v1/messages don't match.
31
+ _ROOT = r"/(?:Users|home|root|tmp|private|var|opt|srv|mnt|etc|workspace)/"
32
+ ABS_PATH_RE = re.compile(r"(?<![\w:])(" + _ROOT + r"[^\s`<>\]\)]+)")
33
+ ABS_LINK_TARGET_RE = re.compile(r"\]\((" + _ROOT + r"[^)\s]+)\)")
34
+ ABS_ANGLE_LINK_TARGET_RE = re.compile(r"\]\(<(" + _ROOT + r"[^>]+)>\)")
35
+ _ROOT_RE = re.compile(_ROOT)
36
+ TREE_OR_PATH_HINT_RE = re.compile(r"(" + _ROOT + r"|[├└│┬┴─]|←|✅|\.md\b|\.txt\b|\.pdf\b|/)")
37
+ CODELIKE_PUNCT_RE = re.compile(r"[{};=()]")
38
+ # `loop.py:128` / `loop.py:128:5` — the label keeps it, the URI can't.
39
+ _LINE_SUFFIX_RE = re.compile(r":\d+(?::\d+)?$")
40
+
41
+
42
+ # Click policy: clicking is for CHECKING things — the
43
+ # paper, the docs, the link — never for RUNNING things. Reading files open
44
+ # with the system default; code/shell/anything else shows a dim note instead.
45
+ READING_SUFFIXES = {
46
+ ".md", ".markdown", ".rst", ".txt", ".pdf", ".html", ".htm",
47
+ ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp",
48
+ }
49
+
50
+
51
+ def link_click_action(href: str) -> tuple[str, str]:
52
+ """Decide what a clicked link may do.
53
+
54
+ Returns ("browser", href) for the web, ("open", file_uri) for an existing
55
+ reading file, ("blocked", path) for code or anything that could execute,
56
+ ("missing", path) for a target that moved or was renamed since the reply.
57
+
58
+ HARD RULE (enforced here, not by the model): "open" is returned only for
59
+ a regular file whose RESOLVED target has a reading suffix and no exec bit.
60
+ Symlinks are judged by what they point at — `innocent.md → evil.command`
61
+ is blocked, because macOS `open` follows the link and would RUN a
62
+ .command/.app/unix-executable target. Nothing a click opens can execute.
63
+ """
64
+ parts = urlsplit(href)
65
+ scheme = parts.scheme.lower()
66
+ if scheme in ("http", "https"):
67
+ return ("browser", href)
68
+ if scheme not in ("", "file"):
69
+ return ("blocked", href)
70
+ path = url2pathname(parts.path)
71
+ if not path.startswith("/"):
72
+ return ("blocked", path)
73
+ real = Path(path).resolve()
74
+ if real.suffix.lower() not in READING_SUFFIXES:
75
+ return ("blocked", path)
76
+ if not real.exists():
77
+ return ("missing", path)
78
+ if not real.is_file() or os.access(real, os.X_OK):
79
+ return ("blocked", path)
80
+ return ("open", real.as_uri())
81
+
82
+
83
+ def rocky_markdown_parser() -> MarkdownIt:
84
+ """markdown-it tuned for the transcript: file:// hrefs allowed (the stock
85
+ security filter drops them and the whole link renders as raw text), fuzzy
86
+ linkify off (`loop.py` is a file, not a Paraguayan website — explicit
87
+ https:// URLs still autolink)."""
88
+ md = MarkdownIt("gfm-like")
89
+ stock_validate = md.validateLink
90
+ md.validateLink = lambda url: url.startswith("file://") or stock_validate(url)
91
+ md.linkify.set({"fuzzy_link": False})
92
+ return md
93
+
94
+
95
+ def _file_uri(path: str) -> str:
96
+ try:
97
+ return Path(path).as_uri()
98
+ except ValueError:
99
+ return path
100
+
101
+
102
+ def _rewrite_link_targets(line: str) -> str:
103
+ # `:128` must come off before as_uri() — quoted to %3A128 it points at a
104
+ # file that doesn't exist. The label lives in [...] and keeps the suffix.
105
+ def uri(m: re.Match) -> str:
106
+ return f"](<{_file_uri(_LINE_SUFFIX_RE.sub('', m.group(1)))}>)"
107
+
108
+ return ABS_LINK_TARGET_RE.sub(uri, ABS_ANGLE_LINK_TARGET_RE.sub(uri, line))
109
+
110
+
111
+ def _markdown_link_text(text: str) -> str:
112
+ return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
113
+
114
+
115
+ def _path_link(path: str) -> str:
116
+ target = _LINE_SUFFIX_RE.sub("", path)
117
+ label = Path(path).name or path
118
+ return f"[{_markdown_link_text(label)}](<{_file_uri(target)}>)"
119
+
120
+
121
+ def _looks_like_tree_or_path_line(line: str) -> bool:
122
+ stripped = line.strip()
123
+ if not stripped:
124
+ return False
125
+ if _ROOT_RE.search(line):
126
+ return True
127
+ if TREE_OR_PATH_HINT_RE.search(line) and not CODELIKE_PUNCT_RE.search(stripped):
128
+ return True
129
+ separators = sum(stripped.count(ch) for ch in "/_-")
130
+ return separators >= 4 and not CODELIKE_PUNCT_RE.search(stripped)
131
+
132
+
133
+ def _take_wrapped_chunk(text: str, max_cells: int) -> tuple[str, str]:
134
+ used = 0
135
+ last_break = -1
136
+ for i, char in enumerate(text):
137
+ used += cell_len(char)
138
+ # Overflow check BEFORE recording this char as a break point — a
139
+ # separator that itself overflows must not become the cut position.
140
+ if used > max_cells:
141
+ if last_break > 0:
142
+ return text[:last_break].rstrip(), text[last_break:].lstrip()
143
+ return "", text
144
+ if char in "/_-" or char.isspace():
145
+ last_break = i + 1
146
+ return text.rstrip(), ""
147
+
148
+
149
+ def _wrap_tree_or_path_line(line: str, max_cells: int) -> list[str]:
150
+ if cell_len(line) <= max_cells or not _looks_like_tree_or_path_line(line):
151
+ return [line]
152
+
153
+ indent_len = len(line) - len(line.lstrip(" "))
154
+ indent = line[:indent_len]
155
+ continuation_indent = indent + " "
156
+ available = max(max_cells - cell_len(indent), 24)
157
+ continuation_available = max(max_cells - cell_len(continuation_indent), 24)
158
+ remaining = line[indent_len:]
159
+ wrapped: list[str] = []
160
+
161
+ while remaining and cell_len(indent + remaining) > max_cells:
162
+ chunk, rest = _take_wrapped_chunk(remaining, available)
163
+ if not chunk:
164
+ break
165
+ wrapped.append(indent + chunk)
166
+ remaining = rest
167
+ indent = continuation_indent
168
+ available = continuation_available
169
+
170
+ if not wrapped:
171
+ return [line]
172
+ if remaining:
173
+ wrapped.append(indent + remaining)
174
+ return wrapped
175
+
176
+
177
+ def enhance_markdown(
178
+ markdown: str, *, width: int | None = None, only_existing: bool = False
179
+ ) -> str:
180
+ """Rewrite abs-path link targets to file:// URIs (outside fences only),
181
+ append a "path links:" line after each fence that trapped paths, and wrap
182
+ path-looking fence lines at `width` cells (None = don't wrap). The original
183
+ text is otherwise untouched — path-free markdown passes through
184
+ byte-identical. With only_existing=True, fence-collected paths get a link
185
+ only if they exist on this machine — dead, renamed, container-only, or
186
+ regex-truncated paths are silently skipped (not pure: reads the fs)."""
187
+ code_wrap_width = None if width is None else max(min(width - 8, 88), 28)
188
+
189
+ out: list[str] = []
190
+ in_fence = False
191
+ fence_marker = ""
192
+ fence_paths: list[str] = []
193
+
194
+ for line in markdown.splitlines():
195
+ stripped = line.lstrip()
196
+ if not in_fence and (stripped.startswith("```") or stripped.startswith("~~~")):
197
+ in_fence = True
198
+ fence_marker = stripped[:3]
199
+ fence_paths = []
200
+ out.append(line)
201
+ continue
202
+
203
+ if in_fence:
204
+ if stripped.startswith(fence_marker):
205
+ out.append(line)
206
+ unique_paths = list(dict.fromkeys(fence_paths))
207
+ if only_existing:
208
+ unique_paths = [
209
+ p for p in unique_paths
210
+ if Path(_LINE_SUFFIX_RE.sub("", p)).exists()
211
+ ]
212
+ if unique_paths:
213
+ links = " · ".join(_path_link(path) for path in unique_paths[:5])
214
+ out.append("")
215
+ out.append(f"> path links: {links}")
216
+ in_fence = False
217
+ fence_marker = ""
218
+ fence_paths = []
219
+ continue
220
+ fence_paths.extend(
221
+ match.group(1).rstrip(".,;:!?") for match in ABS_PATH_RE.finditer(line)
222
+ )
223
+ if code_wrap_width is not None:
224
+ out.extend(_wrap_tree_or_path_line(line, code_wrap_width))
225
+ continue
226
+ out.append(line)
227
+ continue
228
+
229
+ # Outside fences only — a fenced markdown example must stay verbatim.
230
+ out.append(_rewrite_link_targets(line))
231
+
232
+ return "\n".join(out)
@@ -0,0 +1,99 @@
1
+ """The doc dock — read a paper or doc BESIDE the chat, never over it.
2
+
3
+ Clicking a text-family link (see TEXT_DOC_SUFFIXES) docks the rendered file
4
+ in a right-hand pane instead of launching an external app: the transcript
5
+ stays live and visible, scrolls independently, and gets its exact width back
6
+ on close. Nothing here ever touches engine history — the dock is pure
7
+ display, so the prompt-cache prefix is byte-identical with or without it.
8
+
9
+ The click policy stays upstream: the app routes a file here only after
10
+ mdterm.link_click_action returned "open" (resolved regular file, reading
11
+ suffix, no exec bit). The dock can only render — it has no way to run.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from rich.markup import escape
18
+ from textual.containers import Vertical, VerticalScroll
19
+ from textual.widgets import Markdown, Static
20
+
21
+ from rockycode.palette import MUTED
22
+ from rockycode.tui.mdterm import rocky_markdown_parser
23
+
24
+ # What docks in-app: files a terminal can honestly render. Everything else
25
+ # that passed the click policy (pdf, images, html) still goes to `open`.
26
+ TEXT_DOC_SUFFIXES = {".md", ".markdown", ".rst", ".txt"}
27
+
28
+ READ_CAP = 200_000 # chars — keeps a giant log paste from freezing the dock
29
+ WIDTHS = ("45%", "62%", "30%") # ⇄ cycles; the CSS default matches WIDTHS[0]
30
+
31
+
32
+ def read_doc(path: Path, cap: int = READ_CAP) -> str:
33
+ """File → markdown for the dock. .txt is fenced so it stays verbatim;
34
+ oversized files are cut at `cap` chars with an honest tail note."""
35
+ text = path.read_text(errors="replace")
36
+ truncated = len(text) > cap
37
+ if truncated:
38
+ text = text[:cap]
39
+ if path.suffix.lower() == ".txt":
40
+ text = f"````text\n{text}\n````"
41
+ if truncated:
42
+ text += f"\n\n> ✂ showing the first {cap:,} characters — the file has more."
43
+ return text
44
+
45
+
46
+ class DocDock(Vertical):
47
+ """Right-hand reading pane. Keeps a back-stack so links inside a docked
48
+ doc (routed through the same click policy by the app) navigate in place."""
49
+
50
+ def __init__(self) -> None:
51
+ super().__init__(id="docdock")
52
+ self._stack: list[Path] = []
53
+ self.width_i = 0
54
+ self.is_full = False
55
+
56
+ @property
57
+ def current(self) -> Path | None:
58
+ return self._stack[-1] if self._stack else None
59
+
60
+ def compose(self):
61
+ yield Static("", id="docdock-head")
62
+ yield VerticalScroll(
63
+ Markdown("", parser_factory=rocky_markdown_parser,
64
+ open_links=False, id="docdock-md"),
65
+ id="docdock-body",
66
+ )
67
+
68
+ async def load(self, path: Path) -> None:
69
+ self._stack.append(path)
70
+ await self._show()
71
+
72
+ async def back(self) -> bool:
73
+ if len(self._stack) < 2:
74
+ return False
75
+ self._stack.pop()
76
+ await self._show()
77
+ return True
78
+
79
+ async def _show(self) -> None:
80
+ path = self._stack[-1]
81
+ await self.query_one("#docdock-md", Markdown).update(read_doc(path))
82
+ self.border_title = f"♪ {escape(path.name)}"
83
+ back = "[@click=app.doc_back]← back[/] · " if len(self._stack) > 1 else ""
84
+ self.query_one("#docdock-head", Static).update(
85
+ f"[{MUTED}]{back}[@click=app.doc_wider]⇄ width[/] · "
86
+ f"[@click=app.doc_full]⛶ full[/] · "
87
+ f"[@click=app.doc_close]✕ close · esc[/] · {escape(str(path))}[/]"
88
+ )
89
+ self.query_one("#docdock-body", VerticalScroll).scroll_home(animate=False)
90
+
91
+ # Wheel over the header/border must scroll the DOC, not fall through to
92
+ # the app-level catch-all that scrolls the transcript.
93
+ def on_mouse_scroll_up(self, event) -> None:
94
+ self.query_one("#docdock-body", VerticalScroll).scroll_relative(y=-3, animate=False)
95
+ event.stop()
96
+
97
+ def on_mouse_scroll_down(self, event) -> None:
98
+ self.query_one("#docdock-body", VerticalScroll).scroll_relative(y=3, animate=False)
99
+ event.stop()
@@ -0,0 +1,103 @@
1
+ """The mode picker: bare /research (or /learn with local additions) opens this
2
+ — browse the family's modes with a when-to-use preview, so nobody has to
3
+ remember type names. Heavy users graduate to `/research <type>`; the entry
4
+ card teaches that at the moment of use.
5
+
6
+ dismiss() value: a Mode to apply, the string "off" to leave the current mode,
7
+ or None on cancel.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from textual import on
12
+ from textual.containers import Vertical
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import OptionList, Static
15
+ from textual.widgets.option_list import Option
16
+
17
+ from rich.markup import escape
18
+
19
+ from rockycode.engine.modes import Mode
20
+ from rockycode.palette import LAVENDER, MUTED
21
+
22
+
23
+ class ModePicker(ModalScreen):
24
+ BINDINGS = [
25
+ ("escape", "cancel", "cancel"),
26
+ ("down", "cursor_down", "↓"),
27
+ ("up", "cursor_up", "↑"),
28
+ ]
29
+
30
+ DEFAULT_CSS = """
31
+ ModePicker { align: center middle; }
32
+ ModePicker > #picker {
33
+ width: 76%;
34
+ max-width: 100;
35
+ height: auto;
36
+ max-height: 80%;
37
+ padding: 1 2;
38
+ background: $surface;
39
+ border: round $primary;
40
+ border-title-color: $text-muted;
41
+ }
42
+ ModePicker OptionList { height: auto; max-height: 12; background: $surface; }
43
+ ModePicker #mode-preview { height: auto; margin-top: 1; color: $text-muted; }
44
+ ModePicker #mode-guide { height: 1; margin-top: 1; color: $text-muted; }
45
+ """
46
+
47
+ def __init__(self, family: str, modes: list[Mode], *, active: str | None = None) -> None:
48
+ super().__init__()
49
+ self.family = family
50
+ self.modes = modes
51
+ self.active = active # currently active mode name (any family) or None
52
+
53
+ def compose(self):
54
+ with Vertical(id="picker") as box:
55
+ box.border_title = f"/{self.family} — pick how we work"
56
+ yield OptionList(id="mode-list")
57
+ yield Static("", id="mode-preview")
58
+ yield Static(
59
+ f"[{MUTED}]↑↓ move · ↵ apply · esc cancel · "
60
+ f"direct next time: [{LAVENDER}]/{self.family} <name>[/][/]",
61
+ id="mode-guide",
62
+ )
63
+
64
+ def on_mount(self) -> None:
65
+ ol = self.query_one("#mode-list", OptionList)
66
+ if self.active:
67
+ ol.add_option(Option(f" ◉ off{'':<18} — back to normal rocky (now: {self.active})", id="off"))
68
+ for i, m in enumerate(self.modes):
69
+ marker = "▸ " if m.name == self.active else " "
70
+ local = "" if m.builtin else " · project-local"
71
+ ol.add_option(Option(f"{marker}{m.name:<22} — {escape(m.description)}{local}", id=str(i)))
72
+ ol.highlighted = 0
73
+ ol.focus()
74
+ self._show_preview(0 if not self.active else None)
75
+
76
+ def _show_preview(self, idx: int | None) -> None:
77
+ pv = self.query_one("#mode-preview", Static)
78
+ if idx is None or not (0 <= idx < len(self.modes)):
79
+ pv.update(f"[{MUTED}]leave the current mode — rocky goes back to its plain contract.[/]")
80
+ return
81
+ pv.update(f"[{MUTED}]{escape(self.modes[idx].preview)}[/]")
82
+
83
+ @on(OptionList.OptionHighlighted)
84
+ def _on_highlight(self, e: OptionList.OptionHighlighted) -> None:
85
+ oid = e.option.id
86
+ self._show_preview(int(oid) if oid and oid.isdigit() else None)
87
+
88
+ @on(OptionList.OptionSelected)
89
+ def _on_select(self, e: OptionList.OptionSelected) -> None:
90
+ oid = e.option.id
91
+ if oid == "off":
92
+ self.dismiss("off")
93
+ elif oid and oid.isdigit():
94
+ self.dismiss(self.modes[int(oid)])
95
+
96
+ def action_cancel(self) -> None:
97
+ self.dismiss(None)
98
+
99
+ def action_cursor_down(self) -> None:
100
+ self.query_one("#mode-list", OptionList).action_cursor_down()
101
+
102
+ def action_cursor_up(self) -> None:
103
+ self.query_one("#mode-list", OptionList).action_cursor_up()
@@ -0,0 +1,154 @@
1
+ """The inline tool-approval prompt + the turn-cancel signal.
2
+
3
+ InlineApproval asks the user to approve one tool call and resolves a Future with
4
+ one of "once" | "session" | "deny" | "cancel". Unlike a modal it is mounted
5
+ *inside* the chat, docked just above the input, so the transcript stays fully
6
+ visible and scrollable (wheel / pageup / shift+↑↓) while a tool waits — you can
7
+ scroll up to re-read the history that led here, then come back and decide.
8
+
9
+ Keyboard-driven like Codex / Claude Code: ↑↓ move the highlight, Enter selects it
10
+ (default = run once, so Enter approves); y/a/n are accelerators; Esc maps to
11
+ "cancel" (abort the whole turn) — the app's approver turns that into CancelTurn,
12
+ which propagates out of Engine.run_turn so its tool-loop finally can keep history
13
+ valid. The transcript-scroll keys live on the App as priority bindings, so they
14
+ keep working even while this prompt holds focus.
15
+
16
+ Styling matches the app: explicit hex from the palette, soft purple, minimal.
17
+ The OptionList pattern mirrors the resume picker (rockycode/tui/resume.py).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from typing import Optional
23
+
24
+ from rich.markup import escape
25
+ from textual.containers import Vertical
26
+ from textual.widgets import Static
27
+
28
+ from rockycode.palette import LAVENDER, MUTED, RED
29
+
30
+
31
+ class CancelTurn(Exception):
32
+ """Raised by the TUI approver when the user chooses 'cancel turn' (Esc) in
33
+ the approval prompt. Propagates out of Engine.run_turn; the tool-loop
34
+ finally backfills tool responses so history stays API-valid."""
35
+
36
+
37
+ class InlineApproval(Vertical):
38
+ """Approve one tool call, rendered INLINE at the bottom of the chat instead
39
+ of a modal. Resolves `future` with 'once' | 'session' | 'deny' | 'cancel'.
40
+
41
+ Enter selects the highlighted choice (default run once → Enter approves),
42
+ ↑↓ move it, y/a/n are accelerators, Esc cancels the whole turn.
43
+
44
+ The choices are plain Static rows (not an OptionList) *on purpose*: an
45
+ OptionList swallows PageUp/PageDown for its own paging, which would block
46
+ the App's transcript-scroll bindings while this prompt holds focus. Binding
47
+ only the keys we use here lets pgup/pgdn/shift+↑↓ fall through to the App so
48
+ the user can still scroll the history that led to this call, then decide.
49
+ """
50
+
51
+ can_focus = True
52
+
53
+ BINDINGS = [
54
+ ("up", "move(-1)", "up"),
55
+ ("down", "move(1)", "down"),
56
+ ("enter", "confirm", "select"),
57
+ ("escape", "cancel_turn", "cancel turn"),
58
+ ("y", "pick('once')", "run once"),
59
+ ("a", "pick('session')", "allow"),
60
+ ("n", "pick('deny')", "deny"),
61
+ ]
62
+
63
+ DEFAULT_CSS = """
64
+ InlineApproval {
65
+ height: auto;
66
+ margin: 0 1 1 1;
67
+ padding: 1 2;
68
+ background: $surface;
69
+ border: round $primary;
70
+ border-title-color: $text-muted;
71
+ border-subtitle-color: $text-muted;
72
+ }
73
+ InlineApproval:focus { border: round $primary; }
74
+ InlineApproval #perm-cmd { margin: 1 0; color: $text; }
75
+ InlineApproval #perm-warn { margin-bottom: 1; }
76
+ InlineApproval .perm-opt { height: 1; }
77
+ InlineApproval #perm-keys { color: $text-muted; margin-top: 1; }
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ tool: str,
83
+ detail: str,
84
+ risk: str,
85
+ warning: Optional[str],
86
+ future: "asyncio.Future[str]",
87
+ session_label: Optional[str] = None,
88
+ ) -> None:
89
+ super().__init__()
90
+ self._tool = tool
91
+ self._detail = detail or "(no arguments)"
92
+ self._risk = risk
93
+ self._warning = warning
94
+ self._future = future
95
+ # The session grant is offered ONLY when session_label is given. A
96
+ # DANGEROUS bash command passes None → no "allow for session" at all
97
+ # (you can never blanket-grant a risky command); it's run-once or deny.
98
+ self._choices = [("once", "▶ Run once")]
99
+ if session_label:
100
+ self._choices.append(("session", f"✓ {session_label}"))
101
+ self._choices.append(("deny", "✗ Deny"))
102
+ self._idx = 0 # default = run once → Enter approves
103
+
104
+ def compose(self):
105
+ # markup=False: the raw command/url is shown literally, never parsed.
106
+ yield Static(self._detail, id="perm-cmd", markup=False)
107
+ if self._warning:
108
+ yield Static(f"[bold {RED}]⚠ {escape(self._warning)}[/]", id="perm-warn")
109
+ for i in range(len(self._choices)):
110
+ yield Static("", id=f"perm-opt-{i}", classes="perm-opt")
111
+ yield Static(
112
+ f"[{LAVENDER}]↑↓[/] choose [{LAVENDER}]↵[/] select "
113
+ f"[{LAVENDER}]y[/]/[{LAVENDER}]a[/]/[{LAVENDER}]n[/] quick "
114
+ f"[{LAVENDER}]esc[/] cancel turn "
115
+ f"[{LAVENDER}]pgup[/]/[{LAVENDER}]shift+↑↓[/] scroll history",
116
+ id="perm-keys",
117
+ )
118
+
119
+ def on_mount(self) -> None:
120
+ self.border_title = f"approve tool · {self._tool}"
121
+ self.border_subtitle = f"risk: {self._risk}"
122
+ self._render_choices()
123
+ self.focus()
124
+
125
+ def _render_choices(self) -> None:
126
+ for i, (_value, text) in enumerate(self._choices):
127
+ row = self.query_one(f"#perm-opt-{i}", Static)
128
+ if i == self._idx:
129
+ row.update(f"[b {LAVENDER}]▸ {text}[/]")
130
+ else:
131
+ row.update(f"[{MUTED}] {text}[/]")
132
+
133
+ def action_move(self, delta: int) -> None:
134
+ self._idx = (self._idx + delta) % len(self._choices)
135
+ self._render_choices()
136
+
137
+ def action_confirm(self) -> None:
138
+ self._resolve(self._choices[self._idx][0])
139
+
140
+ def action_pick(self, choice: str) -> None:
141
+ # 'a' must no-op when the session option isn't offered (dangerous bash).
142
+ if choice not in {v for v, _ in self._choices}:
143
+ return
144
+ self._resolve(choice)
145
+
146
+ def action_cancel_turn(self) -> None:
147
+ self._resolve("cancel")
148
+
149
+ def _resolve(self, value: str) -> None:
150
+ """Deliver the choice to the awaiting approver. Idempotent — a stray
151
+ second key (e.g. Enter after y) must not crash on an already-set Future.
152
+ Removal is left to the awaiter's finally so there's no double-remove."""
153
+ if not self._future.done():
154
+ self._future.set_result(value)