Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2b8d2af
fix(policy): gate out-of-workspace reads by run_command reader execs
lavindeep Jun 20, 2026
8b61522
fix(policy): fail closed on non-benign git globals
lavindeep Jun 20, 2026
155064b
fix(policy): require approval for pytest execution
lavindeep Jun 20, 2026
bcc4e0b
fix(policy): distrust path-qualified executable names
lavindeep Jun 20, 2026
228a798
fix(policy): cover git deletion and force aliases
lavindeep Jun 20, 2026
5ba4f88
fix(cli): sanitize model-controlled terminal output
lavindeep Jun 20, 2026
67d3b4d
test(cli): make fenced-markdown no-regression check deterministic
lavindeep Jun 20, 2026
fde832f
fix(persistence): create audit log and session files mode 0600
lavindeep Jun 20, 2026
2441fb5
fix(config): make egress and security-posture keys config-file-only
lavindeep Jun 20, 2026
8f44d05
fix(cli): sanitize model-controlled plan goal and step titles
lavindeep Jun 20, 2026
eb84e54
fix(web): block DNS rebinding by resolving and validating hostnames
lavindeep Jun 20, 2026
af1b032
feat(security): gate project AGENTS.md behind trust-on-first-use
lavindeep Jun 20, 2026
d411aa0
feat(privacy): redact and audit egress at the model and web chokepoints
lavindeep Jun 20, 2026
6ad63a8
feat(cloud): opt-in cloud models behind a fail-closed egress-consent …
lavindeep Jun 21, 2026
3f317f5
docs: document opt-in cloud models and correct the local-first framing
lavindeep Jun 21, 2026
01ef2b4
feat(skills): add opt-in debugging, verification, code-review, git-wo…
lavindeep Jun 21, 2026
d78e2bf
feat(cli): add boot banner renderer with fighter-jet logo
lavindeep Jun 21, 2026
a0ea154
feat(cli): use the panel boot banner; drop the old 2-line banner
lavindeep Jun 21, 2026
3f8d774
feat(cli): streamline the boot picker to a one-key last-model confirm
lavindeep Jun 21, 2026
decb5b6
feat(cli): add the persistent active-cloud locality indicator
lavindeep Jun 21, 2026
051325e
test(cli): guard one-key cloud-confirm reaches consent gate
lavindeep Jun 21, 2026
b118752
feat(cli): cap long approval diffs with a (+N more) footer
lavindeep Jun 21, 2026
be26e96
feat(cli): reveal approval diffs with a bounded scrolling animation
lavindeep Jun 21, 2026
e32af0c
docs(readme): v0.10.0 — cloud opt-in, workflow skills, honesty condit…
lavindeep Jun 21, 2026
eb633db
docs(design): correct section 15.1 egress-chokepoint scope to convers…
lavindeep Jun 21, 2026
4b76078
docs(design): v0.10.0 final pass + security-hardening section
lavindeep Jun 21, 2026
9c6fc47
fix(model): detect :cloud-tagged Ollama models as cloud egress
lavindeep Jun 21, 2026
bbf266b
chore(release): v0.10.0
lavindeep Jun 21, 2026
e78286c
feat(cli): redesign boot banner — sectioned omp-style layout, fix jet…
lavindeep Jun 21, 2026
ca1a273
feat(cli): wire ! as a manual-shell escape
lavindeep Jun 21, 2026
051989b
fix(cli): sanitize recent-session labels in the boot banner
lavindeep Jun 21, 2026
d924538
feat(config): allow confirmed runtime /config set for egress keys; ke…
lavindeep Jun 21, 2026
c1c8c82
fix(config): /config set security_profile applies this session, not next
lavindeep Jun 21, 2026
d6839ee
refactor(cli): drop /quit (keep /exit); hoist config.model imports
lavindeep Jun 21, 2026
da21b43
refactor(cli): retire /prefs into /memory; demote /config reset <key>…
lavindeep Jun 21, 2026
0664800
docs: correct user-facing claims the v0.10.0 hardening invalidated
lavindeep Jun 21, 2026
36a42a5
fix(cli): render changed diff lines as full-width red/green bars
lavindeep Jun 21, 2026
25c87e6
feat(cli): /config edit writes a starter config.toml when none exists
lavindeep Jun 21, 2026
62d1b68
fix(security): show resolved action path in approvals, not the raw mo…
lavindeep Jun 21, 2026
c1b17fc
feat(approval): add reject-and-steer [e]dit option to tool/command ap…
lavindeep Jun 21, 2026
792118f
feat(cli): persistent status bar (V1) folding in cloud indicator + co…
lavindeep Jun 21, 2026
d16bf70
docs: pre-release accuracy pass for v0.10.0
lavindeep Jun 25, 2026
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
64 changes: 42 additions & 22 deletions README.md

