Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2823,7 +2823,7 @@ A one-line status bar is pinned at the input as `prompt_toolkit`'s `bottom_toolb

### 31.12 Full-screen app shell (v2, in progress)

The interactive REPL is migrating from a per-turn `prompt_toolkit` `prompt` to a persistent full-screen `Application` on the alternate screen (`cli/app.py`, `build_app(...)`), so the status bar and input dock stay pinned and never scroll away mid-turn. The layout is a scrolling chat pane on top, a custom rounded multi-line **input dock**, and the persistent status bar (§31.11) pinned at the bottom. The dock border is **hand-drawn** (`horizontal_border(width, box, top=...)` — rounded `╭─╮`/`╰─╯`, ASCII `+`/`-` fallback) rather than `prompt_toolkit`'s `Frame`, which only draws square corners and reserves completion-menu height inside the box (filling the terminal height instead of hugging the input); this is the one deliberate exception to the §31.9 "borders from rich primitives" rule, since the dock lives in a `prompt_toolkit` layout. The dock border width, the pane wrap width, and the terminal width are one shared value read at render time, so a resize re-derives the border with nothing cached. Enter submits (a literal newline is Alt+Enter); the dock grows to a 10-row cap then scrolls; PageUp/PageDown scroll the (unfocused) pane while the wheel routes to the window under the cursor. The current module is the **inert shell** — it renders and echoes submitted text into the pane but wires no runtime, model, or AI turn; the conversation, Rich-content pane rendering, thinking indicator, and Ctrl-C turn cancellation arrive in later UI-v2 branches. The non-TTY `PlainInput` path (§31.2) is unchanged.
The interactive REPL is migrating from a per-turn `prompt_toolkit` `prompt` to a persistent full-screen `Application` on the alternate screen (`cli/app.py`, `build_app(...)`), so the status bar and input dock stay pinned and never scroll away mid-turn. The layout is a scrolling chat pane on top, a custom rounded multi-line **input dock**, and the persistent status bar (§31.11) pinned at the bottom. The dock border is **hand-drawn** (`horizontal_border(width, box, top=...)` — rounded `╭─╮`/`╰─╯`, ASCII `+`/`-` fallback) rather than `prompt_toolkit`'s `Frame`, which only draws square corners and reserves completion-menu height inside the box (filling the terminal height instead of hugging the input); this is the one deliberate exception to the §31.9 "borders from rich primitives" rule, since the dock lives in a `prompt_toolkit` layout. The dock border width, the pane wrap width, and the terminal width are one shared value read at render time, so a resize re-derives the border with nothing cached. Enter submits (a literal newline is Alt+Enter); the dock grows to a 10-row cap then scrolls; PageUp/PageDown scroll the (unfocused) pane while the wheel routes to the window under the cursor. The pane now renders Rich content via `AppUI` (`cli/app_ui.py`), which implements the `RuntimeUI` protocol. `AppUI` holds a `list[RenderableType]` as the transcript source of truth, with an optional in-progress open-response accumulator (accumulated token text rendered as `rich.Markdown`). `AppUI._render_ansi()` renders the renderables to a single ANSI string at the shared terminal width via a `Console(force_terminal=True, width=W)`; the result is cached by width so a resize re-derives it automatically (cache miss) when the pane's `FormattedTextControl(lambda: ANSI(ui._render_ansi()))` renders. `wrap_lines=False` on the pane window lets Rich own all wrapping. Secret redaction (`redact_structure`) and display-integrity path resolution (`workspace_display`) are applied in `show_tool_call` before the summary reaches the pane. Approval methods raise `NotImplementedError` until branch 7 wires the focus-swap; no silent default is acceptable there. `AppUI` never calls `get_app()` or `invalidate()` — those are branch-4 concerns — so it is fully testable without a running app. The non-TTY `PlainInput` path (§31.2) is unchanged.

## 32. Model Selection And Preload

Expand Down
37 changes: 20 additions & 17 deletions shellpilot/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,8 @@

from prompt_toolkit.application import Application, get_app
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.filters import has_focus
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.formatted_text import ANSI, StyleAndTextTuples
from prompt_toolkit.input import Input
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
Expand All @@ -38,8 +37,8 @@
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.output import Output

