rockycode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. rockycode/__init__.py +1 -0
  2. rockycode/banner.py +37 -0
  3. rockycode/cli.py +1386 -0
  4. rockycode/config.py +178 -0
  5. rockycode/dream/__init__.py +9 -0
  6. rockycode/dream/core.py +523 -0
  7. rockycode/dream/judge.py +134 -0
  8. rockycode/dream/mining.py +152 -0
  9. rockycode/dream/proposals.py +440 -0
  10. rockycode/engine/__init__.py +10 -0
  11. rockycode/engine/artifact.py +367 -0
  12. rockycode/engine/budget.py +90 -0
  13. rockycode/engine/checks.py +157 -0
  14. rockycode/engine/compaction.py +181 -0
  15. rockycode/engine/container.py +225 -0
  16. rockycode/engine/effort.py +46 -0
  17. rockycode/engine/events.py +101 -0
  18. rockycode/engine/explore.py +592 -0
  19. rockycode/engine/goal.py +541 -0
  20. rockycode/engine/goal_review.py +161 -0
  21. rockycode/engine/goal_session.py +259 -0
  22. rockycode/engine/headless.py +481 -0
  23. rockycode/engine/loop.py +711 -0
  24. rockycode/engine/lsp.py +473 -0
  25. rockycode/engine/mcp.py +364 -0
  26. rockycode/engine/modes.py +123 -0
  27. rockycode/engine/outcome.py +81 -0
  28. rockycode/engine/permission.py +198 -0
  29. rockycode/engine/planmode.py +249 -0
  30. rockycode/engine/providers.py +196 -0
  31. rockycode/engine/redact.py +83 -0
  32. rockycode/engine/safety.py +139 -0
  33. rockycode/engine/sandbox.py +219 -0
  34. rockycode/engine/server.py +431 -0
  35. rockycode/engine/skills.py +178 -0
  36. rockycode/engine/titler.py +46 -0
  37. rockycode/engine/tools.py +479 -0
  38. rockycode/engine/trajectory.py +131 -0
  39. rockycode/engine/web.py +431 -0
  40. rockycode/engine/worktree.py +128 -0
  41. rockycode/memory/__init__.py +7 -0
  42. rockycode/memory/index.py +260 -0
  43. rockycode/memory/store.py +331 -0
  44. rockycode/modes/learn/learn.md +46 -0
  45. rockycode/modes/research/deep-research.md +53 -0
  46. rockycode/modes/research/paper-reading.md +49 -0
  47. rockycode/modes/research/prove.md +60 -0
  48. rockycode/modes/research/whiteboard.md +64 -0
  49. rockycode/onboarding.py +332 -0
  50. rockycode/palette.py +15 -0
  51. rockycode/pricing.py +178 -0
  52. rockycode/prompts/__init__.py +0 -0
  53. rockycode/prompts/rocky.py +257 -0
  54. rockycode/routines.py +287 -0
  55. rockycode/runners/__init__.py +0 -0
  56. rockycode/runners/agent.py +273 -0
  57. rockycode/runners/data.py +61 -0
  58. rockycode/runners/raw.py +176 -0
  59. rockycode/score.py +114 -0
  60. rockycode/session.py +298 -0
  61. rockycode/skills/architecture-viz/SKILL.md +71 -0
  62. rockycode/skills/architecture-viz/template.html +87 -0
  63. rockycode/skills/lean-prover/SKILL.md +155 -0
  64. rockycode/skills/lean-prover/torchlean-api.md +85 -0
  65. rockycode/tui/__init__.py +1 -0
  66. rockycode/tui/app.py +2450 -0
  67. rockycode/tui/exitsheet.py +181 -0
  68. rockycode/tui/goal_screen.py +315 -0
  69. rockycode/tui/mdterm.py +232 -0
  70. rockycode/tui/mdview.py +99 -0
  71. rockycode/tui/modepicker.py +103 -0
  72. rockycode/tui/permission.py +154 -0
  73. rockycode/tui/plangate.py +110 -0
  74. rockycode/tui/prompt_history.py +77 -0
  75. rockycode/tui/proposalcard.py +126 -0
  76. rockycode/tui/resume.py +142 -0
  77. rockycode/tui/rocky_pet.py +96 -0
  78. rockycode/tui/routinecard.py +123 -0
  79. rockycode-0.1.0.dist-info/METADATA +488 -0
  80. rockycode-0.1.0.dist-info/RECORD +83 -0
  81. rockycode-0.1.0.dist-info/WHEEL +4 -0
  82. rockycode-0.1.0.dist-info/entry_points.txt +2 -0
  83. rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
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()