Initial build: scrawl, a shared ASCII canvas over SSH
ssh in and draw on a live, shared doodle wall -- everyone connected sees everyone else's edits in real time. Same wish+bubbletea security model as delve-term (no shell, no exec, structurally can't reach a real shell on the host), extended with the one thing delve-term didn't need: multiple sessions sharing live state. wish's bm.Middleware helper hides the *tea.Program it creates, so this builds the program directly instead, keeping a registry (game.Canvas) that broadcasts a redraw signal to every other connected session the instant one of them paints. Banner generated via `figlet -f slant SCRAWL` rather than hand-drawn -- slant felt right for a doodle/scribble tool. Two real bugs caught by actually running this with real SSH sessions, not just unit-testing the game logic in isolation: 1. Program.Send() blocks until that program's Run() event loop is actively reading from it. Broadcasting synchronously from inside Join()/Paint() (including a session broadcasting to its own, not-yet-running program on join) deadlocked every session before it ever reached Run() -- the very first connection just hung with nothing rendered. Fixed by sending asynchronously (go p.Send(...)) everywhere the canvas notifies sessions of a change. 2. Subtler: lipgloss's default package-level styles detect color support from the *server process's* os.Stdout, not any given session's actual terminal -- and a server's stdout is typically redirected (a log file, systemd journal), so every connected session silently lost all color/background styling at once, server-wide. Manifested as painting a cell your own cursor already sat on being invisible (the cursor glyph before/after looked identical, so bubbletea's diffing renderer correctly sent zero bytes for a change that produced no visual diff) -- confirmed via server-side debug logging that painting itself worked correctly every time, isolating the bug to rendering, then confirmed via a Go test that forcing a real color profile was the difference between 0 and 1327 runes of diff between two frames that should look different. Fixed with a lipgloss.Renderer created per-session, bound to that session's actual output, forced to TrueColor; cursors now highlight whatever's already painted at that cell (background tint) rather than replacing the character, so a session's own paint is never masked by its own cursor marker sitting on top of it. Verified end-to-end with two real, simultaneous SSH sessions (scripted via pexpect): peer count syncs correctly, and one session's paint genuinely arrives at the other via the live broadcast -- not just unit-tested in isolation. Also verified the actual Docker image builds and serves correctly over real SSH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
168
game/canvas.go
Normal file
168
game/canvas.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
const (
|
||||
Width = 76
|
||||
Height = 20
|
||||
)
|
||||
|
||||
type Cell struct {
|
||||
Char rune
|
||||
Color string // lipgloss-compatible ANSI color string, "" = unset
|
||||
}
|
||||
|
||||
// Cursor is another connected session's position, shown as a marker on
|
||||
// top of the canvas so drawing feels like a shared space, not a diff you
|
||||
// only notice after the fact.
|
||||
type Cursor struct {
|
||||
X, Y int
|
||||
Color string
|
||||
Label string
|
||||
}
|
||||
|
||||
// canvasUpdatedMsg is broadcast to every connected session's bubbletea
|
||||
// program whenever the shared state changes — cells or cursors — so each
|
||||
// client's own Update/View loop redraws without polling.
|
||||
type canvasUpdatedMsg struct{}
|
||||
|
||||
var cursorPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#00d2d3"}
|
||||
|
||||
// Canvas is the single shared drawing surface plus the registry of
|
||||
// connected sessions' bubbletea programs, used purely to fan a redraw
|
||||
// signal out to everyone whenever anything changes -- there's no other
|
||||
// use of the registry (no per-session logic reaches back in through it).
|
||||
type Canvas struct {
|
||||
mu sync.Mutex
|
||||
cells [Height][Width]Cell
|
||||
cursors map[string]*Cursor
|
||||
programs map[string]*tea.Program
|
||||
nextColorIdx int
|
||||
}
|
||||
|
||||
func NewCanvas() *Canvas {
|
||||
return &Canvas{
|
||||
cursors: make(map[string]*Cursor),
|
||||
programs: make(map[string]*tea.Program),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Canvas) Join(id string, label string, p *tea.Program) *Cursor {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
color := cursorPalette[c.nextColorIdx%len(cursorPalette)]
|
||||
c.nextColorIdx++
|
||||
cur := &Cursor{X: Width / 2, Y: Height / 2, Color: color, Label: label}
|
||||
c.cursors[id] = cur
|
||||
c.programs[id] = p
|
||||
c.broadcastLocked()
|
||||
return cur
|
||||
}
|
||||
|
||||
func (c *Canvas) Leave(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.cursors, id)
|
||||
delete(c.programs, id)
|
||||
c.broadcastLocked()
|
||||
}
|
||||
|
||||
func (c *Canvas) Paint(x, y int, char rune, color string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if x < 0 || x >= Width || y < 0 || y >= Height {
|
||||
return
|
||||
}
|
||||
c.cells[y][x] = Cell{Char: char, Color: color}
|
||||
c.broadcastLocked()
|
||||
}
|
||||
|
||||
func (c *Canvas) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cells = [Height][Width]Cell{}
|
||||
c.broadcastLocked()
|
||||
}
|
||||
|
||||
func (c *Canvas) MoveCursor(id string, dx, dy int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cur, ok := c.cursors[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cur.X = clamp(cur.X+dx, 0, Width-1)
|
||||
cur.Y = clamp(cur.Y+dy, 0, Height-1)
|
||||
c.broadcastLocked()
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the current cells and cursors, safe to read
|
||||
// from a View() call without holding the lock while rendering.
|
||||
func (c *Canvas) Snapshot() (cells [Height][Width]Cell, cursors map[string]Cursor) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cursors = make(map[string]Cursor, len(c.cursors))
|
||||
for id, cur := range c.cursors {
|
||||
cursors[id] = *cur
|
||||
}
|
||||
return c.cells, cursors
|
||||
}
|
||||
|
||||
// CursorsSnapshot is Snapshot() without paying for a cell-array copy when
|
||||
// only cursor positions are needed (the common case for input handling).
|
||||
func (c *Canvas) CursorsSnapshot() map[string]Cursor {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cursors := make(map[string]Cursor, len(c.cursors))
|
||||
for id, cur := range c.cursors {
|
||||
cursors[id] = *cur
|
||||
}
|
||||
return cursors
|
||||
}
|
||||
|
||||
func (c *Canvas) PeerCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.cursors)
|
||||
}
|
||||
|
||||
// broadcastLocked must be called with c.mu already held. Each Send runs in
|
||||
// its own goroutine rather than inline: a just-joined program's Run()
|
||||
// loop hasn't started reading its input channel yet at the moment Join()
|
||||
// calls this (Join registers the program, then broadcasts, before the
|
||||
// caller gets around to calling p.Run()) -- an inline, blocking Send to
|
||||
// that program would deadlock the whole session handler before it ever
|
||||
// reaches Run(). Confirmed by hitting exactly that deadlock in testing:
|
||||
// the very first session hung with nothing rendered.
|
||||
func (c *Canvas) broadcastLocked() {
|
||||
for _, p := range c.programs {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
go p.Send(canvasUpdatedMsg{})
|
||||
}
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func RandomID() string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 8)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
95
game/model.go
Normal file
95
game/model.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var inkPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#ffffff"}
|
||||
var charPalette = []rune{'█', '▓', '▒', '░', '#', '*', '.', '@', 'o', '+'}
|
||||
|
||||
type state int
|
||||
|
||||
const (
|
||||
stateIntro state = iota
|
||||
stateCanvas
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
canvas *Canvas
|
||||
id string
|
||||
label string
|
||||
state state
|
||||
renderer *lipgloss.Renderer
|
||||
|
||||
inkColorIdx int
|
||||
charIdx int
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
func NewModel(canvas *Canvas, id, label string, renderer *lipgloss.Renderer) Model {
|
||||
return Model{canvas: canvas, id: id, label: label, state: stateIntro, renderer: renderer}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m, nil
|
||||
|
||||
case canvasUpdatedMsg:
|
||||
// Someone (possibly this session) changed the shared state --
|
||||
// nothing to do here but re-render; View() always reads a fresh
|
||||
// snapshot from the canvas.
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == stateIntro {
|
||||
m.state = stateCanvas
|
||||
return m, nil
|
||||
}
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
|
||||
case "up", "k":
|
||||
m.canvas.MoveCursor(m.id, 0, -1)
|
||||
case "down", "j":
|
||||
m.canvas.MoveCursor(m.id, 0, 1)
|
||||
case "left", "h":
|
||||
m.canvas.MoveCursor(m.id, -1, 0)
|
||||
case "right", "l":
|
||||
m.canvas.MoveCursor(m.id, 1, 0)
|
||||
|
||||
case " ", "enter":
|
||||
if cur, ok := m.canvas.CursorsSnapshot()[m.id]; ok {
|
||||
m.canvas.Paint(cur.X, cur.Y, charPalette[m.charIdx], inkPalette[m.inkColorIdx])
|
||||
}
|
||||
|
||||
case "backspace", "delete":
|
||||
if cur, ok := m.canvas.CursorsSnapshot()[m.id]; ok {
|
||||
m.canvas.Paint(cur.X, cur.Y, ' ', "")
|
||||
}
|
||||
|
||||
case "[":
|
||||
m.charIdx = (m.charIdx - 1 + len(charPalette)) % len(charPalette)
|
||||
case "]":
|
||||
m.charIdx = (m.charIdx + 1) % len(charPalette)
|
||||
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8":
|
||||
m.inkColorIdx = int(msg.String()[0] - '1')
|
||||
|
||||
case "c":
|
||||
m.canvas.Clear()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
120
game/model_test.go
Normal file
120
game/model_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
func TestColorAndCharSwitching(t *testing.T) {
|
||||
c := NewCanvas()
|
||||
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||
c.Join("test-id", "tester", nil)
|
||||
|
||||
// First keypress just exits the intro screen, per Update()'s own logic.
|
||||
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
|
||||
m = mi.(Model)
|
||||
if m.state != stateCanvas {
|
||||
t.Fatalf("expected first keypress to exit intro, state=%v", m.state)
|
||||
}
|
||||
if m.inkColorIdx != 0 {
|
||||
t.Fatalf("expected inkColorIdx unchanged by the intro-exit keypress, got %d", m.inkColorIdx)
|
||||
}
|
||||
|
||||
// Now actually press "3" for color selection.
|
||||
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
|
||||
m = mi.(Model)
|
||||
if m.inkColorIdx != 2 {
|
||||
t.Fatalf("expected inkColorIdx=2 after pressing '3', got %d", m.inkColorIdx)
|
||||
}
|
||||
|
||||
// Paint with the selected color.
|
||||
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
m = mi.(Model)
|
||||
|
||||
cells, cursors := c.Snapshot()
|
||||
cur := cursors["test-id"]
|
||||
painted := cells[cur.Y][cur.X]
|
||||
if painted.Char != charPalette[0] {
|
||||
t.Fatalf("expected painted char to be default charPalette[0]=%q, got %q", charPalette[0], painted.Char)
|
||||
}
|
||||
if painted.Color != inkPalette[2] {
|
||||
t.Fatalf("expected painted color to be inkPalette[2]=%q (selected via '3'), got %q", inkPalette[2], painted.Color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharCycling(t *testing.T) {
|
||||
c := NewCanvas()
|
||||
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||
c.Join("test-id", "tester", nil)
|
||||
|
||||
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
|
||||
m = mi.(Model)
|
||||
|
||||
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("]")})
|
||||
m = mi.(Model)
|
||||
if m.charIdx != 1 {
|
||||
t.Fatalf("expected charIdx=1 after ']', got %d", m.charIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewChangesAfterMoveAwayFromPaint(t *testing.T) {
|
||||
c := NewCanvas()
|
||||
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||
c.Join("test-id", "tester", nil)
|
||||
|
||||
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
|
||||
m = mi.(Model)
|
||||
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // paint
|
||||
m = mi.(Model)
|
||||
|
||||
viewAtPaintPosition := m.View()
|
||||
|
||||
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right
|
||||
m = mi.(Model)
|
||||
|
||||
viewAfterMove := m.View()
|
||||
|
||||
if viewAtPaintPosition == viewAfterMove {
|
||||
t.Fatal("expected View() output to differ after moving away from a painted cell, but it's identical")
|
||||
}
|
||||
t.Logf("view at paint position:\n%s", viewAtPaintPosition)
|
||||
t.Logf("view after move:\n%s", viewAfterMove)
|
||||
}
|
||||
|
||||
func TestMoveCursorActuallyMoves(t *testing.T) {
|
||||
c := NewCanvas()
|
||||
m := NewModel(c, "test-id", "tester", newTestRenderer())
|
||||
c.Join("test-id", "tester", nil)
|
||||
|
||||
before := c.CursorsSnapshot()["test-id"]
|
||||
t.Logf("before: x=%d y=%d", before.X, before.Y)
|
||||
|
||||
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
|
||||
m = mi.(Model)
|
||||
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right
|
||||
m = mi.(Model)
|
||||
|
||||
after := c.CursorsSnapshot()["test-id"]
|
||||
t.Logf("after: x=%d y=%d", after.X, after.Y)
|
||||
|
||||
if after.X != before.X+1 {
|
||||
t.Fatalf("expected cursor X to move from %d to %d, got %d", before.X, before.X+1, after.X)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRenderer forces TrueColor rather than letting lipgloss
|
||||
// auto-detect from the writer (io.Discard, like any non-*os.File writer,
|
||||
// would otherwise be treated as non-color-capable) -- matches main.go's
|
||||
// real per-session renderer setup, so these tests actually exercise the
|
||||
// same styling path a real SSH session does instead of silently testing
|
||||
// an unstyled/plain-text code path that would mask exactly this class
|
||||
// of bug.
|
||||
func newTestRenderer() *lipgloss.Renderer {
|
||||
r := lipgloss.NewRenderer(io.Discard)
|
||||
r.SetColorProfile(termenv.TrueColor)
|
||||
return r
|
||||
}
|
||||
119
game/view.go
Normal file
119
game/view.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Generated via `figlet -f slant SCRAWL` — the slant felt right for a
|
||||
// doodle/scribble tool, not hand-tuned character by character.
|
||||
const banner = ` _____ __________ ___ _ ____
|
||||
/ ___// ____/ __ \/ | | / / /
|
||||
\__ \/ / / /_/ / /| | | /| / / /
|
||||
___/ / /___/ _, _/ ___ | |/ |/ / /___
|
||||
/____/\____/_/ |_/_/ |_|__/|__/_____/`
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.state == stateIntro {
|
||||
return m.renderIntro()
|
||||
}
|
||||
return m.renderCanvas()
|
||||
}
|
||||
|
||||
// style is a small accessor so every render call goes through m.renderer
|
||||
// (bound to this specific SSH session's actual output) rather than
|
||||
// lipgloss's package-level default styles, which detect color support
|
||||
// from the *server process's* os.Stdout -- not any given session's
|
||||
// terminal. That's a real bug this app hit: the server's stdout is
|
||||
// typically redirected (a log file, a systemd journal, anything that
|
||||
// isn't a TTY), so the default global renderer silently disabled all
|
||||
// color/background styling for every connected session at once,
|
||||
// regardless of what terminal they were actually using.
|
||||
func (m Model) style() lipgloss.Style {
|
||||
return m.renderer.NewStyle()
|
||||
}
|
||||
|
||||
func (m Model) renderIntro() string {
|
||||
title := m.style().Bold(true).Foreground(lipgloss.Color("#ff6b6b")).Render(banner)
|
||||
dim := m.style().Foreground(lipgloss.Color("#777777"))
|
||||
body := title + "\n" +
|
||||
dim.Render("a shared doodle wall, one keystroke at a time") + "\n\n" +
|
||||
"Everyone connected right now is drawing on the same canvas.\n" +
|
||||
"Move with arrow keys or hjkl, space/enter to paint, backspace to\n" +
|
||||
"erase. [ and ] cycle the ink character, 1-8 pick a color, c clears\n" +
|
||||
"everything for everyone (yes, really — it's a shared wall).\n\n" +
|
||||
dim.Render("press any key to start")
|
||||
return m.boxStyle().Render(body)
|
||||
}
|
||||
|
||||
func (m Model) boxStyle() lipgloss.Style {
|
||||
return m.style().Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#555555")).Padding(1, 2)
|
||||
}
|
||||
|
||||
func (m Model) renderCanvas() string {
|
||||
cells, cursors := m.canvas.Snapshot()
|
||||
|
||||
// Cursors are a background highlight on whatever's actually painted at
|
||||
// that cell, not a glyph that replaces it -- a glyph-replace bug meant
|
||||
// painting a cell your own cursor already sat on was invisible: the
|
||||
// cursor marker before and after painting rendered as the exact same
|
||||
// character in the exact same color, so bubbletea's diffing renderer
|
||||
// (correctly) sent zero bytes, since nothing had visually changed.
|
||||
type painted struct {
|
||||
ch rune
|
||||
color string
|
||||
bg string
|
||||
}
|
||||
grid := make([][]painted, Height)
|
||||
for y := 0; y < Height; y++ {
|
||||
grid[y] = make([]painted, Width)
|
||||
for x := 0; x < Width; x++ {
|
||||
c := cells[y][x]
|
||||
if c.Char == 0 {
|
||||
grid[y][x] = painted{ch: ' '}
|
||||
} else {
|
||||
grid[y][x] = painted{ch: c.Char, color: c.Color}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, cur := range cursors {
|
||||
if id == m.id {
|
||||
continue
|
||||
}
|
||||
grid[cur.Y][cur.X].bg = cur.Color
|
||||
}
|
||||
if self, ok := cursors[m.id]; ok {
|
||||
grid[self.Y][self.X].bg = inkPalette[m.inkColorIdx]
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for y := 0; y < Height; y++ {
|
||||
for x := 0; x < Width; x++ {
|
||||
p := grid[y][x]
|
||||
style := m.style()
|
||||
if p.color != "" {
|
||||
style = style.Foreground(lipgloss.Color(p.color))
|
||||
}
|
||||
if p.bg != "" {
|
||||
style = style.Background(lipgloss.Color(p.bg)).Foreground(lipgloss.Color("#000000"))
|
||||
}
|
||||
ch := p.ch
|
||||
if ch == 0 || ch == ' ' {
|
||||
ch = ' '
|
||||
}
|
||||
b.WriteString(style.Render(" "))
|
||||
b.WriteString(style.Render(string(ch)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
inkSwatch := m.style().Foreground(lipgloss.Color(inkPalette[m.inkColorIdx])).Render(string(charPalette[m.charIdx]))
|
||||
dim := m.style().Foreground(lipgloss.Color("#777777"))
|
||||
status := fmt.Sprintf("ink %s (1-8 color, [ ] char) peers: %d %s",
|
||||
inkSwatch, m.canvas.PeerCount(), dim.Render("space paint · c clear · q quit"))
|
||||
|
||||
return m.boxStyle().Render(b.String() + "\n" + status)
|
||||
}
|
||||
Reference in New Issue
Block a user