Files
gnoma/internal/tui/rendering.go
T
vikingowl e38cce5f1f fix(tui): security hardening, race-safety, and event handling fixes
Bundles the pending TUI work into a coherent batch. Bug fixes from
external review:

* expandPlaceholders: single-pass alternation regex over the original
  input prevents `#p\d+` / `#img\d+` tokens inside pasted content from
  being re-expanded after the bracket form is inlined.
* /incognito: gate savePromptHistory and the Ctrl+V image-write branch
  on `!m.incognito` so the no-persistence contract holds.
* history.txt: write at mode 0600 (chmod existing 0644 files), create
  parent dir at 0700, truncate to 500 entries on every save, slog.Warn
  on errors instead of swallowing.
* triggerPickerAction: guard m.config.Engine before SetModel, matching
  the /model handler.
* Picker key handler: navigation/enter/q consume, escape/ctrl+c close
  the picker AND fall through to global handlers (so streaming cancel
  and double-tap quit work with an overlay open), default swallows
  stray input.
* Paste line count: report total non-empty lines instead of newline
  count, ignoring trailing newlines (no more "+0 lines" for "abc").
* Ctrl+O restored to expand-output; Ctrl+Y is the new copy-response
  bind. /keys help text updated; picker help entries reordered.
* Tighter perms on .gnoma/pasted_image_*.png (0600).

Race-safety refactor: ApplyTheme used to mutate ~25 package-level
lipgloss styles in place. Replaced with an immutable themeStyles
snapshot and atomic.Pointer[themeStyles] swap. Readers go through a
theme() helper (one atomic load) instead of touching package vars
directly. No locks, no nested-RLock risk if rendering ever moves
off-thread.

Includes pre-existing in-flight work: TUISection in config with
persistent theme/vim settings; /copy /theme /vim slash commands;
provider-name completion; session.SetProvider for the provider picker.

Tests: placeholder_test.go (6 regression + happy-path cases including
the pasted-content collision), history_test.go (5 cases covering perms
on new and existing files, on-disk truncation, blank-input, newline
flattening), provider_test.go (provider switching + picker transitions
+ SLM gating).
2026-05-22 11:50:12 +02:00

