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,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)
@@ -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)