Large diffs are not rendered by default.

293 changes: 257 additions & 36 deletions docs/DESIGN.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "shellpilot"
version = "0.9.0"
version = "0.10.0"
description = "A local-first AI shell harness for your terminal, powered by Ollama."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion shellpilot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ShellPilot: a local-first AI shell harness."""

__version__ = "0.9.0"
__version__ = "0.10.0"
263 changes: 263 additions & 0 deletions shellpilot/cli/banner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
"""Boot-banner renderer for ShellPilot.

Public API:
render_banner(model, *, is_cloud, profile, skills=(), recent_sessions=()) -> Panel
"""

from __future__ import annotations

from collections.abc import Sequence

from rich.align import Align
from rich.box import ROUNDED, Box
from rich.console import Group, RenderableType
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

from shellpilot import __version__
from shellpilot.cli.render import _sanitize_line

# ---------------------------------------------------------------------------
# Jet art — locked v2 block art, embedded verbatim (do NOT read from disk).
# Each row is 28 chars wide; trailing spaces are significant.
# ---------------------------------------------------------------------------

_JET_BLOCKS = (
" ██ ",
" ▄██▄ ",
" ████ ",
" ████ ",
" ▄████▄ ",
" ████████ ",
" ████████ ",
" ██████████ ",
" ▄██████████▄ ",
" ▄████████████████▄ ",
" ▄████████████████████▄ ",
" ▄████████████████████████▄ ",
"████████████████████████████",
" ▀▀██████████████████████▀▀ ",
" ▀▀██████████████▀▀ ",
" ▄██████████████▄ ",
" ██████▀ ▀▀ ▀██████ ",
" ▀▀██▀▀ ▀▀██▀▀ ",
)

# Parallel class grid: '.' = empty, 'b' = body, 'c' = cockpit.
_JET_CELLS = (
".............bb.............",
"............bbbb............",
"............bccb............",
"............bccb............",
"...........bbccbb...........",
"..........bbbbbbbb..........",
"..........bbbbbbbb..........",
".........bbbbbbbbbb.........",
"........bbbbbbbbbbbb........",
".....bbbbbbbbbbbbbbbbbb.....",
"...bbbbbbbbbbbbbbbbbbbbbb...",
".bbbbbbbbbbbbbbbbbbbbbbbbbb.",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbb",
".bbbbbbbbbbbbbbbbbbbbbbbbbb.",
".....bbbbbbbbbbbbbbbbbb.....",
"......bbbbbbbbbbbbbbbb......",
".....bbbbbbb.bb.bbbbbbb.....",
".....bbbbbb......bbbbbb.....",
)

_JET_WIDTH = len(_JET_BLOCKS[0]) # 28 — every row is this wide.

# Theme color constants (aligned with shellpilot/cli/theme.py).
_COLOR_ACCENT = "#98c379" # green — local model
_COLOR_WARN = "#e5c07b" # amber — cloud model
_COLOR_CYAN = "#7fb3c8" # section headers
_COLOR_DIM = "#6b6b6b" # sp.dim — item text
_COLOR_FAINT = "#444444" # sp.faint — hints / rules
_COLOR_COCKPIT = "#080808" # near-black for cockpit cells
_GRADIENT_TOP = (0x7C, 0x7C, 0x7C) # #7c7c7c — nose
_GRADIENT_BOT = (0x3A, 0x3A, 0x3A) # #3a3a3a — tail

# Available built-in workflow skills shown (dim) as the enable hint when none
# are enabled. These are real builtins under skills/builtin/.
_AVAILABLE_WORKFLOW_SKILLS = ("debugging", "verification", "code-review", "git-workflow")

# Box drawing ONLY the inner vertical divider between the two columns — no
# edge, no top/bottom caps, no horizontal section rules. The third char of each
# line is the column divider (see rich.box format); everything else is blank so
# the divider spans the full content height as a clean `│`.
_DIVIDER_BOX = Box(
" \n" # top
" │ \n" # head
" \n" # head_row
" │ \n" # mid
" \n" # row
" \n" # foot_row
" │ \n" # foot
" \n" # bottom
)


def _lerp_hex(top: tuple[int, int, int], bot: tuple[int, int, int], t: float) -> str:
r = round(top[0] + (bot[0] - top[0]) * t)
g = round(top[1] + (bot[1] - top[1]) * t)
b = round(top[2] + (bot[2] - top[2]) * t)
return f"#{r:02x}{g:02x}{b:02x}"


def _build_jet() -> Text:
"""The block-art jet as one fixed-width, left-aligned Text block.

