mirror of
https://github.com/theniceboy/.config.git
synced 2026-07-16 22:01:21 +08:00
2606 lines
68 KiB
Go
2606 lines
68 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type goalPanelMode int
|
|
|
|
const (
|
|
goalModeList goalPanelMode = iota
|
|
goalModeMove
|
|
goalModeRename
|
|
goalModeAddGoal
|
|
goalModeAddThread
|
|
goalModeAddTodo
|
|
goalModeSetGoal
|
|
goalModeSetBlocker
|
|
goalModePromote
|
|
goalModePromoteGoal
|
|
goalModeMoreOptions
|
|
goalModeConfirmDeleteGoal
|
|
goalModeConfirmDone
|
|
goalModeConfirmDeletePlanned
|
|
goalModeConfirmMerge
|
|
goalModeHelp
|
|
)
|
|
|
|
type goalPickerItem struct {
|
|
ID string
|
|
Label string
|
|
Subtitle string
|
|
IsCreate bool
|
|
}
|
|
|
|
type goalActionMsg struct {
|
|
err error
|
|
closeAfter bool
|
|
}
|
|
|
|
type goalPanelModel struct {
|
|
runtime *paletteRuntime
|
|
width int
|
|
height int
|
|
list *flatList
|
|
cursor int
|
|
expanded map[string]bool
|
|
|
|
mode goalPanelMode
|
|
status string
|
|
statusUntil time.Time
|
|
|
|
// move mode
|
|
moveGhost ghostState
|
|
moveName string
|
|
|
|
// text input modes (rename, add-goal, add-thread, add-todo)
|
|
inputText []rune
|
|
inputCursor int
|
|
inputLabel string
|
|
inputHint string
|
|
inputTarget string // thread id for rename / goal id parent for add-goal / window for add-todo
|
|
|
|
// picker modes (set-goal, set-blocker, more-options)
|
|
picker []goalPickerItem
|
|
pickerFilter []rune
|
|
pickerCursor int
|
|
pickerOffset int
|
|
pickerKind string
|
|
pickerSelected string // thread id the picker acts on
|
|
|
|
// promote form
|
|
promoteWindow string
|
|
promoteName []rune
|
|
promoteCursor int
|
|
promoteGoal string
|
|
promoteBind string // existing thread id, or ""
|
|
promoteTab int // 0=create, 1=bind
|
|
promoteNoGoal bool // promote-goal mode: cursor on "no goal"
|
|
promoteCreateGoal bool // creating new goal from promote mode
|
|
|
|
// confirm modes
|
|
confirmThread string
|
|
confirmGoal string
|
|
confirmMergeSrc string
|
|
confirmMergeTgt string
|
|
|
|
showAssignedOnly bool
|
|
helpVisible bool
|
|
showAltHints bool
|
|
requestBack bool
|
|
requestClose bool
|
|
}
|
|
|
|
func newGoalPanelModel(runtime *paletteRuntime) *goalPanelModel {
|
|
m := &goalPanelModel{
|
|
runtime: runtime,
|
|
expanded: map[string]bool{},
|
|
mode: goalModeList,
|
|
showAssignedOnly: true,
|
|
}
|
|
m.reload()
|
|
return m
|
|
}
|
|
|
|
type goalPanelTickMsg struct{}
|
|
|
|
func goalPanelTickCmd() tea.Cmd {
|
|
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return goalPanelTickMsg{} })
|
|
}
|
|
|
|
func (m *goalPanelModel) activate() tea.Cmd {
|
|
m.requestBack = false
|
|
m.requestClose = false
|
|
reapOrphanedThreads()
|
|
m.reload()
|
|
m.jumpToCurrentWindow()
|
|
return goalPanelTickCmd()
|
|
}
|
|
|
|
func (m *goalPanelModel) jumpToCurrentWindow() {
|
|
wid := strings.TrimSpace(m.currentWindowID())
|
|
if wid == "" || m.list == nil {
|
|
return
|
|
}
|
|
for i, n := range m.list.Nodes {
|
|
if n.Kind == nodeThread && n.Thread != nil && strings.TrimSpace(n.Thread.WindowID) == wid {
|
|
m.cursor = i
|
|
return
|
|
}
|
|
if n.Kind == nodeDraft && n.Draft != nil && strings.TrimSpace(n.Draft.WindowID) == wid {
|
|
m.cursor = i
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *goalPanelModel) Init() tea.Cmd { return nil }
|
|
|
|
func (m *goalPanelModel) reload() {
|
|
list, err := buildDisplayList(m.expanded)
|
|
if err != nil {
|
|
m.list = &flatList{}
|
|
m.status = err.Error()
|
|
m.statusUntil = time.Now().Add(3 * time.Second)
|
|
return
|
|
}
|
|
m.list = list
|
|
if m.cursor >= len(list.Nodes) {
|
|
m.cursor = len(list.Nodes) - 1
|
|
}
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
}
|
|
|
|
func (m *goalPanelModel) setStatus(text string) {
|
|
m.status = strings.TrimSpace(text)
|
|
m.statusUntil = time.Now().Add(2500 * time.Millisecond)
|
|
}
|
|
|
|
func (m *goalPanelModel) currentStatus() string {
|
|
if m.status == "" || (!m.statusUntil.IsZero() && time.Now().After(m.statusUntil)) {
|
|
return ""
|
|
}
|
|
return m.status
|
|
}
|
|
|
|
// ── current window context ──────────────────────────────────
|
|
|
|
func (m *goalPanelModel) currentWindowID() string {
|
|
if m.runtime != nil && strings.TrimSpace(m.runtime.windowID) != "" {
|
|
return strings.TrimSpace(m.runtime.windowID)
|
|
}
|
|
return resolveGoalWindowID("")
|
|
}
|
|
|
|
func (m *goalPanelModel) currentNode() *goalNode {
|
|
if m.list == nil || m.cursor < 0 || m.cursor >= len(m.list.Nodes) {
|
|
return nil
|
|
}
|
|
return m.list.Nodes[m.cursor]
|
|
}
|
|
|
|
// threadForNode returns the thread a node refers to (or a draft's implicit thread nil).
|
|
func (m *goalPanelModel) threadForNode(n *goalNode) *Thread {
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
if n.Kind == nodeThread {
|
|
return n.Thread
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Update ──────────────────────────────────────────────────
|
|
|
|
func (m *goalPanelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
return m, nil
|
|
case goalActionMsg:
|
|
if msg.err != nil {
|
|
m.setStatus(msg.err.Error())
|
|
}
|
|
if msg.closeAfter {
|
|
m.requestClose = true
|
|
}
|
|
return m, nil
|
|
case goalPanelTickMsg:
|
|
return m, goalPanelTickCmd()
|
|
case tea.KeyMsg:
|
|
if isAltFooterToggleKey(msg) {
|
|
m.showAltHints = !m.showAltHints
|
|
return m, nil
|
|
}
|
|
m.showAltHints = false
|
|
key := msg.String()
|
|
if key == "alt+s" {
|
|
m.requestClose = true
|
|
return m, nil
|
|
}
|
|
switch m.mode {
|
|
case goalModeMove:
|
|
return m.updateMove(key)
|
|
case goalModeRename, goalModeAddGoal, goalModeAddThread, goalModeAddTodo:
|
|
return m.updateTextInput(key)
|
|
case goalModeSetGoal, goalModeSetBlocker, goalModeMoreOptions:
|
|
return m.updatePicker(key)
|
|
case goalModePromoteGoal:
|
|
return m.updatePromoteGoal(key)
|
|
case goalModePromote:
|
|
return m.updatePromote(key)
|
|
case goalModeConfirmDeleteGoal:
|
|
return m.updateConfirmDeleteGoal(key)
|
|
case goalModeConfirmDone:
|
|
return m.updateConfirmDone(key)
|
|
case goalModeConfirmDeletePlanned:
|
|
return m.updateConfirmDeletePlanned(key)
|
|
case goalModeConfirmMerge:
|
|
return m.updateConfirmMerge(key)
|
|
case goalModeHelp:
|
|
if key == "?" || key == "esc" {
|
|
m.mode = goalModeList
|
|
}
|
|
return m, nil
|
|
default:
|
|
return m.updateList(key)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ── list mode ───────────────────────────────────────────────
|
|
|
|
func (m *goalPanelModel) updateList(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "esc", "ctrl+c":
|
|
m.requestBack = true
|
|
return m, nil
|
|
case "?":
|
|
m.mode = goalModeHelp
|
|
return m, nil
|
|
case "u", "up":
|
|
m.moveCursor(-1)
|
|
case "e", "down":
|
|
m.moveCursor(1)
|
|
case "alt+u":
|
|
return m.moveSibling(-1)
|
|
case "alt+e":
|
|
return m.moveSibling(1)
|
|
case "n":
|
|
m.jumpToParent()
|
|
case "right", ">", "enter":
|
|
return m.handleEnterOrExpand(key)
|
|
case "left", "<":
|
|
m.jumpToParent()
|
|
case "l":
|
|
m.showAssignedOnly = !m.showAssignedOnly
|
|
m.setStatus(toggleViewLabel(m.showAssignedOnly))
|
|
case "r":
|
|
return m.beginRename()
|
|
case "R":
|
|
return m.beginRenameFromCurrent()
|
|
case "g":
|
|
return m.beginSetGoal()
|
|
case "b":
|
|
return m.beginSetBlocker()
|
|
case "m":
|
|
return m.beginMove()
|
|
case "p":
|
|
return m.beginPromote()
|
|
case "t":
|
|
return m.beginAddThread()
|
|
case "T":
|
|
return m.beginAddThreadTopLevel()
|
|
case "o":
|
|
return m.beginMoreOptions()
|
|
case "d", "x":
|
|
return m.handleDeleteOrRemoveTodo()
|
|
case "D":
|
|
return m.handleContextualD()
|
|
case "a":
|
|
return m.beginAddContextual()
|
|
case "A":
|
|
return m.beginAddGoalTopLevel()
|
|
case "c":
|
|
return m.toggleTodoOrExpand()
|
|
case "y":
|
|
return m.copyNodeTitle()
|
|
case " ":
|
|
return m.toggleTodoOrExpand()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func toggleViewLabel(assigned bool) string {
|
|
if assigned {
|
|
return "showing assigned + unassigned"
|
|
}
|
|
return "showing unassigned only"
|
|
}
|
|
|
|
func (m *goalPanelModel) moveCursor(delta int) {
|
|
if m.list == nil || len(m.list.Nodes) == 0 {
|
|
return
|
|
}
|
|
m.cursor = clampInt(m.cursor+delta, 0, len(m.list.Nodes)-1)
|
|
}
|
|
|
|
func (m *goalPanelModel) jumpToParent() {
|
|
n := m.currentNode()
|
|
if n == nil || n.Depth <= 0 {
|
|
return
|
|
}
|
|
for i := m.cursor - 1; i >= 0; i-- {
|
|
if m.list.Nodes[i].Depth < n.Depth && m.list.Nodes[i].Kind == nodeGoal {
|
|
m.cursor = i
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *goalPanelModel) toggleExpand() {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return
|
|
}
|
|
key := expandKeyForNode(n)
|
|
if key == "" {
|
|
return
|
|
}
|
|
if n.Kind == nodeGoal {
|
|
_, ok := m.expanded[key]
|
|
if ok {
|
|
delete(m.expanded, key)
|
|
} else {
|
|
m.expanded[key] = false
|
|
}
|
|
} else {
|
|
m.expanded[key] = !m.expanded[key]
|
|
}
|
|
m.reload()
|
|
}
|
|
|
|
func expandKeyForNode(n *goalNode) string {
|
|
if n == nil {
|
|
return ""
|
|
}
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
return n.Goal.ID
|
|
case nodeThread:
|
|
return n.Thread.ID
|
|
case nodeDraft:
|
|
return "draft:" + n.Draft.WindowID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *goalPanelModel) handleEnterOrExpand(key string) (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
if key == "enter" {
|
|
return m.gotoOrBind()
|
|
}
|
|
// right / > expands threads/drafts
|
|
if n.Kind == nodeThread || n.Kind == nodeDraft {
|
|
m.toggleExpand()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) gotoOrBind() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
switch n.Kind {
|
|
case nodeThread:
|
|
if strings.TrimSpace(n.Thread.WindowID) == "" {
|
|
// planned thread -> bind to current window
|
|
wid := m.currentWindowID()
|
|
if wid == "" {
|
|
m.setStatus("No current window to bind")
|
|
return m, nil
|
|
}
|
|
if err := bindThreadWindow(n.Thread.ID, wid); err != nil {
|
|
m.setStatus(err.Error())
|
|
return m, nil
|
|
}
|
|
m.setStatus("Bound to window")
|
|
m.reload()
|
|
return m, nil
|
|
}
|
|
return m, m.runFunc(func() error { return focusGoalNode(n) }, true)
|
|
case nodeDraft:
|
|
return m, m.runFunc(func() error { return focusWindow(n.Draft.WindowID) }, true)
|
|
case nodeGoal:
|
|
m.toggleExpand()
|
|
return m, nil
|
|
case nodeTodo:
|
|
return m, m.runFunc(func() error { return focusWindow(n.TodoWindow) }, true)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func focusGoalNode(n *goalNode) error {
|
|
if n == nil || n.Kind != nodeThread {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(n.Thread.WindowID) == "" {
|
|
return nil
|
|
}
|
|
return focusWindow(n.Thread.WindowID)
|
|
}
|
|
|
|
func focusWindow(windowID string) error {
|
|
windowID = strings.TrimSpace(windowID)
|
|
if windowID == "" {
|
|
return nil
|
|
}
|
|
sessionID, _, err := tmuxSessionForWindow(windowID)
|
|
if err == nil && strings.TrimSpace(sessionID) != "" {
|
|
_ = runTmux("switch-client", "-t", strings.TrimSpace(sessionID))
|
|
}
|
|
return selectTmuxWindow(windowID)
|
|
}
|
|
|
|
// ── rename ──────────────────────────────────────────────────
|
|
|
|
func (m *goalPanelModel) beginRename() (tea.Model, tea.Cmd) {
|
|
return m.beginRenameImpl(false)
|
|
}
|
|
|
|
func (m *goalPanelModel) beginRenameFromCurrent() (tea.Model, tea.Cmd) {
|
|
return m.beginRenameImpl(true)
|
|
}
|
|
|
|
func (m *goalPanelModel) beginRenameImpl(prefill bool) (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
m.inputLabel = "Rename goal"
|
|
m.inputTarget = "goal:" + n.Goal.ID
|
|
if prefill {
|
|
m.inputText = []rune(n.Goal.Title)
|
|
} else {
|
|
m.inputText = nil
|
|
}
|
|
case nodeThread:
|
|
m.inputLabel = "Rename thread"
|
|
m.inputTarget = "thread:" + n.Thread.ID
|
|
if prefill {
|
|
m.inputText = []rune(n.Thread.Name)
|
|
} else {
|
|
m.inputText = nil
|
|
}
|
|
case nodeDraft:
|
|
m.inputLabel = "Rename thread"
|
|
m.inputTarget = "draft:" + n.Draft.WindowID
|
|
if prefill {
|
|
m.inputText = []rune(draftDisplayName(n.Draft))
|
|
} else {
|
|
m.inputText = nil
|
|
}
|
|
default:
|
|
return m, nil
|
|
}
|
|
m.inputCursor = len(m.inputText)
|
|
m.inputHint = "Enter to save · Esc to cancel"
|
|
m.mode = goalModeRename
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) submitRename() {
|
|
text := strings.TrimSpace(string(m.inputText))
|
|
target := m.inputTarget
|
|
m.mode = goalModeList
|
|
if text == "" {
|
|
m.setStatus("Name required")
|
|
return
|
|
}
|
|
if strings.HasPrefix(target, "goal:") {
|
|
if err := renameGoal(strings.TrimPrefix(target, "goal:"), text); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Renamed")
|
|
} else if strings.HasPrefix(target, "thread:") {
|
|
if err := renameThread(strings.TrimPrefix(target, "thread:"), text); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Renamed")
|
|
} else if strings.HasPrefix(target, "draft:") {
|
|
wid := strings.TrimPrefix(target, "draft:")
|
|
if _, err := promoteDraftToThread(wid, text, "", ""); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Renamed")
|
|
}
|
|
m.reload()
|
|
}
|
|
|
|
// ── add goal / thread / todo ────────────────────────────────
|
|
|
|
func (m *goalPanelModel) currentGoalID() string {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return ""
|
|
}
|
|
if n.Kind == nodeGoal {
|
|
return n.Goal.ID
|
|
}
|
|
for i := m.cursor - 1; i >= 0; i-- {
|
|
node := m.list.Nodes[i]
|
|
if node.Kind == nodeGoal && node.Depth < n.Depth {
|
|
return node.Goal.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *goalPanelModel) beginAddGoal() (tea.Model, tea.Cmd) {
|
|
goalID := m.currentGoalID()
|
|
m.inputLabel = "New sub-goal"
|
|
if goalID == "" {
|
|
m.inputLabel = "New goal"
|
|
}
|
|
m.inputTarget = goalID
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
m.inputHint = "Enter to create · Esc to cancel"
|
|
m.mode = goalModeAddGoal
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) beginAddGoalTopLevel() (tea.Model, tea.Cmd) {
|
|
m.inputLabel = "New goal"
|
|
m.inputTarget = ""
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
m.inputHint = "Enter to create · Esc to cancel"
|
|
m.mode = goalModeAddGoal
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) beginAddContextual() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m.beginAddGoalTopLevel()
|
|
}
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
return m.beginAddGoal()
|
|
case nodeThread, nodeDraft, nodeTodo:
|
|
return m.beginAddTodo()
|
|
default:
|
|
return m.beginAddGoalTopLevel()
|
|
}
|
|
}
|
|
|
|
func (m *goalPanelModel) submitAddGoal() {
|
|
title := strings.TrimSpace(string(m.inputText))
|
|
if m.promoteCreateGoal {
|
|
m.promoteCreateGoal = false
|
|
if title == "" {
|
|
m.mode = goalModePromoteGoal
|
|
return
|
|
}
|
|
goal, err := addGoal(title, m.inputTarget)
|
|
if err != nil {
|
|
m.setStatus(err.Error())
|
|
m.mode = goalModePromoteGoal
|
|
return
|
|
}
|
|
m.setStatus("Goal created")
|
|
m.reload()
|
|
m.mode = goalModePromoteGoal
|
|
m.promoteNoGoal = false
|
|
if goal != nil {
|
|
if idx := m.list.indexOfGoal(goal.ID); idx >= 0 {
|
|
m.cursor = idx
|
|
}
|
|
}
|
|
return
|
|
}
|
|
m.mode = goalModeList
|
|
if title == "" {
|
|
m.setStatus("Title required")
|
|
return
|
|
}
|
|
if _, err := addGoal(title, m.inputTarget); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Goal created")
|
|
m.reload()
|
|
}
|
|
|
|
func (m *goalPanelModel) beginAddThread() (tea.Model, tea.Cmd) {
|
|
goalID := m.currentGoalID()
|
|
m.inputLabel = "New thread"
|
|
m.inputTarget = goalID
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
m.inputHint = "Enter to create · Esc to cancel"
|
|
m.mode = goalModeAddThread
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) beginAddThreadTopLevel() (tea.Model, tea.Cmd) {
|
|
m.inputLabel = "New thread"
|
|
m.inputTarget = ""
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
m.inputHint = "Enter to create · Esc to cancel"
|
|
m.mode = goalModeAddThread
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) submitAddThread() {
|
|
name := strings.TrimSpace(string(m.inputText))
|
|
m.mode = goalModeList
|
|
if name == "" {
|
|
m.setStatus("Name required")
|
|
return
|
|
}
|
|
if _, err := addManualThread(name, m.inputTarget, nil); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Thread created")
|
|
m.reload()
|
|
}
|
|
|
|
func (m *goalPanelModel) beginAddTodo() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
windowID := ""
|
|
switch n.Kind {
|
|
case nodeThread:
|
|
windowID = n.Thread.WindowID
|
|
case nodeDraft:
|
|
windowID = n.Draft.WindowID
|
|
case nodeTodo:
|
|
windowID = n.TodoWindow
|
|
default:
|
|
m.setStatus("Select a thread first")
|
|
return m, nil
|
|
}
|
|
if strings.TrimSpace(windowID) == "" {
|
|
m.setStatus("No window bound for todos")
|
|
return m, nil
|
|
}
|
|
m.inputTarget = windowID
|
|
m.inputLabel = "New todo"
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
m.inputHint = "Enter to add · Esc to cancel"
|
|
m.mode = goalModeAddTodo
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) submitAddTodo() {
|
|
title := strings.TrimSpace(string(m.inputText))
|
|
m.mode = goalModeList
|
|
if title == "" {
|
|
m.setStatus("Todo title required")
|
|
return
|
|
}
|
|
if err := addTmuxTodo(todoScopeWindow, m.inputTarget, title); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Todo added")
|
|
m.reload()
|
|
}
|
|
|
|
// ── text input shared ───────────────────────────────────────
|
|
|
|
func (m *goalPanelModel) updateTextInput(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" {
|
|
if m.promoteCreateGoal {
|
|
m.promoteCreateGoal = false
|
|
m.mode = goalModePromoteGoal
|
|
} else {
|
|
m.mode = goalModeList
|
|
}
|
|
return m, nil
|
|
}
|
|
if key == "ctrl+c" {
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
return m, nil
|
|
}
|
|
if key == "enter" && m.mode != goalModeAddTodo {
|
|
switch m.mode {
|
|
case goalModeRename:
|
|
m.submitRename()
|
|
case goalModeAddGoal:
|
|
m.submitAddGoal()
|
|
case goalModeAddThread:
|
|
m.submitAddThread()
|
|
}
|
|
return m, nil
|
|
}
|
|
if key == "enter" && m.mode == goalModeAddTodo {
|
|
m.submitAddTodo()
|
|
return m, nil
|
|
}
|
|
applyPaletteInputKey(key, &m.inputText, &m.inputCursor, false)
|
|
return m, nil
|
|
}
|
|
|
|
// ── pickers (set-goal, set-blocker, more-options) ───────────
|
|
|
|
func (m *goalPanelModel) beginSetGoal() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
tid := threadIDForNode(n)
|
|
if tid == "" {
|
|
m.setStatus("Select a thread first")
|
|
return m, nil
|
|
}
|
|
store, err := loadGoalStore()
|
|
if err != nil {
|
|
m.setStatus(err.Error())
|
|
return m, nil
|
|
}
|
|
items := []goalPickerItem{{ID: "", Label: "— unassign —", Subtitle: "no goal"}}
|
|
items = appendGoalPickerItems(items, store, "", 0)
|
|
m.picker = items
|
|
m.pickerFilter = nil
|
|
m.pickerCursor = 0
|
|
m.pickerOffset = 0
|
|
m.pickerKind = "goal"
|
|
m.pickerSelected = tid
|
|
m.inputCursor = 0
|
|
m.mode = goalModeSetGoal
|
|
return m, nil
|
|
}
|
|
|
|
func appendGoalPickerItems(items []goalPickerItem, store *GoalStore, parentID string, depth int) []goalPickerItem {
|
|
var children []*Goal
|
|
for _, g := range store.Goals {
|
|
if strings.TrimSpace(g.ParentID) == strings.TrimSpace(parentID) {
|
|
children = append(children, g)
|
|
}
|
|
}
|
|
sortGoalsByOrder(children)
|
|
for _, g := range children {
|
|
indent := strings.Repeat(" ", depth)
|
|
items = append(items, goalPickerItem{ID: g.ID, Label: indent + g.Title, Subtitle: g.ID})
|
|
items = appendGoalPickerItems(items, store, g.ID, depth+1)
|
|
}
|
|
return items
|
|
}
|
|
|
|
func sortGoalsByOrder(goals []*Goal) {
|
|
for i := 1; i < len(goals); i++ {
|
|
for j := i; j > 0 && goals[j-1].Order > goals[j].Order; j-- {
|
|
goals[j-1], goals[j] = goals[j], goals[j-1]
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *goalPanelModel) submitSetGoal() {
|
|
items := m.filteredPickerItems()
|
|
if len(items) == 0 || m.pickerCursor >= len(items) {
|
|
m.mode = goalModeList
|
|
return
|
|
}
|
|
pick := items[m.pickerCursor]
|
|
m.mode = goalModeList
|
|
if err := setThreadGoal(m.pickerSelected, pick.ID); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Goal set")
|
|
m.reload()
|
|
}
|
|
|
|
func (m *goalPanelModel) beginSetBlocker() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
tid := threadIDForNode(n)
|
|
if tid == "" {
|
|
m.setStatus("Select a thread first")
|
|
return m, nil
|
|
}
|
|
store, err := loadGoalStore()
|
|
if err != nil {
|
|
m.setStatus(err.Error())
|
|
return m, nil
|
|
}
|
|
current := store.Threads[tid]
|
|
items := []goalPickerItem{{ID: "", Label: "— clear blockers —", Subtitle: "remove all"}}
|
|
ids := make([]string, 0, len(store.Threads))
|
|
for id := range store.Threads {
|
|
if id != tid {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
for _, id := range ids {
|
|
t := store.Threads[id]
|
|
if t == nil {
|
|
continue
|
|
}
|
|
items = append(items, goalPickerItem{ID: t.ID, Label: threadDisplayName(t, ""), Subtitle: t.ID})
|
|
}
|
|
m.picker = items
|
|
m.pickerFilter = nil
|
|
m.pickerCursor = 0
|
|
m.pickerOffset = 0
|
|
m.pickerKind = "blocker"
|
|
m.pickerSelected = tid
|
|
m.inputCursor = 0
|
|
m.mode = goalModeSetBlocker
|
|
_ = current
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) submitSetBlocker() {
|
|
items := m.filteredPickerItems()
|
|
if len(items) == 0 || m.pickerCursor >= len(items) {
|
|
m.mode = goalModeList
|
|
return
|
|
}
|
|
pick := items[m.pickerCursor]
|
|
m.mode = goalModeList
|
|
if pick.ID == "" {
|
|
if err := setThreadBlocker(m.pickerSelected, nil); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Blockers cleared")
|
|
} else {
|
|
if err := setThreadBlocker(m.pickerSelected, []string{pick.ID}); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Blocker set")
|
|
}
|
|
m.reload()
|
|
}
|
|
|
|
func (m *goalPanelModel) beginMoreOptions() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
m.picker = moreOptionsForNode(n)
|
|
m.pickerFilter = nil
|
|
m.pickerCursor = 0
|
|
m.pickerOffset = 0
|
|
m.pickerKind = "action"
|
|
m.pickerSelected = ""
|
|
m.inputCursor = 0
|
|
m.mode = goalModeMoreOptions
|
|
return m, nil
|
|
}
|
|
|
|
func moreOptionsForNode(n *goalNode) []goalPickerItem {
|
|
base := []goalPickerItem{
|
|
{ID: "add-goal", Label: "Add goal"},
|
|
{ID: "add-thread", Label: "Add thread"},
|
|
}
|
|
if n == nil {
|
|
return base
|
|
}
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
base = append(base,
|
|
goalPickerItem{ID: "add-goal-child", Label: "Add sub-goal"},
|
|
goalPickerItem{ID: "rename", Label: "Rename goal"},
|
|
goalPickerItem{ID: "delete", Label: "Delete goal (cascade)"},
|
|
)
|
|
case nodeThread:
|
|
base = append(base,
|
|
goalPickerItem{ID: "rename", Label: "Rename thread"},
|
|
goalPickerItem{ID: "set-goal", Label: "Set goal"},
|
|
goalPickerItem{ID: "blocker", Label: "Set blocker"},
|
|
goalPickerItem{ID: "done", Label: "Toggle done"},
|
|
)
|
|
if strings.TrimSpace(n.Thread.WindowID) == "" {
|
|
base = append(base, goalPickerItem{ID: "bind-current", Label: "Bind to current window"})
|
|
} else {
|
|
base = append(base, goalPickerItem{ID: "unbind", Label: "Unbind window"})
|
|
base = append(base, goalPickerItem{ID: "goto", Label: "Go to window"})
|
|
}
|
|
base = append(base, goalPickerItem{ID: "merge", Label: "Merge into another thread"})
|
|
base = append(base, goalPickerItem{ID: "delete", Label: "Delete thread (window closes otherwise)"})
|
|
case nodeDraft:
|
|
base = append(base,
|
|
goalPickerItem{ID: "rename", Label: "Name / promote"},
|
|
goalPickerItem{ID: "set-goal", Label: "Set goal"},
|
|
goalPickerItem{ID: "blocker", Label: "Set blocker"},
|
|
goalPickerItem{ID: "goto", Label: "Go to window"},
|
|
)
|
|
case nodeTodo:
|
|
base = append(base,
|
|
goalPickerItem{ID: "toggle-todo", Label: "Toggle todo"},
|
|
goalPickerItem{ID: "delete-todo", Label: "Delete todo"},
|
|
)
|
|
}
|
|
return base
|
|
}
|
|
|
|
func (m *goalPanelModel) submitMoreOptions() {
|
|
items := m.filteredPickerItems()
|
|
if len(items) == 0 || m.pickerCursor >= len(items) {
|
|
m.mode = goalModeList
|
|
return
|
|
}
|
|
action := items[m.pickerCursor].ID
|
|
m.mode = goalModeList
|
|
m.applyMoreOption(action)
|
|
}
|
|
|
|
func (m *goalPanelModel) applyMoreOption(action string) {
|
|
n := m.currentNode()
|
|
switch action {
|
|
case "add-goal":
|
|
m.beginAddGoal()
|
|
case "add-goal-child":
|
|
if n != nil && n.Kind == nodeGoal {
|
|
m.inputTarget = n.Goal.ID
|
|
m.inputLabel = "New sub-goal"
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
m.inputHint = "Enter to create · Esc to cancel"
|
|
m.mode = goalModeAddGoal
|
|
}
|
|
case "add-thread":
|
|
m.beginAddThread()
|
|
case "rename":
|
|
m.beginRename()
|
|
case "set-goal":
|
|
m.beginSetGoal()
|
|
case "blocker":
|
|
m.beginSetBlocker()
|
|
case "done":
|
|
if n != nil && n.Kind == nodeThread {
|
|
m.confirmThread = n.Thread.ID
|
|
m.mode = goalModeConfirmDone
|
|
}
|
|
case "delete":
|
|
m.handleContextualD()
|
|
case "bind-current":
|
|
if n != nil && n.Kind == nodeThread {
|
|
wid := m.currentWindowID()
|
|
if wid != "" {
|
|
if err := bindThreadWindow(n.Thread.ID, wid); err != nil {
|
|
m.setStatus(err.Error())
|
|
} else {
|
|
m.setStatus("Bound")
|
|
m.reload()
|
|
}
|
|
}
|
|
}
|
|
case "unbind":
|
|
if n != nil && n.Kind == nodeThread {
|
|
if err := unbindThreadWindow(n.Thread.ID); err != nil {
|
|
m.setStatus(err.Error())
|
|
} else {
|
|
m.setStatus("unbound")
|
|
m.reload()
|
|
}
|
|
}
|
|
case "goto":
|
|
m.gotoOrBind()
|
|
case "merge":
|
|
m.beginSetBlocker() // reuse picker skeleton; merge handled at submit
|
|
_ = n
|
|
case "toggle-todo":
|
|
if n != nil && n.Kind == nodeTodo {
|
|
_ = toggleTmuxTodoByIndex(todoScopeWindow, n.TodoWindow, n.TodoIndex)
|
|
m.reload()
|
|
}
|
|
case "delete-todo":
|
|
if n != nil && n.Kind == nodeTodo {
|
|
_ = deleteTmuxTodoByIndex(todoScopeWindow, n.TodoWindow, n.TodoIndex)
|
|
m.reload()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *goalPanelModel) updatePicker(key string) (tea.Model, tea.Cmd) {
|
|
items := m.filteredPickerItems()
|
|
switch key {
|
|
case "esc":
|
|
m.mode = goalModeList
|
|
return m, nil
|
|
case "u", "up":
|
|
if len(items) > 0 {
|
|
m.pickerCursor = clampInt(m.pickerCursor-1, 0, len(items)-1)
|
|
}
|
|
case "e", "down":
|
|
if len(items) > 0 {
|
|
m.pickerCursor = clampInt(m.pickerCursor+1, 0, len(items)-1)
|
|
}
|
|
case "enter":
|
|
switch m.mode {
|
|
case goalModeSetGoal:
|
|
m.submitSetGoal()
|
|
case goalModeSetBlocker:
|
|
m.submitSetBlocker()
|
|
case goalModeMoreOptions:
|
|
m.submitMoreOptions()
|
|
case goalModePromoteGoal:
|
|
m.submitPromoteGoal()
|
|
}
|
|
return m, nil
|
|
default:
|
|
applyPaletteInputKey(key, &m.pickerFilter, &m.inputCursor, false)
|
|
m.pickerCursor = clampInt(m.pickerCursor, 0, maxInt(0, len(m.filteredPickerItems())-1))
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) filteredPickerItems() []goalPickerItem {
|
|
needle := strings.ToLower(strings.TrimSpace(string(m.pickerFilter)))
|
|
if needle == "" {
|
|
return m.picker
|
|
}
|
|
var out []goalPickerItem
|
|
for _, it := range m.picker {
|
|
if strings.Contains(strings.ToLower(it.Label), needle) || strings.Contains(strings.ToLower(it.Subtitle), needle) {
|
|
out = append(out, it)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── promote form ────────────────────────────────────────────
|
|
|
|
func (m *goalPanelModel) beginPromote() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil || n.Kind != nodeDraft {
|
|
m.setStatus("Select a draft to promote")
|
|
return m, nil
|
|
}
|
|
m.promoteWindow = n.Draft.WindowID
|
|
m.promoteName = []rune(draftDisplayName(n.Draft))
|
|
m.promoteCursor = m.cursor
|
|
m.mode = goalModePromoteGoal
|
|
m.cursor = 0
|
|
m.promoteNoGoal = false
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) promoteGoalIndices() []int {
|
|
var indices []int
|
|
if m.list == nil {
|
|
return indices
|
|
}
|
|
for i, n := range m.list.Nodes {
|
|
if n.Kind == nodeGoal {
|
|
indices = append(indices, i)
|
|
}
|
|
}
|
|
return indices
|
|
}
|
|
|
|
func (m *goalPanelModel) promoteGoalCursorIndex(numGoals int) int {
|
|
indices := m.promoteGoalIndices()
|
|
for i, idx := range indices {
|
|
if idx == m.cursor {
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *goalPanelModel) beginAddGoalFromPromote() (tea.Model, tea.Cmd) {
|
|
parentID := ""
|
|
if !m.promoteNoGoal {
|
|
n := m.currentNode()
|
|
if n != nil && n.Kind == nodeGoal {
|
|
parentID = n.Goal.ID
|
|
}
|
|
}
|
|
m.inputLabel = "New sub-goal"
|
|
if parentID == "" {
|
|
m.inputLabel = "New goal"
|
|
}
|
|
m.inputTarget = parentID
|
|
m.inputText = nil
|
|
m.inputCursor = 0
|
|
m.inputHint = "Enter to create · Esc to cancel"
|
|
m.mode = goalModeAddGoal
|
|
m.promoteCreateGoal = true
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) updatePromoteGoal(key string) (tea.Model, tea.Cmd) {
|
|
indices := m.promoteGoalIndices()
|
|
numGoals := len(indices)
|
|
switch key {
|
|
case "esc":
|
|
m.mode = goalModeList
|
|
m.cursor = m.promoteCursor
|
|
return m, nil
|
|
case "u", "up":
|
|
if m.promoteNoGoal {
|
|
if numGoals > 0 {
|
|
m.cursor = indices[numGoals-1]
|
|
m.promoteNoGoal = false
|
|
}
|
|
} else {
|
|
cur := m.promoteGoalCursorIndex(numGoals)
|
|
if cur > 0 {
|
|
m.cursor = indices[cur-1]
|
|
}
|
|
}
|
|
case "e", "down":
|
|
if m.promoteNoGoal {
|
|
// stay at no goal
|
|
} else {
|
|
cur := m.promoteGoalCursorIndex(numGoals)
|
|
if cur < numGoals-1 {
|
|
m.cursor = indices[cur+1]
|
|
} else {
|
|
m.promoteNoGoal = true
|
|
}
|
|
}
|
|
case "enter", "m":
|
|
m.submitPromoteGoal()
|
|
case "a":
|
|
return m.beginAddGoalFromPromote()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) submitPromoteGoal() {
|
|
name := strings.TrimSpace(string(m.promoteName))
|
|
m.mode = goalModeList
|
|
var goalID string
|
|
if !m.promoteNoGoal {
|
|
n := m.currentNode()
|
|
if n != nil && n.Kind == nodeGoal {
|
|
goalID = n.Goal.ID
|
|
}
|
|
}
|
|
if _, err := promoteDraftToThread(m.promoteWindow, name, goalID, ""); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
m.setStatus("Promoted")
|
|
m.reload()
|
|
}
|
|
|
|
func (m *goalPanelModel) updatePromote(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "esc":
|
|
m.mode = goalModeList
|
|
return m, nil
|
|
case "tab":
|
|
m.promoteTab = 1 - m.promoteTab
|
|
return m, nil
|
|
case "enter":
|
|
m.submitPromote()
|
|
return m, nil
|
|
}
|
|
applyPaletteInputKey(key, &m.promoteName, &m.promoteCursor, false)
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) submitPromote() {
|
|
name := strings.TrimSpace(string(m.promoteName))
|
|
m.mode = goalModeList
|
|
if m.promoteTab == 1 && m.promoteBind != "" {
|
|
if _, err := promoteDraftToThread(m.promoteWindow, name, "", m.promoteBind); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
} else {
|
|
if _, err := promoteDraftToThread(m.promoteWindow, name, m.promoteGoal, ""); err != nil {
|
|
m.setStatus(err.Error())
|
|
return
|
|
}
|
|
}
|
|
m.setStatus("Promoted")
|
|
m.reload()
|
|
}
|
|
|
|
// ── move mode ───────────────────────────────────────────────
|
|
|
|
// moveSibling reorders the selected node among its siblings (same parent,
|
|
// same depth) by one slot, without changing parent/depth. No-op at the
|
|
// first/last sibling boundary and for drafts (ordered by recency).
|
|
func (m *goalPanelModel) moveSibling(delta int) (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
var parentID, nodeID string
|
|
var isGoal bool
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
parentID = strings.TrimSpace(n.Goal.ParentID)
|
|
nodeID = n.Goal.ID
|
|
isGoal = true
|
|
case nodeThread:
|
|
parentID = strings.TrimSpace(n.Thread.GoalID)
|
|
nodeID = n.Thread.ID
|
|
isGoal = false
|
|
default:
|
|
return m, nil
|
|
}
|
|
if !reorderChildSibling(parentID, nodeID, isGoal, delta) {
|
|
return m, nil
|
|
}
|
|
m.reload()
|
|
if isGoal {
|
|
if idx := m.list.indexOfGoal(nodeID); idx >= 0 {
|
|
m.cursor = idx
|
|
}
|
|
} else {
|
|
if idx := m.list.indexOfThread(nodeID); idx >= 0 {
|
|
m.cursor = idx
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) beginMove() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
if n.Kind == nodeGoal {
|
|
idx := m.list.indexOfGoal(n.Goal.ID)
|
|
if idx < 0 {
|
|
return m, nil
|
|
}
|
|
m.moveGhost = ghostState{GoalID: n.Goal.ID, IsGoal: true, Pos: idx, Depth: n.Depth}
|
|
m.moveName = n.Goal.Title
|
|
m.mode = goalModeMove
|
|
return m, nil
|
|
}
|
|
if n.Kind == nodeThread {
|
|
idx := m.list.indexOfThread(n.Thread.ID)
|
|
if idx < 0 {
|
|
return m, nil
|
|
}
|
|
m.moveGhost = ghostState{ThreadID: n.Thread.ID, Pos: idx, Depth: n.Depth}
|
|
m.moveName = threadDisplayName(n.Thread, "")
|
|
m.mode = goalModeMove
|
|
return m, nil
|
|
}
|
|
if n.Kind == nodeDraft {
|
|
idx := -1
|
|
for i, node := range m.list.Nodes {
|
|
if node.Kind == nodeDraft && node.Draft != nil && node.Draft.WindowID == n.Draft.WindowID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return m, nil
|
|
}
|
|
m.moveGhost = ghostState{ThreadID: "draft:" + n.Draft.WindowID, Pos: idx, Depth: n.Depth, IsDraft: true}
|
|
m.moveName = draftDisplayName(n.Draft)
|
|
m.mode = goalModeMove
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) updateMove(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "esc":
|
|
m.mode = goalModeList
|
|
return m, nil
|
|
case "enter", "m":
|
|
return m.commitMove()
|
|
case "u", "up":
|
|
m.moveGhost = m.clampGoalGhost(m.moveGhostUp(), -1)
|
|
case "e", "down":
|
|
m.moveGhost = m.clampGoalGhost(m.moveGhostDown(), +1)
|
|
case "n":
|
|
if m.moveGhost.Depth > 0 {
|
|
m.moveGhost = ghostState{ThreadID: m.moveGhost.ThreadID, GoalID: m.moveGhost.GoalID, IsGoal: m.moveGhost.IsGoal, Pos: m.moveGhost.Pos, Depth: m.moveGhost.Depth - 1, IsDraft: m.moveGhost.IsDraft}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) clampGoalGhost(g ghostState, dir int) ghostState {
|
|
if !m.moveGhost.IsGoal {
|
|
return g
|
|
}
|
|
if g.Pos >= m.separatorIndex() {
|
|
return m.moveGhost
|
|
}
|
|
if g.Pos == m.moveGhost.Pos && g.Depth == m.moveGhost.Depth && g.After == m.moveGhost.After {
|
|
return g
|
|
}
|
|
movedIdx := m.ghostMovedIdx()
|
|
if movedIdx < 0 {
|
|
return g
|
|
}
|
|
subtreeEnd := m.ghostSubtreeEnd(movedIdx)
|
|
if g.Pos >= movedIdx && g.Pos < subtreeEnd {
|
|
g.After = false
|
|
g.Before = false
|
|
if dir < 0 && movedIdx > 0 {
|
|
g.Pos = movedIdx - 1
|
|
} else {
|
|
g.Pos = subtreeEnd
|
|
if g.Pos >= len(m.list.Nodes) {
|
|
g.Pos = len(m.list.Nodes) - 1
|
|
if g.Depth > 0 {
|
|
g.Depth--
|
|
}
|
|
}
|
|
if g.Pos >= m.separatorIndex() {
|
|
g.Pos = movedIdx
|
|
g.Depth = 0
|
|
}
|
|
}
|
|
}
|
|
return g
|
|
}
|
|
|
|
func (m *goalPanelModel) separatorIndex() int {
|
|
for i, n := range m.list.Nodes {
|
|
if n.Kind == nodeDraft {
|
|
return i
|
|
}
|
|
}
|
|
return len(m.list.Nodes)
|
|
}
|
|
|
|
func (m *goalPanelModel) moveGhostUp() ghostState {
|
|
sep := m.separatorIndex()
|
|
g := m.moveGhost
|
|
if g.IsDraft && g.Pos >= sep {
|
|
return ghostState{ThreadID: g.ThreadID, GoalID: g.GoalID, IsGoal: g.IsGoal, Pos: sep - 1, Depth: 0, IsDraft: true, After: true}
|
|
}
|
|
wasAfter := g.After
|
|
if g.After {
|
|
node := m.list.Nodes[g.Pos]
|
|
if node.Depth == g.Depth && node.Kind != nodeGoal {
|
|
return ghostState{ThreadID: g.ThreadID, GoalID: g.GoalID, IsGoal: g.IsGoal, Pos: g.Pos, Depth: g.Depth, IsDraft: g.IsDraft, Before: true}
|
|
}
|
|
g.Pos = g.Pos + 1
|
|
g.After = false
|
|
}
|
|
movedIdx := m.ghostMovedIdx()
|
|
if movedIdx >= 0 && g.Pos == movedIdx && g.Depth < m.list.Nodes[movedIdx].Depth {
|
|
return ghostState{ThreadID: g.ThreadID, GoalID: g.GoalID, IsGoal: g.IsGoal, Pos: g.Pos, Depth: g.Depth + 1, IsDraft: g.IsDraft}
|
|
}
|
|
if !wasAfter && g.Pos >= 0 && g.Pos < len(m.list.Nodes) {
|
|
node := m.list.Nodes[g.Pos]
|
|
if node.Kind == nodeGoal && g.Depth == node.Depth+1 {
|
|
return ghostState{ThreadID: g.ThreadID, GoalID: g.GoalID, IsGoal: g.IsGoal, Pos: g.Pos, Depth: node.Depth, IsDraft: g.IsDraft}
|
|
}
|
|
}
|
|
return moveGhostUp(m.list, g)
|
|
}
|
|
|
|
func (m *goalPanelModel) ghostMovedIdx() int {
|
|
if m.moveGhost.IsGoal {
|
|
for i, n := range m.list.Nodes {
|
|
if n.Kind == nodeGoal && n.Goal != nil && n.Goal.ID == m.moveGhost.GoalID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
if m.moveGhost.IsDraft {
|
|
wid := strings.TrimPrefix(m.moveGhost.ThreadID, "draft:")
|
|
for i, n := range m.list.Nodes {
|
|
if n.Kind == nodeDraft && n.Draft != nil && n.Draft.WindowID == wid {
|
|
return i
|
|
}
|
|
}
|
|
} else {
|
|
for i, n := range m.list.Nodes {
|
|
if n.Kind == nodeThread && n.Thread != nil && n.Thread.ID == m.moveGhost.ThreadID {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (m *goalPanelModel) ghostSubtreeEnd(startIdx int) int {
|
|
if startIdx < 0 || startIdx >= len(m.list.Nodes) {
|
|
return startIdx + 1
|
|
}
|
|
depth := m.list.Nodes[startIdx].Depth
|
|
end := startIdx + 1
|
|
for end < len(m.list.Nodes) && m.list.Nodes[end].Depth > depth {
|
|
end++
|
|
}
|
|
return end
|
|
}
|
|
|
|
func (m *goalPanelModel) moveGhostDown() ghostState {
|
|
sep := m.separatorIndex()
|
|
g := m.moveGhost
|
|
if g.IsDraft && g.Pos >= sep {
|
|
return g
|
|
}
|
|
isDraftAboveSep := g.IsDraft && g.Pos < sep
|
|
wasAfter := g.After
|
|
g.After = false
|
|
g.Before = false
|
|
movedIdx := m.ghostMovedIdx()
|
|
if !wasAfter && movedIdx >= 0 && g.Pos < movedIdx && g.Pos < len(m.list.Nodes) {
|
|
node := m.list.Nodes[g.Pos]
|
|
if node.Depth == g.Depth && node.Kind == nodeGoal {
|
|
return applyDownTarget(m.list, g, g.Pos)
|
|
}
|
|
}
|
|
if !isDraftAboveSep && movedIdx >= 0 && g.Pos+1 == movedIdx {
|
|
g.Pos = movedIdx
|
|
}
|
|
if isDraftAboveSep && movedIdx >= 0 && g.Pos+1 == movedIdx {
|
|
g.Pos = movedIdx
|
|
}
|
|
next := moveGhostDown(m.list, g)
|
|
if isDraftAboveSep {
|
|
if next.Pos < sep {
|
|
next.IsDraft = true
|
|
return next
|
|
}
|
|
origIdx := m.ghostMovedIdx()
|
|
if origIdx >= 0 {
|
|
return ghostState{ThreadID: m.moveGhost.ThreadID, Pos: origIdx, Depth: 0, IsDraft: true}
|
|
}
|
|
return ghostState{ThreadID: m.moveGhost.ThreadID, Pos: sep, Depth: 0, IsDraft: true}
|
|
}
|
|
if !m.moveGhost.IsDraft && next.Pos >= sep && m.moveGhost.Pos < sep {
|
|
return ghostState{ThreadID: m.moveGhost.ThreadID, Pos: sep, Depth: 0}
|
|
}
|
|
return next
|
|
}
|
|
|
|
func (m *goalPanelModel) commitMove() (tea.Model, tea.Cmd) {
|
|
g := m.moveGhost
|
|
if g.IsGoal {
|
|
if err := applyGoalMove(g.GoalID, m.list, g); err != nil {
|
|
m.setStatus(err.Error())
|
|
m.mode = goalModeList
|
|
return m, nil
|
|
}
|
|
m.mode = goalModeList
|
|
m.setStatus("Goal moved")
|
|
m.reload()
|
|
if idx := m.list.indexOfGoal(g.GoalID); idx >= 0 {
|
|
m.cursor = idx
|
|
}
|
|
return m, nil
|
|
}
|
|
sep := m.separatorIndex()
|
|
if g.IsDraft && g.Pos < sep {
|
|
wid := strings.TrimPrefix(g.ThreadID, "draft:")
|
|
if _, err := promoteDraftToThread(wid, m.moveName, "", ""); err != nil {
|
|
m.setStatus(err.Error())
|
|
m.mode = goalModeList
|
|
return m, nil
|
|
}
|
|
list, _ := buildDisplayList(m.expanded)
|
|
m.list = list
|
|
var newThreadID string
|
|
for _, n := range list.Nodes {
|
|
if n.Kind == nodeThread && n.Thread != nil && strings.TrimSpace(n.Thread.WindowID) == wid {
|
|
newThreadID = n.Thread.ID
|
|
break
|
|
}
|
|
}
|
|
if newThreadID != "" {
|
|
ghostAfter := ghostState{ThreadID: newThreadID, Pos: g.Pos, Depth: g.Depth}
|
|
if ghostAfter.Pos >= len(list.Nodes) {
|
|
ghostAfter.Pos = len(list.Nodes) - 1
|
|
}
|
|
if err := applyGhostMove(newThreadID, list, ghostAfter); err != nil {
|
|
m.setStatus(err.Error())
|
|
m.mode = goalModeList
|
|
m.reload()
|
|
return m, nil
|
|
}
|
|
}
|
|
m.setStatus("Promoted")
|
|
m.mode = goalModeList
|
|
m.reload()
|
|
if newThreadID != "" {
|
|
if idx := m.list.indexOfThread(newThreadID); idx >= 0 {
|
|
m.cursor = idx
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
if !g.IsDraft && g.Pos >= sep {
|
|
wid := ""
|
|
if store, err := loadGoalStore(); err == nil && store != nil {
|
|
if t := store.Threads[strings.TrimSpace(g.ThreadID)]; t != nil {
|
|
wid = strings.TrimSpace(t.WindowID)
|
|
}
|
|
}
|
|
if err := deleteThread(g.ThreadID); err != nil {
|
|
m.setStatus(err.Error())
|
|
} else {
|
|
m.setStatus("Demoted to draft")
|
|
}
|
|
m.mode = goalModeList
|
|
m.reload()
|
|
if wid != "" {
|
|
for i, n := range m.list.Nodes {
|
|
if n.Kind == nodeDraft && n.Draft != nil && strings.TrimSpace(n.Draft.WindowID) == wid {
|
|
m.cursor = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
if err := applyGhostMove(g.ThreadID, m.list, g); err != nil {
|
|
m.setStatus(err.Error())
|
|
m.mode = goalModeList
|
|
return m, nil
|
|
}
|
|
m.mode = goalModeList
|
|
m.setStatus("Moved")
|
|
m.reload()
|
|
if idx := m.list.indexOfThread(g.ThreadID); idx >= 0 {
|
|
m.cursor = idx
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ── contextual D ────────────────────────────────────────────
|
|
|
|
func (m *goalPanelModel) handleContextualD() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
m.confirmGoal = n.Goal.ID
|
|
m.mode = goalModeConfirmDeleteGoal
|
|
case nodeThread:
|
|
m.confirmThread = n.Thread.ID
|
|
if strings.TrimSpace(n.Thread.WindowID) == "" {
|
|
m.mode = goalModeConfirmDeletePlanned
|
|
} else {
|
|
m.mode = goalModeConfirmDone
|
|
}
|
|
case nodeTodo:
|
|
_ = deleteTmuxTodoByIndex(todoScopeWindow, n.TodoWindow, n.TodoIndex)
|
|
m.reload()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) updateConfirmDeleteGoal(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "y":
|
|
if err := deleteGoalCascade(m.confirmGoal); err != nil {
|
|
m.setStatus(err.Error())
|
|
} else {
|
|
m.setStatus("Goal deleted")
|
|
}
|
|
m.mode = goalModeList
|
|
m.reload()
|
|
case "n", "esc":
|
|
m.mode = goalModeList
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) updateConfirmDone(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "y", "enter":
|
|
t, _ := loadGoalStore()
|
|
if t != nil {
|
|
if th := t.Threads[m.confirmThread]; th != nil {
|
|
_ = setThreadDone(m.confirmThread, !th.Done)
|
|
}
|
|
}
|
|
m.mode = goalModeList
|
|
m.reload()
|
|
case "n", "esc":
|
|
m.mode = goalModeList
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) updateConfirmDeletePlanned(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "y", "enter":
|
|
_ = deleteThread(m.confirmThread)
|
|
m.mode = goalModeList
|
|
m.reload()
|
|
case "n", "esc":
|
|
m.mode = goalModeList
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) updateConfirmMerge(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "y", "enter":
|
|
_ = mergeThreads(m.confirmMergeSrc, m.confirmMergeTgt)
|
|
m.mode = goalModeList
|
|
m.reload()
|
|
case "n", "esc":
|
|
m.mode = goalModeList
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) handleDeleteOrRemoveTodo() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
if n.Kind == nodeTodo {
|
|
_ = deleteTmuxTodoByIndex(todoScopeWindow, n.TodoWindow, n.TodoIndex)
|
|
m.reload()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) toggleTodoOrExpand() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
if n.Kind == nodeTodo {
|
|
_ = toggleTmuxTodoByIndex(todoScopeWindow, n.TodoWindow, n.TodoIndex)
|
|
m.reload()
|
|
return m, nil
|
|
}
|
|
if n.Kind == nodeThread || n.Kind == nodeDraft {
|
|
m.toggleExpand()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *goalPanelModel) copyNodeTitle() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil {
|
|
return m, nil
|
|
}
|
|
title := ""
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
title = n.Goal.Title
|
|
case nodeThread:
|
|
title = n.Thread.Name
|
|
case nodeDraft:
|
|
title = draftDisplayName(n.Draft)
|
|
case nodeTodo:
|
|
title = n.Todo.Title
|
|
}
|
|
if title == "" {
|
|
return m, nil
|
|
}
|
|
if err := writeTodoClipboard(title); err != nil {
|
|
m.setStatus(err.Error())
|
|
} else {
|
|
m.setStatus("copied")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func threadIDForNode(n *goalNode) string {
|
|
if n == nil {
|
|
return ""
|
|
}
|
|
if n.Kind == nodeThread {
|
|
return n.Thread.ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func renameGoal(goalID, title string) error {
|
|
return mutateGoalStore(func(store *GoalStore) error {
|
|
g, ok := store.Goals[strings.TrimSpace(goalID)]
|
|
if !ok {
|
|
return fmt.Errorf("goal not found: %s", goalID)
|
|
}
|
|
g.Title = strings.TrimSpace(title)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (m *goalPanelModel) runFunc(fn func() error, closeAfter bool) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return goalActionMsg{err: fn(), closeAfter: closeAfter}
|
|
}
|
|
}
|
|
|
|
// ── View ────────────────────────────────────────────────────
|
|
|
|
func (m *goalPanelModel) View() string {
|
|
return m.render(newPaletteStyles(), m.width, m.height)
|
|
}
|
|
|
|
func (m *goalPanelModel) render(styles paletteStyles, width, height int) string {
|
|
if width <= 0 {
|
|
width = 96
|
|
}
|
|
if height <= 0 {
|
|
height = 28
|
|
}
|
|
switch m.mode {
|
|
case goalModeRename, goalModeAddGoal, goalModeAddThread, goalModeAddTodo:
|
|
return m.renderInputBox(styles, width, height)
|
|
case goalModeSetGoal, goalModeSetBlocker, goalModeMoreOptions:
|
|
return m.renderPicker(styles, width, height)
|
|
case goalModePromoteGoal:
|
|
return m.renderList(styles, width, height)
|
|
case goalModePromote:
|
|
return m.renderPromote(styles, width, height)
|
|
case goalModeConfirmDeleteGoal:
|
|
return m.renderConfirmDeleteGoal(styles, width, height)
|
|
case goalModeConfirmDeletePlanned:
|
|
return m.renderConfirmDone(styles, width, height)
|
|
case goalModeConfirmDone:
|
|
return m.renderConfirmDone(styles, width, height)
|
|
case goalModeHelp:
|
|
return m.renderHelp(styles, width, height)
|
|
default:
|
|
return m.renderList(styles, width, height)
|
|
}
|
|
}
|
|
|
|
func (m *goalPanelModel) renderList(styles paletteStyles, width, height int) string {
|
|
store, _ := loadGoalStore()
|
|
drafts, _ := collectDrafts()
|
|
goalCount := len(store.Goals)
|
|
threadCount := len(store.Threads)
|
|
draftCount := len(drafts)
|
|
headerText := "Goals"
|
|
statsText := fmt.Sprintf("%d goals · %d threads · %d unassigned", goalCount, threadCount, draftCount)
|
|
statsLine := padBetween(styles.title.Render(headerText), styles.meta.Render(statsText), width-2)
|
|
innerW := width - 2
|
|
body := m.renderTree(styles, innerW, height-4)
|
|
footer := m.renderFooter(styles, innerW)
|
|
view := lipgloss.JoinVertical(lipgloss.Left, statsLine, "", body, "", footer)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderTree(styles paletteStyles, width, height int) string {
|
|
if m.list == nil || len(m.list.Nodes) == 0 {
|
|
empty := styles.muted.Render("No goals or threads yet.")
|
|
addHint := styles.muted.Render("Press G to add a goal, or start an opencode turn to create a draft.")
|
|
return lipgloss.JoinVertical(lipgloss.Left, empty, "", addHint)
|
|
}
|
|
contentH := maxInt(4, height)
|
|
sepLines := 0
|
|
for _, n := range m.list.Nodes {
|
|
if n.Kind == nodeDraft {
|
|
sepLines = 1
|
|
break
|
|
}
|
|
}
|
|
visibleRows := maxInt(2, (contentH-sepLines)/2)
|
|
|
|
if m.mode == goalModeMove {
|
|
return m.renderTreeMove(styles, width, contentH, visibleRows)
|
|
}
|
|
|
|
offset := stableListOffset(0, m.cursor, visibleRows, len(m.list.Nodes))
|
|
rows := []string{}
|
|
sawDraft := false
|
|
nodeCount := 0
|
|
for i := offset; i < len(m.list.Nodes) && nodeCount < visibleRows; i++ {
|
|
n := m.list.Nodes[i]
|
|
if n.Kind == nodeDraft && !sawDraft {
|
|
sawDraft = true
|
|
if m.mode == goalModePromoteGoal {
|
|
noGoalSel := m.promoteNoGoal
|
|
noGoalStyle := styles.muted
|
|
if noGoalSel {
|
|
noGoalStyle = styles.itemTitle.Copy().Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230"))
|
|
}
|
|
noGoalText := noGoalStyle.Render(fmt.Sprintf(" %s %s", "▹", "No goal (top-level)"))
|
|
noGoalText = fillWidthBg([]string{noGoalText}, width, lipgloss.Color("238"), noGoalSel)
|
|
rows = append(rows, noGoalText)
|
|
nodeCount++
|
|
}
|
|
rows = append(rows, styles.muted.Render(strings.Repeat("─", width)))
|
|
}
|
|
rows = append(rows, m.renderNode(styles, n, i, width))
|
|
nodeCount++
|
|
}
|
|
body := strings.Join(rows, "\n")
|
|
return lipgloss.NewStyle().Width(width).Height(contentH).Render(body)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderTreeMove(styles paletteStyles, width, contentH, visibleRows int) string {
|
|
movedIdx := m.ghostMovedIdx()
|
|
skipStart := movedIdx
|
|
skipEnd := movedIdx + 1
|
|
if m.moveGhost.IsGoal && movedIdx >= 0 {
|
|
skipEnd = m.ghostSubtreeEnd(movedIdx)
|
|
}
|
|
ghostPos := m.moveGhost.Pos
|
|
ghostDepth := m.moveGhost.Depth
|
|
ghostAfter := m.moveGhost.After
|
|
ghostBefore := m.moveGhost.Before
|
|
ghostName := m.moveName
|
|
|
|
type displayItem struct {
|
|
node *goalNode
|
|
isGhost bool
|
|
ghostDep int
|
|
}
|
|
var items []displayItem
|
|
for i, n := range m.list.Nodes {
|
|
if i >= skipStart && i < skipEnd {
|
|
if ghostPos == i {
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
}
|
|
continue
|
|
}
|
|
if ghostPos != i {
|
|
items = append(items, displayItem{node: n})
|
|
continue
|
|
}
|
|
if n.Depth < ghostDepth {
|
|
if ghostAfter {
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
items = append(items, displayItem{node: n})
|
|
} else {
|
|
items = append(items, displayItem{node: n})
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
}
|
|
} else if n.Depth > ghostDepth {
|
|
if ghostAfter {
|
|
items = append(items, displayItem{node: n})
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
} else {
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
items = append(items, displayItem{node: n})
|
|
}
|
|
} else {
|
|
if ghostAfter || (ghostPos > movedIdx && !ghostBefore) {
|
|
items = append(items, displayItem{node: n})
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
} else {
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
items = append(items, displayItem{node: n})
|
|
}
|
|
}
|
|
}
|
|
if ghostPos >= len(m.list.Nodes) || (movedIdx < len(m.list.Nodes) && ghostPos == movedIdx && ghostPos == len(m.list.Nodes)-1) {
|
|
if ghostPos >= len(m.list.Nodes) {
|
|
items = append(items, displayItem{isGhost: true, ghostDep: ghostDepth})
|
|
}
|
|
}
|
|
|
|
totalItems := len(items)
|
|
offset := stableListOffset(0, ghostPos, visibleRows, totalItems)
|
|
sep := m.separatorIndex()
|
|
rows := []string{}
|
|
sawDraft := false
|
|
itemCount := 0
|
|
for i := offset; i < totalItems && itemCount < visibleRows; i++ {
|
|
item := items[i]
|
|
isInDraftZone := false
|
|
if item.isGhost {
|
|
origPos := m.moveGhost.Pos
|
|
isInDraftZone = origPos >= sep
|
|
} else {
|
|
isInDraftZone = item.node.Kind == nodeDraft
|
|
}
|
|
if isInDraftZone && !sawDraft {
|
|
sawDraft = true
|
|
rows = append(rows, styles.muted.Render(strings.Repeat("─", width)))
|
|
}
|
|
if item.isGhost {
|
|
indent := strings.Repeat(" ", item.ghostDep)
|
|
maxW := width - len(indent) - 4 - 10
|
|
if maxW < 8 {
|
|
maxW = 8
|
|
}
|
|
glyph := "◦"
|
|
if m.moveGhost.IsGoal {
|
|
glyph = "▾"
|
|
}
|
|
line1 := styles.muted.Render(fmt.Sprintf("%s%s %s (moving)", indent, glyph, truncate(firstLine(ghostName), maxW)))
|
|
line2 := styles.muted.Render(indent + " ")
|
|
rows = append(rows, lipgloss.JoinVertical(lipgloss.Left,
|
|
lipgloss.NewStyle().Width(width).Render(line1),
|
|
lipgloss.NewStyle().Width(width).Render(line2)))
|
|
} else {
|
|
rows = append(rows, m.renderNode(styles, item.node, i, width))
|
|
}
|
|
itemCount++
|
|
}
|
|
body := strings.Join(rows, "\n")
|
|
return lipgloss.NewStyle().Width(width).Height(contentH).Render(body)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderNode(styles paletteStyles, n *goalNode, idx, width int) string {
|
|
indent := strings.Repeat(" ", n.Depth)
|
|
selected := idx == m.cursor && m.mode != goalModeMove && !(m.mode == goalModePromoteGoal && m.promoteNoGoal)
|
|
nameStyle := styles.itemTitle
|
|
metaStyle := styles.itemSubtitle
|
|
if selected {
|
|
bg := lipgloss.Color("238")
|
|
nameStyle = nameStyle.Copy().Background(bg).Foreground(lipgloss.Color("230"))
|
|
metaStyle = metaStyle.Copy().Background(bg).Foreground(lipgloss.Color("251"))
|
|
}
|
|
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
store, _ := loadGoalStore()
|
|
hasChildren := false
|
|
if store != nil {
|
|
for _, g := range store.Goals {
|
|
if strings.TrimSpace(g.ParentID) == strings.TrimSpace(n.Goal.ID) {
|
|
hasChildren = true
|
|
break
|
|
}
|
|
}
|
|
if !hasChildren {
|
|
for _, t := range store.Threads {
|
|
if strings.TrimSpace(t.GoalID) == strings.TrimSpace(n.Goal.ID) {
|
|
hasChildren = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
isCollapsed := false
|
|
if v, ok := m.expanded[n.Goal.ID]; ok && !v {
|
|
isCollapsed = true
|
|
}
|
|
var glyph string
|
|
if !hasChildren {
|
|
glyph = "▹"
|
|
} else if isCollapsed {
|
|
glyph = "▸"
|
|
} else {
|
|
glyph = "▾"
|
|
}
|
|
goalStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("153"))
|
|
if selected {
|
|
goalStyle = goalStyle.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("153"))
|
|
}
|
|
maxW := width - len(indent) - 4
|
|
if maxW < 8 {
|
|
maxW = 8
|
|
}
|
|
label := fmt.Sprintf("%s%s %s", indent, glyph, truncate(n.Goal.Title, maxW))
|
|
left := goalStyle.Render(label)
|
|
right := ""
|
|
if isCollapsed && hasChildren {
|
|
threadCount, subGoalCount := 0, 0
|
|
for _, c := range m.list.Nodes {
|
|
if c.Depth <= n.Depth {
|
|
continue
|
|
}
|
|
if c.Kind == nodeThread && c.Thread != nil && strings.TrimSpace(c.Thread.GoalID) == strings.TrimSpace(n.Goal.ID) {
|
|
threadCount++
|
|
}
|
|
if c.Kind == nodeGoal && c.Goal != nil && strings.TrimSpace(c.Goal.ParentID) == strings.TrimSpace(n.Goal.ID) {
|
|
subGoalCount++
|
|
}
|
|
}
|
|
parts := []string{}
|
|
if threadCount > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d thread%s", threadCount, pluralS(threadCount)))
|
|
}
|
|
if subGoalCount > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d sub-goal%s", subGoalCount, pluralS(subGoalCount)))
|
|
}
|
|
if len(parts) > 0 {
|
|
rm := metaStyle
|
|
if selected {
|
|
rm = rm.Copy().Background(lipgloss.Color("238"))
|
|
}
|
|
right = rm.Render(strings.Join(parts, " · "))
|
|
}
|
|
}
|
|
return buildRowLine(left, right, width, lipgloss.Color("238"), selected)
|
|
case nodeTodo:
|
|
check := "☐"
|
|
checkStyle := styles.todoCheck
|
|
titleStyle := styles.itemTitle
|
|
if n.Todo != nil && n.Todo.Done {
|
|
check = "☑"
|
|
checkStyle = styles.todoCheckDone
|
|
titleStyle = styles.panelTextDone
|
|
}
|
|
if selected {
|
|
bg := lipgloss.Color("238")
|
|
checkStyle = checkStyle.Copy().Background(bg)
|
|
titleStyle = titleStyle.Copy().Background(bg).Foreground(lipgloss.Color("230"))
|
|
}
|
|
title := ""
|
|
if n.Todo != nil {
|
|
title = n.Todo.Title
|
|
}
|
|
maxW := width - len(indent) - 8
|
|
if maxW < 8 {
|
|
maxW = 8
|
|
}
|
|
checkR := checkStyle.Render(check)
|
|
titleR := titleStyle.Render(truncate(title, maxW))
|
|
indentR := indent + " "
|
|
sepR := " "
|
|
if selected {
|
|
bg := lipgloss.Color("238")
|
|
indentR = lipgloss.NewStyle().Background(bg).Render(indentR)
|
|
sepR = lipgloss.NewStyle().Background(bg).Render(" ")
|
|
}
|
|
return fillWidthBg([]string{indentR, checkR, sepR, titleR}, width, lipgloss.Color("238"), selected)
|
|
case nodeDraft:
|
|
isPromoting := m.mode == goalModePromoteGoal && strings.TrimSpace(n.Draft.WindowID) == strings.TrimSpace(m.promoteWindow)
|
|
draftNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("251"))
|
|
if isPromoting {
|
|
draftNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("223"))
|
|
}
|
|
if selected {
|
|
draftNameStyle = draftNameStyle.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230"))
|
|
}
|
|
bg := lipgloss.Color("238")
|
|
icon := threadStatusIcon(n.Status)
|
|
iconStyle := threadStatusIconStyle(styles, n.Status)
|
|
if isPromoting {
|
|
iconStyle = styles.selectedLabel
|
|
}
|
|
if n.Unread {
|
|
icon = "⚑"
|
|
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
|
|
}
|
|
if selected {
|
|
iconStyle = iconStyle.Copy().Background(bg)
|
|
}
|
|
currentWID := m.currentWindowID()
|
|
isCurrent := strings.TrimSpace(n.Draft.WindowID) == currentWID && currentWID != ""
|
|
maxW := width - len(indent) - 6
|
|
if isCurrent {
|
|
maxW -= lipgloss.Width("► current") + 2
|
|
}
|
|
if maxW < 8 {
|
|
maxW = 8
|
|
}
|
|
indentR := indent
|
|
sepR := " "
|
|
if selected {
|
|
indentR = lipgloss.NewStyle().Background(bg).Render(indentR)
|
|
sepR = lipgloss.NewStyle().Background(bg).Render(sepR)
|
|
}
|
|
currentTag := ""
|
|
if isCurrent {
|
|
tagStyle := styles.selectedLabel
|
|
if selected {
|
|
tagStyle = tagStyle.Copy().Background(bg)
|
|
}
|
|
gap := " "
|
|
if selected {
|
|
gap = lipgloss.NewStyle().Background(bg).Render(gap)
|
|
}
|
|
currentTag = gap + tagStyle.Render("► current")
|
|
}
|
|
hasName := strings.TrimSpace(n.Draft.Name) != ""
|
|
dispName := n.Draft.Name
|
|
if !hasName {
|
|
dispName = strings.TrimSpace(n.SessionName) + " " + strings.TrimSpace(n.WindowName)
|
|
dispName = strings.TrimSpace(dispName)
|
|
}
|
|
nameR := draftNameStyle.Render(truncate(firstLine(dispName), maxW))
|
|
left := indentR + iconStyle.Render(icon) + sepR + nameR + currentTag
|
|
|
|
rightParts := []string{}
|
|
if hasName {
|
|
if s := strings.TrimSpace(n.SessionName); s != "" {
|
|
loc := s
|
|
if w := strings.TrimSpace(n.WindowName); w != "" {
|
|
loc += " / " + w
|
|
}
|
|
rightParts = append(rightParts, loc)
|
|
}
|
|
}
|
|
if n.HasTodos {
|
|
rightParts = append(rightParts, fmt.Sprintf("%d/%d todos", n.OpenTodos, n.TotalTodos))
|
|
}
|
|
right := ""
|
|
if len(rightParts) > 0 {
|
|
rm := metaStyle
|
|
if selected {
|
|
rm = rm.Copy().Background(bg)
|
|
}
|
|
right = rm.Render(strings.Join(rightParts, " · "))
|
|
}
|
|
line1 := buildRowLine(left, right, width, bg, selected)
|
|
line2 := m.renderStatusLine(styles, metaStyle, n, indent, width, selected)
|
|
return lipgloss.JoinVertical(lipgloss.Left, line1, line2)
|
|
case nodeThread:
|
|
name := threadDisplayName(n.Thread, "")
|
|
if n.Thread.Done {
|
|
nameStyle = styles.panelTextDone
|
|
if selected {
|
|
nameStyle = nameStyle.Copy().Background(lipgloss.Color("238")).Foreground(lipgloss.Color("246"))
|
|
}
|
|
}
|
|
bg := lipgloss.Color("238")
|
|
icon := threadStatusIcon(n.Status)
|
|
iconStyle := threadStatusIconStyle(styles, n.Status)
|
|
if n.Unread {
|
|
icon = "⚑"
|
|
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
|
|
}
|
|
if selected {
|
|
iconStyle = iconStyle.Copy().Background(bg)
|
|
}
|
|
maxW := width - len(indent) - 6
|
|
if maxW < 8 {
|
|
maxW = 8
|
|
}
|
|
indentR := indent
|
|
sepR := " "
|
|
if selected {
|
|
indentR = lipgloss.NewStyle().Background(bg).Render(indentR)
|
|
sepR = lipgloss.NewStyle().Background(bg).Render(sepR)
|
|
}
|
|
nameR := nameStyle.Render(truncate(name, maxW))
|
|
left := indentR + iconStyle.Render(icon) + sepR + nameR
|
|
|
|
rightParts := []string{}
|
|
if s := strings.TrimSpace(n.SessionName); s != "" {
|
|
loc := s
|
|
if w := strings.TrimSpace(n.WindowName); w != "" {
|
|
loc += " / " + w
|
|
}
|
|
rightParts = append(rightParts, loc)
|
|
}
|
|
if n.HasTodos {
|
|
rightParts = append(rightParts, fmt.Sprintf("%d/%d todos", n.OpenTodos, n.TotalTodos))
|
|
}
|
|
right := ""
|
|
if len(rightParts) > 0 {
|
|
rm := metaStyle
|
|
if selected {
|
|
rm = rm.Copy().Background(bg)
|
|
}
|
|
right = rm.Render(strings.Join(rightParts, " · "))
|
|
}
|
|
line1 := buildRowLine(left, right, width, bg, selected)
|
|
line2 := m.renderStatusLine(styles, metaStyle, n, indent, width, selected)
|
|
return lipgloss.JoinVertical(lipgloss.Left, line1, line2)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func pluralS(n int) string {
|
|
if n == 1 {
|
|
return ""
|
|
}
|
|
return "s"
|
|
}
|
|
|
|
func (m *goalPanelModel) renderStatusLine(styles paletteStyles, metaStyle lipgloss.Style, n *goalNode, indent string, width int, selected bool) string {
|
|
bg := lipgloss.Color("238")
|
|
indentText := indent + " "
|
|
indentR := indentText
|
|
if selected {
|
|
indentR = lipgloss.NewStyle().Background(bg).Render(indentText)
|
|
}
|
|
|
|
segments := []string{indentR}
|
|
|
|
currentWID := m.currentWindowID()
|
|
isCurrent := false
|
|
if n.Kind == nodeThread && strings.TrimSpace(n.Thread.WindowID) == currentWID && currentWID != "" {
|
|
isCurrent = true
|
|
} else if n.Kind == nodeDraft && strings.TrimSpace(n.Draft.WindowID) == currentWID && currentWID != "" {
|
|
isCurrent = true
|
|
}
|
|
showCurrentTag := isCurrent && n.Kind != nodeDraft
|
|
if showCurrentTag {
|
|
tagStyle := styles.selectedLabel
|
|
if selected {
|
|
tagStyle = tagStyle.Copy().Background(bg)
|
|
}
|
|
segments = append(segments, tagStyle.Render("● current"))
|
|
}
|
|
|
|
primary := ""
|
|
primaryStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
if n.Status == threadStatusBlocked && n.Kind == nodeThread {
|
|
store, _ := loadGoalStore()
|
|
names := blockerNames(store, n.Thread)
|
|
if len(names) > 0 {
|
|
primary = "blocked ← " + strings.Join(names, ", ")
|
|
primaryStyle = styles.statusBad
|
|
}
|
|
}
|
|
if primary == "" && n.LastUpdate != "" {
|
|
primary = n.LastUpdate
|
|
if n.Phase != "" {
|
|
primaryStyle = phaseDisplayStyle(styles, n.Phase)
|
|
}
|
|
}
|
|
if primary == "" {
|
|
if pt, phaseStyle := phaseDisplay(styles, n.Phase); pt != "" {
|
|
primary = pt
|
|
primaryStyle = phaseStyle
|
|
}
|
|
}
|
|
if primary == "" && n.Unread {
|
|
primary = "needs review"
|
|
primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
|
|
}
|
|
|
|
prefixW := 0
|
|
if showCurrentTag {
|
|
prefixW = lipgloss.Width("● current")
|
|
if primary != "" {
|
|
prefixW += lipgloss.Width(" · ")
|
|
}
|
|
}
|
|
maxW := width - lipgloss.Width(indentText) - prefixW
|
|
if maxW < 8 {
|
|
maxW = 8
|
|
}
|
|
primary = truncate(primary, maxW)
|
|
|
|
if selected {
|
|
primaryStyle = primaryStyle.Copy().Background(bg)
|
|
}
|
|
if primary != "" {
|
|
if showCurrentTag {
|
|
ms := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
if selected {
|
|
ms = ms.Copy().Background(bg)
|
|
}
|
|
segments = append(segments, ms.Render(" · "))
|
|
}
|
|
segments = append(segments, primaryStyle.Render(primary))
|
|
}
|
|
|
|
return fillWidthBg(segments, width, bg, selected)
|
|
}
|
|
|
|
func phaseDisplayStyle(styles paletteStyles, phase string) lipgloss.Style {
|
|
switch phase {
|
|
case "waiting":
|
|
return styles.muted
|
|
case "responding":
|
|
return styles.selectedLabel
|
|
case "tool":
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
|
|
case "question":
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
|
|
default:
|
|
return styles.muted
|
|
}
|
|
}
|
|
|
|
func phaseDisplay(styles paletteStyles, phase string) (string, lipgloss.Style) {
|
|
switch phase {
|
|
case "waiting":
|
|
return "waiting for response…", styles.muted
|
|
case "responding":
|
|
return "responding…", styles.selectedLabel
|
|
case "tool":
|
|
return "running tool…", lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
|
|
case "question":
|
|
return "awaiting answer…", lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
|
|
default:
|
|
return "", lipgloss.Style{}
|
|
}
|
|
}
|
|
|
|
func threadStatusIcon(status string) string {
|
|
switch status {
|
|
case threadStatusRunning:
|
|
frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
|
now := time.Now()
|
|
return string(frames[int(now.UnixNano()/int64(100*time.Millisecond))%len(frames)])
|
|
case threadStatusDone:
|
|
return "✓"
|
|
case threadStatusBlocked:
|
|
return "✗"
|
|
case threadStatusReady:
|
|
return "◦"
|
|
default:
|
|
return "◦"
|
|
}
|
|
}
|
|
|
|
func threadStatusIconStyle(styles paletteStyles, status string) lipgloss.Style {
|
|
switch status {
|
|
case threadStatusRunning:
|
|
return styles.selectedLabel
|
|
case threadStatusDone:
|
|
return styles.todoCheckDone
|
|
case threadStatusBlocked:
|
|
return styles.statusBad
|
|
default:
|
|
return styles.muted
|
|
}
|
|
}
|
|
|
|
func renderThreadStatus(styles paletteStyles, status string, selected bool) string {
|
|
var label string
|
|
style := styles.meta
|
|
switch status {
|
|
case threadStatusRunning:
|
|
frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
|
now := time.Now()
|
|
label = string(frames[int(now.UnixNano()/int64(100*time.Millisecond))%len(frames)]) + " running"
|
|
style = styles.selectedLabel
|
|
case threadStatusReady:
|
|
label = "ready"
|
|
style = styles.selectedLabel
|
|
case threadStatusBlocked:
|
|
label = "⛔ blocked"
|
|
style = styles.statusBad
|
|
case threadStatusDone:
|
|
label = "✓ done"
|
|
style = styles.todoCheckDone
|
|
case threadStatusPlanned:
|
|
label = "planned"
|
|
style = styles.muted
|
|
}
|
|
if selected {
|
|
style = style.Copy().Background(lipgloss.Color("238"))
|
|
}
|
|
if label == "" {
|
|
return ""
|
|
}
|
|
return style.Render(label)
|
|
}
|
|
|
|
func goalHasVisibleChildren(expanded map[string]bool, g *goalNode, list *flatList) bool {
|
|
if list == nil {
|
|
return false
|
|
}
|
|
for _, n := range list.Nodes {
|
|
if n.Kind == nodeThread && n.Thread != nil && strings.TrimSpace(n.Thread.GoalID) == strings.TrimSpace(g.Goal.ID) {
|
|
return true
|
|
}
|
|
if n.Kind == nodeGoal && n.Goal != nil && strings.TrimSpace(n.Goal.ParentID) == strings.TrimSpace(g.Goal.ID) {
|
|
return true
|
|
}
|
|
if n.Kind == nodeDraft && g.Goal.ID == "" && g.Depth == 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func padBetween(left, right string, width int) string {
|
|
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
return left + strings.Repeat(" ", gap) + right
|
|
}
|
|
|
|
func fillWidthBg(segments []string, width int, bg lipgloss.Color, selected bool) string {
|
|
joined := strings.Join(segments, "")
|
|
if !selected {
|
|
trailing := width - lipgloss.Width(joined)
|
|
if trailing > 0 {
|
|
joined += strings.Repeat(" ", trailing)
|
|
}
|
|
return joined
|
|
}
|
|
totalUsed := lipgloss.Width(joined)
|
|
trailing := width - totalUsed
|
|
if trailing > 0 {
|
|
joined += lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", trailing))
|
|
}
|
|
return joined
|
|
}
|
|
|
|
func buildRowLine(left, right string, width int, bg lipgloss.Color, selected bool) string {
|
|
leftW := lipgloss.Width(left)
|
|
rightW := lipgloss.Width(right)
|
|
if leftW+rightW+2 > width {
|
|
right = ""
|
|
rightW = 0
|
|
}
|
|
gap := width - leftW - rightW
|
|
if gap < 0 {
|
|
gap = 0
|
|
}
|
|
if selected && gap > 0 {
|
|
gapFill := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", gap))
|
|
return left + gapFill + right
|
|
}
|
|
return left + strings.Repeat(" ", gap) + right
|
|
}
|
|
|
|
func (m *goalPanelModel) renderFooter(styles paletteStyles, width int) string {
|
|
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)
|
|
}
|
|
if m.mode == goalModePromoteGoal {
|
|
draftName := truncate(firstLine(string(m.promoteName)), 30)
|
|
draftTag := styles.selectedLabel.Render("◇ " + draftName)
|
|
hints := [][2]string{{"u/e", "move"}, {"a", "new goal"}, {"Enter", "promote"}, {"Esc", "cancel"}}
|
|
hintStr := pickRenderedShortcutFooter(width-lipgloss.Width(draftTag)-4, renderSegments, hints, hints)
|
|
return draftTag + " " + hintStr
|
|
}
|
|
n := m.currentNode()
|
|
|
|
enterLabel := "open"
|
|
if n != nil {
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
enterLabel = "expand"
|
|
case nodeThread:
|
|
if strings.TrimSpace(n.Thread.WindowID) == "" {
|
|
enterLabel = "bind"
|
|
} else {
|
|
enterLabel = "goto"
|
|
}
|
|
case nodeDraft, nodeTodo:
|
|
enterLabel = "goto"
|
|
}
|
|
}
|
|
|
|
base := [][2]string{{"u/e", "move"}, {"t", "thread"}, {"a", "goal"}, {"Enter", enterLabel}, {"Esc", "back"}}
|
|
var full [][2]string
|
|
if n != nil {
|
|
switch n.Kind {
|
|
case nodeGoal:
|
|
full = [][2]string{{"u/e", "move"}, {"Alt-U/E", "reorder"}, {"t", "thread"}, {"T", "top"}, {"a", "sub-goal"}, {"A", "top goal"}, {"r", "rename"}, {"R", "edit"}, {"Enter", enterLabel}, {"D", "delete"}, {"m", "move"}, {"o", "more"}, {"Esc", "back"}}
|
|
case nodeThread:
|
|
dLabel := "done"
|
|
if strings.TrimSpace(n.Thread.WindowID) == "" {
|
|
dLabel = "delete"
|
|
}
|
|
full = [][2]string{{"u/e", "move"}, {"Alt-U/E", "reorder"}, {"t", "thread"}, {"a", "goal"}, {"r", "rename"}, {"R", "edit"}, {"g", "goal"}, {"m", "move"}, {"Enter", enterLabel}, {"D", dLabel}, {"o", "more"}, {"Esc", "back"}}
|
|
case nodeDraft:
|
|
full = [][2]string{{"u/e", "move"}, {"a", "goal"}, {"m", "move"}, {"p", "promote"}, {"Enter", enterLabel}, {"o", "more"}, {"Esc", "back"}}
|
|
case nodeTodo:
|
|
full = [][2]string{{"u/e", "move"}, {"Enter", enterLabel}, {"D", "del"}, {"Esc", "back"}}
|
|
default:
|
|
full = base
|
|
}
|
|
} else {
|
|
full = base
|
|
}
|
|
return pickRenderedShortcutFooter(width, renderSegments, full, base)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderInputBox(styles paletteStyles, width, height int) string {
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render(m.inputLabel),
|
|
"",
|
|
styles.input.Render(renderInputValue(m.inputText, m.inputCursor, styles)),
|
|
"",
|
|
styles.modalHint.Render(m.inputHint),
|
|
)
|
|
box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderPicker(styles paletteStyles, width, height int) string {
|
|
title := "Pick"
|
|
switch m.mode {
|
|
case goalModeSetGoal:
|
|
title = "Set goal"
|
|
case goalModeSetBlocker:
|
|
title = "Set blocker"
|
|
case goalModeMoreOptions:
|
|
title = "Actions"
|
|
case goalModePromoteGoal:
|
|
title = "Promote to thread — pick goal"
|
|
}
|
|
items := m.filteredPickerItems()
|
|
innerW := width - 2
|
|
contentH := maxInt(4, height-8)
|
|
visibleRows := contentH
|
|
offset := stableListOffset(m.pickerOffset, m.pickerCursor, visibleRows, len(items))
|
|
m.pickerOffset = offset
|
|
rows := []string{}
|
|
for i := offset; i < len(items) && len(rows) < visibleRows; i++ {
|
|
line := items[i].Label
|
|
if items[i].Subtitle != "" {
|
|
line += " " + items[i].Subtitle
|
|
}
|
|
style := styles.item
|
|
if i == m.pickerCursor {
|
|
style = styles.selectedItem
|
|
}
|
|
rows = append(rows, style.Width(innerW).Render(truncate(line, maxInt(8, innerW-2))))
|
|
}
|
|
if len(rows) == 0 {
|
|
rows = append(rows, styles.muted.Render("no matches"))
|
|
}
|
|
body := strings.Join(rows, "\n")
|
|
filterLine := styles.searchBox.Width(innerW).Render(
|
|
lipgloss.JoinHorizontal(lipgloss.Center,
|
|
styles.searchPrompt.Render(">"),
|
|
" ",
|
|
styles.input.Render(renderInputValue(m.pickerFilter, m.inputCursor, styles)),
|
|
),
|
|
)
|
|
view := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render(title),
|
|
"",
|
|
filterLine,
|
|
"",
|
|
body,
|
|
"",
|
|
styles.modalHint.Render("u/e to move · type to filter · Enter to select · Esc to cancel"),
|
|
)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderPromote(styles paletteStyles, width, height int) string {
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render("Promote draft"),
|
|
styles.meta.Render("name"),
|
|
styles.input.Render(renderInputValue(m.promoteName, m.promoteCursor, styles)),
|
|
"",
|
|
styles.modalHint.Render("Enter promote Esc cancel"),
|
|
)
|
|
box := styles.modal.Width(minInt(72, maxInt(34, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderConfirmDeleteGoal(styles paletteStyles, width, height int) string {
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render("Delete goal?"),
|
|
styles.modalBody.Render("Sub-goals deleted; threads unassigned."),
|
|
"",
|
|
styles.modalHint.Render("y to delete n to cancel"),
|
|
)
|
|
box := styles.modal.Width(minInt(60, maxInt(34, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderConfirmDone(styles paletteStyles, width, height int) string {
|
|
title := "Toggle done?"
|
|
if m.mode == goalModeConfirmDeletePlanned {
|
|
title = "Delete thread?"
|
|
}
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.modalTitle.Render(title),
|
|
styles.modalHint.Render("y to confirm · n to cancel"),
|
|
)
|
|
box := styles.modal.Width(minInt(40, maxInt(24, width-10))).Render(body)
|
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
|
}
|
|
|
|
func (m *goalPanelModel) renderHelp(styles paletteStyles, width, height int) string {
|
|
lines := []string{
|
|
"u/e move through the list. n jumps to the parent goal.",
|
|
"→ or t expands a thread to show its todos. Enter goes to the thread's window (or binds it if planned).",
|
|
"r renames. g sets the goal. b sets a blocker. Alt-U/E reorders the item among its siblings (no nesting). m enters move mode (u/e to reposition, Enter to commit).",
|
|
"p promotes a draft into a named thread. D marks a thread done / deletes a goal (cascade).",
|
|
"a adds a todo to the selected thread. c toggles a todo. l toggles the assigned/unassigned view.",
|
|
"o opens all actions. Esc returns to the palette.",
|
|
}
|
|
styled := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
styled = append(styled, styles.panelText.Render(truncate(line, maxInt(10, width-4))))
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, styles.title.Render("Guide"), "", strings.Join(styled, "\n\n"))
|
|
}
|