mirror of
https://github.com/theniceboy/.config.git
synced 2026-05-01 19:05:55 +08:00
2294 lines
72 KiB
Go
2294 lines
72 KiB
Go
package main
|
||
|
||
import (
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
const paletteNoDeviceOption = "__no_device__"
|
||
|
||
var paletteModalBorder = lipgloss.Border{
|
||
Top: "─",
|
||
Bottom: "─",
|
||
Left: "│",
|
||
Right: "│",
|
||
TopLeft: "┌",
|
||
TopRight: "┐",
|
||
BottomLeft: "└",
|
||
BottomRight: "┘",
|
||
}
|
||
|
||
var paletteDestroyLauncher = launchPaletteDestroyWithConfirm
|
||
var paletteTmuxRunner = runTmux
|
||
var paletteTmuxOutput = runTmuxOutput
|
||
|
||
type paletteRuntime struct {
|
||
windowID string
|
||
agentID string
|
||
reg *registry
|
||
record *agentRecord
|
||
startupMessage string
|
||
currentPath string
|
||
currentSessionName string
|
||
currentWindowName string
|
||
mainRepoRoot string
|
||
}
|
||
|
||
type paletteModel struct {
|
||
runtime *paletteRuntime
|
||
state paletteUIState
|
||
actions []paletteAction
|
||
openedAt time.Time
|
||
width int
|
||
height int
|
||
result paletteResult
|
||
todo *todoPanelModel
|
||
activity *activityMonitorBT
|
||
devices *devicePanelModel
|
||
status *statusRightPanelModel
|
||
tracker *trackerPanelModel
|
||
}
|
||
|
||
type paletteStyles struct {
|
||
title lipgloss.Style
|
||
meta lipgloss.Style
|
||
searchBox lipgloss.Style
|
||
searchPrompt lipgloss.Style
|
||
input lipgloss.Style
|
||
inputCursor lipgloss.Style
|
||
item lipgloss.Style
|
||
selectedItem lipgloss.Style
|
||
sectionLabel lipgloss.Style
|
||
selectedLabel lipgloss.Style
|
||
itemTitle lipgloss.Style
|
||
itemSubtitle lipgloss.Style
|
||
selectedSubtle lipgloss.Style
|
||
panelTitle lipgloss.Style
|
||
panelText lipgloss.Style
|
||
muted lipgloss.Style
|
||
footer lipgloss.Style
|
||
keyword lipgloss.Style
|
||
modal lipgloss.Style
|
||
modalTitle lipgloss.Style
|
||
modalBody lipgloss.Style
|
||
modalHint lipgloss.Style
|
||
statusBad lipgloss.Style
|
||
statLabel lipgloss.Style
|
||
statValue lipgloss.Style
|
||
todoCheck lipgloss.Style
|
||
todoCheckDone lipgloss.Style
|
||
panelTextDone lipgloss.Style
|
||
shortcutKey lipgloss.Style
|
||
shortcutText lipgloss.Style
|
||
}
|
||
|
||
type paletteTodoPreviewItem struct {
|
||
Title string
|
||
Done bool
|
||
}
|
||
|
||
type paletteTodoPreviewSection struct {
|
||
Title string
|
||
Lead string
|
||
Items []paletteTodoPreviewItem
|
||
Empty string
|
||
}
|
||
|
||
func runBubbleTeaPalette(args []string) error {
|
||
runtime, err := loadPaletteRuntime(args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
state := paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage}
|
||
for {
|
||
model := newPaletteModel(runtime, state)
|
||
finalModel, err := tea.NewProgram(model).Run()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
final, ok := finalModel.(*paletteModel)
|
||
if !ok {
|
||
return fmt.Errorf("unexpected palette model type")
|
||
}
|
||
state = final.result.State
|
||
switch final.result.Kind {
|
||
case paletteResultClose:
|
||
return nil
|
||
case paletteResultOpenActivityMonitor:
|
||
err := runtime.runActivityMonitor()
|
||
if errors.Is(err, errClosePalette) {
|
||
return nil
|
||
}
|
||
state.Mode = paletteModeList
|
||
state.Message = paletteMessageForError(err)
|
||
continue
|
||
case paletteResultOpenSnippets:
|
||
state.Mode = paletteModeSnippets
|
||
state.Filter = nil
|
||
state.FilterCursor = 0
|
||
state.Selected = 0
|
||
state.Message = ""
|
||
continue
|
||
case paletteResultRunAction:
|
||
reopen, message, err := runtime.execute(final.result)
|
||
if err != nil {
|
||
if reopen {
|
||
state.Mode = paletteModeList
|
||
state.Message = err.Error()
|
||
continue
|
||
}
|
||
return err
|
||
}
|
||
if !reopen {
|
||
return nil
|
||
}
|
||
state.Mode = paletteModeList
|
||
state.Message = message
|
||
continue
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
|
||
fs := flag.NewFlagSet("agent palette", flag.ContinueOnError)
|
||
var windowID string
|
||
var agentID string
|
||
var currentPath string
|
||
var currentSessionName string
|
||
var currentWindowName string
|
||
fs.StringVar(&windowID, "window", "", "window id")
|
||
fs.StringVar(&agentID, "agent-id", "", "agent id")
|
||
fs.StringVar(¤tPath, "path", "", "current pane path")
|
||
fs.StringVar(¤tSessionName, "session-name", "", "current session name")
|
||
fs.StringVar(¤tWindowName, "window-name", "", "current window name")
|
||
fs.SetOutput(nil)
|
||
if err := fs.Parse(args); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
runtime := &paletteRuntime{
|
||
windowID: firstNonEmpty(windowID, os.Getenv("AGENT_PALETTE_WINDOW_ID")),
|
||
agentID: firstNonEmpty(agentID, os.Getenv("AGENT_PALETTE_AGENT_ID")),
|
||
currentPath: firstNonEmpty(currentPath, os.Getenv("AGENT_PALETTE_PATH")),
|
||
currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")),
|
||
currentWindowName: firstNonEmpty(currentWindowName, os.Getenv("AGENT_PALETTE_WINDOW_NAME")),
|
||
}
|
||
logPaletteLaunchIfMalformed(runtime)
|
||
if looksLikeTmuxFormatLiteral(runtime.agentID) {
|
||
runtime.agentID = ""
|
||
}
|
||
if runtime.agentID == "" && runtime.windowID != "" {
|
||
if ctx, err := detectCurrentAgentFromTmux(runtime.windowID); err == nil {
|
||
runtime.agentID = ctx.ID
|
||
}
|
||
} else if runtime.agentID == "" {
|
||
if ctx, err := detectCurrentAgentFromTmux(""); err == nil {
|
||
runtime.agentID = ctx.ID
|
||
}
|
||
}
|
||
if err := runtime.reload(); err != nil {
|
||
return nil, err
|
||
}
|
||
return runtime, nil
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
trimmed := strings.TrimSpace(value)
|
||
if trimmed != "" {
|
||
return trimmed
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func logPaletteLaunchIfMalformed(runtime *paletteRuntime) {
|
||
if runtime == nil {
|
||
return
|
||
}
|
||
values := []string{
|
||
runtime.windowID,
|
||
runtime.agentID,
|
||
runtime.currentPath,
|
||
runtime.currentSessionName,
|
||
runtime.currentWindowName,
|
||
}
|
||
for _, value := range values {
|
||
if strings.Contains(value, "#{") {
|
||
file, err := os.OpenFile("/tmp/agent-palette-launch.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer file.Close()
|
||
_, _ = fmt.Fprintf(file, "%s window=%q agent=%q path=%q session=%q window_name=%q args=%q\n",
|
||
time.Now().Format(time.RFC3339Nano),
|
||
runtime.windowID,
|
||
runtime.agentID,
|
||
runtime.currentPath,
|
||
runtime.currentSessionName,
|
||
runtime.currentWindowName,
|
||
os.Args,
|
||
)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *paletteRuntime) reload() error {
|
||
reg, err := loadRegistry()
|
||
if err != nil {
|
||
r.startupMessage = fmt.Sprintf("Ignoring malformed registry: %v", err)
|
||
reg = ®istry{Agents: map[string]*agentRecord{}}
|
||
} else {
|
||
r.startupMessage = ""
|
||
}
|
||
r.reg = reg
|
||
r.record = nil
|
||
if looksLikeTmuxFormatLiteral(r.agentID) {
|
||
r.agentID = ""
|
||
}
|
||
if r.agentID != "" {
|
||
r.record = reg.Agents[r.agentID]
|
||
}
|
||
tmuxValue := func(target string, format string) string {
|
||
args := []string{"display-message", "-p"}
|
||
if strings.TrimSpace(target) != "" {
|
||
args = append(args, "-t", strings.TrimSpace(target))
|
||
}
|
||
args = append(args, format)
|
||
out, err := runTmuxOutput(args...)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(out)
|
||
}
|
||
if strings.TrimSpace(r.windowID) == "" {
|
||
r.windowID = tmuxValue("", "#{window_id}")
|
||
}
|
||
if strings.TrimSpace(r.currentPath) == "" {
|
||
r.currentPath = tmuxValue(r.windowID, "#{pane_current_path}")
|
||
}
|
||
if strings.TrimSpace(r.currentSessionName) == "" {
|
||
r.currentSessionName = tmuxValue(r.windowID, "#{session_name}")
|
||
}
|
||
if strings.TrimSpace(r.currentWindowName) == "" {
|
||
r.currentWindowName = tmuxValue(r.windowID, "#{window_name}")
|
||
}
|
||
if inferredAgentID := detectPaletteAgentIDFromPath(r.currentPath); inferredAgentID != "" {
|
||
if strings.TrimSpace(r.agentID) == "" || r.record == nil {
|
||
r.agentID = inferredAgentID
|
||
}
|
||
if r.record == nil {
|
||
r.record = reg.Agents[inferredAgentID]
|
||
}
|
||
}
|
||
if r.record != nil {
|
||
r.agentID = r.record.ID
|
||
}
|
||
r.mainRepoRoot = detectPaletteMainRepoRoot(r.currentPath, r.record)
|
||
return nil
|
||
}
|
||
|
||
func (r *paletteRuntime) effectiveAgentID() string {
|
||
if r.record != nil && strings.TrimSpace(r.record.ID) != "" {
|
||
return strings.TrimSpace(r.record.ID)
|
||
}
|
||
if inferred := detectPaletteAgentIDFromPath(r.currentPath); inferred != "" {
|
||
return inferred
|
||
}
|
||
if looksLikeTmuxFormatLiteral(r.agentID) {
|
||
return ""
|
||
}
|
||
return sanitizeFeatureName(r.agentID)
|
||
}
|
||
|
||
func (r *paletteRuntime) persistRecord(update func(*agentRecord) error) error {
|
||
if r.record == nil {
|
||
return fmt.Errorf("no agent found for this tmux window")
|
||
}
|
||
if err := update(r.record); err != nil {
|
||
return err
|
||
}
|
||
r.record.UpdatedAt = time.Now()
|
||
r.reg.Agents[r.record.ID] = r.record
|
||
if err := saveRegistry(r.reg); err != nil {
|
||
return err
|
||
}
|
||
return r.reload()
|
||
}
|
||
|
||
func (r *paletteRuntime) buildActions() []paletteAction {
|
||
actions := []paletteAction{
|
||
{
|
||
Section: "Agent",
|
||
Title: "Start agent",
|
||
Subtitle: startAgentSubtitle(r.mainRepoRoot, r.currentPath),
|
||
Keywords: []string{"agent", "start", "new", "feature", "repo"},
|
||
Kind: paletteActionPromptStartAgent,
|
||
RepoRoot: r.mainRepoRoot,
|
||
},
|
||
}
|
||
if strings.TrimSpace(r.agentID) != "" {
|
||
actions = append(actions, paletteAction{
|
||
Section: "Agent",
|
||
Title: "Destroy agent",
|
||
Subtitle: "Delete the workspace and close its tmux window",
|
||
Keywords: []string{"agent", "destroy", "remove", "delete"},
|
||
Kind: paletteActionConfirmDestroy,
|
||
})
|
||
}
|
||
actions = append(actions,
|
||
paletteAction{
|
||
Section: "System",
|
||
Title: "Tracker",
|
||
Subtitle: "Live tasks and completion status",
|
||
Keywords: []string{"tracker", "tasks", "activity", "status"},
|
||
Kind: paletteActionOpenTracker,
|
||
},
|
||
paletteAction{
|
||
Section: "System",
|
||
Title: "Activity Monitor",
|
||
Subtitle: "View CPU, memory and process usage",
|
||
Keywords: []string{"activity", "monitor", "cpu", "memory", "processes", "top", "ps"},
|
||
Kind: paletteActionOpenActivityMonitor,
|
||
},
|
||
paletteAction{
|
||
Section: "System",
|
||
Title: "Paste snippet",
|
||
Subtitle: "Search and paste a snippet into the current pane",
|
||
Keywords: []string{"snippet", "paste", "template", "text", "insert"},
|
||
Kind: paletteActionOpenSnippets,
|
||
},
|
||
paletteAction{
|
||
Section: "System",
|
||
Title: "Todos",
|
||
Subtitle: "Manage window/global todos",
|
||
Keywords: []string{"todo", "task", "checklist", "manage"},
|
||
Kind: paletteActionOpenTodos,
|
||
},
|
||
paletteAction{
|
||
Section: "System",
|
||
Title: "Edit devices",
|
||
Subtitle: "Add or remove global launch devices",
|
||
Keywords: []string{"devices", "device", "edit", "manage", "web-server"},
|
||
Kind: paletteActionOpenDevices,
|
||
},
|
||
paletteAction{
|
||
Section: "System",
|
||
Title: "Reload tmux config",
|
||
Subtitle: "Source ~/.config/.tmux.conf",
|
||
Keywords: []string{"tmux", "reload", "config", "source", "refresh"},
|
||
Kind: paletteActionReloadTmuxConfig,
|
||
},
|
||
paletteAction{
|
||
Section: "System",
|
||
Title: "Bottom-right status",
|
||
Subtitle: "Open control center for tmux right-side status modules",
|
||
Keywords: []string{"tmux", "status", "status-right", "bottom-right", "control", "center", "istat", "cpu", "network", "memory", "notes", "host", "flash"},
|
||
Kind: paletteActionOpenStatusRight,
|
||
},
|
||
)
|
||
if strings.TrimSpace(r.agentID) == "" {
|
||
return actions
|
||
}
|
||
if r.record == nil {
|
||
return actions
|
||
}
|
||
return actions
|
||
}
|
||
|
||
func (r *paletteRuntime) runAgentStart(repoRoot, feature, device string, keepWorktree bool) error {
|
||
repoRoot = r.resolveStartRepoRoot(repoRoot)
|
||
feature = sanitizeFeatureName(feature)
|
||
if !isPaletteNoDeviceOption(device) {
|
||
device = normalizeManagedDeviceID(device)
|
||
}
|
||
if repoRoot == "" {
|
||
return fmt.Errorf("main repo not found")
|
||
}
|
||
if feature == "" {
|
||
return fmt.Errorf("feature name is required")
|
||
}
|
||
agentBin := filepath.Join(os.Getenv("HOME"), ".config", "bin", "agent")
|
||
args := buildAgentStartArgs(feature, device, keepWorktree)
|
||
cmd := exec.Command(agentBin, args...)
|
||
cmd.Dir = repoRoot
|
||
cmd.Stdin = os.Stdin
|
||
cmd.Env = os.Environ()
|
||
if strings.TrimSpace(r.windowID) != "" {
|
||
cmd.Env = append(cmd.Env, "AGENT_TMUX_TARGET_WINDOW="+strings.TrimSpace(r.windowID))
|
||
}
|
||
output, err := cmd.CombinedOutput()
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
message := strings.TrimSpace(string(output))
|
||
if message != "" {
|
||
return fmt.Errorf("%s", message)
|
||
}
|
||
return err
|
||
}
|
||
|
||
func launchPaletteDestroy(agentID string) error {
|
||
return launchPaletteDestroyWithConfirm(agentID, "")
|
||
}
|
||
|
||
func launchPaletteDestroyWithConfirm(agentID string, confirmText string) error {
|
||
agentID = strings.TrimSpace(agentID)
|
||
if agentID == "" {
|
||
return fmt.Errorf("no agent found for this tmux window")
|
||
}
|
||
if _, err := loadDestroyTarget(agentID); err != nil {
|
||
return err
|
||
}
|
||
extraArgs := ""
|
||
if strings.TrimSpace(confirmText) != "" {
|
||
extraArgs = fmt.Sprintf(" --confirm %s", shellQuote(strings.TrimSpace(confirmText)))
|
||
}
|
||
if os.Getenv("TMUX") != "" {
|
||
exe, err := os.Executable()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return runTmux("run-shell", "-b", fmt.Sprintf("%s destroy --id %s%s", shellQuote(exe), shellQuote(agentID), extraArgs))
|
||
}
|
||
args := []string{"destroy", "--id", agentID}
|
||
if strings.TrimSpace(confirmText) != "" {
|
||
args = append(args, "--confirm", strings.TrimSpace(confirmText))
|
||
}
|
||
return spawnDetachedAgentCommand(args...)
|
||
}
|
||
|
||
func buildAgentStartArgs(feature, device string, keepWorktree bool) []string {
|
||
args := []string{"start"}
|
||
if keepWorktree {
|
||
args = append(args, "--keep-worktree")
|
||
}
|
||
if isPaletteNoDeviceOption(device) {
|
||
args = append(args, "--no-device")
|
||
} else if device != "" {
|
||
args = append(args, "-d", device)
|
||
}
|
||
return append(args, feature)
|
||
}
|
||
|
||
func isPaletteNoDeviceOption(device string) bool {
|
||
return strings.TrimSpace(device) == paletteNoDeviceOption
|
||
}
|
||
|
||
func startAgentPromptDevices(repoRoot string) ([]string, int) {
|
||
repoRoot = strings.TrimSpace(repoRoot)
|
||
devices := loadManagedDevices()
|
||
if len(devices) == 0 {
|
||
devices = []string{defaultManagedDeviceID}
|
||
}
|
||
if repoRoot != "" && fileExists(filepath.Join(repoRoot, "pubspec.yaml")) {
|
||
return append([]string{paletteNoDeviceOption}, devices...), 1
|
||
}
|
||
return devices, 0
|
||
}
|
||
|
||
func (r *paletteRuntime) resolveStartRepoRoot(repoRoot string) string {
|
||
tryPaths := []string{
|
||
strings.TrimSpace(repoRoot),
|
||
strings.TrimSpace(r.mainRepoRoot),
|
||
strings.TrimSpace(r.currentPath),
|
||
}
|
||
if strings.TrimSpace(r.windowID) != "" {
|
||
if out, err := runTmuxOutput("display-message", "-p", "-t", strings.TrimSpace(r.windowID), "#{pane_current_path}"); err == nil {
|
||
tryPaths = append(tryPaths, strings.TrimSpace(out))
|
||
}
|
||
}
|
||
if cwd, err := os.Getwd(); err == nil {
|
||
tryPaths = append(tryPaths, strings.TrimSpace(cwd))
|
||
}
|
||
for _, path := range tryPaths {
|
||
if resolved := detectPaletteMainRepoRoot(path, r.record); strings.TrimSpace(resolved) != "" {
|
||
return strings.TrimSpace(resolved)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (r *paletteRuntime) startSourceBranch(repoRoot string) string {
|
||
repoRoot = r.resolveStartRepoRoot(repoRoot)
|
||
if repoRoot == "" {
|
||
return ""
|
||
}
|
||
repoCfg, err := loadRepoConfigOrDefault(repoRoot)
|
||
if err != nil {
|
||
return detectDefaultBaseBranch(repoRoot)
|
||
}
|
||
return resolveStartSourceBranch(repoRoot, repoCfg)
|
||
}
|
||
|
||
func (r *paletteRuntime) canStartAgent(repoRoot string) bool {
|
||
return strings.TrimSpace(r.resolveStartRepoRoot(repoRoot)) != ""
|
||
}
|
||
|
||
func (r *paletteRuntime) runActivityMonitor() error {
|
||
return runBubbleTeaActivityMonitor(r.windowID)
|
||
}
|
||
|
||
func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
|
||
action := result.Action
|
||
text := strings.TrimSpace(result.Input)
|
||
switch action.Kind {
|
||
case paletteActionPromptStartAgent:
|
||
if err := r.runAgentStart(action.RepoRoot, text, result.Device, result.KeepWorktree); err != nil {
|
||
return true, "", err
|
||
}
|
||
return false, "", nil
|
||
case paletteActionConfirmDestroy:
|
||
agentID := r.effectiveAgentID()
|
||
if agentID == "" {
|
||
return true, "", fmt.Errorf("no agent found for this tmux window")
|
||
}
|
||
confirmText := ""
|
||
if result.State.ConfirmRequiresText {
|
||
confirmText = strings.TrimSpace(result.Input)
|
||
}
|
||
err := paletteDestroyLauncher(agentID, confirmText)
|
||
if err != nil {
|
||
return true, "", err
|
||
}
|
||
return false, "", nil
|
||
case paletteActionReloadTmuxConfig:
|
||
return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf")
|
||
default:
|
||
return false, "", nil
|
||
}
|
||
}
|
||
|
||
func statusRightModuleLabel(module string) string {
|
||
switch module {
|
||
case statusRightModuleCPU:
|
||
return "CPU"
|
||
case statusRightModuleNetwork:
|
||
return "Network"
|
||
case statusRightModuleMemory:
|
||
return "Memory"
|
||
case statusRightModuleMemoryTotals:
|
||
return "Tmux Memory"
|
||
case statusRightModuleAgent:
|
||
return "Agent"
|
||
case statusRightModuleNotes:
|
||
return "Notes"
|
||
case statusRightModuleFlashMoe:
|
||
return "Flash-MoE"
|
||
case statusRightModuleHost:
|
||
return "Host"
|
||
default:
|
||
return module
|
||
}
|
||
}
|
||
|
||
func statusRightModuleDescription(module string) string {
|
||
switch module {
|
||
case statusRightModuleCPU:
|
||
return "CPU usage"
|
||
case statusRightModuleNetwork:
|
||
return "network throughput"
|
||
case statusRightModuleMemory:
|
||
return "pane memory stats"
|
||
case statusRightModuleMemoryTotals:
|
||
return "window, session, and total tmux memory"
|
||
case statusRightModuleAgent:
|
||
return "active agent device"
|
||
case statusRightModuleNotes:
|
||
return "todo count"
|
||
case statusRightModuleFlashMoe:
|
||
return "Flash-MoE status"
|
||
case statusRightModuleHost:
|
||
return "hostname"
|
||
default:
|
||
return module
|
||
}
|
||
}
|
||
|
||
func togglePaletteStatusRightModule(module string) error {
|
||
if err := toggleStatusRightModule(module); err != nil {
|
||
return err
|
||
}
|
||
return paletteTmuxRunner("refresh-client", "-S")
|
||
}
|
||
|
||
func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteModel {
|
||
if state.Mode == 0 {
|
||
state.Mode = paletteModeList
|
||
}
|
||
state.FilterCursor = clampInt(state.FilterCursor, 0, len(state.Filter))
|
||
state.PromptCursor = clampInt(state.PromptCursor, 0, len(state.PromptText))
|
||
if len(state.PromptDevices) > 0 {
|
||
state.PromptDeviceIndex = clampInt(state.PromptDeviceIndex, 0, len(state.PromptDevices)-1)
|
||
}
|
||
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now()}
|
||
if state.Mode == paletteModeTodos {
|
||
_ = model.openTodosPanel()
|
||
}
|
||
if state.Mode == paletteModeActivity {
|
||
_, _ = model.openActivityPanel()
|
||
}
|
||
if state.Mode == paletteModeDevices {
|
||
model.openDevicesPanel()
|
||
}
|
||
if state.Mode == paletteModeStatusRight {
|
||
model.openStatusRightPanel()
|
||
}
|
||
if state.Mode == paletteModeTracker {
|
||
_, _ = model.openTrackerPanel()
|
||
}
|
||
return model
|
||
}
|
||
|
||
func (m *paletteModel) Init() tea.Cmd {
|
||
return nil
|
||
}
|
||
|
||
func (m *paletteModel) openTodosPanel() error {
|
||
sessionID, windowID := getCurrentTmuxScopeInfo()
|
||
if m.todo == nil {
|
||
panel, err := newTodoPanelModel(sessionID, windowID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
m.todo = panel
|
||
} else {
|
||
m.todo.sessionID = strings.TrimSpace(sessionID)
|
||
m.todo.windowID = strings.TrimSpace(windowID)
|
||
m.todo.reloadEntries()
|
||
m.todo.clampSelections()
|
||
m.todo.focusedScope = todoScopeWindow
|
||
m.todo.mode = todoPanelModeList
|
||
}
|
||
m.todo.showAltHints = false
|
||
m.state.Mode = paletteModeTodos
|
||
m.state.Message = ""
|
||
m.state.ShowAltHints = false
|
||
return nil
|
||
}
|
||
|
||
func (m *paletteModel) openSnippetsPanel() {
|
||
m.state.Mode = paletteModeSnippets
|
||
m.state.Filter = nil
|
||
m.state.FilterCursor = 0
|
||
m.state.Selected = 0
|
||
m.state.SnippetOffset = 0
|
||
m.state.Message = ""
|
||
m.state.ShowAltHints = false
|
||
}
|
||
|
||
func (m *paletteModel) openActivityPanel() (tea.Cmd, error) {
|
||
if m.activity == nil {
|
||
m.activity = newActivityMonitorModel(m.runtime.windowID, true)
|
||
} else {
|
||
m.activity.windowID = strings.TrimSpace(m.runtime.windowID)
|
||
m.activity.requestBack = false
|
||
m.activity.requestClose = false
|
||
}
|
||
m.activity.width = m.width
|
||
m.activity.height = m.height
|
||
m.activity.showAltHints = false
|
||
m.state.Mode = paletteModeActivity
|
||
m.state.Message = ""
|
||
m.state.ShowAltHints = false
|
||
if !m.activity.refreshInFlight {
|
||
return tea.Batch(
|
||
activityRequestRefreshBT(true, m.activity.refreshedAt.IsZero(), m.activity),
|
||
activityTickCmd(),
|
||
), nil
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func (m *paletteModel) openDevicesPanel() {
|
||
if m.devices == nil {
|
||
m.devices = newDevicePanelModel()
|
||
} else {
|
||
m.devices.reload()
|
||
m.devices.mode = devicePanelModeList
|
||
m.devices.requestBack = false
|
||
}
|
||
m.devices.showAltHints = false
|
||
m.state.Mode = paletteModeDevices
|
||
m.state.Message = ""
|
||
m.state.ShowAltHints = false
|
||
}
|
||
|
||
func (m *paletteModel) openStatusRightPanel() {
|
||
if m.status == nil {
|
||
m.status = newStatusRightPanelModel()
|
||
} else {
|
||
m.status.reload()
|
||
m.status.requestBack = false
|
||
}
|
||
m.status.showAltHints = false
|
||
m.state.Mode = paletteModeStatusRight
|
||
m.state.Message = ""
|
||
m.state.ShowAltHints = false
|
||
}
|
||
|
||
func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) {
|
||
if m.tracker == nil {
|
||
m.tracker = newTrackerPanelModel(m.runtime)
|
||
} else {
|
||
m.tracker.runtime = m.runtime
|
||
m.tracker.requestBack = false
|
||
m.tracker.requestClose = false
|
||
}
|
||
m.tracker.width = m.width
|
||
m.tracker.height = m.height
|
||
m.tracker.showAltHints = false
|
||
m.state.Mode = paletteModeTracker
|
||
m.state.Message = ""
|
||
m.state.ShowAltHints = false
|
||
return m.tracker.activate(), nil
|
||
}
|
||
|
||
func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
if m.todo != nil {
|
||
m.todo.width = msg.Width
|
||
m.todo.height = msg.Height
|
||
}
|
||
if m.activity != nil {
|
||
m.activity.width = msg.Width
|
||
m.activity.height = msg.Height
|
||
}
|
||
if m.tracker != nil {
|
||
m.tracker.width = msg.Width
|
||
m.tracker.height = msg.Height
|
||
}
|
||
if m.status != nil {
|
||
m.status.width = msg.Width
|
||
m.status.height = msg.Height
|
||
}
|
||
case tea.KeyMsg:
|
||
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker {
|
||
if isAltFooterToggleKey(msg) {
|
||
m.state.ShowAltHints = !m.state.ShowAltHints
|
||
return m, nil
|
||
}
|
||
m.state.ShowAltHints = false
|
||
}
|
||
key := msg.String()
|
||
if key == "alt+s" {
|
||
if time.Since(m.openedAt) < 250*time.Millisecond {
|
||
return m, nil
|
||
}
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if m.state.Mode == paletteModeActivity {
|
||
if m.activity == nil {
|
||
cmd, err := m.openActivityPanel()
|
||
if err != nil {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
model, cmd := m.activity.Update(msg)
|
||
if updated, ok := model.(*activityMonitorBT); ok {
|
||
m.activity = updated
|
||
}
|
||
if m.activity.requestClose {
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if m.activity.requestBack {
|
||
m.activity.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.activity.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeTodos {
|
||
if key == "esc" && m.todo != nil && m.todo.mode == todoPanelModeList {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.todo.currentStatus()
|
||
return m, nil
|
||
}
|
||
if m.todo == nil {
|
||
if err := m.openTodosPanel(); err != nil {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
}
|
||
model, cmd := m.todo.Update(msg)
|
||
if updated, ok := model.(*todoPanelModel); ok {
|
||
m.todo = updated
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeDevices {
|
||
if m.devices == nil {
|
||
m.openDevicesPanel()
|
||
}
|
||
model, cmd := m.devices.Update(msg)
|
||
if updated, ok := model.(*devicePanelModel); ok {
|
||
m.devices = updated
|
||
}
|
||
if m.devices.requestBack {
|
||
m.devices.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.devices.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeStatusRight {
|
||
if m.status == nil {
|
||
m.openStatusRightPanel()
|
||
}
|
||
model, cmd := m.status.Update(msg)
|
||
if updated, ok := model.(*statusRightPanelModel); ok {
|
||
m.status = updated
|
||
}
|
||
if m.status.requestBack {
|
||
m.status.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.status.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeTracker {
|
||
if m.tracker == nil {
|
||
cmd, err := m.openTrackerPanel()
|
||
if err != nil {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
model, cmd := m.tracker.Update(msg)
|
||
if updated, ok := model.(*trackerPanelModel); ok {
|
||
m.tracker = updated
|
||
}
|
||
if m.tracker.requestClose {
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if m.tracker.requestBack {
|
||
m.tracker.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.tracker.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
switch m.state.Mode {
|
||
case paletteModePrompt:
|
||
return m.updatePrompt(key)
|
||
case paletteModeConfirmDestroy:
|
||
return m.updateConfirm(key)
|
||
case paletteModeSnippets:
|
||
return m.updateSnippets(key)
|
||
case paletteModeSnippetVars:
|
||
return m.updateSnippetVars(key)
|
||
default:
|
||
return m.updateList(key)
|
||
}
|
||
}
|
||
if m.state.Mode == paletteModeActivity && m.activity != nil {
|
||
model, cmd := m.activity.Update(msg)
|
||
if updated, ok := model.(*activityMonitorBT); ok {
|
||
m.activity = updated
|
||
}
|
||
if m.activity.requestClose {
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if m.activity.requestBack {
|
||
m.activity.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.activity.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeTodos && m.todo != nil {
|
||
model, cmd := m.todo.Update(msg)
|
||
if updated, ok := model.(*todoPanelModel); ok {
|
||
m.todo = updated
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeDevices && m.devices != nil {
|
||
model, cmd := m.devices.Update(msg)
|
||
if updated, ok := model.(*devicePanelModel); ok {
|
||
m.devices = updated
|
||
}
|
||
if m.devices.requestBack {
|
||
m.devices.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.devices.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeStatusRight && m.status != nil {
|
||
model, cmd := m.status.Update(msg)
|
||
if updated, ok := model.(*statusRightPanelModel); ok {
|
||
m.status = updated
|
||
}
|
||
if m.status.requestBack {
|
||
m.status.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.status.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if m.state.Mode == paletteModeTracker && m.tracker != nil {
|
||
model, cmd := m.tracker.Update(msg)
|
||
if updated, ok := model.(*trackerPanelModel); ok {
|
||
m.tracker = updated
|
||
}
|
||
if m.tracker.requestClose {
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if m.tracker.requestBack {
|
||
m.tracker.requestBack = false
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = m.tracker.currentStatus()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) {
|
||
if key == "esc" || key == "ctrl+c" || key == "alt+n" {
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if key == "alt+a" {
|
||
cmd, err := m.openActivityPanel()
|
||
if err != nil {
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if key == "alt+p" {
|
||
m.openSnippetsPanel()
|
||
return m, nil
|
||
}
|
||
if key == "alt+r" {
|
||
cmd, err := m.openTrackerPanel()
|
||
if err != nil {
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
}
|
||
if key == "alt+t" {
|
||
if err := m.openTodosPanel(); err != nil {
|
||
m.state.Message = err.Error()
|
||
}
|
||
return m, nil
|
||
}
|
||
if key == "alt+c" {
|
||
m.openPrompt(palettePromptStartAgent, "", m.runtime.mainRepoRoot)
|
||
return m, nil
|
||
}
|
||
actions := m.filteredActions()
|
||
navigate := func(delta int) {
|
||
if len(actions) == 0 {
|
||
m.state.Selected = 0
|
||
return
|
||
}
|
||
m.state.Selected = clampInt(m.state.Selected+delta, 0, len(actions)-1)
|
||
}
|
||
switch key {
|
||
case "ctrl+u", "alt+u", "up":
|
||
navigate(-1)
|
||
return m, nil
|
||
case "ctrl+e", "alt+e", "down":
|
||
navigate(1)
|
||
return m, nil
|
||
case "ctrl+n", "left":
|
||
m.state.FilterCursor = clampInt(m.state.FilterCursor-1, 0, len(m.state.Filter))
|
||
return m, nil
|
||
case "ctrl+i", "tab", "right":
|
||
m.state.FilterCursor = clampInt(m.state.FilterCursor+1, 0, len(m.state.Filter))
|
||
return m, nil
|
||
case "enter", "alt+i":
|
||
if len(actions) == 0 || m.state.Selected < 0 || m.state.Selected >= len(actions) {
|
||
return m, nil
|
||
}
|
||
return m.selectAction(actions[m.state.Selected])
|
||
}
|
||
if applyPaletteInputKey(key, &m.state.Filter, &m.state.FilterCursor, false) {
|
||
m.state.Selected = 0
|
||
m.state.ActionOffset = 0
|
||
m.state.Message = ""
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) {
|
||
switch action.Kind {
|
||
case paletteActionPromptStartAgent:
|
||
m.openPrompt(palettePromptStartAgent, "", action.RepoRoot)
|
||
return m, nil
|
||
case paletteActionConfirmDestroy:
|
||
target, err := loadDestroyTarget(m.runtime.effectiveAgentID())
|
||
if err != nil {
|
||
m.state.Message = err.Error()
|
||
m.state.Mode = paletteModeList
|
||
return m, nil
|
||
}
|
||
m.state.Mode = paletteModeConfirmDestroy
|
||
m.state.Message = ""
|
||
m.state.ShowAltHints = false
|
||
m.state.ConfirmRequiresText = target.RequiresExplicitConfirm
|
||
m.state.PromptText = nil
|
||
m.state.PromptCursor = 0
|
||
return m, nil
|
||
case paletteActionOpenActivityMonitor:
|
||
cmd, err := m.openActivityPanel()
|
||
if err != nil {
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
case paletteActionOpenSnippets:
|
||
m.openSnippetsPanel()
|
||
return m, nil
|
||
case paletteActionOpenTracker:
|
||
cmd, err := m.openTrackerPanel()
|
||
if err != nil {
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
return m, cmd
|
||
case paletteActionOpenTodos:
|
||
if err := m.openTodosPanel(); err != nil {
|
||
m.state.Message = err.Error()
|
||
}
|
||
return m, nil
|
||
case paletteActionOpenDevices:
|
||
m.openDevicesPanel()
|
||
return m, nil
|
||
case paletteActionOpenStatusRight:
|
||
m.openStatusRightPanel()
|
||
return m, nil
|
||
default:
|
||
m.state.Mode = paletteModeList
|
||
m.result = paletteResult{Kind: paletteResultRunAction, Action: action, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
}
|
||
|
||
func (m *paletteModel) openPrompt(kind palettePromptKind, initial string, repoRoot string) {
|
||
devices := []string(nil)
|
||
deviceIndex := 0
|
||
if kind == palettePromptStartAgent {
|
||
resolvedRepoRoot := strings.TrimSpace(m.runtime.resolveStartRepoRoot(repoRoot))
|
||
devices, deviceIndex = startAgentPromptDevices(resolvedRepoRoot)
|
||
}
|
||
m.state.Mode = paletteModePrompt
|
||
m.state.PromptKind = kind
|
||
m.state.PromptField = palettePromptFieldName
|
||
m.state.PromptText = []rune(initial)
|
||
m.state.PromptCursor = len(m.state.PromptText)
|
||
m.state.PromptRepoRoot = strings.TrimSpace(repoRoot)
|
||
m.state.PromptDevices = devices
|
||
m.state.PromptDeviceIndex = deviceIndex
|
||
m.state.PromptKeepWorktree = false
|
||
m.state.ShowAltHints = false
|
||
m.state.Message = ""
|
||
}
|
||
|
||
func (m *paletteModel) updatePrompt(key string) (tea.Model, tea.Cmd) {
|
||
if key == "esc" {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = ""
|
||
return m, nil
|
||
}
|
||
if m.state.PromptKind == palettePromptStartAgent {
|
||
if !m.runtime.canStartAgent(m.state.PromptRepoRoot) {
|
||
return m, nil
|
||
}
|
||
if key == "alt+d" || key == "alt+D" || key == "alt+shift+d" {
|
||
deviceCount := len(m.state.PromptDevices)
|
||
if deviceCount == 0 {
|
||
resolvedRepoRoot := strings.TrimSpace(m.runtime.resolveStartRepoRoot(m.state.PromptRepoRoot))
|
||
m.state.PromptDevices, m.state.PromptDeviceIndex = startAgentPromptDevices(resolvedRepoRoot)
|
||
deviceCount = len(m.state.PromptDevices)
|
||
}
|
||
if deviceCount > 0 {
|
||
selected := clampInt(m.state.PromptDeviceIndex, 0, deviceCount-1)
|
||
if key == "alt+d" {
|
||
m.state.PromptDeviceIndex = (selected + 1) % deviceCount
|
||
} else {
|
||
m.state.PromptDeviceIndex = (selected - 1 + deviceCount) % deviceCount
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
switch key {
|
||
case "tab", "ctrl+i":
|
||
switch m.state.PromptField {
|
||
case palettePromptFieldName:
|
||
m.state.PromptField = palettePromptFieldDevice
|
||
case palettePromptFieldDevice:
|
||
m.state.PromptField = palettePromptFieldWorktree
|
||
default:
|
||
m.state.PromptField = palettePromptFieldName
|
||
}
|
||
return m, nil
|
||
case "shift+tab":
|
||
switch m.state.PromptField {
|
||
case palettePromptFieldWorktree:
|
||
m.state.PromptField = palettePromptFieldDevice
|
||
case palettePromptFieldDevice:
|
||
m.state.PromptField = palettePromptFieldName
|
||
default:
|
||
m.state.PromptField = palettePromptFieldWorktree
|
||
}
|
||
return m, nil
|
||
}
|
||
if m.state.PromptField == palettePromptFieldDevice {
|
||
deviceCount := len(m.state.PromptDevices)
|
||
if deviceCount == 0 {
|
||
m.state.PromptDevices = []string{defaultManagedDeviceID}
|
||
deviceCount = 1
|
||
}
|
||
switch key {
|
||
case "ctrl+n", "left", "n":
|
||
m.state.PromptDeviceIndex = clampInt(m.state.PromptDeviceIndex-1, 0, deviceCount-1)
|
||
return m, nil
|
||
case "right", "i":
|
||
m.state.PromptDeviceIndex = clampInt(m.state.PromptDeviceIndex+1, 0, deviceCount-1)
|
||
return m, nil
|
||
}
|
||
}
|
||
if m.state.PromptField == palettePromptFieldWorktree {
|
||
switch key {
|
||
case "ctrl+n", "left", "n":
|
||
m.state.PromptKeepWorktree = false
|
||
return m, nil
|
||
case "right", "i":
|
||
m.state.PromptKeepWorktree = true
|
||
return m, nil
|
||
case " ", "space":
|
||
m.state.PromptKeepWorktree = !m.state.PromptKeepWorktree
|
||
return m, nil
|
||
}
|
||
}
|
||
}
|
||
if key == "enter" {
|
||
text := strings.TrimSpace(string(m.state.PromptText))
|
||
if m.state.PromptKind == palettePromptStartAgent && text == "" {
|
||
m.state.Message = "Feature name is required"
|
||
m.state.Mode = paletteModeList
|
||
return m, nil
|
||
}
|
||
action := paletteAction{}
|
||
switch m.state.PromptKind {
|
||
case palettePromptStartAgent:
|
||
action = paletteAction{Kind: paletteActionPromptStartAgent, RepoRoot: m.state.PromptRepoRoot}
|
||
}
|
||
device := ""
|
||
if m.state.PromptKind == palettePromptStartAgent && m.state.PromptDeviceIndex >= 0 && m.state.PromptDeviceIndex < len(m.state.PromptDevices) {
|
||
device = m.state.PromptDevices[m.state.PromptDeviceIndex]
|
||
}
|
||
m.state.Mode = paletteModeList
|
||
m.result = paletteResult{Kind: paletteResultRunAction, Action: action, Input: text, Device: device, KeepWorktree: m.state.PromptKeepWorktree, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if m.state.PromptKind == palettePromptStartAgent && m.state.PromptField != palettePromptFieldName {
|
||
return m, nil
|
||
}
|
||
applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true)
|
||
return m, nil
|
||
}
|
||
|
||
func (m *paletteModel) updateConfirm(key string) (tea.Model, tea.Cmd) {
|
||
if key == "esc" {
|
||
m.state.Mode = paletteModeList
|
||
m.state.ConfirmRequiresText = false
|
||
m.state.PromptText = nil
|
||
m.state.PromptCursor = 0
|
||
return m, nil
|
||
}
|
||
if m.state.ConfirmRequiresText {
|
||
if key == "enter" {
|
||
if strings.TrimSpace(string(m.state.PromptText)) != "destroy" {
|
||
m.state.Message = "Type destroy to confirm"
|
||
return m, nil
|
||
}
|
||
m.state.Mode = paletteModeList
|
||
m.result = paletteResult{Kind: paletteResultRunAction, Action: paletteAction{Kind: paletteActionConfirmDestroy}, Input: strings.TrimSpace(string(m.state.PromptText)), State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true)
|
||
return m, nil
|
||
}
|
||
if key == "y" || key == "Y" {
|
||
m.state.Mode = paletteModeList
|
||
m.state.ConfirmRequiresText = false
|
||
m.result = paletteResult{Kind: paletteResultRunAction, Action: paletteAction{Kind: paletteActionConfirmDestroy}, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
m.state.Mode = paletteModeList
|
||
m.state.ConfirmRequiresText = false
|
||
return m, nil
|
||
}
|
||
|
||
func (m *paletteModel) updateSnippets(key string) (tea.Model, tea.Cmd) {
|
||
if key == "esc" || key == "ctrl+c" {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = ""
|
||
return m, nil
|
||
}
|
||
snippets := m.filteredSnippets()
|
||
navigate := func(delta int) {
|
||
if len(snippets) == 0 {
|
||
m.state.Selected = 0
|
||
return
|
||
}
|
||
m.state.Selected = clampInt(m.state.Selected+delta, 0, len(snippets)-1)
|
||
}
|
||
switch key {
|
||
case "ctrl+u", "up":
|
||
navigate(-1)
|
||
return m, nil
|
||
case "ctrl+e", "down":
|
||
navigate(1)
|
||
return m, nil
|
||
case "ctrl+n", "left":
|
||
m.state.FilterCursor = clampInt(m.state.FilterCursor-1, 0, len(m.state.Filter))
|
||
return m, nil
|
||
case "ctrl+i", "tab", "right":
|
||
m.state.FilterCursor = clampInt(m.state.FilterCursor+1, 0, len(m.state.Filter))
|
||
return m, nil
|
||
case "enter":
|
||
if len(snippets) == 0 || m.state.Selected < 0 || m.state.Selected >= len(snippets) {
|
||
return m, nil
|
||
}
|
||
snippet := snippets[m.state.Selected]
|
||
if len(snippet.Vars) > 0 {
|
||
m.state.SnippetName = snippet.Name
|
||
m.state.SnippetContent = snippet.Content
|
||
m.state.SnippetVars = snippet.Vars
|
||
m.state.SnippetVarIndex = 0
|
||
m.state.SnippetVarValues = make(map[string]string)
|
||
m.state.PromptText = nil
|
||
m.state.PromptCursor = 0
|
||
m.state.Mode = paletteModeSnippetVars
|
||
return m, nil
|
||
}
|
||
if err := pasteToTmuxPane(snippet.Content); err != nil {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
if applyPaletteInputKey(key, &m.state.Filter, &m.state.FilterCursor, false) {
|
||
m.state.Selected = 0
|
||
m.state.SnippetOffset = 0
|
||
m.state.Message = ""
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *paletteModel) updateSnippetVars(key string) (tea.Model, tea.Cmd) {
|
||
if key == "esc" {
|
||
m.state.Mode = paletteModeSnippets
|
||
m.state.Message = ""
|
||
return m, nil
|
||
}
|
||
if key == "enter" {
|
||
varName := m.state.SnippetVars[m.state.SnippetVarIndex]
|
||
m.state.SnippetVarValues[varName] = string(m.state.PromptText)
|
||
m.state.SnippetVarIndex++
|
||
if m.state.SnippetVarIndex >= len(m.state.SnippetVars) {
|
||
rendered := renderSnippet(m.state.SnippetContent, m.state.SnippetVarValues)
|
||
if err := pasteToTmuxPane(rendered); err != nil {
|
||
m.state.Mode = paletteModeList
|
||
m.state.Message = err.Error()
|
||
return m, nil
|
||
}
|
||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||
return m, tea.Quit
|
||
}
|
||
m.state.PromptText = nil
|
||
m.state.PromptCursor = 0
|
||
return m, nil
|
||
}
|
||
applyPaletteInputKey(key, &m.state.PromptText, &m.state.PromptCursor, true)
|
||
return m, nil
|
||
}
|
||
|
||
func (m *paletteModel) filteredSnippets() []snippet {
|
||
snippets := loadSnippets()
|
||
query := strings.ToLower(strings.TrimSpace(string(m.state.Filter)))
|
||
if query == "" {
|
||
return snippets
|
||
}
|
||
parts := strings.Fields(query)
|
||
filtered := make([]snippet, 0, len(snippets))
|
||
for _, s := range snippets {
|
||
haystack := strings.ToLower(s.Name + " " + s.Description + " " + s.Content)
|
||
matched := true
|
||
for _, part := range parts {
|
||
if !strings.Contains(haystack, part) {
|
||
matched = false
|
||
break
|
||
}
|
||
}
|
||
if matched {
|
||
filtered = append(filtered, s)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func (m *paletteModel) View() string {
|
||
width := m.width
|
||
height := m.height
|
||
if width <= 0 {
|
||
width = 96
|
||
}
|
||
if height <= 0 {
|
||
height = 28
|
||
}
|
||
if width < 48 || height < 14 {
|
||
return "Window too small for command palette"
|
||
}
|
||
styles := newPaletteStyles()
|
||
if m.state.Mode == paletteModePrompt {
|
||
return m.renderPrompt(styles, width, height)
|
||
}
|
||
if m.state.Mode == paletteModeConfirmDestroy {
|
||
return m.renderConfirm(styles, width, height)
|
||
}
|
||
if m.state.Mode == paletteModeActivity {
|
||
if m.activity != nil {
|
||
m.activity.width = width
|
||
m.activity.height = height
|
||
return m.activity.View()
|
||
}
|
||
return styles.muted.Render("Activity monitor unavailable")
|
||
}
|
||
if m.state.Mode == paletteModeSnippets {
|
||
return m.renderSnippets(styles, width, height)
|
||
}
|
||
if m.state.Mode == paletteModeSnippetVars {
|
||
return m.renderSnippetVars(styles, width, height)
|
||
}
|
||
if m.state.Mode == paletteModeTodos {
|
||
if m.todo != nil {
|
||
m.todo.width = width
|
||
m.todo.height = height
|
||
return m.todo.View()
|
||
}
|
||
return styles.muted.Render("Todo panel unavailable")
|
||
}
|
||
if m.state.Mode == paletteModeDevices {
|
||
if m.devices != nil {
|
||
m.devices.width = width
|
||
m.devices.height = height
|
||
return m.devices.render(styles, width, height)
|
||
}
|
||
return styles.muted.Render("Device panel unavailable")
|
||
}
|
||
if m.state.Mode == paletteModeStatusRight {
|
||
if m.status != nil {
|
||
m.status.width = width
|
||
m.status.height = height
|
||
return m.status.render(styles, width, height)
|
||
}
|
||
return styles.muted.Render("Status panel unavailable")
|
||
}
|
||
if m.state.Mode == paletteModeTracker {
|
||
if m.tracker != nil {
|
||
m.tracker.width = width
|
||
m.tracker.height = height
|
||
return m.tracker.render(styles, width, height)
|
||
}
|
||
return styles.muted.Render("Tracker unavailable")
|
||
}
|
||
return m.renderListView(styles, width, height)
|
||
}
|
||
|
||
func (m *paletteModel) renderListView(styles paletteStyles, width, height int) string {
|
||
actions := m.filteredActions()
|
||
if len(actions) == 0 {
|
||
m.state.Selected = 0
|
||
} else {
|
||
m.state.Selected = clampInt(m.state.Selected, 0, len(actions)-1)
|
||
}
|
||
title := "Command Palette"
|
||
if m.runtime.record != nil {
|
||
title = title + " " + styles.keyword.Render(m.runtime.record.ID)
|
||
}
|
||
metaParts := []string{}
|
||
if m.runtime.currentSessionName != "" {
|
||
metaParts = append(metaParts, m.runtime.currentSessionName)
|
||
}
|
||
if m.runtime.currentWindowName != "" {
|
||
metaParts = append(metaParts, m.runtime.currentWindowName)
|
||
}
|
||
if m.runtime.mainRepoRoot != "" {
|
||
metaParts = append(metaParts, filepathBaseOrFull(m.runtime.mainRepoRoot))
|
||
}
|
||
header := styles.title.Render(title)
|
||
if len(metaParts) > 0 {
|
||
header = lipgloss.JoinVertical(lipgloss.Left, header, styles.meta.Render(strings.Join(metaParts, " · ")))
|
||
}
|
||
filterLine := styles.searchBox.Width(width).Render(
|
||
lipgloss.JoinHorizontal(lipgloss.Center,
|
||
styles.searchPrompt.Render(">"),
|
||
" ",
|
||
styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)),
|
||
),
|
||
)
|
||
contentHeight := maxInt(8, height-7)
|
||
listWidth := maxInt(34, width*48/100)
|
||
sidebarWidth := maxInt(28, width-listWidth-3)
|
||
list := m.renderActions(styles, actions, listWidth, contentHeight)
|
||
sidebar := m.renderSidebar(styles, sidebarWidth, contentHeight)
|
||
body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), sidebar)
|
||
footer := renderPaletteFooter(styles, width, m.state.Message, m.state.ShowAltHints)
|
||
view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", body, "", footer)
|
||
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
||
}
|
||
|
||
func (m *paletteModel) renderActions(styles paletteStyles, actions []paletteAction, width, height int) string {
|
||
entriesPerPage := maxInt(1, (height-2)/3)
|
||
selected := clampInt(m.state.Selected, 0, maxInt(0, len(actions)-1))
|
||
offset := stableListOffset(m.state.ActionOffset, selected, entriesPerPage, len(actions))
|
||
m.state.ActionOffset = offset
|
||
blocks := []string{styles.meta.Render(fmt.Sprintf("%d commands", len(actions))), ""}
|
||
if len(actions) == 0 {
|
||
blocks = append(blocks, styles.muted.Width(width).Render("No matching commands"))
|
||
} else {
|
||
for row := 0; row < entriesPerPage; row++ {
|
||
idx := offset + row
|
||
if idx >= len(actions) {
|
||
break
|
||
}
|
||
action := actions[idx]
|
||
sectionLabel := styles.sectionLabel
|
||
subtle := styles.itemSubtitle
|
||
titleStyle := styles.itemTitle
|
||
box := styles.item
|
||
markerText := " "
|
||
markerStyle := styles.muted
|
||
rowStyle := lipgloss.NewStyle().Width(maxInt(16, width-2))
|
||
fillStyle := lipgloss.NewStyle()
|
||
if idx == selected {
|
||
selectedBG := lipgloss.Color("238")
|
||
sectionLabel = styles.selectedLabel.Background(selectedBG)
|
||
subtle = styles.selectedSubtle.Background(selectedBG)
|
||
titleStyle = styles.itemTitle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
||
box = styles.selectedItem
|
||
markerText = "› "
|
||
markerStyle = styles.selectedLabel.Background(selectedBG)
|
||
rowStyle = rowStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
||
fillStyle = fillStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
||
}
|
||
innerWidth := maxInt(16, width-2)
|
||
labelText := strings.ToUpper(action.Section)
|
||
labelWidth := lipgloss.Width(labelText)
|
||
markerWidth := lipgloss.Width(markerText)
|
||
titleWidth := maxInt(10, innerWidth-markerWidth-labelWidth-1)
|
||
titleText := truncate(action.Title, titleWidth)
|
||
gapWidth := maxInt(1, innerWidth-markerWidth-lipgloss.Width(titleText)-labelWidth)
|
||
titleRow := rowStyle.Render(
|
||
markerStyle.Render(markerText) +
|
||
titleStyle.Render(titleText) +
|
||
fillStyle.Render(strings.Repeat(" ", gapWidth)) +
|
||
sectionLabel.Render(labelText),
|
||
)
|
||
subtitleRow := rowStyle.Render(fillStyle.Render(strings.Repeat(" ", markerWidth)) + subtle.Render(truncate(action.Subtitle, maxInt(0, innerWidth-markerWidth))))
|
||
block := lipgloss.JoinVertical(lipgloss.Left, titleRow, subtitleRow)
|
||
blocks = append(blocks, box.Width(width).Render(block))
|
||
}
|
||
}
|
||
content := strings.Join(blocks, "\n")
|
||
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
|
||
}
|
||
|
||
func (m *paletteModel) renderSidebar(styles paletteStyles, width, height int) string {
|
||
lines := []string{}
|
||
trackerContext, trackerAgent, trackerBootstrap := m.runtime.sidebarTrackerStatus()
|
||
lines = append(lines, styles.panelTitle.Render("Tracker Status"))
|
||
lines = append(lines, renderPaletteStat(styles, "Context", trackerContext, width, 9))
|
||
lines = append(lines, renderPaletteStat(styles, "Agent", trackerAgent, width, 9))
|
||
lines = append(lines, renderPaletteStat(styles, "Bootstrap", trackerBootstrap, width, 9))
|
||
lines = append(lines, "")
|
||
lines = append(lines, styles.panelTitle.Render("Todo Preview"))
|
||
previewLimit := clampInt((height-6)/4, 1, 3)
|
||
sections := m.runtime.sidebarTodoPreviewSections()
|
||
for idx, section := range sections {
|
||
if idx > 0 {
|
||
lines = append(lines, "")
|
||
}
|
||
lines = append(lines, renderPaletteTodoPreviewSection(styles, section, width, previewLimit)...)
|
||
}
|
||
content := strings.Join(lines, "\n")
|
||
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
|
||
}
|
||
|
||
func (r *paletteRuntime) sidebarTrackerStatus() (contextSummary, agentSummary, bootstrapSummary string) {
|
||
contextParts := []string{}
|
||
if r.currentSessionName != "" {
|
||
contextParts = append(contextParts, r.currentSessionName)
|
||
}
|
||
if r.currentWindowName != "" {
|
||
contextParts = append(contextParts, r.currentWindowName)
|
||
}
|
||
if r.mainRepoRoot != "" {
|
||
contextParts = append(contextParts, filepathBaseOrFull(r.mainRepoRoot))
|
||
} else if r.currentPath != "" {
|
||
contextParts = append(contextParts, filepathBaseOrFull(r.currentPath))
|
||
}
|
||
if len(contextParts) == 0 {
|
||
contextSummary = "No tmux context detected"
|
||
} else {
|
||
contextSummary = strings.Join(contextParts, " · ")
|
||
}
|
||
if r.record == nil {
|
||
agentID := r.effectiveAgentID()
|
||
if agentID == "" {
|
||
return contextSummary, "No active agent", "No active agent"
|
||
}
|
||
return contextSummary, fmt.Sprintf("%s not loaded", agentID), "No active agent"
|
||
}
|
||
agentSummary = r.record.ID
|
||
if r.record.Branch != "" {
|
||
agentSummary = agentSummary + " on " + r.record.Branch
|
||
}
|
||
bootstrapSummary = paletteBootstrapStatus(r.record)
|
||
return contextSummary, agentSummary, bootstrapSummary
|
||
}
|
||
|
||
func (r *paletteRuntime) sidebarTodoPreviewSections() []paletteTodoPreviewSection {
|
||
sections := []paletteTodoPreviewSection{}
|
||
store, err := loadTmuxTodoStore()
|
||
windowID := strings.TrimSpace(r.windowID)
|
||
if err != nil {
|
||
sections = append(sections, paletteTodoPreviewSection{Title: "Window", Empty: "Todo store unavailable"})
|
||
sections = append(sections, paletteTodoPreviewSection{Title: "Global", Empty: "Todo store unavailable"})
|
||
} else {
|
||
windowSection := paletteTodoPreviewSection{Title: "Window", Empty: "No window todos"}
|
||
if windowID == "" {
|
||
windowSection.Empty = "No window context"
|
||
} else {
|
||
windowSection.Items = paletteTmuxTodoPreviewItems(todoItemsForScope(store, todoScopeWindow, windowID))
|
||
}
|
||
sections = append(sections, windowSection)
|
||
sections = append(sections, paletteTodoPreviewSection{
|
||
Title: "Global",
|
||
Items: paletteTmuxTodoPreviewItems(todoItemsForScope(store, todoScopeGlobal, "")),
|
||
Empty: "No global todos",
|
||
})
|
||
}
|
||
return sections
|
||
}
|
||
|
||
func paletteBootstrapStatus(record *agentRecord) string {
|
||
if record == nil {
|
||
return "No active agent"
|
||
}
|
||
workspaceRoot := strings.TrimSpace(record.WorkspaceRoot)
|
||
if workspaceRoot == "" {
|
||
return "No workspace"
|
||
}
|
||
if fileExists(bootstrapRepoReadyPath(workspaceRoot)) {
|
||
return paletteBootstrapLabel("ready", paletteBootstrapPID(workspaceRoot))
|
||
}
|
||
if fileExists(bootstrapFailedPath(workspaceRoot)) {
|
||
message := firstPaletteLine(readPaletteBootstrapFailure(workspaceRoot))
|
||
if message == "" {
|
||
return "failed"
|
||
}
|
||
return "failed: " + message
|
||
}
|
||
pid := paletteBootstrapPID(workspaceRoot)
|
||
if fileExists(bootstrapGitReadyPath(workspaceRoot)) {
|
||
return paletteBootstrapLabel("copying repo", pid)
|
||
}
|
||
if pid > 0 {
|
||
return paletteBootstrapLabel("preparing git", pid)
|
||
}
|
||
return "preparing git"
|
||
}
|
||
|
||
func paletteBootstrapPID(workspaceRoot string) int {
|
||
data, err := os.ReadFile(bootstrapPIDPath(workspaceRoot))
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||
if err != nil || !processRunning(pid) {
|
||
return 0
|
||
}
|
||
return pid
|
||
}
|
||
|
||
func paletteBootstrapLabel(status string, pid int) string {
|
||
if pid <= 0 {
|
||
return status
|
||
}
|
||
return fmt.Sprintf("%s (pid %d)", status, pid)
|
||
}
|
||
|
||
func readPaletteBootstrapFailure(workspaceRoot string) string {
|
||
data, err := os.ReadFile(bootstrapFailedPath(workspaceRoot))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(string(data))
|
||
}
|
||
|
||
func (m *paletteModel) renderPrompt(styles paletteStyles, width, height int) string {
|
||
title := "Input"
|
||
detail := "Enter a value"
|
||
if m.state.PromptKind == palettePromptStartAgent {
|
||
title = "Start agent"
|
||
repoRoot := blankIfEmpty(m.runtime.resolveStartRepoRoot(m.state.PromptRepoRoot), "Main repo not found")
|
||
if repoRoot == "Main repo not found" {
|
||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||
styles.modalTitle.Render(title),
|
||
styles.statusBad.Render(repoRoot),
|
||
"",
|
||
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
||
[][][2]string{{{"Esc", "back"}, {footerHintToggleKey, "more"}}},
|
||
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
||
)),
|
||
)
|
||
box := styles.modal.Width(minInt(72, maxInt(36, width-10))).Render(body)
|
||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
||
}
|
||
sourceBranch := blankIfEmpty(m.runtime.startSourceBranch(m.state.PromptRepoRoot), "Unavailable")
|
||
devices := m.state.PromptDevices
|
||
if len(devices) == 0 {
|
||
devices = []string{defaultManagedDeviceID}
|
||
}
|
||
nameLabel := styles.modalHint.Render("NAME")
|
||
deviceLabel := styles.modalHint.Render("DEVICE")
|
||
worktreeLabel := styles.modalHint.Render("WORKTREE")
|
||
if m.state.PromptField == palettePromptFieldName {
|
||
nameLabel = styles.selectedLabel.Render("NAME")
|
||
} else if m.state.PromptField == palettePromptFieldDevice {
|
||
deviceLabel = styles.selectedLabel.Render("DEVICE")
|
||
} else {
|
||
worktreeLabel = styles.selectedLabel.Render("WORKTREE")
|
||
}
|
||
deviceChips := make([]string, 0, len(devices))
|
||
for idx, deviceID := range devices {
|
||
deviceChips = append(deviceChips, renderPaletteDeviceChip(styles, deviceID, idx == clampInt(m.state.PromptDeviceIndex, 0, len(devices)-1)))
|
||
}
|
||
worktreeChips := []string{
|
||
renderPaletteDeviceChip(styles, "CLEAR", !m.state.PromptKeepWorktree),
|
||
renderPaletteDeviceChip(styles, "KEEP", m.state.PromptKeepWorktree),
|
||
}
|
||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||
styles.modalTitle.Render(title),
|
||
styles.modalBody.Render(repoRoot),
|
||
"",
|
||
styles.modalHint.Render("BRANCH"),
|
||
styles.modalBody.Render(sourceBranch),
|
||
"",
|
||
nameLabel,
|
||
styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)),
|
||
"",
|
||
deviceLabel,
|
||
styles.modalBody.Render(strings.Join(deviceChips, " ")),
|
||
"",
|
||
worktreeLabel,
|
||
styles.modalBody.Render(strings.Join(worktreeChips, " ")),
|
||
"",
|
||
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(64, maxInt(28, width-18)), m.state.ShowAltHints,
|
||
[][][2]string{
|
||
{{"Enter", "create"}, {"Tab", "focus"}, {"n/i", "choose"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||
{{"Enter", "create"}, {"n/i", "choose"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||
{{"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||
},
|
||
[][][2]string{
|
||
{{"Alt-D", "next"}, {"Alt-Shift-D", "prev"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||
{{"Alt-D", "next"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||
{{"Alt-S", "close"}},
|
||
},
|
||
)),
|
||
)
|
||
box := styles.modal.Width(minInt(84, maxInt(40, width-10))).Render(body)
|
||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
||
}
|
||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||
styles.modalTitle.Render(title),
|
||
styles.modalBody.Render(detail),
|
||
"",
|
||
styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)),
|
||
"",
|
||
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
||
[][][2]string{{{"Enter", "save"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, {{"Esc", "back"}, {footerHintToggleKey, "more"}}},
|
||
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
||
)),
|
||
)
|
||
box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body)
|
||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
||
}
|
||
|
||
func (m *paletteModel) renderConfirm(styles paletteStyles, width, height int) string {
|
||
agentID := "this agent"
|
||
detail := "Remove " + agentID + " and close its tmux window?"
|
||
hint := renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
||
[][][2]string{{{"y", "confirm"}, {"Esc", "cancel"}, {footerHintToggleKey, "more"}}, {{"Esc", "cancel"}, {footerHintToggleKey, "more"}}},
|
||
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
||
)
|
||
if m.runtime.record != nil {
|
||
agentID = m.runtime.record.ID
|
||
detail = "Remove " + agentID + " and close its tmux window?"
|
||
if windowID := activeAgentWindowID(m.runtime.record); windowID != "" {
|
||
if openTodos, err := countOpenTmuxTodos(todoScopeWindow, windowID); err == nil && openTodos > 0 {
|
||
label := "todos"
|
||
if openTodos == 1 {
|
||
label = "todo"
|
||
}
|
||
detail = fmt.Sprintf("Close %d open window %s before destroying %s.", openTodos, label, agentID)
|
||
hint = renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
||
[][][2]string{{{"Esc", "cancel"}, {footerHintToggleKey, "more"}}},
|
||
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
||
)
|
||
}
|
||
}
|
||
}
|
||
if m.state.ConfirmRequiresText {
|
||
detail = detail + " Uncommitted changes detected; type destroy to continue."
|
||
hint = renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
||
[][][2]string{{{"Enter", "confirm"}, {"Esc", "cancel"}, {footerHintToggleKey, "more"}}, {{"Esc", "cancel"}, {footerHintToggleKey, "more"}}},
|
||
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
||
)
|
||
}
|
||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||
styles.modalTitle.Render("Destroy agent"),
|
||
styles.modalBody.Render(detail),
|
||
func() string {
|
||
if !m.state.ConfirmRequiresText {
|
||
return ""
|
||
}
|
||
return styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles))
|
||
}(),
|
||
"",
|
||
styles.modalHint.Render(hint),
|
||
)
|
||
box := styles.modal.Width(minInt(72, maxInt(36, width-10))).Render(body)
|
||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
||
}
|
||
|
||
func (m *paletteModel) renderSnippets(styles paletteStyles, width, height int) string {
|
||
snippets := m.filteredSnippets()
|
||
if len(snippets) == 0 {
|
||
m.state.Selected = 0
|
||
} else {
|
||
m.state.Selected = clampInt(m.state.Selected, 0, len(snippets)-1)
|
||
}
|
||
|
||
title := "Paste Snippet"
|
||
header := styles.title.Render(title)
|
||
|
||
filterLine := styles.searchBox.Width(width).Render(
|
||
lipgloss.JoinHorizontal(lipgloss.Center,
|
||
styles.searchPrompt.Render(">"),
|
||
" ",
|
||
styles.input.Render(renderInputValue(m.state.Filter, m.state.FilterCursor, styles)),
|
||
),
|
||
)
|
||
|
||
contentHeight := maxInt(8, height-7)
|
||
listWidth := maxInt(34, width*52/100)
|
||
previewWidth := maxInt(28, width-listWidth-3)
|
||
|
||
list := m.renderSnippetList(styles, snippets, listWidth, contentHeight)
|
||
preview := m.renderSnippetPreview(styles, snippets, previewWidth, contentHeight)
|
||
body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), preview)
|
||
|
||
footer := renderPaletteModeFooter(styles, width, m.state.Message, m.state.ShowAltHints,
|
||
[][][2]string{
|
||
{{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||
{{"Ctrl-U/E", "move"}, {"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||
{{"Enter", "paste"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||
},
|
||
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
||
)
|
||
|
||
view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", body, "", footer)
|
||
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
||
}
|
||
|
||
func (m *paletteModel) renderSnippetList(styles paletteStyles, snippets []snippet, width, height int) string {
|
||
entriesPerPage := maxInt(1, (height-2)/2)
|
||
selected := clampInt(m.state.Selected, 0, maxInt(0, len(snippets)-1))
|
||
offset := stableListOffset(m.state.SnippetOffset, selected, entriesPerPage, len(snippets))
|
||
m.state.SnippetOffset = offset
|
||
|
||
blocks := []string{styles.meta.Render(fmt.Sprintf("%d snippets", len(snippets))), ""}
|
||
if len(snippets) == 0 {
|
||
blocks = append(blocks, styles.muted.Width(width).Render("No matching snippets"))
|
||
} else {
|
||
for row := 0; row < entriesPerPage; row++ {
|
||
idx := offset + row
|
||
if idx >= len(snippets) {
|
||
break
|
||
}
|
||
snippet := snippets[idx]
|
||
selectedBG := lipgloss.Color("238")
|
||
titleStyle := styles.itemTitle
|
||
subtitleStyle := styles.itemSubtitle
|
||
rowStyle := lipgloss.NewStyle().Width(maxInt(16, width-2))
|
||
fillStyle := lipgloss.NewStyle()
|
||
varLabelStyle := styles.sectionLabel
|
||
if idx == selected {
|
||
titleStyle = styles.itemTitle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
||
subtitleStyle = styles.selectedSubtle.Background(selectedBG)
|
||
rowStyle = rowStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
||
fillStyle = fillStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
||
varLabelStyle = styles.selectedLabel.Background(selectedBG)
|
||
}
|
||
|
||
varLabel := ""
|
||
if len(snippet.Vars) > 0 {
|
||
varLabel = varLabelStyle.Render(fmt.Sprintf(" %d vars", len(snippet.Vars)))
|
||
}
|
||
|
||
innerWidth := maxInt(16, width-2)
|
||
titleText := truncate(snippet.Name, innerWidth-lipgloss.Width(varLabel)-1)
|
||
gapWidth := maxInt(1, innerWidth-lipgloss.Width(titleText)-lipgloss.Width(varLabel))
|
||
|
||
titleRow := rowStyle.Render(
|
||
titleStyle.Render(titleText) +
|
||
fillStyle.Render(strings.Repeat(" ", gapWidth)) +
|
||
varLabel,
|
||
)
|
||
desc := snippet.Description
|
||
if desc == "" {
|
||
desc = truncate(snippet.Content, 40)
|
||
}
|
||
subtitleRow := rowStyle.Render(fillStyle.Render(" ") + subtitleStyle.Render(truncate(desc, innerWidth-2)))
|
||
block := lipgloss.JoinVertical(lipgloss.Left, titleRow, subtitleRow)
|
||
blocks = append(blocks, styles.item.Width(width).Render(block))
|
||
}
|
||
}
|
||
|
||
content := strings.Join(blocks, "\n")
|
||
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
|
||
}
|
||
|
||
func (m *paletteModel) renderSnippetPreview(styles paletteStyles, snippets []snippet, width, height int) string {
|
||
lines := []string{}
|
||
if len(snippets) > 0 && m.state.Selected >= 0 && m.state.Selected < len(snippets) {
|
||
snippet := snippets[m.state.Selected]
|
||
lines = append(lines, styles.panelTitle.Render("Preview"))
|
||
lines = append(lines, styles.title.Render(snippet.Name))
|
||
if snippet.Description != "" {
|
||
lines = append(lines, styles.muted.Render(snippet.Description))
|
||
}
|
||
lines = append(lines, "")
|
||
if len(snippet.Vars) > 0 {
|
||
chips := []string{}
|
||
for _, v := range snippet.Vars {
|
||
chips = append(chips, styles.keyword.Render("{{"+v+"}}"))
|
||
}
|
||
lines = append(lines, "Variables: "+strings.Join(chips, " "))
|
||
lines = append(lines, "")
|
||
}
|
||
lines = append(lines, styles.panelTitle.Render("Content"))
|
||
for _, l := range wrapText(snippet.Content, maxInt(10, width-2)) {
|
||
lines = append(lines, styles.panelText.Render(truncate(l, width)))
|
||
}
|
||
}
|
||
content := strings.Join(lines, "\n")
|
||
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
|
||
}
|
||
|
||
func (m *paletteModel) renderSnippetVars(styles paletteStyles, width, height int) string {
|
||
varName := m.state.SnippetVars[m.state.SnippetVarIndex]
|
||
progress := fmt.Sprintf("(%d/%d)", m.state.SnippetVarIndex+1, len(m.state.SnippetVars))
|
||
title := fmt.Sprintf("Enter %s %s", varName, progress)
|
||
|
||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||
styles.modalTitle.Render(title),
|
||
styles.modalBody.Render("Value for {{"+varName+"}}"),
|
||
"",
|
||
styles.input.Render(renderInputValue(m.state.PromptText, m.state.PromptCursor, styles)),
|
||
"",
|
||
styles.modalHint.Render(renderPaletteHintLine(styles, minInt(52, maxInt(20, width-18)), m.state.ShowAltHints,
|
||
[][][2]string{{{"Enter", "continue"}, {"Esc", "back"}, {footerHintToggleKey, "more"}}, {{"Esc", "back"}, {footerHintToggleKey, "more"}}},
|
||
[][][2]string{{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}}, {{"Alt-S", "close"}}},
|
||
)),
|
||
)
|
||
box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body)
|
||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
||
}
|
||
|
||
func (m *paletteModel) filteredActions() []paletteAction {
|
||
query := strings.ToLower(strings.TrimSpace(string(m.state.Filter)))
|
||
if query == "" {
|
||
return m.actions
|
||
}
|
||
parts := strings.Fields(query)
|
||
filtered := make([]paletteAction, 0, len(m.actions))
|
||
for _, action := range m.actions {
|
||
haystack := strings.ToLower(action.Title + " " + action.Subtitle + " " + strings.Join(action.Keywords, " "))
|
||
matched := true
|
||
for _, part := range parts {
|
||
if !strings.Contains(haystack, part) {
|
||
matched = false
|
||
break
|
||
}
|
||
}
|
||
if matched {
|
||
filtered = append(filtered, action)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func newPaletteStyles() paletteStyles {
|
||
return paletteStyles{
|
||
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")),
|
||
meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||
searchBox: lipgloss.NewStyle().Background(lipgloss.Color("236")).Padding(0, 1),
|
||
searchPrompt: lipgloss.NewStyle().Foreground(lipgloss.Color("223")).Bold(true),
|
||
input: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
||
inputCursor: lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("223")).Bold(true),
|
||
item: lipgloss.NewStyle().Padding(0, 1).MarginBottom(1),
|
||
selectedItem: lipgloss.NewStyle().Padding(0, 1).MarginBottom(1).Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230")),
|
||
sectionLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("180")).Bold(true),
|
||
selectedLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("223")).Bold(true),
|
||
itemTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")),
|
||
itemSubtitle: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||
selectedSubtle: lipgloss.NewStyle().Foreground(lipgloss.Color("251")),
|
||
panelTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("223")),
|
||
panelText: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
||
muted: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||
footer: lipgloss.NewStyle().Foreground(lipgloss.Color("216")),
|
||
keyword: lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("237")).Padding(0, 1),
|
||
modal: lipgloss.NewStyle().Border(paletteModalBorder).BorderForeground(lipgloss.Color("223")).Padding(1, 2),
|
||
modalTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")),
|
||
modalBody: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
||
modalHint: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||
statusBad: lipgloss.NewStyle().Foreground(lipgloss.Color("203")),
|
||
statLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||
statValue: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
|
||
todoCheck: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||
todoCheckDone: lipgloss.NewStyle().Foreground(lipgloss.Color("150")),
|
||
panelTextDone: lipgloss.NewStyle().Foreground(lipgloss.Color("246")),
|
||
shortcutKey: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("223")).Padding(0, 1).Bold(true),
|
||
shortcutText: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||
}
|
||
}
|
||
|
||
func renderInputValue(text []rune, cursor int, styles paletteStyles) string {
|
||
if cursor < 0 {
|
||
cursor = 0
|
||
}
|
||
if cursor > len(text) {
|
||
cursor = len(text)
|
||
}
|
||
left := string(text[:cursor])
|
||
right := string(text[cursor:])
|
||
cursorChar := " "
|
||
if cursor < len(text) {
|
||
cursorChar = string(text[cursor])
|
||
right = string(text[cursor+1:])
|
||
}
|
||
if len(text) == 0 && cursor == 0 {
|
||
cursorChar = " "
|
||
}
|
||
return left + styles.inputCursor.Render(cursorChar) + right
|
||
}
|
||
|
||
func applyPaletteInputKey(key string, text *[]rune, cursor *int, allowEnter bool) bool {
|
||
if text == nil || cursor == nil {
|
||
return false
|
||
}
|
||
switch key {
|
||
case "left":
|
||
*cursor = clampInt(*cursor-1, 0, len(*text))
|
||
return true
|
||
case "right":
|
||
*cursor = clampInt(*cursor+1, 0, len(*text))
|
||
return true
|
||
case "backspace", "ctrl+h":
|
||
if *cursor > 0 {
|
||
*text = append((*text)[:*cursor-1], (*text)[*cursor:]...)
|
||
*cursor--
|
||
}
|
||
return true
|
||
case "delete":
|
||
if *cursor < len(*text) {
|
||
*text = append((*text)[:*cursor], (*text)[*cursor+1:]...)
|
||
}
|
||
return true
|
||
case "ctrl+a", "home":
|
||
*cursor = 0
|
||
return true
|
||
case "ctrl+e", "end":
|
||
*cursor = len(*text)
|
||
return true
|
||
case "ctrl+u":
|
||
*text = (*text)[*cursor:]
|
||
*cursor = 0
|
||
return true
|
||
case "ctrl+w":
|
||
start := previousWordBoundary(*text, *cursor)
|
||
*text = append((*text)[:start], (*text)[*cursor:]...)
|
||
*cursor = start
|
||
return true
|
||
case "enter":
|
||
return allowEnter
|
||
}
|
||
r, ok := paletteRuneFromKey(key)
|
||
if !ok {
|
||
return false
|
||
}
|
||
*text = append((*text)[:*cursor], append([]rune{r}, (*text)[*cursor:]...)...)
|
||
*cursor++
|
||
return true
|
||
}
|
||
|
||
func paletteRuneFromKey(key string) (rune, bool) {
|
||
if key == "space" {
|
||
return ' ', true
|
||
}
|
||
runes := []rune(key)
|
||
if len(runes) == 1 {
|
||
return runes[0], true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func renderVerticalDivider(height int) string {
|
||
lines := make([]string, maxInt(1, height))
|
||
for i := range lines {
|
||
lines[i] = "│"
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func renderPaletteStat(styles paletteStyles, label, value string, width int, labelWidth int) string {
|
||
parts := wrapText(value, maxInt(10, width-labelWidth-3))
|
||
if len(parts) == 0 {
|
||
parts = []string{"-"}
|
||
}
|
||
lines := []string{styles.statLabel.Width(labelWidth).Render(label+":") + " " + styles.statValue.Render(parts[0])}
|
||
for _, part := range parts[1:] {
|
||
lines = append(lines, strings.Repeat(" ", labelWidth+1)+styles.statValue.Render(part))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func renderPaletteModeFooter(styles paletteStyles, width int, message string, showAltHints bool, normalCandidates [][][2]string, altCandidates [][][2]string) string {
|
||
message = strings.TrimSpace(message)
|
||
if message != "" {
|
||
style := styles.footer
|
||
lower := strings.ToLower(message)
|
||
if strings.Contains(lower, "error") || strings.Contains(lower, "required") || strings.Contains(lower, "unknown") {
|
||
style = styles.statusBad
|
||
}
|
||
return style.Width(width).Render(truncate(message, width))
|
||
}
|
||
renderSegments := func(pairs [][2]string) string {
|
||
return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs)
|
||
}
|
||
candidates := normalCandidates
|
||
if showAltHints {
|
||
candidates = altCandidates
|
||
}
|
||
footer := pickRenderedShortcutFooter(width, renderSegments, candidates...)
|
||
return lipgloss.NewStyle().Width(width).Render(footer)
|
||
}
|
||
|
||
func renderPaletteFooter(styles paletteStyles, width int, message string, showAltHints bool) string {
|
||
return renderPaletteModeFooter(styles, width, message, showAltHints,
|
||
[][][2]string{
|
||
{{"Ctrl-U/E", "move"}, {"Ctrl-N/I", "filter"}, {"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
||
{{"Ctrl-U/E", "move"}, {"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
||
{{"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
||
},
|
||
[][][2]string{
|
||
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-S", "close"}},
|
||
},
|
||
)
|
||
}
|
||
|
||
func renderPaletteHintLine(styles paletteStyles, width int, showAltHints bool, normalCandidates [][][2]string, altCandidates [][][2]string) string {
|
||
return pickRenderedShortcutFooter(width, func(pairs [][2]string) string {
|
||
return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs)
|
||
}, func() [][][2]string {
|
||
if showAltHints {
|
||
return altCandidates
|
||
}
|
||
return normalCandidates
|
||
}()...)
|
||
}
|
||
|
||
func paletteTmuxTodoPreviewItems(items []tmuxTodoItem) []paletteTodoPreviewItem {
|
||
rows := make([]paletteTodoPreviewItem, 0, len(items))
|
||
for _, item := range items {
|
||
title := firstPaletteLine(item.Title)
|
||
if title == "" || item.Done {
|
||
continue
|
||
}
|
||
rows = append(rows, paletteTodoPreviewItem{Title: title, Done: item.Done})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func renderPaletteTodoPreviewSection(styles paletteStyles, section paletteTodoPreviewSection, width int, previewLimit int) []string {
|
||
lines := []string{styles.statLabel.Render(section.Title)}
|
||
if section.Lead != "" {
|
||
lines = append(lines, renderPalettePreviewValue(styles, section.Lead, width, 2)...)
|
||
}
|
||
if len(section.Items) == 0 {
|
||
if section.Lead == "" {
|
||
lines = append(lines, styles.muted.Render(" "+section.Empty))
|
||
}
|
||
return lines
|
||
}
|
||
limit := clampInt(previewLimit, 1, len(section.Items))
|
||
for _, item := range section.Items[:limit] {
|
||
lines = append(lines, renderPaletteTodoPreviewItem(styles, item, width, 2)...)
|
||
}
|
||
hidden := len(section.Items) - limit
|
||
if hidden > 0 {
|
||
lines = append(lines, styles.muted.Render(fmt.Sprintf(" +%d more", hidden)))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func renderPaletteTodoPreviewItem(styles paletteStyles, item paletteTodoPreviewItem, width int, indent int) []string {
|
||
title := strings.TrimSpace(item.Title)
|
||
if title == "" {
|
||
return nil
|
||
}
|
||
check := "○"
|
||
checkStyle := styles.todoCheck
|
||
textStyle := styles.panelText
|
||
if item.Done {
|
||
check = "●"
|
||
checkStyle = styles.todoCheckDone
|
||
textStyle = styles.panelTextDone
|
||
}
|
||
indentPrefix := strings.Repeat(" ", maxInt(0, indent))
|
||
textPrefix := indentPrefix + check + " "
|
||
available := maxInt(10, width-lipgloss.Width(textPrefix))
|
||
parts := wrapText(title, available)
|
||
if len(parts) == 0 {
|
||
parts = []string{title}
|
||
}
|
||
lines := []string{indentPrefix + checkStyle.Render(check) + " " + textStyle.Render(truncate(parts[0], available))}
|
||
continuationPrefix := strings.Repeat(" ", lipgloss.Width(textPrefix))
|
||
for _, part := range parts[1:] {
|
||
lines = append(lines, continuationPrefix+textStyle.Render(truncate(part, available)))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func renderPalettePreviewValue(styles paletteStyles, value string, width int, indent int) []string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return nil
|
||
}
|
||
prefix := strings.Repeat(" ", maxInt(0, indent))
|
||
available := maxInt(10, width-len([]rune(prefix)))
|
||
parts := wrapText(value, available)
|
||
if len(parts) == 0 {
|
||
parts = []string{value}
|
||
}
|
||
lines := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
lines = append(lines, prefix+styles.panelText.Render(truncate(part, available)))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func renderPaletteDeviceChip(styles paletteStyles, deviceID string, active bool) string {
|
||
chipStyle := styles.keyword
|
||
if active {
|
||
chipStyle = styles.keyword.Copy().Foreground(lipgloss.Color("223")).Background(lipgloss.Color("238")).Bold(true)
|
||
}
|
||
label := deviceID
|
||
if isPaletteNoDeviceOption(deviceID) {
|
||
label = "NONE"
|
||
}
|
||
return chipStyle.Render(label)
|
||
}
|
||
|
||
func firstPaletteLine(value string) string {
|
||
parts := strings.Split(strings.TrimSpace(value), "\n")
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(parts[0])
|
||
}
|
||
|
||
func paletteMessageForError(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
func paletteSuccessMessage(err error, success string) string {
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return success
|
||
}
|
||
|
||
func filepathBaseOrFull(path string) string {
|
||
base := strings.TrimSpace(filepath.Base(path))
|
||
if base == "" || base == "." || base == string(filepath.Separator) {
|
||
return path
|
||
}
|
||
return base
|
||
}
|
||
|
||
func clampInt(value, low, high int) int {
|
||
if high < low {
|
||
return low
|
||
}
|
||
if value < low {
|
||
return low
|
||
}
|
||
if value > high {
|
||
return high
|
||
}
|
||
return value
|
||
}
|