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
6 changes: 5 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2819,7 +2819,11 @@ A one-line status bar is pinned at the input as `prompt_toolkit`'s `bottom_toolb
- **Right:** `<pct>% ctx` — context utilization, color-coded **green** (`< 50`) → **amber** (`50–79`) → **red** (`>= 80`).
- **Cloud emphasis:** when egressing, the bar's separators carry a faint amber tint (the prototype's amber "wash" adapted to the terminal) so the whole line reads as cloud at a glance, distinct from a local (faint-grey) bar — alongside the amber model name, the `☁ CLOUD` locality, and the amber chevron.

`status_bar(...)` (`cli/status_bar.py`) is a **pure** builder mirroring `cli/banner.py`: it takes `workspace`/`model`/`profile`/`is_cloud`/`ctx_pct` and returns a `prompt_toolkit` `FormattedText`, with no I/O. Every field is sourced from runtime/session state (`runtime.status()`), never from model output. The `is_cloud` field is the caller's real `is_egressing(runtime.model, base_url)` value (recomputed each REPL iteration on the live model), keeping the active-cloud indicator unspoofable and harness-rendered (§15.2). `ctx_pct` reuses the runtime's own utilization formula via `ctx_percent(used, total)`. The workspace path is user-controlled, so it is run through the shared `_sanitize_line` control/ANSI strip before render (§36.2) — a crafted directory name cannot repaint the bar. The bar consolidates two readouts that previously printed separately each loop: the cloud indicator (was a bold-amber header line) and context utilization (was a per-turn stats line, §31.8).
`status_bar(...)` (`cli/status_bar.py`) is a **pure** builder mirroring `cli/banner.py`: it takes `workspace`/`model`/`profile`/`is_cloud`/`ctx_pct` and returns a `prompt_toolkit` `FormattedText`, with no I/O. Every field is sourced from runtime/session state (`runtime.status()`), never from model output. The `is_cloud` field is the caller's real `is_egressing(runtime.model, base_url)` value (recomputed each REPL iteration on the live model), keeping the active-cloud indicator unspoofable and harness-rendered (§15.2). `ctx_pct` reuses the runtime's own utilization formula via `ctx_percent(used, total)`. The workspace path is user-controlled, so it is run through the shared `_sanitize_line` control/ANSI strip before render (§36.2) — a crafted directory name cannot repaint the bar. The bar consolidates two readouts that previously printed separately each loop: the cloud indicator (was a bold-amber header line) and context utilization (was a per-turn stats line, §31.8). An optional `branch` segment (`⎇ <branch>`, ASCII `git: <branch>`) inserts after `profile` when the host supplies one; it is sanitized like the path, and omitting it leaves the toolbar caller's output byte-identical.

### 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.

## 32. Model Selection And Preload

Expand Down
285 changes: 285 additions & 0 deletions shellpilot/cli/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
"""Full-screen TUI app shell (UI v2 — design section 31).

The interactive REPL is moving from a per-turn ``prompt`` (which scrolls the
status bar and input away mid-turn) to a persistent full-screen
``prompt_toolkit`` ``Application`` on the alternate screen, so the status bar
and the input dock never vanish. This module is the **inert app shell**: it
builds the layout — a scrolling chat pane, a custom rounded multi-line input
dock, and the persistent status bar — with no runtime, model, or AI turn wired
in. Submitting echoes the dock text into the pane and clears the dock; ``/exit``
quits. Later branches wire the conversation, render Rich content in the pane,
add the thinking indicator, and handle Ctrl-C turn cancellation.

The dock border is drawn by hand (rounded corners, ASCII fallback) rather than
with ``prompt_toolkit``'s ``Frame``: ``Frame`` only draws square corners and
reserves completion-menu height inside the box, so it fills the terminal height
instead of hugging the input — this section is the one deliberate exception to
the §31.9 "borders come from rich primitives" contract, because the dock lives
in a ``prompt_toolkit`` layout, not a Rich render.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path

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.input import Input
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.output import Output

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,
COLOR_FAINT,
UNICODE_GLYPHS,
Glyphs,
)

# The dock grows to fit multi-line input up to this many rows, then scrolls
# internally. NOTE: a fixed cap — a per-terminal-height fraction would be nicer
# on a very short terminal, but a flat cap is enough until the live wiring
# (branch 4) shows whether it bites.
DOCK_MAX_ROWS = 10

# Idle Ctrl-C hint. NOTE: there is no turn to cancel in this inert shell; branch
# 6 replaces this with real turn cancellation / subprocess kill.
_IDLE_HINT = "(idle — type /exit to quit)"


@dataclass(frozen=True)
class BoxChars:
"""Border glyphs for the rounded input dock; the ASCII set degrades them."""

top_left: str
top_right: str
bottom_left: str
bottom_right: str
horizontal: str
vertical: str


UNICODE_BOX = BoxChars("╭", "╮", "╰", "╯", "─", "│")
ASCII_BOX = BoxChars("+", "+", "+", "+", "-", "|")


def horizontal_border(width: int, box: BoxChars, *, top: bool) -> str:
"""A horizontal dock border line of exactly ``width`` cells.

