admin 8dc496b626
Some checks failed
CI / test (push) Has been cancelled
Release / release (push) Failing after 4m36s
first commit
2026-03-08 15:40:34 +07:00

755 lines
25 KiB
Go

package tui
import (
"fmt"
"strings"
"time"
"ai-agent/internal/agent"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
func (m *Model) View() tea.View {
if !m.ready {
return tea.NewView(" initializing...")
}
var content string
rightWidth := m.width - 1
if m.sidePanel.IsVisible() {
rightWidth = m.width - m.sidePanel.width - 1
}
var rightSide strings.Builder
rightSide.WriteString(m.viewport.View())
rightSide.WriteString("\n")
rightSide.WriteString(m.styles.Divider.Render(rule(rightWidth)))
rightSide.WriteString("\n")
rightSide.WriteString(m.renderStatusLine())
rightSide.WriteString("\n")
if m.state == StateIdle {
rightSide.WriteString(m.input.View())
} else if m.state == StateWaiting {
rightSide.WriteString(m.styles.StreamHint.Render(" " + m.scramble.View() + " thinking... press Esc to cancel"))
} else {
rightSide.WriteString(m.styles.StreamHint.Render(" " + m.spin.View() + " streaming... press Esc to cancel"))
}
if m.sidePanel.IsVisible() {
panelView := m.sidePanel.View()
rightContent := rightSide.String()
panelW := m.sidePanel.width
rightW := rightWidth
leftStyle := lipgloss.NewStyle().Width(panelW).Height(m.height)
left := leftStyle.Render(panelView)
rightStyle := lipgloss.NewStyle().Width(rightW).Height(m.height)
right := rightStyle.Render(rightContent)
dividerChars := ""
for i := 0; i < m.height; i++ {
dividerChars += "│\n"
}
divider := lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7a89")).Render(dividerChars)
content = lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right)
} else {
content = rightSide.String()
}
if m.overlay != OverlayNone {
var overlay string
switch m.overlay {
case OverlayHelp:
overlay = m.renderHelpOverlay(m.width)
case OverlayCompletion:
if m.isCompletionActive() {
overlay = m.renderCompletionModal()
}
case OverlayModelPicker:
if m.modelPickerState != nil {
overlay = m.renderModelPicker()
}
case OverlayPlanForm:
if m.planFormState != nil {
overlay = m.renderPlanForm()
}
case OverlaySessionsPicker:
if m.sessionsPickerState != nil {
overlay = m.renderSessionsPicker()
}
}
if overlay != "" {
content = m.overlayOnContent(content, overlay)
}
}
var b strings.Builder
b.WriteString(content)
b.WriteString("\n")
if m.toastMgr != nil && m.toastMgr.HasToasts() {
m.toastMgr.Update()
toastStr := m.toastMgr.Render(m.width)
if toastStr != "" {
b.WriteString("\n")
b.WriteString(toastStr)
}
}
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
loc := m.tr()
switch m.state {
case StateWaiting:
v.WindowTitle = loc.WindowTitleThink
case StateStreaming:
v.WindowTitle = loc.WindowTitleStream
default:
if m.doneFlash {
v.WindowTitle = loc.WindowTitleDone
} else {
v.WindowTitle = loc.WindowTitle
}
}
return v
}
func (m *Model) renderCompletionModal() string {
cs := m.completionState
if cs == nil {
return ""
}
var b strings.Builder
var title string
switch cs.Kind {
case "command":
title = "Commands"
case "attachments":
title = "Attach Files & Agents"
case "skills":
title = "Skills"
default:
title = "Complete"
}
b.WriteString(m.styles.OverlayTitle.Render(title))
b.WriteString("\n")
b.WriteString(m.styles.CompletionFilter.Render("> " + cs.Filter.View()))
b.WriteString("\n")
if cs.Kind == "attachments" && cs.CurrentPath != "" {
b.WriteString(m.styles.CompletionCategory.Render(cs.CurrentPath + "/"))
b.WriteString("\n")
}
maxW := 40
if m.width-8 > maxW {
maxW = m.width - 8
}
if maxW > 60 {
maxW = 60
}
b.WriteString(m.styles.FocusIndicator.Render(strings.Repeat("─", maxW)))
b.WriteString("\n")
maxVisible := 10
items := cs.FilteredItems
if len(items) == 0 {
b.WriteString(m.styles.CompletionCategory.Render(" (no matches)"))
b.WriteString("\n")
} else {
start := 0
if cs.Index >= maxVisible {
start = cs.Index - maxVisible + 1
}
end := start + maxVisible
if end > len(items) {
end = len(items)
}
for i := start; i < end; i++ {
item := items[i]
prefix := " "
if i == cs.Index {
prefix = m.styles.FocusIndicator.Render("▸ ")
}
selectedMark := ""
if cs.Selected != nil {
for oi, orig := range cs.AllItems {
if orig.Label == item.Label && orig.Insert == item.Insert {
if cs.Selected[oi] {
selectedMark = m.styles.FocusIndicator.Render(" ✓")
}
break
}
}
}
label := item.Label
cat := m.styles.CompletionCategory.Render(" " + item.Category)
if i == cs.Index {
b.WriteString(prefix + m.styles.FocusIndicator.Render(label) + cat + selectedMark)
} else {
b.WriteString(prefix + label + cat + selectedMark)
}
b.WriteString("\n")
}
}
if cs.Searching {
b.WriteString(m.styles.CompletionSearching.Render(" searching..."))
b.WriteString("\n")
}
hints := "Enter=select Esc=cancel"
if cs.Kind == "attachments" && cs.CurrentPath != "" {
hints += " ←=back"
}
if cs.Selected != nil {
hints += " Tab=toggle"
}
b.WriteString(m.styles.CompletionFooter.Render(hints))
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(m.styles.OverlayBorder)).
Padding(1, 2).
Width(maxW + 4)
return box.Render(b.String())
}
// renderHeader builds:
//
// ai-agent qwen3:8b · 5 tools
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func (m *Model) renderHeader() string {
title := m.styles.HeaderTitle.Render("AI AGENT")
var infoStr string
if m.model != "" {
parts := []string{m.model}
if m.toolCount > 0 {
parts = append(parts, fmt.Sprintf("%d tools", m.toolCount))
}
if m.serverCount > 0 {
parts = append(parts, fmt.Sprintf("%d servers", m.serverCount))
}
if m.loadedFile != "" {
parts = append(parts, "ctx")
}
if m.iceEnabled {
parts = append(parts, "ICE")
}
if m.promptTokens > 0 && m.numCtx > 0 {
pct := m.promptTokens * 100 / m.numCtx
var pctStyle lipgloss.Style
switch {
case pct > 85:
pctStyle = m.styles.ContextPctHigh
case pct > 60:
pctStyle = m.styles.ContextPctMid
default:
pctStyle = m.styles.ContextPctLow
}
parts = append(parts, pctStyle.Render(contextProgressBar(pct)))
}
infoStr = m.styles.HeaderInfo.Render(strings.Join(parts, " · "))
}
titleW := lipgloss.Width(title)
infoW := lipgloss.Width(infoStr)
gap := m.width - titleW - infoW
if gap < 1 {
gap = 1
}
line := title + strings.Repeat(" ", gap) + infoStr
ruler := m.styles.HeaderRule.Render(rule(m.width))
return line + "\n" + ruler
}
func (m *Model) renderFooter() string {
var b strings.Builder
b.WriteString(m.styles.Divider.Render(rule(m.width)))
b.WriteString("\n")
b.WriteString(m.renderStatusLine())
b.WriteString("\n")
if m.state == StateIdle {
b.WriteString(m.input.View())
} else if m.state == StateWaiting {
b.WriteString(m.styles.StreamHint.Render(" " + m.scramble.View() + " thinking... press Esc to cancel"))
} else {
b.WriteString(m.styles.StreamHint.Render(" " + m.spin.View() + " streaming... press Esc to cancel"))
}
return b.String()
}
func (m *Model) renderStatusLine() string {
if m.pendingApproval != nil {
args := agent.FormatToolArgs(m.pendingApproval.Args)
promptText := m.pendingApproval.ToolName
if args != "" {
promptText += " " + args
}
if len(promptText) > 60 {
promptText = promptText[:57] + "..."
}
return m.styles.ApprovalPrompt.Render(
fmt.Sprintf(" ⚡ Allow %s? [y]es / [n]o / [a]lways", promptText),
)
}
if m.pendingPaste != "" {
lines := strings.Count(m.pendingPaste, "\n") + 1
return m.styles.StatusText.Render(
fmt.Sprintf(" Large paste (%d lines). Wrap as code block? [y/n/esc]", lines),
)
}
var parts []string
switch m.state {
case StateWaiting:
// No status line content — the hint line below shows "thinking..."
case StateStreaming:
if m.streamBuf.Len() > 0 {
parts = append(parts, m.styles.StatusText.Render(
fmt.Sprintf("%d chars", m.streamBuf.Len()),
))
}
if m.toolsPending > 0 {
parts = append(parts, m.styles.StatusText.Render(
fmt.Sprintf("%d tool(s) pending", m.toolsPending),
))
}
case StateIdle:
cfg := m.modeConfigs[m.mode]
var modeStyle lipgloss.Style
switch m.mode {
case ModeAsk:
modeStyle = m.styles.ModeAsk
case ModePlan:
modeStyle = m.styles.ModePlan
case ModeBuild:
modeStyle = m.styles.ModeBuild
}
parts = append(parts, modeStyle.Render("[ "+cfg.Label+" ]"))
dot := m.styles.StatusDot.Render("○")
label := m.styles.StatusText.Render(" ready")
parts = append(parts, dot+label)
if m.promptTokens > 0 && m.numCtx > 0 {
parts = append(parts, m.styles.StatusText.Render(
fmt.Sprintf("~%s / %s ctx", formatTokens(m.promptTokens), formatTokens(m.numCtx)),
))
}
if m.sessionEvalTotal > 0 {
parts = append(parts, m.styles.StatusText.Render(
fmt.Sprintf("%s out (%d turns)", formatTokens(m.sessionEvalTotal), m.sessionTurnCount),
))
}
}
if len(parts) == 0 {
return ""
}
return " " + strings.Join(parts, m.styles.StatusText.Render(" · "))
}
func formatTokens(n int) string {
if n >= 1000 {
return fmt.Sprintf("%.1fk", float64(n)/1000)
}
return fmt.Sprintf("%d", n)
}
func (m *Model) renderEntries() string {
viewportW := m.width - 1
if m.sidePanel.IsVisible() {
viewportW = m.width - m.sidePanel.width - 2
}
if viewportW < 20 {
viewportW = 20
}
contentW := viewportW - 6
if contentW < 14 {
contentW = 14
}
if m.initializing {
var b strings.Builder
m.renderStartup(&b)
return b.String()
}
hasUserMsg := false
for _, e := range m.entries {
if e.Kind == "user" || e.Kind == "assistant" {
hasUserMsg = true
break
}
}
if !hasUserMsg && m.streamBuf.Len() == 0 {
var b strings.Builder
m.renderWelcome(&b)
for _, e := range m.entries {
if e.Kind == "system" {
b.WriteString(m.styles.SystemText.Render(e.Content))
b.WriteString("\n\n")
} else if e.Kind == "error" {
b.WriteString(m.styles.ErrorText.Render("error: " + e.Content))
b.WriteString("\n\n")
}
}
return b.String()
}
if m.entryCacheValid && len(m.entries) == m.cachedEntryCount {
m.toolEntryRows = m.cachedToolEntryRows
if m.streamBuf.Len() > 0 {
var b strings.Builder
b.WriteString(m.cachedEntriesRender)
if len(m.entries) > 0 {
last := m.entries[len(m.entries)-1]
if last.Kind != "tool_group" {
b.WriteString("\n")
}
}
m.renderStreamingMsg(&b, m.streamBuf.String(), contentW)
return b.String()
}
return m.cachedEntriesRender
}
var b strings.Builder
m.toolEntryRows = make(map[int]int)
for i, entry := range m.entries {
switch entry.Kind {
case "user":
m.renderUserMsg(&b, entry.Content, contentW)
case "assistant":
m.renderAssistantMsg(&b, entry, contentW)
case "tool_group":
m.toolEntryRows[entry.ToolIndex] = strings.Count(b.String(), "\n")
m.renderToolGroup(&b, entry.ToolIndex, i)
case "error":
b.WriteString(m.styles.ErrorText.Render("error: " + entry.Content))
b.WriteString("\n\n")
case "system":
b.WriteString(m.styles.SystemText.Render(entry.Content))
b.WriteString("\n\n")
}
if i < len(m.entries)-1 {
next := m.entries[i+1]
curr := entry.Kind
nextK := next.Kind
if curr == "tool_group" {
continue
} else if curr != nextK {
b.WriteString("\n")
}
}
}
m.cachedEntriesRender = b.String()
m.cachedEntryCount = len(m.entries)
if m.cachedToolEntryRows == nil {
m.cachedToolEntryRows = make(map[int]int, 8)
} else {
clear(m.cachedToolEntryRows)
}
for k, v := range m.toolEntryRows {
m.cachedToolEntryRows[k] = v
}
m.entryCacheValid = true
if m.streamBuf.Len() > 0 {
if len(m.entries) > 0 {
last := m.entries[len(m.entries)-1]
if last.Kind != "tool_group" {
b.WriteString("\n")
}
}
m.renderStreamingMsg(&b, m.streamBuf.String(), contentW)
}
return b.String()
}
func (m *Model) renderWelcome(b *strings.Builder) {
var wb strings.Builder
for _, line := range logoLines() {
if line == "" {
wb.WriteString("\n")
} else {
wb.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color("#88c0d0")).
Bold(true).
Render(line))
wb.WriteString("\n")
}
}
title := gradientText("Welcome to AI AGENT", []string{"#88c0d0", "#81a1c1", "#b48ead"})
wb.WriteString(" " + m.styles.OverlayTitle.Render(title))
wb.WriteString("\n")
var infoParts []string
if m.model != "" {
infoParts = append(infoParts, m.model)
}
if m.toolCount > 0 {
infoParts = append(infoParts, fmt.Sprintf("%d tools", m.toolCount))
}
if m.serverCount > 0 {
infoParts = append(infoParts, fmt.Sprintf("%d servers", m.serverCount))
}
if len(infoParts) > 0 {
wb.WriteString(m.styles.StatusText.Render(" " + strings.Join(infoParts, " · ")))
wb.WriteString("\n")
}
wb.WriteString("\n")
modes := []struct {
key string
desc string
color string
}{
{"ASK", "Quick answers", "#81a1c1"},
{"PLAN", "Design & reasoning", "#ebcb8b"},
{"BUILD", "Full execution", "#a3be8c"},
}
for _, mode := range modes {
modeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(mode.color)).
Bold(true)
wb.WriteString(" ")
wb.WriteString(modeStyle.Render(mode.key))
wb.WriteString(m.styles.StatusText.Render(" — " + mode.desc))
wb.WriteString("\n")
}
wb.WriteString("\n")
wb.WriteString(m.styles.SystemText.Render(" Type a message to start · Press ? for help"))
wb.WriteString("\n")
contentWidth := m.width
if m.sidePanel.IsVisible() {
contentWidth = m.width - m.sidePanel.width - 1
}
centered := lipgloss.PlaceHorizontal(contentWidth, lipgloss.Center, wb.String())
b.WriteString(centered)
}
func (m *Model) renderUserMsg(b *strings.Builder, content string, contentW int) {
label := m.styles.UserLabel.Render("you")
labelW := lipgloss.Width(label)
ruleW := contentW - labelW - 3
if ruleW < 4 {
ruleW = 4
}
b.WriteString(label + " " + m.styles.RoleRule.Render(rule(ruleW)))
b.WriteString("\n")
b.WriteString(m.styles.UserContent.Render(wrapText(content, contentW)))
b.WriteString("\n")
}
func (m *Model) renderAssistantMsg(b *strings.Builder, entry ChatEntry, contentW int) {
if entry.ThinkingContent != "" {
thinkBox := m.renderThinkingBox(entry.ThinkingContent, entry.ThinkingCollapsed)
b.WriteString(indentBlock(thinkBox, " "))
b.WriteString("\n")
}
label := m.styles.AsstLabel.Render("assistant")
labelW := lipgloss.Width(label)
ruleW := contentW - labelW - 3
if ruleW < 4 {
ruleW = 4
}
b.WriteString(label + " " + m.styles.RoleRule.Render(rule(ruleW)))
b.WriteString("\n")
rendered := entry.RenderedContent
if rendered == "" {
rendered = entry.Content
if m.md != nil {
rendered = m.md.RenderFull(rendered)
}
}
rendered = strings.TrimRight(rendered, " \t\n")
rendered = indentBlock(rendered, " ")
b.WriteString(rendered)
b.WriteString("\n")
}
func (m *Model) renderStreamingMsg(b *strings.Builder, content string, contentW int) {
if m.thinkBuf.Len() > 0 {
thinkHint := m.styles.ThinkingHeader.Render(
fmt.Sprintf(" thinking: %d chars...", m.thinkBuf.Len()),
)
b.WriteString(thinkHint)
b.WriteString("\n")
}
label := m.styles.AsstLabel.Render("assistant")
cursor := m.styles.StreamCursor.Render(" " + m.spin.View())
labelW := lipgloss.Width(label) + lipgloss.Width(cursor)
ruleW := contentW - labelW - 3
if ruleW < 4 {
ruleW = 4
}
b.WriteString(label + cursor + " " + m.styles.RoleRule.Render(rule(ruleW)))
b.WriteString("\n")
wrapWidth := contentW - 2
if wrapWidth < 10 {
wrapWidth = 10
}
wrapped := wrapText(content, wrapWidth)
rendered := indentBlock(wrapped, " ")
b.WriteString(rendered)
b.WriteString("\n")
}
func (m *Model) renderToolGroup(b *strings.Builder, toolIdx, entryIdx int) {
if toolIdx < 0 || toolIdx >= len(m.toolEntries) {
return
}
te := m.toolEntries[toolIdx]
layout := m.currentLayout()
if entryIdx > 0 && m.entries[entryIdx-1].Kind != "tool_group" {
b.WriteString("\n")
}
var card *ToolCard
for i := range m.toolCardMgr.Cards {
if m.toolCardMgr.Cards[i].Name == te.Name {
card = &m.toolCardMgr.Cards[i]
break
}
}
if card != nil {
card.Expanded = !te.Collapsed
availableWidth := m.width - 8
if m.sidePanel.IsVisible() {
availableWidth = m.width - m.sidePanel.width - 10
}
if availableWidth < 30 {
availableWidth = 30
}
cardView := card.View(availableWidth)
cardView = indentBlock(cardView, " ")
b.WriteString(cardView)
b.WriteString("\n\n")
} else {
tt := classifyTool(te.Name)
switch te.Status {
case ToolStatusRunning:
icon := m.styles.ToolCallIcon.Render(toolIcon(tt, te.Status))
spinView := m.spin.View()
text := m.styles.ToolCallText.Render(fmt.Sprintf(" %s ", te.Name))
hint := m.styles.ToolRunningText.Render(spinView + " running...")
b.WriteString(icon + text + hint)
if tt == ToolTypeBash {
if summary := toolSummary(tt, te); summary != "" {
b.WriteString("\n")
b.WriteString(m.styles.ToolBashCmd.Render(layout.ToolIndent + "$ " + summary))
}
}
b.WriteString("\n")
case ToolStatusDone:
dur := formatDuration(te.Duration)
icon := m.styles.ToolDoneIcon.Render(toolIcon(tt, te.Status))
if te.Collapsed {
// Collapsed: single line with type-specific summary
text := m.styles.ToolDoneText.Render(fmt.Sprintf(" %s (%s)", te.Name, dur))
b.WriteString(icon + text)
if summary := toolSummary(tt, te); summary != "" {
summ := truncate(summary, layout.ToolSummaryMax)
b.WriteString(m.styles.ToolBashCmd.Render(" " + summ))
}
b.WriteString("\n")
} else {
// Expanded: show args + result (or diff for file writes)
text := m.styles.ToolDoneText.Render(fmt.Sprintf(" %s (%s)", te.Name, dur))
b.WriteString(icon + text)
b.WriteString("\n")
args := truncate(te.Args, layout.ArgsTruncMax)
b.WriteString(m.styles.ToolDetailText.Render(layout.ToolIndent + "args: " + args))
b.WriteString("\n")
if te.DiffLines != nil {
b.WriteString(renderDiff(te.DiffLines, m.styles, 30))
} else {
result := formatToolResult(te.Result, 20, layout.ResultTruncMax)
resultLines := strings.Count(result, "\n") + 1
if resultLines > 20 {
b.WriteString(m.styles.ToolDetailText.Render(layout.ToolIndent + "result (truncated, expand to see more):\n"))
b.WriteString(m.styles.ToolDetailText.Render(indentBlock(truncate(result, layout.ResultTruncMax), layout.ToolIndent)))
} else {
b.WriteString(m.styles.ToolDetailText.Render(layout.ToolIndent + "result:\n"))
b.WriteString(m.styles.ToolDetailText.Render(indentBlock(result, layout.ToolIndent)))
}
b.WriteString("\n")
}
}
case ToolStatusError:
// Error: always expanded regardless of collapse state
dur := formatDuration(te.Duration)
icon := m.styles.ToolErrorIcon.Render(toolIcon(tt, te.Status))
text := m.styles.ToolErrorText.Render(fmt.Sprintf(" %s (%s)", te.Name, dur))
b.WriteString(icon + text)
b.WriteString("\n")
result := truncate(te.Result, layout.ResultTruncMax)
b.WriteString(m.styles.ToolErrorText.Render(layout.ToolIndent + result))
b.WriteString("\n")
}
}
if entryIdx < len(m.entries)-1 && m.entries[entryIdx+1].Kind != "tool_group" {
b.WriteString("\n")
}
}
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.1fs", d.Seconds())
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
func wrapText(s string, width int) string {
if width <= 0 {
return s
}
if len(s) <= width {
return s
}
var result strings.Builder
for _, line := range strings.Split(s, "\n") {
result.WriteString(wrapLine(line, width))
result.WriteString("\n")
}
return strings.TrimSuffix(result.String(), "\n")
}
func wrapLine(line string, width int) string {
if len(line) <= width {
return line
}
var result strings.Builder
words := strings.Fields(line)
current := ""
for _, w := range words {
if current == "" {
current = w
} else if len(current)+1+len(w) <= width {
current += " " + w
} else {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString(current)
current = w
}
}
if current != "" {
if result.Len() > 0 {
result.WriteString("\n")
}
for len(current) > width {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString(current[:width])
current = current[width:]
}
if len(current) > 0 {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString(current)
}
}
return result.String()
}
func indentBlock(s, prefix string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
if line != "" {
lines[i] = prefix + line
}
}
return strings.Join(lines, "\n")
}