Files
dasandClaude Opus 5.5 6564d1eb17
build / build (push) Canceled after 0s
Hardware fixes: issue version, installer Cyrillic, console TUIs (0.9.2)
- /etc/issue shows the Das OS version instead of the kernel's; the server
  installer carries it into the installed system.
- das-server-installer: setfont -C /dev/tty0 before the language menu
  (sudo runs it in its own pty, so the Cyrillic was drawn as Latin), and
  floppy drives are no longer offered as install targets.
- ncdu on the desktop image and in das-server 0.4.0.
- das-shell 0.2.0: each prompt line sets its own color; the dbanner start
  screen buttons are no longer covered by the key bar; on the Linux
  console (TERM=linux) dprompt and dbanner draw solid borders and swap
  the glyphs that no console font has (▊▎▔▁ and others) for ones it has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:21:47 +03:00

431 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)))
if p.text.endswith("\n"):
# Readline redraws only the last line of the prompt, so
# each line has to set its color again
current = None
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))