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