rockycode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rockycode/__init__.py +1 -0
- rockycode/banner.py +37 -0
- rockycode/cli.py +1386 -0
- rockycode/config.py +178 -0
- rockycode/dream/__init__.py +9 -0
- rockycode/dream/core.py +523 -0
- rockycode/dream/judge.py +134 -0
- rockycode/dream/mining.py +152 -0
- rockycode/dream/proposals.py +440 -0
- rockycode/engine/__init__.py +10 -0
- rockycode/engine/artifact.py +367 -0
- rockycode/engine/budget.py +90 -0
- rockycode/engine/checks.py +157 -0
- rockycode/engine/compaction.py +181 -0
- rockycode/engine/container.py +225 -0
- rockycode/engine/effort.py +46 -0
- rockycode/engine/events.py +101 -0
- rockycode/engine/explore.py +592 -0
- rockycode/engine/goal.py +541 -0
- rockycode/engine/goal_review.py +161 -0
- rockycode/engine/goal_session.py +259 -0
- rockycode/engine/headless.py +481 -0
- rockycode/engine/loop.py +711 -0
- rockycode/engine/lsp.py +473 -0
- rockycode/engine/mcp.py +364 -0
- rockycode/engine/modes.py +123 -0
- rockycode/engine/outcome.py +81 -0
- rockycode/engine/permission.py +198 -0
- rockycode/engine/planmode.py +249 -0
- rockycode/engine/providers.py +196 -0
- rockycode/engine/redact.py +83 -0
- rockycode/engine/safety.py +139 -0
- rockycode/engine/sandbox.py +219 -0
- rockycode/engine/server.py +431 -0
- rockycode/engine/skills.py +178 -0
- rockycode/engine/titler.py +46 -0
- rockycode/engine/tools.py +479 -0
- rockycode/engine/trajectory.py +131 -0
- rockycode/engine/web.py +431 -0
- rockycode/engine/worktree.py +128 -0
- rockycode/memory/__init__.py +7 -0
- rockycode/memory/index.py +260 -0
- rockycode/memory/store.py +331 -0
- rockycode/modes/learn/learn.md +46 -0
- rockycode/modes/research/deep-research.md +53 -0
- rockycode/modes/research/paper-reading.md +49 -0
- rockycode/modes/research/prove.md +60 -0
- rockycode/modes/research/whiteboard.md +64 -0
- rockycode/onboarding.py +332 -0
- rockycode/palette.py +15 -0
- rockycode/pricing.py +178 -0
- rockycode/prompts/__init__.py +0 -0
- rockycode/prompts/rocky.py +257 -0
- rockycode/routines.py +287 -0
- rockycode/runners/__init__.py +0 -0
- rockycode/runners/agent.py +273 -0
- rockycode/runners/data.py +61 -0
- rockycode/runners/raw.py +176 -0
- rockycode/score.py +114 -0
- rockycode/session.py +298 -0
- rockycode/skills/architecture-viz/SKILL.md +71 -0
- rockycode/skills/architecture-viz/template.html +87 -0
- rockycode/skills/lean-prover/SKILL.md +155 -0
- rockycode/skills/lean-prover/torchlean-api.md +85 -0
- rockycode/tui/__init__.py +1 -0
- rockycode/tui/app.py +2450 -0
- rockycode/tui/exitsheet.py +181 -0
- rockycode/tui/goal_screen.py +315 -0
- rockycode/tui/mdterm.py +232 -0
- rockycode/tui/mdview.py +99 -0
- rockycode/tui/modepicker.py +103 -0
- rockycode/tui/permission.py +154 -0
- rockycode/tui/plangate.py +110 -0
- rockycode/tui/prompt_history.py +77 -0
- rockycode/tui/proposalcard.py +126 -0
- rockycode/tui/resume.py +142 -0
- rockycode/tui/rocky_pet.py +96 -0
- rockycode/tui/routinecard.py +123 -0
- rockycode-0.1.0.dist-info/METADATA +488 -0
- rockycode-0.1.0.dist-info/RECORD +83 -0
- rockycode-0.1.0.dist-info/WHEEL +4 -0
- rockycode-0.1.0.dist-info/entry_points.txt +2 -0
- rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
rockycode/tui/app.py
ADDED
|
@@ -0,0 +1,2450 @@
|
|
|
1
|
+
"""The rockycode TUI shell.
|
|
2
|
+
|
|
3
|
+
Layout: top bar (title + status word) / scrolling transcript / input.
|
|
4
|
+
|
|
5
|
+
Styling: a custom "rocky" theme with explicit hex colors (soft purples).
|
|
6
|
+
Never use ANSI named colors ("magenta", "cyan") in this app — terminals
|
|
7
|
+
remap them arbitrarily (neon pink, electric cyan) and it gets ugly fast.
|
|
8
|
+
|
|
9
|
+
Streaming discipline (this is the no-glitch contract):
|
|
10
|
+
- engine deltas land in string buffers, never directly in widgets
|
|
11
|
+
- a 10 Hz flush timer moves dirty buffers into widgets in one update each
|
|
12
|
+
- the transcript is a real scroll container — no terminal-scrollback
|
|
13
|
+
arithmetic anywhere
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
from urllib.request import url2pathname
|
|
24
|
+
|
|
25
|
+
from rich.markup import escape
|
|
26
|
+
from textual import events, on, work
|
|
27
|
+
from textual.app import App, ComposeResult
|
|
28
|
+
from textual.binding import Binding
|
|
29
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
30
|
+
from textual.css.query import NoMatches
|
|
31
|
+
from textual.message import Message
|
|
32
|
+
from textual.screen import ModalScreen
|
|
33
|
+
from textual.theme import Theme
|
|
34
|
+
from textual.widgets import Button, Collapsible, Markdown, Static, TextArea
|
|
35
|
+
from textual.worker import Worker, WorkerState
|
|
36
|
+
|
|
37
|
+
from rockycode.banner import ROCKY_TAGLINE
|
|
38
|
+
from rockycode.engine import AgentState, Engine
|
|
39
|
+
from rockycode.engine import tools as tools_mod
|
|
40
|
+
from rockycode.engine.effort import EFFORT_LEVELS, to_deepseek
|
|
41
|
+
from rockycode.engine.events import (
|
|
42
|
+
Compacted,
|
|
43
|
+
ContextReminder,
|
|
44
|
+
EngineError,
|
|
45
|
+
StateChanged,
|
|
46
|
+
TextDelta,
|
|
47
|
+
ThinkingDelta,
|
|
48
|
+
ToolFinished,
|
|
49
|
+
ToolStarted,
|
|
50
|
+
TurnFinished,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
from rockycode.palette import AMBER, LAVENDER, MUTED, PURPLE, RED, VIOLET
|
|
54
|
+
from rockycode.onboarding import project_env_warnings
|
|
55
|
+
from rockycode.tui.mdterm import (
|
|
56
|
+
enhance_markdown,
|
|
57
|
+
link_click_action,
|
|
58
|
+
rocky_markdown_parser,
|
|
59
|
+
)
|
|
60
|
+
from rockycode.tui.mdview import TEXT_DOC_SUFFIXES, WIDTHS, DocDock
|
|
61
|
+
from rockycode.tui.prompt_history import PromptHistory
|
|
62
|
+
|
|
63
|
+
FLUSH_HZ = 10
|
|
64
|
+
|
|
65
|
+
ROCKY_THEME = Theme(
|
|
66
|
+
name="rocky",
|
|
67
|
+
primary=PURPLE,
|
|
68
|
+
secondary=LAVENDER,
|
|
69
|
+
accent=VIOLET,
|
|
70
|
+
foreground="#c0caf5",
|
|
71
|
+
background="#16161e",
|
|
72
|
+
surface="#1e1e28",
|
|
73
|
+
panel="#23232f",
|
|
74
|
+
success=PURPLE,
|
|
75
|
+
warning=AMBER,
|
|
76
|
+
error=RED,
|
|
77
|
+
dark=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
ROCKY_THEME_LIGHT = Theme(
|
|
81
|
+
name="rocky-light",
|
|
82
|
+
primary=PURPLE,
|
|
83
|
+
secondary="#5a5e7a",
|
|
84
|
+
accent="#7c5cba",
|
|
85
|
+
foreground="#2a2a38",
|
|
86
|
+
background="#f6f4fb",
|
|
87
|
+
surface="#ffffff",
|
|
88
|
+
panel="#ece8f7",
|
|
89
|
+
success="#6a4ca3",
|
|
90
|
+
warning="#b5852f",
|
|
91
|
+
error=RED,
|
|
92
|
+
dark=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Rocky speaks in musical chords — the note cycles while he thinks/sings.
|
|
96
|
+
NOTES = "♪♫♬♩"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _status_text(state: AgentState, note: str) -> str:
|
|
100
|
+
return {
|
|
101
|
+
AgentState.IDLE: "",
|
|
102
|
+
AgentState.THINKING: f"[italic {VIOLET}]{note} thinking…[/]",
|
|
103
|
+
AgentState.RESPONDING: f"[italic {LAVENDER}]{note} singing…[/]",
|
|
104
|
+
AgentState.TOOL: f"[italic {MUTED}]⚒ working…[/]",
|
|
105
|
+
AgentState.COMPACTING: f"[italic {MUTED}]♻ squeeze context…[/]",
|
|
106
|
+
AgentState.AMAZED: f"[bold {VIOLET}]✦ amaze! {note}[/]",
|
|
107
|
+
AgentState.ERROR: f"[{RED}]✗ no good[/]",
|
|
108
|
+
}[state]
|
|
109
|
+
|
|
110
|
+
HELP_TEXT = f"""\
|
|
111
|
+
[bold {VIOLET}]commands[/]
|
|
112
|
+
[{LAVENDER}]/help[/] show this
|
|
113
|
+
[{LAVENDER}]/plan [topic|off][/] plan first — read-only explore + brainstorm into a plan file you approve
|
|
114
|
+
[{LAVENDER}]/goal [objective][/] go autonomous — plan → confirm → work, in its own view (needs Docker)
|
|
115
|
+
[{LAVENDER}]/research[/] research mode — pick how we work: deep-research · paper-reading · whiteboard
|
|
116
|
+
[{LAVENDER}]/learn[/] learn mode — rocky tutors you through a paper, a codebase, or a concept
|
|
117
|
+
[{LAVENDER}]/model[/] switch provider + model (deepseek · minimax · glm · kimi · mimo)
|
|
118
|
+
[{LAVENDER}]/sandbox[/] sandbox on | off | status — isolate tools in a container
|
|
119
|
+
[{LAVENDER}]/lsp[/] language-server status (diagnostics ride along read_file)
|
|
120
|
+
[{LAVENDER}]/artifact[/] artifact live on | off — auto-refresh artifacts in the browser
|
|
121
|
+
[{LAVENDER}]/prompt[/] show rocky's system prompt
|
|
122
|
+
[{LAVENDER}]/mcp[/] show connected MCP servers + tools
|
|
123
|
+
[{LAVENDER}]/skills[/] show installed skills
|
|
124
|
+
[{LAVENDER}]/memory[/] show what rocky remembers
|
|
125
|
+
[{LAVENDER}]/proposals[/] review skills rocky drafted while dreaming (approve or archive)
|
|
126
|
+
[{LAVENDER}]/routines[/] run or lease due routines (recurring, sandboxed, budgeted)
|
|
127
|
+
[{LAVENDER}]/remember <note>[/] save a note to memory (feedback)
|
|
128
|
+
[{LAVENDER}]/config [key] [value][/] show or set preferences — language auto|en|zh · theme · currency (restart to apply)
|
|
129
|
+
[{LAVENDER}]/effort [off|high|xhigh|max][/] how hard i think — applies from the next reply
|
|
130
|
+
[{LAVENDER}]/permission [yolo|ask|careful][/] show or switch approval mode (this session)
|
|
131
|
+
[{LAVENDER}]! <cmd>[/] run a shell command; output goes into rocky's context
|
|
132
|
+
[{LAVENDER}]/clear[/] clear the transcript
|
|
133
|
+
[{LAVENDER}]/exit[/] quit (also /quit or ctrl+q)"""
|
|
134
|
+
|
|
135
|
+
WELCOME = (
|
|
136
|
+
f"[bold {PURPLE}]♪♫ rocky ▸[/]\n"
|
|
137
|
+
"hello hello! i rocky. you ask, we fix bug together. amaze!\n"
|
|
138
|
+
f"[{MUTED}]try: “read the README and explain this repo”[/]"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TextViewerScreen(ModalScreen):
|
|
143
|
+
"""Scrollable full-screen viewer for long text (system prompt, files).
|
|
144
|
+
|
|
145
|
+
A modal owns keyboard focus, so arrows/PageUp/PageDown scroll naturally —
|
|
146
|
+
in the transcript those keys belong to the Input.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
BINDINGS = [("escape", "close", "close"), ("q", "close", "close")]
|
|
150
|
+
|
|
151
|
+
DEFAULT_CSS = """
|
|
152
|
+
TextViewerScreen { align: center middle; }
|
|
153
|
+
TextViewerScreen > VerticalScroll {
|
|
154
|
+
width: 90%;
|
|
155
|
+
height: 90%;
|
|
156
|
+
padding: 1 2;
|
|
157
|
+
background: $surface;
|
|
158
|
+
border: round $primary;
|
|
159
|
+
border-title-color: $text-muted;
|
|
160
|
+
border-subtitle-color: $text-muted;
|
|
161
|
+
}
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, title: str, text: str) -> None:
|
|
165
|
+
super().__init__()
|
|
166
|
+
self._title = title
|
|
167
|
+
self._text = text
|
|
168
|
+
|
|
169
|
+
def compose(self) -> ComposeResult:
|
|
170
|
+
body = VerticalScroll(Static(self._text))
|
|
171
|
+
body.border_title = self._title
|
|
172
|
+
body.border_subtitle = "↑/↓/pgup/pgdn scroll · esc close"
|
|
173
|
+
yield body
|
|
174
|
+
|
|
175
|
+
def on_mount(self) -> None:
|
|
176
|
+
self.query_one(VerticalScroll).focus()
|
|
177
|
+
|
|
178
|
+
def action_close(self) -> None:
|
|
179
|
+
self.dismiss()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ArtifactLiveModal(ModalScreen):
|
|
183
|
+
"""Ask once: open artifacts live (local server, auto-refresh) or static?
|
|
184
|
+
|
|
185
|
+
dismiss() → "live" | "static". Esc → "static" (the safe default).
|
|
186
|
+
↑↓/←→ move between the two choices, Enter picks the focused one.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
BINDINGS = [
|
|
190
|
+
("escape", "static", "static"),
|
|
191
|
+
# Buttons only move on Tab by default — let the arrows walk between them
|
|
192
|
+
# too (they sit side by side, so either axis feels natural). Enter on the
|
|
193
|
+
# focused button picks it (Button's own binding).
|
|
194
|
+
("left", "prev", ""),
|
|
195
|
+
("up", "prev", ""),
|
|
196
|
+
("right", "next", ""),
|
|
197
|
+
("down", "next", ""),
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
CSS = """
|
|
201
|
+
ArtifactLiveModal { align: center middle; }
|
|
202
|
+
ArtifactLiveModal > Vertical {
|
|
203
|
+
width: 66; height: auto; padding: 1 2;
|
|
204
|
+
background: $surface; border: round $primary;
|
|
205
|
+
}
|
|
206
|
+
ArtifactLiveModal .q { margin-bottom: 1; }
|
|
207
|
+
ArtifactLiveModal Horizontal { height: auto; align: center middle; }
|
|
208
|
+
ArtifactLiveModal Button { margin: 0 1; }
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def compose(self) -> ComposeResult:
|
|
212
|
+
with Vertical():
|
|
213
|
+
yield Static(
|
|
214
|
+
f"[bold {VIOLET}]rocky want show artifact![/]\n"
|
|
215
|
+
"open it [bold]live[/]? rocky start small local server so the "
|
|
216
|
+
"browser tab auto-refresh when rocky rebuild it. amaze!\n"
|
|
217
|
+
f"[{MUTED}](else just open once as a file — /artifact live on later)[/]",
|
|
218
|
+
classes="q",
|
|
219
|
+
)
|
|
220
|
+
with Horizontal():
|
|
221
|
+
yield Button("open live", variant="primary", id="live")
|
|
222
|
+
yield Button("just once", id="static")
|
|
223
|
+
|
|
224
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
225
|
+
self.dismiss(event.button.id)
|
|
226
|
+
|
|
227
|
+
def action_next(self) -> None:
|
|
228
|
+
self.focus_next()
|
|
229
|
+
|
|
230
|
+
def action_prev(self) -> None:
|
|
231
|
+
self.focus_previous()
|
|
232
|
+
|
|
233
|
+
def action_static(self) -> None:
|
|
234
|
+
self.dismiss("static")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ChatInput(TextArea):
|
|
238
|
+
"""Multi-line chat input. Enter sends; Ctrl+J inserts a newline; paste keeps
|
|
239
|
+
its newlines. Grows with content up to MAX_LINES, then scrolls — so a
|
|
240
|
+
pasted error or a few lines of code are actually visible while you type.
|
|
241
|
+
|
|
242
|
+
(Shift+Enter would be the usual newline key, but the kitty keyboard
|
|
243
|
+
protocol is disabled for IME support, so it's indistinguishable from
|
|
244
|
+
Enter — Ctrl+J is the reliable alternative.)
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
MAX_LINES = 8
|
|
248
|
+
|
|
249
|
+
SLASH_COMMANDS = [
|
|
250
|
+
"/help", "/plan", "/goal", "/research", "/learn", "/sandbox", "/lsp", "/artifact",
|
|
251
|
+
"/prompt", "/config", "/model", "/effort", "/permission", "/mcp", "/skills", "/memory",
|
|
252
|
+
"/proposals", "/routines", "/remember", "/clear", "/exit", "/quit",
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
class Submitted(Message):
|
|
256
|
+
def __init__(self, value: str) -> None:
|
|
257
|
+
self.value = value
|
|
258
|
+
super().__init__()
|
|
259
|
+
|
|
260
|
+
def __init__(self, *, history: PromptHistory | None = None, **kwargs) -> None:
|
|
261
|
+
super().__init__(soft_wrap=True, show_line_numbers=False, **kwargs)
|
|
262
|
+
# None → in-memory only (every direct construction in tests stays
|
|
263
|
+
# hermetic); the CLI injects a store so history survives restarts.
|
|
264
|
+
self._store = history
|
|
265
|
+
self._history: list[str] = list(history.items) if history else []
|
|
266
|
+
self._hist_idx: int | None = None
|
|
267
|
+
self._draft = "" # the in-progress text before browsing history
|
|
268
|
+
self._suggest_widget: Static | None = None
|
|
269
|
+
|
|
270
|
+
def on_mount(self) -> None:
|
|
271
|
+
self._sync_height()
|
|
272
|
+
|
|
273
|
+
def _sync_height(self) -> None:
|
|
274
|
+
# Count VISUAL rows (soft-wrapped), not just logical newlines, so a
|
|
275
|
+
# single long line or a wrapped CJK/pasted block grows the box to fit
|
|
276
|
+
# instead of staying collapsed to one row. wrapped_document.height
|
|
277
|
+
# reflects the current wrap width; fall back to logical lines before the
|
|
278
|
+
# widget has been laid out (width still 0).
|
|
279
|
+
try:
|
|
280
|
+
lines = self.wrapped_document.height or self.document.line_count
|
|
281
|
+
except Exception: # noqa: BLE001 — height math must never break typing
|
|
282
|
+
lines = self.document.line_count if self.document else 1
|
|
283
|
+
# +2 for the rounded border (border-box height includes it)
|
|
284
|
+
self.styles.height = min(max(lines, 1), self.MAX_LINES) + 2
|
|
285
|
+
|
|
286
|
+
def on_resize(self, _event: events.Resize) -> None:
|
|
287
|
+
# Terminal width changed → text re-wraps → recompute visual rows so the
|
|
288
|
+
# box still fits. TextArea._on_resize still runs its own re-wrap.
|
|
289
|
+
self._sync_height()
|
|
290
|
+
|
|
291
|
+
def _recall(self, text: str) -> None:
|
|
292
|
+
self.text = text
|
|
293
|
+
self.move_cursor(self.document.end)
|
|
294
|
+
|
|
295
|
+
def _cursor_visual_row(self) -> tuple[int, int]:
|
|
296
|
+
"""(row, total) of the cursor among VISUAL rows — soft-wrap aware, so a
|
|
297
|
+
long line that wraps to several rows is counted by what's on screen, not
|
|
298
|
+
by newlines. cursor_location[0] is the *logical* line and would treat a
|
|
299
|
+
wrapped single line as one row (up/down would recall history mid-text)."""
|
|
300
|
+
try:
|
|
301
|
+
wd = self.wrapped_document
|
|
302
|
+
return wd.location_to_offset(self.cursor_location).y, wd.height
|
|
303
|
+
except Exception: # noqa: BLE001 — cursor math must never break typing
|
|
304
|
+
return self.cursor_location[0], self.document.line_count
|
|
305
|
+
|
|
306
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
307
|
+
if event.key == "enter":
|
|
308
|
+
event.prevent_default()
|
|
309
|
+
event.stop()
|
|
310
|
+
text = self.text.strip()
|
|
311
|
+
if text:
|
|
312
|
+
if not self._history or self._history[-1] != text:
|
|
313
|
+
self._history.append(text)
|
|
314
|
+
if self._store is not None:
|
|
315
|
+
self._store.append(text)
|
|
316
|
+
self._hist_idx = None
|
|
317
|
+
self._draft = ""
|
|
318
|
+
self.post_message(self.Submitted(text))
|
|
319
|
+
self.text = ""
|
|
320
|
+
self._sync_height()
|
|
321
|
+
return
|
|
322
|
+
if event.key == "ctrl+j": # reliable manual newline
|
|
323
|
+
event.prevent_default()
|
|
324
|
+
event.stop()
|
|
325
|
+
self.insert("\n")
|
|
326
|
+
return
|
|
327
|
+
# Up/Down recall previous inputs — but only from the top/bottom VISUAL
|
|
328
|
+
# row, so while there's text above/below the cursor they just move a row
|
|
329
|
+
# within it (like an editor). Only past the top row does up reach into
|
|
330
|
+
# history — and the in-progress text is saved as the draft first.
|
|
331
|
+
vrow, vtotal = self._cursor_visual_row()
|
|
332
|
+
if event.key == "up" and vrow == 0 and self._history:
|
|
333
|
+
event.prevent_default()
|
|
334
|
+
event.stop()
|
|
335
|
+
if self._hist_idx is None:
|
|
336
|
+
self._draft = self.text
|
|
337
|
+
self._hist_idx = len(self._history) - 1
|
|
338
|
+
elif self._hist_idx > 0:
|
|
339
|
+
self._hist_idx -= 1
|
|
340
|
+
self._recall(self._history[self._hist_idx])
|
|
341
|
+
return
|
|
342
|
+
if event.key == "down" and self._hist_idx is not None \
|
|
343
|
+
and vrow == vtotal - 1:
|
|
344
|
+
event.prevent_default()
|
|
345
|
+
event.stop()
|
|
346
|
+
if self._hist_idx < len(self._history) - 1:
|
|
347
|
+
self._hist_idx += 1
|
|
348
|
+
self._recall(self._history[self._hist_idx])
|
|
349
|
+
else:
|
|
350
|
+
self._hist_idx = None
|
|
351
|
+
self._recall(self._draft)
|
|
352
|
+
return
|
|
353
|
+
if event.key == "tab":
|
|
354
|
+
text = self.text.strip()
|
|
355
|
+
if text.startswith("/"):
|
|
356
|
+
prefix = text.lower()
|
|
357
|
+
matches = [c for c in self.SLASH_COMMANDS if c.lower().startswith(prefix)]
|
|
358
|
+
if len(matches) == 1:
|
|
359
|
+
event.prevent_default()
|
|
360
|
+
event.stop()
|
|
361
|
+
self.text = matches[0] + " "
|
|
362
|
+
self.move_cursor(self.document.end)
|
|
363
|
+
self._hide_suggestions()
|
|
364
|
+
return
|
|
365
|
+
await super()._on_key(event)
|
|
366
|
+
|
|
367
|
+
@on(TextArea.Changed)
|
|
368
|
+
def _on_changed(self, _event: TextArea.Changed) -> None:
|
|
369
|
+
self._sync_height()
|
|
370
|
+
text = self.text.strip()
|
|
371
|
+
if text.startswith("/") and len(text) > 1 and "\n" not in text:
|
|
372
|
+
prefix = text.lower()
|
|
373
|
+
matches = [c for c in self.SLASH_COMMANDS if c.lower().startswith(prefix)]
|
|
374
|
+
if matches:
|
|
375
|
+
self._show_suggestions(matches, prefix)
|
|
376
|
+
else:
|
|
377
|
+
self._show_suggestions([f" ? no match — /help for all commands"], "")
|
|
378
|
+
else:
|
|
379
|
+
self._hide_suggestions()
|
|
380
|
+
|
|
381
|
+
def _show_suggestions(self, matches: list[str], prefix: str) -> None:
|
|
382
|
+
try:
|
|
383
|
+
hint = self.app.query_one("#hints", Static)
|
|
384
|
+
except Exception: # noqa: BLE001
|
|
385
|
+
return
|
|
386
|
+
line = " ".join(f"[bold]{m}[/]" if m.startswith(prefix) else m for m in matches[:8])
|
|
387
|
+
if self._suggest_widget is None:
|
|
388
|
+
hint.update(line)
|
|
389
|
+
self._suggest_widget = hint
|
|
390
|
+
|
|
391
|
+
def _hide_suggestions(self) -> None:
|
|
392
|
+
if self._suggest_widget is not None:
|
|
393
|
+
try:
|
|
394
|
+
self.app.query_one("#hints", Static).update(
|
|
395
|
+
"/help · /model · /effort · /sandbox · /prompt · /config · /mcp · /skills · /memory · !cmd · /clear · /exit"
|
|
396
|
+
)
|
|
397
|
+
except Exception: # noqa: BLE001
|
|
398
|
+
pass
|
|
399
|
+
self._suggest_widget = None
|
|
400
|
+
|
|
401
|
+
# Some terminals (iTerm2) route the wheel to the focused input instead of
|
|
402
|
+
# the widget under the pointer, so the transcript never scrolls. Forward
|
|
403
|
+
# wheel events from the input to the transcript so it scrolls regardless.
|
|
404
|
+
def _scroll_chat(self, dy: int) -> None:
|
|
405
|
+
try:
|
|
406
|
+
self.app.query_one("#transcript").scroll_relative(y=dy, animate=False)
|
|
407
|
+
except Exception: # noqa: BLE001 — best effort; never break input on scroll
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
def on_mouse_scroll_up(self, event) -> None:
|
|
411
|
+
self._scroll_chat(-3)
|
|
412
|
+
event.stop()
|
|
413
|
+
|
|
414
|
+
def on_mouse_scroll_down(self, event) -> None:
|
|
415
|
+
self._scroll_chat(3)
|
|
416
|
+
event.stop()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class RockyCodeApp(App):
|
|
420
|
+
TITLE = "rockycode"
|
|
421
|
+
|
|
422
|
+
CSS = """
|
|
423
|
+
Screen { layout: vertical; }
|
|
424
|
+
#topbar {
|
|
425
|
+
dock: top;
|
|
426
|
+
height: 4;
|
|
427
|
+
border-bottom: solid $primary;
|
|
428
|
+
}
|
|
429
|
+
#title-block { width: 1fr; padding: 1 2; }
|
|
430
|
+
#status { width: auto; padding: 1 2; content-align: right middle; }
|
|
431
|
+
#workspace { height: 1fr; }
|
|
432
|
+
#transcript {
|
|
433
|
+
width: 1fr;
|
|
434
|
+
height: 100%;
|
|
435
|
+
margin: 1 1 0 1;
|
|
436
|
+
padding: 0 2;
|
|
437
|
+
border: round $panel-lighten-2;
|
|
438
|
+
border-title-color: $text-muted;
|
|
439
|
+
scrollbar-size-vertical: 1;
|
|
440
|
+
scrollbar-color: $primary 40%;
|
|
441
|
+
scrollbar-color-hover: $primary;
|
|
442
|
+
scrollbar-color-active: $primary;
|
|
443
|
+
scrollbar-background: $surface;
|
|
444
|
+
}
|
|
445
|
+
#docdock {
|
|
446
|
+
width: 45%;
|
|
447
|
+
height: 100%;
|
|
448
|
+
margin: 1 1 0 0;
|
|
449
|
+
border: round $primary;
|
|
450
|
+
border-title-color: $text-muted;
|
|
451
|
+
}
|
|
452
|
+
#docdock-head { height: 1; padding: 0 1; }
|
|
453
|
+
#docdock-body {
|
|
454
|
+
height: 1fr;
|
|
455
|
+
padding: 0 2;
|
|
456
|
+
scrollbar-size-vertical: 1;
|
|
457
|
+
scrollbar-color: $primary 40%;
|
|
458
|
+
scrollbar-color-hover: $primary;
|
|
459
|
+
scrollbar-color-active: $primary;
|
|
460
|
+
scrollbar-background: $surface;
|
|
461
|
+
}
|
|
462
|
+
#prompt {
|
|
463
|
+
margin: 0 1;
|
|
464
|
+
padding: 0 1;
|
|
465
|
+
border: round $panel-lighten-2;
|
|
466
|
+
border-title-color: $text-muted;
|
|
467
|
+
border-subtitle-color: $text-muted;
|
|
468
|
+
background: $surface;
|
|
469
|
+
scrollbar-size-vertical: 1;
|
|
470
|
+
}
|
|
471
|
+
#prompt:focus { border: round $primary; }
|
|
472
|
+
#statusbar { height: 1; padding: 0 2; }
|
|
473
|
+
#cwd { width: 1fr; color: $text-muted; }
|
|
474
|
+
#modechip { width: auto; margin-right: 2; }
|
|
475
|
+
#total { width: auto; color: $text-muted; content-align: right middle; }
|
|
476
|
+
#hints { height: 1; padding: 0 2; color: $text-muted; }
|
|
477
|
+
|
|
478
|
+
.user-msg { margin: 1 0 0 0; }
|
|
479
|
+
.rocky-label { margin: 1 0 0 0; }
|
|
480
|
+
.tool-line { color: $text-muted; }
|
|
481
|
+
.error-msg { color: $error; }
|
|
482
|
+
.usage-line { color: $text-muted; text-style: italic; margin: 0 0 1 0; }
|
|
483
|
+
.thinking-body { color: $text-muted; text-style: italic; }
|
|
484
|
+
Collapsible { margin: 0; border: none; }
|
|
485
|
+
Markdown { margin: 0 0 1 0; }
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
BINDINGS = [
|
|
489
|
+
("ctrl+q", "quit", "quit"),
|
|
490
|
+
("escape", "cancel_turn", "stop turn"),
|
|
491
|
+
# priority: the focused input (a TextArea) would otherwise eat these
|
|
492
|
+
# for its own cursor, so the transcript could never scroll.
|
|
493
|
+
Binding("pageup", "transcript_up", "scroll up", priority=True),
|
|
494
|
+
Binding("pagedown", "transcript_down", "scroll down", priority=True),
|
|
495
|
+
Binding("shift+up", "transcript_line_up", "scroll up", priority=True),
|
|
496
|
+
Binding("shift+down", "transcript_line_down", "scroll down", priority=True),
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
def __init__(
|
|
500
|
+
self, engine: Engine, *, resume: bool = False, resume_session=None, sandbox=None,
|
|
501
|
+
currency: str = "usd", theme: str = "dark",
|
|
502
|
+
permission: str = "ask", permission_weakened: bool = False,
|
|
503
|
+
prompt_history: PromptHistory | None = None,
|
|
504
|
+
exit_sheet: str = "auto", dream: str = "auto",
|
|
505
|
+
) -> None:
|
|
506
|
+
super().__init__()
|
|
507
|
+
self.engine = engine
|
|
508
|
+
self._prompt_history = prompt_history
|
|
509
|
+
self._resume_requested = resume
|
|
510
|
+
self._resume_session = resume_session # SessionInfo → skip the picker
|
|
511
|
+
self._titled = False # one title record per session file (see _maybe_title)
|
|
512
|
+
self._currency = currency
|
|
513
|
+
self._theme_pref = theme
|
|
514
|
+
self._permission_mode = permission
|
|
515
|
+
self._permission_weakened = permission_weakened
|
|
516
|
+
self._auto_approve: set[str] = set() # non-bash tools OK'd this session
|
|
517
|
+
self._auto_approve_bins: set[str] = set() # bash BINARIES OK'd this session
|
|
518
|
+
self._turn_worker: Worker | None = None # captured so Esc can cancel it
|
|
519
|
+
from rockycode.pricing import UsageLedger
|
|
520
|
+
self._ledger = getattr(engine, "ledger", None) or UsageLedger()
|
|
521
|
+
self.sandbox = sandbox # ChatSandbox | None — created on /sandbox on
|
|
522
|
+
self._local_registry: dict | None = None # saved so we can swap back
|
|
523
|
+
self._sandbox_starting = False # guard concurrent /sandbox on
|
|
524
|
+
self._lsp_connecting = False # guard concurrent LSP start
|
|
525
|
+
# Artifact live mode: None = undecided (ask on first artifact), True/False set.
|
|
526
|
+
self._artifact_live = getattr(engine, "artifact_live", None)
|
|
527
|
+
self._artifact_server = None # ArtifactServer once live mode starts
|
|
528
|
+
# Set by the plan gate's "approve": the implement-turn text, submitted
|
|
529
|
+
# from on_worker_state_changed once the current plan-mode turn finishes.
|
|
530
|
+
self._plan_go: str | None = None
|
|
531
|
+
self._exit_sheet_open = False # re-entry guard for /exit + ctrl+q
|
|
532
|
+
self._exit_sheet_fut: asyncio.Future | None = None # so a 2nd /exit can skip
|
|
533
|
+
self._exit_sheet = exit_sheet # "auto" | "on" | "off" (config; live via /config)
|
|
534
|
+
self._dream_mode = dream # "auto" | "manual" — launch catch-up dream
|
|
535
|
+
# Did the launch probe find Ollama? None = not probed (dream off/manual,
|
|
536
|
+
# memory off, or exited before the worker ran). "auto" sheets key off
|
|
537
|
+
# this: no live dream consumer → the user is never asked for feedback.
|
|
538
|
+
self._ollama_ok: bool | None = None
|
|
539
|
+
|
|
540
|
+
# animate=False on purpose: instant, snappy scroll (the default animation
|
|
541
|
+
# lags on a fast keypress and, in headless pilot runs, hadn't applied yet).
|
|
542
|
+
def action_transcript_up(self) -> None:
|
|
543
|
+
self._transcript().scroll_page_up(animate=False)
|
|
544
|
+
|
|
545
|
+
def action_transcript_down(self) -> None:
|
|
546
|
+
self._transcript().scroll_page_down(animate=False)
|
|
547
|
+
|
|
548
|
+
def action_transcript_line_up(self) -> None:
|
|
549
|
+
self._transcript().scroll_up(animate=False)
|
|
550
|
+
|
|
551
|
+
def action_transcript_line_down(self) -> None:
|
|
552
|
+
self._transcript().scroll_down(animate=False)
|
|
553
|
+
|
|
554
|
+
# ---- permission + interrupt ---------------------------------------------
|
|
555
|
+
|
|
556
|
+
async def action_cancel_turn(self) -> None:
|
|
557
|
+
"""Esc: interrupt the running turn. Cancelling the worker raises
|
|
558
|
+
CancelledError into run_turn, whose finally backfills tool responses so
|
|
559
|
+
history stays API-valid; on_worker_state_changed resets the status.
|
|
560
|
+
With no turn running, esc closes the doc dock instead (interrupting
|
|
561
|
+
always wins — while streaming, close the dock with its ✕)."""
|
|
562
|
+
w = self._turn_worker
|
|
563
|
+
if w is not None and w.is_running:
|
|
564
|
+
w.cancel()
|
|
565
|
+
return
|
|
566
|
+
await self.action_doc_close()
|
|
567
|
+
|
|
568
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
569
|
+
# A binding-Esc cancel raises CancelledError (BaseException), which the
|
|
570
|
+
# _run_turn wrapper deliberately does NOT catch, so the worker ends
|
|
571
|
+
# CANCELLED. The `is self._turn_worker` guard also excludes the prior
|
|
572
|
+
# worker that @work(exclusive) cancels on a new submit (its handle was
|
|
573
|
+
# already replaced), so this fires only for an explicit interrupt.
|
|
574
|
+
if event.worker is self._turn_worker and event.state is WorkerState.CANCELLED:
|
|
575
|
+
self._set_status(AgentState.IDLE)
|
|
576
|
+
# The cancelled worker can't await; finalize from a fresh one so the
|
|
577
|
+
# partial reply renders as markdown (matching the modal-cancel path).
|
|
578
|
+
self.run_worker(self._after_cancel(), exclusive=False)
|
|
579
|
+
# Plan gate "approve": the implement turn is submitted only AFTER the
|
|
580
|
+
# plan-mode turn finishes (it can't self-submit from inside its own worker).
|
|
581
|
+
elif (event.worker is self._turn_worker
|
|
582
|
+
and event.state is WorkerState.SUCCESS and self._plan_go):
|
|
583
|
+
text, self._plan_go = self._plan_go, None
|
|
584
|
+
self._turn_worker = self._run_turn(text)
|
|
585
|
+
|
|
586
|
+
async def _after_cancel(self) -> None:
|
|
587
|
+
await self._flush_buffers()
|
|
588
|
+
await self._finalize_reply()
|
|
589
|
+
await self._add(Static(f"[{MUTED}]· turn cancelled[/]", classes="tool-line"))
|
|
590
|
+
try:
|
|
591
|
+
self.query_one(ChatInput).focus()
|
|
592
|
+
except Exception: # noqa: BLE001 — focus is best-effort during teardown
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
async def _approve_tool(self, name: str, args: dict) -> bool:
|
|
596
|
+
"""Engine approver (wired in on_mount for ask/careful). Runs the pure
|
|
597
|
+
policy; only asks when it says 'ask' and the tool isn't already allowed
|
|
598
|
+
for the session. The prompt is shown INLINE (see _ask_inline) so the
|
|
599
|
+
transcript stays scrollable while it waits. Esc aborts the whole turn."""
|
|
600
|
+
from rockycode.engine.permission import decide, session_grantable, sniff_danger
|
|
601
|
+
from rockycode.tui.permission import CancelTurn
|
|
602
|
+
|
|
603
|
+
tool = self.engine.registry.get(name)
|
|
604
|
+
risk = getattr(tool, "risk", "risky")
|
|
605
|
+
verdict = decide(self._permission_mode, risk, args, self.engine.workdir,
|
|
606
|
+
tool=name, read_grants=self.engine.read_grants)
|
|
607
|
+
if verdict == "block":
|
|
608
|
+
# A never-allowed command (e.g. sudo rm -rf /). Refuse in every mode —
|
|
609
|
+
# this is the system blocking, not the user denying, so say so loudly.
|
|
610
|
+
await self._add(Static(
|
|
611
|
+
f"[bold {RED}]⛔ blocked a dangerous command[/] "
|
|
612
|
+
f"[dim]{escape(self._tool_detail(name, args))}[/]", classes="error-msg"))
|
|
613
|
+
return False
|
|
614
|
+
if verdict == "allow":
|
|
615
|
+
return True # in-jail (or already-granted) → nothing new to grant
|
|
616
|
+
# verdict == "ask": a session grant only covers non-dangerous calls, and
|
|
617
|
+
# for bash it's scoped to the BINARY, not "all bash" — approving `lake
|
|
618
|
+
# build` lets `lake …` run but a later `curl`/`rm` still prompts.
|
|
619
|
+
from rockycode.engine.permission import command_binary
|
|
620
|
+
|
|
621
|
+
if session_grantable(name, args):
|
|
622
|
+
if name == "bash":
|
|
623
|
+
binary = command_binary(str(args.get("command", "")))
|
|
624
|
+
if binary and binary in self._auto_approve_bins:
|
|
625
|
+
return True
|
|
626
|
+
elif name in self._auto_approve:
|
|
627
|
+
self._grant_read_if_escaping(name, args)
|
|
628
|
+
return True
|
|
629
|
+
# A dangerous bash command may NEVER be session-granted → no "allow"
|
|
630
|
+
# option is offered (session_label=None); only run-once or deny.
|
|
631
|
+
session_label = None
|
|
632
|
+
if session_grantable(name, args):
|
|
633
|
+
if name == "bash":
|
|
634
|
+
binary = command_binary(str(args.get("command", "")))
|
|
635
|
+
if binary:
|
|
636
|
+
session_label = f"Allow `{binary}` for this session"
|
|
637
|
+
else:
|
|
638
|
+
session_label = f"Allow {name} for this session"
|
|
639
|
+
choice = await self._ask_inline(
|
|
640
|
+
name, self._tool_detail(name, args), risk, sniff_danger(name, args),
|
|
641
|
+
session_label=session_label,
|
|
642
|
+
)
|
|
643
|
+
if choice in ("once", "session"):
|
|
644
|
+
if choice == "session":
|
|
645
|
+
if name == "bash":
|
|
646
|
+
b = command_binary(str(args.get("command", "")))
|
|
647
|
+
if b:
|
|
648
|
+
self._auto_approve_bins.add(b)
|
|
649
|
+
else:
|
|
650
|
+
self._auto_approve.add(name)
|
|
651
|
+
self._grant_read_if_escaping(name, args) # make the approval REAL: widen the read jail
|
|
652
|
+
return True
|
|
653
|
+
if choice == "cancel":
|
|
654
|
+
raise CancelTurn()
|
|
655
|
+
return False # "deny" (or dismissed) → model gets a [denied] result
|
|
656
|
+
|
|
657
|
+
def _grant_read_if_escaping(self, name: str, args: dict) -> None:
|
|
658
|
+
"""Approving an out-of-workdir read must actually let it happen — the hard
|
|
659
|
+
jail (tools._jail) blocks it otherwise, so the approval would be a lie.
|
|
660
|
+
Record the resolved path in engine.read_grants (reads only); the jail +
|
|
661
|
+
permission layer both consult it, so it now runs and won't re-prompt.
|
|
662
|
+
Secret files (_is_secret_file) are still refused inside a granted path."""
|
|
663
|
+
if name != "read_file":
|
|
664
|
+
return
|
|
665
|
+
p = args.get("path")
|
|
666
|
+
if not isinstance(p, str) or not p:
|
|
667
|
+
return
|
|
668
|
+
try:
|
|
669
|
+
target = Path(p)
|
|
670
|
+
if not target.is_absolute():
|
|
671
|
+
target = self.engine.workdir / target
|
|
672
|
+
target = target.resolve()
|
|
673
|
+
wd = self.engine.workdir.resolve()
|
|
674
|
+
except (OSError, ValueError, RuntimeError):
|
|
675
|
+
return
|
|
676
|
+
roots = (wd, *self.engine.allowed_roots, *self.engine.read_grants)
|
|
677
|
+
if not any(target == r or r in target.parents for r in roots):
|
|
678
|
+
self.engine.read_grants.add(target) # escapes every root → grant it
|
|
679
|
+
|
|
680
|
+
# ---- plan mode ----------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
async def _handle_plan(self, text: str) -> None:
|
|
683
|
+
"""/plan [topic] — enter plan mode; /plan off — leave it.
|
|
684
|
+
|
|
685
|
+
Plan mode makes the ENGINE read-only (engine/planmode.py gates every tool
|
|
686
|
+
call before the approver — even in yolo) except for one plan file the
|
|
687
|
+
model drafts. The instruction rides each user turn, so toggling never
|
|
688
|
+
touches the system prompt or tool schemas (the prefix cache stays warm)."""
|
|
689
|
+
from rockycode.engine import planmode
|
|
690
|
+
|
|
691
|
+
parts = text.split(maxsplit=1)
|
|
692
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
693
|
+
if arg.lower() == "off":
|
|
694
|
+
if self.engine.plan_file is None:
|
|
695
|
+
await self._add(Static(f"[{MUTED}]plan mode is not on — /plan [topic] to start[/]",
|
|
696
|
+
classes="tool-line"))
|
|
697
|
+
return
|
|
698
|
+
kept = self.engine.plan_file.relative_to(self.engine.workdir)
|
|
699
|
+
self.engine.plan_file = None
|
|
700
|
+
self._render_cwd()
|
|
701
|
+
await self._add(Static(
|
|
702
|
+
f"[{VIOLET}]✦ plan mode off[/] [dim]— {kept} is kept for reference; "
|
|
703
|
+
"rocky acts on it only if you ask[/]", classes="tool-line"))
|
|
704
|
+
return
|
|
705
|
+
if self.engine.plan_file is not None:
|
|
706
|
+
await self._add(Static(
|
|
707
|
+
f"[{MUTED}]already planning → {self.engine.plan_file.relative_to(self.engine.workdir)} · "
|
|
708
|
+
f"/plan off to leave[/]", classes="tool-line"))
|
|
709
|
+
return
|
|
710
|
+
path = planmode.create_plan_file(self.engine.workdir, topic=arg)
|
|
711
|
+
self.engine.plan_file = path
|
|
712
|
+
self._render_cwd()
|
|
713
|
+
await self._add(Static(
|
|
714
|
+
f"[{VIOLET}]✦ plan mode[/] — read-only: rocky explores + brainstorms first, then drafts the plan\n"
|
|
715
|
+
f" [dim]plan file:[/] {path.relative_to(self.engine.workdir)}\n"
|
|
716
|
+
f" [dim]code stays untouched · /plan off to leave[/]", classes="tool-line"))
|
|
717
|
+
|
|
718
|
+
def _plan_digest(self) -> "str | None":
|
|
719
|
+
"""Content hash of the plan file (None when plan mode is off / unreadable).
|
|
720
|
+
Compared before/after a turn: a change IS the handoff signal."""
|
|
721
|
+
pf = self.engine.plan_file
|
|
722
|
+
if pf is None:
|
|
723
|
+
return None
|
|
724
|
+
try:
|
|
725
|
+
return hashlib.sha1(pf.read_bytes()).hexdigest()
|
|
726
|
+
except OSError:
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
async def _maybe_plan_gate(self, before: "str | None") -> None:
|
|
730
|
+
"""Show the plan-approval gate (inline) when this plan-mode turn changed
|
|
731
|
+
the plan file. Fires at turn END only, so a mid-turn draft never
|
|
732
|
+
interrupts. approve → mode off + implement turn (submitted once this
|
|
733
|
+
worker finishes); discuss → keep planning, input prefilled; exit → mode
|
|
734
|
+
off, file kept; Esc → keep planning silently."""
|
|
735
|
+
from rockycode.tui.plangate import InlinePlanGate
|
|
736
|
+
|
|
737
|
+
pf = self.engine.plan_file
|
|
738
|
+
if pf is None:
|
|
739
|
+
return
|
|
740
|
+
after = self._plan_digest()
|
|
741
|
+
if after is None or after == before:
|
|
742
|
+
return
|
|
743
|
+
try:
|
|
744
|
+
plan_text = pf.read_text(errors="replace").strip()
|
|
745
|
+
except OSError:
|
|
746
|
+
return
|
|
747
|
+
if not plan_text:
|
|
748
|
+
return
|
|
749
|
+
rel = pf.relative_to(self.engine.workdir)
|
|
750
|
+
fut: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
751
|
+
widget = InlinePlanGate(str(rel), plan_text, fut)
|
|
752
|
+
await self.mount(widget, before=self.query_one("#prompt"))
|
|
753
|
+
try:
|
|
754
|
+
choice = await fut
|
|
755
|
+
finally:
|
|
756
|
+
if widget.is_mounted:
|
|
757
|
+
await widget.remove()
|
|
758
|
+
try:
|
|
759
|
+
self.query_one(ChatInput).focus()
|
|
760
|
+
except Exception: # noqa: BLE001
|
|
761
|
+
pass
|
|
762
|
+
if choice == "approve":
|
|
763
|
+
self.engine.plan_file = None
|
|
764
|
+
self._render_cwd()
|
|
765
|
+
await self._add(Static(
|
|
766
|
+
f"[{VIOLET}]✦ plan approved[/] [dim]— plan mode off, building now[/]", classes="tool-line"))
|
|
767
|
+
self._plan_go = (f"Plan approved — implement the plan in {rel} now, phase by phase. "
|
|
768
|
+
"Stay inside its scope.")
|
|
769
|
+
elif choice == "goal":
|
|
770
|
+
# Hand the approved plan to goal: it executes the SAME plan file in the
|
|
771
|
+
# sandbox (skipping its own planner), then pops back to chat.
|
|
772
|
+
await self._plan_to_goal(pf, rel)
|
|
773
|
+
elif choice == "discuss":
|
|
774
|
+
inp = self.query_one(ChatInput)
|
|
775
|
+
inp.text = "revise the plan: "
|
|
776
|
+
inp.focus()
|
|
777
|
+
elif choice == "exit":
|
|
778
|
+
self.engine.plan_file = None
|
|
779
|
+
self._render_cwd()
|
|
780
|
+
await self._add(Static(
|
|
781
|
+
f"[{VIOLET}]✦ plan mode off[/] [dim]— {rel} is kept for reference; "
|
|
782
|
+
"rocky acts on it only if you ask[/]", classes="tool-line"))
|
|
783
|
+
# "dismiss" (Esc) → keep planning silently
|
|
784
|
+
|
|
785
|
+
async def _plan_to_goal(self, plan_file, rel) -> None:
|
|
786
|
+
"""Hand an approved plan file to goal: it runs the SAME plan in the sandbox
|
|
787
|
+
(skipping its own planner), then pops back to chat. Docker-gated."""
|
|
788
|
+
import shutil
|
|
789
|
+
|
|
790
|
+
self.engine.plan_file = None # leaving plan mode; goal takes over
|
|
791
|
+
self._render_cwd()
|
|
792
|
+
if shutil.which("docker") is None or not await self._docker_ready():
|
|
793
|
+
await self._add(Static(
|
|
794
|
+
f"[{AMBER}]◆ goal needs Docker running[/] [dim]— start it, then run /goal; "
|
|
795
|
+
f"or approve to build {rel} here instead. Your plan {rel} is kept.[/]",
|
|
796
|
+
classes="tool-line"))
|
|
797
|
+
return
|
|
798
|
+
from rockycode.tui.goal_screen import GoalScreen
|
|
799
|
+
|
|
800
|
+
backend = self._make_goal_backend(f"implement the approved plan ({rel})", plan_file=plan_file)
|
|
801
|
+
await self._add(Static(
|
|
802
|
+
f"[bold {VIOLET}]◆ goal[/] [dim]— running your approved plan in the sandbox "
|
|
803
|
+
f"(autonomous, isolated); back here when it's done.[/]", classes="tool-line"))
|
|
804
|
+
summary = await self.push_screen_wait(GoalScreen(backend, f"plan: {rel}"))
|
|
805
|
+
await self._on_goal_return(summary)
|
|
806
|
+
|
|
807
|
+
async def _ask_inline(
|
|
808
|
+
self, tool: str, detail: str, risk: str, warning: str | None,
|
|
809
|
+
session_label: str | None = None,
|
|
810
|
+
) -> str:
|
|
811
|
+
"""Mount an InlineApproval just above the input and await the user's
|
|
812
|
+
choice. Docking it into the chat (rather than a modal that owns the
|
|
813
|
+
screen) keeps the transcript visible and scrollable — pageup / shift+↑↓
|
|
814
|
+
/ the wheel all still work while the prompt waits. Returns one of
|
|
815
|
+
'once' | 'session' | 'deny' | 'cancel'."""
|
|
816
|
+
from rockycode.tui.permission import InlineApproval
|
|
817
|
+
|
|
818
|
+
fut: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
819
|
+
widget = InlineApproval(tool, detail, risk, warning, fut, session_label=session_label)
|
|
820
|
+
await self.mount(widget, before=self.query_one("#prompt"))
|
|
821
|
+
try:
|
|
822
|
+
return await fut
|
|
823
|
+
finally:
|
|
824
|
+
if widget.is_mounted:
|
|
825
|
+
await widget.remove()
|
|
826
|
+
# give focus back to the input so the user can keep typing / scrolling
|
|
827
|
+
try:
|
|
828
|
+
self.query_one(ChatInput).focus()
|
|
829
|
+
except Exception: # noqa: BLE001 — app may be tearing down
|
|
830
|
+
pass
|
|
831
|
+
|
|
832
|
+
def _tool_detail(self, name: str, args: dict) -> str:
|
|
833
|
+
"""A human-readable one-liner of what the call will do, for the modal."""
|
|
834
|
+
if name == "bash":
|
|
835
|
+
return args.get("command", "") or "(no command)"
|
|
836
|
+
if name == "web_fetch":
|
|
837
|
+
return args.get("url", "") or "(no url)"
|
|
838
|
+
if name in ("write_file", "edit_file"):
|
|
839
|
+
return f"{name} → {args.get('path', '?')}"
|
|
840
|
+
if name == "remember":
|
|
841
|
+
return f"remember: {str(args.get('name') or args.get('content', ''))[:140]}"
|
|
842
|
+
try:
|
|
843
|
+
return json.dumps(args)[:400]
|
|
844
|
+
except (TypeError, ValueError):
|
|
845
|
+
return str(args)[:400]
|
|
846
|
+
|
|
847
|
+
def _permission_note(self) -> str:
|
|
848
|
+
return {
|
|
849
|
+
"yolo": "yolo — no prompts (esc still stops a turn)",
|
|
850
|
+
"careful": "careful — every write & command asks first (esc stops a turn)",
|
|
851
|
+
}.get(self._permission_mode, "ask — risky actions ask first (esc stops a turn)")
|
|
852
|
+
|
|
853
|
+
def _perm_chip(self) -> str:
|
|
854
|
+
"""The always-visible status-bar permission indicator (red for yolo)."""
|
|
855
|
+
if self._permission_mode == "yolo":
|
|
856
|
+
return f"[{RED}]🔓 yolo · no prompts[/]"
|
|
857
|
+
if self._permission_mode == "careful":
|
|
858
|
+
return f"[{MUTED}]🔒 careful[/]"
|
|
859
|
+
return f"[{MUTED}]🔒 ask[/]"
|
|
860
|
+
|
|
861
|
+
def _effort_note(self) -> str:
|
|
862
|
+
"""Current effort-dial reading, wire-honest: names what DeepSeek is sent
|
|
863
|
+
when the dial value and the provider tier differ (xhigh → max)."""
|
|
864
|
+
if not self.engine.thinking:
|
|
865
|
+
return (f"[{LAVENDER}]off[/] [dim](thinking disabled — "
|
|
866
|
+
f"{self.engine.reasoning_effort} kept for when it's back on)[/]")
|
|
867
|
+
eff = self.engine.reasoning_effort
|
|
868
|
+
sent = to_deepseek(eff)
|
|
869
|
+
wire = f" [dim](sends {sent} to deepseek)[/]" if sent != eff else ""
|
|
870
|
+
return f"[{LAVENDER}]{eff}[/]{wire}"
|
|
871
|
+
|
|
872
|
+
def _render_cwd(self) -> None:
|
|
873
|
+
plan = f" [{VIOLET}]📋 plan[/]" if self.engine.plan_file is not None else ""
|
|
874
|
+
self.query_one("#cwd", Static).update(
|
|
875
|
+
f"[{MUTED}]📁 {self.engine.workdir.name}[/]{plan} {self._perm_chip()}"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def _set_permission_mode(self, mode: str) -> None:
|
|
879
|
+
"""Flip the approval mode for this session (does NOT persist — use /config
|
|
880
|
+
for that). Tightening clears the session allowlist so tools previously
|
|
881
|
+
OK'd via 'allow all' will ask again; loosening leaves it untouched."""
|
|
882
|
+
rank = {"yolo": 0, "ask": 1, "careful": 2}
|
|
883
|
+
if rank[mode] > rank[self._permission_mode]:
|
|
884
|
+
self._auto_approve.clear()
|
|
885
|
+
self._auto_approve_bins.clear() # tightening revokes bash binary grants too
|
|
886
|
+
self._permission_mode = mode
|
|
887
|
+
self._render_cwd()
|
|
888
|
+
|
|
889
|
+
def _in_sandbox(self) -> bool:
|
|
890
|
+
return self.sandbox is not None and self.sandbox.is_running
|
|
891
|
+
|
|
892
|
+
async def _maybe_nudge_yolo_host(self) -> None:
|
|
893
|
+
"""When yolo is on but tools run on the real machine (no sandbox), say
|
|
894
|
+
plainly what that means and point at /sandbox to contain it. Only fires in
|
|
895
|
+
the exposed state, so it's quiet once a sandbox is on or the mode tightens."""
|
|
896
|
+
if self._permission_mode != "yolo" or self._in_sandbox():
|
|
897
|
+
return
|
|
898
|
+
await self._add(Static(
|
|
899
|
+
f"[bold {AMBER}]⚠ yolo runs on your machine[/] [dim]— no prompts, so installs, network "
|
|
900
|
+
f"and git push happen for real. (rocky still refuses the truly destructive.)[/]\n"
|
|
901
|
+
f" [dim]want it contained? → [/][{LAVENDER}]/sandbox on[/][dim] runs tools in a throwaway "
|
|
902
|
+
f"box: offline, your files untouched.[/]",
|
|
903
|
+
classes="tool-line"))
|
|
904
|
+
|
|
905
|
+
# Wheel anywhere scrolls the transcript. Textual scrolls a widget natively
|
|
906
|
+
# when the pointer is over it; these app-level handlers catch wheel events
|
|
907
|
+
# that bubble up (e.g. over the input or a gap), so the transcript scrolls
|
|
908
|
+
# regardless of where the pointer is — works the same in iTerm2, Ghostty,
|
|
909
|
+
# and the VS Code terminal (all send mouse events; Textual enables them).
|
|
910
|
+
def on_mouse_scroll_up(self, event) -> None:
|
|
911
|
+
self._transcript().scroll_relative(y=-3, animate=False)
|
|
912
|
+
|
|
913
|
+
def on_mouse_scroll_down(self, event) -> None:
|
|
914
|
+
self._transcript().scroll_relative(y=3, animate=False)
|
|
915
|
+
|
|
916
|
+
def on_mouse_up(self, event) -> None:
|
|
917
|
+
"""Drag over transcript text, release → it's in the clipboard (OSC 52).
|
|
918
|
+
The release IS the copy — no second action. Selection is cleared after
|
|
919
|
+
copying so a later stray click can't silently re-clobber the clipboard.
|
|
920
|
+
Plain clicks never have a selection, so links stay links; the input
|
|
921
|
+
box's own TextArea selection is a separate mechanism, untouched."""
|
|
922
|
+
text = self.screen.get_selected_text()
|
|
923
|
+
if not text:
|
|
924
|
+
return
|
|
925
|
+
self.copy_to_clipboard(text) # OSC 52 — remote/ssh terminals
|
|
926
|
+
self._pbcopy(text) # local macOS clipboard — works even where
|
|
927
|
+
# the terminal blocks OSC 52 (iTerm2 default)
|
|
928
|
+
self.clear_selection()
|
|
929
|
+
n = len(text.splitlines())
|
|
930
|
+
what = f"{n} lines" if n > 1 else (f"{len(text)} chars" if len(text) > 24 else repr(text))
|
|
931
|
+
self.notify(f"⧉ copied {what}", timeout=2)
|
|
932
|
+
|
|
933
|
+
def _pbcopy(self, text: str) -> bool:
|
|
934
|
+
"""Write the system clipboard directly (macOS pbcopy). iTerm2 ships
|
|
935
|
+
with OSC-52 clipboard access OFF, so the escape-sequence path silently
|
|
936
|
+
drops the copy there — the local pipe always lands."""
|
|
937
|
+
import shutil
|
|
938
|
+
import subprocess
|
|
939
|
+
if shutil.which("pbcopy") is None:
|
|
940
|
+
return False
|
|
941
|
+
try:
|
|
942
|
+
subprocess.run(["pbcopy"], input=text.encode(), timeout=2, check=True)
|
|
943
|
+
return True
|
|
944
|
+
except Exception: # noqa: BLE001 — OSC 52 already attempted
|
|
945
|
+
return False
|
|
946
|
+
|
|
947
|
+
def compose(self) -> ComposeResult:
|
|
948
|
+
with Horizontal(id="topbar"):
|
|
949
|
+
yield Static(
|
|
950
|
+
f"[bold {VIOLET}]ROCKYCODE[/] [{MUTED}]· {self.engine.model}[/]\n"
|
|
951
|
+
f"[italic {MUTED}]{ROCKY_TAGLINE}[/]",
|
|
952
|
+
id="title-block",
|
|
953
|
+
)
|
|
954
|
+
yield Static("", id="status")
|
|
955
|
+
# #workspace: the doc dock mounts beside the transcript in here, so a
|
|
956
|
+
# docked paper narrows the chat instead of covering it.
|
|
957
|
+
with Horizontal(id="workspace"):
|
|
958
|
+
transcript = VerticalScroll(id="transcript")
|
|
959
|
+
# Non-focusable on purpose: clicking the transcript must never steal
|
|
960
|
+
# focus from the input. Otherwise focus gets stuck on the scroll panel
|
|
961
|
+
# and you can't type the next message — and in old iTerm2 click-to-
|
|
962
|
+
# refocus the input is unreliable, so there's no way back. Scrolling
|
|
963
|
+
# still works via the wheel (app-level handlers) and the pageup /
|
|
964
|
+
# shift-arrow bindings, all of which work while the input stays focused.
|
|
965
|
+
transcript.can_focus = False
|
|
966
|
+
yield transcript
|
|
967
|
+
prompt = ChatInput(id="prompt", history=self._prompt_history)
|
|
968
|
+
prompt.border_title = "ask rocky"
|
|
969
|
+
prompt.border_subtitle = "enter ↵ send · ctrl+j newline · shift+↑↓ / pgup·pgdn scroll history"
|
|
970
|
+
yield prompt
|
|
971
|
+
with Horizontal(id="statusbar"):
|
|
972
|
+
yield Static(f"[{MUTED}]📁 {self.engine.workdir.name}[/]", id="cwd")
|
|
973
|
+
yield Static("", id="modechip")
|
|
974
|
+
yield Static("", id="total")
|
|
975
|
+
yield Static(f"[{MUTED}]drag text = copy · /help · /research · /learn · /model · /effort · /sandbox · /artifact · /permission · /config · /mcp · /skills · /memory · /proposals · !cmd · /clear · /exit[/]", id="hints")
|
|
976
|
+
|
|
977
|
+
async def on_mount(self) -> None:
|
|
978
|
+
self.register_theme(ROCKY_THEME)
|
|
979
|
+
self.register_theme(ROCKY_THEME_LIGHT)
|
|
980
|
+
self.theme = "rocky-light" if self._theme_pref == "light" else "rocky"
|
|
981
|
+
# Wire the interactive approver — always, even in yolo, so /permission can
|
|
982
|
+
# flip modes mid-session; decide() short-circuits yolo to "allow" (no modal)
|
|
983
|
+
# at negligible cost. Headless/bench never set an approver, so they keep the
|
|
984
|
+
# engine's zero-overhead always-allow default.
|
|
985
|
+
self.engine.approver = self._approve_tool
|
|
986
|
+
# Streaming buffers; the flush timer is the only writer to widgets.
|
|
987
|
+
self._think_buf = ""
|
|
988
|
+
self._reply_buf = ""
|
|
989
|
+
self._think_flushed = 0
|
|
990
|
+
self._reply_flushed = 0
|
|
991
|
+
self._think_widget: Static | None = None
|
|
992
|
+
self._think_box: Collapsible | None = None
|
|
993
|
+
self._reply_widget: Markdown | None = None
|
|
994
|
+
self._local_registry = dict(self.engine.registry)
|
|
995
|
+
self._state = AgentState.IDLE
|
|
996
|
+
self._note_i = 0
|
|
997
|
+
self.set_interval(1 / FLUSH_HZ, self._flush_buffers)
|
|
998
|
+
self.set_interval(0.4, self._animate_status)
|
|
999
|
+
self._transcript().border_title = "♪ chat"
|
|
1000
|
+
# Persistent permission reminder in the bottom bar — always visible, so a
|
|
1001
|
+
# no-prompt (yolo) session can't be forgotten mid-research.
|
|
1002
|
+
self._render_cwd()
|
|
1003
|
+
self._render_mode_chip() # a launch-time config mode lights the chip
|
|
1004
|
+
if self._permission_weakened:
|
|
1005
|
+
await self._add(Static(
|
|
1006
|
+
f"[bold {RED}]⚠ this folder's .rockycode/config.toml lowered permission to "
|
|
1007
|
+
f"'{self._permission_mode}' — rocky runs with fewer prompts here than your "
|
|
1008
|
+
f"default. (see the chip in the status bar)[/]", classes="error-msg"))
|
|
1009
|
+
# Inside the TUI, not the pre-launch console — the alt screen covers
|
|
1010
|
+
# anything printed before the app starts (found in cici's live test:
|
|
1011
|
+
# the warning was only visible after exit, i.e. exactly too late).
|
|
1012
|
+
for w in project_env_warnings(self.engine.workdir):
|
|
1013
|
+
await self._add(Static(f"[{AMBER}]· {escape(w)}[/]", classes="tool-line"))
|
|
1014
|
+
if os.environ.get("TERM_PROGRAM") == "iTerm.app":
|
|
1015
|
+
await self._add(Static(
|
|
1016
|
+
"[dim]· iterm2: clicks, links and drag-copy need Settings → Profiles → "
|
|
1017
|
+
"Terminal → 'Enable mouse reporting' (⌥-drag keeps native selection)[/]",
|
|
1018
|
+
classes="tool-line"))
|
|
1019
|
+
if self._resume_session is not None:
|
|
1020
|
+
await self._load_session(self._resume_session) # direct: --resume <id>
|
|
1021
|
+
self.query_one(ChatInput).focus()
|
|
1022
|
+
elif self._resume_requested:
|
|
1023
|
+
self._resume_flow() # picker → replay (or fall through to fresh)
|
|
1024
|
+
else:
|
|
1025
|
+
await self._greet()
|
|
1026
|
+
# If we're starting in yolo on the host, say what that means up front.
|
|
1027
|
+
await self._maybe_nudge_yolo_host()
|
|
1028
|
+
# Catch-up dream over past sessions (background; silent when idle-less).
|
|
1029
|
+
self._dream_catchup()
|
|
1030
|
+
# Dream proposals waiting from earlier passes — one muted, clickable-by-
|
|
1031
|
+
# command line; the inbox never nags beyond this.
|
|
1032
|
+
try:
|
|
1033
|
+
from rockycode.dream.proposals import ProposalStore
|
|
1034
|
+
from rockycode.session import get_project
|
|
1035
|
+
n = len(ProposalStore().list(project_id=get_project(self.engine.workdir).id))
|
|
1036
|
+
if n:
|
|
1037
|
+
await self._add(Static(
|
|
1038
|
+
f"[{MUTED}]🌙 {n} dream proposal(s) waiting — /proposals to review[/]",
|
|
1039
|
+
classes="tool-line"))
|
|
1040
|
+
except Exception: # noqa: BLE001 — the inbox must never break startup
|
|
1041
|
+
pass
|
|
1042
|
+
# Due routines: leased ones run themselves (that's what the lease
|
|
1043
|
+
# bought); the rest get one muted line. Catch-up, never a daemon.
|
|
1044
|
+
try:
|
|
1045
|
+
from rockycode.routines import RoutineStore
|
|
1046
|
+
from rockycode.session import get_project
|
|
1047
|
+
rstore = RoutineStore()
|
|
1048
|
+
due = rstore.due(project_id=get_project(self.engine.workdir).id)
|
|
1049
|
+
leased = [r for r in due if rstore.lease_active(r)]
|
|
1050
|
+
manual = [r for r in due if not rstore.lease_active(r)]
|
|
1051
|
+
if manual:
|
|
1052
|
+
names = ", ".join(r.name for r in manual)
|
|
1053
|
+
await self._add(Static(
|
|
1054
|
+
f"[{MUTED}]⏰ routine(s) due: {names} — /routines to run[/]",
|
|
1055
|
+
classes="tool-line"))
|
|
1056
|
+
for r in leased:
|
|
1057
|
+
self._run_routine_bg(r)
|
|
1058
|
+
except Exception: # noqa: BLE001 — routines must never break startup
|
|
1059
|
+
pass
|
|
1060
|
+
|
|
1061
|
+
async def _greet(self) -> None:
|
|
1062
|
+
await self._add(Static(WELCOME, classes="rocky-label"))
|
|
1063
|
+
await self._add(Static(f"[dim]· permission: {self._permission_note()}[/]", classes="tool-line"))
|
|
1064
|
+
for fname in getattr(self.engine, "project_notes", []) or []:
|
|
1065
|
+
await self._add(Static(f"[dim]· loaded {fname} (project instructions)[/]", classes="tool-line"))
|
|
1066
|
+
n_skills = len(getattr(self.engine, "skills", []) or [])
|
|
1067
|
+
if n_skills:
|
|
1068
|
+
await self._add(
|
|
1069
|
+
Static(f"[dim]· {n_skills} skill(s) available — /skills to list[/]", classes="tool-line")
|
|
1070
|
+
)
|
|
1071
|
+
if getattr(self.engine, "web_enabled", False):
|
|
1072
|
+
order = " → ".join(getattr(self.engine, "web_order", ()) or ())
|
|
1073
|
+
await self._add(Static(f"[dim]· web tools on (search: {order})[/]", classes="tool-line"))
|
|
1074
|
+
if getattr(self.engine, "mcp_manager", None) and self.engine.mcp_manager.configs:
|
|
1075
|
+
names = ", ".join(self.engine.mcp_manager.configs)
|
|
1076
|
+
await self._add(Static(f"[dim]· connecting mcp servers: {names}…[/]", classes="tool-line"))
|
|
1077
|
+
self._connect_mcp()
|
|
1078
|
+
if getattr(self.engine, "lsp_manager", None) is not None:
|
|
1079
|
+
await self._add(Static(f"[dim]· connecting lsp…[/]", classes="tool-line"))
|
|
1080
|
+
self._connect_lsp()
|
|
1081
|
+
# Artifacts are lazy: create_artifact calls _artifact_target, which asks
|
|
1082
|
+
# once (unless --live) and starts the server on demand. No idle server.
|
|
1083
|
+
self.engine.artifact_target = self._artifact_target
|
|
1084
|
+
|
|
1085
|
+
@work
|
|
1086
|
+
async def _resume_flow(self) -> None:
|
|
1087
|
+
from rockycode.tui.resume import ResumePicker
|
|
1088
|
+
|
|
1089
|
+
info = await self.push_screen_wait(ResumePicker(self.engine.workdir))
|
|
1090
|
+
if info is None:
|
|
1091
|
+
await self._greet() # cancelled → fresh session
|
|
1092
|
+
else:
|
|
1093
|
+
await self._load_session(info)
|
|
1094
|
+
self.query_one(ChatInput).focus()
|
|
1095
|
+
|
|
1096
|
+
async def _load_session(self, info) -> None:
|
|
1097
|
+
"""Carry a stored session into this one — shared by the picker and the
|
|
1098
|
+
direct `--resume <id>` path."""
|
|
1099
|
+
from rockycode.session import load_history, public_id
|
|
1100
|
+
|
|
1101
|
+
carried = self.engine.resume(load_history(info.path), from_session=info.session_id)
|
|
1102
|
+
await self._replay(carried)
|
|
1103
|
+
await self._add(Static(
|
|
1104
|
+
f"[dim]· resumed {public_id(info.session_id)} · {escape(info.display_title)} · "
|
|
1105
|
+
f"{info.n_messages} msgs[/]", classes="tool-line"))
|
|
1106
|
+
if self.engine.resumed_mode:
|
|
1107
|
+
from rockycode.engine.modes import discover
|
|
1108
|
+
fam = next((f for f, ms in discover(self.engine.workdir).items()
|
|
1109
|
+
if self.engine.resumed_mode in ms), "research")
|
|
1110
|
+
await self._add(Static(
|
|
1111
|
+
f"[dim]· mode {escape(self.engine.resumed_mode)} was active in that session — "
|
|
1112
|
+
f"/{fam} {escape(self.engine.resumed_mode)} to re-enter[/]",
|
|
1113
|
+
classes="tool-line"))
|
|
1114
|
+
await self._add(Static(
|
|
1115
|
+
f"[dim]· permission: {self._permission_note()}[/]", classes="tool-line"))
|
|
1116
|
+
|
|
1117
|
+
def _render_mode_chip(self) -> None:
|
|
1118
|
+
name = self.engine.mode_name
|
|
1119
|
+
self.query_one("#modechip", Static).update(
|
|
1120
|
+
f"[{LAVENDER}]◉ {name}[/]" if name else "")
|
|
1121
|
+
|
|
1122
|
+
async def _handle_mode_cmd(self, family: str, text: str) -> None:
|
|
1123
|
+
"""/research and /learn — bare opens the picker (or applies the single
|
|
1124
|
+
typeless mode), `<name>` is the direct path, `off` leaves, `always`
|
|
1125
|
+
makes it this folder's launch default, `never` clears that default."""
|
|
1126
|
+
from rockycode.engine.modes import discover, resolve
|
|
1127
|
+
|
|
1128
|
+
args = text.split()[1:]
|
|
1129
|
+
arg = args[0].lower() if args else ""
|
|
1130
|
+
if arg == "off":
|
|
1131
|
+
if self.engine.mode_name is None:
|
|
1132
|
+
await self._add(Static(f"[{MUTED}]· no mode active[/]", classes="tool-line"))
|
|
1133
|
+
else:
|
|
1134
|
+
self.engine.clear_mode()
|
|
1135
|
+
self._render_mode_chip()
|
|
1136
|
+
await self._add(Static(
|
|
1137
|
+
f"[{MUTED}]· mode off — plain rocky again[/]", classes="tool-line"))
|
|
1138
|
+
return
|
|
1139
|
+
if arg == "always":
|
|
1140
|
+
name = args[1].lower() if len(args) > 1 else (self.engine.mode_name or "")
|
|
1141
|
+
if not name:
|
|
1142
|
+
await self._add(Static(
|
|
1143
|
+
f"[{MUTED}]· nothing to persist — enter a mode first (/{family}), "
|
|
1144
|
+
f"then /{family} always[/]", classes="tool-line"))
|
|
1145
|
+
return
|
|
1146
|
+
# Built-ins only: a folder default is applied silently at launch, so a
|
|
1147
|
+
# cloned repo's local mode file must never ride in through it.
|
|
1148
|
+
mode, err = resolve(family, name, workdir=self.engine.workdir, builtin_only=True)
|
|
1149
|
+
if mode is None:
|
|
1150
|
+
await self._add(Static(
|
|
1151
|
+
f"[{MUTED}]· {escape(err)} (only built-in modes can be a folder default)[/]",
|
|
1152
|
+
classes="tool-line"))
|
|
1153
|
+
return
|
|
1154
|
+
from rockycode.config import set_project_value
|
|
1155
|
+
_v, werr = set_project_value(self.engine.workdir, "mode", mode.name)
|
|
1156
|
+
if werr:
|
|
1157
|
+
await self._add(Static(f"[{AMBER}]? {escape(werr)}[/]", classes="tool-line"))
|
|
1158
|
+
return
|
|
1159
|
+
if self.engine.mode_name != mode.name:
|
|
1160
|
+
await self._apply_mode(family, mode)
|
|
1161
|
+
await self._add(Static(
|
|
1162
|
+
f"[{MUTED}]· saved — rocky starts in {mode.name} mode in this folder "
|
|
1163
|
+
f"(undo: /{family} never)[/]", classes="tool-line"))
|
|
1164
|
+
return
|
|
1165
|
+
if arg == "never":
|
|
1166
|
+
from rockycode.config import set_project_value
|
|
1167
|
+
_v, werr = set_project_value(self.engine.workdir, "mode", "")
|
|
1168
|
+
await self._add(Static(
|
|
1169
|
+
f"[{AMBER}]? {escape(werr)}[/]" if werr else
|
|
1170
|
+
f"[{MUTED}]· folder default cleared — modes are per-session again[/]",
|
|
1171
|
+
classes="tool-line"))
|
|
1172
|
+
return
|
|
1173
|
+
if arg:
|
|
1174
|
+
mode, err = resolve(family, arg, workdir=self.engine.workdir)
|
|
1175
|
+
if mode is None:
|
|
1176
|
+
await self._add(Static(f"[{MUTED}]· {escape(err)}[/]", classes="tool-line"))
|
|
1177
|
+
return
|
|
1178
|
+
await self._apply_mode(family, mode)
|
|
1179
|
+
return
|
|
1180
|
+
# Bare command: a single-mode family (typeless /learn) applies directly;
|
|
1181
|
+
# otherwise — or when a mode is active, so "off" stays reachable — picker.
|
|
1182
|
+
modes = list(discover(self.engine.workdir).get(family, {}).values())
|
|
1183
|
+
if not modes:
|
|
1184
|
+
await self._add(Static(f"[{MUTED}]· no {family} modes installed[/]", classes="tool-line"))
|
|
1185
|
+
elif len(modes) == 1 and self.engine.mode_name is None:
|
|
1186
|
+
await self._apply_mode(family, modes[0])
|
|
1187
|
+
else:
|
|
1188
|
+
self._mode_picker_flow(family, modes)
|
|
1189
|
+
|
|
1190
|
+
@work
|
|
1191
|
+
async def _mode_picker_flow(self, family: str, modes: list) -> None:
|
|
1192
|
+
from rockycode.tui.modepicker import ModePicker
|
|
1193
|
+
|
|
1194
|
+
result = await self.push_screen_wait(
|
|
1195
|
+
ModePicker(family, modes, active=self.engine.mode_name))
|
|
1196
|
+
if result == "off":
|
|
1197
|
+
self.engine.clear_mode()
|
|
1198
|
+
self._render_mode_chip()
|
|
1199
|
+
await self._add(Static(
|
|
1200
|
+
f"[{MUTED}]· mode off — plain rocky again[/]", classes="tool-line"))
|
|
1201
|
+
elif result is not None:
|
|
1202
|
+
await self._apply_mode(family, result)
|
|
1203
|
+
self.query_one(ChatInput).focus()
|
|
1204
|
+
|
|
1205
|
+
async def _apply_mode(self, family: str, mode) -> None:
|
|
1206
|
+
"""Swap the contract in + the entry card that teaches the direct path
|
|
1207
|
+
(same teach-at-use pattern as the resume exit card)."""
|
|
1208
|
+
self.engine.set_mode(mode.name, mode.body)
|
|
1209
|
+
self._render_mode_chip()
|
|
1210
|
+
await self._add(Static(
|
|
1211
|
+
f"[bold {LAVENDER}]◉ {mode.name}[/] [{MUTED}]— {escape(mode.description)}[/]",
|
|
1212
|
+
classes="tool-line"))
|
|
1213
|
+
direct = f"/{family}" if mode.name == family else f"/{family} {mode.name}"
|
|
1214
|
+
await self._add(Static(
|
|
1215
|
+
f"[{MUTED}] leave with /{family} off · folder default: /{family} always · "
|
|
1216
|
+
f"next time, jump straight in: {direct}[/]", classes="tool-line"))
|
|
1217
|
+
|
|
1218
|
+
def _maybe_title(self) -> None:
|
|
1219
|
+
"""Name the session once its first exchange exists: a background flash
|
|
1220
|
+
call appends a title record to the trajectory (the exit card and resume
|
|
1221
|
+
picker read it). Best-effort — offline/no-key just leaves the
|
|
1222
|
+
first-message fallback."""
|
|
1223
|
+
if self._titled:
|
|
1224
|
+
return
|
|
1225
|
+
first_user = first_reply = ""
|
|
1226
|
+
for m in self.engine.history:
|
|
1227
|
+
role, c = m.get("role"), m.get("content")
|
|
1228
|
+
if not isinstance(c, str) or not c.strip():
|
|
1229
|
+
continue
|
|
1230
|
+
if role == "user" and not first_user and not c.startswith("[harness]"):
|
|
1231
|
+
first_user = c
|
|
1232
|
+
elif role == "assistant" and first_user and not first_reply:
|
|
1233
|
+
first_reply = c
|
|
1234
|
+
break
|
|
1235
|
+
if not first_user or not first_reply:
|
|
1236
|
+
return # nothing titleable yet — try again after the next turn
|
|
1237
|
+
self._titled = True
|
|
1238
|
+
self._title_worker(first_user, first_reply)
|
|
1239
|
+
|
|
1240
|
+
@work(group="title", exclusive=False, exit_on_error=False)
|
|
1241
|
+
async def _title_worker(self, first_user: str, first_reply: str) -> None:
|
|
1242
|
+
from rockycode.engine.titler import generate_title
|
|
1243
|
+
|
|
1244
|
+
t = await generate_title(self.engine.client, first_user, first_reply)
|
|
1245
|
+
if t:
|
|
1246
|
+
self.engine.trajectory.title(t)
|
|
1247
|
+
|
|
1248
|
+
async def _replay(self, messages: list[dict]) -> None:
|
|
1249
|
+
"""Render a carried conversation so the prior session is visible."""
|
|
1250
|
+
for m in messages:
|
|
1251
|
+
role, content = m.get("role"), m.get("content")
|
|
1252
|
+
if role == "user":
|
|
1253
|
+
if isinstance(content, str) and not content.startswith("[harness]"):
|
|
1254
|
+
await self._add(Static(f"[bold {VIOLET}]you ▸[/] {escape(content)}", classes="user-msg"))
|
|
1255
|
+
elif role == "assistant":
|
|
1256
|
+
if m.get("tool_calls"):
|
|
1257
|
+
for tc in m["tool_calls"]:
|
|
1258
|
+
await self._add(Static(f" [{PURPLE}]⚒[/] {tc['function']['name']}", classes="tool-line"))
|
|
1259
|
+
if isinstance(content, str) and content.strip():
|
|
1260
|
+
await self._add(Static(f"[bold {PURPLE}]♪♫ rocky ▸[/]", classes="rocky-label"))
|
|
1261
|
+
md = Markdown(
|
|
1262
|
+
enhance_markdown(content, width=self._reply_width(),
|
|
1263
|
+
only_existing=True),
|
|
1264
|
+
parser_factory=rocky_markdown_parser,
|
|
1265
|
+
open_links=False,
|
|
1266
|
+
)
|
|
1267
|
+
await self._add(md)
|
|
1268
|
+
|
|
1269
|
+
@work(group="mcp", exclusive=False)
|
|
1270
|
+
async def _connect_mcp(self) -> None:
|
|
1271
|
+
mgr = self.engine.mcp_manager
|
|
1272
|
+
await mgr.start()
|
|
1273
|
+
self.engine.registry.update(mgr.tools())
|
|
1274
|
+
ok = len(mgr.actors)
|
|
1275
|
+
n_tools = len(mgr.tools())
|
|
1276
|
+
line = f"[dim]· mcp ready: {ok} server(s), {n_tools} tools[/]"
|
|
1277
|
+
if mgr.failures:
|
|
1278
|
+
line += f" [{MUTED}]({len(mgr.failures)} failed — /mcp for details)[/]"
|
|
1279
|
+
await self._add(Static(line, classes="tool-line"))
|
|
1280
|
+
|
|
1281
|
+
async def _artifact_target(self):
|
|
1282
|
+
"""Decide live vs static for artifacts, lazy-starting the server.
|
|
1283
|
+
|
|
1284
|
+
Called by create_artifact. Asks once (unless --live already decided),
|
|
1285
|
+
remembers the answer for the session, and returns the running
|
|
1286
|
+
ArtifactServer (live) or None (static file://).
|
|
1287
|
+
"""
|
|
1288
|
+
if self._artifact_live is False:
|
|
1289
|
+
return None
|
|
1290
|
+
if self._artifact_live is None:
|
|
1291
|
+
choice = await self.push_screen_wait(ArtifactLiveModal())
|
|
1292
|
+
self._artifact_live = choice == "live"
|
|
1293
|
+
if not self._artifact_live:
|
|
1294
|
+
await self._add(Static(
|
|
1295
|
+
"[dim]· artifacts: static (file://) — /artifact live on to enable live updates[/]",
|
|
1296
|
+
classes="tool-line"))
|
|
1297
|
+
return None
|
|
1298
|
+
if self._artifact_server is None:
|
|
1299
|
+
from rockycode.engine.artifact import ArtifactServer
|
|
1300
|
+
srv = ArtifactServer(self.engine.workdir)
|
|
1301
|
+
await srv.start()
|
|
1302
|
+
self._artifact_server = srv
|
|
1303
|
+
self.engine.artifact_server = srv
|
|
1304
|
+
await self._add(Static(
|
|
1305
|
+
f"[dim]· artifacts live at {srv.base_url} — tab auto-refreshes on rebuild[/]",
|
|
1306
|
+
classes="tool-line"))
|
|
1307
|
+
return self._artifact_server
|
|
1308
|
+
|
|
1309
|
+
@work(group="lsp", exclusive=False)
|
|
1310
|
+
async def _connect_lsp(self) -> None:
|
|
1311
|
+
mgr = self.engine.lsp_manager
|
|
1312
|
+
try:
|
|
1313
|
+
await mgr.start() # default session
|
|
1314
|
+
await asyncio.wait_for(mgr.ready, timeout=30)
|
|
1315
|
+
await mgr.start_cleanup_loop()
|
|
1316
|
+
# Register the 4 active LSP tools now that the server is ready.
|
|
1317
|
+
from rockycode.engine.lsp import build_lsp_tools
|
|
1318
|
+
lsp_tools = build_lsp_tools(mgr)
|
|
1319
|
+
self.engine.registry.update(lsp_tools)
|
|
1320
|
+
n_tools = len(lsp_tools)
|
|
1321
|
+
await self._add(
|
|
1322
|
+
Static(f"[dim]· lsp ready ({mgr.command.split('/')[-1]}) — "
|
|
1323
|
+
f"diagnostics + {n_tools} tools[/]",
|
|
1324
|
+
classes="tool-line")
|
|
1325
|
+
)
|
|
1326
|
+
except Exception as exc:
|
|
1327
|
+
await self._add(
|
|
1328
|
+
Static(f"[dim]· lsp unavailable: {exc} — diagnostics disabled[/]",
|
|
1329
|
+
classes="tool-line")
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
@work(group="dream", exclusive=True, exit_on_error=False)
|
|
1333
|
+
async def _dream_catchup(self) -> None:
|
|
1334
|
+
"""Phase 1 slice: the catch-up dream. At launch, quietly consolidate
|
|
1335
|
+
this project's UN-DREAMED past sessions on local Ollama (free, private
|
|
1336
|
+
— the only reader allowed to see exit-sheet feedback). Silent unless
|
|
1337
|
+
it actually dreamed: no memory store / no Ollama / lock held / nothing
|
|
1338
|
+
pending → no noise. The current session is excluded; it gets its turn
|
|
1339
|
+
on a future launch, after its outcome record exists."""
|
|
1340
|
+
import httpx
|
|
1341
|
+
|
|
1342
|
+
from rockycode.dream.core import OLLAMA_URL, DreamRunner
|
|
1343
|
+
|
|
1344
|
+
if self._dream_mode != "auto" or getattr(self.engine, "memory_store", None) is None:
|
|
1345
|
+
return
|
|
1346
|
+
try:
|
|
1347
|
+
async with httpx.AsyncClient(timeout=2.0) as http:
|
|
1348
|
+
(await http.get(f"{OLLAMA_URL}/api/tags")).raise_for_status()
|
|
1349
|
+
except Exception: # noqa: BLE001 — no ollama, no dream, no nagging
|
|
1350
|
+
self._ollama_ok = False
|
|
1351
|
+
return
|
|
1352
|
+
self._ollama_ok = True
|
|
1353
|
+
index = None
|
|
1354
|
+
try:
|
|
1355
|
+
from rockycode.memory.index import MemoryIndex
|
|
1356
|
+
index = MemoryIndex(self.engine.memory_store)
|
|
1357
|
+
index.conn()
|
|
1358
|
+
except Exception: # noqa: BLE001 — semantic index is optional
|
|
1359
|
+
index = None
|
|
1360
|
+
# The judge reuses the LIVE engine's client + model — no new auth, and
|
|
1361
|
+
# the sheet never reaches it (condense's default hides feedback).
|
|
1362
|
+
from rockycode.dream.judge import TranscriptJudge
|
|
1363
|
+
runner = DreamRunner(
|
|
1364
|
+
self.engine.workdir,
|
|
1365
|
+
exclude={self.engine.trajectory.session_id},
|
|
1366
|
+
judge=TranscriptJudge(self.engine.client, self.engine.model),
|
|
1367
|
+
)
|
|
1368
|
+
try:
|
|
1369
|
+
report = await runner.run(limit=5, index=index)
|
|
1370
|
+
except RuntimeError:
|
|
1371
|
+
return # another dream holds the lock — it's being handled
|
|
1372
|
+
except Exception: # noqa: BLE001 — a failed dream must never disturb chat
|
|
1373
|
+
return
|
|
1374
|
+
if report.sessions_digested:
|
|
1375
|
+
drafted = " · drafted a proposal, /proposals" if report.proposals_drafted else ""
|
|
1376
|
+
await self._add(Static(
|
|
1377
|
+
f"[{MUTED}]🌙 rocky dreamed over {report.sessions_digested} past session(s) — "
|
|
1378
|
+
f"+{report.facts_added} facts, {report.facts_updated} updated · "
|
|
1379
|
+
f"/memory to browse{drafted}[/]",
|
|
1380
|
+
classes="tool-line"))
|
|
1381
|
+
|
|
1382
|
+
@work(group="proposals", exclusive=True, exit_on_error=False)
|
|
1383
|
+
async def _review_proposals(self) -> None:
|
|
1384
|
+
"""Walk this project's pending dream proposals, one inline card each.
|
|
1385
|
+
Approve installs the skill globally; archive keeps the file; later
|
|
1386
|
+
stops the walk and leaves the rest pending. The card is the trust
|
|
1387
|
+
boundary — dream drafts, only a human installs."""
|
|
1388
|
+
from rockycode.dream.proposals import ProposalStore
|
|
1389
|
+
from rockycode.session import get_project
|
|
1390
|
+
from rockycode.tui.proposalcard import ProposalCard
|
|
1391
|
+
|
|
1392
|
+
store = ProposalStore()
|
|
1393
|
+
pending = store.list(project_id=get_project(self.engine.workdir).id)
|
|
1394
|
+
if not pending:
|
|
1395
|
+
await self._add(Static(
|
|
1396
|
+
f"[{MUTED}]no pending proposals — rocky drafts them while dreaming, "
|
|
1397
|
+
f"when a pattern keeps coming back[/]", classes="tool-line"))
|
|
1398
|
+
return
|
|
1399
|
+
for p in pending:
|
|
1400
|
+
fut: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
1401
|
+
card = ProposalCard(p, fut)
|
|
1402
|
+
await self.mount(card, before=self.query_one("#prompt"))
|
|
1403
|
+
try:
|
|
1404
|
+
choice = await fut
|
|
1405
|
+
finally:
|
|
1406
|
+
if card.is_mounted:
|
|
1407
|
+
await card.remove()
|
|
1408
|
+
if choice == "install":
|
|
1409
|
+
installed = store.approve(p)
|
|
1410
|
+
await self._add(Static(
|
|
1411
|
+
f"[{VIOLET}]✦ skill '{p.name}' installed[/] "
|
|
1412
|
+
f"[dim]→ {installed.parent} · loads next session (/skills)[/]",
|
|
1413
|
+
classes="tool-line"))
|
|
1414
|
+
elif choice == "archive":
|
|
1415
|
+
store.archive(p)
|
|
1416
|
+
await self._add(Static(
|
|
1417
|
+
f"[{MUTED}]· archived '{p.name}' — kept under proposals/archived[/]",
|
|
1418
|
+
classes="tool-line"))
|
|
1419
|
+
else: # later — stop walking, leave the rest pending
|
|
1420
|
+
await self._add(Static(
|
|
1421
|
+
f"[{MUTED}]· later — /proposals when you're ready[/]", classes="tool-line"))
|
|
1422
|
+
break
|
|
1423
|
+
try:
|
|
1424
|
+
self.query_one(ChatInput).focus()
|
|
1425
|
+
except Exception: # noqa: BLE001 — focus is best-effort
|
|
1426
|
+
pass
|
|
1427
|
+
|
|
1428
|
+
@work(group="routines", exclusive=False, exit_on_error=False)
|
|
1429
|
+
async def _run_routine_bg(self, r) -> None:
|
|
1430
|
+
"""Run one routine detached (sandboxed exec) and report a single line.
|
|
1431
|
+
Docker down / any failure = a loud line, never a broken chat."""
|
|
1432
|
+
from rockycode.routines import RoutineStore, run_routine
|
|
1433
|
+
|
|
1434
|
+
store = RoutineStore()
|
|
1435
|
+
await self._add(Static(
|
|
1436
|
+
f"[{MUTED}]⏰ routine {r.name} running (sandboxed)…[/]", classes="tool-line"))
|
|
1437
|
+
try:
|
|
1438
|
+
res = await run_routine(store, r, model=self.engine.model,
|
|
1439
|
+
client=self.engine.client)
|
|
1440
|
+
except Exception as e: # noqa: BLE001
|
|
1441
|
+
await self._add(Static(
|
|
1442
|
+
f"[{AMBER}]⏰ routine {r.name} failed: {e}[/]", classes="tool-line"))
|
|
1443
|
+
return
|
|
1444
|
+
where = r.output_dir or "last-run.md (beside the routine)"
|
|
1445
|
+
await self._add(Static(
|
|
1446
|
+
f"[{MUTED}]🌙 routine {r.name}: {res['status']} · ${res['cost']:.4f} · "
|
|
1447
|
+
f"see {where} · {res['session']}[/]", classes="tool-line"))
|
|
1448
|
+
|
|
1449
|
+
@work(group="routines", exclusive=True, exit_on_error=False)
|
|
1450
|
+
async def _review_routines(self) -> None:
|
|
1451
|
+
"""Walk due routines, one inline card each: run / lease / later / off."""
|
|
1452
|
+
from rockycode.routines import RoutineStore
|
|
1453
|
+
from rockycode.session import get_project
|
|
1454
|
+
from rockycode.tui.routinecard import RoutineCard
|
|
1455
|
+
|
|
1456
|
+
store = RoutineStore()
|
|
1457
|
+
due = store.due(project_id=get_project(self.engine.workdir).id)
|
|
1458
|
+
if not due:
|
|
1459
|
+
await self._add(Static(
|
|
1460
|
+
f"[{MUTED}]no routines due — they come back on their cadence[/]",
|
|
1461
|
+
classes="tool-line"))
|
|
1462
|
+
return
|
|
1463
|
+
for r in due:
|
|
1464
|
+
fut: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
1465
|
+
card = RoutineCard(r, store.state(r).lease_spent, fut)
|
|
1466
|
+
await self.mount(card, before=self.query_one("#prompt"))
|
|
1467
|
+
try:
|
|
1468
|
+
choice = await fut
|
|
1469
|
+
finally:
|
|
1470
|
+
if card.is_mounted:
|
|
1471
|
+
await card.remove()
|
|
1472
|
+
if choice == "run":
|
|
1473
|
+
self._run_routine_bg(r)
|
|
1474
|
+
elif choice == "auto":
|
|
1475
|
+
store.grant_lease(r, days=7, budget=r.budget_lease)
|
|
1476
|
+
await self._add(Static(
|
|
1477
|
+
f"[{VIOLET}]✦ auto lease granted[/] [dim]— {r.name} runs itself for 7 days "
|
|
1478
|
+
f"or ${r.budget_lease:.2f}, whichever ends first; renew from this card[/]",
|
|
1479
|
+
classes="tool-line"))
|
|
1480
|
+
self._run_routine_bg(r)
|
|
1481
|
+
elif choice == "off":
|
|
1482
|
+
r.enabled = False
|
|
1483
|
+
store.save(r)
|
|
1484
|
+
await self._add(Static(
|
|
1485
|
+
f"[{MUTED}]· {r.name} turned off — re-enable in its routine.toml[/]",
|
|
1486
|
+
classes="tool-line"))
|
|
1487
|
+
else: # later — stop walking
|
|
1488
|
+
await self._add(Static(
|
|
1489
|
+
f"[{MUTED}]· later — /routines when you're ready[/]", classes="tool-line"))
|
|
1490
|
+
break
|
|
1491
|
+
try:
|
|
1492
|
+
self.query_one(ChatInput).focus()
|
|
1493
|
+
except Exception: # noqa: BLE001 — focus is best-effort
|
|
1494
|
+
pass
|
|
1495
|
+
|
|
1496
|
+
async def on_unmount(self) -> None:
|
|
1497
|
+
mgr = getattr(self.engine, "mcp_manager", None)
|
|
1498
|
+
if mgr is not None:
|
|
1499
|
+
try:
|
|
1500
|
+
await asyncio.wait_for(mgr.stop(), timeout=8)
|
|
1501
|
+
except asyncio.TimeoutError:
|
|
1502
|
+
pass
|
|
1503
|
+
lsp_mgr = getattr(self.engine, "lsp_manager", None)
|
|
1504
|
+
if lsp_mgr is not None:
|
|
1505
|
+
try:
|
|
1506
|
+
await lsp_mgr.stop()
|
|
1507
|
+
except Exception: # noqa: BLE001
|
|
1508
|
+
pass
|
|
1509
|
+
if self.sandbox is not None and self.sandbox.is_running:
|
|
1510
|
+
try:
|
|
1511
|
+
await self.sandbox.stop()
|
|
1512
|
+
except Exception: # noqa: BLE001 — best-effort teardown
|
|
1513
|
+
pass
|
|
1514
|
+
if self._artifact_server is not None:
|
|
1515
|
+
try:
|
|
1516
|
+
await self._artifact_server.stop()
|
|
1517
|
+
except Exception: # noqa: BLE001
|
|
1518
|
+
pass
|
|
1519
|
+
|
|
1520
|
+
# ---- transcript helpers -------------------------------------------------
|
|
1521
|
+
|
|
1522
|
+
def _transcript(self) -> VerticalScroll:
|
|
1523
|
+
return self.query_one("#transcript", VerticalScroll)
|
|
1524
|
+
|
|
1525
|
+
def _at_bottom(self) -> bool:
|
|
1526
|
+
"""Is the transcript pinned to the bottom? If the user scrolled up to
|
|
1527
|
+
read, we leave their position alone instead of yanking them back."""
|
|
1528
|
+
try:
|
|
1529
|
+
t = self._transcript()
|
|
1530
|
+
except NoMatches:
|
|
1531
|
+
return True # a screen (e.g. goal) is on top of chat — nothing to pin
|
|
1532
|
+
return t.scroll_offset.y >= t.max_scroll_y - 2
|
|
1533
|
+
|
|
1534
|
+
async def _add(self, widget) -> None:
|
|
1535
|
+
stick = self._at_bottom()
|
|
1536
|
+
await self._transcript().mount(widget)
|
|
1537
|
+
if stick:
|
|
1538
|
+
self._transcript().scroll_end(animate=False)
|
|
1539
|
+
|
|
1540
|
+
# NOT named _flush: Textual's App._flush(stderr=...) is a real method it
|
|
1541
|
+
# calls internally to flush its output streams. Overriding it shadowed
|
|
1542
|
+
# Textual's and crashed intermittently ("unexpected keyword argument
|
|
1543
|
+
# 'stderr'") whenever Textual hit that path.
|
|
1544
|
+
async def _flush_buffers(self) -> None:
|
|
1545
|
+
# A pushed screen (e.g. the goal view) can be on top of chat while this
|
|
1546
|
+
# timer keeps ticking — the chat transcript isn't queryable then, so bail.
|
|
1547
|
+
try:
|
|
1548
|
+
self._transcript()
|
|
1549
|
+
except NoMatches:
|
|
1550
|
+
return
|
|
1551
|
+
dirty = False
|
|
1552
|
+
stick = self._at_bottom()
|
|
1553
|
+
if self._think_widget is not None and len(self._think_buf) != self._think_flushed:
|
|
1554
|
+
self._think_widget.update(self._think_buf)
|
|
1555
|
+
self._think_flushed = len(self._think_buf)
|
|
1556
|
+
dirty = True
|
|
1557
|
+
if self._reply_widget is not None and len(self._reply_buf) != self._reply_flushed:
|
|
1558
|
+
self._reply_widget.update(self._reply_buf) # plain Static — no markdown parse
|
|
1559
|
+
self._reply_flushed = len(self._reply_buf)
|
|
1560
|
+
dirty = True
|
|
1561
|
+
if dirty and stick:
|
|
1562
|
+
self._transcript().scroll_end(animate=False)
|
|
1563
|
+
|
|
1564
|
+
def _set_status(self, state: AgentState) -> None:
|
|
1565
|
+
self._state = state
|
|
1566
|
+
self._render_status()
|
|
1567
|
+
|
|
1568
|
+
def _animate_status(self) -> None:
|
|
1569
|
+
# Only the tiny #status widget repaints — the transcript is untouched.
|
|
1570
|
+
if self._state in (AgentState.THINKING, AgentState.RESPONDING, AgentState.AMAZED):
|
|
1571
|
+
self._note_i += 1
|
|
1572
|
+
self._render_status()
|
|
1573
|
+
|
|
1574
|
+
async def _handle_model(self, text: str) -> None:
|
|
1575
|
+
"""/model — switch provider AND exact model live. Bare shows only the
|
|
1576
|
+
options you've KEYED (so an EN/CN catalog stays short), with an "N more"
|
|
1577
|
+
footer; `/model all` shows the full catalog. A spec (provider,
|
|
1578
|
+
provider-region, provider:model, or a unique model id) rebuilds the
|
|
1579
|
+
client with that endpoint's base_url + rocky-owned key."""
|
|
1580
|
+
from rockycode.engine import providers as P
|
|
1581
|
+
|
|
1582
|
+
parts = text.split(maxsplit=1)
|
|
1583
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
1584
|
+
|
|
1585
|
+
if not arg or arg == "all":
|
|
1586
|
+
show_all = arg == "all"
|
|
1587
|
+
picks = P.choices() if show_all else P.configured_choices()
|
|
1588
|
+
lines = [f"[bold {VIOLET}]model[/] [dim](provider + model, this session)[/]",
|
|
1589
|
+
f" now: [{VIOLET}]{escape(self.engine.provider_name)}:{escape(self.engine.model)}[/]"]
|
|
1590
|
+
for c in picks:
|
|
1591
|
+
cur = (c.prov_id == self.engine.provider_name and c.model == self.engine.model)
|
|
1592
|
+
tag = "" if c.configured else " [dim]✗ no key[/]"
|
|
1593
|
+
lines.append(f" [{'bold ' + VIOLET if cur else LAVENDER}]/model {c.id}[/]"
|
|
1594
|
+
f"[dim] — {escape(c.provider.label)}[/]{tag}")
|
|
1595
|
+
if not show_all:
|
|
1596
|
+
hidden = len(P.choices()) - len(picks)
|
|
1597
|
+
if hidden > 0:
|
|
1598
|
+
lines.append(f"[dim] + {hidden} more (no key yet) — /model all to see · "
|
|
1599
|
+
f"keys go in ~/.rockycode as ROCKYCODE_<PROVIDER>[_CN]_API_KEY[/]")
|
|
1600
|
+
await self._add(Static("\n".join(lines), classes="tool-line"))
|
|
1601
|
+
return
|
|
1602
|
+
|
|
1603
|
+
resolved = P.resolve(arg)
|
|
1604
|
+
if resolved is None:
|
|
1605
|
+
await self._add(Static(
|
|
1606
|
+
f"[{AMBER}]· unknown or ambiguous — /model to see your options, /model all for the catalog[/]",
|
|
1607
|
+
classes="tool-line"))
|
|
1608
|
+
return
|
|
1609
|
+
prov, ep, model = resolved
|
|
1610
|
+
prev_model = self.engine.model
|
|
1611
|
+
try:
|
|
1612
|
+
from rockycode.onboarding import provider_key
|
|
1613
|
+
key = provider_key(ep.key_env)
|
|
1614
|
+
except Exception as e: # noqa: BLE001 — missing key: say exactly what to set
|
|
1615
|
+
await self._add(Static(f"[{AMBER}]· {escape(str(e))}[/]", classes="tool-line"))
|
|
1616
|
+
return
|
|
1617
|
+
from openai import AsyncOpenAI
|
|
1618
|
+
client = AsyncOpenAI(api_key=key, base_url=ep.base_url, max_retries=5, timeout=300.0)
|
|
1619
|
+
self.engine.switch_provider(
|
|
1620
|
+
client, model, provider_name=ep.eid,
|
|
1621
|
+
reasoning_policy=prov.reasoning, tools_enabled=(prov.tools == "native"),
|
|
1622
|
+
)
|
|
1623
|
+
self._render_status()
|
|
1624
|
+
note = "" if prov.tools == "native" else " [dim](tools off — plain responder)[/]"
|
|
1625
|
+
await self._add(Static(
|
|
1626
|
+
f"[{VIOLET}]✦ model → {escape(ep.eid)}:{escape(model)}[/]{note} "
|
|
1627
|
+
f"[dim]applies from the next reply[/]", classes="tool-line"))
|
|
1628
|
+
# Cost reminder on a real switch: the new model's API rate, and that the
|
|
1629
|
+
# prompt cache resets (first turn on it is all cache-miss → pricier).
|
|
1630
|
+
if model != prev_model:
|
|
1631
|
+
await self._add(Static(self._model_cost_note(model), classes="tool-line"))
|
|
1632
|
+
|
|
1633
|
+
def _model_cost_note(self, model: str) -> str:
|
|
1634
|
+
"""One-line API-fee reminder for a just-switched model: its input/output
|
|
1635
|
+
rate in the session currency, or a nudge to set it. All pricing is
|
|
1636
|
+
per-token API fee (not membership/plan) — the ledger prices each turn by
|
|
1637
|
+
the model that produced it."""
|
|
1638
|
+
sym = "¥" if self._currency == "cny" else "$"
|
|
1639
|
+
if self._ledger.priced(model):
|
|
1640
|
+
r = self._ledger.rate(model, self._currency)
|
|
1641
|
+
body = (f"[dim]· {escape(model)} · API fee {sym}{r['in_miss']:.4g} in / "
|
|
1642
|
+
f"{sym}{r['out']:.4g} out per 1M tok — cache resets on switch "
|
|
1643
|
+
f"(first turn all-miss)[/]")
|
|
1644
|
+
else:
|
|
1645
|
+
body = (f"[{AMBER}]· {escape(model)} has no price set — add its API fee to "
|
|
1646
|
+
f"~/.rockycode/pricing.toml or cost shows 0. cache resets on switch.[/]")
|
|
1647
|
+
return body
|
|
1648
|
+
|
|
1649
|
+
def _render_status(self) -> None:
|
|
1650
|
+
note = NOTES[self._note_i % len(NOTES)]
|
|
1651
|
+
base = _status_text(self._state, note)
|
|
1652
|
+
if self.sandbox is not None and self.sandbox.is_running:
|
|
1653
|
+
base += f" [{VIOLET}]⬢[/]"
|
|
1654
|
+
try:
|
|
1655
|
+
self.query_one("#status", Static).update(base)
|
|
1656
|
+
except NoMatches:
|
|
1657
|
+
pass # a screen is on top of chat — the status bar isn't visible now
|
|
1658
|
+
|
|
1659
|
+
# ---- exit + slash commands -----------------------------------------------
|
|
1660
|
+
|
|
1661
|
+
async def action_quit(self) -> None:
|
|
1662
|
+
"""ctrl+q — same path as /exit, so the feedback sheet isn't bypassed."""
|
|
1663
|
+
self._request_exit()
|
|
1664
|
+
|
|
1665
|
+
def _request_exit(self) -> None:
|
|
1666
|
+
"""Start the exit flow WITHOUT blocking the caller. The sheet awaits a
|
|
1667
|
+
Future, so it must run in a worker — awaiting it inside a message
|
|
1668
|
+
handler (slash command / binding) would block the App's message pump
|
|
1669
|
+
and deadlock the whole UI (the same reason _after_cancel is a worker)."""
|
|
1670
|
+
self.run_worker(self._exit_with_sheet(), exclusive=False)
|
|
1671
|
+
|
|
1672
|
+
async def _exit_with_sheet(self) -> None:
|
|
1673
|
+
"""Leave the app, offering the one-click exit feedback sheet first —
|
|
1674
|
+
only when a real exchange happened. The answer lands as a `feedback`
|
|
1675
|
+
trajectory record, which is LOCAL ONLY: never placed in a cloud-bound
|
|
1676
|
+
prompt; the local dream reads it later (self-evolve phase 0)."""
|
|
1677
|
+
from rockycode.tui.exitsheet import NEVER, ExitSheet
|
|
1678
|
+
|
|
1679
|
+
if self._exit_sheet_open:
|
|
1680
|
+
# A second /exit or ctrl+q while the sheet waits = "skip, leave now".
|
|
1681
|
+
if self._exit_sheet_fut is not None and not self._exit_sheet_fut.done():
|
|
1682
|
+
self._exit_sheet_fut.set_result(None)
|
|
1683
|
+
return
|
|
1684
|
+
# "auto" (the default) asks ONLY when the dream pipeline is alive this
|
|
1685
|
+
# session — the probe succeeded, so the feedback has a real local
|
|
1686
|
+
# consumer. Users who never dream are never asked (and never left
|
|
1687
|
+
# wondering whether a rating goes to the model provider).
|
|
1688
|
+
wanted = self._exit_sheet == "on" or (
|
|
1689
|
+
self._exit_sheet == "auto" and self._ollama_ok is True)
|
|
1690
|
+
if (not wanted
|
|
1691
|
+
or self.engine.stats.turns == 0
|
|
1692
|
+
or self.engine.trajectory.path is None):
|
|
1693
|
+
self.exit() # sheet off / no dream consumer / nothing to rate
|
|
1694
|
+
return
|
|
1695
|
+
self._exit_sheet_open = True
|
|
1696
|
+
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
1697
|
+
self._exit_sheet_fut = fut
|
|
1698
|
+
widget = ExitSheet(fut)
|
|
1699
|
+
try:
|
|
1700
|
+
await self.mount(widget, before=self.query_one("#prompt"))
|
|
1701
|
+
except Exception: # noqa: BLE001 — a covered screen must never block exit
|
|
1702
|
+
self._exit_sheet_open = False
|
|
1703
|
+
self.exit()
|
|
1704
|
+
return
|
|
1705
|
+
try:
|
|
1706
|
+
result = await fut
|
|
1707
|
+
finally:
|
|
1708
|
+
self._exit_sheet_open = False
|
|
1709
|
+
if widget.is_mounted:
|
|
1710
|
+
await widget.remove()
|
|
1711
|
+
if result == NEVER:
|
|
1712
|
+
# Persist the off-switch from where the annoyance is; /config
|
|
1713
|
+
# exit_sheet on brings it back.
|
|
1714
|
+
from rockycode.config import set_value
|
|
1715
|
+
set_value("exit_sheet", "off")
|
|
1716
|
+
self._exit_sheet = "off"
|
|
1717
|
+
elif result is not None:
|
|
1718
|
+
self.engine.trajectory.feedback({**result, "local_only": True})
|
|
1719
|
+
self.exit()
|
|
1720
|
+
|
|
1721
|
+
async def _handle_slash(self, text: str) -> None:
|
|
1722
|
+
cmd = text.split()[0].lower()
|
|
1723
|
+
if cmd in ("/exit", "/quit"):
|
|
1724
|
+
self._request_exit()
|
|
1725
|
+
elif cmd == "/proposals":
|
|
1726
|
+
self._review_proposals()
|
|
1727
|
+
elif cmd == "/routines":
|
|
1728
|
+
self._review_routines()
|
|
1729
|
+
elif cmd == "/clear":
|
|
1730
|
+
await self._transcript().remove_children()
|
|
1731
|
+
elif cmd == "/help":
|
|
1732
|
+
await self._add(Static(HELP_TEXT, classes="tool-line"))
|
|
1733
|
+
elif cmd in ("/research", "/learn"):
|
|
1734
|
+
await self._handle_mode_cmd(cmd[1:], text)
|
|
1735
|
+
elif cmd == "/config":
|
|
1736
|
+
from rockycode.config import DEFAULTS, GLOBAL_PATH, load, set_value
|
|
1737
|
+
parts = text.split(maxsplit=2)
|
|
1738
|
+
if len(parts) >= 3: # /config key value → set
|
|
1739
|
+
v, err = set_value(parts[1], parts[2])
|
|
1740
|
+
if err:
|
|
1741
|
+
await self._add(Static(f"[{AMBER}]? {err}[/]", classes="tool-line"))
|
|
1742
|
+
else:
|
|
1743
|
+
# Model limits take effect on the live engine at once; other
|
|
1744
|
+
# keys (currency/theme/language) are read at startup.
|
|
1745
|
+
if parts[1] in ("context_window", "max_tokens"):
|
|
1746
|
+
setattr(self.engine, parts[1], v)
|
|
1747
|
+
note = "applied now"
|
|
1748
|
+
elif parts[1] == "exit_sheet":
|
|
1749
|
+
self._exit_sheet = v
|
|
1750
|
+
note = "applied now"
|
|
1751
|
+
elif parts[1] == "dream":
|
|
1752
|
+
self._dream_mode = v
|
|
1753
|
+
note = "applies at next launch"
|
|
1754
|
+
else:
|
|
1755
|
+
note = "restart to apply"
|
|
1756
|
+
await self._add(Static(
|
|
1757
|
+
f"[{VIOLET}]✦ saved: {parts[1]} = {v}[/] [dim]({note})[/]",
|
|
1758
|
+
classes="tool-line"))
|
|
1759
|
+
else: # /config → show
|
|
1760
|
+
cfg = load(self.engine.workdir)
|
|
1761
|
+
lines = [f"[bold {VIOLET}]config[/] [dim]{GLOBAL_PATH}[/]"]
|
|
1762
|
+
lines += [f" [{LAVENDER}]{k}[/] = {cfg[k]}" for k in DEFAULTS]
|
|
1763
|
+
lines.append("[dim] /config <key> <value> to set[/]")
|
|
1764
|
+
await self._add(Static("\n".join(lines), classes="tool-line"))
|
|
1765
|
+
elif cmd == "/prompt":
|
|
1766
|
+
await self.push_screen(
|
|
1767
|
+
TextViewerScreen(
|
|
1768
|
+
"system prompt — edit in rockycode/prompts/rocky.py",
|
|
1769
|
+
self.engine.history[0]["content"],
|
|
1770
|
+
)
|
|
1771
|
+
)
|
|
1772
|
+
elif cmd == "/skills":
|
|
1773
|
+
skills = getattr(self.engine, "skills", []) or []
|
|
1774
|
+
if not skills:
|
|
1775
|
+
await self._add(
|
|
1776
|
+
Static(
|
|
1777
|
+
"[dim]no skills found (.claude/skills, .rockycode/skills, "
|
|
1778
|
+
"~/.claude/skills, ~/.codex/prompts)[/]",
|
|
1779
|
+
classes="tool-line",
|
|
1780
|
+
)
|
|
1781
|
+
)
|
|
1782
|
+
else:
|
|
1783
|
+
lines = [f"[bold {VIOLET}]skills[/]"]
|
|
1784
|
+
lines += [f" [{LAVENDER}]{s.name}[/] — {s.description} [dim]({s.source})[/]" for s in skills]
|
|
1785
|
+
await self._add(Static("\n".join(lines), classes="tool-line"))
|
|
1786
|
+
elif cmd == "/memory":
|
|
1787
|
+
store = getattr(self.engine, "memory_store", None)
|
|
1788
|
+
memories = store.load_all() if store is not None else []
|
|
1789
|
+
if store is None:
|
|
1790
|
+
await self._add(Static("[dim]memory is off (--no-memory)[/]", classes="tool-line"))
|
|
1791
|
+
elif not memories:
|
|
1792
|
+
await self._add(
|
|
1793
|
+
Static(
|
|
1794
|
+
"[dim]i remember nothing yet. tell me things with /remember "
|
|
1795
|
+
"— files land in .rockycode/memory/[/]",
|
|
1796
|
+
classes="tool-line",
|
|
1797
|
+
)
|
|
1798
|
+
)
|
|
1799
|
+
else:
|
|
1800
|
+
lines = [f"[bold {VIOLET}]memory[/]"]
|
|
1801
|
+
lines += [f" [{LAVENDER}]{m.name}[/] [dim]({m.type})[/dim] — {m.description}" for m in memories]
|
|
1802
|
+
lines.append("[dim] rockycode memory show <name> · files in .rockycode/memory/[/]")
|
|
1803
|
+
await self._add(Static("\n".join(lines), classes="tool-line"))
|
|
1804
|
+
elif cmd == "/remember":
|
|
1805
|
+
store = getattr(self.engine, "memory_store", None)
|
|
1806
|
+
note = text[len("/remember"):].strip()
|
|
1807
|
+
if store is None:
|
|
1808
|
+
await self._add(Static("[dim]memory is off (--no-memory)[/]", classes="tool-line"))
|
|
1809
|
+
elif not note:
|
|
1810
|
+
await self._add(Static(f"[{MUTED}]usage: /remember <note>[/]", classes="tool-line"))
|
|
1811
|
+
else:
|
|
1812
|
+
from rockycode.memory import Memory
|
|
1813
|
+
mem = Memory(name="", type="feedback", description=note[:80], body=note, origin="user")
|
|
1814
|
+
path = store.save(mem)
|
|
1815
|
+
await self._add(
|
|
1816
|
+
Static(
|
|
1817
|
+
f"[{VIOLET}]✦ amaze! i remember:[/] {note}\n[dim] {path}[/]",
|
|
1818
|
+
classes="tool-line",
|
|
1819
|
+
)
|
|
1820
|
+
)
|
|
1821
|
+
elif cmd == "/plan":
|
|
1822
|
+
await self._handle_plan(text)
|
|
1823
|
+
elif cmd == "/goal":
|
|
1824
|
+
self._handle_goal(text) # worker: probes Docker without blocking the pump
|
|
1825
|
+
elif cmd == "/sandbox":
|
|
1826
|
+
self._handle_sandbox(text) # worker; don't block the pump
|
|
1827
|
+
elif cmd == "/lsp":
|
|
1828
|
+
mgr = getattr(self.engine, "lsp_manager", None)
|
|
1829
|
+
if mgr is None:
|
|
1830
|
+
await self._add(Static("[dim]lsp is off (--no-lsp)[/]", classes="tool-line"))
|
|
1831
|
+
elif not mgr.is_running:
|
|
1832
|
+
await self._add(Static(f"[dim]lsp: configured ({mgr.command}) but not connected[/]", classes="tool-line"))
|
|
1833
|
+
else:
|
|
1834
|
+
tools = ", ".join(sorted(mgr.available_tools)) or "(listing…)"
|
|
1835
|
+
await self._add(
|
|
1836
|
+
Static(
|
|
1837
|
+
f"[{VIOLET}]lsp[/] [{LAVENDER}]connected[/]\n"
|
|
1838
|
+
f" [dim]server: {mgr.command}\n"
|
|
1839
|
+
f" tools: {tools}[/]",
|
|
1840
|
+
classes="tool-line",
|
|
1841
|
+
)
|
|
1842
|
+
)
|
|
1843
|
+
elif cmd == "/artifact":
|
|
1844
|
+
parts = text.split()
|
|
1845
|
+
sub = parts[1].lower() if len(parts) > 1 else "status"
|
|
1846
|
+
arg = parts[2].lower() if len(parts) > 2 else ""
|
|
1847
|
+
if sub == "live" and arg == "on":
|
|
1848
|
+
self._artifact_live = True
|
|
1849
|
+
await self._artifact_target() # ensure server (announces if it starts)
|
|
1850
|
+
elif sub == "live" and arg == "off":
|
|
1851
|
+
self._artifact_live = False
|
|
1852
|
+
if self._artifact_server is not None:
|
|
1853
|
+
await self._artifact_server.stop()
|
|
1854
|
+
self._artifact_server = None
|
|
1855
|
+
self.engine.artifact_server = None
|
|
1856
|
+
state = ("live" if self._artifact_live else
|
|
1857
|
+
"static (file://)" if self._artifact_live is False else
|
|
1858
|
+
"undecided — rocky asks on first artifact")
|
|
1859
|
+
url = f" [dim]{self._artifact_server.base_url}[/]" if self._artifact_server is not None else ""
|
|
1860
|
+
await self._add(Static(
|
|
1861
|
+
f"[bold {VIOLET}]artifact[/] [{LAVENDER}]{state}[/]{url}\n"
|
|
1862
|
+
f" [dim]/artifact live on | off[/]", classes="tool-line"))
|
|
1863
|
+
elif cmd == "/mcp":
|
|
1864
|
+
mgr = getattr(self.engine, "mcp_manager", None)
|
|
1865
|
+
if mgr is None:
|
|
1866
|
+
await self._add(Static("[dim]mcp is off (--no-mcp)[/]", classes="tool-line"))
|
|
1867
|
+
else:
|
|
1868
|
+
lines = [f"[bold {VIOLET}]mcp[/]"]
|
|
1869
|
+
lines += [f" {line}" for line in mgr.status()]
|
|
1870
|
+
tools = sorted(mgr.tools())
|
|
1871
|
+
if tools:
|
|
1872
|
+
lines.append(f" [dim]{' · '.join(tools)}[/]")
|
|
1873
|
+
await self._add(Static("\n".join(lines), classes="tool-line"))
|
|
1874
|
+
elif cmd == "/effort":
|
|
1875
|
+
parts = text.split()
|
|
1876
|
+
arg = parts[1].lower() if len(parts) > 1 else ""
|
|
1877
|
+
if arg in EFFORT_LEVELS:
|
|
1878
|
+
if arg == "off":
|
|
1879
|
+
self.engine.thinking = False
|
|
1880
|
+
else:
|
|
1881
|
+
self.engine.thinking = True
|
|
1882
|
+
self.engine.reasoning_effort = arg
|
|
1883
|
+
await self._add(Static(
|
|
1884
|
+
f"[{VIOLET}]✦ effort → {self._effort_note()}[/] "
|
|
1885
|
+
f"[dim]applies from the next reply (this session)[/]",
|
|
1886
|
+
classes="tool-line"))
|
|
1887
|
+
else:
|
|
1888
|
+
await self._add(Static(
|
|
1889
|
+
f"[bold {VIOLET}]effort[/] [dim](how hard i think — this session)[/]\n"
|
|
1890
|
+
f" now: {self._effort_note()}\n"
|
|
1891
|
+
f" [dim]/effort off | high | xhigh | max — off skips thinking; "
|
|
1892
|
+
f"xhigh = max on deepseek, its own tier elsewhere[/]",
|
|
1893
|
+
classes="tool-line"))
|
|
1894
|
+
elif cmd == "/model":
|
|
1895
|
+
await self._handle_model(text)
|
|
1896
|
+
elif cmd == "/permission":
|
|
1897
|
+
parts = text.split()
|
|
1898
|
+
if len(parts) >= 2 and parts[1].lower() in ("yolo", "ask", "careful"):
|
|
1899
|
+
self._set_permission_mode(parts[1].lower())
|
|
1900
|
+
await self._add(Static(
|
|
1901
|
+
f"[{VIOLET}]✦ permission → {self._permission_note()}[/]",
|
|
1902
|
+
classes="tool-line"))
|
|
1903
|
+
await self._maybe_nudge_yolo_host() # switching TO yolo on the host
|
|
1904
|
+
else:
|
|
1905
|
+
await self._add(Static(
|
|
1906
|
+
f"[bold {VIOLET}]permission[/] [dim](this session — /config persists)[/]\n"
|
|
1907
|
+
f" now: {self._perm_chip()}\n"
|
|
1908
|
+
f" [dim]/permission yolo|ask|careful[/]",
|
|
1909
|
+
classes="tool-line"))
|
|
1910
|
+
else:
|
|
1911
|
+
await self._add(
|
|
1912
|
+
Static(
|
|
1913
|
+
f"[{MUTED}]?[/] i no know [bold]{cmd}[/]. try [{LAVENDER}]/help[/]",
|
|
1914
|
+
classes="tool-line",
|
|
1915
|
+
)
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
def _gather_host_tools(self) -> dict:
|
|
1919
|
+
"""Return tools that should stay on the host (not enter the sandbox)."""
|
|
1920
|
+
host_names = {"web_search", "web_research", "web_fetch", "create_artifact",
|
|
1921
|
+
"lsp_lookup", "lsp_symbol_search", "lsp_file_symbols", "lsp_diagnostics"}
|
|
1922
|
+
if self._local_registry is None:
|
|
1923
|
+
return {}
|
|
1924
|
+
return {k: v for k, v in self._local_registry.items() if k in host_names}
|
|
1925
|
+
|
|
1926
|
+
def _restore_host_tools(self) -> None:
|
|
1927
|
+
"""Re-add host-side tools after a sandbox→local swap (lost in the swap)."""
|
|
1928
|
+
host_tools = self._gather_host_tools()
|
|
1929
|
+
if host_tools:
|
|
1930
|
+
self.engine.registry.update(host_tools)
|
|
1931
|
+
|
|
1932
|
+
@work(group="goal", exclusive=True, exit_on_error=False)
|
|
1933
|
+
async def _handle_goal(self, text: str) -> None:
|
|
1934
|
+
"""/goal [objective] — go autonomous. With an objective AND Docker ready it
|
|
1935
|
+
opens the goal SCREEN on top of chat (plan → confirm → work → summary) and
|
|
1936
|
+
pops back here when done. Without an objective (or Docker) it just guides.
|
|
1937
|
+
Worker: the docker probe must not block the message pump."""
|
|
1938
|
+
import shutil
|
|
1939
|
+
|
|
1940
|
+
obj = text[len("/goal"):].strip()
|
|
1941
|
+
if shutil.which("docker") is None:
|
|
1942
|
+
docker_up = False
|
|
1943
|
+
docker_line = (f" [{AMBER}]✗ Docker not found[/] — goal needs it for the sandbox. "
|
|
1944
|
+
f"install Docker Desktop, start it, then /goal again.")
|
|
1945
|
+
else:
|
|
1946
|
+
docker_up = await self._docker_ready()
|
|
1947
|
+
docker_line = (f" [{VIOLET}]✓ Docker ready[/]" if docker_up else
|
|
1948
|
+
f" [{AMBER}]✗ Docker installed but not running[/] — start Docker Desktop, then /goal again.")
|
|
1949
|
+
|
|
1950
|
+
# Run the goal IN A SCREEN on top of chat (plan → confirm → milestones →
|
|
1951
|
+
# summary all rendered here), then pop back — no dropping to a bare terminal.
|
|
1952
|
+
if obj and docker_up:
|
|
1953
|
+
from rockycode.tui.goal_screen import GoalScreen
|
|
1954
|
+
|
|
1955
|
+
backend = self._make_goal_backend(obj)
|
|
1956
|
+
await self._add(Static(
|
|
1957
|
+
f"[bold {VIOLET}]◆ goal[/] [dim]— opening the goal view (plan → confirm → work). "
|
|
1958
|
+
f"you'll come back here when it's done.[/]\n [dim]{escape(obj)}[/]",
|
|
1959
|
+
classes="tool-line"))
|
|
1960
|
+
summary = await self.push_screen_wait(GoalScreen(backend, obj))
|
|
1961
|
+
await self._on_goal_return(summary)
|
|
1962
|
+
return
|
|
1963
|
+
|
|
1964
|
+
# Otherwise: guide (no objective, or Docker not ready).
|
|
1965
|
+
lines = [
|
|
1966
|
+
f"[bold {VIOLET}]goal mode[/] — hand rocky an objective and it works "
|
|
1967
|
+
f"unattended: on an isolated COPY of your repo, in a Docker sandbox, "
|
|
1968
|
+
f"under a budget, committing each milestone. You wake up to a branch to review.",
|
|
1969
|
+
docker_line,
|
|
1970
|
+
]
|
|
1971
|
+
if not obj:
|
|
1972
|
+
lines.append(" [dim]give it an objective:[/] [italic]/goal fix the failing auth test[/]")
|
|
1973
|
+
else:
|
|
1974
|
+
lines.append(" [dim]once Docker's running, run[/] "
|
|
1975
|
+
f"[{LAVENDER}]/goal {escape(obj)}[/] [dim]again — it opens the goal view here.[/]")
|
|
1976
|
+
await self._add(Static("\n".join(lines), classes="tool-line"))
|
|
1977
|
+
|
|
1978
|
+
async def _docker_ready(self) -> bool:
|
|
1979
|
+
"""True if the Docker daemon answers. Separate so /goal wiring is testable
|
|
1980
|
+
without a real daemon."""
|
|
1981
|
+
try:
|
|
1982
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1983
|
+
"docker", "info",
|
|
1984
|
+
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
|
1985
|
+
return (await asyncio.wait_for(proc.wait(), timeout=8)) == 0
|
|
1986
|
+
except Exception: # noqa: BLE001
|
|
1987
|
+
return False
|
|
1988
|
+
|
|
1989
|
+
def _make_goal_backend(self, objective: str, *, plan_file=None):
|
|
1990
|
+
"""Build the live goal backend from the session's model + config. Separate
|
|
1991
|
+
so a test can swap in a fake backend and drive the whole /goal loop.
|
|
1992
|
+
plan_file (from a /plan handoff) makes goal skip its planner and execute
|
|
1993
|
+
the already-approved plan."""
|
|
1994
|
+
from rockycode.config import load as load_config
|
|
1995
|
+
from rockycode.engine.budget import recommended
|
|
1996
|
+
from rockycode.engine.goal_session import LiveGoalBackend
|
|
1997
|
+
|
|
1998
|
+
currency = load_config(self.engine.workdir)["currency"]
|
|
1999
|
+
reviewer = os.getenv("ROCKYCODE_REVIEWER_MODEL") or self.engine.model
|
|
2000
|
+
return LiveGoalBackend(
|
|
2001
|
+
objective, "" if plan_file else self._chat_digest(),
|
|
2002
|
+
model=self.engine.model, reviewer_model=reviewer,
|
|
2003
|
+
budget=recommended(currency), workdir=self.engine.workdir,
|
|
2004
|
+
currency=currency, plan_file=plan_file,
|
|
2005
|
+
)
|
|
2006
|
+
|
|
2007
|
+
def _chat_digest(self, max_chars: int = 4000) -> str:
|
|
2008
|
+
"""A compact recap of the chat for the goal — recent user/assistant turns,
|
|
2009
|
+
truncated, so the goal continues what we were doing."""
|
|
2010
|
+
parts = []
|
|
2011
|
+
for m in self.engine.history:
|
|
2012
|
+
role, content = m.get("role"), m.get("content")
|
|
2013
|
+
if role in ("user", "assistant") and isinstance(content, str) and content.strip():
|
|
2014
|
+
parts.append(f"{role}: {content.strip()}")
|
|
2015
|
+
return "\n".join(parts[-12:])[-max_chars:]
|
|
2016
|
+
|
|
2017
|
+
async def _on_goal_return(self, summary) -> None:
|
|
2018
|
+
"""Back in chat after the goal screen dismissed. Recap the outcome for the
|
|
2019
|
+
user AND seed the model's context so 'review it' / 'keep going' just work."""
|
|
2020
|
+
self.query_one(ChatInput).focus()
|
|
2021
|
+
if summary is None:
|
|
2022
|
+
return
|
|
2023
|
+
if summary.status == "cancelled":
|
|
2024
|
+
await self._add(Static(f"[{MUTED}]· goal cancelled — nothing ran.[/]", classes="tool-line"))
|
|
2025
|
+
return
|
|
2026
|
+
colour = VIOLET if summary.status == "done" else AMBER
|
|
2027
|
+
recap = f"[bold {colour}]◆ goal {escape(summary.status)}[/] — {escape(summary.reason)}"
|
|
2028
|
+
recap += (f"\n [dim]{summary.milestones_done}/{summary.milestones_total} milestones"
|
|
2029
|
+
f"{f' · branch {escape(summary.branch)}' if summary.branch else ''}[/]")
|
|
2030
|
+
if summary.branch:
|
|
2031
|
+
recap += (f"\n [{AMBER}]⚠ the changes are on branch {escape(summary.branch)} in a separate "
|
|
2032
|
+
f"worktree — your current files are untouched.[/]")
|
|
2033
|
+
recap += f"\n [dim]review:[/] [{LAVENDER}]{escape(summary.review_cmd)}[/]"
|
|
2034
|
+
recap += f"\n [dim]merge:[/] [{LAVENDER}]git -C {escape(summary.origin)} merge {escape(summary.branch)}[/] [dim](or ask me to)[/]"
|
|
2035
|
+
if summary.log:
|
|
2036
|
+
recap += f"\n [dim]log: {escape(summary.log)}[/]"
|
|
2037
|
+
await self._add(Static(recap, classes="tool-line"))
|
|
2038
|
+
# Seed the model with the exact facts so "review it" / "merge it" work and
|
|
2039
|
+
# it never assumes the changes are in the current working tree.
|
|
2040
|
+
if summary.branch:
|
|
2041
|
+
note = (f'I just ran an autonomous goal: {summary.status} — '
|
|
2042
|
+
f'{summary.milestones_done}/{summary.milestones_total} milestones. The changes are '
|
|
2043
|
+
f'COMMITTED ON BRANCH {summary.branch} in a separate git worktree ({summary.workspace}), '
|
|
2044
|
+
f'NOT in the current working tree — the user\'s current files are unchanged. '
|
|
2045
|
+
f'If they ask to REVIEW it, call the review_goal_branch tool with branch '
|
|
2046
|
+
f'"{summary.branch}". If they ask to MERGE it, call the merge_goal_branch tool with '
|
|
2047
|
+
f'branch "{summary.branch}" — it guards the merge (refuses a dirty tree, aborts on '
|
|
2048
|
+
f'conflict) so use it instead of raw git. Always remind them the work is in a '
|
|
2049
|
+
f'separate worktree.')
|
|
2050
|
+
else:
|
|
2051
|
+
note = (f'I just ran an autonomous goal: {summary.status} — '
|
|
2052
|
+
f'{summary.milestones_done}/{summary.milestones_total} milestones. {summary.reason}')
|
|
2053
|
+
self.engine.history.append({"role": "user", "content": f"[system] {note}"})
|
|
2054
|
+
|
|
2055
|
+
@work(group="sandbox", exclusive=True, exit_on_error=False)
|
|
2056
|
+
async def _handle_sandbox(self, text: str) -> None:
|
|
2057
|
+
"""Handle /sandbox on|off|status.
|
|
2058
|
+
|
|
2059
|
+
A worker (not the message pump): the first `/sandbox on` may pull a
|
|
2060
|
+
Docker image, which is unbounded — awaiting it inline froze the UI with
|
|
2061
|
+
no way to cancel or quit. Own group so a turn won't cancel a start."""
|
|
2062
|
+
parts = text.split()
|
|
2063
|
+
sub = parts[1].lower() if len(parts) > 1 else "status"
|
|
2064
|
+
|
|
2065
|
+
if sub == "status":
|
|
2066
|
+
if self.sandbox is not None and self.sandbox.is_running:
|
|
2067
|
+
await self._add(
|
|
2068
|
+
Static(
|
|
2069
|
+
f"[{LAVENDER}]sandbox[/] [{VIOLET}]on[/] "
|
|
2070
|
+
f"[dim]({self.sandbox.container_id[:12]}…)[/]",
|
|
2071
|
+
classes="tool-line",
|
|
2072
|
+
)
|
|
2073
|
+
)
|
|
2074
|
+
else:
|
|
2075
|
+
await self._add(Static(f"[{LAVENDER}]sandbox[/] [dim]off — /sandbox on to start[/]", classes="tool-line"))
|
|
2076
|
+
return
|
|
2077
|
+
|
|
2078
|
+
if sub == "off":
|
|
2079
|
+
if self.sandbox is None or not self.sandbox.is_running:
|
|
2080
|
+
await self._add(Static("[dim]sandbox is not running[/]", classes="tool-line"))
|
|
2081
|
+
return
|
|
2082
|
+
await self.sandbox.stop()
|
|
2083
|
+
self.sandbox = None
|
|
2084
|
+
if self._local_registry is not None:
|
|
2085
|
+
self.engine.swap_registry(self._local_registry)
|
|
2086
|
+
# Re-add host-side tools that were stripped during sandbox swap.
|
|
2087
|
+
self._restore_host_tools()
|
|
2088
|
+
self._render_status()
|
|
2089
|
+
await self._add(
|
|
2090
|
+
Static(
|
|
2091
|
+
f"[{LAVENDER}]sandbox[/] [dim]stopped — tools back on host filesystem[/]",
|
|
2092
|
+
classes="tool-line",
|
|
2093
|
+
)
|
|
2094
|
+
)
|
|
2095
|
+
await self._maybe_nudge_yolo_host() # if yolo, the host is exposed again
|
|
2096
|
+
return
|
|
2097
|
+
|
|
2098
|
+
if sub == "on":
|
|
2099
|
+
if self.sandbox is not None and self.sandbox.is_running:
|
|
2100
|
+
await self._add(Static("[dim]sandbox is already running[/]", classes="tool-line"))
|
|
2101
|
+
return
|
|
2102
|
+
if self._sandbox_starting:
|
|
2103
|
+
await self._add(Static("[dim]sandbox is already starting…[/]", classes="tool-line"))
|
|
2104
|
+
return
|
|
2105
|
+
self._sandbox_starting = True
|
|
2106
|
+
try:
|
|
2107
|
+
from rockycode.engine.sandbox import ChatSandbox, build_sandbox_registry
|
|
2108
|
+
want_net = len(parts) > 2 and parts[2].lower() in ("network", "net", "online")
|
|
2109
|
+
await self._add(Static(f"[{LAVENDER}]sandbox[/] [dim]starting…[/]", classes="tool-line"))
|
|
2110
|
+
sb = await ChatSandbox.start(self.engine.workdir, network=want_net)
|
|
2111
|
+
self.sandbox = sb
|
|
2112
|
+
host_tools = self._gather_host_tools()
|
|
2113
|
+
self.engine.swap_registry(build_sandbox_registry(sb, extras=host_tools))
|
|
2114
|
+
self._render_status()
|
|
2115
|
+
net_note = (f"[{VIOLET}]network on[/]" if want_net
|
|
2116
|
+
else "[dim]offline (no network) · /sandbox on network to allow[/]")
|
|
2117
|
+
await self._add(
|
|
2118
|
+
Static(
|
|
2119
|
+
f"[{LAVENDER}]sandbox[/] [{VIOLET}]on[/] "
|
|
2120
|
+
f"[dim]container {sb.container_id[:12]}… · "
|
|
2121
|
+
f"tools execute in /workspace · [/]{net_note}[dim] · /sandbox off to stop[/]",
|
|
2122
|
+
classes="tool-line",
|
|
2123
|
+
)
|
|
2124
|
+
)
|
|
2125
|
+
except Exception as exc:
|
|
2126
|
+
await self._add(
|
|
2127
|
+
Static(
|
|
2128
|
+
f"[{RED}]sandbox start failed: {exc}[/]",
|
|
2129
|
+
classes="tool-line error-msg",
|
|
2130
|
+
)
|
|
2131
|
+
)
|
|
2132
|
+
finally:
|
|
2133
|
+
self._sandbox_starting = False
|
|
2134
|
+
return
|
|
2135
|
+
|
|
2136
|
+
await self._add(Static(f"[{MUTED}]usage: /sandbox on | off | status[/]", classes="tool-line"))
|
|
2137
|
+
|
|
2138
|
+
# ---- input → engine ------------------------------------------------------
|
|
2139
|
+
|
|
2140
|
+
@on(ChatInput.Submitted)
|
|
2141
|
+
async def _on_submit(self, event: ChatInput.Submitted) -> None:
|
|
2142
|
+
text = event.value.strip()
|
|
2143
|
+
if not text:
|
|
2144
|
+
return
|
|
2145
|
+
if text.startswith("!"):
|
|
2146
|
+
self._run_shell(text[1:].strip()) # worker; don't block the pump
|
|
2147
|
+
return
|
|
2148
|
+
if text.startswith("/"):
|
|
2149
|
+
await self._handle_slash(text)
|
|
2150
|
+
return
|
|
2151
|
+
await self._add(Static(f"[bold {VIOLET}]you ▸[/] {escape(text)}", classes="user-msg"))
|
|
2152
|
+
self._turn_worker = self._run_turn(text)
|
|
2153
|
+
|
|
2154
|
+
@work(group="shell", exclusive=True, exit_on_error=False)
|
|
2155
|
+
async def _run_shell(self, cmd: str) -> None:
|
|
2156
|
+
"""`! <cmd>`: run a shell command directly (no model round-trip).
|
|
2157
|
+
Output shows here AND lands in rocky's context, so the next message
|
|
2158
|
+
can refer to it — same semantics as Claude Code's `!` prefix.
|
|
2159
|
+
|
|
2160
|
+
Runs in a worker (not on the message pump): `! npm install` can take
|
|
2161
|
+
minutes, and awaiting it inline froze the whole UI — no typing, no
|
|
2162
|
+
ctrl+q — until it finished. Its own group so a new turn won't cancel it."""
|
|
2163
|
+
if not cmd:
|
|
2164
|
+
await self._add(Static(f"[{MUTED}]usage: ! <shell command>[/]", classes="tool-line"))
|
|
2165
|
+
return
|
|
2166
|
+
if "bash" not in self.engine.registry:
|
|
2167
|
+
await self._add(Static("[dim]no bash tool in this session[/]", classes="tool-line"))
|
|
2168
|
+
return
|
|
2169
|
+
await self._add(Static(f"[bold {AMBER}]$[/] {escape(cmd)}", classes="user-msg"))
|
|
2170
|
+
output, _ok = await tools_mod.execute(
|
|
2171
|
+
self.engine.registry, "bash", json.dumps({"command": cmd})
|
|
2172
|
+
)
|
|
2173
|
+
shown = output if len(output) <= 3000 else output[:3000] + "\n… [truncated in view]"
|
|
2174
|
+
await self._add(Static(escape(shown), classes="tool-line"))
|
|
2175
|
+
# Into history via the engine so the trajectory logs it too.
|
|
2176
|
+
self.engine._append(
|
|
2177
|
+
{"role": "user", "content": f"[user ran a shell command]\n$ {cmd}\n{output}"}
|
|
2178
|
+
)
|
|
2179
|
+
|
|
2180
|
+
@work(group="turn", exclusive=True)
|
|
2181
|
+
async def _run_turn(self, text: str) -> None:
|
|
2182
|
+
"""Drive one turn; turn the modal's Esc (CancelTurn) into a clean stop.
|
|
2183
|
+
A worker .cancel() (new submit / Esc binding) is handled in
|
|
2184
|
+
on_worker_state_changed — run_turn's finally already keeps history valid."""
|
|
2185
|
+
from rockycode.tui.permission import CancelTurn
|
|
2186
|
+
try:
|
|
2187
|
+
plan_before = self._plan_digest()
|
|
2188
|
+
await self._drive_turn(text)
|
|
2189
|
+
await self._maybe_plan_gate(plan_before) # no-op unless plan mode + file changed
|
|
2190
|
+
self._maybe_title() # first completed turn names the session (async, free)
|
|
2191
|
+
except CancelTurn:
|
|
2192
|
+
await self._flush_buffers()
|
|
2193
|
+
await self._finalize_reply()
|
|
2194
|
+
self._set_status(AgentState.IDLE)
|
|
2195
|
+
await self._add(Static(f"[{MUTED}]· turn cancelled[/]", classes="tool-line"))
|
|
2196
|
+
self.query_one(ChatInput).focus()
|
|
2197
|
+
except Exception as e: # noqa: BLE001
|
|
2198
|
+
# Never let one turn crash the whole app. The @work default
|
|
2199
|
+
# exit_on_error tears down the TUI on any uncaught exception — a
|
|
2200
|
+
# mid-stream network drop (loop iterates the stream outside its own
|
|
2201
|
+
# try), a MountError from /clear mid-reply, an unexpected API error.
|
|
2202
|
+
# Surface it as a line and recover instead. (CancelledError is a
|
|
2203
|
+
# BaseException, so a worker .cancel() still propagates to
|
|
2204
|
+
# on_worker_state_changed untouched by this handler.)
|
|
2205
|
+
try:
|
|
2206
|
+
await self._flush_buffers()
|
|
2207
|
+
await self._finalize_reply()
|
|
2208
|
+
except Exception: # noqa: BLE001 — best-effort; don't mask the original
|
|
2209
|
+
pass
|
|
2210
|
+
self._set_status(AgentState.IDLE)
|
|
2211
|
+
await self._add(Static(f"✗ {escape(f'{type(e).__name__}: {e}')}", classes="error-msg"))
|
|
2212
|
+
self.query_one(ChatInput).focus()
|
|
2213
|
+
|
|
2214
|
+
async def _drive_turn(self, text: str) -> None:
|
|
2215
|
+
self._think_buf = ""
|
|
2216
|
+
self._reply_buf = ""
|
|
2217
|
+
self._think_flushed = 0
|
|
2218
|
+
self._reply_flushed = 0
|
|
2219
|
+
self._think_widget = None
|
|
2220
|
+
self._think_box = None
|
|
2221
|
+
self._reply_widget = None
|
|
2222
|
+
|
|
2223
|
+
async for ev in self.engine.run_turn(text):
|
|
2224
|
+
if isinstance(ev, StateChanged):
|
|
2225
|
+
self._set_status(ev.state)
|
|
2226
|
+
if ev.state == AgentState.RESPONDING and self._think_box is not None:
|
|
2227
|
+
self._think_box.collapsed = True
|
|
2228
|
+
|
|
2229
|
+
elif isinstance(ev, ThinkingDelta):
|
|
2230
|
+
if self._think_widget is None:
|
|
2231
|
+
# markup=False: model thinking is raw text; a stray "[/…]"
|
|
2232
|
+
# in it would otherwise crash the Static render (as the reply
|
|
2233
|
+
# stream below already guards against).
|
|
2234
|
+
self._think_widget = Static(markup=False, classes="thinking-body")
|
|
2235
|
+
self._think_box = Collapsible(
|
|
2236
|
+
self._think_widget, title="♪ rocky thinking", collapsed=False
|
|
2237
|
+
)
|
|
2238
|
+
await self._add(self._think_box)
|
|
2239
|
+
self._think_buf += ev.text
|
|
2240
|
+
|
|
2241
|
+
elif isinstance(ev, TextDelta):
|
|
2242
|
+
if self._reply_widget is None:
|
|
2243
|
+
await self._add(Static(f"[bold {PURPLE}]♪♫ rocky ▸[/]", classes="rocky-label"))
|
|
2244
|
+
# Stream into a cheap plain Static; markdown is parsed ONCE
|
|
2245
|
+
# at finalize, not on every 100ms flush (the CPU win).
|
|
2246
|
+
self._reply_widget = Static(markup=False, classes="reply-stream")
|
|
2247
|
+
await self._add(self._reply_widget)
|
|
2248
|
+
self._reply_buf += ev.text
|
|
2249
|
+
|
|
2250
|
+
elif isinstance(ev, ToolStarted):
|
|
2251
|
+
await self._add(
|
|
2252
|
+
Static(f" [{PURPLE}]⚒[/] {ev.tool} …", classes="tool-line")
|
|
2253
|
+
)
|
|
2254
|
+
|
|
2255
|
+
elif isinstance(ev, ToolFinished):
|
|
2256
|
+
mark = f"[{PURPLE}]✓[/]" if ev.ok else f"[{RED}]✗[/]"
|
|
2257
|
+
first = ev.output.strip().splitlines()[0] if ev.output.strip() else ""
|
|
2258
|
+
await self._add(
|
|
2259
|
+
Static(
|
|
2260
|
+
f" {mark} {ev.tool} [dim]({ev.duration_s:.1f}s)[/] [dim]{escape(first[:80])}[/]",
|
|
2261
|
+
classes="tool-line",
|
|
2262
|
+
)
|
|
2263
|
+
)
|
|
2264
|
+
# Fresh thinking + reply blocks after each tool round, so a
|
|
2265
|
+
# post-tool summary lands BELOW the tool output as its own
|
|
2266
|
+
# bubble instead of merging into an earlier one.
|
|
2267
|
+
await self._reset_step_blocks()
|
|
2268
|
+
|
|
2269
|
+
elif isinstance(ev, Compacted):
|
|
2270
|
+
await self._add(
|
|
2271
|
+
Static(
|
|
2272
|
+
f" [dim]♻ context squeezed: ~{ev.tokens_before:,} → "
|
|
2273
|
+
f"~{ev.tokens_after:,} tokens ({ev.strategy})[/dim]",
|
|
2274
|
+
classes="tool-line",
|
|
2275
|
+
)
|
|
2276
|
+
)
|
|
2277
|
+
|
|
2278
|
+
elif isinstance(ev, ContextReminder):
|
|
2279
|
+
await self._add(
|
|
2280
|
+
Static(
|
|
2281
|
+
f" [dim]◐ context at ~{ev.pct:.0%} — DeepSeek V4 is sharpest under half. "
|
|
2282
|
+
f"type [/][{LAVENDER}]/clear[/][dim] to start fresh, or keep going "
|
|
2283
|
+
f"(auto-compacts near full).[/]",
|
|
2284
|
+
classes="tool-line",
|
|
2285
|
+
)
|
|
2286
|
+
)
|
|
2287
|
+
|
|
2288
|
+
elif isinstance(ev, EngineError):
|
|
2289
|
+
await self._add(Static(f"✗ {escape(ev.message)}", classes="error-msg"))
|
|
2290
|
+
|
|
2291
|
+
elif isinstance(ev, TurnFinished):
|
|
2292
|
+
u = ev.usage
|
|
2293
|
+
if u:
|
|
2294
|
+
await self._add(
|
|
2295
|
+
Static(
|
|
2296
|
+
f"[dim]· {ev.steps} step(s) · "
|
|
2297
|
+
f"{u.get('prompt_tokens', 0):,} in / "
|
|
2298
|
+
f"{u.get('completion_tokens', 0):,} out · "
|
|
2299
|
+
f"cache hit {u.get('prompt_cache_hit_tokens', 0):,}[/]",
|
|
2300
|
+
classes="usage-line",
|
|
2301
|
+
)
|
|
2302
|
+
)
|
|
2303
|
+
self._ledger.add(self.engine.model, u)
|
|
2304
|
+
self._update_total()
|
|
2305
|
+
|
|
2306
|
+
await self._flush_buffers()
|
|
2307
|
+
await self._finalize_reply()
|
|
2308
|
+
|
|
2309
|
+
async def _finalize_reply(self) -> None:
|
|
2310
|
+
"""Replace the plain streaming Static with a one-shot Markdown render
|
|
2311
|
+
when the reply block is done — markdown is parsed once, not per flush."""
|
|
2312
|
+
if self._reply_widget is None:
|
|
2313
|
+
return
|
|
2314
|
+
text = self._reply_buf
|
|
2315
|
+
if text.strip():
|
|
2316
|
+
# width=0 before first layout → None → links yes, wrapping no.
|
|
2317
|
+
md = Markdown(
|
|
2318
|
+
enhance_markdown(text, width=self._reply_width(),
|
|
2319
|
+
only_existing=True),
|
|
2320
|
+
parser_factory=rocky_markdown_parser,
|
|
2321
|
+
open_links=False,
|
|
2322
|
+
)
|
|
2323
|
+
await self._transcript().mount(md, after=self._reply_widget)
|
|
2324
|
+
await self._reply_widget.remove()
|
|
2325
|
+
self._reply_buf = ""
|
|
2326
|
+
self._reply_flushed = 0
|
|
2327
|
+
self._reply_widget = None
|
|
2328
|
+
|
|
2329
|
+
async def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None:
|
|
2330
|
+
"""Clicks are for checking things — the paper, the docs, the web —
|
|
2331
|
+
never for running things. Text docs dock beside the chat, other
|
|
2332
|
+
reading files open with the system default; code and everything else
|
|
2333
|
+
get a dim note (open_links=False everywhere, so this is the only
|
|
2334
|
+
click path — including for links inside a docked doc)."""
|
|
2335
|
+
href = event.href
|
|
2336
|
+
dock = self._dock()
|
|
2337
|
+
if href.startswith("#"): # heading anchor inside the docked doc
|
|
2338
|
+
if dock is not None:
|
|
2339
|
+
dock.query_one("#docdock-md", Markdown).goto_anchor(href[1:])
|
|
2340
|
+
return
|
|
2341
|
+
if (dock is not None and dock.current is not None
|
|
2342
|
+
and not urlsplit(href).scheme and not href.startswith("/")):
|
|
2343
|
+
# a docked doc's relative link ("figs/x.md") — resolve against the
|
|
2344
|
+
# doc, then through the same policy gate as every other click
|
|
2345
|
+
href = str((dock.current.parent / url2pathname(href)).resolve())
|
|
2346
|
+
action, target = link_click_action(href)
|
|
2347
|
+
if action == "open" and Path(urlsplit(target).path).suffix.lower() in TEXT_DOC_SUFFIXES:
|
|
2348
|
+
await self._open_doc(target)
|
|
2349
|
+
elif action in ("browser", "open"):
|
|
2350
|
+
self.open_url(target)
|
|
2351
|
+
elif action == "missing":
|
|
2352
|
+
await self._add(Static(
|
|
2353
|
+
f"[dim]· link target missing (moved or renamed?): {escape(target)}[/]",
|
|
2354
|
+
classes="tool-line"))
|
|
2355
|
+
else:
|
|
2356
|
+
await self._add(Static(
|
|
2357
|
+
f"[dim]· not opened by click — only reading files (md/pdf/…) and "
|
|
2358
|
+
f"web links auto-open: {escape(target)}[/]",
|
|
2359
|
+
classes="tool-line"))
|
|
2360
|
+
|
|
2361
|
+
# ---- doc dock: read beside the chat, never over it ----------------------
|
|
2362
|
+
|
|
2363
|
+
def _dock(self) -> DocDock | None:
|
|
2364
|
+
try:
|
|
2365
|
+
return self.query_one(DocDock)
|
|
2366
|
+
except NoMatches:
|
|
2367
|
+
return None
|
|
2368
|
+
|
|
2369
|
+
def _reply_width(self) -> int | None:
|
|
2370
|
+
"""Fence-wrap width for replies: the TRANSCRIPT's width, not the
|
|
2371
|
+
app's — with a doc docked the chat column is narrower. 0 before
|
|
2372
|
+
first layout → None → links yes, wrapping no."""
|
|
2373
|
+
return self._transcript().container_size.width or self.size.width or None
|
|
2374
|
+
|
|
2375
|
+
async def _open_doc(self, uri: str) -> None:
|
|
2376
|
+
path = Path(url2pathname(urlsplit(uri).path))
|
|
2377
|
+
dock = self._dock()
|
|
2378
|
+
if dock is None:
|
|
2379
|
+
dock = DocDock()
|
|
2380
|
+
await self.query_one("#workspace").mount(dock)
|
|
2381
|
+
await dock.load(path)
|
|
2382
|
+
|
|
2383
|
+
async def action_doc_close(self) -> None:
|
|
2384
|
+
dock = self._dock()
|
|
2385
|
+
if dock is not None:
|
|
2386
|
+
self._transcript().display = True # in case it closed while ⛶ full
|
|
2387
|
+
await dock.remove()
|
|
2388
|
+
|
|
2389
|
+
async def action_doc_back(self) -> None:
|
|
2390
|
+
dock = self._dock()
|
|
2391
|
+
if dock is not None:
|
|
2392
|
+
await dock.back()
|
|
2393
|
+
|
|
2394
|
+
def action_doc_wider(self) -> None:
|
|
2395
|
+
dock = self._dock()
|
|
2396
|
+
if dock is not None and not dock.is_full:
|
|
2397
|
+
dock.width_i = (dock.width_i + 1) % len(WIDTHS)
|
|
2398
|
+
dock.styles.width = WIDTHS[dock.width_i]
|
|
2399
|
+
|
|
2400
|
+
def action_doc_full(self) -> None:
|
|
2401
|
+
dock = self._dock()
|
|
2402
|
+
if dock is None:
|
|
2403
|
+
return
|
|
2404
|
+
dock.is_full = not dock.is_full
|
|
2405
|
+
self._transcript().display = not dock.is_full
|
|
2406
|
+
dock.styles.width = "100%" if dock.is_full else WIDTHS[dock.width_i]
|
|
2407
|
+
|
|
2408
|
+
def _update_total(self) -> None:
|
|
2409
|
+
# Price directly in the chosen currency from its own official table —
|
|
2410
|
+
# no usd→cny conversion (see pricing.py). Peak surcharge is applied per
|
|
2411
|
+
# request inside cost().
|
|
2412
|
+
amt = self._ledger.cost(self._currency)
|
|
2413
|
+
sym = "¥" if self._currency == "cny" else "$"
|
|
2414
|
+
note = "" if self._ledger.configured(self._currency) else " [dim](prices unset)[/]"
|
|
2415
|
+
t = self._ledger.totals()
|
|
2416
|
+
rate = (t["hit"] / t["prompt"] * 100) if t["prompt"] else 0.0
|
|
2417
|
+
self.query_one("#total", Static).update(
|
|
2418
|
+
f"[{MUTED}]Σ {sym}{amt:.4f} · {t['prompt']:,} in · "
|
|
2419
|
+
f"{t['completion']:,} out · cache {rate:.0f}%{note}[/]"
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
async def _reset_step_blocks(self) -> None:
|
|
2423
|
+
"""After a tool round, the next model step gets fresh thinking + reply
|
|
2424
|
+
blocks, so each think/act/answer round reads as its own block — and a
|
|
2425
|
+
post-tool summary is its own bubble below the tool output, not glued
|
|
2426
|
+
to a pre-tool preamble above it."""
|
|
2427
|
+
if self._think_widget is not None:
|
|
2428
|
+
self._think_widget.update(self._think_buf)
|
|
2429
|
+
self._think_buf = ""
|
|
2430
|
+
self._think_flushed = 0
|
|
2431
|
+
self._think_widget = None
|
|
2432
|
+
self._think_box = None
|
|
2433
|
+
await self._finalize_reply()
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
def run_app(
|
|
2437
|
+
engine: Engine, *, resume: bool = False, resume_session=None, sandbox=None,
|
|
2438
|
+
currency: str = "usd", theme: str = "dark",
|
|
2439
|
+
permission: str = "ask", permission_weakened: bool = False,
|
|
2440
|
+
exit_sheet: str = "auto", dream: str = "auto",
|
|
2441
|
+
):
|
|
2442
|
+
"""Run the chat TUI. /goal runs in its own screen inside the app and pops back,
|
|
2443
|
+
so this just runs the app to normal exit."""
|
|
2444
|
+
return RockyCodeApp(
|
|
2445
|
+
engine, resume=resume, resume_session=resume_session, sandbox=sandbox,
|
|
2446
|
+
currency=currency, theme=theme,
|
|
2447
|
+
permission=permission, permission_weakened=permission_weakened,
|
|
2448
|
+
prompt_history=PromptHistory(), # the real CLI persists; tests stay in-memory
|
|
2449
|
+
exit_sheet=exit_sheet, dream=dream,
|
|
2450
|
+
).run()
|