Every row is exactly ``_JET_WIDTH`` cells of real characters (empty cells
are literal spaces, never trimmed). Rendered as a left-aligned block so the
containing grid column centers the whole jet as a unit — NOT per line via
``justify="center"``, which Rich would skew by stripping trailing spaces.
"""
num_rows = len(_JET_BLOCKS)
jet = Text(no_wrap=True, justify="left")
for row_idx, (glyph_row, cell_row) in enumerate(zip(_JET_BLOCKS, _JET_CELLS, strict=True)):
t_frac = row_idx / max(num_rows - 1, 1)
body_color = _lerp_hex(_GRADIENT_TOP, _GRADIENT_BOT, t_frac)
for glyph, cell_class in zip(glyph_row, cell_row, strict=True):
if cell_class == "c":
jet.append(glyph, style=f"bold {_COLOR_COCKPIT}")
elif cell_class == "b":
jet.append(glyph, style=body_color)
else:
jet.append(" ")
if row_idx < num_rows - 1:
jet.append("\n")
return jet


def _build_left_col(model: str, *, is_cloud: bool, profile: str) -> Group:
model_style = f"bold {_COLOR_WARN if is_cloud else _COLOR_ACCENT}"
locality = "cloud" if is_cloud else "local"
# The jet is centered as a fixed-width BLOCK via Align (width=_JET_WIDTH),
# not per line — per-line centering skews because Rich strips trailing
# spaces, leaving narrow rows lopsided in a live terminal.
return Group(
Text("Welcome back, pilot", style="bold", justify="center"),
Text(""),
Align.center(_build_jet(), width=_JET_WIDTH),
Text(""),
Text(model, style=model_style, justify="center"),
Text(f"{profile} · {locality}", style=_COLOR_DIM, justify="center"),
)


# Right-column rule width: matches the widest section item so the horizontal
# rules stay bounded to the column rather than spanning the full panel.
_RULE_WIDTH = 40
_ITEM_PAD = 9 # left field for the bold lead token, e.g. "/help "


def _section(header: str, items: Sequence[tuple[str, str]]) -> Text:
t = Text()
t.append(header, style=f"bold {_COLOR_CYAN}")
for lead, desc in items:
t.append("\n")
t.append(f"{lead:<{_ITEM_PAD}}", style="bold")
t.append(desc, style=_COLOR_DIM)
return t


def _rule() -> Text:
return Text("─" * _RULE_WIDTH, style=_COLOR_FAINT)


def _workflow_section(skills: Sequence[str]) -> Text:
t = Text()
t.append("Workflow skills", style=f"bold {_COLOR_CYAN}")
t.append("\n")
if skills:
t.append(" · ".join(skills), style=_COLOR_DIM)
else:
t.append(" · ".join(_AVAILABLE_WORKFLOW_SKILLS), style=_COLOR_DIM)
t.append("\n")
t.append("/skills to enable", style=_COLOR_FAINT)
return t


def _recent_section(recent_sessions: Sequence[tuple[str, str]]) -> Text:
t = Text()
t.append("Recent sessions", style=f"bold {_COLOR_CYAN}")
for label, age in recent_sessions:
t.append("\n")
t.append("● ", style=_COLOR_DIM)
# The label is a snippet of a past session's first USER message — untrusted,
# possibly-pasted input. Strip control/ANSI bytes at this render sink so a
# stored escape sequence cannot repaint the terminal on boot (Group B).
t.append(_sanitize_line(label))
t.append(f" ({age})", style=_COLOR_DIM)
return t


def _build_right_col(skills: Sequence[str], recent_sessions: Sequence[tuple[str, str]]) -> Group:
blocks: list[RenderableType] = [
_section(
"Commands",
(
("/help", "shortcuts & commands"),
("/plan", "propose a plan"),
("/skills", "enable workflow skills"),
("/status", "session & locality"),
),
),
_rule(),
_section(
"Tips",
(
("/", "for slash commands"),
("!", "to run a shell command"),
('"run"', "to confirm a high-risk command"),
),
),
_rule(),
_workflow_section(skills),
]
if recent_sessions:
blocks.append(_rule())
blocks.append(_recent_section(recent_sessions))
return Group(*blocks)


def render_banner(
model: str,
*,
is_cloud: bool,
profile: str,
skills: Sequence[str] = (),
recent_sessions: Sequence[tuple[str, str]] = (),
) -> Panel:
"""Return a rich Panel for the boot banner.