Pure function of ``width`` (and the glyph set): ``╭───╮`` for the top row,
``╰───╯`` for the bottom. The border, the pane wrap width, and the terminal
width are one shared value (see :func:`build_app`), so this is rebuilt per
render from the live terminal width and nothing caches a stale size.
"""
if width < 2:
return box.horizontal * max(0, width)
left = box.top_left if top else box.bottom_left
right = box.top_right if top else box.bottom_right
return left + box.horizontal * (width - 2) + right


def _read_git_branch(workspace: Path) -> str | None:
"""Current git branch from ``<workspace>/.git/HEAD``, or None.

Pure (one ``read_text``, no other I/O). Fails closed to None on every
non-branch case: not a repo (no ``.git``), a worktree (``.git`` is a plain
file → descending into it raises ``NotADirectoryError`` ⊂ ``OSError``),
permission errors, and a detached HEAD (a bare SHA with no ``ref:`` prefix).
"""
try:
head = (workspace / ".git" / "HEAD").read_text(encoding="utf-8", errors="replace")
except OSError:
return None
# First line only + length cap: a real HEAD is one short ref line, but a
# crafted clone's HEAD must not inject extra lines or unbounded text into the
# status-bar dock that hosts the (unspoofable) cloud indicator.
content = head.splitlines()[0].strip() if head else ""
prefix = "ref: refs/heads/"
if content.startswith(prefix):
return content[len(prefix) :][:128] or None
return None


def _scroll_pane(window: Window, direction: int) -> None:
"""Scroll the (unfocused) chat pane one page, clamped to its content.

The dock holds focus, so PageUp/PageDown and the wheel can't fall through to
the pane on their own — this nudges the pane window's own vertical scroll
using the public ``WindowRenderInfo`` heights, so wrapping is accounted for
and we never scroll past the ends.
"""
info = window.render_info
if info is None:
return
page = max(1, info.window_height - 1)
max_scroll = max(0, info.content_height - info.window_height)
window.vertical_scroll = max(0, min(max_scroll, window.vertical_scroll + direction * page))


def build_app(
*,
workspace: Path,
model: str,
profile: str,
glyphs: Glyphs,
commands: Sequence[str],
is_cloud: bool = False,
ctx_pct: int = 0,
input: Input | None = None,
output: Output | None = None,
) -> Application[None]:
"""Build the inert full-screen app shell.

