Terminal: Das prompt, dprompt and dbanner (0.9.1)
build / build (push) Successful in 38m26s

- das-shell 0.1.0: the Das two-line bash prompt by default (replaces the
  PS1 of Void's skel .bashrc once), dprompt (Textual prompt builder with
  presets and live preview) and dbanner (SSH login banner editor: canvas
  tools, fields with values, widgets, das-banner service for cached ones,
  Das OS logo and tryzub pictures). Python tests in make test.
- das-server 0.3.0 depends on das-shell; the server installer mentions
  dprompt and dbanner; the desktop image includes it.
- run-qemu.sh forwards the guest's SSH to localhost:2222.
- Hardware fixes: Ventoy menu icon, boot menu titles "Das OS <version>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-21 09:17:10 +03:00
co-authored by Claude Opus 5.5
parent 66ea614e0d
commit a2bffc7fe9
72 changed files with 5506 additions and 23 deletions
@@ -0,0 +1,426 @@
"""Bash prompt model of dprompt: segments in a frame, turned into PS1 code or a preview.
A design is JSON:
{"name": "das", "frame": "das", "frame_fg": "red", "open": "[", "close": "]",
"sep": "─", "segments": [{"type": "status"}, {"type": "userhost"}, ...]}
Static segments become PS1 escapes (\\u, \\w, \\D{...}). Conditional ones (exit
status, git branch, venv, jobs, ssh, command output) are computed by a function
that the PROMPT_COMMAND hook of /usr/lib/das/prompt-lib.sh calls before each
prompt; PS1 only reads their variables, so branch names are never evaluated as code.
"""
import copy
import datetime
import json
import os
from . import DATA_DIR, colors
from .i18n import t, title
FRAMES = ("das", "plain")
# type: (Ukrainian name, English name, default options)
SEGMENTS = {
"status": ("Код помилки", "Exit status", {"fg": "white", "mode": "mark", "mark": "✗"}),
"userhost": ("Користувач@хост", "User@host",
{"fg": "bright_white", "at_fg": "yellow", "at_bold": True,
"host_fg": "cyan", "host_bold": True, "root_fg": "red", "root_bold": True}),
"user": ("Користувач", "User", {"fg": "bright_white", "root_fg": "red", "root_bold": True}),
"host": ("Хост", "Host", {"fg": "cyan", "bold": True, "full": False}),
"cwd": ("Каталог", "Directory", {"fg": "bright_green", "mode": "full", "depth": 3}),
"date": ("Дата", "Date", {"fg": "white", "format": "%Y-%m-%d"}),
"time": ("Час", "Time", {"fg": "white", "seconds": False}),
"git": ("Гілка git", "Git branch", {"fg": "bright_magenta"}),
"venv": ("Python venv", "Python venv", {"fg": "bright_blue"}),
"jobs": ("Фонові задачі", "Background jobs", {"fg": "yellow", "prefix": "jobs:"}),
"ssh": ("Ознака SSH", "SSH mark", {"fg": "bright_red", "bold": True, "text": "ssh"}),
"text": ("Текст", "Text", {"fg": None, "text": ""}),
"cmd": ("Вивід команди", "Command output", {"fg": "white", "cmd": ""}),
"newline": ("Новий рядок", "New line", {}),
"symbol": ("Символ $/#", "Symbol $/#", {"fg": "yellow", "char": ""}),
}
# Glyphs missing from the Linux console font
CONSOLE_GLYPHS = {"╼": ">", "✗": "x", "❯": ">", "➜": ">", "λ": ">", "⚙": "*", "●": "*"}
VOID_PS1 = "[\\u@\\h \\W]\\$ "
def segment_name(seg_type):
uk, en, _ = SEGMENTS[seg_type]
return t(uk, en)
def new_segment(seg_type):
seg = {"type": seg_type}
seg.update(copy.deepcopy(SEGMENTS[seg_type][2]))
return seg
def normalize(design):
"""Fills in the defaults, so older or hand-written files keep working."""
d = copy.deepcopy(design)
d.setdefault("name", "custom")
d.setdefault("frame", "das")
if d["frame"] not in FRAMES:
d["frame"] = "das"
d.setdefault("frame_fg", "red")
d.setdefault("open", "[" if d["frame"] == "das" else "")
d.setdefault("close", "]" if d["frame"] == "das" else "")
d.setdefault("sep", "─" if d["frame"] == "das" else " ")
segs = []
for seg in d.get("segments", []):
if seg.get("type") not in SEGMENTS:
continue
full = new_segment(seg["type"])
full.update(seg)
segs.append(full)
d["segments"] = segs
return d
# --- presets and files ------------------------------------------------------
def preset_dir():
return os.path.join(DATA_DIR, "prompt", "presets")
def list_presets():
"""(name, title) of the shipped presets, "das" first."""
out = []
try:
names = sorted(f[:-5] for f in os.listdir(preset_dir()) if f.endswith(".json"))
except OSError:
names = []
for name in sorted(names, key=lambda n: (n != "das", n)):
try:
out.append((name, title(load_preset(name).get("title"), name)))
except (OSError, ValueError):
continue
return out
def load_preset(name):
return load_file(os.path.join(preset_dir(), name + ".json"))
def load_file(path):
with open(path, encoding="utf-8") as f:
return normalize(json.load(f))
def load(spec):
"""A preset name or a path to a design file."""
if os.sep in spec or spec.endswith(".json"):
return load_file(spec)
return load_preset(spec)
def dumps(design):
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
# --- pieces -------------------------------------------------------------------
# Lit: literal text; Esc: a PS1 escape such as \u; Var: a variable the hook sets;
# Dyn: pieces shown only when a condition holds. Style is (fg, bold) or None.
class Lit:
def __init__(self, text, style=None):
self.text, self.style = text, style
class Esc:
def __init__(self, code, style=None):
self.code, self.style = code, style
class Var:
def __init__(self, name, style=None):
self.name, self.style = name, style
class Dyn:
def __init__(self, cond, pieces, extra=None):
self.cond, self.pieces, self.extra = cond, pieces, extra or {}
def _st(seg, prefix=""):
return (seg.get(prefix + "fg"), bool(seg.get(prefix + "bold")))
def _content(seg, index, root):
"""(pieces, condition or None, bracketed?) for one segment."""
kind = seg["type"]
style = _st(seg)
if kind == "status":
inner = [Var("rc", style)] if seg.get("mode") == "code" else [Lit(seg.get("mark") or "✗", style)]
return inner, "error", True
if kind == "userhost":
user = _st(seg, "root_") if root else style
return [Esc("\\u", user), Lit("@", _st(seg, "at_")), Esc("\\h", _st(seg, "host_"))], None, True
if kind == "user":
return [Esc("\\u", _st(seg, "root_") if root else style)], None, True
if kind == "host":
return [Esc("\\H" if seg.get("full") else "\\h", style)], None, True
if kind == "cwd":
return [Esc("\\W" if seg.get("mode") == "base" else "\\w", style)], None, True
if kind == "date":
return [Esc("\\D{%s}" % (seg.get("format") or "%Y-%m-%d"), style)], None, True
if kind == "time":
return [Esc("\\t" if seg.get("seconds") else "\\A", style)], None, True
if kind == "git":
return [Var("git", style)], "git", True
if kind == "venv":
return [Var("venv", style)], "venv", True
if kind == "jobs":
return [Lit(seg.get("prefix") or "", style), Var("jobs", style)], "jobs", True
if kind == "ssh":
return [Lit(seg.get("text") or "ssh", style)], "ssh", True
if kind == "text":
return [Lit(seg.get("text") or "", style)], None, bool(seg.get("bracket", False))
if kind == "cmd":
return [Var(f"cmd{index}", style)], f"cmd{index}", True
if kind == "symbol":
char = seg.get("char") or ""
return [Lit(char, style) if char else Esc("\\$", style)], None, False
raise ValueError(kind)
def pieces(design, root=False):
"""The prompt as a flat list of pieces."""
d = normalize(design)
frame = (d["frame_fg"], False)
lines = [[]]
for i, seg in enumerate(d["segments"]):
if seg["type"] == "newline":
lines.append([])
else:
lines[-1].append((i, seg))
out = []
for n, items in enumerate(lines):
if n:
out.append(Lit("\n"))
if d["frame"] == "das" and len(lines) > 1:
prefix = "┌─" if n == 0 else ("└─╼ " if n == len(lines) - 1 else "├─")
out.append(Lit(prefix, frame))
first_static = next((k for k, (i, s) in enumerate(items)
if _content(s, i, root)[1] is None), len(items))
for k, (i, seg) in enumerate(items):
content, cond, bracket = _content(seg, i, root)
is_symbol = seg["type"] == "symbol"
group = []
if bracket and d["open"]:
group.append(Lit(d["open"], frame))
group += content
if bracket and d["close"]:
group.append(Lit(d["close"], frame))
sep = Lit(" " if is_symbol else d["sep"], frame) if d["sep"] else None
if cond is not None:
# A hidden segment must take its separator with it
if k < first_static:
group = group + ([sep] if sep and k + 1 < len(items) else [])
elif sep:
group = [sep] + group
out.append(Dyn(cond, group, {"cmd": seg.get("cmd", "")}))
else:
if k > first_static and sep:
out.append(sep)
out += group
out.append(Lit(" "))
return _merge(out)
def _merge(items):
"""Joins neighbouring literals of the same style."""
out = []
for p in items:
if isinstance(p, Lit) and out and isinstance(out[-1], Lit) and out[-1].style == p.style:
out[-1] = Lit(out[-1].text + p.text, p.style)
else:
out.append(p)
return out
# --- bash code ----------------------------------------------------------------
def _ps1_text(text):
return text.replace("\\", "\\\\").replace("$", "\\044").replace("`", "\\140")
def _sq(text):
return "'" + text.replace("'", "'\\''") + "'"
def _ansi_q(text):
return text.replace("\\", "\\\\").replace("'", "\\'")
class _Bash:
def __init__(self, color, console):
self.color, self.console = color, console
def glyphs(self, text):
if self.console:
for a, b in CONSOLE_GLYPHS.items():
text = text.replace(a, b)
return text
def ps1(self, items):
out, current = [], None
for p in items:
style = getattr(p, "style", None)
if self.color and not isinstance(p, Dyn) and style is not None and style != current:
out.append("\\[\\e[%sm\\]" % colors.sgr_params(style[0], None, style[1]))
current = style
if isinstance(p, Lit):
out.append(_ps1_text(self.glyphs(p.text)))
elif isinstance(p, Esc):
out.append(p.code)
elif isinstance(p, Dyn):
out.append("${__das_p_%s}" % p.cond)
current = None # the dynamic part leaves its own color behind
if self.color:
out.append("\\[\\e[0m\\]")
return "".join(out)
def value(self, items):
"""A bash word with the dynamic part: $'...' literals and "$var"s."""
out, current = [], None
for p in items:
style = getattr(p, "style", None)
if self.color and style is not None and style != current:
out.append("$'\\001\\e[%sm\\002'" % colors.sgr_params(style[0], None, style[1]))
current = style
if isinstance(p, Lit):
out.append("$'%s'" % _ansi_q(self.glyphs(p.text)))
elif isinstance(p, Var):
out.append('"$__das_%s"' % p.name)
return "".join(out) or "''"
def dynamic(self, items):
lines = []
for p in items:
if not isinstance(p, Dyn):
continue
var, val = "__das_p_" + p.cond, self.value(p.pieces)
test = {
"error": "[[ $__das_rc != 0 ]]",
"git": "__das_git_info",
"venv": "[[ -n ${VIRTUAL_ENV:-} ]] && __das_venv=${VIRTUAL_ENV##*/}",
"jobs": "__das_jobs_count",
"ssh": "[[ -n ${SSH_CONNECTION:-} ]]",
}.get(p.cond)
if test is None: # command output
lines.append(f" __das_{p.cond}=$({{ {p.extra['cmd']}; }} 2>/dev/null)")
test = f"[[ -n $__das_{p.cond} ]]"
lines.append(f" {var}=")
lines.append(f" {test} && {var}={val}")
return lines
def script(design):
"""The prompt.sh that /etc/bash/bashrc.d/das-prompt.sh sources."""
d = normalize(design)
out = [
f"# Das OS bash prompt \"{d['name']}\", made by dprompt: run dprompt to change it.",
"# shellcheck shell=bash disable=SC2016",
]
cwd = next((s for s in d["segments"] if s["type"] == "cwd"), None)
if cwd and cwd.get("mode") == "short":
out.append(f"PROMPT_DIRTRIM={int(cwd.get('depth') or 3)}")
else:
out.append("unset PROMPT_DIRTRIM")
if any(s["type"] == "venv" for s in d["segments"]):
out.append("VIRTUAL_ENV_DISABLE_PROMPT=1")
variants = [("[[ $TERM == linux ]]", True, True),
("__das_has_color", True, False),
(None, False, False)]
for n, (test, color, console) in enumerate(variants):
b = _Bash(color, console)
user, root = pieces(d, False), pieces(d, True)
head = "if" if n == 0 else ("elif" if test else "else")
out.append(f"{head} {test}; then" if test else "else")
body = b.dynamic(user)
out.append(" __das_prompt_dynamic() {")
out += [" " + line for line in body] or [" :"]
out.append(" }")
out.append(" if ((EUID == 0)); then")
out.append(" PS1=" + _sq(b.ps1(root)))
out.append(" else")
out.append(" PS1=" + _sq(b.ps1(user)))
out.append(" fi")
out.append("fi")
out.append("case $TERM in xterm* | rxvt* | alacritty | foot*) PS1='\\[\\e]0;\\u@\\h: \\w\\a\\]'$PS1 ;; esac")
out.append("__das_prompt_install")
return "\n".join(out) + "\n"
# --- preview ----------------------------------------------------------------------
def sample_state(**over):
now = datetime.datetime.now()
state = {"user": "das", "host": "dasos", "fqdn": "dasos.lan", "home": "/home/das",
"cwd": "/home/das/Projects/web", "rc": 0, "git": "", "venv": "", "jobs": 0,
"ssh": False, "root": False, "cmd": "…", "now": now}
state.update(over)
if state["root"]:
state["user"] = "root"
return state
def _esc_value(code, s):
if code == "\\u":
return s["user"]
if code == "\\h":
return s["host"]
if code == "\\H":
return s["fqdn"]
if code in ("\\w", "\\W"):
home = "/root" if s["root"] else s["home"]
cwd = s["cwd"] if not s["root"] else "/etc/sv"
short = "~" + cwd[len(home):] if cwd.startswith(home) else cwd
return short if code == "\\w" else (os.path.basename(cwd) or "/")
if code.startswith("\\D{"):
return s["now"].strftime(code[3:-1])
if code == "\\A":
return s["now"].strftime("%H:%M")
if code == "\\t":
return s["now"].strftime("%H:%M:%S")
if code == "\\$":
return "#" if s["root"] else "$"
return code
def _cond_holds(cond, s):
return {"error": s["rc"] != 0, "git": bool(s["git"]), "venv": bool(s["venv"]),
"jobs": s["jobs"] > 0, "ssh": s["ssh"]}.get(cond, bool(s["cmd"]))
def _var_value(name, s):
return {"rc": str(s["rc"]), "git": s["git"], "venv": s["venv"],
"jobs": str(s["jobs"])}.get(name, s["cmd"])
def preview(design, state):
"""[(text, rich style or "")] of the prompt as the user would see it."""
out = []
def walk(items):
for p in items:
style = colors.rich_style(p.style[0], None, p.style[1]) if getattr(p, "style", None) else ""
if isinstance(p, Lit):
out.append((p.text, style))
elif isinstance(p, Esc):
out.append((_esc_value(p.code, state), style))
elif isinstance(p, Var):
out.append((_var_value(p.name, state), style))
elif isinstance(p, Dyn) and _cond_holds(p.cond, state):
walk(p.pieces)
walk(pieces(design, state["root"]))
return out
def preview_text(design, state):
return "".join(text for text, _ in preview(design, state))