v0.0.1: agentu mvp

Add OpenAI-compatible providers, TUI, slash commands, and model switching.
Add local file tools plus optional yolo shell execution.
Document config, usage, and development workflow.
This commit is contained in:
loveuer
2026-06-22 17:28:22 +08:00
commit a431d1ec95
23 changed files with 3526 additions and 0 deletions
+622
View File
@@ -0,0 +1,622 @@
package tui
import (
"context"
"errors"
"fmt"
"io"
"math"
"strings"
"agentu/internal/agent"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
minInputLines = 1
maxInputLines = 6
)
type Options struct {
ModelName string
Yolo bool
ThemeMode ThemeMode
ModelManager ModelManager
}
type ModelManager interface {
CurrentProvider() string
CurrentModel() string
CurrentThinking() string
Info() string
SetProvider(name string) (string, error)
SetModel(model string) (string, error)
SetThinking(level string) (string, error)
}
func Run(ctx context.Context, assistant *agent.Agent, opts Options) error {
model := newModel(ctx, assistant, opts)
_, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
return err
}
type role string
const (
roleUser role = "user"
roleAssistant role = "assistant"
roleTool role = "tool"
roleError role = "error"
roleSystem role = "system"
)
type message struct {
role role
content string
}
type model struct {
ctx context.Context
agent *agent.Agent
modelName string
yolo bool
models ModelManager
theme theme
styles styles
viewport viewport.Model
input textarea.Model
width int
height int
messages []message
status string
running bool
cancel context.CancelFunc
events <-chan tea.Msg
}
type assistantChunkMsg string
type toolLogMsg string
type turnStartedMsg struct{}
type turnDoneMsg struct {
err error
}
type slashCommand struct {
Name string
Description string
}
var slashCommands = []slashCommand{
{Name: "/clear", Description: "reset context"},
{Name: "/exit", Description: "quit"},
{Name: "/quit", Description: "quit"},
{Name: "/model", Description: "model/provider/thinking"},
}
func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
mode := opts.ThemeMode
if mode == "" {
mode = ThemeLight
}
th := themeForMode(mode)
st := th.styles()
input := textarea.New()
input.Placeholder = "Message agentu..."
input.Prompt = ""
input.ShowLineNumbers = false
input.MaxHeight = maxInputLines
input.SetHeight(minInputLines)
input.SetWidth(80)
applyTextareaTheme(&input, th)
input.Focus()
vp := viewport.New(80, 20)
vp.Style = st.Viewport
vp.SetContent("")
return model{
ctx: ctx,
agent: assistant,
modelName: opts.ModelName,
yolo: opts.Yolo,
models: opts.ModelManager,
theme: th,
styles: st,
viewport: vp,
input: input,
status: "ready",
messages: []message{
{role: roleSystem, content: "Ask anything. Enter sends, ctrl+j or alt+enter inserts a newline, esc cancels a running turn."},
},
}
}
func (m model) Init() tea.Cmd {
return textarea.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resize()
m.refreshViewport(true)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
if m.cancel != nil {
m.cancel()
}
return m, tea.Quit
case "esc":
if m.running && m.cancel != nil {
m.cancel()
m.status = "canceling..."
return m, nil
}
case "enter":
if m.running {
return m, nil
}
return m.submit()
case "alt+enter", "ctrl+j":
return m.insertInputNewline()
}
case assistantChunkMsg:
m.appendAssistantChunk(string(msg))
m.status = "streaming..."
m.refreshViewport(true)
return m, m.waitForEvent()
case toolLogMsg:
text := cleanToolLog(string(msg))
if text != "" {
m.messages = append(m.messages, message{role: roleTool, content: text})
m.status = text
m.refreshViewport(true)
}
return m, m.waitForEvent()
case turnDoneMsg:
m.running = false
m.events = nil
m.cancel = nil
if msg.err != nil {
m.messages = append(m.messages, message{role: roleError, content: msg.err.Error()})
m.status = "error"
} else {
m.status = "ready"
}
m.refreshViewport(true)
cmds = append(cmds, textarea.Blink)
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if isViewportKey(keyMsg.String()) {
nextViewport, cmd := m.viewport.Update(msg)
m.viewport = nextViewport
return m, cmd
}
if !m.running {
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.afterInputChanged()
return m, cmd
}
return m, nil
}
if !m.running {
nextInput, cmd := m.input.Update(msg)
m.input = nextInput
m.afterInputChanged()
cmds = append(cmds, cmd)
}
nextViewport, cmd := m.viewport.Update(msg)
m.viewport = nextViewport
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if m.width <= 0 || m.height <= 0 {
return "agentu"
}
header := m.headerView()
status := m.statusView()
input := m.inputView()
body := lipgloss.JoinVertical(lipgloss.Left, header, m.viewport.View(), input, status)
return m.styles.App.Width(m.width).Height(m.height).Render(body)
}
func (m *model) submit() (tea.Model, tea.Cmd) {
input := strings.TrimSpace(m.input.Value())
if input == "" {
return *m, nil
}
switch input {
case "/exit", "/quit":
return *m, tea.Quit
case "/clear":
m.agent.Clear()
m.messages = []message{{role: roleSystem, content: "Context cleared."}}
m.input.SetValue("")
m.afterInputChanged()
m.status = "ready"
m.refreshViewport(true)
return *m, nil
case "/model":
return m.runLocalCommand(input)
}
if strings.HasPrefix(input, "/") {
return m.runLocalCommand(input)
}
m.messages = append(m.messages, message{role: roleUser, content: input})
m.input.SetValue("")
m.afterInputChanged()
m.running = true
m.status = "thinking..."
m.refreshViewport(true)
turnCtx, cancel := context.WithCancel(m.ctx)
m.cancel = cancel
events := make(chan tea.Msg, 256)
m.events = events
start := func() tea.Msg {
go func() {
defer close(events)
out := eventWriter{ctx: turnCtx, events: events, assistant: true}
logs := eventWriter{ctx: turnCtx, events: events}
err := m.agent.RunTurn(turnCtx, input, out, logs)
if errors.Is(err, context.Canceled) {
err = nil
}
select {
case events <- turnDoneMsg{err: err}:
case <-turnCtx.Done():
}
}()
return turnStartedMsg{}
}
return *m, tea.Batch(start, m.waitForEvent())
}
func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
enter := tea.KeyMsg{Type: tea.KeyEnter}
nextInput, cmd := m.input.Update(enter)
m.input = nextInput
m.afterInputChanged()
return *m, cmd
}
func (m model) waitForEvent() tea.Cmd {
events := m.events
if events == nil {
return nil
}
return func() tea.Msg {
msg, ok := <-events
if !ok {
return turnDoneMsg{}
}
return msg
}
}
func (m *model) appendAssistantChunk(chunk string) {
if chunk == "" {
return
}
if len(m.messages) == 0 || m.messages[len(m.messages)-1].role != roleAssistant {
m.messages = append(m.messages, message{role: roleAssistant})
}
m.messages[len(m.messages)-1].content += chunk
}
func (m *model) resize() {
width := max(40, m.width)
inputWidth := max(20, width-4)
m.input.SetWidth(inputWidth)
m.syncInputHeight()
headerHeight := 1
statusHeight := 1
inputHeight := m.inputBlockHeight()
m.viewport.Width = width
m.viewport.Height = max(4, m.height-headerHeight-statusHeight-inputHeight)
m.viewport.Style = m.styles.Viewport
}
func (m *model) refreshViewport(bottom bool) {
m.viewport.SetContent(m.renderMessages())
if bottom {
m.viewport.GotoBottom()
}
}
func (m model) renderMessages() string {
width := max(40, m.width)
contentWidth := max(20, width-6)
parts := make([]string, 0, len(m.messages))
for _, msg := range m.messages {
content := strings.TrimRight(msg.content, "\n")
if content == "" {
continue
}
parts = append(parts, m.messageView(msg.role, content, contentWidth))
}
if len(parts) == 0 {
return m.styles.Muted.Width(contentWidth).Render("Start a conversation.")
}
return strings.Join(parts, "\n\n")
}
func (m model) messageView(r role, content string, width int) string {
label := string(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"
}
labelLine := labelStyle.Render(label)
body := style.Width(width).Render(content)
return lipgloss.JoinVertical(lipgloss.Left, labelLine, body)
}
func (m model) headerView() string {
mode := "read-only tools"
if m.yolo {
mode = "yolo tools"
}
modelName := m.modelName
thinking := ""
if m.models != nil {
modelName = m.models.CurrentModel()
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
thinking = " · 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)
}
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, title, box)
}
func (m model) statusView() string {
left := m.status
if left == "" {
left = "ready"
}
return m.styles.Status.Width(max(1, m.width)).Render(left)
}
type eventWriter struct {
ctx context.Context
events chan<- tea.Msg
assistant bool
}
func (w eventWriter) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
text := string(append([]byte(nil), p...))
var msg tea.Msg
if w.assistant {
msg = assistantChunkMsg(text)
} else {
msg = toolLogMsg(text)
}
select {
case w.events <- msg:
return len(p), nil
case <-w.ctx.Done():
return 0, w.ctx.Err()
}
}
func cleanToolLog(text string) string {
text = strings.TrimSpace(text)
text = strings.TrimPrefix(text, "[tool]")
return strings.TrimSpace(text)
}
func isViewportKey(key string) bool {
switch key {
case "pgup", "pgdown", "ctrl+up", "ctrl+down":
return true
default:
return false
}
}
func (m *model) afterInputChanged() {
m.syncInputHeight()
if m.width > 0 && m.height > 0 {
m.resize()
m.refreshViewport(true)
}
}
func (m *model) syncInputHeight() {
m.input.SetHeight(m.desiredInputHeight())
}
func (m model) desiredInputHeight() int {
value := m.input.Value()
if value == "" {
return minInputLines
}
textWidth := max(1, m.input.Width())
lines := 0
for _, line := range strings.Split(value, "\n") {
displayWidth := lipgloss.Width(line)
wrapped := int(math.Ceil(float64(max(1, displayWidth)) / float64(textWidth)))
lines += max(1, wrapped)
}
return min(max(lines, minInputLines), maxInputLines)
}
func (m model) inputBlockHeight() int {
height := 1 + m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
if m.showSlashSuggestions() {
height++
}
return height
}
func (m model) showSlashSuggestions() bool {
value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
}
func (m model) slashSuggestionsView() string {
if !m.showSlashSuggestions() {
return ""
}
prefix := strings.TrimSpace(m.input.Value())
var parts []string
for _, command := range slashCommands {
if !strings.HasPrefix(command.Name, prefix) {
continue
}
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description))
}
if len(parts) == 0 {
parts = append(parts, "No matching commands")
}
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, " "))
}
func (m model) modelInfo() string {
if m.models != nil {
return m.models.Info()
}
mode := "read-only tools"
if m.yolo {
mode = "yolo tools"
}
return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode)
}
func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
output, err := m.handleLocalCommand(input)
role := roleSystem
status := "command"
if err != nil {
output = err.Error()
role = roleError
status = "command error"
}
m.messages = append(m.messages, message{role: role, content: output})
m.input.SetValue("")
m.afterInputChanged()
m.status = status
m.refreshViewport(true)
return *m, nil
}
func (m model) handleLocalCommand(input string) (string, error) {
fields := strings.Fields(input)
if len(fields) == 0 {
return "", fmt.Errorf("empty command")
}
switch fields[0] {
case "/model":
return m.handleModelCommand(fields[1:])
default:
return "", fmt.Errorf("unknown command: %s", fields[0])
}
}
func (m model) handleModelCommand(args []string) (string, error) {
if m.models == nil {
if len(args) > 0 {
return "", fmt.Errorf("model switching is not configured")
}
return m.modelInfo(), nil
}
if len(args) == 0 || args[0] == "help" {
return m.models.Info(), nil
}
if len(args) == 1 {
return m.models.SetModel(args[0])
}
switch args[0] {
case "provider":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model provider <name>")
}
return m.models.SetProvider(args[1])
case "model":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model model <name>")
}
return m.models.SetModel(args[1])
case "thinking":
if len(args) != 2 {
return "", fmt.Errorf("usage: /model thinking <none|middle|high|xhigh|max>")
}
return m.models.SetThinking(args[1])
default:
return "", fmt.Errorf("unknown /model command %q", args[0])
}
}
var _ io.Writer = eventWriter{}
+163
View File
@@ -0,0 +1,163 @@
package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestModelRendersChatShell(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()
for _, want := range []string{"agentu", "test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
}
}
}
func TestCtrlJInsertsInputNewline(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.input.SetValue("hello")
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ})
got := next.(model).input.Value()
if got != "hello\n" {
t.Fatalf("input value = %q", got)
}
}
func TestInputStartsAtOneLineAndGrows(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
if got := m.input.Height(); got != 1 {
t.Fatalf("initial input height = %d", got)
}
m.input.SetValue("one\ntwo\nthree")
m.afterInputChanged()
if got := m.input.Height(); got != 3 {
t.Fatalf("multiline input height = %d", got)
}
m.input.SetValue(strings.Repeat("line\n", 20))
m.afterInputChanged()
if got := m.input.Height(); got != maxInputLines {
t.Fatalf("clamped input height = %d", got)
}
}
func TestSlashCommandSuggestions(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/")
m.afterInputChanged()
rendered := m.View()
for _, want := range []string{"/clear reset context", "/exit quit", "/model model/provider/thinking"} {
if !strings.Contains(rendered, want) {
t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered)
}
}
}
func TestModelCommand(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
m.input.SetValue("/model")
next, _ := m.submit()
rendered := next.(model).renderMessages()
for _, want := range []string{"model: test-model", "mode: yolo tools", "theme: light"} {
if !strings.Contains(rendered, want) {
t.Fatalf("model command output missing %q:\n%s", want, rendered)
}
}
}
func TestModelThinkingCommand(t *testing.T) {
manager := &fakeModelManager{model: "test-model", thinking: "none"}
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ModelManager: manager})
m.input.SetValue("/model thinking high")
next, _ := m.submit()
rendered := next.(model).renderMessages()
if manager.thinking != "high" {
t.Fatalf("thinking = %q", manager.thinking)
}
if !strings.Contains(rendered, "thinking: high") {
t.Fatalf("model command output missing thinking:\n%s", rendered)
}
}
type fakeModelManager struct {
provider string
model string
thinking string
}
func (f *fakeModelManager) CurrentProvider() string {
if f.provider == "" {
return "default"
}
return f.provider
}
func (f *fakeModelManager) CurrentModel() string {
return f.model
}
func (f *fakeModelManager) CurrentThinking() string {
return f.thinking
}
func (f *fakeModelManager) Info() string {
return "provider: " + f.CurrentProvider() + "\nmodel: " + f.model + "\nthinking: " + f.thinking
}
func (f *fakeModelManager) SetProvider(name string) (string, error) {
f.provider = name
return f.Info(), nil
}
func (f *fakeModelManager) SetModel(model string) (string, error) {
f.model = model
return f.Info(), nil
}
func (f *fakeModelManager) SetThinking(level string) (string, error) {
f.thinking = level
return f.Info(), nil
}
func TestParseThemeMode(t *testing.T) {
for input, want := range map[string]ThemeMode{
"": ThemeLight,
"light": ThemeLight,
"dark": ThemeDark,
"DARK": ThemeDark,
} {
got, err := ParseThemeMode(input)
if err != nil {
t.Fatalf("ParseThemeMode(%q): %v", input, err)
}
if got != want {
t.Fatalf("ParseThemeMode(%q) = %q, want %q", input, got, want)
}
}
if _, err := ParseThemeMode("sepia"); err == nil {
t.Fatal("expected invalid theme error")
}
}
func TestCleanToolLog(t *testing.T) {
got := cleanToolLog("\n[tool] shell_run {\"command\":\"pwd\"}\n")
want := `shell_run {"command":"pwd"}`
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
+199
View File
@@ -0,0 +1,199 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/lipgloss"
)
type ThemeMode string
const (
ThemeLight ThemeMode = "light"
ThemeDark ThemeMode = "dark"
)
func ParseThemeMode(value string) (ThemeMode, error) {
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
case "", ThemeLight:
return ThemeLight, nil
case ThemeDark:
return ThemeDark, nil
default:
return "", fmt.Errorf("unsupported theme %q; expected light or dark", value)
}
}
type theme struct {
Mode ThemeMode
Background string
Surface string
SurfaceAlt string
Border string
Text string
Muted string
Title string
User string
Assistant string
Tool string
Error string
System string
}
type styles struct {
App lipgloss.Style
Header lipgloss.Style
Title lipgloss.Style
Muted lipgloss.Style
Status lipgloss.Style
Viewport lipgloss.Style
InputBox lipgloss.Style
CommandHint lipgloss.Style
UserLabel lipgloss.Style
AssistantLabel lipgloss.Style
ToolLabel lipgloss.Style
ErrorLabel lipgloss.Style
SystemLabel lipgloss.Style
UserMessage lipgloss.Style
AssistantMsg lipgloss.Style
ToolMessage lipgloss.Style
ErrorMessage lipgloss.Style
SystemMessage lipgloss.Style
}
func themeForMode(mode ThemeMode) theme {
if mode == ThemeDark {
return darkTheme()
}
return lightTheme()
}
func lightTheme() theme {
return theme{
Mode: ThemeLight,
Background: "#F8FAFC",
Surface: "#FFFFFF",
SurfaceAlt: "#EEF2F7",
Border: "#CBD5E1",
Text: "#111827",
Muted: "#64748B",
Title: "#0F766E",
User: "#2563EB",
Assistant: "#0F766E",
Tool: "#B45309",
Error: "#DC2626",
System: "#64748B",
}
}
func darkTheme() theme {
return theme{
Mode: ThemeDark,
Background: "#111827",
Surface: "#1F2937",
SurfaceAlt: "#0F172A",
Border: "#475569",
Text: "#E5E7EB",
Muted: "#94A3B8",
Title: "#5EEAD4",
User: "#93C5FD",
Assistant: "#5EEAD4",
Tool: "#FBBF24",
Error: "#FCA5A5",
System: "#94A3B8",
}
}
func (t theme) styles() styles {
return styles{
App: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Background)),
Header: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
Title: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Title)),
Muted: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
Status: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
Viewport: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Background)),
InputBox: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Surface)).
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(t.Border)).
Padding(0, 1),
CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.SurfaceAlt)).
Padding(0, 1),
UserLabel: labelStyle(t.User),
AssistantLabel: labelStyle(t.Assistant),
ToolLabel: labelStyle(t.Tool),
ErrorLabel: labelStyle(t.Error),
SystemLabel: labelStyle(t.System),
UserMessage: messageStyle(t.Text, t.User),
AssistantMsg: messageStyle(t.Text, t.Assistant),
ToolMessage: messageStyle(t.Text, t.Tool),
ErrorMessage: messageStyle(t.Error, t.Error),
SystemMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(t.System)).PaddingLeft(1),
}
}
func labelStyle(color string) lipgloss.Style {
return lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(color))
}
func messageStyle(textColor, accentColor string) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(textColor)).
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color(accentColor)).
PaddingLeft(1)
}
func applyTextareaTheme(input *textarea.Model, t theme) {
focused := textarea.Style{
Base: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)).
Background(lipgloss.Color(t.Surface)),
CursorLine: lipgloss.NewStyle().
Background(lipgloss.Color(t.Surface)),
Placeholder: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
Prompt: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)),
Text: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)),
EndOfBuffer: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Surface)),
}
blurred := focused
blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.Surface))
input.FocusedStyle = focused
input.BlurredStyle = blurred
}