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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The plan-approval gate — inline, at the bottom of the chat.
|
|
2
|
+
|
|
3
|
+
Mounted (not a modal) when a plan-mode turn ends having CHANGED the plan file —
|
|
4
|
+
the file write IS the handoff signal (no update_plan tool, no tagged-text
|
|
5
|
+
parsing; see docs/plan-mode-design.md). Resolves the passed-in Future with
|
|
6
|
+
"approve" | "discuss" | "exit" | "dismiss", then the caller removes it.
|
|
7
|
+
|
|
8
|
+
Inline (like InlineApproval) so the transcript stays visible and scrollable
|
|
9
|
+
while you weigh the plan. Verbs follow the goal-mode gate you already know:
|
|
10
|
+
y = approve & build, e = discuss/edit, n = leave plan mode; Esc keeps planning.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
from textual.containers import Vertical
|
|
17
|
+
from textual.widgets import Static
|
|
18
|
+
|
|
19
|
+
from rockycode.palette import LAVENDER, MUTED, VIOLET
|
|
20
|
+
|
|
21
|
+
_PREVIEW_LINES = 14
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InlinePlanGate(Vertical):
|
|
25
|
+
"""Approve a drafted plan. Resolves `future` with the choice; choices are
|
|
26
|
+
plain Static rows (not an OptionList — it swallows pgup/pgdn, and the
|
|
27
|
+
transcript-scroll keys must keep working while this holds focus)."""
|
|
28
|
+
|
|
29
|
+
can_focus = True
|
|
30
|
+
|
|
31
|
+
BINDINGS = [
|
|
32
|
+
("up", "move(-1)", "up"),
|
|
33
|
+
("down", "move(1)", "down"),
|
|
34
|
+
("enter", "confirm", "select"),
|
|
35
|
+
("y", "pick('approve')", "approve"),
|
|
36
|
+
("g", "pick('goal')", "run as goal"),
|
|
37
|
+
("e", "pick('discuss')", "discuss"),
|
|
38
|
+
("n", "pick('exit')", "leave"),
|
|
39
|
+
("escape", "pick('dismiss')", "keep planning"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
DEFAULT_CSS = """
|
|
43
|
+
InlinePlanGate {
|
|
44
|
+
height: auto;
|
|
45
|
+
margin: 0 1 1 1;
|
|
46
|
+
padding: 1 2;
|
|
47
|
+
background: $surface;
|
|
48
|
+
border: round $primary;
|
|
49
|
+
border-title-color: $text-muted;
|
|
50
|
+
border-subtitle-color: $text-muted;
|
|
51
|
+
}
|
|
52
|
+
InlinePlanGate #plan-preview { margin: 1 0; color: $text-muted; }
|
|
53
|
+
InlinePlanGate .plan-opt { height: 1; }
|
|
54
|
+
InlinePlanGate #plan-keys { color: $text-muted; margin-top: 1; }
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_CHOICES = (
|
|
58
|
+
("approve", "▶ Approve & build here"),
|
|
59
|
+
("goal", "◆ Run as goal (sandbox, autonomous)"),
|
|
60
|
+
("discuss", "✎ Discuss / edit the plan"),
|
|
61
|
+
("exit", "✗ Leave plan mode (keep the file)"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(self, rel_path: str, plan_text: str, future: "asyncio.Future[str]") -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self._rel = rel_path
|
|
67
|
+
self._plan = plan_text
|
|
68
|
+
self._future = future
|
|
69
|
+
self._idx = 0
|
|
70
|
+
|
|
71
|
+
def compose(self):
|
|
72
|
+
lines = self._plan.splitlines()
|
|
73
|
+
preview = "\n".join(lines[:_PREVIEW_LINES])
|
|
74
|
+
if len(lines) > _PREVIEW_LINES:
|
|
75
|
+
preview += f"\n… (+{len(lines) - _PREVIEW_LINES} more lines in the file)"
|
|
76
|
+
# markup=False: the plan text is shown literally, never parsed.
|
|
77
|
+
yield Static(preview or "(empty plan)", id="plan-preview", markup=False)
|
|
78
|
+
for i in range(len(self._CHOICES)):
|
|
79
|
+
yield Static("", id=f"plan-opt-{i}", classes="plan-opt")
|
|
80
|
+
yield Static(
|
|
81
|
+
f"[{LAVENDER}]↑↓[/] choose [{LAVENDER}]↵[/] select "
|
|
82
|
+
f"[{LAVENDER}]y[/] build [{LAVENDER}]g[/] goal [{LAVENDER}]e[/] discuss "
|
|
83
|
+
f"[{LAVENDER}]n[/] leave [{LAVENDER}]esc[/] keep planning [dim]· pgup/pgdn scroll[/]",
|
|
84
|
+
id="plan-keys",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def on_mount(self) -> None:
|
|
88
|
+
self.border_title = f"📋 review the plan · {self._rel}"
|
|
89
|
+
self.border_subtitle = "read-only until you approve"
|
|
90
|
+
self._render_choices()
|
|
91
|
+
self.focus()
|
|
92
|
+
|
|
93
|
+
def _render_choices(self) -> None:
|
|
94
|
+
for i, (_v, label) in enumerate(self._CHOICES):
|
|
95
|
+
row = self.query_one(f"#plan-opt-{i}", Static)
|
|
96
|
+
row.update(f"[b {VIOLET}]▸ {label}[/]" if i == self._idx else f"[{MUTED}] {label}[/]")
|
|
97
|
+
|
|
98
|
+
def action_move(self, delta: int) -> None:
|
|
99
|
+
self._idx = (self._idx + delta) % len(self._CHOICES)
|
|
100
|
+
self._render_choices()
|
|
101
|
+
|
|
102
|
+
def action_confirm(self) -> None:
|
|
103
|
+
self._resolve(self._CHOICES[self._idx][0])
|
|
104
|
+
|
|
105
|
+
def action_pick(self, choice: str) -> None:
|
|
106
|
+
self._resolve(choice)
|
|
107
|
+
|
|
108
|
+
def _resolve(self, value: str) -> None:
|
|
109
|
+
if not self._future.done():
|
|
110
|
+
self._future.set_result(value)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Persistent prompt history for the chat input — the up-arrow list survives
|
|
2
|
+
restarts (ported from a research branch, persistence only: draft
|
|
3
|
+
handling and the visual-row navigation stay in ChatInput, which already does
|
|
4
|
+
them better).
|
|
5
|
+
|
|
6
|
+
ONE global file, like shell history — renaming or moving a project never loses
|
|
7
|
+
it, and prompts can't end up committed inside a repo's tree. Honors
|
|
8
|
+
$ROCKYCODE_HOME, same convention as session.py / trajectory.py.
|
|
9
|
+
|
|
10
|
+
Storage is append-only JSONL ({"input": ...} per line, the research branch's
|
|
11
|
+
format), adjacent-deduped, capped at LIMIT by a full rewrite once over. All IO
|
|
12
|
+
is best-effort: a broken disk or a torn line must never break typing.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_path() -> Path:
|
|
22
|
+
home = Path(os.environ.get("ROCKYCODE_HOME") or Path.home() / ".rockycode")
|
|
23
|
+
return home / "prompt-history.jsonl"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PromptHistory:
|
|
27
|
+
"""Load-on-start, append-on-submit. `items` is oldest-first, ready to seed
|
|
28
|
+
ChatInput._history directly."""
|
|
29
|
+
|
|
30
|
+
LIMIT = 200
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
33
|
+
self.path = path or default_path()
|
|
34
|
+
self.items: list[str] = []
|
|
35
|
+
self._load()
|
|
36
|
+
|
|
37
|
+
def _load(self) -> None:
|
|
38
|
+
try:
|
|
39
|
+
lines = self.path.read_text().splitlines()
|
|
40
|
+
except OSError:
|
|
41
|
+
return
|
|
42
|
+
items: list[str] = []
|
|
43
|
+
for line in lines:
|
|
44
|
+
try:
|
|
45
|
+
value = json.loads(line)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
continue # torn write / hand edit — skip the line, keep the rest
|
|
48
|
+
text = value.get("input") if isinstance(value, dict) else value
|
|
49
|
+
if isinstance(text, str) and text.strip():
|
|
50
|
+
if not items or items[-1] != text:
|
|
51
|
+
items.append(text)
|
|
52
|
+
self.items = items[-self.LIMIT:]
|
|
53
|
+
|
|
54
|
+
def _rewrite(self) -> None:
|
|
55
|
+
try:
|
|
56
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
self.path.write_text(
|
|
58
|
+
"".join(json.dumps({"input": item}, ensure_ascii=False) + "\n"
|
|
59
|
+
for item in self.items))
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def append(self, text: str) -> None:
|
|
64
|
+
text = text.strip()
|
|
65
|
+
if not text or (self.items and self.items[-1] == text):
|
|
66
|
+
return
|
|
67
|
+
self.items.append(text)
|
|
68
|
+
if len(self.items) > self.LIMIT:
|
|
69
|
+
self.items = self.items[-self.LIMIT:]
|
|
70
|
+
self._rewrite()
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with self.path.open("a") as f:
|
|
75
|
+
f.write(json.dumps({"input": text}, ensure_ascii=False) + "\n")
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The proposal review card — one dream-drafted skill, approve or archive.
|
|
2
|
+
|
|
3
|
+
Mounted inline above the input by /proposals (one card per pending proposal,
|
|
4
|
+
walked in order). Resolves the passed-in Future with "install" | "archive" |
|
|
5
|
+
"later". Nothing self-installs: this card IS the trust boundary the
|
|
6
|
+
self-evolve design locked — dream drafts, the human decides.
|
|
7
|
+
|
|
8
|
+
Rows are clickable (house rule) with paired key accelerators: ↑↓ + Enter,
|
|
9
|
+
y = install, n = archive, Esc = later (keeps the proposal pending).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
|
|
15
|
+
from textual.containers import Vertical
|
|
16
|
+
from textual.widgets import Static
|
|
17
|
+
|
|
18
|
+
from rockycode.palette import LAVENDER, MUTED, VIOLET
|
|
19
|
+
|
|
20
|
+
_PREVIEW_LINES = 14
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _ChoiceRow(Static):
|
|
24
|
+
def __init__(self, value: str, idx: int, card: "ProposalCard") -> None:
|
|
25
|
+
super().__init__("", id=f"prop-opt-{idx}", classes="prop-opt")
|
|
26
|
+
self._value = value
|
|
27
|
+
self._card = card
|
|
28
|
+
|
|
29
|
+
def on_click(self) -> None:
|
|
30
|
+
self._card.pick(self._value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProposalCard(Vertical):
|
|
34
|
+
"""Review one proposal. Resolves `future` with the choice."""
|
|
35
|
+
|
|
36
|
+
can_focus = True
|
|
37
|
+
|
|
38
|
+
BINDINGS = [
|
|
39
|
+
("up", "move(-1)", "up"),
|
|
40
|
+
("down", "move(1)", "down"),
|
|
41
|
+
("enter", "confirm", "select"),
|
|
42
|
+
("y", "pick('install')", "install"),
|
|
43
|
+
("n", "pick('archive')", "archive"),
|
|
44
|
+
("escape", "pick('later')", "later"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
DEFAULT_CSS = """
|
|
48
|
+
ProposalCard {
|
|
49
|
+
height: auto;
|
|
50
|
+
margin: 0 1 1 1;
|
|
51
|
+
padding: 1 2;
|
|
52
|
+
background: $surface;
|
|
53
|
+
border: round $primary;
|
|
54
|
+
border-title-color: $text-muted;
|
|
55
|
+
border-subtitle-color: $text-muted;
|
|
56
|
+
}
|
|
57
|
+
ProposalCard #prop-why { color: $text-muted; }
|
|
58
|
+
ProposalCard #prop-preview { margin: 1 0; color: $text-muted; }
|
|
59
|
+
ProposalCard .prop-opt { height: 1; }
|
|
60
|
+
ProposalCard #prop-keys { color: $text-muted; margin-top: 1; }
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, proposal, future: "asyncio.Future[str]") -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self._p = proposal
|
|
66
|
+
self._future = future
|
|
67
|
+
self._idx = 0
|
|
68
|
+
# kind-aware install label: a routine installs to /routines (click-to-run
|
|
69
|
+
# until you grant its lease), a skill to /skills.
|
|
70
|
+
if getattr(proposal, "kind", "skill") == "routine":
|
|
71
|
+
install = "✓ Install routine (global — /routines, click-to-run until you grant its lease)"
|
|
72
|
+
else:
|
|
73
|
+
install = "✓ Install skill (global — /skills next session)"
|
|
74
|
+
self._CHOICES = (
|
|
75
|
+
("install", install),
|
|
76
|
+
("archive", "✗ Archive (kept for reference, never asked again)"),
|
|
77
|
+
("later", "· Later (stays in the inbox)"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def compose(self):
|
|
81
|
+
p = self._p
|
|
82
|
+
ev = ", ".join(p.evidence[:4]) + ("…" if len(p.evidence) > 4 else "")
|
|
83
|
+
yield Static(
|
|
84
|
+
f"[{MUTED}]why: {p.reason or '(recurring pattern)'} · from {ev or 'past sessions'}[/]",
|
|
85
|
+
id="prop-why",
|
|
86
|
+
)
|
|
87
|
+
lines = p.body.splitlines()
|
|
88
|
+
preview = "\n".join(lines[:_PREVIEW_LINES])
|
|
89
|
+
if len(lines) > _PREVIEW_LINES:
|
|
90
|
+
preview += f"\n… (+{len(lines) - _PREVIEW_LINES} more lines)"
|
|
91
|
+
# markup=False: the drafted body is shown literally, never parsed.
|
|
92
|
+
yield Static(preview or "(empty draft)", id="prop-preview", markup=False)
|
|
93
|
+
for i, (value, _label) in enumerate(self._CHOICES):
|
|
94
|
+
yield _ChoiceRow(value, i, self)
|
|
95
|
+
yield Static(
|
|
96
|
+
f"[{LAVENDER}]↑↓[/] choose · [{LAVENDER}]↵[/] select · "
|
|
97
|
+
f"[{LAVENDER}]y[/] install · [{LAVENDER}]n[/] archive · "
|
|
98
|
+
f"[{LAVENDER}]esc[/] later · or just click a row",
|
|
99
|
+
id="prop-keys",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def on_mount(self) -> None:
|
|
103
|
+
self.border_title = f"🌙 dream proposal · {self._p.name}"
|
|
104
|
+
self.border_subtitle = "drafted while dreaming — nothing installs without you"
|
|
105
|
+
self._render_choices()
|
|
106
|
+
self.focus()
|
|
107
|
+
|
|
108
|
+
def _render_choices(self) -> None:
|
|
109
|
+
for i, (_v, label) in enumerate(self._CHOICES):
|
|
110
|
+
row = self.query_one(f"#prop-opt-{i}", Static)
|
|
111
|
+
row.update(f"[b {VIOLET}]▸ {label}[/]" if i == self._idx else f"[{MUTED}] {label}[/]")
|
|
112
|
+
|
|
113
|
+
def action_move(self, delta: int) -> None:
|
|
114
|
+
self._idx = (self._idx + delta) % len(self._CHOICES)
|
|
115
|
+
self._render_choices()
|
|
116
|
+
|
|
117
|
+
def action_confirm(self) -> None:
|
|
118
|
+
self.pick(self._CHOICES[self._idx][0])
|
|
119
|
+
|
|
120
|
+
def action_pick(self, choice: str) -> None:
|
|
121
|
+
self.pick(choice)
|
|
122
|
+
|
|
123
|
+
def pick(self, value: str) -> None:
|
|
124
|
+
"""Idempotent — a stray second click/key must not crash the Future."""
|
|
125
|
+
if not self._future.done():
|
|
126
|
+
self._future.set_result(value)
|
rockycode/tui/resume.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The resume picker: a modal list of past sessions, newest on top.
|
|
2
|
+
|
|
3
|
+
Default scope is the current folder; Ctrl+A flips to all folders (the global
|
|
4
|
+
registry). Type to search; ↑↓ move; Enter resumes; Esc cancels. The search
|
|
5
|
+
box stays focused so you can filter immediately — arrows/enter are handled at
|
|
6
|
+
the screen level so they work without leaving the input.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from textual import on
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Vertical
|
|
17
|
+
from textual.screen import ModalScreen
|
|
18
|
+
from textual.widgets import Input, OptionList, Static
|
|
19
|
+
from textual.widgets.option_list import Option
|
|
20
|
+
|
|
21
|
+
from rich.markup import escape
|
|
22
|
+
|
|
23
|
+
from rockycode.palette import LAVENDER, MUTED, VIOLET
|
|
24
|
+
from rockycode.session import SessionInfo, list_sessions, public_id
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def rel_time(ts: float) -> str:
|
|
28
|
+
d = time.time() - ts
|
|
29
|
+
if d < 60:
|
|
30
|
+
return "just now"
|
|
31
|
+
if d < 3600:
|
|
32
|
+
return f"{int(d // 60)}m ago"
|
|
33
|
+
if d < 86400:
|
|
34
|
+
return f"{int(d // 3600)}h ago"
|
|
35
|
+
if d < 172800:
|
|
36
|
+
return "yesterday"
|
|
37
|
+
return f"{int(d // 86400)}d ago"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ResumePicker(ModalScreen):
|
|
41
|
+
"""Returns the chosen SessionInfo via dismiss(), or None on cancel."""
|
|
42
|
+
|
|
43
|
+
BINDINGS = [
|
|
44
|
+
("escape", "cancel", "cancel"),
|
|
45
|
+
("down", "cursor_down", "↓"),
|
|
46
|
+
("up", "cursor_up", "↑"),
|
|
47
|
+
# priority: the search Input otherwise eats ctrl+a as "line start"
|
|
48
|
+
Binding("ctrl+a", "toggle_scope", "all folders", priority=True),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
DEFAULT_CSS = """
|
|
52
|
+
ResumePicker { align: center middle; }
|
|
53
|
+
ResumePicker > #picker {
|
|
54
|
+
width: 88%;
|
|
55
|
+
height: 80%;
|
|
56
|
+
padding: 1 2;
|
|
57
|
+
background: $surface;
|
|
58
|
+
border: round $primary;
|
|
59
|
+
border-title-color: $text-muted;
|
|
60
|
+
}
|
|
61
|
+
ResumePicker #q { border: round $panel-lighten-2; margin-bottom: 1; }
|
|
62
|
+
ResumePicker #q:focus { border: round $primary; }
|
|
63
|
+
ResumePicker OptionList { height: 1fr; background: $surface; }
|
|
64
|
+
ResumePicker #picker-guide { height: 1; color: $text-muted; }
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, workdir: Path) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self.workdir = workdir
|
|
70
|
+
self.scope = "project"
|
|
71
|
+
self._sessions: list[SessionInfo] = []
|
|
72
|
+
|
|
73
|
+
def compose(self):
|
|
74
|
+
with Vertical(id="picker") as box:
|
|
75
|
+
box.border_title = "resume a session"
|
|
76
|
+
yield Input(placeholder="type to search…", id="q")
|
|
77
|
+
yield OptionList(id="list")
|
|
78
|
+
yield Static("", id="picker-guide")
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
self.query_one("#q", Input).focus()
|
|
82
|
+
self._reload()
|
|
83
|
+
|
|
84
|
+
def _guide(self) -> str:
|
|
85
|
+
where = "this folder" if self.scope == "project" else "all folders"
|
|
86
|
+
return (
|
|
87
|
+
f"[{MUTED}]scope: [{LAVENDER}]{where}[/] · ↑↓ move · ↵ resume · "
|
|
88
|
+
f"^a {'this folder' if self.scope == 'all' else 'all folders'} · esc cancel[/]"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _fmt(self, s: SessionInfo) -> str:
|
|
92
|
+
when = rel_time(s.started_at)
|
|
93
|
+
folder = Path(s.project_path).name if s.project_path else s.project_name
|
|
94
|
+
# escape: a past session's title/summary is untrusted user text; a stray
|
|
95
|
+
# "[/…]" in it is a Textual markup close tag that would crash the picker.
|
|
96
|
+
title = escape(s.display_title)
|
|
97
|
+
return (f"{when:>9} {title[:44]:<44} {s.n_messages:>3} msgs "
|
|
98
|
+
f"{public_id(s.session_id)} 📁 {folder}")
|
|
99
|
+
|
|
100
|
+
def _reload(self) -> None:
|
|
101
|
+
q = self.query_one("#q", Input).value or None
|
|
102
|
+
self._sessions = list_sessions(self.scope, workdir=self.workdir, query=q)
|
|
103
|
+
ol = self.query_one("#list", OptionList)
|
|
104
|
+
ol.clear_options()
|
|
105
|
+
if self._sessions:
|
|
106
|
+
for i, s in enumerate(self._sessions):
|
|
107
|
+
ol.add_option(Option(self._fmt(s), id=str(i)))
|
|
108
|
+
ol.highlighted = 0
|
|
109
|
+
else:
|
|
110
|
+
ol.add_option(Option("(no sessions found)", id="none", disabled=True))
|
|
111
|
+
self.query_one("#picker-guide", Static).update(self._guide())
|
|
112
|
+
|
|
113
|
+
@on(Input.Changed, "#q")
|
|
114
|
+
def _on_search(self, _e: Input.Changed) -> None:
|
|
115
|
+
self._reload()
|
|
116
|
+
|
|
117
|
+
@on(Input.Submitted, "#q")
|
|
118
|
+
def _on_enter(self, _e: Input.Submitted) -> None:
|
|
119
|
+
self._select_current()
|
|
120
|
+
|
|
121
|
+
@on(OptionList.OptionSelected)
|
|
122
|
+
def _on_click(self, e: OptionList.OptionSelected) -> None:
|
|
123
|
+
if e.option.id and e.option.id.isdigit():
|
|
124
|
+
self.dismiss(self._sessions[int(e.option.id)])
|
|
125
|
+
|
|
126
|
+
def _select_current(self) -> None:
|
|
127
|
+
ol = self.query_one("#list", OptionList)
|
|
128
|
+
if ol.highlighted is not None and self._sessions:
|
|
129
|
+
self.dismiss(self._sessions[ol.highlighted])
|
|
130
|
+
|
|
131
|
+
def action_cursor_down(self) -> None:
|
|
132
|
+
self.query_one("#list", OptionList).action_cursor_down()
|
|
133
|
+
|
|
134
|
+
def action_cursor_up(self) -> None:
|
|
135
|
+
self.query_one("#list", OptionList).action_cursor_up()
|
|
136
|
+
|
|
137
|
+
def action_toggle_scope(self) -> None:
|
|
138
|
+
self.scope = "all" if self.scope == "project" else "project"
|
|
139
|
+
self._reload()
|
|
140
|
+
|
|
141
|
+
def action_cancel(self) -> None:
|
|
142
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Rocky the pet: a small frame-based sprite widget driven by AgentState.
|
|
2
|
+
|
|
3
|
+
Eridian anatomy: Rocky has FIVE limbs arranged radially — drawn in the
|
|
4
|
+
兴 pose (three limbs up/out over the carapace, two below), never five-down
|
|
5
|
+
like a spider. Carapace carries sonar dots (Rocky has no eyes — he hears).
|
|
6
|
+
A more delicate sprite pass is still planned (user request, 2026-06-10).
|
|
7
|
+
|
|
8
|
+
Colors are Tokyo Night palette, matching the app theme.
|
|
9
|
+
|
|
10
|
+
The widget is isolated on purpose: the animation timer repaints only this
|
|
11
|
+
~9×6 cell region, so it can never glitch the transcript.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from textual.reactive import reactive
|
|
16
|
+
from textual.widgets import Static
|
|
17
|
+
|
|
18
|
+
from rockycode.engine.events import AgentState
|
|
19
|
+
|
|
20
|
+
# Every frame: 6 lines × 7 cols. 5 limbs (3 over carapace, 2 under),
|
|
21
|
+
# carapace with sonar dots, last line = caption.
|
|
22
|
+
FRAMES: dict[AgentState, list[str]] = {
|
|
23
|
+
AgentState.IDLE: [
|
|
24
|
+
" ╲ │ ╱ \n╭─────╮\n│ ∙∙∙ │\n╰─────╯\n ╱ ╲ \n ",
|
|
25
|
+
" ╲ │ ╱ \n╭─────╮\n│ ∘∙∘ │\n╰─────╯\n ╱ ╲ \n ",
|
|
26
|
+
],
|
|
27
|
+
AgentState.THINKING: [
|
|
28
|
+
" ╲ │ ♪ \n╭─────╮\n│ ∙∘∙ │\n╰─────╯\n ╱ ╲ \n hmm… ",
|
|
29
|
+
" ╲ │ ♫ \n╭─────╮\n│ ∘∙∘ │\n╰─────╯\n ╱ ╲ \n hmm… ",
|
|
30
|
+
" ╲ │ ♪ \n╭─────╮\n│ ∘∘∘ │\n╰─────╯\n ╱ ╲ \n hmm… ",
|
|
31
|
+
],
|
|
32
|
+
AgentState.RESPONDING: [
|
|
33
|
+
" ╲ │ ╱ \n╭─────╮\n│ ∙∙∙ │\n╰─────╯\n ╱ ╲ \n ♪ ",
|
|
34
|
+
" ╲ │ ╱ \n╭─────╮\n│ ∙∙∙ │\n╰─────╯\n ╱ ╲ \n ♫ ",
|
|
35
|
+
],
|
|
36
|
+
AgentState.TOOL: [
|
|
37
|
+
" ╲ │ ⚒ \n╭─────╮\n│ ∙∙∙ │\n╰─────╯\n ╱ ╲ \n work… ",
|
|
38
|
+
" ╲ │ ╱ \n╭────⚒╮\n│ ∙∙∙ │\n╰─────╯\n ╱ ╲ \n work… ",
|
|
39
|
+
],
|
|
40
|
+
AgentState.COMPACTING: [
|
|
41
|
+
" ╲ │ ╱ \n╭─────╮\n│ >∙< │\n╰─────╯\n ╱ ╲ \nsquish…",
|
|
42
|
+
" ╲ │ ╱ \n╭─────╮\n│ »∙« │\n╰─────╯\n ╱ ╲ \nsquish…",
|
|
43
|
+
],
|
|
44
|
+
AgentState.AMAZED: [
|
|
45
|
+
"╲ │ ╱\n╭─────╮\n│ ✧✧✧ │\n╰─────╯\n╱ ╲\namaze! ",
|
|
46
|
+
" ╲ │ ╱ \n╭─────╮\n│ ✧∙✧ │\n╰─────╯\n ╱ ╲ \namaze! ",
|
|
47
|
+
"╲ │ ╱\n╭─────╮\n│ ∙✧∙ │\n╰─────╯\n╱ ╲\namaze! ",
|
|
48
|
+
],
|
|
49
|
+
AgentState.ERROR: [
|
|
50
|
+
" ╲ ? ╱ \n╭─────╮\n│ ××× │\n╰─────╯\n ╳ ╲ \nno good",
|
|
51
|
+
" ╲ ¿ ╱ \n╭─────╮\n│ ××× │\n╰─────╯\n ╱ ╳ \nno good",
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Tokyo Night.
|
|
56
|
+
STYLES: dict[AgentState, str] = {
|
|
57
|
+
AgentState.IDLE: "#bb9af7",
|
|
58
|
+
AgentState.THINKING: "#9d7cd8",
|
|
59
|
+
AgentState.RESPONDING: "#7dcfff",
|
|
60
|
+
AgentState.TOOL: "#e0af68",
|
|
61
|
+
AgentState.COMPACTING: "#73daca",
|
|
62
|
+
AgentState.AMAZED: "bold #7dcfff",
|
|
63
|
+
AgentState.ERROR: "#f7768e",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
FPS = 3 # a pet, not a video game — cheap repaints
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RockyPet(Static):
|
|
70
|
+
state: reactive[AgentState] = reactive(AgentState.IDLE)
|
|
71
|
+
|
|
72
|
+
DEFAULT_CSS = """
|
|
73
|
+
RockyPet {
|
|
74
|
+
width: 9;
|
|
75
|
+
height: 6;
|
|
76
|
+
content-align: center middle;
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
self._frame = 0
|
|
82
|
+
self.set_interval(1 / FPS, self._tick)
|
|
83
|
+
self._render_frame()
|
|
84
|
+
|
|
85
|
+
def watch_state(self, _old: AgentState, _new: AgentState) -> None:
|
|
86
|
+
self._frame = 0
|
|
87
|
+
self._render_frame()
|
|
88
|
+
|
|
89
|
+
def _tick(self) -> None:
|
|
90
|
+
self._frame += 1
|
|
91
|
+
self._render_frame()
|
|
92
|
+
|
|
93
|
+
def _render_frame(self) -> None:
|
|
94
|
+
frames = FRAMES[self.state]
|
|
95
|
+
art = frames[self._frame % len(frames)]
|
|
96
|
+
self.update(f"[{STYLES[self.state]}]{art}[/]")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The routine due card — run it, lease it, or turn it off.
|
|
2
|
+
|
|
3
|
+
Mounted inline by /routines, one card per due routine. Resolves the Future
|
|
4
|
+
with "run" | "auto" | "later" | "off". The auto choice grants (or renews)
|
|
5
|
+
the 7-day lease with the routine's lease budget — the label always shows
|
|
6
|
+
what the lease costs, and on renewal what the last lease actually spent,
|
|
7
|
+
so trust is re-earned with the bill in view (locked design 2026-07-17).
|
|
8
|
+
|
|
9
|
+
Rows are clickable with paired keys: ↑↓ + Enter, y run, a auto, n off,
|
|
10
|
+
Esc later.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
from textual.containers import Vertical
|
|
17
|
+
from textual.widgets import Static
|
|
18
|
+
|
|
19
|
+
from rockycode.palette import LAVENDER, MUTED, VIOLET
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Row(Static):
|
|
23
|
+
def __init__(self, value: str, idx: int, card: "RoutineCard") -> None:
|
|
24
|
+
super().__init__("", id=f"rt-opt-{idx}", classes="rt-opt")
|
|
25
|
+
self._value = value
|
|
26
|
+
self._card = card
|
|
27
|
+
|
|
28
|
+
def on_click(self) -> None:
|
|
29
|
+
self._card.pick(self._value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RoutineCard(Vertical):
|
|
33
|
+
"""One due routine. Resolves `future` with the choice."""
|
|
34
|
+
|
|
35
|
+
can_focus = True
|
|
36
|
+
|
|
37
|
+
BINDINGS = [
|
|
38
|
+
("up", "move(-1)", "up"),
|
|
39
|
+
("down", "move(1)", "down"),
|
|
40
|
+
("enter", "confirm", "select"),
|
|
41
|
+
("y", "pick('run')", "run"),
|
|
42
|
+
("a", "pick('auto')", "auto"),
|
|
43
|
+
("n", "pick('off')", "off"),
|
|
44
|
+
("escape", "pick('later')", "later"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
DEFAULT_CSS = """
|
|
48
|
+
RoutineCard {
|
|
49
|
+
height: auto;
|
|
50
|
+
margin: 0 1 1 1;
|
|
51
|
+
padding: 1 2;
|
|
52
|
+
background: $surface;
|
|
53
|
+
border: round $primary;
|
|
54
|
+
border-title-color: $text-muted;
|
|
55
|
+
border-subtitle-color: $text-muted;
|
|
56
|
+
}
|
|
57
|
+
RoutineCard #rt-desc { color: $text-muted; }
|
|
58
|
+
RoutineCard .rt-opt { height: 1; }
|
|
59
|
+
RoutineCard #rt-keys { color: $text-muted; margin-top: 1; }
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, routine, lease_spent: float, future: "asyncio.Future[str]") -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self._r = routine
|
|
65
|
+
self._lease_spent = lease_spent
|
|
66
|
+
self._future = future
|
|
67
|
+
self._idx = 0
|
|
68
|
+
auto_label = (
|
|
69
|
+
f"↻ Renew auto — 7 days, ${routine.budget_lease:.2f} lease "
|
|
70
|
+
f"(last lease spent ${lease_spent:.2f})"
|
|
71
|
+
if routine.auto else
|
|
72
|
+
f"↻ Auto for 7 days (lease · budget ${routine.budget_lease:.2f})"
|
|
73
|
+
)
|
|
74
|
+
self._choices = (
|
|
75
|
+
("run", "▶ Run now (sandboxed)"),
|
|
76
|
+
("auto", auto_label),
|
|
77
|
+
("later", "· Later"),
|
|
78
|
+
("off", "✗ Turn off"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def compose(self):
|
|
82
|
+
r = self._r
|
|
83
|
+
grants = ", ".join(r.tools) or "none"
|
|
84
|
+
yield Static(
|
|
85
|
+
f"[{MUTED}]{r.description or r.prompt[:100]}\n"
|
|
86
|
+
f"cadence: {r.cadence} · network: {'on' if r.network else 'off'} · "
|
|
87
|
+
f"grants: {grants} · ≤{r.max_steps} steps[/]",
|
|
88
|
+
id="rt-desc",
|
|
89
|
+
)
|
|
90
|
+
for i, (value, _label) in enumerate(self._choices):
|
|
91
|
+
yield _Row(value, i, self)
|
|
92
|
+
yield Static(
|
|
93
|
+
f"[{LAVENDER}]↑↓[/] choose · [{LAVENDER}]↵[/] select · "
|
|
94
|
+
f"[{LAVENDER}]y[/] run · [{LAVENDER}]a[/] auto · [{LAVENDER}]n[/] off · "
|
|
95
|
+
f"[{LAVENDER}]esc[/] later · or just click a row",
|
|
96
|
+
id="rt-keys",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def on_mount(self) -> None:
|
|
100
|
+
self.border_title = f"⏰ routine due · {self._r.name}"
|
|
101
|
+
self.border_subtitle = "runs only when you say — auto is a lease, not a switch"
|
|
102
|
+
self._render_rows()
|
|
103
|
+
self.focus()
|
|
104
|
+
|
|
105
|
+
def _render_rows(self) -> None:
|
|
106
|
+
for i, (_v, label) in enumerate(self._choices):
|
|
107
|
+
row = self.query_one(f"#rt-opt-{i}", Static)
|
|
108
|
+
row.update(f"[b {VIOLET}]▸ {label}[/]" if i == self._idx else f"[{MUTED}] {label}[/]")
|
|
109
|
+
|
|
110
|
+
def action_move(self, delta: int) -> None:
|
|
111
|
+
self._idx = (self._idx + delta) % len(self._choices)
|
|
112
|
+
self._render_rows()
|
|
113
|
+
|
|
114
|
+
def action_confirm(self) -> None:
|
|
115
|
+
self.pick(self._choices[self._idx][0])
|
|
116
|
+
|
|
117
|
+
def action_pick(self, choice: str) -> None:
|
|
118
|
+
self.pick(choice)
|
|
119
|
+
|
|
120
|
+
def pick(self, value: str) -> None:
|
|
121
|
+
"""Idempotent — a stray second click/key must not crash the Future."""
|
|
122
|
+
if not self._future.done():
|
|
123
|
+
self._future.set_result(value)
|