``input``/``output`` default to the real terminal; tests inject a pipe input
and ``DummyOutput`` to drive the shell headlessly. The shell is standalone —
branch 4 wires it into the REPL; the non-TTY ``PlainInput`` path is untouched.
"""
# One unicode/ascii decision drives the border, the branch glyph, and the
# bar — recovered from the resolved glyph set the caller already probed
# (``resolve_glyphs``), so we don't re-probe the console encoding here.
unicode_mode = glyphs == UNICODE_GLYPHS
box = UNICODE_BOX if unicode_mode else ASCII_BOX
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)
pane_window = Window(
BufferControl(buffer=pane_buffer, focusable=False),
wrap_lines=True,
)

# Input dock: a focused, multi-line buffer with slash completion.
dock_buffer = Buffer(
name="dock",
multiline=True,
completer=SlashCompleter(commands),
complete_while_typing=True,
)
dock_focused = has_focus(dock_buffer)

def _dock_prefix(line_number: int, wrap_count: int) -> StyleAndTextTuples:
if line_number == 0 and wrap_count == 0:
return [(f"fg:{COLOR_ACCENT} bold", f"{glyphs.chevron} ")]
return [("", " ")]

dock_window = Window(
BufferControl(buffer=dock_buffer),
height=Dimension(min=1, max=DOCK_MAX_ROWS),
dont_extend_height=True,
wrap_lines=True,
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.
def _render() -> StyleAndTextTuples:
width = get_app().output.get_size().columns
return [(f"fg:{COLOR_FAINT}", horizontal_border(width, box, top=top))]

return _render

def _status() -> StyleAndTextTuples:
return list(
status_bar(
workspace=workspace,
model=model,
profile=profile,
is_cloud=is_cloud,
ctx_pct=ctx_pct,
branch=branch,
branch_glyph=branch_glyph,
)
)

def _bar() -> Window:
return Window(width=1, char=box.vertical, style=f"fg:{COLOR_FAINT}")

dock_row = VSplit(
[
_bar(),
Window(width=1, char=" "),
dock_window,
Window(width=1, char=" "),
_bar(),
]
)

body = HSplit(
[
pane_window,
Window(FormattedTextControl(_border(top=True)), height=1),
dock_row,
Window(FormattedTextControl(_border(top=False)), height=1),
Window(FormattedTextControl(_status), height=1),
]
)
root = FloatContainer(
content=body,
floats=[Float(content=CompletionsMenu(max_height=8, scroll_offset=1))],
)

kb = KeyBindings()

# Enter submits. NOTE: a pipe sends LF (``\n`` → ``c-j``) and a real terminal
# sends CR (``\r`` → ``enter``); both submit. A literal newline for multi-line
# input is Alt+Enter (``escape, enter``), the prompt_toolkit convention.
@kb.add("enter", filter=dock_focused)
@kb.add("c-j", filter=dock_focused)
def _submit(event: KeyPressEvent) -> None:
text = dock_buffer.text
if text.strip() == "/exit":
event.app.exit()
return
if text.strip():
_append_pane(text)
dock_buffer.reset()

@kb.add("escape", "enter", filter=dock_focused)
def _newline(event: KeyPressEvent) -> None:
dock_buffer.insert_text("\n")

@kb.add("pageup")
def _page_up(event: KeyPressEvent) -> None:
_scroll_pane(pane_window, -1)

@kb.add("pagedown")
def _page_down(event: KeyPressEvent) -> None:
_scroll_pane(pane_window, 1)

@kb.add("c-c")
def _interrupt(event: KeyPressEvent) -> None:
_append_pane(_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
# ``_mouse_handler`` scrolls it, regardless of which window holds focus.
return Application[None](
layout=Layout(root, focused_element=dock_window),
key_bindings=kb,
full_screen=True,
mouse_support=True,
input=input,
output=output,
)
20 changes: 18 additions & 2 deletions shellpilot/cli/status_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ def status_bar(
is_cloud: bool,
ctx_pct: int,
home: Path | None = None,
branch: str | None = None,
branch_glyph: str = "⎇",
) -> FormattedText:
"""Build the persistent status-bar fragments for the input bottom toolbar.

Expand All @@ -80,6 +82,15 @@ def status_bar(
ctx_pct: Context utilization percent (see ``ctx_percent``); color-
coded green → amber → red as it fills.
home: Home directory for abbreviation (defaults to ``Path.home()``).
branch: Active git branch, inserted as a ``<glyph> <branch>`` segment
after ``profile`` when non-None. The branch name is user-
controlled (it comes from ``.git/HEAD``), so it is sanitized
like the other fields. ``None`` (the default) yields output
byte-identical to before this parameter existed, so the
existing bottom-toolbar caller is unaffected.
branch_glyph: Glyph prefixing the branch segment — ``⎇`` in unicode,
an ASCII fallback (e.g. ``git:``) otherwise. The caller drives
this off the same unicode/ascii decision as the dock border.

Returns:
A prompt_toolkit ``FormattedText`` (list of ``(style, text)`` fragments).
Expand Down Expand Up @@ -108,9 +119,14 @@ def status_bar(
(f"fg:{model_color}", model_text),
sep,
(f"fg:{COLOR_DIM}", profile_text),
sep,
locality,
]
if branch is not None:
# The branch name reaches the terminal, so strip control/ANSI bytes the
# same way the workspace path is sanitized above.
left.append(sep)
left.append((f"fg:{COLOR_DIM}", f"{branch_glyph} {_sanitize_line(branch)}"))
left.append(sep)
left.append(locality)

right: list[tuple[str, str]] = [
(f"fg:{_ctx_color(ctx_pct)}", f"{ctx_pct}%"),
Expand Down
Loading