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:
@@ -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{}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user