mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
Third slice of #501. - Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as iterations accrue), with the loopNextWake channel kept as an override for a future explicit model signal. - Persistent footer segment "↻ N loops · next 3:05pm" while loops are active. - /clear notes that loops keep running (it wipes the transcript, not the session) so a loop never silently fires into a cleared screen. - Tests for adaptive cadence + footer summary.
1005 lines
33 KiB
Go
1005 lines
33 KiB
Go
package tui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"charm.land/lipgloss/v2"
|
||
|
||
"github.com/Gitlawb/zero/internal/agent"
|
||
)
|
||
|
||
const (
|
||
suggestionPaletteMaxVisible = 7
|
||
suggestionPaletteMaxWidth = 76
|
||
suggestionPaletteMinWidth = 44
|
||
pickerOverlayMaxVisible = 10
|
||
pickerOverlayMaxWidth = 92
|
||
pickerOverlayMinWidth = 56
|
||
modelPickerOverlayMaxWidth = 76
|
||
modelPickerOverlayMinWidth = 58
|
||
)
|
||
|
||
// layoutTier buckets the terminal width into the spec's adaptive tiers. It
|
||
// is derived from the live width at every render, so a WindowSizeMsg
|
||
// re-evaluates it implicitly.
|
||
type layoutTier int
|
||
|
||
const (
|
||
tierTiny layoutTier = iota // < 58: single-segment header, rail-less cards
|
||
tierNarrow // 58–79: no gutters, bare badge, lean status
|
||
tierMedium // 80–99: no tool-arg column, no ctx
|
||
tierFull // ≥ 100: everything
|
||
)
|
||
|
||
func widthTier(width int) layoutTier {
|
||
switch {
|
||
case width >= 100:
|
||
return tierFull
|
||
case width >= 80:
|
||
return tierMedium
|
||
case width >= minStartupWidth:
|
||
return tierNarrow
|
||
default:
|
||
return tierTiny
|
||
}
|
||
}
|
||
|
||
// titleBar renders the top zone of the chat surface: git branch and cwd on the
|
||
// left, provider/model and context window on the right, then a rule. Segments
|
||
// drop with the width tier (full → no ctx → no cwd → branch/path only), reusing
|
||
// the startupHeaderLine candidate fallback.
|
||
func (m model) titleBar(width int) string {
|
||
tier := widthTier(width)
|
||
|
||
workspace := m.titleWorkspaceSegment()
|
||
workspaceShort := m.titleWorkspaceSegmentShort()
|
||
branchOnly := m.titleBranchSegment()
|
||
cwdOnly := zeroTheme.faint.Render(shortenPath(m.cwd))
|
||
compactLeft := cwdOnly
|
||
if branchOnly != "" {
|
||
compactLeft = branchOnly
|
||
}
|
||
model := m.titleModelSegment()
|
||
ctx := ""
|
||
if window := m.modelContextWindow(m.modelName); window > 0 {
|
||
ctx = zeroTheme.faint.Render(" · " + formatContextWindow(window))
|
||
}
|
||
|
||
var candidates []headerCandidate
|
||
switch tier {
|
||
case tierFull:
|
||
candidates = []headerCandidate{
|
||
{left: workspace, right: model + ctx},
|
||
{left: workspace, right: model},
|
||
{left: workspaceShort, right: model},
|
||
{left: cwdOnly, right: model},
|
||
{left: compactLeft, right: model},
|
||
}
|
||
case tierMedium:
|
||
candidates = []headerCandidate{
|
||
{left: workspace, right: model},
|
||
{left: workspaceShort, right: model},
|
||
{left: cwdOnly, right: model},
|
||
{left: compactLeft, right: model},
|
||
}
|
||
case tierNarrow:
|
||
candidates = []headerCandidate{
|
||
{left: branchOnly, right: model},
|
||
{left: "", right: model},
|
||
}
|
||
default:
|
||
// Tiny: one segment, no right column.
|
||
candidates = []headerCandidate{
|
||
{left: compactLeft, right: ""},
|
||
{left: cwdOnly, right: ""},
|
||
}
|
||
}
|
||
|
||
line := startupHeaderLine(width, candidates)
|
||
rule := zeroTheme.line.Render(strings.Repeat("─", width))
|
||
return line + "\n" + rule
|
||
}
|
||
|
||
func (m model) titleWorkspaceSegment() string {
|
||
cwd := zeroTheme.faint.Render(shortenPath(m.cwd))
|
||
parts := []string{}
|
||
if branch := m.titleBranchSegment(); branch != "" {
|
||
parts = append(parts, branch)
|
||
}
|
||
if pr := m.titlePRSegment(); pr != "" {
|
||
parts = append(parts, pr)
|
||
}
|
||
if len(parts) > 0 {
|
||
return strings.Join(append(parts, cwd), " ")
|
||
}
|
||
return cwd
|
||
}
|
||
|
||
func (m model) titleWorkspaceSegmentShort() string {
|
||
cwd := zeroTheme.faint.Render(shortenPath(m.cwd))
|
||
parts := []string{}
|
||
branch := strings.TrimSpace(m.gitBranch)
|
||
if branch != "" {
|
||
icon := zeroTheme.muted.Render("")
|
||
parts = append(parts, icon+" "+zeroTheme.muted.Render(middleTruncate(branch, 22)))
|
||
}
|
||
if pr := m.titlePRSegment(); pr != "" {
|
||
parts = append(parts, pr)
|
||
}
|
||
if len(parts) > 0 {
|
||
return strings.Join(append(parts, cwd), " ")
|
||
}
|
||
return cwd
|
||
}
|
||
|
||
func (m model) titleBranchSegment() string {
|
||
branch := strings.TrimSpace(m.gitBranch)
|
||
if branch == "" {
|
||
return ""
|
||
}
|
||
return zeroTheme.muted.Render("") + " " + zeroTheme.muted.Render(branch)
|
||
}
|
||
|
||
func (m model) titlePRSegment() string {
|
||
return renderPRSegments(BuildPRSegments(m.prState, false))
|
||
}
|
||
|
||
func (m model) titleModelSegment() string {
|
||
provider := strings.TrimSpace(m.providerDisplayName())
|
||
model := strings.TrimSpace(m.modelName)
|
||
switch {
|
||
case provider == "" && model == "":
|
||
return zeroTheme.muted.Render("no provider")
|
||
case model == "":
|
||
return zeroTheme.ink.Render(provider)
|
||
case provider == "":
|
||
return zeroTheme.ink.Render(model)
|
||
default:
|
||
return zeroTheme.ink.Render(provider + "/" + model)
|
||
}
|
||
}
|
||
|
||
func (m model) composerDividerLine(width int) string {
|
||
model := displayValue(strings.TrimSpace(m.modelName), "no model")
|
||
// The composer rule is a quiet model reminder above the input. Permission mode
|
||
// and reasoning effort now live in the persistent status line (the conventional
|
||
// footer for run-state), so they're not duplicated on this rule.
|
||
meta := zeroTheme.muted.Render(model)
|
||
metaWidth := lipgloss.Width(meta)
|
||
if width < 8 {
|
||
return zeroTheme.lineStrong.Render(strings.Repeat("─", width))
|
||
}
|
||
if width < metaWidth+4 {
|
||
return zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", width-2) + "╯")
|
||
}
|
||
rule := strings.Repeat("─", width-metaWidth-4)
|
||
return zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯")
|
||
}
|
||
|
||
// statusLine renders the bottom readout as ` │ `-separated groups: the run-state
|
||
// chip (permission mode + effort) on the left, a flexible gap, then the
|
||
// context-fill gauge and token/cost usage on the right. The provider lives in the
|
||
// title bar and is NOT duplicated here. Groups drop with the width tier.
|
||
func (m model) statusLine(width int) string {
|
||
tier := widthTier(width)
|
||
separator := zeroTheme.line.Render(" │ ")
|
||
prefix := " "
|
||
|
||
// Left chip: the safety-relevant run-state — permission mode (auto/ask/unsafe)
|
||
// in its mode colour. This was previously only on the easy-to-miss composer
|
||
// rule; the persistent footer is where users look for "will it run commands?".
|
||
modeText, modeStyle := m.modeLabel()
|
||
left := prefix + zeroTheme.accent.Render("●") + " " + modeStyle.Render(modeText)
|
||
|
||
if tier == tierTiny {
|
||
if m.exitConfirmActive {
|
||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||
}
|
||
if m.cancelConfirmActive {
|
||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||
}
|
||
return fitStyledLine(left, width)
|
||
}
|
||
|
||
// Non-tiny: append the active reasoning effort (brand lime, omitted on auto).
|
||
if m.reasoningEffort != "" {
|
||
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort))
|
||
}
|
||
if m.exitConfirmActive {
|
||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||
} else if m.cancelConfirmActive {
|
||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||
} else if summary := m.backgroundTerminalSummary(); summary != "" {
|
||
left += separator + zeroTheme.muted.Render(summary)
|
||
}
|
||
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
|
||
// running loop is always visible (hidden during an exit/cancel confirm above).
|
||
if !m.exitConfirmActive && !m.cancelConfirmActive {
|
||
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
|
||
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
|
||
}
|
||
}
|
||
|
||
rightGroups := []string{}
|
||
// Context-fill gauge: surface it down to the narrow tier (where it matters
|
||
// most), but skip it when the context sidebar is already showing the % so the
|
||
// figure isn't duplicated.
|
||
gaugeShown := false
|
||
if tier >= tierNarrow && !m.sidebarActive() {
|
||
if gauge := m.contextWindowSegment(); gauge != "" {
|
||
rightGroups = append(rightGroups, gauge)
|
||
gaugeShown = true
|
||
}
|
||
}
|
||
// The sidebar pins the token readout at its floor, and the gauge's "used"
|
||
// figure is the exact same number (both read latestUsageTokens) — either
|
||
// one showing means the plain usage segment must drop its own token count
|
||
// to just the cost, or the count renders twice side by side.
|
||
usage := m.usageStatusSegment()
|
||
if m.sidebarActive() || gaugeShown {
|
||
usage = m.usageCostSegment()
|
||
}
|
||
if usage != "" {
|
||
rightGroups = append(rightGroups, zeroTheme.muted.Render(usage))
|
||
}
|
||
right := strings.Join(rightGroups, separator)
|
||
|
||
return fitStyledLine(joinHeaderLine(left, right, width), width)
|
||
}
|
||
|
||
func (m model) providerDisplayName() string {
|
||
provider := strings.TrimSpace(m.providerName)
|
||
if provider == "" {
|
||
provider = strings.TrimSpace(m.providerProfile.Name)
|
||
}
|
||
if !providerDisplayNameIsGenericCustom(provider) {
|
||
return provider
|
||
}
|
||
baseURL := strings.TrimSpace(m.providerProfile.BaseURL)
|
||
if baseURL == "" || strings.Contains(strings.ToLower(baseURL), "example.invalid") {
|
||
return provider
|
||
}
|
||
derived := providerWizardNameFromBaseURL(baseURL)
|
||
if derived == "" || derived == "custom" {
|
||
return provider
|
||
}
|
||
return derived
|
||
}
|
||
|
||
func providerDisplayNameIsGenericCustom(name string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||
case "custom-openai-compatible", "custom-anthropic-compatible":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// nextPermissionMode toggles between the two prompt-respecting modes:
|
||
// Auto ⇄ Ask. Unsafe (which disables permission prompts entirely) is
|
||
// deliberately NOT reachable by a casual keypress — a single shift+tab landing
|
||
// on it would let prompt-required tools run with no decision. Unsafe stays an
|
||
// explicit opt-in (the launch/--skip-permissions-unsafe path), not a UI toggle.
|
||
// Unsafe is folded back to Ask so the toggle always lands somewhere safe.
|
||
func nextPermissionMode(mode agent.PermissionMode) agent.PermissionMode {
|
||
switch mode {
|
||
case agent.PermissionModeAuto:
|
||
return agent.PermissionModeAsk
|
||
case agent.PermissionModeAsk:
|
||
return agent.PermissionModeAuto
|
||
default:
|
||
// Anything else (incl. an externally-set Unsafe) folds to Ask — the stricter
|
||
// landing, so toggling never makes an Unsafe session less strict.
|
||
return agent.PermissionModeAsk
|
||
}
|
||
}
|
||
|
||
func (m model) modeLabel() (string, lipgloss.Style) {
|
||
switch m.permissionMode {
|
||
case agent.PermissionModeAuto:
|
||
return "auto-approve", zeroTheme.modeAuto
|
||
case agent.PermissionModeAsk:
|
||
return "ask", zeroTheme.modeAsk
|
||
case agent.PermissionModeUnsafe:
|
||
return "unsafe", zeroTheme.modeUnsafe
|
||
default:
|
||
mode := strings.TrimSpace(string(m.permissionMode))
|
||
if mode == "" {
|
||
return "auto-approve", zeroTheme.modeAuto
|
||
}
|
||
return mode, zeroTheme.muted
|
||
}
|
||
}
|
||
|
||
// usageStatusSegment shows the latest provider step's token footprint, plus
|
||
// cumulative cost once anything is priced.
|
||
func (m model) usageStatusSegment() string {
|
||
if m.usageTracker == nil {
|
||
return ""
|
||
}
|
||
summary := m.usageTracker.Summary()
|
||
tokens := m.latestUsageTokens(summary)
|
||
if tokens <= 0 {
|
||
return ""
|
||
}
|
||
if summary.RecordCount == 0 {
|
||
return humanCount(tokens) + " tok"
|
||
}
|
||
return fmt.Sprintf("%s tok · %s",
|
||
humanCount(tokens),
|
||
summary.FormattedTotalCost,
|
||
)
|
||
}
|
||
|
||
// usageCostSegment returns just the session cost, with the token figure dropped.
|
||
// Used in the status line when the sidebar is open and already showing tokens at
|
||
// its floor, so the cost survives without duplicating the token count. Empty
|
||
// until a priced usage record lands (no cost to show yet).
|
||
func (m model) usageCostSegment() string {
|
||
if m.usageTracker == nil {
|
||
return ""
|
||
}
|
||
summary := m.usageTracker.Summary()
|
||
if summary.RecordCount == 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(summary.FormattedTotalCost)
|
||
}
|
||
|
||
// contextFillPercent returns the latest request's context-window fill as a percent
|
||
// (0-100), the tokens used, the model's window, and a colour graded for the fill
|
||
// (green <75% → amber ≥75% → red ≥90%). ok is false until a usage event lands or
|
||
// when the model's window is unknown. Shared by the status-line gauge and the
|
||
// sidebar context chip so they grade identically. This is the "you're at X% of
|
||
// context" reading the compaction trigger already reasons about at ~80%.
|
||
func (m model) contextFillPercent() (pct, used, window int, style lipgloss.Style, ok bool) {
|
||
if m.usageTracker == nil {
|
||
return 0, 0, 0, lipgloss.Style{}, false
|
||
}
|
||
summary := m.usageTracker.Summary()
|
||
used = m.latestUsageTokens(summary)
|
||
window = m.modelContextWindow(m.modelName)
|
||
if used <= 0 || window <= 0 {
|
||
return 0, 0, 0, lipgloss.Style{}, false
|
||
}
|
||
ratio := float64(used) / float64(window)
|
||
if ratio > 1 {
|
||
ratio = 1
|
||
}
|
||
style = zeroTheme.green
|
||
switch {
|
||
case ratio >= 0.90:
|
||
style = zeroTheme.red
|
||
case ratio >= 0.75:
|
||
style = zeroTheme.amber
|
||
}
|
||
return int(ratio*100 + 0.5), used, window, style, true
|
||
}
|
||
|
||
// contextWindowSegment renders the status-line context-fill gauge as
|
||
// "◔ used/window · NN%", graded by contextFillPercent.
|
||
func (m model) contextWindowSegment() string {
|
||
pct, used, window, style, ok := m.contextFillPercent()
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return style.Render(fmt.Sprintf("◔ %s/%s · %d%%", humanCount(used), humanCount(window), pct))
|
||
}
|
||
|
||
// humanCount renders a token count the way the status line wants it: 999,
|
||
// 12.4K, 200K, 1M, 1.2M.
|
||
func humanCount(n int) string {
|
||
if n < 0 {
|
||
n = 0
|
||
}
|
||
switch {
|
||
case n < 1000:
|
||
return strconv.Itoa(n)
|
||
case n < 1_000_000:
|
||
return humanCountScaled(float64(n)/1000, "K")
|
||
default:
|
||
return humanCountScaled(float64(n)/1_000_000, "M")
|
||
}
|
||
}
|
||
|
||
func humanCountScaled(value float64, suffix string) string {
|
||
text := fmt.Sprintf("%.1f%s", value, suffix)
|
||
return strings.Replace(text, ".0"+suffix, suffix, 1)
|
||
}
|
||
|
||
// formatContextWindow renders a model's context window for the title bar
|
||
// (200000 → 200K, 1048576 → 1M).
|
||
func formatContextWindow(window int) string {
|
||
if window <= 0 {
|
||
return ""
|
||
}
|
||
if window >= 1_000_000 && window%1_000_000 < 100_000 {
|
||
return strconv.Itoa(window/1_000_000) + "M"
|
||
}
|
||
return strconv.Itoa(window/1000) + "K"
|
||
}
|
||
|
||
func shortenPath(path string) string {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return "unknown"
|
||
}
|
||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||
// Match on a path boundary: a bare prefix check would mangle siblings
|
||
// like /Users/alice2 when home is /Users/alice.
|
||
if path == home {
|
||
return "~"
|
||
}
|
||
if strings.HasPrefix(path, home+string(os.PathSeparator)) {
|
||
return "~" + path[len(home):]
|
||
}
|
||
}
|
||
return path
|
||
}
|
||
|
||
// gitBranch reads the current branch (or short SHA when detached) for cwd, handling
|
||
// both regular checkouts (.git dir) and worktrees (.git file). Returns "" on any
|
||
// problem — the header simply omits the segment.
|
||
func gitBranch(cwd string) string {
|
||
if strings.TrimSpace(cwd) == "" {
|
||
return ""
|
||
}
|
||
gitPath := filepath.Join(cwd, ".git")
|
||
info, err := os.Stat(gitPath)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
|
||
headPath := filepath.Join(gitPath, "HEAD")
|
||
if !info.IsDir() {
|
||
data, err := os.ReadFile(gitPath)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
dir := strings.TrimPrefix(strings.TrimSpace(string(data)), "gitdir: ")
|
||
if dir == "" {
|
||
return ""
|
||
}
|
||
headPath = filepath.Join(dir, "HEAD")
|
||
}
|
||
|
||
data, err := os.ReadFile(headPath)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
ref := strings.TrimSpace(string(data))
|
||
if strings.HasPrefix(ref, "ref: ") {
|
||
ref = strings.TrimPrefix(ref, "ref: ")
|
||
return strings.TrimPrefix(ref, "refs/heads/")
|
||
}
|
||
if len(ref) >= 7 {
|
||
return ref[:7]
|
||
}
|
||
return ref
|
||
}
|
||
|
||
// suggestionOverlay renders the slash-command/file autocomplete palette below
|
||
// the composer: compact, bordered, and capped so short prefixes cannot flood
|
||
// the chat surface. Returns "" when no overlay should show.
|
||
func (m model) suggestionOverlay(width int) string {
|
||
if !m.suggestionsActive() {
|
||
return ""
|
||
}
|
||
title := "Commands"
|
||
query := commandSuggestionQuery(m.input.Value())
|
||
footer := "↑/↓ move Enter run Esc close"
|
||
if m.selectedCommandSuggestionRequiresInput() {
|
||
footer = "↑/↓ move Enter insert Esc close"
|
||
}
|
||
if m.suggestionsAreFiles {
|
||
title = "Files"
|
||
query = fileSuggestionQuery(m.input.Value())
|
||
footer = "↑/↓ move Enter insert Esc close"
|
||
}
|
||
return centerRenderedBlock(renderSuggestionPalette(selectableItems(m.suggestions, m.suggestionsAreFiles), m.suggestionIdx, width, title, query, footer), width)
|
||
}
|
||
|
||
func commandSuggestionQuery(value string) string {
|
||
trimmed := strings.TrimLeft(value, " ")
|
||
if trimmed == "" {
|
||
return ""
|
||
}
|
||
if fields := strings.Fields(trimmed); len(fields) > 0 {
|
||
return strings.TrimPrefix(fields[0], "/")
|
||
}
|
||
return strings.TrimPrefix(strings.TrimSpace(trimmed), "/")
|
||
}
|
||
|
||
func fileSuggestionQuery(value string) string {
|
||
if token, ok := trailingAtToken(value); ok {
|
||
return token
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func renderSuggestionPalette(items []selectableListItem, selected, width int, title, query, footer string) string {
|
||
if width <= 0 {
|
||
width = defaultStartupWidth
|
||
}
|
||
paletteWidth := minInt(width, suggestionPaletteMaxWidth)
|
||
if paletteWidth < suggestionPaletteMinWidth {
|
||
paletteWidth = width
|
||
}
|
||
innerWidth := maxInt(1, paletteWidth-4)
|
||
maxVisible := minInt(suggestionPaletteMaxVisible, len(items))
|
||
visible := []selectableListItem{}
|
||
start := 0
|
||
if len(items) > 0 {
|
||
selected = clampInt(selected, 0, len(items)-1)
|
||
start = selectableListStart(len(items), maxVisible, selected)
|
||
visible = items[start : start+maxVisible]
|
||
}
|
||
|
||
labelWidth := 0
|
||
for _, item := range visible {
|
||
if w := lipgloss.Width(item.Label); w > labelWidth {
|
||
labelWidth = w
|
||
}
|
||
}
|
||
labelWidth = minInt(labelWidth, maxInt(8, innerWidth/2))
|
||
|
||
lines := make([]string, 0, len(visible)+5)
|
||
searchInset := lipgloss.Width("❯ ")
|
||
searchPrefix := transparentSurface(zeroTheme.ink).Render(strings.Repeat(" ", searchInset))
|
||
lines = append(lines, fillPaletteLine(searchPrefix+renderSuggestionSearchLine(query, maxInt(1, innerWidth-searchInset)), innerWidth, transparentSurface))
|
||
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
|
||
|
||
for index, item := range visible {
|
||
absoluteIndex := start + index
|
||
surface := transparentSurface
|
||
marker := surface(zeroTheme.faintest).Render(" ")
|
||
if absoluteIndex == selected {
|
||
surface = zeroTheme.onSel
|
||
marker = surface(zeroTheme.accent).Render("❯ ")
|
||
}
|
||
|
||
labelText := truncateRunes(item.Label, labelWidth)
|
||
label := surface(zeroTheme.ink).Render(labelText)
|
||
pad := surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(0, labelWidth-lipgloss.Width(labelText))))
|
||
line := marker + label + pad
|
||
if desc := strings.TrimSpace(item.Description); desc != "" {
|
||
descWidth := innerWidth - lipgloss.Width(marker) - labelWidth - 2
|
||
if truncated := truncateRunes(desc, maxInt(0, descWidth)); truncated != "" {
|
||
line += surface(zeroTheme.faint).Render(" " + truncated)
|
||
}
|
||
}
|
||
lines = append(lines, fillPaletteLine(line, innerWidth, surface))
|
||
}
|
||
if len(visible) == 0 {
|
||
message := "no matching commands"
|
||
if strings.EqualFold(strings.TrimSpace(title), "Files") {
|
||
message = "no matching files"
|
||
}
|
||
lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render(message), innerWidth, transparentSurface))
|
||
}
|
||
|
||
if footer = strings.TrimSpace(footer); footer != "" {
|
||
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
|
||
line := zeroTheme.faint.Render(footer)
|
||
lines = append(lines, fillPaletteLine(line, innerWidth, transparentSurface))
|
||
}
|
||
return styledBlockFillTitle(paletteWidth, strings.TrimSpace(title), lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||
}
|
||
|
||
func styledBlockFillTitle(width int, title string, lines []string, borderStyle lipgloss.Style, fill lipgloss.Style) string {
|
||
return styledBlockFillTitleStyled(width, title, lines, borderStyle, fill, zeroTheme.ink.Bold(true))
|
||
}
|
||
|
||
// styledBlockFillTitleStyled is styledBlockFillTitle with a caller-supplied style
|
||
// for the inset title text, so a card can pick a calmer/status-tinted title
|
||
// without changing the default bright-bold heading every other card uses.
|
||
func styledBlockFillTitleStyled(width int, title string, lines []string, borderStyle lipgloss.Style, fill lipgloss.Style, titleStyle lipgloss.Style) string {
|
||
if width < 4 {
|
||
width = 4
|
||
}
|
||
if title = strings.TrimSpace(title); title == "" || widthTier(width) == tierTiny {
|
||
return styledBlockFill(width, lines, borderStyle, fill)
|
||
}
|
||
ruleWidth := width - 2
|
||
titleText := " " + title + " "
|
||
titleWidth := lipgloss.Width(titleText)
|
||
if titleWidth >= ruleWidth {
|
||
return styledBlockFill(width, lines, borderStyle, fill)
|
||
}
|
||
|
||
leftRule := "──"
|
||
rightRule := strings.Repeat("─", maxInt(0, ruleWidth-lipgloss.Width(leftRule)-titleWidth))
|
||
top := borderStyle.Render("╭"+leftRule) + titleStyle.Render(titleText) + borderStyle.Render(rightRule+"╮")
|
||
bottom := borderStyle.Render("╰" + strings.Repeat("─", width-2) + "╯")
|
||
|
||
body := make([]string, 0, len(lines)+2)
|
||
body = append(body, top)
|
||
for _, line := range lines {
|
||
available := width - 4
|
||
fitted := fitStyledLine(line, available)
|
||
pad := fill.Render(strings.Repeat(" ", maxInt(0, available-lipgloss.Width(fitted))))
|
||
body = append(body, borderStyle.Render("│ ")+fitted+pad+borderStyle.Render(" │"))
|
||
}
|
||
body = append(body, bottom)
|
||
return strings.Join(body, "\n")
|
||
}
|
||
|
||
func renderSuggestionSearchLine(query string, width int) string {
|
||
query = strings.TrimSpace(query)
|
||
label := zeroTheme.userPrompt.Render("search > ")
|
||
valueWidth := maxInt(1, width-lipgloss.Width(label))
|
||
value := zeroTheme.ink.Render(truncateRunes(query, valueWidth))
|
||
return fitStyledLine(label+value, width)
|
||
}
|
||
|
||
func transparentSurface(style lipgloss.Style) lipgloss.Style {
|
||
return style
|
||
}
|
||
|
||
func fillPaletteLine(line string, width int, surface func(lipgloss.Style) lipgloss.Style) string {
|
||
line = fitStyledLine(line, width)
|
||
pad := maxInt(0, width-lipgloss.Width(line))
|
||
if pad > 0 {
|
||
line += surface(zeroTheme.ink).Render(strings.Repeat(" ", pad))
|
||
}
|
||
return line
|
||
}
|
||
|
||
func centerRenderedBlock(block string, width int) string {
|
||
if block == "" || width <= 0 {
|
||
return block
|
||
}
|
||
lines := strings.Split(block, "\n")
|
||
blockWidth := 0
|
||
for _, line := range lines {
|
||
if w := lipgloss.Width(line); w > blockWidth {
|
||
blockWidth = w
|
||
}
|
||
}
|
||
pad := maxInt(0, (width-blockWidth)/2)
|
||
if pad == 0 {
|
||
return block
|
||
}
|
||
return indentBlock(block, pad)
|
||
}
|
||
|
||
func selectableItems(suggestions []commandSuggestion, files bool) []selectableListItem {
|
||
items := make([]selectableListItem, 0, len(suggestions))
|
||
for _, suggestion := range suggestions {
|
||
item := selectableListItem{Label: suggestion.Name, Description: suggestion.Desc}
|
||
if files {
|
||
item = fileSelectableItem(suggestion.Name)
|
||
} else {
|
||
item.Label = strings.TrimPrefix(item.Label, "/")
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items
|
||
}
|
||
|
||
func fileSelectableItem(token string) selectableListItem {
|
||
rel := strings.TrimPrefix(token, "@")
|
||
rel = filepath.ToSlash(rel)
|
||
isDir := strings.HasSuffix(rel, "/")
|
||
cleanRel := strings.TrimSuffix(rel, "/")
|
||
base := path.Base(cleanRel)
|
||
if base == "." || base == "/" || base == "" {
|
||
return selectableListItem{Label: strings.TrimPrefix(token, "@"), Description: "file"}
|
||
}
|
||
if isDir {
|
||
base += "/"
|
||
}
|
||
dir := path.Dir(cleanRel)
|
||
if dir == "." || dir == "" {
|
||
return selectableListItem{Label: base}
|
||
}
|
||
return selectableListItem{Label: base, Description: dir}
|
||
}
|
||
|
||
// pickerOverlay renders an open interactive selector as a centered modal: a
|
||
// bordered panel with a title-and-hints row, rows carrying a provider dot and
|
||
// right metadata when the catalog exposes them, and the selected row on the
|
||
// selection tint.
|
||
func (m model) pickerOverlay(width int) string {
|
||
if m.picker == nil {
|
||
return ""
|
||
}
|
||
if m.picker.kind == pickerModel {
|
||
return m.modelPickerOverlay(width)
|
||
}
|
||
overlayWidth := minInt(width, pickerOverlayMaxWidth)
|
||
if overlayWidth < pickerOverlayMinWidth {
|
||
overlayWidth = width
|
||
}
|
||
innerWidth := maxInt(1, overlayWidth-4)
|
||
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
|
||
start := 0
|
||
visible := []pickerItem{}
|
||
if len(m.picker.items) > 0 {
|
||
m.picker.selected = clampInt(m.picker.selected, 0, len(m.picker.items)-1)
|
||
start = selectableListStart(len(m.picker.items), maxVisible, m.picker.selected)
|
||
visible = m.picker.items[start : start+maxVisible]
|
||
}
|
||
|
||
lines := make([]string, 0, len(visible)+5)
|
||
title := strings.TrimSpace(m.picker.title)
|
||
hint := "↑/↓ · ⏎ · esc"
|
||
lines = append(lines, zeroTheme.faint.Render(hint))
|
||
lastGroup := ""
|
||
for index, item := range visible {
|
||
absoluteIndex := start + index
|
||
if item.Group != "" && item.Group != lastGroup {
|
||
lines = append(lines, zeroTheme.accent.Render(item.Group))
|
||
lastGroup = item.Group
|
||
}
|
||
surface := transparentSurface
|
||
marker := surface(zeroTheme.faintest).Render(" ")
|
||
if absoluteIndex == m.picker.selected {
|
||
surface = zeroTheme.onSel
|
||
marker = surface(zeroTheme.accent).Render("❯ ")
|
||
}
|
||
left := marker
|
||
switch {
|
||
case item.Local:
|
||
left += surface(zeroTheme.blue).Render("● ")
|
||
case item.Remote:
|
||
left += surface(zeroTheme.accent).Render("● ")
|
||
}
|
||
if item.Favorite {
|
||
left += surface(zeroTheme.accent).Render("* ")
|
||
}
|
||
left += surface(zeroTheme.ink).Render(item.Label)
|
||
right := ""
|
||
if item.Meta != "" {
|
||
right = surface(zeroTheme.faintest).Render(item.Meta)
|
||
}
|
||
// Paint the gap on the row surface so selected rows read as one solid
|
||
// band; joinHeaderLine would pad with bare (untinted) spaces.
|
||
gap := innerWidth - lipgloss.Width(left) - lipgloss.Width(right)
|
||
line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right
|
||
lines = append(lines, fitStyledLine(line, innerWidth))
|
||
}
|
||
if len(visible) == 0 {
|
||
lines = append(lines, zeroTheme.faint.Render(" no matching items"))
|
||
}
|
||
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
|
||
}
|
||
|
||
func (m model) modelPickerOverlay(width int) string {
|
||
if m.picker == nil {
|
||
return ""
|
||
}
|
||
if m.modelPickerLoading {
|
||
return m.modelPickerLoadingOverlay(width)
|
||
}
|
||
overlayWidth := modelPickerOverlayWidth(width, m.picker)
|
||
innerWidth := maxInt(1, overlayWidth-4)
|
||
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
|
||
start := 0
|
||
visible := []pickerItem{}
|
||
if len(m.picker.items) > 0 {
|
||
m.picker.selected = clampInt(m.picker.selected, 0, len(m.picker.items)-1)
|
||
start = selectableListStart(len(m.picker.items), maxVisible, m.picker.selected)
|
||
visible = m.picker.items[start : start+maxVisible]
|
||
}
|
||
|
||
lines := make([]string, 0, len(visible)+6)
|
||
searchInset := lipgloss.Width("❯ ")
|
||
searchPrefix := transparentSurface(zeroTheme.ink).Render(strings.Repeat(" ", searchInset))
|
||
lines = append(lines, fillPaletteLine(searchPrefix+renderModelPickerSearchLine(m.picker.query, maxInt(1, innerWidth-searchInset)), innerWidth, transparentSurface))
|
||
if status := strings.TrimSpace(m.modelPickerLoadError); status != "" {
|
||
lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render(status), innerWidth, transparentSurface))
|
||
}
|
||
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
|
||
lastGroup := ""
|
||
for index, item := range visible {
|
||
if item.Group != "" && item.Group != lastGroup {
|
||
lines = append(lines, fillPaletteLine(zeroTheme.accent.Bold(true).Render(item.Group), innerWidth, transparentSurface))
|
||
lastGroup = item.Group
|
||
}
|
||
lines = append(lines, renderModelPickerRow(innerWidth, start+index == m.picker.selected, item))
|
||
}
|
||
if len(visible) == 0 {
|
||
lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render("no matching models"), innerWidth, transparentSurface))
|
||
}
|
||
if item, ok := m.picker.current(); ok {
|
||
if detail := modelPickerItemDetail(item); detail != "" {
|
||
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
|
||
lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render(detail), innerWidth, transparentSurface))
|
||
}
|
||
}
|
||
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
|
||
footer := "↑/↓ move Enter select Ctrl+F favorite Esc close"
|
||
lines = append(lines, fillPaletteLine(zeroTheme.faint.Render(footer), innerWidth, transparentSurface))
|
||
title := strings.TrimSpace(m.picker.title)
|
||
if title == "" {
|
||
title = "Choose a model"
|
||
}
|
||
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
|
||
}
|
||
|
||
func (m model) modelPickerLoadingOverlay(width int) string {
|
||
overlayWidth := modelPickerLoadingOverlayWidth(width)
|
||
innerWidth := maxInt(1, overlayWidth-4)
|
||
lines := []string{
|
||
fillPaletteLine(zeroTheme.faint.Render("Checking available models..."), innerWidth, transparentSurface),
|
||
fillPaletteLine(zeroTheme.faint.Render("Built-in models will be used if discovery fails."), innerWidth, transparentSurface),
|
||
zeroTheme.line.Render(strings.Repeat("─", innerWidth)),
|
||
fillPaletteLine(zeroTheme.faint.Render("Esc close"), innerWidth, transparentSurface),
|
||
}
|
||
title := strings.TrimSpace(m.picker.title)
|
||
if title == "" {
|
||
title = "Choose a model"
|
||
}
|
||
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
|
||
}
|
||
|
||
func modelPickerLoadingOverlayWidth(terminalWidth int) int {
|
||
if terminalWidth <= 0 {
|
||
terminalWidth = defaultStartupWidth
|
||
}
|
||
available := minInt(terminalWidth, modelPickerOverlayMaxWidth)
|
||
if terminalWidth < modelPickerOverlayMinWidth {
|
||
available = terminalWidth
|
||
}
|
||
target := lipgloss.Width("Built-in models will be used if discovery fails.")
|
||
target = maxInt(target, lipgloss.Width("Choose a model"))
|
||
target = maxInt(target, lipgloss.Width("Esc close"))
|
||
overlayWidth := maxInt(modelPickerOverlayMinWidth, target+4)
|
||
return minInt(overlayWidth, maxInt(4, available))
|
||
}
|
||
|
||
func modelPickerOverlayWidth(terminalWidth int, picker *commandPicker) int {
|
||
if terminalWidth <= 0 {
|
||
terminalWidth = defaultStartupWidth
|
||
}
|
||
available := minInt(terminalWidth, modelPickerOverlayMaxWidth)
|
||
if terminalWidth < modelPickerOverlayMinWidth {
|
||
available = terminalWidth
|
||
}
|
||
target := lipgloss.Width("Choose a model")
|
||
target = maxInt(target, lipgloss.Width(" search > model name..."))
|
||
target = maxInt(target, lipgloss.Width("↑/↓ move Enter select Ctrl+F favorite Esc close"))
|
||
target = maxInt(target, lipgloss.Width(" Using built-in model list"))
|
||
if picker != nil {
|
||
for _, item := range picker.items {
|
||
labelWidth := lipgloss.Width(item.Label)
|
||
if item.Favorite {
|
||
labelWidth += lipgloss.Width("* ")
|
||
}
|
||
target = maxInt(target, lipgloss.Width("❯ ")+labelWidth)
|
||
if detail := modelPickerItemDetail(item); detail != "" {
|
||
target = maxInt(target, lipgloss.Width(" "+detail))
|
||
}
|
||
}
|
||
}
|
||
overlayWidth := maxInt(modelPickerOverlayMinWidth, target+4)
|
||
return minInt(overlayWidth, maxInt(4, available))
|
||
}
|
||
|
||
func renderModelPickerSearchLine(query string, width int) string {
|
||
query = strings.TrimSpace(query)
|
||
prompt := zeroTheme.userPrompt.Render("search > ")
|
||
cursor := zeroTheme.accent.Render("▌")
|
||
if query == "" {
|
||
return fitStyledLine(prompt+cursor+zeroTheme.faint.Render("model name..."), width)
|
||
}
|
||
return fitStyledLine(prompt+zeroTheme.ink.Render(query)+cursor, width)
|
||
}
|
||
|
||
func renderModelPickerRow(width int, selected bool, item pickerItem) string {
|
||
surface := transparentSurface
|
||
marker := surface(zeroTheme.faintest).Render(" ")
|
||
if selected {
|
||
surface = zeroTheme.onSel
|
||
marker = surface(zeroTheme.accent).Render("❯ ")
|
||
}
|
||
label := strings.TrimSpace(item.Label)
|
||
if label == "" {
|
||
label = strings.TrimSpace(item.Value)
|
||
}
|
||
prefix := ""
|
||
if item.Favorite {
|
||
prefix = "* "
|
||
}
|
||
left := marker + surface(zeroTheme.ink).Render(prefix+label)
|
||
// The provider is shown as a section header above each group, so rows no longer
|
||
// repeat it as a right-aligned tag (matches a grouped provider+model list).
|
||
return fillPaletteLine(left, width, surface)
|
||
}
|
||
|
||
func modelPickerItemDetail(item pickerItem) string {
|
||
parts := []string{}
|
||
value := strings.TrimSpace(item.Value)
|
||
label := strings.TrimSpace(item.Label)
|
||
if value != "" && value != label {
|
||
parts = append(parts, value)
|
||
}
|
||
if meta := strings.TrimSpace(item.Meta); meta != "" {
|
||
parts = append(parts, meta)
|
||
}
|
||
return strings.Join(parts, " · ")
|
||
}
|
||
|
||
// argHint extracts the most representative argument from a tool call's raw JSON
|
||
// arguments for the single-line tool row (the path, pattern, or command acted on).
|
||
func argHint(raw string) string {
|
||
return firstArgValue(raw, []string{"path", "file", "file_path", "filepath", "pattern", "query", "command", "cmd", "url", "task"})
|
||
}
|
||
|
||
// argHintSecondary extracts the card head's faintest arg column: the
|
||
// non-target argument (pattern/query/command) when argHint already resolved to
|
||
// a path. With no path argument the value is argHint itself, so it stays in
|
||
// the target slot and this returns "".
|
||
func argHintSecondary(raw string) string {
|
||
secondary := firstArgValue(raw, []string{"pattern", "query", "command", "cmd", "url"})
|
||
if secondary == "" || secondary == argHint(raw) {
|
||
return ""
|
||
}
|
||
return secondary
|
||
}
|
||
|
||
func firstArgValue(raw string, keys []string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" || raw == "{}" {
|
||
return ""
|
||
}
|
||
var args map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||
return ""
|
||
}
|
||
for _, key := range keys {
|
||
if value, ok := args[key]; ok {
|
||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||
return singleLineToolHeadText(text)
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// looksLikeDiff reports whether output should be rendered as a diff card: a
|
||
// real hunk header, or both old/new file headers. A single line starting with
|
||
// "---" (a Markdown rule, YAML document marker, log separator…) must NOT
|
||
// hijack ordinary bash/tool output into the diff renderer.
|
||
func looksLikeDiff(text string) bool {
|
||
if !strings.Contains(text, "\n") {
|
||
return false
|
||
}
|
||
hasOld, hasNew := false, false
|
||
for _, line := range strings.Split(text, "\n") {
|
||
switch {
|
||
case hunkHeaderPattern.MatchString(line):
|
||
return true
|
||
case strings.HasPrefix(line, "+++ "):
|
||
hasNew = true
|
||
case strings.HasPrefix(line, "--- "):
|
||
hasOld = true
|
||
}
|
||
if hasOld && hasNew {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func truncateRunes(text string, limit int) string {
|
||
if limit <= 0 {
|
||
return ""
|
||
}
|
||
runes := []rune(text)
|
||
if len(runes) <= limit {
|
||
return text
|
||
}
|
||
if limit == 1 {
|
||
return "…"
|
||
}
|
||
return string(runes[:limit-1]) + "…"
|
||
}
|