from shellpilot.cli.app_ui import AppUI
from shellpilot.cli.input import SlashCompleter
from shellpilot.cli.render import _sanitize_line
from shellpilot.cli.status_bar import status_bar
from shellpilot.cli.theme import (
COLOR_ACCENT,
Expand Down Expand Up @@ -139,6 +138,7 @@ def build_app(
ctx_pct: int = 0,
input: Input | None = None,
output: Output | None = None,
ui: AppUI | None = None,
) -> Application[None]:
"""Build the inert full-screen app shell.

Expand All @@ -154,13 +154,22 @@ def build_app(
branch_glyph = "⎇" if unicode_mode else "git:"
branch = _read_git_branch(workspace)

# Chat pane: a read-only, unfocusable, wrapping plain-text buffer.
# NOTE: branch 3 revisits this pane control to render Rich → ANSI content
# (responses, tool calls, diffs); for now it is plain echoed text.
pane_buffer = Buffer(name="pane", read_only=True)
# Chat pane: a FormattedTextControl rendering the AppUI transcript as Rich→ANSI.
# Rich handles all wrapping at width W (wrap_lines=False on the Window), so the
# ANSI string already contains the correct line breaks for the current width.
# The AppUI re-renders when the width changes (cache miss in _render_ansi).
if ui is None:
ui = AppUI(
glyphs=glyphs,
workspace=workspace,
width_fn=lambda: get_app().output.get_size().columns,
)
# Explicit AppUI annotation (not AppUI | None) so the closures below capture a
# non-optional type; ui is already narrowed to AppUI by the guard above.
_ui: AppUI = ui
pane_window = Window(
BufferControl(buffer=pane_buffer, focusable=False),
wrap_lines=True,
FormattedTextControl(lambda: ANSI(_ui._render_ansi()), focusable=False),
wrap_lines=False,
)

# Input dock: a focused, multi-line buffer with slash completion.
Expand All @@ -185,12 +194,6 @@ def _dock_prefix(line_number: int, wrap_count: int) -> StyleAndTextTuples:
get_line_prefix=_dock_prefix,
)

def _append_pane(text: str) -> None:
sanitized = "\n".join(_sanitize_line(line) for line in text.split("\n"))
existing = pane_buffer.text
combined = f"{existing}{sanitized}\n" if existing else f"{sanitized}\n"
pane_buffer.set_document(Document(combined, len(combined)), bypass_readonly=True)

def _border(*, top: bool) -> Callable[[], StyleAndTextTuples]:
# One shared width: the live terminal columns, read at render time so a
# resize re-derives the line and nothing is pinned to a stale size.
Expand Down Expand Up @@ -253,7 +256,7 @@ def _submit(event: KeyPressEvent) -> None:
event.app.exit()
return
if text.strip():
_append_pane(text)
_ui.show_status(text)
dock_buffer.reset()

@kb.add("escape", "enter", filter=dock_focused)
Expand All @@ -270,7 +273,7 @@ def _page_down(event: KeyPressEvent) -> None:

@kb.add("c-c")
def _interrupt(event: KeyPressEvent) -> None:
_append_pane(_IDLE_HINT)
_ui.show_status(_IDLE_HINT)

# NOTE: mouse-wheel scroll over the pane needs no binding — prompt_toolkit
# routes wheel events to the window under the cursor (the pane), whose own
Expand Down
205 changes: 205 additions & 0 deletions shellpilot/cli/app_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""AppUI: RuntimeUI implementation that routes content into the full-screen app pane.

This is the content seam for UI v2 (design section 31.12). ``AppUI`` holds a list
of Rich renderables as the transcript source of truth, renders them to a single
ANSI string at the shared terminal width, and caches the result until the content
or the width changes. The pane control in ``app.py`` is a ``FormattedTextControl``
over ``ANSI(ui._render_ansi())``, so Rich handles all wrapping and theming at width
W while prompt_toolkit handles layout and scrolling.

``AppUI`` is a pure state-and-render object: it never calls ``get_app()`` or
``invalidate()`` — those are branch-4 concerns — so it is fully testable without a
running app.
"""

from __future__ import annotations

import io
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING

from rich.console import Console, RenderableType
from rich.markdown import Markdown
from rich.padding import Padding
from rich.text import Text

from shellpilot.cli.render import _sanitize_line, plan_step_line
from shellpilot.cli.render import tool_call as render_tool_call
from shellpilot.cli.render import tool_result as render_tool_result
from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS, Glyphs
from shellpilot.memory.redaction import redact_structure
from shellpilot.tools.base import workspace_display

if TYPE_CHECKING:
from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest
from shellpilot.runtime.events import TurnStats
from shellpilot.runtime.planner import TaskPlan


class AppUI:
"""RuntimeUI implementation that routes content into the full-screen app pane.

All methods must be called from the UI thread. Cross-thread marshaling and
``app.invalidate()`` are branch-4 concerns; this class stays pure state+render.
"""

def __init__(
self,
*,
glyphs: Glyphs = UNICODE_GLYPHS,
workspace: Path | None = None,
width_fn: Callable[[], int],
) -> None:
self._glyphs = glyphs
# Workspace for display-integrity (design section 14.5): when set, a
# `path` argument in the tool-call line is shown as its resolved,
# workspace-relative target — the SAME resolution the tool acts on.
self._workspace = workspace
self._width_fn = width_fn
# Source of truth: every committed renderable in the transcript.
self._renderables: list[RenderableType] = []
# Accumulated token text for the current open response.
# None means no response is open; an open response is the last renderable
# in spirit but lives here until end_response() finalizes it.
self._open_response: str | None = None
# Width-keyed ANSI cache: (width, ansi_string), or None when stale.
self._cache: tuple[int, str] | None = None

# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------

def _close_open_response(self) -> None:
"""Finalize the open response as a Markdown renderable, if one is open."""
if self._open_response is not None:
self._renderables.append(Markdown(_sanitize_line(self._open_response)))
self._open_response = None
self._cache = None

def _add_renderable(self, renderable: RenderableType) -> None:
"""Close any open response first, then append a renderable to the transcript.

Preserves ordering: response → tool call → response produces three distinct
transcript entries (the open response closes around the tool call).
"""
self._close_open_response()
self._renderables.append(renderable)
self._cache = None

def _render_ansi(self) -> str:
"""Render the full transcript to an ANSI string at the current terminal width.

The width is read from ``width_fn`` on every call and compared against the
cache key; a resize automatically re-derives the ANSI (Rich re-wraps at the
new width) and updates the cache. ``AppUI`` never calls ``get_app()``; the
caller supplies the width source via ``width_fn`` at construction time.
"""
width = self._width_fn()
if self._cache is not None and self._cache[0] == width:
return self._cache[1]
buf = io.StringIO()
console = Console(
file=buf,
force_terminal=True,
color_system="truecolor",
theme=SHELLPILOT_THEME,
width=width,
)
renderables: list[RenderableType] = list(self._renderables)
if self._open_response is not None:
# Include in-progress response without committing it yet. Sanitize the
# model text at the sink (mirrors ResponseStream, streaming.py:171) —
# _sanitize_line keeps LF, so markdown structure survives.
renderables.append(Markdown(_sanitize_line(self._open_response)))
for renderable in renderables:
console.print(renderable)
ansi = buf.getvalue()
self._cache = (width, ansi)
return ansi

# ------------------------------------------------------------------
# RuntimeUI content methods — mirroring TerminalUI exactly
# ------------------------------------------------------------------

def stream_token(self, token: str) -> None:
"""Accumulate a streaming token into the open response."""
if self._open_response is None:
self._open_response = token
else:
self._open_response += token
self._cache = None

def end_response(self) -> None:
"""Close the open response so the next stream_token starts a fresh one."""
self._close_open_response()

# NOTE: begin_response is a no-op here; the waiting indicator is wired in branch 4/5.
def begin_response(self) -> None:
return

# NOTE: turn_finished is a no-op here; post-turn stats live in the status bar (branch 4).
def turn_finished(self, stats: TurnStats) -> None:
return

# NOTE: stream_thinking is a no-op here; the reasoning readout is wired in branch 5.
def stream_thinking(self, text: str) -> None:
return

def show_status(self, text: str) -> None:
self._add_renderable(Text(_sanitize_line(text), style="sp.dim"))

def show_error(self, text: str) -> None:
self._add_renderable(Text(_sanitize_line(text), style="sp.error"))

def show_tool_call(self, name: str, arguments: dict[str, object]) -> None:
# Redact secrets in the summary line so auto-approved tool calls never
# expose credentials in the visible pane channel. A `path` argument is
# shown as its resolved, workspace-relative target (the SAME resolution
# the tool acts on) so the displayed path cannot be spoofed and matches
# the file actually touched (design section 14.5).
redacted = redact_structure(arguments)
assert isinstance(redacted, dict)
summary = ", ".join(
f"{key}={self._tool_call_value(key, value)}" for key, value in redacted.items()
)
if len(summary) > 80:
summary = summary[:79] + self._glyphs.ellipsis
self._add_renderable(render_tool_call(name, summary, self._glyphs))

def _tool_call_value(self, key: str, value: object) -> str:
# A `path` argument is shown as its resolved, workspace-relative target
# (display-integrity, design section 14.5). Without a workspace the value
# renders verbatim — matching TerminalUI._tool_call_value exactly.
if key == "path" and isinstance(value, str) and self._workspace is not None:
return repr(workspace_display(self._workspace, value))
return repr(value)

def show_tool_result(self, name: str, success: bool, summary: str) -> None:
self._add_renderable(render_tool_result(success, summary, self._glyphs))

def show_command_output(self, line: str) -> None:
# " " prefix + control-char sanitization + dim, no markup, no highlight —
# mirroring TerminalUI (markup=False/highlight=False are implied by Text).
self._add_renderable(Text(" " + _sanitize_line(line), style="sp.dim"))

def show_plan_progress(self, plan: TaskPlan) -> None:
# Uses plan_step_line (not plan_panel) with 2-cell left indent, then a blank
# line — mirroring TerminalUI.show_plan_progress exactly.
self._close_open_response()
for index, step in enumerate(plan.steps, 1):
self._renderables.append(
Padding(plan_step_line(index, step, self._glyphs), (0, 0, 0, 2))
)
self._renderables.append(Text("")) # blank line separator
self._cache = None

# ------------------------------------------------------------------
# Approval stubs — raise until branch 7 wires the focus-swap
# ------------------------------------------------------------------

def ask_approval(self, request: ApprovalRequest) -> ApprovalReply:
raise NotImplementedError("approval focus-swap is wired in branch 7")

def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]:
raise NotImplementedError("approval focus-swap is wired in branch 7")
Loading