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,181 @@
|
|
|
1
|
+
"""The exit feedback sheet — one tiny, skippable question on the way out.
|
|
2
|
+
|
|
3
|
+
Mounted inline (above the input, like InlineApproval) when the user leaves via
|
|
4
|
+
/exit, /quit or ctrl+q after a real exchange. Resolves the passed-in Future
|
|
5
|
+
with {"mood", "text"} or None (skipped); the caller writes it as a `feedback`
|
|
6
|
+
trajectory record. That record stays ON THIS MACHINE: it is never placed in a
|
|
7
|
+
cloud-bound prompt — only the local dream (Ollama) reads it later — and the
|
|
8
|
+
sheet says so right on it (the self-evolve design's disclosure line).
|
|
9
|
+
|
|
10
|
+
Everything is clickable (house rule: a keystroke is never the only way):
|
|
11
|
+
click a mood to send it, click skip to leave silently, click "don't ask
|
|
12
|
+
again" to persist exit_sheet=off. Keys are paired accelerators — ←→ move the
|
|
13
|
+
highlight, Enter sends it with the typed note, Esc skips.
|
|
14
|
+
|
|
15
|
+
The sheet never holds the exit hostage: after TIMEOUT_S with no answer it
|
|
16
|
+
auto-skips and the app leaves as requested (the caller sees None, exactly
|
|
17
|
+
like a click on skip). Config `exit_sheet`: the default "auto" shows it only
|
|
18
|
+
when the dream pipeline is alive this session (the feedback has a real local
|
|
19
|
+
consumer); "off" suppresses it entirely; "on" forces it.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from typing import Optional, Union
|
|
25
|
+
|
|
26
|
+
from textual.containers import Horizontal, Vertical
|
|
27
|
+
from textual.widgets import Input, Static
|
|
28
|
+
|
|
29
|
+
from rockycode.palette import LAVENDER, MUTED, VIOLET
|
|
30
|
+
|
|
31
|
+
# One glance, one click — if it hasn't been answered in a minute it won't be,
|
|
32
|
+
# and a typed /exit must actually exit (the outcome record + exit card wait
|
|
33
|
+
# on this). Deliberately short; the sheet is optional, the exit is not.
|
|
34
|
+
TIMEOUT_S = 60.0
|
|
35
|
+
|
|
36
|
+
MOODS = (
|
|
37
|
+
("good", "♥ good"),
|
|
38
|
+
("meh", "· okay"),
|
|
39
|
+
("bad", "✗ rough"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
NEVER = "never" # sentinel result: the user clicked "don't ask again"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _MoodChip(Static):
|
|
46
|
+
"""One clickable mood. A click picks it and sends the sheet."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, mood: str, sheet: "ExitSheet") -> None:
|
|
49
|
+
super().__init__("", id=f"sheet-mood-{mood}", classes="sheet-chip")
|
|
50
|
+
self._mood = mood
|
|
51
|
+
self._sheet = sheet
|
|
52
|
+
|
|
53
|
+
def on_click(self) -> None:
|
|
54
|
+
self._sheet.pick(self._mood)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _SkipChip(Static):
|
|
58
|
+
def __init__(self, sheet: "ExitSheet") -> None:
|
|
59
|
+
super().__init__(f"[{MUTED}]skip →[/]", id="sheet-skip")
|
|
60
|
+
self._sheet = sheet
|
|
61
|
+
|
|
62
|
+
def on_click(self) -> None:
|
|
63
|
+
self._sheet.skip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _NeverChip(Static):
|
|
67
|
+
"""The off-switch lives exactly where the annoyance is — no docs hunt."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, sheet: "ExitSheet") -> None:
|
|
70
|
+
super().__init__(f"[{MUTED}]don't ask again[/]", id="sheet-never")
|
|
71
|
+
self._sheet = sheet
|
|
72
|
+
|
|
73
|
+
def on_click(self) -> None:
|
|
74
|
+
self._sheet.never()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ExitSheet(Vertical):
|
|
78
|
+
"""Rate the session on the way out. Resolves `future` with
|
|
79
|
+
{"mood": "good"|"meh"|"bad", "text": str}, None when skipped (click,
|
|
80
|
+
Esc, or timeout), or NEVER when the user turns the sheet off for good."""
|
|
81
|
+
|
|
82
|
+
can_focus = True
|
|
83
|
+
|
|
84
|
+
BINDINGS = [
|
|
85
|
+
("left", "move(-1)", "left"),
|
|
86
|
+
("right", "move(1)", "right"),
|
|
87
|
+
("enter", "send", "send"),
|
|
88
|
+
("escape", "skip", "skip"),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
DEFAULT_CSS = """
|
|
92
|
+
ExitSheet {
|
|
93
|
+
height: auto;
|
|
94
|
+
margin: 0 1 1 1;
|
|
95
|
+
padding: 1 2;
|
|
96
|
+
background: $surface;
|
|
97
|
+
border: round $primary;
|
|
98
|
+
border-title-color: $text-muted;
|
|
99
|
+
border-subtitle-color: $text-muted;
|
|
100
|
+
}
|
|
101
|
+
ExitSheet #sheet-moods { height: 1; margin-bottom: 1; }
|
|
102
|
+
ExitSheet .sheet-chip { width: auto; margin-right: 4; }
|
|
103
|
+
ExitSheet #sheet-never { width: 1fr; text-align: right; }
|
|
104
|
+
ExitSheet #sheet-skip { width: auto; margin-left: 4; }
|
|
105
|
+
ExitSheet #sheet-privacy { color: $text-muted; margin-top: 1; }
|
|
106
|
+
ExitSheet #sheet-keys { color: $text-muted; }
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
future: "asyncio.Future[Union[dict, str, None]]",
|
|
112
|
+
timeout_s: Optional[float] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
super().__init__()
|
|
115
|
+
self._future = future
|
|
116
|
+
# Resolved at call time so tests (and a future config knob) can adjust.
|
|
117
|
+
self._timeout_s = TIMEOUT_S if timeout_s is None else timeout_s
|
|
118
|
+
self._idx = 1 # default highlight = the neutral middle, never presume
|
|
119
|
+
|
|
120
|
+
def compose(self):
|
|
121
|
+
with Horizontal(id="sheet-moods"):
|
|
122
|
+
for mood, _label in MOODS:
|
|
123
|
+
yield _MoodChip(mood, self)
|
|
124
|
+
yield _NeverChip(self)
|
|
125
|
+
yield _SkipChip(self)
|
|
126
|
+
yield Input(placeholder="a note for rocky's dream (optional)", id="sheet-note")
|
|
127
|
+
yield Static(
|
|
128
|
+
f"[{MUTED}]feeds rocky's dream (local memory) — never sent to the model API · "
|
|
129
|
+
f"turn off anytime: “don't ask again” here, or /config exit_sheet off[/]",
|
|
130
|
+
id="sheet-privacy",
|
|
131
|
+
)
|
|
132
|
+
yield Static(
|
|
133
|
+
f"[{LAVENDER}]←→[/] choose · [{LAVENDER}]↵[/] send · "
|
|
134
|
+
f"[{LAVENDER}]esc[/] skip · or just click a mood",
|
|
135
|
+
id="sheet-keys",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def on_mount(self) -> None:
|
|
139
|
+
self.border_title = "♪ before you go — how was this session?"
|
|
140
|
+
self.border_subtitle = f"optional · one click · auto-skips in {self._timeout_s:.0f}s"
|
|
141
|
+
self._render_moods()
|
|
142
|
+
self.focus()
|
|
143
|
+
# Never hold the exit hostage: unanswered → auto-skip, app leaves.
|
|
144
|
+
self.set_timer(self._timeout_s, self.skip)
|
|
145
|
+
|
|
146
|
+
def _render_moods(self) -> None:
|
|
147
|
+
for i, (mood, label) in enumerate(MOODS):
|
|
148
|
+
chip = self.query_one(f"#sheet-mood-{mood}", Static)
|
|
149
|
+
chip.update(f"[b {VIOLET}]▸ {label}[/]" if i == self._idx else f"[{MUTED}] {label}[/]")
|
|
150
|
+
|
|
151
|
+
def action_move(self, delta: int) -> None:
|
|
152
|
+
self._idx = (self._idx + delta) % len(MOODS)
|
|
153
|
+
self._render_moods()
|
|
154
|
+
|
|
155
|
+
def action_send(self) -> None:
|
|
156
|
+
self.pick(MOODS[self._idx][0])
|
|
157
|
+
|
|
158
|
+
def action_skip(self) -> None:
|
|
159
|
+
self.skip()
|
|
160
|
+
|
|
161
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
162
|
+
# Enter inside the note box sends the note with the highlighted mood.
|
|
163
|
+
event.stop()
|
|
164
|
+
self.action_send()
|
|
165
|
+
|
|
166
|
+
def pick(self, mood: str) -> None:
|
|
167
|
+
text = self.query_one("#sheet-note", Input).value.strip()
|
|
168
|
+
self._resolve({"mood": mood, "text": text})
|
|
169
|
+
|
|
170
|
+
def skip(self) -> None:
|
|
171
|
+
self._resolve(None)
|
|
172
|
+
|
|
173
|
+
def never(self) -> None:
|
|
174
|
+
self._resolve(NEVER)
|
|
175
|
+
|
|
176
|
+
def _resolve(self, value: "Union[dict, str, None]") -> None:
|
|
177
|
+
"""Deliver the answer to the awaiting exit path. Idempotent — a stray
|
|
178
|
+
second click/key (or the timeout firing after a click) must not crash
|
|
179
|
+
on an already-set Future."""
|
|
180
|
+
if not self._future.done():
|
|
181
|
+
self._future.set_result(value)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""The in-app goal screen.
|
|
2
|
+
|
|
3
|
+
/goal runs the autonomous agent in its OWN full-screen Textual screen, pushed on
|
|
4
|
+
top of the chat — plan, confirm, milestones and the summary all render here in
|
|
5
|
+
the palette, and when it's done we pop back to chat with a one-line recap. No
|
|
6
|
+
more dropping to a bare terminal mid-session.
|
|
7
|
+
|
|
8
|
+
The screen only renders and collects input (y/e/n at the gate, a line of text to
|
|
9
|
+
discuss/edit the plan, Esc to stop). All the real work — git worktree, Docker
|
|
10
|
+
sandbox, the milestone loop — lives behind the GoalBackend seam
|
|
11
|
+
(engine/goal_session.py), so this file has no idea Docker exists and is testable
|
|
12
|
+
with a fake backend.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from rich.markup import escape
|
|
20
|
+
from textual.binding import Binding
|
|
21
|
+
from textual.containers import VerticalScroll
|
|
22
|
+
from textual.css.query import NoMatches
|
|
23
|
+
from textual.screen import Screen
|
|
24
|
+
from textual.widgets import Input, Static
|
|
25
|
+
|
|
26
|
+
from rockycode.engine.goal_session import GoalSummary, Permits
|
|
27
|
+
from rockycode.palette import AMBER, LAVENDER, MUTED, PURPLE, RED, VIOLET
|
|
28
|
+
|
|
29
|
+
_ROCKY = "#8b6fc9"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GoalScreen(Screen):
|
|
33
|
+
"""Drive one goal run to completion, then dismiss(GoalSummary).
|
|
34
|
+
|
|
35
|
+
States: setup → planning → confirm ⇄ edit → running → done. `backend` is any
|
|
36
|
+
object with setup()/plan()/permits()/discuss()/run()/cleanup() — LiveGoalBackend
|
|
37
|
+
in production, a fake in tests."""
|
|
38
|
+
|
|
39
|
+
CSS = """
|
|
40
|
+
GoalScreen { layout: vertical; background: $surface; }
|
|
41
|
+
#goal-head {
|
|
42
|
+
dock: top; height: 3; padding: 1 2;
|
|
43
|
+
border-bottom: solid $primary;
|
|
44
|
+
}
|
|
45
|
+
#goal-log { height: 1fr; padding: 0 2; margin: 1 0; }
|
|
46
|
+
#goal-log > Static { margin-bottom: 0; }
|
|
47
|
+
#goal-foot {
|
|
48
|
+
dock: bottom; height: auto; min-height: 1; padding: 0 2 1 2;
|
|
49
|
+
border-top: solid $primary;
|
|
50
|
+
}
|
|
51
|
+
#goal-edit { margin: 1 0 0 0; display: none; }
|
|
52
|
+
#goal-edit.on { display: block; }
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
BINDINGS = [
|
|
56
|
+
Binding("y", "decide('go')", "run", show=False),
|
|
57
|
+
Binding("e", "decide('edit')", "discuss/edit", show=False),
|
|
58
|
+
Binding("n", "decide('cancel')", "cancel", show=False),
|
|
59
|
+
Binding("escape", "stop", "stop", show=False),
|
|
60
|
+
Binding("pageup", "log_up", "scroll", show=False, priority=True),
|
|
61
|
+
Binding("pagedown", "log_down", "scroll", show=False, priority=True),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def __init__(self, backend, objective: str) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self._backend = backend
|
|
67
|
+
self._objective = objective
|
|
68
|
+
self._state = "setup"
|
|
69
|
+
self._decision: Optional[asyncio.Future] = None
|
|
70
|
+
self._edit: Optional[asyncio.Future] = None
|
|
71
|
+
self._summary: Optional[GoalSummary] = None
|
|
72
|
+
self._events: Optional[asyncio.Queue] = None
|
|
73
|
+
self._consumer: Optional[asyncio.Task] = None
|
|
74
|
+
|
|
75
|
+
# ---- layout -------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def compose(self):
|
|
78
|
+
yield Static("", id="goal-head")
|
|
79
|
+
yield VerticalScroll(id="goal-log")
|
|
80
|
+
foot = Static("", id="goal-foot")
|
|
81
|
+
yield foot
|
|
82
|
+
edit = Input(placeholder="ask about or change the plan…", id="goal-edit")
|
|
83
|
+
yield edit
|
|
84
|
+
|
|
85
|
+
def on_mount(self) -> None:
|
|
86
|
+
self.query_one("#goal-log", VerticalScroll).can_focus = False
|
|
87
|
+
self._render_head("starting…")
|
|
88
|
+
self._run()
|
|
89
|
+
|
|
90
|
+
# ---- rendering ----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _render_head(self, status: str) -> None:
|
|
93
|
+
obj = escape(self._objective) if self._objective else "(no objective)"
|
|
94
|
+
try:
|
|
95
|
+
self.query_one("#goal-head", Static).update(
|
|
96
|
+
f"[bold {VIOLET}]◆ goal[/] [dim]·[/] [{LAVENDER}]{status}[/]\n[dim]{obj}[/]")
|
|
97
|
+
except NoMatches:
|
|
98
|
+
pass # screen tearing down (app closing / popped) — nothing to paint
|
|
99
|
+
|
|
100
|
+
def _foot(self, markup: str) -> None:
|
|
101
|
+
try:
|
|
102
|
+
self.query_one("#goal-foot", Static).update(markup)
|
|
103
|
+
except NoMatches:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
async def _line(self, markup: str, *, literal: str | None = None) -> None:
|
|
107
|
+
"""Append one line to the log and keep it pinned to the bottom. A no-op if
|
|
108
|
+
the screen is gone (worker still winding down after a pop / app close)."""
|
|
109
|
+
try:
|
|
110
|
+
log = self.query_one("#goal-log", VerticalScroll)
|
|
111
|
+
except NoMatches:
|
|
112
|
+
return
|
|
113
|
+
w = Static(literal, markup=False) if literal is not None else Static(markup)
|
|
114
|
+
await log.mount(w)
|
|
115
|
+
log.scroll_end(animate=False)
|
|
116
|
+
|
|
117
|
+
def _emit_event(self, msg: str) -> None:
|
|
118
|
+
"""on_event callback for the runner. It fires synchronously inside the run
|
|
119
|
+
loop; queue the message so a single consumer renders events strictly in
|
|
120
|
+
order (and so the summary can wait for the queue to drain before it prints,
|
|
121
|
+
instead of racing the scheduled appends)."""
|
|
122
|
+
if self._events is not None:
|
|
123
|
+
self._events.put_nowait(msg)
|
|
124
|
+
|
|
125
|
+
async def _consume_events(self) -> None:
|
|
126
|
+
while True:
|
|
127
|
+
msg = await self._events.get()
|
|
128
|
+
await self._line(f"[{MUTED}]·[/] {escape(msg)}")
|
|
129
|
+
self._events.task_done()
|
|
130
|
+
|
|
131
|
+
# ---- the run ------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _run(self):
|
|
134
|
+
self.run_worker(self._drive(), exclusive=True, name="goal-drive")
|
|
135
|
+
|
|
136
|
+
async def _drive(self) -> None:
|
|
137
|
+
try:
|
|
138
|
+
self._render_head("setting up the isolated workspace…")
|
|
139
|
+
where = await self._backend.setup()
|
|
140
|
+
await self._line(f"[{PURPLE}]workspace[/] [dim]{escape(where)}[/]")
|
|
141
|
+
|
|
142
|
+
self._render_head("planning…")
|
|
143
|
+
self._foot(f"[dim]rocky is drafting a plan for:[/] [{LAVENDER}]{escape(self._objective)}[/]")
|
|
144
|
+
plan, requires = await self._backend.plan()
|
|
145
|
+
if not plan:
|
|
146
|
+
await self._fail("the planner produced no milestones.")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# confirm ⇄ edit loop (cheap — no sandbox yet)
|
|
150
|
+
while True:
|
|
151
|
+
permits = self._backend.permits(plan, requires)
|
|
152
|
+
if permits.blocked:
|
|
153
|
+
await self._fail(f"plan names a blocked action: {permits.blocked}")
|
|
154
|
+
return
|
|
155
|
+
await self._show_plan(plan, permits)
|
|
156
|
+
choice = await self._await_decision()
|
|
157
|
+
if choice == "go":
|
|
158
|
+
break
|
|
159
|
+
if choice == "cancel":
|
|
160
|
+
await self._line(f"[{AMBER}]cancelled[/] [dim]— nothing ran.[/]")
|
|
161
|
+
await self._backend.cleanup(keep=False)
|
|
162
|
+
self._finish(GoalSummary("cancelled", "cancelled at the plan gate"))
|
|
163
|
+
return
|
|
164
|
+
# edit → discuss: echo YOUR question, rocky answers, then we
|
|
165
|
+
# re-show the (revised) plan.
|
|
166
|
+
msg = await self._await_edit()
|
|
167
|
+
if msg:
|
|
168
|
+
await self._line(f"[b {LAVENDER}]you:[/] {escape(msg)}")
|
|
169
|
+
self._render_head("thinking about your note…")
|
|
170
|
+
reply, plan, requires = await self._backend.discuss(plan, requires, msg)
|
|
171
|
+
if reply:
|
|
172
|
+
await self._line(f"[b {_ROCKY}]rocky:[/] [italic]{escape(reply)}[/]")
|
|
173
|
+
|
|
174
|
+
# run — a single consumer renders events in order; join() guarantees
|
|
175
|
+
# every event is on screen before the summary prints.
|
|
176
|
+
self._state = "running" # so Esc now stops the run (not cancels a gate)
|
|
177
|
+
self._render_head("running — working milestones")
|
|
178
|
+
self._foot(f"[dim]working autonomously · [/][{LAVENDER}]esc[/][dim] to stop "
|
|
179
|
+
f"(finished milestones stay committed)[/]")
|
|
180
|
+
await self._line(f"[{PURPLE}]sandbox[/] [dim]network {'on' if permits.use_network else 'off (offline)'} — provisioning…[/]")
|
|
181
|
+
self._events = asyncio.Queue()
|
|
182
|
+
self._consumer = asyncio.create_task(self._consume_events())
|
|
183
|
+
try:
|
|
184
|
+
summary = await self._backend.run(plan, permits, on_event=self._emit_event)
|
|
185
|
+
await self._events.join()
|
|
186
|
+
finally:
|
|
187
|
+
self._consumer.cancel()
|
|
188
|
+
await self._show_summary(summary)
|
|
189
|
+
self._finish(summary)
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
# Esc while running: the runner commits each milestone, so partial work
|
|
192
|
+
# is safe on the branch. Keep it for review.
|
|
193
|
+
await self._backend.cleanup(keep=True)
|
|
194
|
+
self._finish(GoalSummary("stopped", "stopped by you (finished milestones kept)"))
|
|
195
|
+
raise
|
|
196
|
+
except Exception as e: # noqa: BLE001
|
|
197
|
+
await self._fail(f"{type(e).__name__}: {e}")
|
|
198
|
+
|
|
199
|
+
async def _fail(self, reason: str) -> None:
|
|
200
|
+
await self._line(f"[bold {RED}]✗ {escape(reason)}[/]")
|
|
201
|
+
await self._backend.cleanup(keep=False)
|
|
202
|
+
self._finish(GoalSummary("error", reason))
|
|
203
|
+
|
|
204
|
+
# ---- plan + summary panels ---------------------------------------------
|
|
205
|
+
|
|
206
|
+
async def _show_plan(self, plan: list[str], permits: Permits) -> None:
|
|
207
|
+
self._render_head(f"review the plan — {len(plan)} milestone(s)")
|
|
208
|
+
await self._line(f"\n[bold {VIOLET}]plan[/] [dim]({len(plan)} milestones)[/]")
|
|
209
|
+
for i, m in enumerate(plan, 1):
|
|
210
|
+
await self._line(f" [{PURPLE}]{i}.[/] {escape(m)}")
|
|
211
|
+
if permits.needs_notice:
|
|
212
|
+
await self._line(f"[dim]this run will need — approve before you leave:[/]")
|
|
213
|
+
if permits.use_network:
|
|
214
|
+
await self._line(f" [magenta]🌐 network[/] — {escape(permits.net_reason or 'requested')}")
|
|
215
|
+
elif permits.net_reason:
|
|
216
|
+
await self._line(f" [dim]· plan implies network ({escape(permits.net_reason)}) but it's forced off — those steps will fail[/]")
|
|
217
|
+
for v in permits.asks:
|
|
218
|
+
await self._line(f" [{AMBER}]⬆ {escape(v.reason)}[/]")
|
|
219
|
+
else:
|
|
220
|
+
await self._line(f"[dim]no extra permissions (offline, no privileged commands).[/]")
|
|
221
|
+
self._foot(f"[{LAVENDER}]y[/] run [{LAVENDER}]e[/] discuss/edit [{LAVENDER}]n[/] cancel"
|
|
222
|
+
f" [dim]· pgup/pgdn scroll[/]")
|
|
223
|
+
|
|
224
|
+
async def _show_summary(self, s: GoalSummary) -> None:
|
|
225
|
+
sym = "¥" if s.currency == "cny" else "$"
|
|
226
|
+
colour = {"done": VIOLET}.get(s.status, AMBER if s.status in ("budget", "stalled", "stopped") else RED)
|
|
227
|
+
await self._line(f"\n[bold {colour}]goal {escape(s.status)}[/] — {escape(s.reason)}")
|
|
228
|
+
await self._line(f"[dim]{s.milestones_done}/{s.milestones_total} milestones · "
|
|
229
|
+
f"spend {sym}{s.spend:.4f}[/]")
|
|
230
|
+
if s.branch:
|
|
231
|
+
# The single most important thing to say: this did NOT touch their files.
|
|
232
|
+
await self._line(
|
|
233
|
+
f"[{AMBER}]⚠ these changes are on branch [b]{escape(s.branch)}[/] in a separate "
|
|
234
|
+
f"worktree — your current files are untouched.[/]")
|
|
235
|
+
await self._line(f"[{LAVENDER}]review:[/] [dim]{escape(s.review_cmd)}[/]")
|
|
236
|
+
await self._line(f"[{LAVENDER}]run it:[/] [dim]cd {escape(s.workspace)}[/]")
|
|
237
|
+
await self._line(f"[{LAVENDER}]tidy:[/] [dim]{escape(s.tidy_cmd)}[/]")
|
|
238
|
+
elif s.workspace:
|
|
239
|
+
await self._line(f"[{LAVENDER}]review the work in:[/] [dim]{escape(s.workspace)}[/]")
|
|
240
|
+
if s.log:
|
|
241
|
+
await self._line(f"[{LAVENDER}]log:[/] [dim]{escape(s.log)}[/]")
|
|
242
|
+
if s.branch:
|
|
243
|
+
await self._line(
|
|
244
|
+
f"\n[dim]↵ back to chat — ask rocky to review or merge it for you, "
|
|
245
|
+
f"or run the commands above yourself.[/]")
|
|
246
|
+
|
|
247
|
+
# ---- input plumbing -----------------------------------------------------
|
|
248
|
+
|
|
249
|
+
async def _await_decision(self) -> str:
|
|
250
|
+
self._decision = asyncio.get_running_loop().create_future()
|
|
251
|
+
self._state = "confirm"
|
|
252
|
+
self.focus()
|
|
253
|
+
return await self._decision
|
|
254
|
+
|
|
255
|
+
async def _await_edit(self) -> str:
|
|
256
|
+
self._edit = asyncio.get_running_loop().create_future()
|
|
257
|
+
self._state = "edit"
|
|
258
|
+
box = self.query_one("#goal-edit", Input)
|
|
259
|
+
box.add_class("on")
|
|
260
|
+
box.value = ""
|
|
261
|
+
box.focus()
|
|
262
|
+
self._foot(f"[dim]type a note and press[/] [{LAVENDER}]enter[/][dim] — or empty to go back[/]")
|
|
263
|
+
try:
|
|
264
|
+
return await self._edit
|
|
265
|
+
finally:
|
|
266
|
+
box.remove_class("on")
|
|
267
|
+
self._state = "confirm"
|
|
268
|
+
|
|
269
|
+
def action_decide(self, choice: str) -> None:
|
|
270
|
+
if self._state == "confirm" and self._decision and not self._decision.done():
|
|
271
|
+
self._decision.set_result(choice)
|
|
272
|
+
|
|
273
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
274
|
+
if event.input.id == "goal-edit" and self._edit and not self._edit.done():
|
|
275
|
+
self._edit.set_result(event.value.strip())
|
|
276
|
+
event.stop()
|
|
277
|
+
|
|
278
|
+
def action_stop(self) -> None:
|
|
279
|
+
"""Esc: at the gate it cancels; while running it stops the worker (partial
|
|
280
|
+
work stays committed on the branch)."""
|
|
281
|
+
if self._state in ("confirm",) and self._decision and not self._decision.done():
|
|
282
|
+
self._decision.set_result("cancel")
|
|
283
|
+
elif self._state == "edit" and self._edit and not self._edit.done():
|
|
284
|
+
self._edit.set_result("")
|
|
285
|
+
elif self._state == "running":
|
|
286
|
+
w = next((w for w in self.workers if w.name == "goal-drive"), None)
|
|
287
|
+
if w is not None:
|
|
288
|
+
w.cancel()
|
|
289
|
+
|
|
290
|
+
def action_log_up(self) -> None:
|
|
291
|
+
self.query_one("#goal-log", VerticalScroll).scroll_page_up(animate=False)
|
|
292
|
+
|
|
293
|
+
def action_log_down(self) -> None:
|
|
294
|
+
self.query_one("#goal-log", VerticalScroll).scroll_page_down(animate=False)
|
|
295
|
+
|
|
296
|
+
# ---- finish -------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def _finish(self, summary: GoalSummary) -> None:
|
|
299
|
+
"""Land in the done state — one clear exit (Enter → chat). Reviewing and
|
|
300
|
+
merging happen back in chat (ask rocky, or run the commands shown), so
|
|
301
|
+
there's no separate screen 'mode' to get stuck in."""
|
|
302
|
+
self._summary = summary
|
|
303
|
+
self._state = "done"
|
|
304
|
+
self._render_head(f"goal {summary.status}")
|
|
305
|
+
self._foot(f"[{LAVENDER}]enter[/] [dim]· back to chat[/] [dim]· pgup/pgdn scroll[/]")
|
|
306
|
+
try:
|
|
307
|
+
self.focus()
|
|
308
|
+
except Exception: # noqa: BLE001 — screen may be tearing down
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
def on_key(self, event) -> None:
|
|
312
|
+
# In the done state, Enter (or Esc) is the single way out — back to chat.
|
|
313
|
+
if self._state == "done" and event.key in ("enter", "escape"):
|
|
314
|
+
event.stop()
|
|
315
|
+
self.dismiss(self._summary)
|