v0.0.2: polish tui rendering
Add visible activity state for running turns. Move model metadata below the input area. Render assistant Markdown with theme-aware styling.
This commit is contained in:
+115
-37
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"agentu/internal/agent"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -70,6 +71,7 @@ type model struct {
|
||||
|
||||
viewport viewport.Model
|
||||
input textarea.Model
|
||||
spinner spinner.Model
|
||||
|
||||
width int
|
||||
height int
|
||||
@@ -118,6 +120,10 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
applyTextareaTheme(&input, th)
|
||||
input.Focus()
|
||||
|
||||
spin := spinner.New(
|
||||
spinner.WithSpinner(spinner.Line),
|
||||
)
|
||||
|
||||
vp := viewport.New(80, 20)
|
||||
vp.Style = st.Viewport
|
||||
vp.SetContent("")
|
||||
@@ -132,10 +138,8 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
styles: st,
|
||||
viewport: vp,
|
||||
input: input,
|
||||
spinner: spin,
|
||||
status: "ready",
|
||||
messages: []message{
|
||||
{role: roleSystem, content: "Ask anything. Enter sends, ctrl+j or alt+enter inserts a newline, esc cancels a running turn."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +158,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.refreshViewport(true)
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if !m.running {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
@@ -201,6 +213,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.status = "ready"
|
||||
}
|
||||
m.resize()
|
||||
m.refreshViewport(true)
|
||||
cmds = append(cmds, textarea.Blink)
|
||||
}
|
||||
@@ -239,10 +252,12 @@ func (m model) View() string {
|
||||
return "agentu"
|
||||
}
|
||||
|
||||
header := m.headerView()
|
||||
status := m.statusView()
|
||||
input := m.inputView()
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, header, m.viewport.View(), input, status)
|
||||
parts := []string{m.headerView(), m.viewport.View()}
|
||||
if activity := m.activityView(); activity != "" {
|
||||
parts = append(parts, activity)
|
||||
}
|
||||
parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView())
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
return m.styles.App.Width(m.width).Height(m.height).Render(body)
|
||||
}
|
||||
|
||||
@@ -276,6 +291,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
||||
m.afterInputChanged()
|
||||
m.running = true
|
||||
m.status = "thinking..."
|
||||
m.resize()
|
||||
m.refreshViewport(true)
|
||||
|
||||
turnCtx, cancel := context.WithCancel(m.ctx)
|
||||
@@ -301,7 +317,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
||||
return turnStartedMsg{}
|
||||
}
|
||||
|
||||
return *m, tea.Batch(start, m.waitForEvent())
|
||||
return *m, tea.Batch(start, m.waitForEvent(), m.spinner.Tick)
|
||||
}
|
||||
|
||||
func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
|
||||
@@ -343,10 +359,14 @@ func (m *model) resize() {
|
||||
m.syncInputHeight()
|
||||
|
||||
headerHeight := 1
|
||||
statusHeight := 1
|
||||
activityHeight := 0
|
||||
if m.running {
|
||||
activityHeight = 1
|
||||
}
|
||||
inputHeight := m.inputBlockHeight()
|
||||
footerHeight := 2
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = max(4, m.height-headerHeight-statusHeight-inputHeight)
|
||||
m.viewport.Height = max(4, m.height-headerHeight-activityHeight-inputHeight-footerHeight)
|
||||
m.viewport.Style = m.styles.Viewport
|
||||
}
|
||||
|
||||
@@ -369,80 +389,115 @@ func (m model) renderMessages() string {
|
||||
parts = append(parts, m.messageView(msg.role, content, contentWidth))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return m.styles.Muted.Width(contentWidth).Render("Start a conversation.")
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func (m model) messageView(r role, content string, width int) string {
|
||||
label := string(r)
|
||||
label := roleLabel(r)
|
||||
style := m.styles.AssistantMsg
|
||||
labelStyle := m.styles.AssistantLabel
|
||||
|
||||
switch r {
|
||||
case roleUser:
|
||||
label = "you"
|
||||
style = m.styles.UserMessage
|
||||
labelStyle = m.styles.UserLabel
|
||||
case roleTool:
|
||||
label = "tool"
|
||||
style = m.styles.ToolMessage
|
||||
labelStyle = m.styles.ToolLabel
|
||||
case roleError:
|
||||
label = "error"
|
||||
style = m.styles.ErrorMessage
|
||||
labelStyle = m.styles.ErrorLabel
|
||||
case roleSystem:
|
||||
label = "note"
|
||||
style = m.styles.SystemMessage
|
||||
labelStyle = m.styles.SystemLabel
|
||||
default:
|
||||
label = "agentu"
|
||||
}
|
||||
|
||||
bodyContent := content
|
||||
if r == roleAssistant {
|
||||
bodyWidth := max(minMarkdownWidth, width-style.GetHorizontalFrameSize())
|
||||
bodyContent = renderMarkdown(content, bodyWidth, m.theme)
|
||||
}
|
||||
|
||||
labelLine := labelStyle.Render(label)
|
||||
body := style.Width(width).Render(content)
|
||||
body := style.Width(width).Render(bodyContent)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, labelLine, body)
|
||||
}
|
||||
|
||||
func roleLabel(r role) string {
|
||||
switch r {
|
||||
case roleUser:
|
||||
return "You"
|
||||
case roleAssistant:
|
||||
return "Agentu"
|
||||
case roleTool:
|
||||
return "Tool"
|
||||
case roleError:
|
||||
return "Error"
|
||||
case roleSystem:
|
||||
return "Note"
|
||||
default:
|
||||
return string(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) headerView() string {
|
||||
title := m.styles.Title.Render("agentu")
|
||||
meta := m.styles.Muted.Render("local agent")
|
||||
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
|
||||
return m.styles.Header.Width(max(1, m.width)).Render(line)
|
||||
}
|
||||
|
||||
func (m model) modelMetaView() string {
|
||||
mode := "read-only tools"
|
||||
if m.yolo {
|
||||
mode = "yolo tools"
|
||||
}
|
||||
modelName := m.modelName
|
||||
thinking := ""
|
||||
parts := []string{}
|
||||
if m.models != nil {
|
||||
if provider := m.models.CurrentProvider(); provider != "" {
|
||||
parts = append(parts, "provider "+provider)
|
||||
}
|
||||
modelName = m.models.CurrentModel()
|
||||
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
||||
thinking = " · thinking " + currentThinking
|
||||
parts = append(parts, "thinking "+currentThinking)
|
||||
}
|
||||
}
|
||||
title := m.styles.Title.Render("agentu")
|
||||
meta := m.styles.Muted.Render(fmt.Sprintf("%s · %s%s · %s", modelName, mode, thinking, m.theme.Mode))
|
||||
line := lipgloss.JoinHorizontal(lipgloss.Center, title, " ", meta)
|
||||
return m.styles.Header.Width(max(1, m.width)).Render(line)
|
||||
parts = append([]string{modelName, mode}, parts...)
|
||||
parts = append(parts, "theme "+string(m.theme.Mode))
|
||||
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
|
||||
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
|
||||
}
|
||||
|
||||
func (m model) inputView() string {
|
||||
title := m.styles.Muted.Render("Enter sends · ctrl+j newline · alt+enter newline · /clear · /exit")
|
||||
if m.running {
|
||||
title = m.styles.Muted.Render("Working · esc cancel · ctrl+c quit")
|
||||
}
|
||||
suggestions := m.slashSuggestionsView()
|
||||
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
||||
if suggestions != "" {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, suggestions, title, box)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box)
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, box)
|
||||
return box
|
||||
}
|
||||
|
||||
func (m model) statusView() string {
|
||||
left := m.status
|
||||
if left == "" {
|
||||
left = "ready"
|
||||
func (m model) activityView() string {
|
||||
if !m.running {
|
||||
return ""
|
||||
}
|
||||
return m.styles.Status.Width(max(1, m.width)).Render(left)
|
||||
status := strings.TrimSpace(m.status)
|
||||
if status == "" || status == "ready" {
|
||||
status = "working..."
|
||||
}
|
||||
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2))
|
||||
return m.styles.Activity.Width(max(1, m.width)).Render(line)
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
hint := "enter send · ctrl+j newline · alt+enter newline · / commands"
|
||||
if m.running {
|
||||
hint = "esc cancel · ctrl+c quit"
|
||||
}
|
||||
return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2)))
|
||||
}
|
||||
|
||||
type eventWriter struct {
|
||||
@@ -514,7 +569,7 @@ func (m model) desiredInputHeight() int {
|
||||
}
|
||||
|
||||
func (m model) inputBlockHeight() int {
|
||||
height := 1 + m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
||||
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
||||
if m.showSlashSuggestions() {
|
||||
height++
|
||||
}
|
||||
@@ -555,6 +610,29 @@ func (m model) modelInfo() string {
|
||||
return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode)
|
||||
}
|
||||
|
||||
func fitLine(text string, width int) string {
|
||||
width = max(1, width)
|
||||
if lipgloss.Width(text) <= width {
|
||||
return text
|
||||
}
|
||||
if width <= 3 {
|
||||
return strings.Repeat(".", width)
|
||||
}
|
||||
|
||||
target := width - 3
|
||||
used := 0
|
||||
var b strings.Builder
|
||||
for _, r := range text {
|
||||
next := lipgloss.Width(string(r))
|
||||
if used+next > target {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
used += next
|
||||
}
|
||||
return b.String() + "..."
|
||||
}
|
||||
|
||||
func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
|
||||
output, err := m.handleLocalCommand(input)
|
||||
role := roleSystem
|
||||
|
||||
@@ -20,6 +20,109 @@ func TestModelRendersChatShell(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelMetaRendersBelowInput(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
rendered := next.(model).View()
|
||||
|
||||
inputIndex := strings.Index(rendered, "Message agentu")
|
||||
modelIndex := strings.Index(rendered, "test-model")
|
||||
if inputIndex < 0 || modelIndex < 0 {
|
||||
t.Fatalf("rendered view missing input or model meta:\n%s", rendered)
|
||||
}
|
||||
if modelIndex < inputIndex {
|
||||
t.Fatalf("model meta should render below input:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialViewDoesNotRenderTopNote(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
rendered := next.(model).View()
|
||||
|
||||
for _, unwanted := range []string{"Note", "Ask anything", "Start a conversation"} {
|
||||
if strings.Contains(rendered, unwanted) {
|
||||
t.Fatalf("initial view should not render %q:\n%s", unwanted, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningStateRendersActivityIndicator(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
m.running = true
|
||||
m.status = "thinking..."
|
||||
m.resize()
|
||||
|
||||
rendered := m.View()
|
||||
for _, want := range []string{"Working", "thinking...", "esc cancels"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("running view missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessageRendersOnLeft(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
|
||||
rendered := m.messageView(roleUser, "hello", 80)
|
||||
if strings.HasPrefix(rendered, " ") {
|
||||
t.Fatalf("user message should not be right-aligned:\n%q", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "You") || !strings.Contains(rendered, "hello") {
|
||||
t.Fatalf("user message missing content:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssistantMessagesRenderMarkdown(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
|
||||
rendered := m.messageView(roleAssistant, "# Title\n\n- item", 80)
|
||||
for _, want := range []string{"Agentu", "Title", "item"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("assistant markdown missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, "# Title") {
|
||||
t.Fatalf("assistant markdown heading was not rendered:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessagesDoNotRenderMarkdown(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
|
||||
rendered := m.messageView(roleUser, "# Title\n\n- item", 80)
|
||||
for _, want := range []string{"# Title", "- item"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("user markdown should stay plain and include %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageRoleLabels(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
m.messages = []message{
|
||||
{role: roleUser, content: "hello"},
|
||||
{role: roleAssistant, content: "hi"},
|
||||
}
|
||||
|
||||
rendered := m.renderMessages()
|
||||
for _, want := range []string{"You", "Agentu"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("messages missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlJInsertsInputNewline(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.input.SetValue("hello")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/ansi"
|
||||
glamstyles "github.com/charmbracelet/glamour/styles"
|
||||
)
|
||||
|
||||
const minMarkdownWidth = 20
|
||||
|
||||
func renderMarkdown(content string, width int, th theme) string {
|
||||
width = max(minMarkdownWidth, width)
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(markdownStyle(th)),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
rendered, err := renderer.Render(content)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
return strings.TrimRight(rendered, "\n")
|
||||
}
|
||||
|
||||
func markdownStyle(th theme) ansi.StyleConfig {
|
||||
cfg := glamstyles.LightStyleConfig
|
||||
if th.Mode == ThemeDark {
|
||||
cfg = glamstyles.DarkStyleConfig
|
||||
}
|
||||
|
||||
zero := uint(0)
|
||||
cfg.Document.Margin = &zero
|
||||
cfg.Document.BlockPrefix = ""
|
||||
cfg.Document.BlockSuffix = ""
|
||||
cfg.Document.Color = stringPtr(th.Text)
|
||||
|
||||
cfg.Heading.Color = stringPtr(th.Assistant)
|
||||
cfg.H1.Prefix = ""
|
||||
cfg.H1.Suffix = ""
|
||||
cfg.H1.Color = stringPtr(th.Assistant)
|
||||
cfg.H1.BackgroundColor = nil
|
||||
cfg.H1.Bold = boolPtr(true)
|
||||
|
||||
cfg.BlockQuote.Color = stringPtr(th.Muted)
|
||||
cfg.Link.Color = stringPtr(th.User)
|
||||
cfg.LinkText.Color = stringPtr(th.User)
|
||||
|
||||
cfg.Code.Color = stringPtr(th.Tool)
|
||||
cfg.Code.BackgroundColor = stringPtr(th.SurfaceAlt)
|
||||
cfg.CodeBlock.Color = stringPtr(th.Text)
|
||||
cfg.CodeBlock.BackgroundColor = nil
|
||||
cfg.CodeBlock.Margin = &zero
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdownSupportsCommonAssistantContent(t *testing.T) {
|
||||
rendered := renderMarkdown("# Title\n\n- one\n- two\n\n```go\nfmt.Println(\"x\")\n```", 80, themeForMode(ThemeLight))
|
||||
plain := stripANSI(rendered)
|
||||
for _, want := range []string{"Title", "one", "two", `fmt.Println("x")`} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "# Title") {
|
||||
t.Fatalf("heading was not rendered:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownSupportsThemesAndNarrowWidth(t *testing.T) {
|
||||
for _, mode := range []ThemeMode{ThemeLight, ThemeDark} {
|
||||
rendered := renderMarkdown("**bold** and `code`", 4, themeForMode(mode))
|
||||
plain := stripANSI(rendered)
|
||||
if rendered == "" {
|
||||
t.Fatalf("empty markdown output for %s", mode)
|
||||
}
|
||||
for _, want := range []string{"bold", "code"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("markdown output for %s missing %q:\n%s", mode, want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "**bold**") {
|
||||
t.Fatalf("strong markdown was not rendered for %s:\n%s", mode, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
|
||||
|
||||
func stripANSI(value string) string {
|
||||
return ansiPattern.ReplaceAllString(value, "")
|
||||
}
|
||||
+16
-3
@@ -49,7 +49,9 @@ type styles struct {
|
||||
Header lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Muted lipgloss.Style
|
||||
Status lipgloss.Style
|
||||
Activity lipgloss.Style
|
||||
ModelMeta lipgloss.Style
|
||||
Footer lipgloss.Style
|
||||
Viewport lipgloss.Style
|
||||
InputBox lipgloss.Style
|
||||
CommandHint lipgloss.Style
|
||||
@@ -126,11 +128,22 @@ func (t theme) styles() styles {
|
||||
Muted: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)),
|
||||
|
||||
Status: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)).
|
||||
Activity: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(t.Background)).
|
||||
Background(lipgloss.Color(t.Title)).
|
||||
Padding(0, 1),
|
||||
|
||||
ModelMeta: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)).
|
||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||
Padding(0, 1),
|
||||
|
||||
Footer: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Muted)).
|
||||
Background(lipgloss.Color(t.Background)).
|
||||
Padding(0, 1),
|
||||
|
||||
Viewport: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(t.Text)).
|
||||
Background(lipgloss.Color(t.Background)),
|
||||
|
||||
Reference in New Issue
Block a user