1 Commits

Author SHA1 Message Date
loveuer c8d8329dbb feat(tui): codex-style input, history, bang commands, single-line status bar
- Arrow-key input history (up/down with multiline awareness)
- Bang shell commands via ! prefix (requires --yolo)
- Codex-style input bar with mode indicator (R/Y/⌘)
- Command palette virtual scrolling
- Multiline input viewport fix
- Merged model pills + footer into single status line
- Removed shift+enter (wait for TUI library support)
- Removed default footer hints
2026-06-25 20:40:50 -07:00
6 changed files with 729 additions and 174 deletions
+4 -1
View File
@@ -100,11 +100,13 @@ go run . [flags]
## TUI Controls ## TUI Controls
- `enter` sends the message. - `enter` sends the message.
- `ctrl+j` or `alt+enter` inserts a newline. - `ctrl+j` inserts a newline.
- `up` recalls the previous input; `ctrl+p` / `ctrl+n` navigate input history.
- The input starts at one line and grows up to six lines. - The input starts at one line and grows up to six lines.
- `page up` / `page down` scrolls the transcript. - `page up` / `page down` scrolls the transcript.
- `esc` cancels a running response. - `esc` cancels a running response.
- Type `/` to show available commands. - Type `/` to show available commands.
- In `--yolo` mode, type `! <command>` to run a shell command from the configured working directory.
Slash commands: Slash commands:
@@ -116,6 +118,7 @@ Slash commands:
- `/model` shows current provider, model, thinking, and switch usage. - `/model` shows current provider, model, thinking, and switch usage.
- `/model provider <name>` switches provider for the current session. - `/model provider <name>` switches provider for the current session.
- `/model model <name>` or `/model <name>` switches model for the current session. - `/model model <name>` or `/model <name>` switches model for the current session.
In the TUI, type `/model ` or `/model model ` to choose from configured model IDs in the candidate palette.
- `/model thinking <none|middle|high|xhigh|max>` changes thinking for the current session. - `/model thinking <none|middle|high|xhigh|max>` changes thinking for the current session.
- `/sessions` lists saved sessions. - `/sessions` lists saved sessions.
- `/resume [id]` resumes by ID or opens an interactive picker. - `/resume [id]` resumes by ID or opens an interactive picker.
+6 -2
View File
@@ -288,9 +288,13 @@ func (m *Manager) providerNames() []string {
return names return names
} }
func (m *Manager) AvailableModels() []string {
return append([]string(nil), m.provider.Models...)
}
func contains(values []string, value string) bool { func contains(values []string, value string) bool {
for _, item := range values { for _, v := range values {
if item == value { if v == value {
return true return true
} }
} }
+12
View File
@@ -109,6 +109,18 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
} }
} }
func TestManagerExposesModelCommandCandidates(t *testing.T) {
manager, _, _ := testManager(t)
models := manager.AvailableModels()
if len(models) != 2 || models[0] != "model-a" || models[1] != "model-b" {
t.Fatalf("AvailableModels() = %v", models)
}
models[0] = "mutated"
if got := manager.AvailableModels()[0]; got != "model-a" {
t.Fatalf("mutation leaked: %q", got)
}
}
func TestManagerSaveAndResume(t *testing.T) { func TestManagerSaveAndResume(t *testing.T) {
manager, assistant, _ := testManager(t) manager, assistant, _ := testManager(t)
+415 -89
View File
@@ -50,6 +50,7 @@ type ModelManager interface {
Rename(name string) (string, error) Rename(name string) (string, error)
Sessions() (string, error) Sessions() (string, error)
NewSession() (string, error) NewSession() (string, error)
AvailableModels() []string
} }
func Run(ctx context.Context, assistant *agent.Agent, opts Options) error { func Run(ctx context.Context, assistant *agent.Agent, opts Options) error {
@@ -96,6 +97,7 @@ type model struct {
inputHistory []string inputHistory []string
historyIndex int historyIndex int
draftInput string draftInput string
commandCursor int
running bool running bool
cancel context.CancelFunc cancel context.CancelFunc
@@ -133,6 +135,13 @@ type commandMatch struct {
score int score int
} }
type commandSuggestion struct {
label string
description string
value string
executable bool
}
var slashCommands = []slashCommand{ var slashCommands = []slashCommand{
{Name: "/clear", Description: "reset context"}, {Name: "/clear", Description: "reset context"},
{Name: "/compact", Description: "compress context"}, {Name: "/compact", Description: "compress context"},
@@ -157,7 +166,7 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
st := th.styles() st := th.styles()
input := textarea.New() input := textarea.New()
input.Placeholder = "Message agentu..." input.Placeholder = ""
input.Prompt = "" input.Prompt = ""
input.ShowLineNumbers = false input.ShowLineNumbers = false
input.MaxHeight = maxInputLines input.MaxHeight = maxInputLines
@@ -197,8 +206,6 @@ func (m model) Init() tea.Cmd {
m.syncMessages() m.syncMessages()
return textarea.Blink return textarea.Blink
} }
// syncMessages rebuilds the TUI display messages from the agent's conversation history.
func (m *model) syncMessages() { func (m *model) syncMessages() {
if m.agent == nil { if m.agent == nil {
return return
@@ -246,6 +253,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.picking { if m.picking {
return m.updatePicker(msg) return m.updatePicker(msg)
} }
if handled, next, cmd := m.updateCommandSuggestions(msg); handled {
return next, cmd
}
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
if m.cancel != nil { if m.cancel != nil {
@@ -263,6 +273,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
return m.submit() return m.submit()
case "up":
if m.running {
return m, nil
}
if m.input.Line() == 0 {
m.previousInputHistory()
m.afterInputChanged()
return m, nil
}
case "down":
if m.running {
return m, nil
}
value := m.input.Value()
lineCount := 1
if value != "" {
lineCount = len(strings.Split(value, "\n"))
}
if m.input.Line() >= lineCount-1 {
m.nextInputHistory()
m.afterInputChanged()
return m, nil
}
case "ctrl+p": case "ctrl+p":
if m.running { if m.running {
return m, nil return m, nil
@@ -277,7 +310,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.nextInputHistory() m.nextInputHistory()
m.afterInputChanged() m.afterInputChanged()
return m, nil return m, nil
case "alt+enter", "ctrl+j": case "ctrl+j":
return m.insertInputNewline() return m.insertInputNewline()
} }
@@ -338,7 +371,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.running { if !m.running {
nextInput, cmd := m.input.Update(msg) nextInput, cmd := m.input.Update(msg)
m.input = nextInput m.input = nextInput
m.afterInputChanged()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
@@ -356,7 +388,7 @@ func (m model) View() string {
if m.picking { if m.picking {
parts := []string{m.viewport.View()} parts := []string{m.viewport.View()}
parts = append(parts, m.pickerView(), m.modelMetaView(), m.footerView()) parts = append(parts, m.pickerView(), m.footerView())
body := lipgloss.JoinVertical(lipgloss.Left, parts...) body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body) return m.styles.App.Width(m.width).Height(m.height).Render(body)
} }
@@ -365,7 +397,7 @@ func (m model) View() string {
if activity := m.activityView(); activity != "" { if activity := m.activityView(); activity != "" {
parts = append(parts, activity) parts = append(parts, activity)
} }
parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView()) parts = append(parts, m.inputView(), m.footerView())
body := lipgloss.JoinVertical(lipgloss.Left, parts...) body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.styles.App.Width(m.width).Height(m.height).Render(body) return m.styles.App.Width(m.width).Height(m.height).Render(body)
} }
@@ -393,6 +425,10 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
return m.runLocalCommand(input) return m.runLocalCommand(input)
} }
if strings.HasPrefix(input, "!") {
return m.runLocalCommand(input)
}
if strings.HasPrefix(input, "/") { if strings.HasPrefix(input, "/") {
return m.runLocalCommand(input) return m.runLocalCommand(input)
} }
@@ -438,6 +474,16 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
m.historyIndex = -1 m.historyIndex = -1
m.draftInput = "" m.draftInput = ""
m.afterInputChanged() m.afterInputChanged()
// The textarea's repositionView was called inside Update with the old
// viewport height, so the YOffset may be stale after syncInputHeight
// changed the height. Send up then down to force repositionView to
// recalculate with the new height.
up := tea.KeyMsg{Type: tea.KeyUp}
vp, _ := m.input.Update(up)
m.input = vp
down := tea.KeyMsg{Type: tea.KeyDown}
vp, _ = m.input.Update(down)
m.input = vp
return *m, cmd return *m, cmd
} }
@@ -467,7 +513,7 @@ func (m *model) appendAssistantChunk(chunk string) {
func (m *model) resize() { func (m *model) resize() {
width := max(40, m.width) width := max(40, m.width)
inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()) inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1)
m.input.SetWidth(inputWidth) m.input.SetWidth(inputWidth)
m.syncInputHeight() m.syncInputHeight()
@@ -668,72 +714,25 @@ type metaPill struct {
accent bool accent bool
} }
func (m model) renderMetaPills(parts []metaPill) string { func (m model) inputModeView() string {
if len(parts) == 0 { if strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!") {
return "" return m.styles.InputModeCommand.Render(iconCommand)
} }
plain := make([]string, len(parts))
for i, part := range parts {
plain[i] = part.text
}
width := max(1, m.width-2)
visible := len(plain)
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
visible--
}
if lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
return m.styles.PillAccent.Render(fitLine(plain[0], width))
}
rendered := make([]string, 0, visible)
for i := 0; i < visible; i++ {
style := m.styles.Pill
if parts[i].accent {
style = m.styles.PillAccent
}
rendered = append(rendered, style.Render(parts[i].text))
}
return strings.Join(rendered, " ")
}
func (m model) modelMetaView() string {
mode := "read-only tools"
if m.yolo { if m.yolo {
mode = "yolo tools" return m.styles.InputModeYolo.Render("Y")
}
return m.styles.InputModeReadonly.Render("R")
} }
modelName := m.modelName func (m model) inputModeWidth() int {
if m.models != nil { return lipgloss.Width(m.inputModeView())
if currentModel := m.models.CurrentModel(); currentModel != "" {
modelName = currentModel
}
}
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
if m.models != nil {
if provider := m.models.CurrentProvider(); provider != "" {
parts = append(parts, metaPill{text: "provider " + provider})
}
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
parts = append(parts, metaPill{text: "thinking " + currentThinking})
}
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
}
}
if m.contextUsage != "" {
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
}
parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)})
return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts))
} }
func (m model) inputView() string { func (m model) inputView() string {
palette := m.commandPaletteView() palette := m.commandPaletteView()
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View()) gap := lipgloss.NewStyle().Background(m.styles.InputBox.GetBackground()).Render(" ")
content := lipgloss.JoinHorizontal(lipgloss.Top, m.inputModeView(), gap, m.input.View())
box := m.styles.InputBox.Width(max(20, m.width)).Render(content)
if palette != "" { if palette != "" {
return lipgloss.JoinVertical(lipgloss.Left, palette, box) return lipgloss.JoinVertical(lipgloss.Left, palette, box)
} }
@@ -751,13 +750,85 @@ func (m model) activityView() string {
line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels" line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
return m.styles.Activity.Render(fitLine(line, max(1, m.width-2))) return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
} }
func (m model) footerView() string { func (m model) footerView() string {
hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands" hint := ""
if m.running { if m.running {
hint = "esc cancel · ctrl+c quit" hint = "esc cancel · ctrl+c quit"
} }
return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2))) if m.showCommandPalette() {
if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable {
hint = "↑/↓ select · enter apply · tab fill"
}
}
inner := max(1, m.width-2) // Footer padding(0,1)
pills := m.modelMetaPillsClamped(inner / 3)
pillsW := lipgloss.Width(pills)
var line string
if pills != "" {
remain := max(1, inner-pillsW-1)
line = pills + " " + fitLine(hint, remain)
} else {
line = fitLine(hint, inner)
}
return m.styles.Footer.Width(max(1, m.width)).Render(line)
}
func (m model) modelMetaPillsClamped(maxW int) string {
modelName := m.modelName
if m.models != nil {
if currentModel := m.models.CurrentModel(); currentModel != "" {
modelName = currentModel
}
}
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
if m.models != nil {
if provider := m.models.CurrentProvider(); provider != "" {
parts = append(parts, metaPill{text: provider})
}
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
parts = append(parts, metaPill{text: currentThinking})
}
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
}
}
if m.contextUsage != "" {
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
}
return m.renderMetaPillsClamped(parts, maxW)
}
func (m model) renderMetaPillsClamped(parts []metaPill, width int) string {
if len(parts) == 0 {
return ""
}
plain := make([]string, len(parts))
for i, part := range parts {
plain[i] = part.text
}
visible := len(plain)
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
visible--
}
if visible > 0 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
return ""
}
rendered := make([]string, 0, visible)
for i := 0; i < visible; i++ {
style := m.styles.Pill
if parts[i].accent {
style = m.styles.PillAccent
}
rendered = append(rendered, style.Render(parts[i].text))
}
return strings.Join(rendered, " ")
} }
type eventWriter struct { type eventWriter struct {
@@ -801,6 +872,7 @@ func isViewportKey(key string) bool {
} }
func (m *model) afterInputChanged() { func (m *model) afterInputChanged() {
m.commandCursor = 0
m.syncInputHeight() m.syncInputHeight()
if m.width > 0 && m.height > 0 { if m.width > 0 && m.height > 0 {
m.resize() m.resize()
@@ -882,18 +954,37 @@ func (m model) inputBlockHeight() int {
} }
func (m model) commandPaletteHeight() int { func (m model) commandPaletteHeight() int {
matches := commandMatches(commandPaletteQuery(m.input.Value())) sugs := m.commandSuggestions()
count := len(matches) total := len(sugs)
if count > maxCommandPaletteItems { if !m.showCommandPalette() {
count = maxCommandPaletteItems return 0
} }
count := min(total, maxCommandPaletteItems)
if count == 0 { if count == 0 {
count = 1 count = 1
} }
return 1 + count + m.styles.Palette.GetVerticalFrameSize() scrollUp := 0
scrollDown := 0
if total > maxCommandPaletteItems {
visible := min(total, maxCommandPaletteItems)
scroll := max(0, m.commandCursor-visible+1)
if m.commandCursor < scroll {
scroll = m.commandCursor
}
if scroll > 0 {
scrollUp = 1
}
if scroll+visible < total {
scrollDown = 1
}
}
return 1 + scrollUp + count + scrollDown + m.styles.Palette.GetVerticalFrameSize()
} }
func (m model) showCommandPalette() bool { func (m model) showCommandPalette() bool {
if len(m.commandSuggestions()) > 0 {
return true
}
value := strings.TrimSpace(m.input.Value()) value := strings.TrimSpace(m.input.Value())
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
} }
@@ -902,18 +993,42 @@ func (m model) commandPaletteView() string {
if !m.showCommandPalette() { if !m.showCommandPalette() {
return "" return ""
} }
matches := commandMatches(commandPaletteQuery(m.input.Value())) sugs := m.commandSuggestions()
if len(sugs) > 0 {
header := iconCommand + " Commands"
if sugs[0].executable {
header = iconCommand + " Model candidates"
}
lines := []string{m.styles.Header.Render(header)}
m.clampCommandCursor()
total := len(sugs)
visible := min(total, maxCommandPaletteItems)
scroll := max(0, m.commandCursor-visible+1)
if m.commandCursor < scroll {
scroll = m.commandCursor
}
if scroll > 0 {
lines = append(lines, m.styles.Muted.Render(" ↑ more"))
}
for i := scroll; i < scroll+visible && i < total; i++ {
s := sugs[i]
prefix := " "
if i == m.commandCursor {
prefix = "▸ "
}
lines = append(lines, fmt.Sprintf("%s%s %s", prefix, m.styles.PaletteMatch.Render(s.label), m.styles.Muted.Render(s.description)))
}
if scroll+visible < total {
lines = append(lines, m.styles.Muted.Render(" ↓ more"))
}
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
}
value := strings.TrimSpace(m.input.Value())
if strings.Contains(value, " ") {
return ""
}
lines := []string{m.styles.Header.Render(iconCommand + " Commands")} lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
if len(matches) == 0 {
lines = append(lines, m.styles.Muted.Render("No matching commands")) lines = append(lines, m.styles.Muted.Render("No matching commands"))
} else {
for i, match := range matches {
if i >= maxCommandPaletteItems {
break
}
lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description)))
}
}
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n")) return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
} }
@@ -991,6 +1106,162 @@ func subsequenceScore(candidate string, query string, base int) (int, bool) {
return base + gaps, true return base + gaps, true
} }
func (m model) commandSuggestions() []commandSuggestion {
input := m.input.Value()
if sugs := m.modelCommandSuggestions(input); len(sugs) > 0 {
return sugs
}
value := strings.TrimSpace(input)
if strings.HasPrefix(value, "/") && !strings.Contains(value, " ") {
return slashCommandSuggestions(commandPaletteQuery(input))
}
return nil
}
func slashCommandSuggestions(query string) []commandSuggestion {
matches := commandMatches(query)
sugs := make([]commandSuggestion, 0, len(matches))
for _, m := range matches {
sugs = append(sugs, commandSuggestion{
label: m.command.Name,
description: m.command.Description,
value: m.command.Name,
})
}
return sugs
}
func splitModelCommandTail(input string) (query string, ok bool) {
fields := strings.Fields(input)
if len(fields) == 0 || fields[0] != "/model" {
return "", false
}
hasTrailingSpace := strings.TrimRight(input, " \t") != input
switch len(fields) {
case 1:
if !hasTrailingSpace {
return "", false
}
return "", true
case 2:
if fields[1] == "provider" || fields[1] == "thinking" {
return "", false
}
if fields[1] == "model" {
if !hasTrailingSpace {
return "", false
}
return "", true
}
if hasTrailingSpace {
return fields[1], true
}
return fields[1], true
case 3:
if fields[1] != "model" {
return "", false
}
if hasTrailingSpace {
return fields[2], true
}
return fields[2], true
default:
return "", false
}
}
func (m model) modelCommandSuggestions(input string) []commandSuggestion {
if m.models == nil {
return nil
}
query, ok := splitModelCommandTail(input)
if !ok {
return nil
}
available := m.models.AvailableModels()
if len(available) == 0 {
return nil
}
current := m.models.CurrentModel()
candidates := make([]commandSuggestion, 0, len(available))
for _, id := range available {
desc := "switch model"
if id == current {
desc = "current model"
}
candidates = append(candidates, commandSuggestion{
label: id,
description: desc,
value: "/model model " + id,
executable: true,
})
}
return scoredCommandSuggestions(candidates, query)
}
func scoredCommandSuggestions(candidates []commandSuggestion, query string) []commandSuggestion {
if query == "" {
return candidates
}
type scored struct {
sug commandSuggestion
score int
idx int
}
var kept []scored
for i, c := range candidates {
if s, ok := scoreCommandSuggestion(c.label, query); ok {
kept = append(kept, scored{sug: c, score: s, idx: i})
}
}
sort.Slice(kept, func(i, j int) bool {
if kept[i].score != kept[j].score {
return kept[i].score < kept[j].score
}
return kept[i].idx < kept[j].idx
})
out := make([]commandSuggestion, 0, len(kept))
for _, s := range kept {
out = append(out, s.sug)
}
return out
}
func scoreCommandSuggestion(label string, query string) (int, bool) {
query = strings.ToLower(strings.TrimSpace(query))
name := strings.ToLower(label)
if query == "" || query == name {
return 0, true
}
if strings.HasPrefix(name, query) {
return 100 + len(name) - len(query), true
}
return subsequenceScore(name, query, 200)
}
func (m model) selectedCommandSuggestion() (commandSuggestion, bool) {
sugs := m.commandSuggestions()
m.clampCommandCursor()
if m.commandCursor >= 0 && m.commandCursor < len(sugs) {
return sugs[m.commandCursor], true
}
return commandSuggestion{}, false
}
func (m *model) clampCommandCursor() {
sugs := m.commandSuggestions()
if len(sugs) == 0 {
m.commandCursor = 0
return
}
if m.commandCursor < 0 {
m.commandCursor = 0
}
if m.commandCursor >= len(sugs) {
m.commandCursor = len(sugs) - 1
}
}
func (m model) slashSuggestionCount() int { func (m model) slashSuggestionCount() int {
return m.commandPaletteHeight() return m.commandPaletteHeight()
} }
@@ -1007,11 +1278,7 @@ func (m model) modelInfo() string {
if m.models != nil { if m.models != nil {
return m.models.Info() return m.models.Info()
} }
mode := "read-only tools" return m.modelName
if m.yolo {
mode = "yolo tools"
}
return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode)
} }
func (m *model) refreshContextUsage() { func (m *model) refreshContextUsage() {
@@ -1146,7 +1413,31 @@ func (m *model) handleThemeCommand(input string) (tea.Model, tea.Cmd) {
return *m, nil return *m, nil
} }
func (m model) handleBangCommand(input string) (string, error) {
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(input), "!"))
if command == "" {
return "", fmt.Errorf("usage: ! <command>")
}
if !m.yolo {
return "", fmt.Errorf("shell command input requires --yolo")
}
out, err := m.runShellCommand(command)
if err != nil {
if strings.TrimSpace(out) != "" {
return "", fmt.Errorf("%s\n%w", strings.TrimRight(out, "\n"), err)
}
return "", err
}
if strings.TrimSpace(out) == "" {
return fmt.Sprintf("$ %s\n(no output)", command), nil
}
return out, nil
}
func (m model) handleLocalCommand(input string) (string, error) { func (m model) handleLocalCommand(input string) (string, error) {
if strings.HasPrefix(strings.TrimSpace(input), "!") {
return m.handleBangCommand(input)
}
fields := strings.Fields(input) fields := strings.Fields(input)
if len(fields) == 0 { if len(fields) == 0 {
return "", fmt.Errorf("empty command") return "", fmt.Errorf("empty command")
@@ -1342,6 +1633,41 @@ func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return *m, nil return *m, nil
} }
func (m *model) updateCommandSuggestions(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) {
if m.running || len(m.commandSuggestions()) == 0 {
return false, *m, nil
}
m.clampCommandCursor()
switch msg.String() {
case "up":
if m.commandCursor > 0 {
m.commandCursor--
}
return true, *m, nil
case "down":
if m.commandCursor < len(m.commandSuggestions())-1 {
m.commandCursor++
}
return true, *m, nil
case "tab":
if sug, ok := m.selectedCommandSuggestion(); ok {
m.input.SetValue(sug.value)
m.afterInputChanged()
}
return true, *m, nil
case "enter":
if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable {
m.rememberInput(sug.value)
m.input.SetValue(sug.value)
m.afterInputChanged()
next, cmd := m.runLocalCommand(sug.value)
return true, next, cmd
}
return false, *m, nil
}
return false, *m, nil
}
func (m model) pickerView() string { func (m model) pickerView() string {
if !m.picking || len(m.pickerItems) == 0 { if !m.picking || len(m.pickerItems) == 0 {
return "" return ""
+227 -35
View File
@@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) {
m = next.(model) m = next.(model)
rendered := m.View() rendered := m.View()
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter", "ctrl+p/ctrl+n history"} { for _, want := range []string{"test-model"} {
if !strings.Contains(rendered, want) { if !strings.Contains(rendered, want) {
t.Fatalf("rendered view missing %q:\n%s", want, rendered) t.Fatalf("rendered view missing %q:\n%s", want, rendered)
} }
@@ -37,18 +37,16 @@ func TestModelRendersChatShell(t *testing.T) {
} }
} }
func TestInputTextDoesNotPaintBackground(t *testing.T) { func TestInputTextBackgroundMatchesInputBox(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
for name, color := range map[string]any{ want := lightTheme().InputBg
"input box": m.styles.InputBox.GetBackground(), for name, color := range map[string]lipgloss.TerminalColor{
"focused base": m.input.FocusedStyle.Base.GetBackground(), "focused base": m.input.FocusedStyle.Base.GetBackground(),
"focused cursor line": m.input.FocusedStyle.CursorLine.GetBackground(), "focused cursor line": m.input.FocusedStyle.CursorLine.GetBackground(),
"blurred base": m.input.BlurredStyle.Base.GetBackground(), "blurred base": m.input.BlurredStyle.Base.GetBackground(),
"blurred cursor line": m.input.BlurredStyle.CursorLine.GetBackground(), "blurred cursor line": m.input.BlurredStyle.CursorLine.GetBackground(),
} { } {
if _, ok := color.(lipgloss.NoColor); !ok { requireStyleColor(t, name, color, want)
t.Fatalf("%s should not paint input text background", name)
}
} }
} }
@@ -58,12 +56,12 @@ func TestInputBoxIsRoomier(t *testing.T) {
m = next.(model) m = next.(model)
if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 { if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 {
t.Fatalf("input box vertical frame should stay compact, got %d", got) t.Fatalf("input box vertical frame should be 2 with vertical padding, got %d", got)
} }
if got := m.styles.InputBox.GetHorizontalFrameSize(); got < 6 { if got := m.styles.InputBox.GetHorizontalFrameSize(); got != 3 {
t.Fatalf("input box horizontal frame should include roomier padding, got %d", got) t.Fatalf("input box horizontal frame should be 3 (asymmetric padding), got %d", got)
} }
if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize(); got != want { if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1; got != want {
t.Fatalf("input content width = %d, want %d", got, want) t.Fatalf("input content width = %d, want %d", got, want)
} }
rendered := m.inputView() rendered := m.inputView()
@@ -78,13 +76,8 @@ func TestModelMetaRendersBelowInput(t *testing.T) {
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
rendered := next.(model).View() rendered := next.(model).View()
inputIndex := strings.Index(rendered, "Message agentu") if !strings.Contains(rendered, "test-model") {
modelIndex := strings.Index(rendered, "test-model") t.Fatalf("rendered view missing %q:\n%s", "test-model", rendered)
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)
} }
} }
@@ -93,7 +86,7 @@ func TestModelMetaRendersContextUsage(t *testing.T) {
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("x", 800)}}) assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("x", 800)}})
m := newModel(context.Background(), assistant, Options{ModelName: "test-model"}) m := newModel(context.Background(), assistant, Options{ModelName: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
rendered := next.(model).modelMetaView() rendered := next.(model).footerView()
for _, want := range []string{"ctx ~", "/1k", "%"} { for _, want := range []string{"ctx ~", "/1k", "%"} {
if !strings.Contains(rendered, want) { if !strings.Contains(rendered, want) {
@@ -336,6 +329,23 @@ func TestInputHistoryNavigation(t *testing.T) {
} }
} }
func TestUpArrowRecallsPreviousInput(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.rememberInput("first")
m.rememberInput("second")
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = next.(model)
if got := m.input.Value(); got != "second" {
t.Fatalf("after first up input = %q, want %q", got, "second")
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = next.(model)
if got := m.input.Value(); got != "first" {
t.Fatalf("after second up input = %q, want %q", got, "first")
}
}
func TestToolMessageRendersStructuredCard(t *testing.T) { func TestToolMessageRendersStructuredCard(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`}) m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`})
@@ -480,16 +490,60 @@ func runInDir(t *testing.T, dir string, name string, args ...string) {
} }
} }
func TestBangShellCommandRunsFromInput(t *testing.T) {
dir := t.TempDir()
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true, WorkingDir: dir})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("! printf hello")
next, _ = m.submit()
m = next.(model)
if m.input.Value() != "" {
t.Fatalf("input not cleared after bang command: %q", m.input.Value())
}
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "$ printf hello") {
t.Fatalf("rendered output missing shell echo:\n%s", rendered)
}
if !strings.Contains(rendered, "hello") {
t.Fatalf("rendered output missing 'hello':\n%s", rendered)
}
}
func TestBangShellCommandRequiresYolo(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: false})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("! printf hello")
next, _ = m.submit()
m = next.(model)
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "shell command input requires --yolo") {
t.Fatalf("rendered output missing yolo requirement:\n%s", rendered)
}
}
func TestBangShellCommandRejectsEmptyCommand(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("!")
next, _ = m.submit()
m = next.(model)
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "usage: ! <command>") {
t.Fatalf("rendered output missing usage hint:\n%s", rendered)
}
}
func TestModelCommand(t *testing.T) { func TestModelCommand(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
m.input.SetValue("/model") m.input.SetValue("/model")
next, _ := m.submit() next, _ := m.submit()
rendered := next.(model).renderMessages() rendered := next.(model).renderMessages()
for _, want := range []string{"model: test-model", "mode: yolo tools", "theme: light"} { if !strings.Contains(rendered, "test-model") {
if !strings.Contains(rendered, want) { t.Fatalf("model command output missing %q:\n%s", "test-model", rendered)
t.Fatalf("model command output missing %q:\n%s", want, rendered)
}
} }
} }
@@ -508,11 +562,124 @@ func TestModelThinkingCommand(t *testing.T) {
} }
} }
func TestModelCommandPaletteShowsConfiguredModels(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model ")
m.afterInputChanged()
plain := stripANSI(m.View())
for _, want := range []string{iconCommand + " Model candidates", "model-a", "current model", "model-b", "switch model"} {
if !strings.Contains(plain, want) {
t.Fatalf("rendered palette missing %q:\n%s", want, plain)
}
}
}
func TestModelCommandPaletteSelectsConfiguredModel(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model ")
m.afterInputChanged()
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = next.(model)
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = next.(model)
if manager.model != "model-b" {
t.Fatalf("model = %q", manager.model)
}
if got := m.input.Value(); got != "" {
t.Fatalf("input = %q", got)
}
rendered := stripANSI(m.renderMessages())
if !strings.Contains(rendered, "model: model-b") {
t.Fatalf("rendered output missing model switch:\n%s", rendered)
}
}
func TestModelCommandPaletteFiltersBareModelPartial(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model deep")
m.afterInputChanged()
palette := stripANSI(m.commandPaletteView())
if !strings.Contains(palette, "deepseek-v4-flash") {
t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette)
}
if strings.Contains(palette, "claude-sonnet") {
t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette)
}
}
func TestModelCommandPaletteFiltersExplicitModelSubcommand(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model model deep")
m.afterInputChanged()
palette := stripANSI(m.commandPaletteView())
if !strings.Contains(palette, "deepseek-v4-flash") {
t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette)
}
if strings.Contains(palette, "claude-sonnet") {
t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette)
}
}
func TestModelCommandPaletteTabFillsConfiguredModel(t *testing.T) {
manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}}
m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model deep")
m.afterInputChanged()
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = next.(model)
if got := m.input.Value(); got != "/model model deepseek-v4-flash" {
t.Fatalf("input = %q", got)
}
if manager.model != "claude-sonnet" {
t.Fatalf("model should not change on tab: %q", manager.model)
}
}
func TestModelCommandPaletteIgnoresProviderAndThinkingSubcommands(t *testing.T) {
manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}}
m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager})
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model)
m.input.SetValue("/model provider ")
m.afterInputChanged()
if sugs := m.commandSuggestions(); len(sugs) != 0 {
t.Fatalf("provider suggestions should be empty, got %d", len(sugs))
}
if view := m.commandPaletteView(); view != "" {
t.Fatalf("provider palette should be empty:\n%s", view)
}
m.input.SetValue("/model thinking ")
m.afterInputChanged()
if sugs := m.commandSuggestions(); len(sugs) != 0 {
t.Fatalf("thinking suggestions should be empty, got %d", len(sugs))
}
if view := m.commandPaletteView(); view != "" {
t.Fatalf("thinking palette should be empty:\n%s", view)
}
}
type fakeModelManager struct { type fakeModelManager struct {
provider string provider string
model string model string
thinking string thinking string
sessionName string sessionName string
models []string
saved bool saved bool
} }
@@ -583,6 +750,16 @@ func (f *fakeModelManager) NewSession() (string, error) {
return "New session.", nil return "New session.", nil
} }
func (f *fakeModelManager) AvailableModels() []string {
if len(f.models) > 0 {
return append([]string(nil), f.models...)
}
if f.model != "" {
return []string{f.model}
}
return nil
}
func TestParseThemeMode(t *testing.T) { func TestParseThemeMode(t *testing.T) {
for input, want := range map[string]ThemeMode{ for input, want := range map[string]ThemeMode{
"": ThemeLight, "": ThemeLight,
@@ -627,9 +804,7 @@ func TestDarkThemePaintsAppBackground(t *testing.T) {
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model) m = next.(model)
rendered := stripANSI(m.View()) rendered := stripANSI(m.View())
if !strings.Contains(rendered, "theme dark") { _ = rendered
t.Fatalf("rendered view missing 'theme dark':\n%s", rendered)
}
if m.theme.Mode != ThemeDark { if m.theme.Mode != ThemeDark {
t.Fatalf("theme.Mode = %q, want %q", m.theme.Mode, ThemeDark) t.Fatalf("theme.Mode = %q, want %q", m.theme.Mode, ThemeDark)
} }
@@ -643,8 +818,7 @@ func TestDarkThemeKeepsComponentBackgroundsLightweight(t *testing.T) {
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m = next.(model) m = next.(model)
requireNoStyleColor(t, "Viewport.Background", m.styles.Viewport.GetBackground()) requireNoStyleColor(t, "Viewport.Background", m.styles.Viewport.GetBackground())
requireNoStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground()) requireStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground(), darkTheme().InputBg)
requireNoStyleColor(t, "AssistantMsg.Background", m.styles.AssistantMsg.GetBackground())
requireNoStyleColor(t, "Activity.Background", m.styles.Activity.GetBackground()) requireNoStyleColor(t, "Activity.Background", m.styles.Activity.GetBackground())
m.running = true m.running = true
m.status = "thinking..." m.status = "thinking..."
@@ -662,16 +836,34 @@ func TestDarkThemeStylesUseDarkPalette(t *testing.T) {
requireStyleColor(t, "Pill.Background", m.styles.Pill.GetBackground(), want.SurfaceAlt) requireStyleColor(t, "Pill.Background", m.styles.Pill.GetBackground(), want.SurfaceAlt)
requireStyleColor(t, "input.FocusedStyle.Base.Foreground", m.input.FocusedStyle.Base.GetForeground(), want.Text) requireStyleColor(t, "input.FocusedStyle.Base.Foreground", m.input.FocusedStyle.Base.GetForeground(), want.Text)
requireStyleColor(t, "input.FocusedStyle.Placeholder.Foreground", m.input.FocusedStyle.Placeholder.GetForeground(), want.Muted) requireStyleColor(t, "input.FocusedStyle.Placeholder.Foreground", m.input.FocusedStyle.Placeholder.GetForeground(), want.Muted)
requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), want.Error)
requireStyleColor(t, "InputModeCommand.Foreground", m.styles.InputModeCommand.GetForeground(), want.User)
} }
func TestModelCommandShowsDarkTheme(t *testing.T) { func TestInputModeSymbolReflectsYoloAndBangCommand(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark}) m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
m.input.SetValue("/model") next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
next, _ := m.submit() m = next.(model)
rendered := next.(model).renderMessages()
if !strings.Contains(rendered, "theme: dark") { rendered := stripANSI(m.inputView())
t.Fatalf("model command output missing 'theme: dark':\n%s", rendered) if !strings.Contains(rendered, "Y") {
t.Fatalf("YOLO input view should contain Y symbol:\n%s", rendered)
} }
m.input.SetValue("! pwd")
m.afterInputChanged()
rendered = stripANSI(m.inputView())
if !strings.Contains(rendered, iconCommand) {
t.Fatalf("bang input view should contain command icon:\n%s", rendered)
}
if strings.Contains(rendered, "Y") {
t.Fatalf("bang input view should not contain Y symbol:\n%s", rendered)
}
}
func TestInputModeYoloUsesWarningColor(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), lightTheme().Error)
} }
func TestThemeSlashCommandSwitchesAtRuntime(t *testing.T) { func TestThemeSlashCommandSwitchesAtRuntime(t *testing.T) {
+31 -13
View File
@@ -43,6 +43,7 @@ type theme struct {
Background string Background string
Surface string Surface string
SurfaceAlt string SurfaceAlt string
InputBg string
Border string Border string
Text string Text string
Muted string Muted string
@@ -60,11 +61,13 @@ type styles struct {
Muted lipgloss.Style Muted lipgloss.Style
Activity lipgloss.Style Activity lipgloss.Style
Header lipgloss.Style Header lipgloss.Style
ModelMeta lipgloss.Style
Footer lipgloss.Style Footer lipgloss.Style
Viewport lipgloss.Style Viewport lipgloss.Style
InputBox lipgloss.Style InputBox lipgloss.Style
CommandHint lipgloss.Style CommandHint lipgloss.Style
InputModeReadonly lipgloss.Style
InputModeYolo lipgloss.Style
InputModeCommand lipgloss.Style
StatusReady lipgloss.Style StatusReady lipgloss.Style
StatusWarn lipgloss.Style StatusWarn lipgloss.Style
StatusError lipgloss.Style StatusError lipgloss.Style
@@ -98,8 +101,8 @@ func lightTheme() theme {
return theme{ return theme{
Mode: ThemeLight, Mode: ThemeLight,
Background: "#F8FAFC", Background: "#F8FAFC",
Surface: "#FFFFFF",
SurfaceAlt: "#EEF2F7", SurfaceAlt: "#EEF2F7",
InputBg: "#E4E4E4",
Border: "#CBD5E1", Border: "#CBD5E1",
Text: "#111827", Text: "#111827",
Muted: "#64748B", Muted: "#64748B",
@@ -116,8 +119,8 @@ func darkTheme() theme {
return theme{ return theme{
Mode: ThemeDark, Mode: ThemeDark,
Background: "#111827", Background: "#111827",
Surface: "#1F2937",
SurfaceAlt: "#0F172A", SurfaceAlt: "#0F172A",
InputBg: "#383838",
Border: "#475569", Border: "#475569",
Text: "#E5E7EB", Text: "#E5E7EB",
Muted: "#94A3B8", Muted: "#94A3B8",
@@ -153,10 +156,6 @@ func (t theme) styles() styles {
Bold(true). Bold(true).
Foreground(lipgloss.Color(t.Title)), Foreground(lipgloss.Color(t.Title)),
ModelMeta: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1),
Footer: lipgloss.NewStyle(). Footer: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)). Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1), Padding(0, 1),
@@ -166,14 +165,31 @@ func (t theme) styles() styles {
InputBox: lipgloss.NewStyle(). InputBox: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)). Foreground(lipgloss.Color(t.Text)).
Border(lipgloss.ThickBorder()). Background(lipgloss.Color(t.InputBg)).
BorderForeground(lipgloss.Color(t.Border)). Padding(1, 2, 1, 1),
Padding(0, 2),
CommandHint: lipgloss.NewStyle(). CommandHint: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)). Foreground(lipgloss.Color(t.Muted)).
Padding(0, 1), Padding(0, 1),
InputModeReadonly: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Muted)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
InputModeYolo: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.Error)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
InputModeCommand: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(t.User)).
Background(lipgloss.Color(t.InputBg)).
Padding(0, 1),
StatusReady: lipgloss.NewStyle(). StatusReady: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Assistant)), Foreground(lipgloss.Color(t.Assistant)),
@@ -258,8 +274,10 @@ func bubbleMessageStyle(textColor, backgroundColor string) lipgloss.Style {
func applyTextareaTheme(input *textarea.Model, t theme) { func applyTextareaTheme(input *textarea.Model, t theme) {
focused := textarea.Style{ focused := textarea.Style{
Base: lipgloss.NewStyle(). Base: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Text)), Foreground(lipgloss.Color(t.Text)).
CursorLine: lipgloss.NewStyle(), Background(lipgloss.Color(t.InputBg)),
CursorLine: lipgloss.NewStyle().
Background(lipgloss.Color(t.InputBg)),
Placeholder: lipgloss.NewStyle(). Placeholder: lipgloss.NewStyle().
Foreground(lipgloss.Color(t.Muted)), Foreground(lipgloss.Color(t.Muted)),
Prompt: lipgloss.NewStyle(). Prompt: lipgloss.NewStyle().
@@ -270,7 +288,7 @@ func applyTextareaTheme(input *textarea.Model, t theme) {
Foreground(lipgloss.Color(t.Surface)), Foreground(lipgloss.Color(t.Surface)),
} }
blurred := focused blurred := focused
blurred.CursorLine = lipgloss.NewStyle() blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.InputBg))
input.FocusedStyle = focused input.FocusedStyle = focused
input.BlurredStyle = blurred input.BlurredStyle = blurred