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") }