fix(cli): report pinned panel focus from selection state

Problem: Pinned panels could scroll to ordinary todo text that resembled
a selection marker, or lose focus when display wording changed.

Root cause: The viewport inferred selection from ANSI-stripped prefixes
and used the rendered row text as its focus identity.

Fix: Have panel builders record selected row boundaries and logical
indices, then return explicit terminal-row metadata after styling and
wrapping. Apply this to every pinned renderer, including native managers,
and restore the original elicitation Submit presentation.

Verification: Preserve existing scroll, footer and selection tests; add
misleading todo prefixes, wording-independent manual scroll, windowed
selection, Unicode wrapping and native-manager focus regressions. The
todo regression fails on the previous PR head for all three prefixes.
go test ./internal/cli -count=1, go build -p 2 ./..., make lint, and the
documentation-impact check pass.
This commit is contained in:
SivanCola committed 2026-10-21 14:35:18 +08:00
1 parent be1658c697
commit 4e9ae7baba
18 files changed
+408 -160

No files matched your search

+25 -11
View File
@@ -2250,13 +2250,17 @@ func (m chatTUI) transcriptHeight() int {
}
func (m chatTUI) renderMainManager() string {
if card := m.renderMCPManager(); card != "" {
return m.renderMainManagerPanel().text
}
func (m chatTUI) renderMainManagerPanel() panelContent {
if card := m.renderMCPManagerPanel(); card.text != "" {
return card
}
if card := m.renderClearConfirm(); card != "" {
if card := m.renderClearConfirmPanel(); card.text != "" {
return card
}
return m.renderSkillPicker()
return m.renderSkillPickerPanel()
}
func managerContentPanelStyle(width int) lipgloss.Style {
@@ -3588,12 +3592,16 @@ func completionSummaryWarning(c *event.CompletionSummaryInfo) string {
// renderApprovalBanner is the slim notice shown above the input while a tool
// call (or a plan) awaits the user's decision.
func (m chatTUI) renderApprovalBanner() string {
return m.renderApprovalBannerPanel().text
}
func (m chatTUI) renderApprovalBannerPanel() panelContent {
w := max(m.width, 10)
if m.pendingApproval == nil {
return ""
return panelContent{}
}
if isRecoveryApprovalEvent(m.pendingApproval) {
return choicePanelStyle.Width(w).Render("ℹ Historical recovery record (retired). It cannot confirm or replay an operation.\n" + dim("Esc/n dismiss"))
return panelContent{text: choicePanelStyle.Width(w).Render("ℹ Historical recovery record (retired). It cannot confirm or replay an operation.\n" + dim("Esc/n dismiss"))}
}
var text string
var planDetails []string
@@ -3630,13 +3638,14 @@ func (m chatTUI) renderApprovalBanner() string {
if len(planDetails) > 0 {
text += "\n" + strings.Join(planDetails, "\n")
}
var b strings.Builder
var b panelBuilder
b.WriteString("⏸ " + text + "\n")
for i, choice := range approvalChoices(m.pendingApproval) {
b.MarkFocus(i == m.approvalSelection, i)
b.WriteString(rowLine(i == m.approvalSelection, i+1, "", choice.label, false) + "\n")
}
b.WriteString(dim("↑/↓ navigate · Enter select · y/a/p/n shortcuts"))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
// maxApprovalSubjectLines bounds the expanded command so a heredoc cannot push
@@ -3720,8 +3729,12 @@ const todoPanelMaxRows = 8
// renderTodoPanel renders the committed current-turn task list above the input.
// Completed lists remain inspectable until the next host turn boundary.
func (m chatTUI) renderTodoPanel() string {
return m.renderTodoContent().text
}
func (m chatTUI) renderTodoContent() panelContent {
if m.todosDismissed || len(m.todos) == 0 {
return ""
return panelContent{}
}
done := 0
for _, t := range m.todos {
@@ -3730,13 +3743,14 @@ func (m chatTUI) renderTodoPanel() string {
}
}
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "%s %s\n", accent("To-dos"), dim(fmt.Sprintf("%d/%d", done, len(m.todos))))
start, end := todoPanelWindow(m.todos)
if start > 0 {
b.WriteString(dim(fmt.Sprintf(" +%d above", start)) + "\n")
}
for _, t := range m.todos[start:end] {
for i, t := range m.todos[start:end] {
b.MarkFocus(t.Status == "in_progress", start+i)
indent := " "
switch t.Status {
case "completed":
@@ -3750,7 +3764,7 @@ func (m chatTUI) renderTodoPanel() string {
if end < len(m.todos) {
b.WriteString(dim(fmt.Sprintf(" +%d more", len(m.todos)-end)) + "\n")
}
return todoPanelStyle.Width(max(m.width, 10)).Render(strings.TrimRight(b.String(), "\n"))
return b.body().trimTrailingNewlines().render(todoPanelStyle.Width(max(m.width, 10)), m.width)
}
func todoPanelWindow(todos []event.Todo) (int, int) {
+11 -4
View File
@@ -206,12 +206,16 @@ func (m chatTUI) chooserAnswer(answers []event.AskAnswer) (tea.Model, tea.Cmd) {
// question), the current question's prompt and options, and the Type-something /
// Chat-about-this rows. On the Submit tab it shows a review of the picks.
func (m chatTUI) renderChooser() string {
return m.renderChooserPanel().text
}
func (m chatTUI) renderChooserPanel() panelContent {
c := m.chooser
if c == nil {
return ""
return panelContent{}
}
w := max(m.width, 10)
var b strings.Builder
var b panelBuilder
if len(c.questions) > 1 {
b.WriteString(m.chooserTabs() + "\n\n")
@@ -228,12 +232,13 @@ func (m chatTUI) renderChooser() string {
fmt.Fprintf(&b, " %s: %s\n", dim(label), ans)
}
b.WriteString(dim(i18n.M.AskSubmitHint))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
q := c.questions[c.tab]
b.WriteString(accent("? ") + q.Prompt + "\n")
for j, opt := range q.Options {
b.MarkFocus(c.cursor == j, j)
b.WriteString(m.chooserOptionRow(j, opt, q.Multi) + "\n")
}
// Type something
@@ -244,13 +249,15 @@ func (m chatTUI) renderChooser() string {
} else if c.typing {
typeLabel = i18n.M.AskTypingHint
}
b.MarkFocus(c.cursor == typeRow, typeRow)
b.WriteString(rowLine(c.cursor == typeRow, typeRow+1, "", typeLabel, c.typing && c.custom[c.tab] == "") + "\n")
// Chat about this
b.WriteString(dim(strings.Repeat("─", min(w-2, 40))) + "\n")
chatRow := typeRow + 1
b.MarkFocus(c.cursor == chatRow, chatRow)
b.WriteString(rowLine(c.cursor == chatRow, chatRow+1, "", i18n.M.AskChatInstead, false))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
func (m chatTUI) chooserTabs() string {
+6 -5
View File
@@ -2,7 +2,6 @@ package cli
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
@@ -72,15 +71,17 @@ func (m *chatTUI) resetFreshContextView(clearTranscript bool) {
m.forceGotoBottom = true
}
func (m chatTUI) renderClearConfirm() string {
func (m chatTUI) renderClearConfirmPanel() panelContent {
if m.clearConfirm == nil {
return ""
return panelContent{}
}
w := max(viewWidth(m.width), 40)
var b strings.Builder
var b panelBuilder
b.WriteString(i18n.M.SlashClearPrompt + "\n")
b.WriteString(viewMeta("This deletes the current transcript from local history and keeps only the system prompt.") + "\n\n")
b.MarkFocus(m.clearConfirm.confirm == 0, 0)
b.WriteString(rowLine(m.clearConfirm.confirm == 0, 1, "", "Clear", false) + "\n")
b.MarkFocus(m.clearConfirm.confirm == 1, 1)
b.WriteString(rowLine(m.clearConfirm.confirm == 1, 2, "", "Cancel", false))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
+8 -3
View File
@@ -583,8 +583,12 @@ func padCompletionLine(s string, w int) string {
// That avoids ghost cells on terminals (mintty) with unreliable erases after
// wide CJK glyphs.
func (m chatTUI) renderCompletion() string {
return m.renderCompletionPanel().text
}
func (m chatTUI) renderCompletionPanel() panelContent {
if !m.completion.active || len(m.completion.items) == 0 {
return ""
return panelContent{}
}
items := m.completion.items
start := 0
@@ -593,7 +597,7 @@ func (m chatTUI) renderCompletion() string {
}
end := min(start+maxCompRows, len(items))
var b strings.Builder
var b panelBuilder
for i := start; i < end; i++ {
it := items[i]
var line string
@@ -605,6 +609,7 @@ func (m chatTUI) renderCompletion() string {
if it.hint != "" {
line += " " + dim(it.hint)
}
b.MarkFocus(i == m.completion.sel, i)
b.WriteString(padCompletionLine(line, m.width))
b.WriteByte('\n')
}
@@ -615,5 +620,5 @@ func (m chatTUI) renderCompletion() string {
hint = i18n.M.CompHintFile
}
b.WriteString(padCompletionLine(dim(hint), m.width))
return b.String()
return b.render(lipgloss.NewStyle(), m.width)
}
+8 -3
View File
@@ -172,13 +172,18 @@ func (m chatTUI) handleConnectionSetupKey(msg tea.KeyPressMsg) (tea.Model, tea.C
}
func (m chatTUI) renderConnectionSetup() string {
return m.renderConnectionSetupPanel().text
}
func (m chatTUI) renderConnectionSetupPanel() panelContent {
if m.setup == nil {
return ""
return panelContent{}
}
w := max(m.width, 10)
contentWidth := max(w-8, 12)
var body strings.Builder
var body panelBuilder
body.WriteString(accent("Configure "+m.setup.providerName) + "\n")
body.MarkFocus(true, 0)
body.WriteString(" API Key\n")
masked := strings.Repeat("•", utf8.RuneCountInString(m.setup.key))
if masked == "" {
@@ -192,7 +197,7 @@ func (m chatTUI) renderConnectionSetup() string {
} else {
body.WriteString(dim("Ctrl+T test · Enter save · Esc cancel"))
}
return choicePanelStyle.Width(w).Render(body.String())
return body.render(choicePanelStyle.Width(w), m.width)
}
func (m *chatTUI) handleConnectionCredentialTested(msg connectionCredentialTestedMsg) {
+5 -6
View File
@@ -1,8 +1,6 @@
package cli
import (
"strings"
tea "charm.land/bubbletea/v2"
"reasonix/internal/i18n"
@@ -62,17 +60,18 @@ func (m chatTUI) applyCopyPick() (tea.Model, tea.Cmd) {
return m, copyToClipboard(text)
}
func (m chatTUI) renderCopyPicker() string {
func (m chatTUI) renderCopyPickerPanel() panelContent {
p := m.copyPick
if p == nil {
return ""
return panelContent{}
}
w := max(m.width, 10)
var b strings.Builder
var b panelBuilder
b.WriteString(accent(i18n.M.SlashCopyListHeader) + "\n")
for i, part := range p.parts {
b.MarkFocus(i == p.sel, i)
b.WriteString(rowLine(i == p.sel, i+1, "", firstLine(part), false) + "\n")
}
b.WriteString(dim("↑/↓ navigate · Enter copy · Esc cancel"))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
+10 -8
View File
@@ -248,14 +248,14 @@ func openElicitURL(raw string) {
}
}
// renderElicit draws the pinned elicitation card.
func (m chatTUI) renderElicit() string {
// renderElicitPanel draws the pinned elicitation card.
func (m chatTUI) renderElicitPanel() panelContent {
c := m.elicit
if c == nil {
return ""
return panelContent{}
}
w := max(m.width, 10)
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "%s %s\n", accent("MCP"), dim("· "+c.server))
b.WriteString(wrapForViewport(c.message, w, activeCLITheme.info) + "\n")
if c.mode == "url" {
@@ -265,11 +265,11 @@ func (m chatTUI) renderElicit() string {
}
fmt.Fprintf(&b, " %s\n", host)
b.WriteString(dim(i18n.M.ElicitURLHint))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
if len(c.fields) == 0 {
b.WriteString(dim(i18n.M.ElicitConfirmOnly))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
for i, f := range c.fields {
marker := " "
@@ -287,13 +287,15 @@ func (m chatTUI) renderElicit() string {
marker = accent("› ")
label = accent(label)
}
b.MarkFocus(i == c.cursor, i)
fmt.Fprintf(&b, "%s%s: %s\n", marker, label, value)
}
b.MarkFocus(c.onSubmitRow(), len(c.fields))
if c.onSubmitRow() {
fmt.Fprintf(&b, "%s\n", accent("› ["+i18n.M.ElicitSubmit+"]"))
fmt.Fprintf(&b, "%s\n", accent(" ["+i18n.M.ElicitSubmit+"]"))
b.WriteString(dim(i18n.M.ElicitSubmitHint))
} else {
fmt.Fprintf(&b, "%s\n", dim(" ["+i18n.M.ElicitSubmit+"]"))
}
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
+5 -4
View File
@@ -76,13 +76,13 @@ func (m chatTUI) handleMCPImportKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m chatTUI) renderMCPImport() string {
func (m chatTUI) renderMCPImportPanel() panelContent {
p := m.mcpImport
if p == nil {
return ""
return panelContent{}
}
w := max(m.width, 10)
var b strings.Builder
var b panelBuilder
b.WriteString(accent("Import MCP from cc-switch") + "\n")
b.WriteString(dim("Space select · Enter import · Esc cancel") + "\n\n")
for i, c := range p.candidates {
@@ -99,7 +99,8 @@ func (m chatTUI) renderMCPImport() string {
if i == p.cursor {
line = reverse(line)
}
b.MarkFocus(i == p.cursor, i)
b.WriteString(line + "\n")
}
return choicePanelStyle.Width(w).Render(strings.TrimRight(b.String(), "\n"))
return b.body().trimTrailingNewlines().render(choicePanelStyle.Width(w), m.width)
}
+61 -43
View File
@@ -10,28 +10,28 @@ import (
"reasonix/internal/mcpdiag"
)
func (m chatTUI) renderMCPManager() string {
func (m chatTUI) renderMCPManagerPanel() panelContent {
if m.mcp == nil {
return ""
return panelContent{}
}
return m.mcp.render(m.width)
return m.mcp.renderPanel(m.width)
}
func (p *mcpManager) render(width int) string {
func (p *mcpManager) renderPanel(width int) panelContent {
w := max(viewWidth(width), 40)
switch p.stage {
case mcpStageDetail:
return managerContentPanelStyle(w).Render(p.renderDetail(w))
return p.renderDetailBody(w).render(managerContentPanelStyle(w), width)
case mcpStageTools:
return managerContentPanelStyle(w).Render(p.renderTools(w))
return p.renderToolsBody(w).render(managerContentPanelStyle(w), width)
case mcpStageLogs:
return managerContentPanelStyle(w).Render(p.renderLogs(w))
return p.renderLogsBody(w).render(managerContentPanelStyle(w), width)
case mcpStageConfirmRemove:
return managerContentPanelStyle(w).Render(p.renderConfirmRemove(w))
return p.renderConfirmRemoveBody(w).render(managerContentPanelStyle(w), width)
case mcpStageConfirmClearAuth:
return managerContentPanelStyle(w).Render(p.renderConfirmClearAuth(w))
return p.renderConfirmClearAuthBody(w).render(managerContentPanelStyle(w), width)
default:
return managerContentPanelStyle(w).Render(p.renderList(w))
return p.renderListBody(w).render(managerContentPanelStyle(w), width)
}
}
@@ -52,7 +52,11 @@ func (p *mcpManager) footerHint() string {
}
func (p *mcpManager) renderList(width int) string {
var b strings.Builder
return p.renderListBody(width).text
}
func (p *mcpManager) renderListBody(width int) panelBody {
var b panelBuilder
fmt.Fprintf(&b, "%s\n", viewHeader("Manage MCP servers"))
fmt.Fprintf(&b, "%s\n\n", viewMeta(fmt.Sprintf("%d servers", len(p.snapshot.servers))))
if p.snapshot.err != "" {
@@ -60,7 +64,7 @@ func (p *mcpManager) renderList(width int) string {
}
if len(p.snapshot.servers) == 0 {
b.WriteString(viewMeta("No MCP servers configured. Use /mcp add <name> ... to add one.") + "\n\n")
return b.String()
return b.body()
}
start, end := visibleRange(len(p.snapshot.servers), p.sel, mcpListMaxRows)
if start > 0 {
@@ -81,12 +85,13 @@ func (p *mcpManager) renderList(width int) string {
fmt.Fprintf(&b, " %s\n", bold(header))
lastGroup = group
}
b.MarkFocus(i == p.sel, i)
b.WriteString(p.renderListRow(i, s, width) + "\n")
}
if end < len(p.snapshot.servers) {
fmt.Fprintf(&b, "%s\n", viewMeta(fmt.Sprintf("↓ %d more below", len(p.snapshot.servers)-end)))
}
return strings.TrimRight(b.String(), "\n")
return b.body().trimTrailingNewlines()
}
func (p *mcpManager) renderListRow(i int, s mcpServerView, width int) string {
@@ -117,62 +122,67 @@ func (p *mcpManager) renderListRow(i int, s mcpServerView, width int) string {
}
func (p *mcpManager) renderDetail(width int) string {
return p.renderDetailBody(width).text
}
func (p *mcpManager) renderDetailBody(width int) panelBody {
v, ok := p.selectedServer()
if !ok {
return "MCP server not found\n\n" + dim("Esc to back")
return panelBody{text: "MCP server not found\n\n" + dim("Esc to back")}
}
actions := mcpActionsFor(v, p.snapshot.configPath)
if p.action >= len(actions) {
p.action = max(0, len(actions)-1)
}
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "%s MCP Server\n\n", bold(titleText(v.Name)))
writeMCPDetailField(&b, "Status", mcpStatusLabel(v))
writeMCPDetailField(&b.Builder, "Status", mcpStatusLabel(v))
if auth := mcpAuthLabel(v); auth != "" {
writeMCPDetailField(&b, "Auth", auth)
writeMCPDetailField(&b.Builder, "Auth", auth)
}
writeMCPDetailField(&b, "Transport", fallbackText(v.Transport, "unknown"))
writeMCPDetailField(&b.Builder, "Transport", fallbackText(v.Transport, "unknown"))
if v.BuiltIn {
writeMCPDetailField(&b, "Config location", "built-in")
writeMCPDetailField(&b.Builder, "Config location", "built-in")
} else if v.Source == config.MCPSourceProjectMCPJSON {
writeMCPDetailField(&b, "Config location", "current project .mcp.json")
writeMCPDetailField(&b.Builder, "Config location", "current project .mcp.json")
} else if v.Source == config.MCPSourceProjectConfig {
writeMCPDetailField(&b, "Config location", "current project reasonix.toml")
writeMCPDetailField(&b.Builder, "Config location", "current project reasonix.toml")
} else {
loc := fallbackText(p.snapshot.configPath, "not saved")
if loc != "not saved" {
loc = viewCompactPath(loc, viewBudget(width, 18))
}
writeMCPDetailField(&b, "Config location", loc)
writeMCPDetailField(&b.Builder, "Config location", loc)
}
writeMCPDetailField(&b, "Capabilities", mcpCapabilitiesText(v))
writeMCPDetailField(&b, "Tools", countText(v.Tools, "tool"))
writeMCPDetailField(&b.Builder, "Capabilities", mcpCapabilitiesText(v))
writeMCPDetailField(&b.Builder, "Tools", countText(v.Tools, "tool"))
if line := mcpCommandLine(v); line != "" {
writeMCPDetailField(&b, mcpCommandLabel(v), viewCompactText(line, viewBudget(width, 18)))
writeMCPDetailField(&b.Builder, mcpCommandLabel(v), viewCompactText(line, viewBudget(width, 18)))
}
if len(v.EnvKeys) > 0 {
writeMCPDetailField(&b, "Env", strings.Join(v.EnvKeys, ", "))
writeMCPDetailField(&b.Builder, "Env", strings.Join(v.EnvKeys, ", "))
}
if v.Error != "" {
writeMCPDetailField(&b, "Error", viewCompactText(v.Error, viewBudget(width, 18)))
writeMCPDetailField(&b.Builder, "Error", viewCompactText(v.Error, viewBudget(width, 18)))
}
b.WriteByte('\n')
if len(actions) == 0 {
b.WriteString(viewMeta("No actions available.") + "\n")
} else {
for i, a := range actions {
b.MarkFocus(i == p.action, i)
b.WriteString(rowLine(i == p.action, i+1, "", a.label, false) + "\n")
}
}
return strings.TrimRight(b.String(), "\n")
return b.body().trimTrailingNewlines()
}
func (p *mcpManager) renderTools(width int) string {
func (p *mcpManager) renderToolsBody(width int) panelBody {
v, ok := p.selectedServer()
if !ok {
return "MCP server not found\n\n" + dim("Esc to back")
return panelBody{text: "MCP server not found\n\n" + dim("Esc to back")}
}
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "%s tools\n\n", bold(v.Name))
if len(v.ToolList) == 0 {
b.WriteString(viewMeta("Current connection did not return tool details.") + "\n")
@@ -190,49 +200,57 @@ func (p *mcpManager) renderTools(width int) string {
fmt.Fprintf(&b, "%s\n", viewMore(extra, "tools"))
}
}
return strings.TrimRight(b.String(), "\n")
return b.body().trimTrailingNewlines()
}
func (p *mcpManager) renderLogs(width int) string {
func (p *mcpManager) renderLogsBody(width int) panelBody {
v, ok := p.selectedServer()
if !ok {
return "MCP server not found\n\n" + dim("Esc to back")
return panelBody{text: "MCP server not found\n\n" + dim("Esc to back")}
}
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "%s logs\n\n", bold(v.Name))
if strings.TrimSpace(v.Error) == "" {
b.WriteString(viewMeta("No failure log recorded for this MCP.") + "\n")
} else {
b.WriteString(viewProtectLines(v.Error, viewBudget(width, 2)) + "\n")
}
return strings.TrimRight(b.String(), "\n")
return b.body().trimTrailingNewlines()
}
func (p *mcpManager) renderConfirmRemove(width int) string {
func (p *mcpManager) renderConfirmRemoveBody(width int) panelBody {
v, ok := p.selectedServer()
if !ok {
return "MCP server not found\n\n" + dim("Esc to back")
return panelBody{text: "MCP server not found\n\n" + dim("Esc to back")}
}
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "Remove MCP server %q?\n", v.Name)
b.WriteString(viewMeta("This removes it from Reasonix config. It cannot be undone from this panel.") + "\n\n")
b.MarkFocus(p.confirm == 0, 0)
b.WriteString(rowLine(p.confirm == 0, 1, "", "Confirm remove", false) + "\n")
b.MarkFocus(p.confirm == 1, 1)
b.WriteString(rowLine(p.confirm == 1, 2, "", "Cancel", false) + "\n")
return strings.TrimRight(b.String(), "\n")
return b.body().trimTrailingNewlines()
}
func (p *mcpManager) renderConfirmClearAuth(width int) string {
return p.renderConfirmClearAuthBody(width).text
}
func (p *mcpManager) renderConfirmClearAuthBody(width int) panelBody {
v, ok := p.selectedServer()
if !ok {
return "MCP server not found\n\n" + dim("Esc to back")
return panelBody{text: "MCP server not found\n\n" + dim("Esc to back")}
}
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "Clear authentication for MCP server %q?\n", v.Name)
hint := "This removes local auth-like headers, environment values, and URL tokens; the server stays in config."
b.WriteString(viewMeta(viewCompactText(hint, viewBudget(width, 0))) + "\n\n")
b.MarkFocus(p.confirm == 0, 0)
b.WriteString(rowLine(p.confirm == 0, 1, "", "Confirm clear authentication", false) + "\n")
b.MarkFocus(p.confirm == 1, 1)
b.WriteString(rowLine(p.confirm == 1, 2, "", "Cancel", false) + "\n")
return strings.TrimRight(b.String(), "\n")
return b.body().trimTrailingNewlines()
}
func mcpActionsFor(v mcpServerView, configPath string) []mcpActionItem {
+66
View File
@@ -0,0 +1,66 @@
package cli
import (
"strings"
"charm.land/lipgloss/v2"
)
// panelContent reports a terminal row and a logical selection independently of
// the display text. The selection still changes when a windowed list moves but
// keeps its selected item on the same terminal row.
type panelContent struct {
text string
focus, selection int
}
// panelBuilder records the start of the selected row while its owner writes
// the body. MarkFocus must be called at a row boundary, before writing the row.
type panelBuilder struct {
strings.Builder
focusOffset, selection int
hasFocus bool
}
func (b *panelBuilder) MarkFocus(selected bool, selection int) {
if selected && !b.hasFocus {
b.focusOffset, b.selection, b.hasFocus = b.Len(), selection, true
}
}
type panelBody struct {
text string
focusOffset, selection int
hasFocus bool
}
func (b panelBody) trimTrailingNewlines() panelBody {
b.text = strings.TrimRight(b.text, "\n")
return b
}
func (b *panelBuilder) body() panelBody {
return panelBody{b.String(), b.focusOffset, b.selection, b.hasFocus}
}
func (b *panelBuilder) render(style lipgloss.Style, width int) panelContent {
return b.body().render(style, width)
}
func (b panelBody) render(style lipgloss.Style, width int) panelContent {
p := panelContent{text: style.Render(b.text), selection: b.selection}
if b.hasFocus {
// Measure preceding rows with the same style and terminal wrapping as
// the actual panel. A single cell preserves the selected row's empty
// start; no marker is inserted into or searched for in the output.
prefix := style.Render(b.text[:b.focusOffset] + "x")
// Exclude the placeholder row and bottom frame before terminal
// wrapping: a manager's minimum width can exceed the terminal width.
rows := strings.Split(prefix, "\n")
before := len(rows) - 1 - style.GetPaddingBottom() - style.GetBorderBottomSize() - style.GetMarginBottom()
if before > 0 {
p.focus = strings.Count(wrapTerminalText(strings.Join(rows[:before], "\n"), max(width, 10)), "\n") + 1
}
}
return p
}
+63
View File
@@ -0,0 +1,63 @@
package cli
import (
"fmt"
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/skill"
)
func TestPanelFocusSurvivesWrappingAndPresentation(t *testing.T) {
for _, width := range []int{20, 40, 80} {
for _, style := range []lipgloss.Style{lipgloss.NewStyle(), choicePanelStyle.Width(40), managerContentPanelStyle(40)} {
for _, marker := range []string{"", "› ", "❯ ", "任意符号 "} {
t.Run(fmt.Sprintf("width=%d/border=%d/marker=%q", width, style.GetBorderBottomSize(), marker), func(t *testing.T) {
var b panelBuilder
b.WriteString("› misleading text\n" + strings.Repeat("中文 é 1️⃣ text ", 12) + "\n\n")
b.MarkFocus(true, 7)
b.WriteString(accent(marker+"TARGET") + "\n" + strings.Repeat("tail\n", 5))
p := b.render(style, width)
rows := strings.Split(wrapTerminalText(p.text, width), "\n")
if p.focus < 0 || p.focus >= len(rows) || !strings.Contains(ansi.Strip(rows[p.focus]), "TARGET") {
t.Fatalf("focus %d missed selected row:\n%s", p.focus, ansi.Strip(strings.Join(rows, "\n")))
}
if p.selection != 7 {
t.Fatalf("lost logical selection: %d", p.selection)
}
if p.text != style.Render(b.String()) {
t.Fatal("focus metadata changed the displayed content")
}
})
}
}
}
}
func TestNativeManagerReportsSelectedRow(t *testing.T) {
for _, width := range []int{20, 40, 80} {
for _, kind := range []string{"mcp-list", "mcp-confirm", "skills"} {
t.Run(fmt.Sprintf("%s/%d", kind, width), func(t *testing.T) {
m := chatTUI{width: width}
want := "TARGET"
switch kind {
case "mcp-list":
m.mcp = &mcpManager{sel: 1, snapshot: mcpSnapshot{servers: []mcpServerView{{Name: "Other"}, {Name: want}}}}
case "mcp-confirm":
m.mcp = &mcpManager{stage: mcpStageConfirmRemove, confirm: 1, snapshot: mcpSnapshot{servers: []mcpServerView{{Name: strings.Repeat("Long name ", 10)}}}}
want = "Cancel"
case "skills":
m.skillPick = &skillPicker{mode: pickerSkills, sel: 1, skills: []skill.Skill{{Name: "Other", Scope: skill.ScopeProject}, {Name: want, Scope: skill.ScopeGlobal}}}
}
p := m.renderMainManagerPanel()
rows := strings.Split(wrapTerminalText(p.text, width), "\n")
if p.focus < 0 || p.focus >= len(rows) || !strings.Contains(ansi.Strip(rows[p.focus]), want) {
t.Fatalf("focus %d missed %q:\n%s", p.focus, want, ansi.Strip(strings.Join(rows, "\n")))
}
})
}
}
}
+24 -34
View File
@@ -9,13 +9,15 @@ import (
)
type pinnedPanelScroll struct {
owner, focusText string
owner string
focus, selection int
offset, width, capacity int
}
type pinnedPanelView struct {
lines []string
owner, focusText string
owner string
selection int
start, focus, offset, capacity int
}
@@ -60,7 +62,7 @@ func (m chatTUI) pinnedPanelView() pinnedPanelView {
if m.pinnedScroll.owner != p.owner || m.pinnedScroll.width != m.width {
p.offset = min(p.start, maximum)
}
if m.pinnedScroll.owner != p.owner || m.pinnedScroll.focusText != p.focusText || m.pinnedScroll.width != m.width || m.pinnedScroll.capacity != p.capacity {
if m.pinnedScroll.owner != p.owner || m.pinnedScroll.focus != p.focus || m.pinnedScroll.selection != p.selection || m.pinnedScroll.width != m.width || m.pinnedScroll.capacity != p.capacity {
if p.focus < p.offset {
p.offset = p.focus
} else if p.focus >= p.offset+p.capacity {
@@ -73,51 +75,39 @@ func (m chatTUI) pinnedPanelView() pinnedPanelView {
func (m chatTUI) pinnedPanelContent() pinnedPanelView {
var p pinnedPanelView
add := func(owner, content string) {
if content == "" {
add := func(owner string, content panelContent) {
if content.text == "" {
return
}
rows := strings.Split(wrapTerminalText(content, max(m.width, 10)), "\n")
focus := 0
// The shared rowLine helper and the elicitation/todo renderers mark
// keyboard selection or the active task at the start of a visual row.
for i, row := range rows {
plain := ansi.Strip(row)
trimmed := strings.TrimLeft(plain, " ")
indent := len(plain) - len(trimmed)
if (indent <= 1 && (strings.HasPrefix(trimmed, "❯ ") || strings.HasPrefix(trimmed, "› "))) || (indent <= 3 && strings.HasPrefix(trimmed, "▶ ")) {
focus = i
break
}
}
p.owner, p.start, p.focus = owner, len(p.lines), len(p.lines)+focus
p.focusText = fmt.Sprintf("%d:%s", p.focus, ansi.Strip(rows[focus]))
rows := strings.Split(wrapTerminalText(content.text, max(m.width, 10)), "\n")
p.owner, p.start, p.focus = owner, len(p.lines), len(p.lines)+content.focus
p.selection = content.selection
p.lines = append(p.lines, rows...)
}
add("todos", m.renderTodoPanel())
add(fmt.Sprintf("approval:%p", m.pendingApproval), m.renderApprovalBanner())
add("todos", m.renderTodoContent())
add(fmt.Sprintf("approval:%p", m.pendingApproval), m.renderApprovalBannerPanel())
chooserKey := fmt.Sprintf("chooser:%p", m.chooser)
if m.chooser != nil {
chooserKey += fmt.Sprintf(":%d", m.chooser.tab)
}
add(chooserKey, m.renderChooser())
add(fmt.Sprintf("elicit:%p", m.elicit), m.renderElicit())
add(fmt.Sprintf("rewind:%p", m.rewind), m.renderRewind())
add(fmt.Sprintf("import:%p", m.mcpImport), m.renderMCPImport())
add(fmt.Sprintf("resume:%p", m.resumePick), m.renderResumePicker())
add(fmt.Sprintf("quick:%p", m.quickPick), m.renderQuickPicker())
add(fmt.Sprintf("setup:%p", m.setup), m.renderConnectionSetup())
add(fmt.Sprintf("copy:%p", m.copyPick), m.renderCopyPicker())
add("completion", m.renderCompletion())
add(chooserKey, m.renderChooserPanel())
add(fmt.Sprintf("elicit:%p", m.elicit), m.renderElicitPanel())
add(fmt.Sprintf("rewind:%p", m.rewind), m.renderRewindPanel())
add(fmt.Sprintf("import:%p", m.mcpImport), m.renderMCPImportPanel())
add(fmt.Sprintf("resume:%p", m.resumePick), m.renderResumePickerPanel())
add(fmt.Sprintf("quick:%p", m.quickPick), m.renderQuickPickerPanel())
add(fmt.Sprintf("setup:%p", m.setup), m.renderConnectionSetupPanel())
add(fmt.Sprintf("copy:%p", m.copyPick), m.renderCopyPickerPanel())
add("completion", m.renderCompletionPanel())
if m.nativeScrollback {
add("manager", m.renderMainManager())
add("manager", m.renderMainManagerPanel())
}
return p
}
func (m *chatTUI) syncPinnedPanelScroll() {
p := m.pinnedPanelView()
m.pinnedScroll = pinnedPanelScroll{owner: p.owner, focusText: p.focusText, offset: p.offset, width: m.width, capacity: p.capacity}
m.pinnedScroll = pinnedPanelScroll{owner: p.owner, focus: p.focus, selection: p.selection, offset: p.offset, width: m.width, capacity: p.capacity}
}
func (m *chatTUI) scrollPinnedPanel(delta int) bool {
@@ -126,7 +116,7 @@ func (m *chatTUI) scrollPinnedPanel(delta int) bool {
return false
}
m.pinnedScroll = pinnedPanelScroll{
owner: p.owner, focusText: p.focusText, width: m.width, capacity: p.capacity,
owner: p.owner, focus: p.focus, selection: p.selection, width: m.width, capacity: p.capacity,
offset: min(max(0, p.offset+delta), len(p.lines)-p.capacity),
}
return true
+45 -1
View File
@@ -172,11 +172,55 @@ func TestPinnedPanelFollowsElicitationFieldsAndSubmit(t *testing.T) {
}
m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
}
if !strings.Contains(ansi.Strip(m.View().Content), "› ["+i18n.M.ElicitSubmit+"]") {
if !strings.Contains(ansi.Strip(m.View().Content), " ["+i18n.M.ElicitSubmit+"]") {
t.Fatal("elicitation Submit is off screen")
}
}
func TestPinnedPanelIgnoresTodoSelectionPrefixes(t *testing.T) {
for _, marker := range []string{"› ", "❯ ", "▶ "} {
t.Run(marker, func(t *testing.T) {
m := longPanelTestTUI(t)
// A continuation line is displayed without the todo status icon.
// Its content must never become the panel's selected row.
m.todos[0].Content = marker + "ordinary todo\n" + marker + "not selected\n" + strings.Repeat("detail\n", 30)
m.todos[3].Content = "ACTIVE-TASK"
m.todos[3].Status = "in_progress"
m = updateComposerMouseTestTUI(t, m, tea.WindowSizeMsg{Width: 80, Height: 18})
p := m.pinnedPanelView()
if !strings.Contains(ansi.Strip(p.lines[p.focus]), "ACTIVE-TASK") || !strings.Contains(ansi.Strip(m.View().Content), "ACTIVE-TASK") {
t.Fatalf("todo text stole focus from the active task:\n%s", ansi.Strip(m.View().Content))
}
// Wording changes must not cancel a deliberate manual scroll.
m.scrollPinnedPanel(-p.offset)
m.todos[3].Content = "RENAMED-TASK"
if m.pinnedPanelView().offset != 0 {
t.Fatal("renaming the active task reset manual scrolling")
}
})
}
}
func TestPinnedPanelFollowsWindowedSelectionAtSameRow(t *testing.T) {
m := longPanelTestTUI(t)
m.todos = nil
m.quickPick = &quickPicker{title: "Models", selected: 8}
for i := range 24 {
m.quickPick.items = append(m.quickPick.items, quickPickerItem{ID: fmt.Sprint(i), Label: "Same label"})
}
m = updateComposerMouseTestTUI(t, m, tea.WindowSizeMsg{Width: 80, Height: 13})
before := m.pinnedPanelContent().focus
m.scrollPinnedPanel(-1000)
m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
p := m.pinnedPanelView()
if p.focus != before {
t.Fatal("fixture did not keep the selected item on the same row")
}
if !strings.Contains(ansi.Strip(p.render(m.width)), "❯ 10. Same label") {
t.Fatal("selection change at the same row did not restore focus after manual scrolling")
}
}
type panelTerminalReplay struct {
output bytes.Buffer
renderer *uv.TerminalRenderer
+6 -2
View File
@@ -159,8 +159,12 @@ func (m chatTUI) handleQuickPickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
}
func (m chatTUI) renderQuickPicker() string {
return m.renderQuickPickerPanel().text
}
func (m chatTUI) renderQuickPickerPanel() panelContent {
if m.quickPick == nil {
return ""
return panelContent{}
}
return m.quickPick.render(m.width)
return m.quickPick.renderPanel(m.width)
}
+8 -3
View File
@@ -117,8 +117,12 @@ func (p *quickPicker) handleKey(msg tea.KeyPressMsg) quickPickerResult {
}
func (p *quickPicker) render(width int) string {
return p.renderPanel(width).text
}
func (p *quickPicker) renderPanel(width int) panelContent {
if p == nil {
return ""
return panelContent{}
}
w := max(width, 10)
contentWidth := max(w-8, 12)
@@ -127,7 +131,7 @@ func (p *quickPicker) render(width int) string {
p.selected = max(len(items)-1, 0)
}
var b strings.Builder
var b panelBuilder
b.WriteString(accent(p.title) + "\n")
if p.query != "" {
b.WriteString(" " + dim("Search: ") + p.query + "\n")
@@ -145,6 +149,7 @@ func (p *quickPicker) render(width int) string {
if item.Status != "" {
label += " " + dim("("+item.Status+")")
}
b.MarkFocus(i == p.selected, i)
b.WriteString(rowLine(i == p.selected, i+1, "", label, item.Status == "active") + "\n")
if item.Description != "" {
b.WriteString(dim(" "+ansi.Truncate(item.Description, contentWidth, "…")) + "\n")
@@ -159,7 +164,7 @@ func (p *quickPicker) render(width int) string {
hint = "Type to filter · ↑/↓ navigate · Enter select · Esc cancel"
}
b.WriteString(dim(hint))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), width)
}
func quickPickerWindow(total, selected int) (int, int) {
+9 -5
View File
@@ -3,7 +3,6 @@ package cli
import (
"errors"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -181,15 +180,19 @@ func (m chatTUI) applyResumePick() (tea.Model, tea.Cmd) {
}
func (m chatTUI) renderResumePicker() string {
return m.renderResumePickerPanel().text
}
func (m chatTUI) renderResumePickerPanel() panelContent {
r := m.resumePick
if r == nil {
return ""
return panelContent{}
}
if r.quick != nil {
return r.quick.render(m.width)
return r.quick.renderPanel(m.width)
}
w := max(m.width, 10)
var b strings.Builder
var b panelBuilder
b.WriteString(accent(i18n.M.ResumePickTitle) + "\n")
for i, entry := range r.entries {
label := sessionPickerLabel(entry.session)
@@ -199,10 +202,11 @@ func (m chatTUI) renderResumePicker() string {
if i == r.active {
label = dim(label) + " " + dim("(active)")
}
b.MarkFocus(i == r.sel, i)
b.WriteString(rowLine(i == r.sel, i+1, "", label, false) + "\n")
}
b.WriteString(dim(i18n.M.ResumePickHint))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
// sessionPickerLabel is the "N turns · display title" line, truncated to fit.
+11 -5
View File
@@ -215,12 +215,16 @@ func (m chatTUI) commitPreparedRewind() (tea.Model, tea.Cmd) {
}
func (m chatTUI) renderRewind() string {
return m.renderRewindPanel().text
}
func (m chatTUI) renderRewindPanel() panelContent {
r := m.rewind
if r == nil {
return ""
return panelContent{}
}
w := max(m.width, 10)
var b strings.Builder
var b panelBuilder
if r.stage == 0 {
b.WriteString(accent(i18n.M.RewindPickTitle) + "\n")
// Long sessions list one row per turn; window it like quickPicker so
@@ -231,13 +235,14 @@ func (m chatTUI) renderRewind() string {
}
for i := start; i < end; i++ {
meta := r.metas[i]
b.MarkFocus(i == r.sel, i)
b.WriteString(rowLine(i == r.sel, meta.Turn+1, "", turnLabel(meta, w), false) + "\n")
}
if end < len(r.metas) {
b.WriteString(dim(" ↓ more") + "\n")
}
b.WriteString(dim(i18n.M.RewindPickHint))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
meta := r.metas[r.sel]
if r.stage == 2 {
@@ -245,14 +250,15 @@ func (m chatTUI) renderRewind() string {
b.WriteString(fmt.Sprintf(i18n.M.RewindCoverageWarningFmt, len(r.pendingPlan.CoverageGaps)) + "\n")
b.WriteString(dim(fmt.Sprintf(i18n.M.RewindRestoreTitleFmt, meta.Turn+1)+oneLine(meta.Prompt, 48)) + "\n")
b.WriteString(dim(i18n.M.RewindConfirmHint))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
b.WriteString(accent(fmt.Sprintf(i18n.M.RewindRestoreTitleFmt, meta.Turn+1)) + dim(oneLine(meta.Prompt, 48)) + "\n")
for i := range rewindActions {
b.MarkFocus(i == r.scope, i)
b.WriteString(rowLine(i == r.scope, i+1, "", rewindActionLabel(i), false) + "\n")
}
b.WriteString(dim(i18n.M.RewindApplyHint))
return choicePanelStyle.Width(w).Render(b.String())
return b.render(choicePanelStyle.Width(w), m.width)
}
func rewindActionLabel(i int) string {
+37 -23
View File
@@ -14,24 +14,28 @@ const (
)
func (m chatTUI) renderSkillPicker() string {
return m.renderSkillPickerPanel().text
}
func (m chatTUI) renderSkillPickerPanel() panelContent {
p := m.skillPick
if p == nil {
return ""
return panelContent{}
}
w := max(viewWidth(m.width), 40)
switch p.mode {
case pickerSkills:
return managerContentPanelStyle(w).Render(m.renderSkillPickerSkills())
return m.renderSkillPickerSkillsBody().render(managerContentPanelStyle(w), m.width)
case pickerSources:
return managerContentPanelStyle(w).Render(m.renderSkillPickerSources())
return m.renderSkillPickerSourcesBody().render(managerContentPanelStyle(w), m.width)
case pickerSourceSkills:
return managerContentPanelStyle(w).Render(m.renderSkillPickerSourceSkills())
return m.renderSkillPickerSourceSkillsBody().render(managerContentPanelStyle(w), m.width)
case pickerDetail:
return managerContentPanelStyle(w).Render(m.renderSkillPickerDetail())
return m.renderSkillPickerDetailBody().render(managerContentPanelStyle(w), m.width)
case pickerConfirmDelete:
return managerContentPanelStyle(w).Render(m.renderSkillPickerConfirmDelete())
return m.renderSkillPickerConfirmDeleteBody().render(managerContentPanelStyle(w), m.width)
}
return ""
return panelContent{}
}
func (m chatTUI) skillPickerFooterHint() string {
@@ -54,10 +58,10 @@ func (m chatTUI) skillPickerFooterHint() string {
}
}
func (m chatTUI) renderSkillPickerSkills() string {
func (m chatTUI) renderSkillPickerSkillsBody() panelBody {
p := m.skillPick
w := max(viewWidth(m.width), 40)
var b strings.Builder
var b panelBuilder
fmt.Fprintf(&b, "%s\n", viewHeader("Manage skills"))
if summary := skillPickerSummary(p); summary != "" {
@@ -91,6 +95,7 @@ func (m chatTUI) renderSkillPickerSkills() string {
fmt.Fprintf(&b, " %s\n", bold(group))
lastGroup = group
}
b.MarkFocus(i == p.sel, i)
b.WriteString(renderSkillRow(i+1, i == p.sel, skills[i], p.skillEnabled(skills[i].Name), w))
b.WriteByte('\n')
}
@@ -100,7 +105,7 @@ func (m chatTUI) renderSkillPickerSkills() string {
}
}
return strings.TrimRight(b.String(), "\n")
return b.body().trimTrailingNewlines()
}
func (m chatTUI) skillPickerVisibleRows() int {
@@ -148,9 +153,9 @@ func renderSkillSearchBox(query string, active bool, w int) string {
return b.String()
}
func (m chatTUI) renderSkillPickerSources() string {
func (m chatTUI) renderSkillPickerSourcesBody() panelBody {
p := m.skillPick
var b strings.Builder
var b panelBuilder
b.WriteString(accent(i18n.M.SkillPickerSourceTitle))
summary := skillSourceSummary(p.roots)
@@ -162,6 +167,7 @@ func (m chatTUI) renderSkillPickerSources() string {
roots := p.visibleRoots()
for i, r := range roots {
label := sourceRowLabel(r, m.width)
b.MarkFocus(i == p.sourceSel, i)
b.WriteString(rowLine(i == p.sourceSel, i+1, "", label, false))
b.WriteByte('\n')
}
@@ -171,16 +177,16 @@ func (m chatTUI) renderSkillPickerSources() string {
} else {
b.WriteString(dim(" " + i18n.M.SkillPickerDiagHidden))
}
return b.String()
return b.body()
}
func (m chatTUI) renderSkillPickerSourceSkills() string {
func (m chatTUI) renderSkillPickerSourceSkillsBody() panelBody {
p := m.skillPick
var b strings.Builder
var b panelBuilder
root, ok := p.selectedRoot()
if !ok {
p.mode = pickerSources
return m.renderSkillPickerSources()
return m.renderSkillPickerSourcesBody()
}
skills := p.selectedRootSkills()
b.WriteString(accent(i18n.M.SkillPickerSourceTitle))
@@ -190,7 +196,7 @@ func (m chatTUI) renderSkillPickerSourceSkills() string {
if len(skills) == 0 {
b.WriteString(dim(" " + i18n.M.SkillPickerSourceSkillsEmpty))
b.WriteByte('\n')
return b.String()
return b.body()
}
start, end := skillListWindow(p.sourceSkillSel, len(skills), m.skillPickerVisibleRows())
if start > 0 {
@@ -198,6 +204,7 @@ func (m chatTUI) renderSkillPickerSourceSkills() string {
b.WriteByte('\n')
}
for i := start; i < end; i++ {
b.MarkFocus(i == p.sourceSkillSel, i)
b.WriteString(renderSkillRow(i+1, i == p.sourceSkillSel, skills[i], p.skillEnabled(skills[i].Name), m.width))
b.WriteByte('\n')
}
@@ -205,12 +212,16 @@ func (m chatTUI) renderSkillPickerSourceSkills() string {
b.WriteString(dim(" " + fmt.Sprintf(i18n.M.SkillPickerMoreBelowFmt, len(skills)-end)))
b.WriteByte('\n')
}
return b.String()
return b.body()
}
func (m chatTUI) renderSkillPickerDetail() string {
return m.renderSkillPickerDetailBody().text
}
func (m chatTUI) renderSkillPickerDetailBody() panelBody {
p := m.skillPick
var b strings.Builder
var b panelBuilder
b.WriteString(renderSkillDetailHeader(p.detailSkill, m.width))
b.WriteByte('\n')
b.WriteByte('\n')
@@ -222,6 +233,7 @@ func (m chatTUI) renderSkillPickerDetail() string {
b.WriteByte('\n')
actions := skillActionsFor(p.detailSkill)
for i, action := range actions {
b.MarkFocus(i == p.detailAction, i)
b.WriteString(rowLine(i == p.detailAction, i+1, "", action.label, false))
b.WriteByte('\n')
}
@@ -229,12 +241,12 @@ func (m chatTUI) renderSkillPickerDetail() string {
b.WriteByte('\n')
b.WriteString(body)
}
return b.String()
return b.body()
}
func (m chatTUI) renderSkillPickerConfirmDelete() string {
func (m chatTUI) renderSkillPickerConfirmDeleteBody() panelBody {
p := m.skillPick
var b strings.Builder
var b panelBuilder
b.WriteString(accent(fmt.Sprintf(i18n.M.SkillPickerDeleteTitleFmt, p.deleteSkill.Name)))
b.WriteByte('\n')
path := skillDeleteTargetLabel(p.deleteSkill)
@@ -243,10 +255,12 @@ func (m chatTUI) renderSkillPickerConfirmDelete() string {
b.WriteByte('\n')
}
b.WriteByte('\n')
b.MarkFocus(p.confirm == 0, 0)
b.WriteString(rowLine(p.confirm == 0, 1, "", i18n.M.SkillPickerDeleteConfirm, false))
b.WriteByte('\n')
b.MarkFocus(p.confirm == 1, 1)
b.WriteString(rowLine(p.confirm == 1, 2, "", i18n.M.SkillPickerDeleteCancel, false))
return b.String()
return b.body()
}
func renderSkillRow(num int, selected bool, s skill.Skill, enabled bool, w int) string {