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,293 @@
"""dprompt editor: segments on the left, their settings on the right, a live preview below."""
import asyncio
import copy
import os
import tempfile
from rich.text import Text
from textual.app import App
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, Select, Static
from . import prompt
from .cli_prompt import apply as apply_design, current
from .i18n import t
from .tui_common import NONE, AskDialog, color_options, color_value, run_as_root, to_select
# Settings of each segment type: (key, kind, Ukrainian label, English label)
COLOR_KEYS = [("fg", "color", "Колір", "Color"), ("bold", "bool", "Жирний", "Bold")]
OPTIONS = {
"status": COLOR_KEYS + [("mode", "choice:mark=знак ✗|mark=mark ✗;code=код виходу|code=exit code", "Показ", "Shows"),
("mark", "text", "Знак", "Mark")],
"userhost": [("fg", "color", "Колір користувача", "User color"), ("bold", "bool", "Жирний", "Bold"),
("at_fg", "color", "Колір @", "@ color"), ("at_bold", "bool", "@ жирне", "Bold @"),
("host_fg", "color", "Колір хоста", "Host color"), ("host_bold", "bool", "Хост жирний", "Bold host"),
("root_fg", "color", "Колір для root", "Color for root"), ("root_bold", "bool", "root жирний", "Bold root")],
"user": COLOR_KEYS + [("root_fg", "color", "Колір для root", "Color for root"),
("root_bold", "bool", "root жирний", "Bold root")],
"host": COLOR_KEYS + [("full", "bool", "Повне ім'я (\\H)", "Full name (\\H)")],
"cwd": COLOR_KEYS + [("mode", "choice:full=повний шлях|full=full path;base=лише остання тека|base=last folder only;"
"short=останні N тек|short=last N folders", "Показ", "Shows"),
("depth", "text", "N для «останні N тек»", "N for \"last N folders\"")],
"date": COLOR_KEYS + [("format", "text", "Формат (strftime)", "Format (strftime)")],
"time": COLOR_KEYS + [("seconds", "bool", "Із секундами", "With seconds")],
"git": COLOR_KEYS,
"venv": COLOR_KEYS,
"jobs": COLOR_KEYS + [("prefix", "text", "Підпис перед числом", "Label before the number")],
"ssh": COLOR_KEYS + [("text", "text", "Текст", "Text")],
"text": COLOR_KEYS + [("text", "text", "Текст", "Text"), ("bracket", "bool", "У дужках", "In brackets")],
"cmd": COLOR_KEYS + [("cmd", "text", "Команда (виконується перед кожним запрошенням)",
"Command (runs before every prompt)")],
"newline": [],
"symbol": COLOR_KEYS + [("char", "text", "Символ (порожньо — $ або # для root)", "Symbol (empty: $, or # for root)")],
}
STATES = [
(("Звичайне", "Normal"), {}),
(("Після помилки, git, venv, задачі, SSH", "After an error, git, venv, jobs, SSH"),
{"rc": 1, "git": "main*", "venv": ".venv", "jobs": 1, "ssh": True, "cmd": "42"}),
(("root", "root"), {"root": True}),
]
def _choices(kind):
out = []
for item in kind[7:].split(";"):
uk, en = item.split("|")
value, uk_label = uk.split("=")
out.append((t(uk_label, en.split("=")[1]), value))
return out
class PromptApp(App):
TITLE = "dprompt"
SUB_TITLE = t("конструктор запрошення bash", "bash prompt builder")
CSS = """
#main { height: 1fr; }
#left { width: 40; border-right: solid $primary; padding: 0 1; }
#segments { height: 1fr; }
#props { padding: 0 2; }
#props Label { margin-top: 1; color: $text-muted; }
#preview { height: auto; max-height: 14; border-top: solid $primary; padding: 0 1; }
.buttons { height: auto; }
.buttons Button { min-width: 5; margin-right: 1; }
.h { text-style: bold; color: $accent; margin-top: 1; }
"""
BINDINGS = [
("ctrl+s", "apply_me", t("Застосувати для себе", "Apply for me")),
("ctrl+g", "apply_all", t("Для всіх (sudo)", "For everyone (sudo)")),
("ctrl+e", "export", t("Зберегти файл", "Save file")),
("ctrl+q", "quit", t("Вийти", "Quit")),
]
def __init__(self, design=None):
# The terminal's own 16 colors, as the prompt will use them
super().__init__(ansi_color=True)
self.design, self.source = (design, None) if design else current()
self.design = prompt.normalize(self.design)
self.props_lock = asyncio.Lock()
# --- layout ------------------------------------------------------------
def compose(self):
yield Header()
with Horizontal(id="main"):
with Vertical(id="left"):
yield Label(Text(t("Готові запрошення", "Ready-made prompts")), classes="h")
yield Select([(title, name) for name, title in prompt.list_presets()],
prompt=t("вибрати…", "choose…"), id="preset")
yield Label(Text(t("Сегменти (по порядку)", "Segments (in order)")), classes="h")
yield ListView(id="segments")
yield Select([(prompt.segment_name(k), k) for k in prompt.SEGMENTS],
prompt=t("+ додати сегмент", "+ add a segment"), id="add")
with Horizontal(classes="buttons"):
yield Button("↑", id="up")
yield Button("↓", id="down")
yield Button(t("Видалити", "Remove"), id="remove", variant="error")
yield VerticalScroll(id="props")
yield Static(id="preview")
yield Footer()
def on_mount(self):
self.refresh_segments(0)
# --- segments list -------------------------------------------------------
def refresh_segments(self, index=None):
lv = self.query_one("#segments", ListView)
lv.clear()
lv.append(ListItem(Label(Text("⚙ " + t("Рамка й кольори", "Frame and colors"), style="bold"))))
for seg in self.design["segments"]:
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
lv.append(ListItem(Label(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))))
if index is not None:
lv.index = index
self.call_after_refresh(self.show_props)
self.update_preview()
def selected(self):
"""Index of the chosen segment, or None for the frame row."""
i = self.query_one("#segments", ListView).index
return None if not i else i - 1
async def on_list_view_highlighted(self, event):
await self.show_props()
# --- properties ----------------------------------------------------------
async def show_props(self):
async with self.props_lock:
await self._show_props()
async def _show_props(self):
box = self.query_one("#props", VerticalScroll)
await box.remove_children()
i = self.selected()
widgets = []
if i is None:
d = self.design
widgets += [Label(Text(t("Вигляд рамки", "Frame"))),
Select([(t("Das: ┌─[…]─[…] / └─╼", "Das: ┌─[…]─[…] / └─╼"), "das"),
(t("Простий рядок", "Plain line"), "plain")],
value=d["frame"], allow_blank=False, id="p_frame"),
Label(Text(t("Колір рамки й дужок", "Frame and bracket color"))),
Select(color_options(), value=to_select(d["frame_fg"]), allow_blank=False, id="p_frame_fg"),
Label(Text(t("Ліва дужка", "Left bracket"))), Input(d["open"], id="p_open"),
Label(Text(t("Права дужка", "Right bracket"))), Input(d["close"], id="p_close"),
Label(Text(t("Між сегментами", "Between segments"))), Input(d["sep"], id="p_sep")]
else:
seg = self.design["segments"][i]
widgets.append(Label(Text(prompt.segment_name(seg["type"]), style="bold")))
if not OPTIONS[seg["type"]]:
widgets.append(Label(Text(t("Без налаштувань.", "Nothing to set."))))
for key, kind, uk, en in OPTIONS[seg["type"]]:
wid = "s_" + key
if kind == "bool":
widgets.append(Checkbox(t(uk, en), bool(seg.get(key)), id=wid))
continue
widgets.append(Label(Text(t(uk, en))))
if kind == "color":
widgets.append(Select(color_options(), value=to_select(seg.get(key)), allow_blank=False, id=wid))
elif kind.startswith("choice:"):
widgets.append(Select(_choices(kind), value=seg.get(key), allow_blank=False, id=wid))
else:
widgets.append(Input(str(seg.get(key) if seg.get(key) is not None else ""), id=wid))
for w in widgets:
# Events of these widgets belong to this segment, whatever is selected later
w.das_target = i
await box.mount(*widgets)
def _set(self, control, value):
if not hasattr(control, "das_target") or not control.is_attached:
return
i, wid = control.das_target, control.id
key = wid[2:]
if wid.startswith("p_"):
if key == "frame" and value != self.design["frame"]:
# Sensible brackets for the new frame
self.design.update({"das": {"open": "[", "close": "]", "sep": "─"},
"plain": {"open": "", "close": "", "sep": " "}}[value])
self.design["frame"] = value
self.call_later(self.show_props)
else:
self.design[key] = value
elif i is not None and i < len(self.design["segments"]):
seg = self.design["segments"][i]
if key == "depth":
value = int(value) if str(value).isdigit() else 3
seg[key] = value
if key in ("text", "cmd", "char"):
self.refresh_label(i)
self.update_preview()
def refresh_label(self, i):
seg = self.design["segments"][i]
item = self.query_one("#segments", ListView).children[i + 1]
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
item.query_one(Label).update(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))
def on_select_changed(self, event: Select.Changed):
wid = event.select.id or ""
if wid == "preset":
if event.value is not Select.NULL:
self.design = prompt.load_preset(event.value)
self.refresh_segments(0)
elif wid == "add":
if event.value is not Select.NULL:
i = self.selected()
pos = len(self.design["segments"]) if i is None else i + 1
self.design["segments"].insert(pos, prompt.new_segment(event.value))
event.select.clear()
self.refresh_segments(pos + 1)
elif wid.startswith(("p_", "s_")):
value = event.value
if wid.endswith("fg"):
value = color_value(value)
self._set(event.select, value)
def on_checkbox_changed(self, event: Checkbox.Changed):
self._set(event.checkbox, event.value)
def on_input_changed(self, event: Input.Changed):
if (event.input.id or "").startswith(("p_", "s_")):
self._set(event.input, event.value)
def on_button_pressed(self, event: Button.Pressed):
i = self.selected()
segs = self.design["segments"]
if i is None:
return
if event.button.id == "remove":
del segs[i]
self.refresh_segments(min(i + 1, len(segs)))
elif event.button.id == "up" and i > 0:
segs[i - 1], segs[i] = segs[i], segs[i - 1]
self.refresh_segments(i)
elif event.button.id == "down" and i + 1 < len(segs):
segs[i + 1], segs[i] = segs[i], segs[i + 1]
self.refresh_segments(i + 2)
# --- preview ---------------------------------------------------------------
def update_preview(self):
out = Text()
for n, ((uk, en), over) in enumerate(STATES):
if n:
out.append("\n")
out.append(t(uk, en) + ":\n", style="dim italic")
for text, style in prompt.preview(self.design, prompt.sample_state(**over)):
out.append(text, style=style or None)
out.append("ls", style="default")
self.query_one("#preview", Static).update(out)
# --- actions -----------------------------------------------------------------
def action_apply_me(self):
path = apply_design(self.design, system=False)
self.notify(t(f"Записано в {path}. Відкрийте новий термінал або exec bash.",
f"Written to {path}. Open a new terminal or run exec bash."), timeout=6)
def action_apply_all(self):
fd, path = tempfile.mkstemp(prefix="dprompt-", suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(prompt.dumps(self.design))
os.chmod(path, 0o644)
ok = run_as_root(self, ["dprompt", "apply", path, "--system"])
os.remove(path)
if ok:
self.notify(t("Запрошення для всіх записано в /etc/das/prompt.sh.",
"The prompt for everyone is written to /etc/das/prompt.sh."), timeout=6)
def action_export(self):
def done(result):
if not result:
return
path = os.path.expanduser(result["path"])
try:
with open(path, "w", encoding="utf-8") as f:
f.write(prompt.dumps(dict(copy.deepcopy(self.design), name=os.path.basename(path)[:-5])))
except OSError as e:
self.notify(str(e), severity="error")
return
self.notify(t(f"Збережено: {path}", f"Saved: {path}"))
self.push_screen(AskDialog(t("Зберегти запрошення у файл", "Save the prompt to a file"),
[("path", t("Файл (.json)", "File (.json)"), "~/my-prompt.json")]), done)
__all__ = ["PromptApp", "NONE"]