1362 lines
39 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
xansi "github.com/charmbracelet/x/ansi"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/session"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("")
}
status := m.renderStatus()
input := m.renderInput()
topLine, bottomLine := m.renderSeparators()
// Suggestion dropdown — rendered between topLine and input.
suggestions := ""
if len(m.suggestions) > 0 {
suggestions = m.renderSuggestions()
}
suggestH := lipgloss.Height(suggestions)
// Fixed: status bar + separator + input + separator + suggestions = bottom area
statusH := lipgloss.Height(status)
inputH := lipgloss.Height(input)
chatH := m.height - statusH - inputH - 2 - suggestH
chat := m.renderChat(chatH)
// Show "new content below" indicator when scrolled up during streaming
if m.scrollOffset > 0 && m.streaming {
indicator := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true).Render(" ⬇ new content below")
topLine = indicator + topLine[len(indicator):]
}
parts := []string{chat, topLine}
if suggestions != "" {
parts = append(parts, suggestions)
}
parts = append(parts, input, bottomLine, status)
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, parts...))
if m.copyMode {
v.MouseMode = tea.MouseModeNone
} else {
v.MouseMode = tea.MouseModeCellMotion
}
v.AltScreen = true
return v
}
func (m Model) shortCwd() string {
dir := m.cwd
home, _ := os.UserHomeDir()
if strings.HasPrefix(dir, home) {
dir = "~" + dir[len(home):]
}
return dir
}
func (m Model) renderChat(height int) string {
var lines []string
// Header info — scrolls with content
status := m.session.Status()
var modelInfo string
if m.config.SLM.Active {
modelInfo = fmt.Sprintf("router (slm: %s)", m.config.SLM.Model)
} else {
modelInfo = fmt.Sprintf("%s/%s", status.Provider, status.Model)
}
lines = append(lines,
theme().sHeaderBrand.Render(" gnoma ")+" "+theme().sHeaderDim.Render("gnoma "+version),
" "+theme().sHeaderModel.Render(modelInfo)+
theme().sHeaderDim.Render(" · ")+theme().sHeaderDim.Render(m.shortCwd()),
"",
)
if len(m.messages) == 0 && !m.streaming {
lines = append(lines,
theme().sHint.Render(" Type a message and press Enter."),
theme().sHint.Render(" /help for commands, Ctrl+C to cancel or quit."),
"",
)
}
for _, msg := range m.messages {
lines = append(lines, m.renderMessage(msg)...)
}
// Elf tree view — shows active elfs with structured progress
if m.streaming && len(m.elfStates) > 0 {
lines = append(lines, m.renderElfTree()...)
}
// Transient: running tools (disappear when tool completes)
for _, name := range m.runningTools {
lines = append(lines, " "+theme().sToolOutput.Render(fmt.Sprintf("⚙ [%s] running...", name)))
}
// Transient: permission prompt (disappear when approved/denied)
if m.permPending {
lines = append(lines, m.renderPermissionPending(m.width)...)
}
// Settings panel (/config)
if m.configPanelOpen {
lines = append(lines, m.renderConfigPanel(m.width)...)
}
// Model Picker
if m.modelPickerOpen {
lines = append(lines, m.renderModelPicker(m.width)...)
}
// Provider Picker
if m.providerPickerOpen {
lines = append(lines, m.renderProviderPicker(m.width)...)
}
// Profile Picker
if m.profilePickerOpen {
lines = append(lines, m.renderProfilePicker(m.width)...)
}
// Skills Picker
if m.skillsPickerOpen {
lines = append(lines, m.renderSkillsPicker(m.width)...)
}
// Plugins Picker
if m.pluginsPickerOpen {
lines = append(lines, m.renderPluginsPicker(m.width)...)
}
// Theme Picker
if m.themePickerOpen {
lines = append(lines, m.renderThemePicker(m.width)...)
}
// Help Picker
if m.helpPickerOpen {
lines = append(lines, m.renderHelpPicker(m.width)...)
}
// Transient: session resume picker
if m.resumePending && len(m.resumeSessions) > 0 {
lines = append(lines, m.renderResumePicker(m.width)...)
}
// Streaming: show frozen thinking above live text content
if m.streaming {
maxWidth := m.width - 2
if m.thinkingBuf.Len() > 0 {
// Thinking is frozen once text starts; show dim with hollow diamond.
// Cap at 3 lines while streaming (ctrl+o expands).
const liveThinkMax = 3
thinkLines := strings.Split(wrapText(m.thinkingBuf.String(), maxWidth), "\n")
showN := len(thinkLines)
if !m.expandOutput && showN > liveThinkMax {
showN = liveThinkMax
}
for i, line := range thinkLines[:showN] {
if i == 0 {
lines = append(lines, theme().sThinkingLabel.Render("◇ ")+theme().sThinkingBody.Render(line))
} else {
lines = append(lines, theme().sThinkingBody.Render(" "+line))
}
}
if !m.expandOutput && len(thinkLines) > liveThinkMax {
lines = append(lines, theme().sHint.Render(fmt.Sprintf(" +%d lines (ctrl+o to expand)", len(thinkLines)-liveThinkMax)))
}
}
if m.streamBuf.Len() > 0 {
// Regular text content — strip model artifacts before display
liveText := sanitizeAssistantText(m.streamBuf.String())
for i, line := range strings.Split(wrapText(liveText, maxWidth), "\n") {
if i == 0 {
lines = append(lines, theme().styleAssistantLabel.Render("◆ ")+line)
} else {
lines = append(lines, " "+line)
}
}
} else if m.thinkingBuf.Len() == 0 {
lines = append(lines, theme().styleAssistantLabel.Render("◆ ")+theme().sCursor.Render("█"))
}
}
// Join all logical lines then split by newlines
raw := strings.Join(lines, "\n")
rawLines := strings.Split(raw, "\n")
// Hard-wrap any remaining overlong lines to get accurate physical line count
// for the scroll logic. Content should already be word-wrapped by renderMessage,
// but ANSI escape overhead can push a styled line past m.width.
var physLines []string
for _, line := range rawLines {
if lipgloss.Width(line) <= m.width {
physLines = append(physLines, line)
} else {
// Actually split the line using ANSI-aware hard wrap so the scroll
// offset math and the rendered content agree.
split := strings.Split(xansi.Hardwrap(line, m.width, false), "\n")
physLines = append(physLines, split...)
}
}
// Apply scroll: offset from bottom
if len(physLines) > height && height > 0 {
maxScroll := len(physLines) - height
offset := m.scrollOffset
if offset > maxScroll {
offset = maxScroll
}
end := len(physLines) - offset
start := end - height
if start < 0 {
start = 0
}
physLines = physLines[start:end]
}
// Hard truncate to exactly height lines — prevent overflow
if len(physLines) > height && height > 0 {
physLines = physLines[:height]
}
content := strings.Join(physLines, "\n")
// Pad to fill height if content is shorter
contentH := strings.Count(content, "\n") + 1
if contentH < height {
content += strings.Repeat("\n", height-contentH)
}
return content
}
func (m Model) renderMessage(msg chatMessage) []string {
var lines []string
indent := " " // 2-space indent for continuation lines
switch msg.role {
case "user":
// first line, indented continuation — word-wrapped to terminal width
maxWidth := m.width - 2 // 2 for the " " / " " prefix
msgLines := strings.Split(wrapText(msg.content, maxWidth), "\n")
for i, line := range msgLines {
if i == 0 {
lines = append(lines, theme().sUserLabel.Render(" ")+theme().sUserLabel.Render(line))
} else {
lines = append(lines, theme().sUserLabel.Render(indent+line))
}
}
lines = append(lines, "")
case "thinking":
// Thinking/reasoning content — dim italic with hollow diamond label.
// Collapsed to 3 lines by default; ctrl+o expands.
const thinkingMaxLines = 3
maxWidth := m.width - 2
msgLines := strings.Split(wrapText(msg.content, maxWidth), "\n")
showLines := len(msgLines)
if !m.expandOutput && showLines > thinkingMaxLines {
showLines = thinkingMaxLines
}
for i, line := range msgLines[:showLines] {
if i == 0 {
lines = append(lines, theme().sThinkingLabel.Render("◇ ")+theme().sThinkingBody.Render(line))
} else {
lines = append(lines, theme().sThinkingBody.Render(indent+line))
}
}
if !m.expandOutput && len(msgLines) > thinkingMaxLines {
remaining := len(msgLines) - thinkingMaxLines
lines = append(lines, theme().sHint.Render(indent+fmt.Sprintf("+%d lines (ctrl+o to expand)", remaining)))
}
lines = append(lines, "")
case "assistant":
// Render markdown with glamour; strip model-specific artifacts first.
clean := sanitizeAssistantText(msg.content)
rendered := clean
if m.mdRenderer != nil {
if md, err := m.mdRenderer.Render(clean); err == nil {
rendered = strings.TrimSpace(md)
}
}
renderedLines := strings.Split(rendered, "\n")
for i, line := range renderedLines {
if i == 0 {
lines = append(lines, theme().styleAssistantLabel.Render("◆ ")+line)
} else {
lines = append(lines, indent+line)
}
}
lines = append(lines, "")
case "tool":
maxW := m.width - len([]rune(indent))
for _, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
lines = append(lines, indent+theme().sToolOutput.Render(line))
}
case "toolresult":
resultLines := strings.Split(msg.content, "\n")
maxShow := 3 // collapsed by default
if m.expandOutput {
maxShow = len(resultLines)
}
maxW := m.width - 4 // indent(2) + indent(2)
for i, line := range resultLines {
if i >= maxShow {
remaining := len(resultLines) - maxShow
lines = append(lines, indent+indent+theme().sHint.Render(
fmt.Sprintf("+%d lines (ctrl+o to expand)", remaining)))
break
}
// Wrap this logical line into sub-lines, then diff-color each sub-line
for _, subLine := range strings.Split(wrapText(line, maxW), "\n") {
trimmed := strings.TrimSpace(subLine)
if strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "++") && len(trimmed) > 1 {
lines = append(lines, indent+indent+theme().sDiffAdd.Render(subLine))
} else if strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "--") && len(trimmed) > 1 {
lines = append(lines, indent+indent+theme().sDiffRemove.Render(subLine))
} else {
lines = append(lines, indent+indent+theme().sToolResult.Render(subLine))
}
}
}
lines = append(lines, "")
case "system":
maxW := m.width - 4 // "• "(2) + indent(2)
for i, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
if i == 0 {
lines = append(lines, theme().sSystem.Render("• "+line))
} else {
lines = append(lines, theme().sSystem.Render(indent+line))
}
}
lines = append(lines, "")
case "error":
maxW := m.width - 2 // "✗ " = 2
for _, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
lines = append(lines, theme().sError.Render("✗ "+line))
}
lines = append(lines, "")
case "cost":
lines = append(lines, theme().sHint.Render(indent+msg.content))
}
return lines
}
func (m Model) renderElfTree() []string {
if len(m.elfOrder) == 0 {
return nil
}
var lines []string
// Count running vs done
running := 0
for _, id := range m.elfOrder {
if p, ok := m.elfStates[id]; ok && !p.Done {
running++
}
}
// Header
if running > 0 {
header := fmt.Sprintf("● Running %d elf", len(m.elfOrder))
if len(m.elfOrder) != 1 {
header += "s"
}
header += "…"
lines = append(lines, theme().sStatusStreaming.Render(header))
} else {
header := fmt.Sprintf("● %d elf", len(m.elfOrder))
if len(m.elfOrder) != 1 {
header += "s"
}
header += " completed"
lines = append(lines, theme().sToolOutput.Render(header))
}
for i, elfID := range m.elfOrder {
p, ok := m.elfStates[elfID]
if !ok {
continue
}
isLast := i == len(m.elfOrder)-1
// Branch character
branch := "├─"
childPrefix := "│ "
if isLast {
branch = "└─"
childPrefix = " "
}
// Main line: branch + description + stats
var stats []string
if p.ToolUses > 0 {
stats = append(stats, fmt.Sprintf("%d tool uses", p.ToolUses))
}
if p.Tokens > 0 {
stats = append(stats, formatTokens(p.Tokens))
}
statsStr := ""
if len(stats) > 0 {
statsStr = " · " + strings.Join(stats, " · ")
}
desc := p.Description
if len(statsStr) > 0 {
// Truncate description so the combined line fits on one terminal row
maxDescW := m.width - 4 - len([]rune(branch)) - len([]rune(statsStr))
if maxDescW > 10 && len([]rune(desc)) > maxDescW {
desc = string([]rune(desc)[:maxDescW-1]) + "…"
}
}
line := theme().sToolOutput.Render(branch+" ") + theme().sText.Render(desc)
if len(statsStr) > 0 {
line += theme().sToolResult.Render(statsStr)
}
lines = append(lines, line)
// Activity sub-line
var activity string
if p.Done {
if p.Error != "" {
activity = theme().sError.Render("Error: " + p.Error)
} else {
dur := p.Duration.Round(time.Millisecond)
activity = theme().sToolOutput.Render(fmt.Sprintf("Done (%s)", dur))
}
} else {
activity = p.Activity
if activity == "" {
activity = "working…"
}
activity = theme().sToolResult.Render(activity)
}
// Wrap activity so long error/path strings don't overflow the terminal.
actPrefix := childPrefix + "└─ "
actMaxW := m.width - len([]rune(actPrefix))
actLines := strings.Split(wrapText(activity, actMaxW), "\n")
for j, al := range actLines {
if j == 0 {
lines = append(lines, theme().sToolResult.Render(actPrefix)+al)
} else {
lines = append(lines, theme().sToolResult.Render(childPrefix+" ")+al)
}
}
}
lines = append(lines, "") // spacing after tree
return lines
}
func formatTokens(tokens int) string {
if tokens >= 1_000_000 {
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
}
if tokens >= 1_000 {
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
}
return fmt.Sprintf("%d tokens", tokens)
}
func (m Model) renderSeparators() (string, string) {
lineColor := theme().cSurface // default dim
modeLabel := ""
if m.config.Permissions != nil {
mode := m.config.Permissions.Mode()
lineColor = ModeColor(mode)
}
// Incognito adds amber overlay
if m.incognito {
lineColor = theme().cYellow
}
// Permission pending — flash the line with command summary
if m.permPending {
lineColor = theme().cRed
hint := shortPermHint(m.permToolName, m.permArgs)
modeLabel = "⚠ " + hint + " [y/n]"
}
lineStyle := lipgloss.NewStyle().Foreground(lineColor)
labelStyle := lipgloss.NewStyle().Foreground(lineColor).Bold(true)
// Top line: ─── with mode label on right if any (like permission pending hint)
var topLine string
if modeLabel != "" {
label := " " + modeLabel + " "
labelW := lipgloss.Width(labelStyle.Render(label))
lineW := m.width - labelW
if lineW < 4 {
lineW = 4
}
leftW := lineW - 2
rightW := 2
topLine = lineStyle.Render(strings.Repeat("─", leftW)) +
labelStyle.Render(label) +
lineStyle.Render(strings.Repeat("─", rightW))
} else {
topLine = lineStyle.Render(strings.Repeat("─", m.width))
}
bottomLine := lineStyle.Render(strings.Repeat("─", m.width))
return topLine, bottomLine
}
func (m Model) renderInput() string {
view := m.input.View()
// Ghost text only when there's no dropdown (dropdown handles completion when visible)
if m.suggestion != "" && len(m.suggestions) == 0 {
rest := strings.TrimPrefix(m.suggestion, m.input.Value())
if rest != "" {
ghost := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(rest + " (tab)")
view += ghost
}
}
return view
}
func (m Model) renderStatus() string {
status := m.session.Status()
// Left: provider + model + incognito
var provModel string
if m.config.SLM.Active {
provModel = " router"
} else {
provModel = fmt.Sprintf(" %s/%s", status.Provider, status.Model)
}
if m.incognito {
provModel += " " + theme().sStatusIncognito.Render("🔒")
}
if !status.ToolsAvailable {
provModel += " " + theme().sStatusDim.Render("text-only")
}
modeStr := ""
if m.config.Permissions != nil {
mode := m.config.Permissions.Mode()
modeColor := ModeColor(mode)
modeStyle := lipgloss.NewStyle().
Background(modeColor).
Foreground(theme().cMantle).
Bold(true).
Padding(0, 1)
modeStr = " " + modeStyle.Render(strings.ToUpper(string(mode)))
}
left := theme().sStatusHighlight.Render(provModel) + modeStr + renderSLMBadge(m.config.SLM) + renderProfileBadge(m.config.Profile)
// Center: cwd + git branch
dir := filepath.Base(m.cwd)
centerParts := []string{"📁 " + dir}
if m.gitBranch != "" {
centerParts = append(centerParts, theme().sStatusBranch.Render(" "+m.gitBranch))
}
center := theme().sStatusDim.Render(strings.Join(centerParts, ""))
// Right: context bar + tokens + turns
right := renderContextBar(status) + theme().sStatusDim.Render(fmt.Sprintf(" │ turns: %d ", status.TurnCount))
if m.quitHint {
right = lipgloss.NewStyle().Foreground(theme().cRed).Bold(true).Render("ctrl+c to quit ") + theme().sStatusDim.Render("│ ") + right
}
if m.copyMode {
right = lipgloss.NewStyle().Foreground(theme().cYellow).Bold(true).Render("✂ COPY ") + theme().sStatusDim.Render("│ ") + right
}
if m.streaming {
right = theme().sStatusStreaming.Render("● streaming ") + theme().sStatusDim.Render("│ ") + right
}
// Compose with spacing
leftW := lipgloss.Width(left)
centerW := lipgloss.Width(center)
rightW := lipgloss.Width(right)
gap1 := (m.width-leftW-centerW-rightW)/2 - 1
if gap1 < 1 {
gap1 = 1
}
gap2 := m.width - leftW - gap1 - centerW - rightW
if gap2 < 0 {
gap2 = 0
}
bar := left + strings.Repeat(" ", gap1) + center + strings.Repeat(" ", gap2) + right
return theme().sStatusBar.Width(m.width).Render(bar)
}
// renderContextBar draws a compact [████░░░░] 45% progress bar for the context window.
func renderContextBar(s session.Status) string {
pct := s.TokenPercent
if pct <= 0 && s.TokensUsed == 0 {
return theme().sStatusDim.Render("ctx: —")
}
const barWidth = 8
filled := (pct * barWidth) / 100
if filled > barWidth {
filled = barWidth
}
empty := barWidth - filled
var barColor lipgloss.Style
switch s.TokenState {
case "critical":
barColor = lipgloss.NewStyle().Foreground(theme().cRed).Bold(true)
case "warning":
barColor = lipgloss.NewStyle().Foreground(theme().cYellow)
default:
barColor = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
}
dimBlock := theme().sStatusDim
bar := barColor.Render(strings.Repeat("█", filled)) + dimBlock.Render(strings.Repeat("░", empty))
label := fmt.Sprintf(" %d%%", pct)
var labelStyle lipgloss.Style
switch s.TokenState {
case "critical":
labelStyle = lipgloss.NewStyle().Foreground(theme().cRed).Bold(true)
case "warning":
labelStyle = lipgloss.NewStyle().Foreground(theme().cYellow)
default:
labelStyle = theme().sStatusDim
}
return "[" + bar + "]" + labelStyle.Render(label)
}
// renderSLMBadge produces a short " · slm: <model> [tools]" badge for the
// status bar's left side. Returns "" when SLM is disabled or unconfigured.
func renderSLMBadge(info SLMInfo) string {
if !info.Enabled {
return ""
}
if !info.Active {
return theme().sStatusDim.Render(" · slm: ✗")
}
label := " · slm: " + info.Model
if info.Tools {
label += " ⚙"
}
return theme().sStatusDim.Render(label)
}
// renderProfileBadge produces " · profile: <name>" for the status bar's
// left side. Returns "" when profile mode is not engaged so legacy
// single-config installations don't carry a "profile: default" badge
// that adds noise without information.
func renderProfileBadge(info ProfileInfo) string {
if !info.Active || info.Name == "" {
return ""
}
return theme().sStatusDim.Render(" · profile: " + info.Name)
}
// formatTurnUsage produces a compact token summary for a single turn.
func formatTurnUsage(u message.Usage) string {
parts := []string{fmt.Sprintf("in: %d", u.InputTokens), fmt.Sprintf("out: %d", u.OutputTokens)}
if u.CacheReadTokens > 0 {
parts = append(parts, fmt.Sprintf("cache: %d", u.CacheReadTokens))
}
return strings.Join(parts, " · ")
}
// renderSuggestions renders the slash-command autocomplete dropdown.
func (m Model) renderSuggestions() string {
const maxVisible = 6
sCmd := lipgloss.NewStyle().Foreground(theme().cPurple).Bold(true)
sDesc := lipgloss.NewStyle().Foreground(theme().cSubtext)
sSelectedCmd := lipgloss.NewStyle().
Background(theme().cSurface).
Foreground(theme().cPurple).
Bold(true)
sSelectedDesc := lipgloss.NewStyle().
Background(theme().cSurface).
Foreground(theme().cText)
// Determine visible window around selected item
start := 0
end := len(m.suggestions)
if end > maxVisible {
start = m.suggIdx - maxVisible/2
if start < 0 {
start = 0
}
if start+maxVisible > len(m.suggestions) {
start = len(m.suggestions) - maxVisible
}
end = start + maxVisible
}
// Measure widest command name for alignment
maxCmdW := 0
for _, s := range m.suggestions[start:end] {
if len(s.name) > maxCmdW {
maxCmdW = len(s.name)
}
}
innerW := m.width - 6
if innerW < 40 {
innerW = 40
}
var bodyLines []string
for i, entry := range m.suggestions[start:end] {
idx := start + i
pad := strings.Repeat(" ", maxCmdW-len(entry.name)+1)
desc := entry.desc
// Truncate desc to fit
maxDescW := innerW - maxCmdW - 2
if maxDescW > 0 && len(desc) > maxDescW {
desc = desc[:maxDescW-1] + "…"
}
if idx == m.suggIdx {
line := sSelectedCmd.Render(entry.name) + sSelectedDesc.Render(pad+desc)
// Pad to fill width
lineW := lipgloss.Width(line)
if lineW < innerW {
line += sSelectedDesc.Render(strings.Repeat(" ", innerW-lineW))
}
bodyLines = append(bodyLines, line)
} else {
bodyLines = append(bodyLines, sCmd.Render(entry.name)+sDesc.Render(pad+desc))
}
}
if len(m.suggestions) > maxVisible {
extra := len(m.suggestions) - maxVisible
bodyLines = append(bodyLines, theme().sHint.Render(fmt.Sprintf(" +%d more", extra)))
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cSurface).
Padding(0, 1).
Width(innerW + 2).
Render(strings.Join(bodyLines, "\n"))
return box
}
// renderConfigPanel renders the interactive /config settings overlay.
func (m Model) renderConfigPanel(width int) []string {
perm := m.config.Permissions
status := m.session.Status()
// Build the setting rows
type row struct{ label, value string }
var rows []row
for _, setting := range m.getActiveSettings() {
switch setting {
case "provider":
provVal := "none"
if status.Provider != "" {
provVal = status.Provider + " (press Enter to switch)"
}
rows = append(rows, row{"Provider", provVal})
case "model":
modelVal := "none"
if status.Model != "" {
modelVal = status.Model + " (press Enter to switch)"
}
rows = append(rows, row{"Model", modelVal})
case "permission":
permVal := "—"
if perm != nil {
permVal = string(perm.Mode())
}
rows = append(rows, row{"Permission", permVal})
case "incognito":
incogVal := "Off"
if m.incognito {
incogVal = "On"
}
rows = append(rows, row{"Incognito", incogVal})
}
}
// Measure widest label for alignment
maxLabel := 0
for _, r := range rows {
if len(r.label) > maxLabel {
maxLabel = len(r.label)
}
}
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
sLabel := lipgloss.NewStyle().Foreground(theme().cSubtext)
innerW := width - 8 // border(2) + padding(2) each side = 8
if innerW < 30 {
innerW = 30
}
var bodyLines []string
for i, r := range rows {
labelPad := strings.Repeat(" ", maxLabel-len(r.label))
if i == m.configSelected {
line := fmt.Sprintf("%s%s: %s", r.label, labelPad, r.value)
// Pad to full inner width so the highlight fills the row
if lipgloss.Width(line) < innerW {
line += strings.Repeat(" ", innerW-lipgloss.Width(line))
}
bodyLines = append(bodyLines, sSelected.Render(line))
} else {
bodyLines = append(bodyLines, sLabel.Render(r.label+labelPad+": ")+sItem.Render(r.value))
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑↓ Navigate Enter Select/Toggle Esc Exit"))
body := strings.Join(bodyLines, "\n")
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2) // +2 for padding
box := boxStyle.Render(titleStyle.Render("Settings") + "\n\n" + body)
return []string{"", box, ""}
}
// wrapText word-wraps text at word boundaries, preserving existing newlines.
// Uses ANSI-aware wrapping so lipgloss-styled text is measured correctly.
func wrapText(text string, width int) string {
if width <= 0 {
return text
}
return xansi.Wordwrap(text, width, "")
}
func (m Model) renderPermissionPending(width int) []string {
innerW := width - 8
if innerW < 40 {
innerW = 40
}
var bodyLines []string
bodyLines = append(bodyLines, lipgloss.NewStyle().Foreground(theme().cRed).Bold(true).Render("Permission Requested"))
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, fmt.Sprintf("Tool: %s", lipgloss.NewStyle().Foreground(theme().cPurple).Bold(true).Render(m.permToolName)))
bodyLines = append(bodyLines, "")
var details []string
switch m.permToolName {
case "bash":
var a struct{ Command string }
if json.Unmarshal(m.permArgs, &a) == nil && a.Command != "" {
details = append(details, "Command to run:")
cmdLines := strings.Split(wrapText(a.Command, innerW-4), "\n")
for _, cl := range cmdLines {
details = append(details, " "+theme().sText.Render(cl))
}
}
case "fs.write", "fs_write":
var a struct {
Path string `json:"file_path"`
Content string `json:"content"`
}
if json.Unmarshal(m.permArgs, &a) == nil && a.Path != "" {
details = append(details, fmt.Sprintf("Write to: %s", theme().sStatusBranch.Render(a.Path)))
if a.Content != "" {
details = append(details, "Preview:")
previewLines := strings.Split(diffPreviewWrite(a.Content), "\n")
for _, pl := range previewLines {
details = append(details, " "+pl)
}
}
}
case "fs.edit", "fs_edit":
var a struct {
Path string `json:"file_path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
}
if json.Unmarshal(m.permArgs, &a) == nil && a.Path != "" {
details = append(details, fmt.Sprintf("Edit: %s", theme().sStatusBranch.Render(a.Path)))
previewLines := strings.Split(diffPreviewEdit(a.OldString, a.NewString), "\n")
for _, pl := range previewLines {
details = append(details, " "+pl)
}
}
default:
var pretty bytes.Buffer
if err := json.Indent(&pretty, m.permArgs, " ", " "); err == nil {
details = append(details, "Arguments:")
for _, line := range strings.Split(pretty.String(), "\n") {
details = append(details, " "+theme().sText.Render(line))
}
} else if len(m.permArgs) > 0 {
details = append(details, "Arguments:", " "+theme().sText.Render(string(m.permArgs)))
}
}
for _, line := range details {
bodyLines = append(bodyLines, strings.Split(wrapText(line, innerW), "\n")...)
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("y Approve / n Deny / Esc Cancel"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cRed).
Padding(1, 2).
Width(innerW + 4)
box := boxStyle.Render(strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderModelPicker(width int) []string {
if m.config.Router == nil {
return []string{"", "No models configured", ""}
}
arms := m.config.Router.Arms()
sort.Slice(arms, func(i, j int) bool {
return string(arms[i].ID) < string(arms[j].ID)
})
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
var bodyLines []string
for i, arm := range arms {
tag := ""
if arm.IsCLIAgent {
tag = " [CLI]"
} else if arm.IsLocal {
tag = " [Local]"
}
lineText := fmt.Sprintf(" %s%s", arm.ID, tag)
if i == m.pickerSelected {
if lipgloss.Width(lineText) < innerW {
lineText += strings.Repeat(" ", innerW-lipgloss.Width(lineText))
}
bodyLines = append(bodyLines, sSelected.Render(lineText))
} else {
bodyLines = append(bodyLines, sItem.Render(lineText))
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑/↓ Navigate / Enter Select / Esc Exit"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Select Model") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderProviderPicker(width int) []string {
providers := m.getAvailableProviders()
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
var bodyLines []string
if len(providers) == 0 {
bodyLines = append(bodyLines, theme().sHint.Render(" No providers configured"))
} else {
for i, prov := range providers {
activeTag := ""
status := m.session.Status()
if prov == status.Provider {
activeTag = " (active)"
}
lineText := fmt.Sprintf(" %s%s", prov, activeTag)
if i == m.pickerSelected {
if lipgloss.Width(lineText) < innerW {
lineText += strings.Repeat(" ", innerW-lipgloss.Width(lineText))
}
bodyLines = append(bodyLines, sSelected.Render(lineText))
} else {
bodyLines = append(bodyLines, sItem.Render(lineText))
}
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑/↓ Navigate / Enter Select / Esc Exit"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Select Provider") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderProfilePicker(width int) []string {
profiles := m.config.ProfileNames
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
var bodyLines []string
if len(profiles) == 0 {
bodyLines = append(bodyLines, theme().sHint.Render(" No profiles configured"))
} else {
for i, name := range profiles {
lineText := " " + name
if name == m.config.Profile.Name {
lineText += " (active)"
}
if i == m.pickerSelected {
if lipgloss.Width(lineText) < innerW {
lineText += strings.Repeat(" ", innerW-lipgloss.Width(lineText))
}
bodyLines = append(bodyLines, sSelected.Render(lineText))
} else {
bodyLines = append(bodyLines, sItem.Render(lineText))
}
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑/↓ Navigate / Enter Select / Esc Exit"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Select Profile") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderSkillsPicker(width int) []string {
var names []string
if m.config.Skills != nil {
names = m.config.Skills.Names()
sort.Strings(names)
}
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
var bodyLines []string
if len(names) == 0 {
bodyLines = append(bodyLines, theme().sHint.Render(" No skills registered"))
} else {
for i, name := range names {
lineText := " /" + name
if i == m.pickerSelected {
if lipgloss.Width(lineText) < innerW {
lineText += strings.Repeat(" ", innerW-lipgloss.Width(lineText))
}
bodyLines = append(bodyLines, sSelected.Render(lineText))
} else {
bodyLines = append(bodyLines, sItem.Render(lineText))
}
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑/↓ Navigate / Enter Select / Esc Exit"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Select Skill") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderPluginsPicker(width int) []string {
plugins := m.config.PluginInfos
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
sDisabled := lipgloss.NewStyle().Foreground(theme().cOverlay)
var bodyLines []string
if len(plugins) == 0 {
bodyLines = append(bodyLines, theme().sHint.Render(" No plugins installed"))
} else {
for i, p := range plugins {
status := "enabled"
if !p.Enabled {
status = "disabled"
}
lineText := fmt.Sprintf(" %s (%s, v%s) [%s]", p.Name, p.Scope, p.Version, status)
if i == m.pickerSelected {
if lipgloss.Width(lineText) < innerW {
lineText += strings.Repeat(" ", innerW-lipgloss.Width(lineText))
}
bodyLines = append(bodyLines, sSelected.Render(lineText))
} else {
if p.Enabled {
bodyLines = append(bodyLines, sItem.Render(lineText))
} else {
bodyLines = append(bodyLines, sDisabled.Render(lineText))
}
}
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑/↓ Navigate / Esc Exit"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Plugins") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderThemePicker(width int) []string {
themes := []string{"catppuccin", "nord", "gruvbox", "monokai", "solarized-light"}
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
var bodyLines []string
for i, name := range themes {
lineText := " " + name
if strings.EqualFold(name, theme().name) || (name == "solarized-light" && strings.EqualFold(theme().name, "solarized_light")) {
lineText += " (active)"
}
if i == m.pickerSelected {
if lipgloss.Width(lineText) < innerW {
lineText += strings.Repeat(" ", innerW-lipgloss.Width(lineText))
}
bodyLines = append(bodyLines, sSelected.Render(lineText))
} else {
bodyLines = append(bodyLines, sItem.Render(lineText))
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑/↓ Navigate / Enter Select / Esc Exit"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Select Theme") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderHelpPicker(width int) []string {
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cPurple).Bold(true)
sCmd := lipgloss.NewStyle().Foreground(theme().cPurple).Bold(true)
sDesc := lipgloss.NewStyle().Foreground(theme().cText)
sHeader := lipgloss.NewStyle().Foreground(theme().cSubtext).Underline(true).Bold(true)
var bodyLines []string
bodyLines = append(bodyLines, sHeader.Render("Slash Commands"))
maxCmdW := 12
for _, cmd := range builtinCommands {
pad := strings.Repeat(" ", maxCmdW-len(cmd.name)+1)
desc := cmd.desc
descLines := strings.Split(wrapText(desc, innerW-maxCmdW-2), "\n")
for j, dl := range descLines {
if j == 0 {
bodyLines = append(bodyLines, " "+sCmd.Render(cmd.name)+pad+sDesc.Render(dl))
} else {
bodyLines = append(bodyLines, " "+strings.Repeat(" ", maxCmdW+1)+sDesc.Render(dl))
}
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, sHeader.Render("Keyboard Shortcuts"))
shortcuts := []struct{ key, desc string }{
{"Ctrl+C", "quit / cancel streaming"},
{"Ctrl+O", "toggle expanded tool output display"},
{"Ctrl+Y", "copy latest assistant response to clipboard"},
{"Ctrl+J / Shift+Enter", "insert newline in prompt"},
{"Tab", "accept autocomplete suggestion"},
{"Esc", "close active menu / picker"},
{"Up / Down", "navigate history (on empty prompt) or menus"},
}
maxKeyW := 21
for _, s := range shortcuts {
pad := strings.Repeat(" ", maxKeyW-len(s.key)+1)
descLines := strings.Split(wrapText(s.desc, innerW-maxKeyW-2), "\n")
for j, dl := range descLines {
if j == 0 {
bodyLines = append(bodyLines, " "+sCmd.Render(s.key)+pad+sDesc.Render(dl))
} else {
bodyLines = append(bodyLines, " "+strings.Repeat(" ", maxKeyW+1)+sDesc.Render(dl))
}
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("Esc / Enter to close help menu"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cPurple).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Help & Keyboard Shortcuts") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}
func (m Model) renderResumePicker(width int) []string {
sessions := m.resumeSessions
innerW := width - 8
if innerW < 40 {
innerW = 40
}
titleStyle := lipgloss.NewStyle().Foreground(theme().cTeal).Bold(true)
sSelected := lipgloss.NewStyle().
Background(theme().cTeal).
Foreground(theme().cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(theme().cText)
var bodyLines []string
if len(sessions) == 0 {
bodyLines = append(bodyLines, theme().sHint.Render(" No saved sessions found"))
} else {
for i, s := range sessions {
title := s.Title
if title == "" {
title = "(untitled session)"
}
dateStr := s.UpdatedAt.Format("2006-01-02 15:04")
lineText := fmt.Sprintf(" %s (%s, turns: %d) - %s", title, s.Model, s.TurnCount, dateStr)
if i == m.resumeSelected {
if lipgloss.Width(lineText) < innerW {
lineText += strings.Repeat(" ", innerW-lipgloss.Width(lineText))
}
bodyLines = append(bodyLines, sSelected.Render(lineText))
} else {
bodyLines = append(bodyLines, sItem.Render(lineText))
}
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, theme().sHint.Render("↑/↓ Navigate / Enter Select / Esc Exit"))
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme().cTeal).
Padding(0, 1).
Width(innerW + 2)
box := boxStyle.Render(titleStyle.Render("Resume Session") + "\n\n" + strings.Join(bodyLines, "\n"))
return []string{"", box, ""}
}