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.
- rockycode/__init__.py +1 -0
- rockycode/banner.py +37 -0
- rockycode/cli.py +1386 -0
- rockycode/config.py +178 -0
- rockycode/dream/__init__.py +9 -0
- rockycode/dream/core.py +523 -0
- rockycode/dream/judge.py +134 -0
- rockycode/dream/mining.py +152 -0
- rockycode/dream/proposals.py +440 -0
- rockycode/engine/__init__.py +10 -0
- rockycode/engine/artifact.py +367 -0
- rockycode/engine/budget.py +90 -0
- rockycode/engine/checks.py +157 -0
- rockycode/engine/compaction.py +181 -0
- rockycode/engine/container.py +225 -0
- rockycode/engine/effort.py +46 -0
- rockycode/engine/events.py +101 -0
- rockycode/engine/explore.py +592 -0
- rockycode/engine/goal.py +541 -0
- rockycode/engine/goal_review.py +161 -0
- rockycode/engine/goal_session.py +259 -0
- rockycode/engine/headless.py +481 -0
- rockycode/engine/loop.py +711 -0
- rockycode/engine/lsp.py +473 -0
- rockycode/engine/mcp.py +364 -0
- rockycode/engine/modes.py +123 -0
- rockycode/engine/outcome.py +81 -0
- rockycode/engine/permission.py +198 -0
- rockycode/engine/planmode.py +249 -0
- rockycode/engine/providers.py +196 -0
- rockycode/engine/redact.py +83 -0
- rockycode/engine/safety.py +139 -0
- rockycode/engine/sandbox.py +219 -0
- rockycode/engine/server.py +431 -0
- rockycode/engine/skills.py +178 -0
- rockycode/engine/titler.py +46 -0
- rockycode/engine/tools.py +479 -0
- rockycode/engine/trajectory.py +131 -0
- rockycode/engine/web.py +431 -0
- rockycode/engine/worktree.py +128 -0
- rockycode/memory/__init__.py +7 -0
- rockycode/memory/index.py +260 -0
- rockycode/memory/store.py +331 -0
- rockycode/modes/learn/learn.md +46 -0
- rockycode/modes/research/deep-research.md +53 -0
- rockycode/modes/research/paper-reading.md +49 -0
- rockycode/modes/research/prove.md +60 -0
- rockycode/modes/research/whiteboard.md +64 -0
- rockycode/onboarding.py +332 -0
- rockycode/palette.py +15 -0
- rockycode/pricing.py +178 -0
- rockycode/prompts/__init__.py +0 -0
- rockycode/prompts/rocky.py +257 -0
- rockycode/routines.py +287 -0
- rockycode/runners/__init__.py +0 -0
- rockycode/runners/agent.py +273 -0
- rockycode/runners/data.py +61 -0
- rockycode/runners/raw.py +176 -0
- rockycode/score.py +114 -0
- rockycode/session.py +298 -0
- rockycode/skills/architecture-viz/SKILL.md +71 -0
- rockycode/skills/architecture-viz/template.html +87 -0
- rockycode/skills/lean-prover/SKILL.md +155 -0
- rockycode/skills/lean-prover/torchlean-api.md +85 -0
- rockycode/tui/__init__.py +1 -0
- rockycode/tui/app.py +2450 -0
- rockycode/tui/exitsheet.py +181 -0
- rockycode/tui/goal_screen.py +315 -0
- rockycode/tui/mdterm.py +232 -0
- rockycode/tui/mdview.py +99 -0
- rockycode/tui/modepicker.py +103 -0
- rockycode/tui/permission.py +154 -0
- rockycode/tui/plangate.py +110 -0
- rockycode/tui/prompt_history.py +77 -0
- rockycode/tui/proposalcard.py +126 -0
- rockycode/tui/resume.py +142 -0
- rockycode/tui/rocky_pet.py +96 -0
- rockycode/tui/routinecard.py +123 -0
- rockycode-0.1.0.dist-info/METADATA +488 -0
- rockycode-0.1.0.dist-info/RECORD +83 -0
- rockycode-0.1.0.dist-info/WHEEL +4 -0
- rockycode-0.1.0.dist-info/entry_points.txt +2 -0
- rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
rockycode/tui/mdterm.py
ADDED
|
@@ -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)
|
rockycode/tui/mdview.py
ADDED
|
@@ -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)
|