Files
zero/internal/tui/transcript_selection.go
Vasanth T 250bf24c9d feat: add MCP manager UX (#188)
* feat: add MCP manager UX

* fix: harden MCP manager UX

* test: stabilize agent eval timeout on Windows

* fix: preserve MCP server metadata

* fix: clarify empty MCP view

* feat: make MCP command a manager view

* fix: make MCP manager usable in TUI

* fix: make MCP manager selectable in TUI

* fix: simplify MCP manager popup

* feat: add searchable MCP marketplace

* fix: address MCP review issues

* fix: address MCP follow-up review

* feat: add MCP setup wizard

* feat: detect MCP setup requests in chat

* fix: address MCP manager review feedback

* fix: avoid hijacking MCP chat prompts

* fix: persist MCP view cache fallback
2026-06-13 22:20:42 +05:30

344 lines
10 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 (
"os"
"strings"
"unicode/utf8"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
type transcriptSelectionPoint struct {
bodyY int
x int
}
type transcriptSelectionState struct {
active bool
anchor transcriptSelectionPoint
cursor transcriptSelectionPoint
}
type transcriptSelectableLine struct {
bodyY int
rowIndex int
textStart int
text string
}
type transcriptCopiedMsg struct {
chars int
}
type transcriptCopyStatusExpiredMsg struct {
seq int
}
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
lines := []string{}
selectable := []transcriptSelectableLine{}
appendBlock := func(block string) int {
start := len(lines)
lines = append(lines, viewLines(block)...)
return start
}
appendBlank := func() {
lines = append(lines, "")
}
// The title bar prints once into scrollback on the first WindowSizeMsg;
// until then it renders managed so the surface never appears headless.
if !m.headerPrinted {
appendBlock(m.titleBar(width))
}
if m.transcriptEmpty() && !m.pending {
if emptyOverlay != "" {
appendBlock(m.emptyStateWithOverlay(width, emptyOverlay))
} else {
appendBlock(m.emptyState(width))
}
} else {
rc := buildRowContext(m.transcript)
shownAny := false
for index := m.flushed; index < len(m.transcript); index++ {
row := m.transcript[index]
// A welcome row carries no Lime visual (the empty state replaced it)
// and a resolved tool call collapses into its result's card.
if row.kind == rowWelcome || rc.skip(row) {
continue
}
// Blank-line separation before turns, including between flushed
// history and the first live row.
if (shownAny || m.flushedAny) && startsTurn(row.kind) {
appendBlank()
}
start := len(lines)
rendered, rowSelectable := m.renderTranscriptRow(index, row, width, rc, start)
appendBlock(rendered)
selectable = append(selectable, rowSelectable...)
shownAny = true
}
}
if m.pending {
appendBlank()
switch {
case m.pendingPermission != nil:
appendBlock(renderFocusedPermissionPrompt(m.pendingPermission.request, width))
case m.pendingAskUser != nil:
appendBlock(renderFocusedAskUserPrompt(*m.pendingAskUser, m.input.Value(), width))
default:
appendBlock(m.interimBlock(width))
}
}
if m.pendingSpecReview != nil {
appendBlank()
appendBlock(renderFocusedSpecReviewPrompt(*m.pendingSpecReview, width))
}
return strings.Join(lines, "\n"), selectable
}
func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
switch row.kind {
case rowUser:
return m.renderSelectableUserRow(rowIndex, row, width, startBodyY)
case rowAssistant:
return m.renderSelectableAssistantRow(rowIndex, row, width, startBodyY)
default:
return m.renderRow(row, width, rc), nil
}
}
func (m model) renderSelectableUserRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
wrapped := wrapPlainText(row.text, sayMeasure(width))
lines := make([]string, 0, len(wrapped))
selectable := make([]transcriptSelectableLine, 0, len(wrapped))
for index, line := range wrapped {
prefix := " "
if index == 0 {
prefix = "❯ "
}
meta := transcriptSelectableLine{
bodyY: startBodyY + index,
rowIndex: rowIndex,
textStart: lipgloss.Width(prefix),
text: line,
}
selectable = append(selectable, meta)
lines = append(lines, zeroTheme.userPrompt.Render(prefix)+m.renderTranscriptSelectableText(meta, zeroTheme.ink))
}
return strings.Join(lines, "\n"), selectable
}
func (m model) renderSelectableAssistantRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
tableMeasure := width
if row.final {
tableMeasure = maxInt(16, width-2)
}
wrapped := renderAssistantMarkdownText(row.text, sayMeasure(width), tableMeasure)
lines := make([]string, 0, len(wrapped)+1)
selectable := make([]transcriptSelectableLine, 0, len(wrapped))
prefix := ""
textStyle := zeroTheme.sayText
if row.final {
prefix = "│ "
textStyle = zeroTheme.ink
}
for index, line := range wrapped {
plainLine := stripMarkdownRenderControls(line)
meta := transcriptSelectableLine{
bodyY: startBodyY + index,
rowIndex: rowIndex,
textStart: lipgloss.Width(prefix),
text: plainLine,
}
selectable = append(selectable, meta)
rendered := m.renderTranscriptSelectableMarkdownText(meta, line, textStyle)
if row.final {
rendered = zeroTheme.finalRail.Render(prefix) + rendered
}
lines = append(lines, rendered)
}
if row.final {
lines = append(lines, doneLine(row, false))
}
return strings.Join(lines, "\n"), selectable
}
func (m model) renderTranscriptSelectableMarkdownText(line transcriptSelectableLine, styledText string, base lipgloss.Style) string {
if _, _, ok := m.selectedColumnsForTranscriptLine(line); ok {
return m.renderTranscriptSelectableText(line, base)
}
return styleAssistantMarkdownLine(styledText, base)
}
func (m model) renderTranscriptSelectableText(line transcriptSelectableLine, base lipgloss.Style) string {
start, end, ok := m.selectedColumnsForTranscriptLine(line)
if !ok {
return base.Render(line.text)
}
before, rest := splitPlainAtDisplayWidth(line.text, start)
middle, after := splitPlainAtDisplayWidth(rest, end-start)
return base.Render(before) + zeroTheme.selection.Render(middle) + base.Render(after)
}
func (m model) selectedColumnsForTranscriptLine(line transcriptSelectableLine) (int, int, bool) {
if !m.transcriptSelection.active {
return 0, 0, false
}
startPoint, endPoint := orderedTranscriptSelectionPoints(m.transcriptSelection.anchor, m.transcriptSelection.cursor)
if line.bodyY < startPoint.bodyY || line.bodyY > endPoint.bodyY {
return 0, 0, false
}
lineStart := line.textStart
lineEnd := line.textStart + lipgloss.Width(line.text)
start := lineStart
end := lineEnd
if line.bodyY == startPoint.bodyY {
start = clampInt(startPoint.x, lineStart, lineEnd)
}
if line.bodyY == endPoint.bodyY {
end = clampInt(endPoint.x, lineStart, lineEnd)
}
if end <= start {
return 0, 0, false
}
return start - line.textStart, end - line.textStart, true
}
func orderedTranscriptSelectionPoints(a transcriptSelectionPoint, b transcriptSelectionPoint) (transcriptSelectionPoint, transcriptSelectionPoint) {
if a.bodyY < b.bodyY || a.bodyY == b.bodyY && a.x <= b.x {
return a, b
}
return b, a
}
func splitPlainAtDisplayWidth(text string, width int) (string, string) {
if width <= 0 {
return "", text
}
used := 0
for index, glyph := range text {
glyphWidth := lipgloss.Width(string(glyph))
if used+glyphWidth > width {
return text[:index], text[index:]
}
used += glyphWidth
}
return text, ""
}
func (m model) transcriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine, bool) {
if !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return transcriptSelectableLine{}, false
}
width := chatWidth(m.width)
body, selectable := m.transcriptBody(width, "")
start, available := m.transcriptViewportStart(body, width)
if msg.Y < 0 || msg.Y >= available {
return transcriptSelectableLine{}, false
}
bodyY := start + msg.Y
for _, line := range selectable {
if line.bodyY != bodyY {
continue
}
if msg.X < 0 {
return transcriptSelectableLine{}, false
}
return line, true
}
return transcriptSelectableLine{}, false
}
func (m model) transcriptViewportStart(body string, width int) (int, int) {
bodyLines := viewLines(body)
footerLines := viewLines(m.footerView(width))
available := m.height - len(footerLines)
if available < 1 {
available = 1
}
maxOffset := maxInt(0, len(bodyLines)-available)
offset := clamp(m.chatScrollOffset, 0, maxOffset)
start := maxInt(0, len(bodyLines)-available-offset)
return start, available
}
func transcriptSelectionPointForMouse(line transcriptSelectableLine, x int) transcriptSelectionPoint {
lineEnd := line.textStart + lipgloss.Width(line.text)
return transcriptSelectionPoint{
bodyY: line.bodyY,
x: clampInt(x, line.textStart, lineEnd),
}
}
func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, bool) {
switch {
case mouseLeftPress(msg):
line, ok := m.transcriptLineAtMouse(msg)
if !ok {
if m.transcriptSelection.active {
m.transcriptSelection = transcriptSelectionState{}
return m, nil, true
}
return m, nil, false
}
point := transcriptSelectionPointForMouse(line, msg.X)
m.copyStatus = ""
m.transcriptSelection = transcriptSelectionState{active: true, anchor: point, cursor: point}
return m, nil, true
case mouseMotion(msg):
if !m.transcriptSelection.active {
return m, nil, false
}
line, ok := m.transcriptLineAtMouse(msg)
if ok {
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, msg.X)
}
return m, nil, true
case mouseRelease(msg):
if !m.transcriptSelection.active {
return m, nil, false
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, msg.X)
}
text := m.selectedTranscriptText()
if strings.TrimSpace(text) == "" {
m.transcriptSelection = transcriptSelectionState{}
return m, nil, true
}
return m, copyTranscriptSelectionCmd(text), true
default:
return m, nil, false
}
}
func (m model) selectedTranscriptText() string {
width := chatWidth(m.width)
_, selectable := m.transcriptBody(width, "")
parts := []string{}
for _, line := range selectable {
start, end, ok := m.selectedColumnsForTranscriptLine(line)
if !ok {
continue
}
before, rest := splitPlainAtDisplayWidth(line.text, start)
_ = before
selected, _ := splitPlainAtDisplayWidth(rest, end-start)
parts = append(parts, selected)
}
return strings.Join(parts, "\n")
}
func copyTranscriptSelectionCmd(text string) tea.Cmd {
return func() tea.Msg {
_, _ = os.Stdout.WriteString(ansi.SetSystemClipboard(text))
return transcriptCopiedMsg{chars: utf8.RuneCountInString(text)}
}
}