Args:
model: The active model name (left column; green local / amber cloud).
is_cloud: When True, the model name is styled amber and the sub-line
reads "cloud"; otherwise green and "local".
profile: Security profile name, shown in the dim sub-line.
skills: Enabled workflow-skill names. When empty, the Workflow skills
section shows the available builtins plus an enable hint.
recent_sessions: (label, age) pairs, newest first. The whole Recent
sessions section is omitted when this is empty.

Returns:
A bounded-width (expand=False) rich Panel ready to print.
"""
grid = Table(box=_DIVIDER_BOX, show_header=False, pad_edge=False, padding=(0, 3))
grid.add_column(justify="center", vertical="middle")
grid.add_column(justify="left", vertical="top")
grid.add_row(
_build_left_col(model, is_cloud=is_cloud, profile=profile),
_build_right_col(skills, recent_sessions),
)
return Panel(
grid,
box=ROUNDED,
title=f"ShellPilot v{__version__}",
title_align="left",
border_style="grey50",
padding=(1, 2),
expand=False,
)
37 changes: 28 additions & 9 deletions shellpilot/cli/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,37 @@
from rich.console import Console

from shellpilot.cli.render import context_line
from shellpilot.cli.status_bar import COLOR_ACCENT, COLOR_WARN, status_bar
from shellpilot.cli.theme import Glyphs

PT_STYLE = Style.from_dict(
{
"context": "#6b6b6b",
"chevron": "#98c379 bold",
"chevron": f"{COLOR_ACCENT} bold",
"chevron.cloud": f"{COLOR_WARN} bold",
# The status bar supplies its own per-fragment colors, so clear
# prompt_toolkit's default reverse-video bottom-toolbar styling and let
# the bar render on the terminal background (instrument-minimal, §31).
"bottom-toolbar": "noreverse",
"bottom-toolbar.text": "noreverse",
}
)


@dataclass(frozen=True)
class PromptContext:
"""What the dim context line shows above the chevron."""
"""What the persistent status bar shows below the chevron.

The locality fields (``is_cloud`` from the real ``is_egressing`` signal,
plus context utilization) feed the unspoofable active-cloud indicator and
the always-on context readout (design section 15.2 / 32).
"""

workspace: Path
model: str
profile: str
is_cloud: bool = False
ctx_pct: int = 0


class InputProvider(Protocol):
Expand Down Expand Up @@ -78,14 +92,19 @@ def __init__(self, history_file: Path, commands: Sequence[str], glyphs: Glyphs)
)

def read(self, context: PromptContext) -> str:
ctx = context_line(context.workspace, context.model, context.profile).plain
prompt = FormattedText(
[
("class:context", ctx + "\n"),
("class:chevron", f"{self._glyphs.chevron} "),
]
# The persistent status bar (dir · model · profile · locality + ctx%)
# rides the bottom toolbar so it stays pinned and refreshes each render.
# The chevron goes amber when egressing — a second at-a-glance cloud cue.
chevron_class = "class:chevron.cloud" if context.is_cloud else "class:chevron"
prompt = FormattedText([(chevron_class, f"{self._glyphs.chevron} ")])
bar = status_bar(
workspace=context.workspace,
model=context.model,
profile=context.profile,
is_cloud=context.is_cloud,
ctx_pct=context.ctx_pct,
)
return self._session.prompt(prompt).strip()
return self._session.prompt(prompt, bottom_toolbar=bar).strip()


class PlainInput:
Expand Down
15 changes: 15 additions & 0 deletions shellpilot/cli/model_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ def resolve_preselect(
return config_default


def confirm_last_model(console: Console, last_model: str) -> bool:
"""Compact "fly the last model or open the menu" prompt.

Returns True to use *last_model* as-is, False to open the full picker.
Empty input (Enter) / EOF / KeyboardInterrupt -> True (fly); any non-empty
input -> False (open the menu).
"""
console.print(f"[sp.emph]✈ Last flight:[/sp.emph] [sp.emph]{escape(last_model)}[/sp.emph]")
prompt = "[sp.dim]Enter to fly · any other key for the menu[/sp.dim] "
try:
return console.input(prompt).strip() == ""
except (EOFError, KeyboardInterrupt):
return True


def choose_model(console: Console, models: list[LocalModel], preselect: str) -> str:
"""Print a numbered model list and prompt the user to pick one.

Expand Down
Loading