tracker + 2nd tmux status line

This commit is contained in:
David Chen 2026-07-09 15:59:07 +08:00
parent f667ba7225
commit 91deb129a8
22 changed files with 2369 additions and 657 deletions

View file

@ -145,7 +145,7 @@ func newActivityMonitorModel(windowID string, embedded bool) *activityMonitorBT
func runBubbleTeaActivityMonitor(windowID string) error {
model := newActivityMonitorModel(windowID, false)
_, err := tea.NewProgram(model).Run()
_, err := tea.NewProgram(model, tea.WithoutBracketedPaste()).Run()
return err
}

View file

@ -271,6 +271,10 @@ func (m *goalPanelModel) updateList(key string) (tea.Model, tea.Cmd) {
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":
@ -1219,6 +1223,44 @@ func (m *goalPanelModel) submitPromote() {
// ── 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 {
@ -2014,9 +2056,6 @@ func (m *goalPanelModel) renderNode(styles paletteStyles, n *goalNode, idx, widt
case nodeDraft:
isPromoting := m.mode == goalModePromoteGoal && strings.TrimSpace(n.Draft.WindowID) == strings.TrimSpace(m.promoteWindow)
draftNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("251"))
if n.Unread {
draftNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230"))
}
if isPromoting {
draftNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("223"))
}
@ -2029,6 +2068,10 @@ func (m *goalPanelModel) renderNode(styles paletteStyles, n *goalNode, idx, widt
if isPromoting {
iconStyle = styles.selectedLabel
}
if n.Unread {
icon = "⚑"
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
}
if selected {
iconStyle = iconStyle.Copy().Background(bg)
}
@ -2066,7 +2109,7 @@ func (m *goalPanelModel) renderNode(styles paletteStyles, n *goalNode, idx, widt
dispName = strings.TrimSpace(dispName)
}
nameR := draftNameStyle.Render(truncate(firstLine(dispName), maxW))
left := indentR + iconStyle.Render(icon) + currentTag + sepR + nameR
left := indentR + iconStyle.Render(icon) + sepR + nameR + currentTag
rightParts := []string{}
if hasName {
@ -2099,15 +2142,14 @@ func (m *goalPanelModel) renderNode(styles paletteStyles, n *goalNode, idx, widt
if selected {
nameStyle = nameStyle.Copy().Background(lipgloss.Color("238")).Foreground(lipgloss.Color("246"))
}
} else if n.Unread {
nameStyle = styles.itemTitle.Copy().Bold(true).Foreground(lipgloss.Color("230"))
if selected {
nameStyle = nameStyle.Copy().Background(lipgloss.Color("238"))
}
}
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)
}
@ -2197,8 +2239,6 @@ func (m *goalPanelModel) renderStatusLine(styles paletteStyles, metaStyle lipglo
primary = n.LastUpdate
if n.Phase != "" {
primaryStyle = phaseDisplayStyle(styles, n.Phase)
} else if n.Unread {
primaryStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230"))
}
}
if primary == "" {
@ -2209,7 +2249,7 @@ func (m *goalPanelModel) renderStatusLine(styles paletteStyles, metaStyle lipglo
}
if primary == "" && n.Unread {
primary = "needs review"
primaryStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("221"))
primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("221"))
}
prefixW := 0
@ -2428,13 +2468,13 @@ func (m *goalPanelModel) renderFooter(styles paletteStyles, width int) string {
if n != nil {
switch n.Kind {
case nodeGoal:
full = [][2]string{{"u/e", "move"}, {"t", "thread"}, {"T", "top"}, {"a", "sub-goal"}, {"A", "top goal"}, {"r", "rename"}, {"R", "edit"}, {"Enter", enterLabel}, {"D", "delete"}, {"m", "move"}, {"o", "more"}, {"Esc", "back"}}
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"}, {"t", "thread"}, {"a", "goal"}, {"r", "rename"}, {"R", "edit"}, {"g", "goal"}, {"m", "move"}, {"Enter", enterLabel}, {"D", dLabel}, {"o", "more"}, {"Esc", "back"}}
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:
@ -2553,7 +2593,7 @@ func (m *goalPanelModel) renderHelp(styles paletteStyles, width, height int) str
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. m enters move mode (u/e to reposition, Enter to commit).",
"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.",

View file

@ -944,6 +944,77 @@ func assignParentsAndOrders(store *GoalStore, entries []flatEntry) {
}
}
// reorderChildSibling swaps a child (goal or thread) by one slot among its
// siblings under parentID, keeping its parent and depth. Children of a goal
// are sub-goals (ParentID==parentID) and threads (GoalID==parentID) sharing a
// single Order sequence. Returns false at the first/last sibling boundary or
// if the node is not found.
func reorderChildSibling(parentID, nodeID string, isGoal bool, delta int) bool {
store, err := loadGoalStore()
if err != nil || store == nil {
return false
}
parentID = strings.TrimSpace(parentID)
type ch struct {
id string
isGoal bool
order int
goal *Goal
thread *Thread
}
var children []ch
for _, g := range store.Goals {
if strings.TrimSpace(g.ParentID) == parentID {
children = append(children, ch{g.ID, true, g.Order, g, nil})
}
}
for _, t := range store.Threads {
if strings.TrimSpace(t.GoalID) == parentID {
children = append(children, ch{t.ID, false, t.Order, nil, t})
}
}
sort.SliceStable(children, func(i, j int) bool {
if children[i].order != children[j].order {
return children[i].order < children[j].order
}
return children[i].id < children[j].id
})
idx := -1
for i, c := range children {
if c.id == nodeID && c.isGoal == isGoal {
idx = i
break
}
}
if idx < 0 {
return false
}
target := idx + delta
if target < 0 || target >= len(children) {
return false
}
moved := children[idx]
rest := make([]ch, 0, len(children)-1)
for i, c := range children {
if i == idx {
continue
}
rest = append(rest, c)
}
ordered := make([]ch, 0, len(children))
ordered = append(ordered, rest[:target]...)
ordered = append(ordered, moved)
ordered = append(ordered, rest[target:]...)
for i, c := range ordered {
if c.isGoal && c.goal != nil {
c.goal.Order = i
} else if !c.isGoal && c.thread != nil {
c.thread.Order = i
}
}
return saveGoalStore(store) == nil
}
func applyGoalMove(goalID string, list *flatList, g ghostState) error {
goalID = strings.TrimSpace(goalID)
startIdx := -1

View file

@ -0,0 +1,238 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func runHotReloadCommand(args []string) error {
fs := flag.NewFlagSet("agent hot-reload", flag.ContinueOnError)
var workspace string
var skipAnalyze bool
fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json")
fs.BoolVar(&skipAnalyze, "skip-analyze", false, "")
fs.SetOutput(nil)
if err := fs.Parse(args); err != nil {
return err
}
workspaceRoot, featurePath, err := resolveBrowserWorkspace(workspace)
if err != nil {
return err
}
return hotReload(workspaceRoot, featurePath, skipAnalyze)
}
func hotReload(workspaceRoot, featurePath string, skipAnalyze bool) error {
cfg, err := loadFeatureConfig(featurePath)
if err != nil {
return err
}
repoDir := filepath.Join(workspaceRoot, "repo")
device := strings.TrimSpace(cfg.Device)
if device == "" {
return fmt.Errorf("no launch device selected")
}
logfile := filepath.Join(workspaceRoot, "logs", fmt.Sprintf("flutter-%d.log", cfg.Port))
if !skipAnalyze {
analyzeOutput, err := runFlutterAnalyze(repoDir)
filtered := filterAnalyzeOutput(analyzeOutput)
if strings.TrimSpace(filtered) != "" {
fmt.Println(filtered)
}
if err != nil {
if strings.TrimSpace(filtered) == "" {
fmt.Print(analyzeOutput)
}
return fmt.Errorf("analysis failed")
}
}
if !flutterServerReady(logfile) {
return fmt.Errorf("flutter server not ready")
}
paneID, err := findFlutterPane(repoDir, workspaceRoot)
if err != nil {
return err
}
linesBefore := countLines(logfile)
_ = browserClearLogsForFeature(featurePath)
if err := runTmux("send-keys", "-t", paneID, "r"); err != nil {
return fmt.Errorf("failed to send hot reload key: %w", err)
}
result := monitorHotReload(logfile, linesBefore, device, featurePath)
fmt.Println(result)
return nil
}
func runFlutterAnalyze(repoDir string) (string, error) {
cmd := exec.Command("flutter", "analyze", "lib", "--no-fatal-infos", "--no-fatal-warnings")
cmd.Dir = repoDir
output, err := cmd.CombinedOutput()
return string(output), err
}
func filterAnalyzeOutput(output string) string {
lines := strings.Split(output, "\n")
var filtered []string
found := false
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "Analyzing") {
found = true
}
if found {
filtered = append(filtered, line)
}
}
return strings.Join(filtered, "\n")
}
func flutterServerReady(logfile string) bool {
data, err := os.ReadFile(logfile)
if err != nil {
return false
}
content := strings.ToLower(string(data))
patterns := []string{
"flutter run key commands.",
"is being served at",
"serving at",
"lib/main.dart is being served",
}
for _, p := range patterns {
if strings.Contains(content, p) {
return true
}
}
return false
}
func findFlutterPane(repoDir, workspaceDir string) (string, error) {
repoDir = strings.TrimSuffix(filepath.Clean(repoDir), string(filepath.Separator))
workspaceDir = strings.TrimSuffix(filepath.Clean(workspaceDir), string(filepath.Separator))
output, err := runTmuxOutput("list-panes", "-a", "-F", "#{pane_id} #{pane_pid}")
if err != nil {
return "", fmt.Errorf("failed to list tmux panes")
}
for _, line := range strings.Split(output, "\n") {
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
paneID, panePID := parts[0], parts[1]
flutterPID := findFlutterRunPID(panePID, 0)
if flutterPID == "" {
continue
}
cwd := processCWD(flutterPID)
cwd = strings.TrimSuffix(filepath.Clean(cwd), string(filepath.Separator))
if cwd == repoDir || cwd == workspaceDir {
return paneID, nil
}
}
return "", fmt.Errorf("flutter pane not found")
}
func findFlutterRunPID(parentPID string, depth int) string {
if depth > 12 {
return ""
}
out, err := exec.Command("pgrep", "-P", parentPID).Output()
if err != nil {
return ""
}
for _, child := range strings.Fields(string(out)) {
cmd := processCommand(child)
if strings.Contains(cmd, "flutter_tools.snapshot") && strings.Contains(cmd, "run") {
return child
}
if found := findFlutterRunPID(child, depth+1); found != "" {
return found
}
}
return ""
}
func processCommand(pid string) string {
out, err := exec.Command("ps", "-p", pid, "-o", "command=").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func processCWD(pid string) string {
out, err := exec.Command("lsof", "-a", "-d", "cwd", "-p", pid).Output()
if err != nil {
return ""
}
lines := strings.Split(string(out), "\n")
if len(lines) < 2 {
return ""
}
fields := strings.Fields(lines[1])
if len(fields) == 0 {
return ""
}
return fields[len(fields)-1]
}
func countLines(path string) int {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
return strings.Count(string(data), "\n") + 1
}
func monitorHotReload(logfile string, linesBefore int, device string, featurePath string) string {
for i := 0; i < 100; i++ {
time.Sleep(300 * time.Millisecond)
newLines, err := readLinesAfter(logfile, linesBefore)
if err != nil {
continue
}
lower := strings.ToLower(newLines)
if strings.Contains(lower, "reloaded ") && (strings.Contains(lower, " libraries") || strings.Contains(lower, " of ") || strings.Contains(lower, "application in")) {
return "Reloaded the application"
}
if strings.Contains(lower, "restarted ") && (strings.Contains(lower, "application in") || strings.Contains(lower, "libraries")) {
return "Restarted the application"
}
if strings.Contains(lower, "page requires refresh") {
_ = syncChromeForFeature(featurePath, true)
_ = refreshChromeForFeature(featurePath)
return "Page refreshed"
}
if strings.Contains(lower, "no client connected") || strings.Contains(lower, "no connected devices") || strings.Contains(lower, "hot reload rejected") {
if device == "web-server" {
_ = syncChromeForFeature(featurePath, true)
_ = refreshChromeForFeature(featurePath)
}
return "Reconnected browser"
}
}
return "Timeout waiting for reload confirmation"
}
func readLinesAfter(path string, startLine int) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
lines := strings.Split(string(data), "\n")
if startLine >= len(lines) {
return "", nil
}
return strings.Join(lines[startLine:], "\n"), nil
}

View file

@ -39,6 +39,7 @@ type agentRecord struct {
Branch string `json:"branch"`
SourceBranch string `json:"source_branch,omitempty"`
KeepWorktree bool `json:"keep_worktree,omitempty"`
Pull bool `json:"pull,omitempty"`
Runtime string `json:"runtime,omitempty"`
Device string `json:"device,omitempty"`
FeatureConfig string `json:"feature_config,omitempty"`
@ -65,6 +66,7 @@ type agentPanes struct {
type agentStartOptions struct {
SourceBranch string
KeepWorktree bool
Pull bool
}
type appConfig struct {
@ -74,16 +76,16 @@ type appConfig struct {
}
type statusRightConfig struct {
CPU *bool `json:"cpu,omitempty"`
Network *bool `json:"network,omitempty"`
Memory *bool `json:"memory,omitempty"`
MemoryTotals *bool `json:"memory_totals,omitempty"`
Agent *bool `json:"agent,omitempty"`
TodoPreview *bool `json:"todo_preview,omitempty"`
Todos *bool `json:"todos,omitempty"`
FlashMoe *bool `json:"flash_moe,omitempty"`
Host *bool `json:"host,omitempty"`
Goal *bool `json:"goal,omitempty"`
CPU *bool `json:"cpu,omitempty"`
Network *bool `json:"network,omitempty"`
Memory *bool `json:"memory,omitempty"`
MemoryTotals *bool `json:"memory_totals,omitempty"`
WindowMemory *bool `json:"window_memory,omitempty"`
SessionMemory *bool `json:"session_memory,omitempty"`
TotalMemory *bool `json:"total_memory,omitempty"`
Scratch *bool `json:"scratch,omitempty"`
FlashMoe *bool `json:"flash_moe,omitempty"`
Host *bool `json:"host,omitempty"`
}
type keyConfig struct {
@ -160,7 +162,7 @@ func main() {
func run(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|goal|browser|feature|update-hot-reload>")
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|goal|browser|feature|hot-reload|update-hot-reload>")
}
switch args[0] {
case "start":
@ -189,6 +191,8 @@ func run(args []string) error {
return runBrowserCommand(args[1:])
case "feature":
return runFeatureCommand(args[1:])
case "hot-reload":
return runHotReloadCommand(args[1:])
case "update-hot-reload":
return runUpdateHotReload(args[1:])
case "bootstrap":
@ -204,10 +208,12 @@ func runStart(args []string) error {
var device string
var noDevice bool
var keepWorktree bool
var pull bool
fs.StringVar(&feature, "name", "", "feature name")
fs.StringVar(&device, "d", "", "flutter device")
fs.BoolVar(&noDevice, "no-device", false, "leave the run pane idle until a device is chosen")
fs.BoolVar(&keepWorktree, "keep-worktree", false, "copy the current repo worktree into the new agent")
fs.BoolVar(&pull, "pull", false, "git fetch + fast-forward the source branch before creating the agent")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
@ -307,8 +313,9 @@ func runStart(args []string) error {
WorkspaceRoot: workspaceRoot,
RepoCopyPath: repoCopyPath,
Branch: feature,
SourceBranch: sourceBranch,
KeepWorktree: keepWorktree,
SourceBranch: sourceBranch,
KeepWorktree: keepWorktree,
Pull: pull,
Runtime: runtime,
Device: device,
FeatureConfig: featureConfigPath,
@ -670,6 +677,11 @@ func runBootstrap(args []string) error {
defer writeBootstrapFailure(workspaceRoot, err)
repoCopyPath := filepath.Join(workspaceRoot, "repo")
if startOptions.Pull {
if err = pullSourceBranch(repoRoot, repoCfg); err != nil {
return err
}
}
if err = copyGitMetadata(repoRoot, repoCopyPath); err != nil {
return err
}
@ -1057,7 +1069,7 @@ func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) {
func runTmuxCommand(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status|scratch>")
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status|work-status|scratch>")
}
switch args[0] {
case "on-focus":
@ -1068,6 +1080,8 @@ func runTmuxCommand(args []string) error {
return runTmuxPalette(args[1:])
case "right-status":
return runTmuxRightStatus(args[1:])
case "work-status":
return runTmuxWorkStatus(args[1:])
case "scratch":
return runTmuxScratch(args[1:])
default:
@ -1077,7 +1091,7 @@ func runTmuxCommand(args []string) error {
func runBrowserCommand(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent browser <open|refresh|close|screenshot|logs|mcp>")
return fmt.Errorf("usage: agent browser <open|refresh|close|screenshot|logs|clear-logs|mcp>")
}
switch args[0] {
case "open":
@ -1090,6 +1104,8 @@ func runBrowserCommand(args []string) error {
return runBrowserScreenshot(args[1:])
case "logs":
return runBrowserLogs(args[1:])
case "clear-logs":
return runBrowserClearLogs(args[1:])
case "mcp":
return runBrowserMCP(args[1:])
default:
@ -1177,8 +1193,12 @@ func runBrowserLogs(args []string) error {
fs := flag.NewFlagSet("agent browser logs", flag.ContinueOnError)
var workspace string
var durationSeconds int
var keep bool
var tail int
fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json")
fs.IntVar(&durationSeconds, "duration", 5, "seconds to listen for browser console output")
fs.IntVar(&durationSeconds, "duration", 0, "seconds to live-stream console output (0 = read buffer)")
fs.BoolVar(&keep, "keep", false, "keep logs in buffer after reading")
fs.IntVar(&tail, "tail", 100, "max lines from buffer (0 = all)")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
@ -1187,10 +1207,38 @@ func runBrowserLogs(args []string) error {
if err != nil {
return err
}
if durationSeconds < 1 {
durationSeconds = 1
if durationSeconds > 0 {
if durationSeconds < 1 {
durationSeconds = 1
}
return streamBrowserLogs(featurePath, time.Duration(durationSeconds)*time.Second, os.Stdout)
}
return streamBrowserLogs(featurePath, time.Duration(durationSeconds)*time.Second, os.Stdout)
text, err := browserReadLogsForFeature(featurePath, tail)
if err != nil {
return err
}
if text != "" {
fmt.Println(text)
}
if !keep {
_ = browserClearLogsForFeature(featurePath)
}
return nil
}
func runBrowserClearLogs(args []string) error {
fs := flag.NewFlagSet("agent browser clear-logs", flag.ContinueOnError)
var workspace string
fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
}
_, featurePath, err := resolveBrowserWorkspace(workspace)
if err != nil {
return err
}
return browserClearLogsForFeature(featurePath)
}
func runFeatureCommand(args []string) error {
@ -1558,6 +1606,7 @@ func configureScratchSession(sessionID, windowID string) error {
_ = runTmux("set-option", "-t", sessionID, "status-left-length", "0")
_ = runTmux("set-option", "-t", sessionID, "status-right-length", "0")
_ = runTmux("set-option", "-t", sessionID, "prefix", "None")
_ = runTmux("set-option", "-t", sessionID, "escape-time", "0")
_ = runTmux("set-option", "-t", sessionID, "detach-on-destroy", "on")
}
if windowID != "" {
@ -1686,13 +1735,17 @@ func runTmuxPalette(args []string) error {
return err
}
cmd := fmt.Sprintf(
"%s palette --window=%s --agent-id=%s --path=%s --session-name=%s --window-name=%s",
"%s palette --window=%s --agent-id=%s --path=%s --session-name=%s --window-name=%s --session-id=%s --pane-id=%s --window-index=%s --pane-index=%s",
shellQuote(exe),
shellQuote(ctx.WindowID),
shellQuote(ctx.AgentID),
shellQuote(ctx.CurrentPath),
shellQuote(ctx.SessionName),
shellQuote(ctx.WindowName),
shellQuote(ctx.SessionID),
shellQuote(ctx.PaneID),
shellQuote(ctx.WindowIndex),
shellQuote(ctx.PaneIndex),
)
return runTmux("display-popup", "-E", "-w", "78%", "-h", "80%", "-T", "agent", cmd)
}
@ -1700,11 +1753,15 @@ func runTmuxPalette(args []string) error {
type currentAgentRef struct{ ID string }
type tmuxPaletteLaunchContext struct {
SessionID string
WindowID string
PaneID string
AgentID string
CurrentPath string
SessionName string
WindowName string
WindowIndex string
PaneIndex string
}
func tmuxPaletteContext(windowID string) (tmuxPaletteLaunchContext, error) {
@ -1712,21 +1769,25 @@ func tmuxPaletteContext(windowID string) (tmuxPaletteLaunchContext, error) {
if strings.TrimSpace(windowID) != "" {
args = append(args, "-t", strings.TrimSpace(windowID))
}
args = append(args, "#{window_id}\n#{@agent_id}\n#{pane_current_path}\n#{session_name}\n#{window_name}")
args = append(args, "#{session_id}\n#{window_id}\n#{pane_id}\n#{@agent_id}\n#{pane_current_path}\n#{session_name}\n#{window_name}\n#{window_index}\n#{pane_index}")
out, err := runTmuxOutput(args...)
if err != nil {
return tmuxPaletteLaunchContext{}, err
}
parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 5)
for len(parts) < 5 {
parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 9)
for len(parts) < 9 {
parts = append(parts, "")
}
return tmuxPaletteLaunchContext{
WindowID: strings.TrimSpace(parts[0]),
AgentID: strings.TrimSpace(parts[1]),
CurrentPath: strings.TrimSpace(parts[2]),
SessionName: strings.TrimSpace(parts[3]),
WindowName: strings.TrimSpace(parts[4]),
SessionID: strings.TrimSpace(parts[0]),
WindowID: strings.TrimSpace(parts[1]),
PaneID: strings.TrimSpace(parts[2]),
AgentID: strings.TrimSpace(parts[3]),
CurrentPath: strings.TrimSpace(parts[4]),
SessionName: strings.TrimSpace(parts[5]),
WindowName: strings.TrimSpace(parts[6]),
WindowIndex: strings.TrimSpace(parts[7]),
PaneIndex: strings.TrimSpace(parts[8]),
}, nil
}
@ -2269,6 +2330,27 @@ func resolveStartSourceBranch(repoRoot string, repoCfg *repoConfig) string {
return detectDefaultBaseBranch(repoRoot)
}
func pullSourceBranch(repoRoot string, repoCfg *repoConfig) error {
branch := resolveStartSourceBranch(repoRoot, repoCfg)
fetchCmd := exec.Command("git", "fetch", "origin")
fetchCmd.Dir = repoRoot
fetchCmd.Stdout = os.Stderr
fetchCmd.Stderr = os.Stderr
if err := fetchCmd.Run(); err != nil {
return fmt.Errorf("git fetch origin: %w", err)
}
if remoteExists(repoRoot, "origin/"+branch) {
mergeCmd := exec.Command("git", "merge", "--ff-only", "origin/"+branch)
mergeCmd.Dir = repoRoot
mergeCmd.Stdout = os.Stderr
mergeCmd.Stderr = os.Stderr
if err := mergeCmd.Run(); err != nil {
return fmt.Errorf("git merge --ff-only origin/%s: %w", branch, err)
}
}
return nil
}
func resolveDefaultDevice(repoCfg *repoConfig) string {
if repoCfg != nil && strings.TrimSpace(repoCfg.DefaultDevice) != "" {
d := strings.TrimSpace(repoCfg.DefaultDevice)
@ -2285,6 +2367,7 @@ func resolveBootstrapStartOptions(repoRoot string, repoCfg *repoConfig, record *
if record != nil {
options.SourceBranch = strings.TrimSpace(record.SourceBranch)
options.KeepWorktree = record.KeepWorktree
options.Pull = record.Pull
}
if options.SourceBranch == "" {
if repoCfg != nil && strings.TrimSpace(repoCfg.BaseBranch) != "" {
@ -2819,7 +2902,7 @@ if [[ "$device" == "web-server" ]]; then
fi
cd "$DIR"
exec script -q "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$device\""
exec script -qF "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$device\""
`
ensurePath := filepath.Join(workspaceRoot, "ensure-server.sh")
if err := os.WriteFile(ensurePath, []byte(ensureServer), 0o755); err != nil {
@ -2840,144 +2923,10 @@ exec script -q "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$d
func writeHotReloadScript(repoCopyPath string) error {
hotReload := `#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
WORKSPACE_DIR="$(dirname "$REPO_DIR")"
INFO="$WORKSPACE_DIR/agent.json"
AGENT_BIN="${AGENT_BIN:-$HOME/.config/agent-tracker/bin/agent}"
port=$(python3 - "$INFO" <<'PY'
import json, pathlib, sys
data = json.loads(pathlib.Path(sys.argv[1]).read_text())
print(data.get('port', ''))
PY
)
device=$(python3 - "$INFO" <<'PY'
import json, pathlib, sys
data = json.loads(pathlib.Path(sys.argv[1]).read_text())
value = data.get('device')
if value is None:
value = 'web-server'
print(value)
PY
)
logfile="$WORKSPACE_DIR/logs/flutter-$port.log"
if [[ -z "$device" ]]; then
echo "No launch device selected"
exit 1
fi
set +e
analyze_output=$(cd "$REPO_DIR" && flutter analyze lib --no-fatal-infos --no-fatal-warnings 2>&1)
analyze_exit=$?
set -e
filtered=$(printf "%s\n" "$analyze_output" | awk '/^Analyzing/ {found=1} found {print}')
[[ -n "$filtered" ]] && printf "%s\n" "$filtered"
if [[ $analyze_exit -ne 0 ]]; then
echo "Analysis failed."
[[ -z "$filtered" ]] && printf "%s\n" "$analyze_output"
exit 1
fi
if [[ ! -f "$logfile" ]] || ! grep -qiE 'Flutter run key commands\.|is being served at|serving at|lib/main\.dart is being served' "$logfile" 2>/dev/null; then
echo "Flutter server not ready"
exit 1
fi
find_flutter_pane() {
[[ -z "${TMUX-}" ]] && return 1
has_flutter_run() {
local pid=$1 depth=${2:-0}
[[ $depth -gt 12 ]] && return 1
local child
while IFS= read -r child; do
[[ -z "$child" ]] && continue
if ps -p "$child" -o command= 2>/dev/null | grep -q 'flutter_tools\.snapshot.*run'; then
FLUTTER_RUN_PID="$child"
return 0
fi
if has_flutter_run "$child" $((depth + 1)); then
return 0
fi
done < <(pgrep -P "$pid" 2>/dev/null || true)
return 1
}
local pane_id pane_pid fpid fcwd
local repo_dir="${REPO_DIR%/}"
local workspace_dir="${WORKSPACE_DIR%/}"
while read -r pane_id pane_pid; do
[[ -z "$pane_id" || -z "$pane_pid" ]] && continue
FLUTTER_RUN_PID=""
if ! has_flutter_run "$pane_pid"; then
continue
fi
fpid="$FLUTTER_RUN_PID"
[[ -z "$fpid" ]] && continue
fcwd="$(lsof -a -d cwd -p "$fpid" 2>/dev/null | tail -n +2 | awk '{print $NF}')"
fcwd="${fcwd%/}"
if [[ "$fcwd" == "$repo_dir" || "$fcwd" == "$workspace_dir" ]]; then
printf "%s\n" "$pane_id"
return 0
fi
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid}' 2>/dev/null)
return 1
}
target_pane=$(find_flutter_pane) || {
echo "Flutter pane not found"
exit 1
}
lines_before=$(wc -l < "$logfile")
tmux send-keys -t "$target_pane" r 2>/dev/null
(
restart_server() {
tmux send-keys -t "$target_pane" C-c 2>/dev/null || true
sleep 0.5
tmux send-keys -t "$target_pane" "cd '$WORKSPACE_DIR' && ./ensure-server.sh" Enter 2>/dev/null || true
}
for _ in $(seq 1 100); do
newlines=$(python3 - "$logfile" "$lines_before" <<'PY'
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
start = int(sys.argv[2])
if not path.exists():
sys.exit(0)
with path.open('r', errors='ignore') as handle:
lines = handle.readlines()
sys.stdout.write(''.join(lines[start:]))
PY
)
if printf "%s\n" "$newlines" | grep -qiE 'Reloaded [0-9]+ libraries|Reloaded 1 of [0-9]+ libraries|Restarted application in'; then
exit 0
fi
if printf "%s\n" "$newlines" | grep -qi 'Page requires refresh'; then
"$AGENT_BIN" browser open --workspace "$WORKSPACE_DIR" --allow-open >/dev/null 2>&1 || true
"$AGENT_BIN" browser refresh --workspace "$WORKSPACE_DIR" >/dev/null 2>&1 || true
exit 0
fi
if printf "%s\n" "$newlines" | grep -qiE 'no client connected|no connected devices|Hot reload rejected'; then
if [[ "$device" == "web-server" ]]; then
"$AGENT_BIN" browser open --workspace "$WORKSPACE_DIR" --allow-open >/dev/null 2>&1 || true
"$AGENT_BIN" browser refresh --workspace "$WORKSPACE_DIR" >/dev/null 2>&1 || true
restart_server
fi
exit 0
fi
sleep 0.3
done
exit 0
) >/dev/null 2>&1 &
echo "Reloaded the application"
exec "$AGENT_BIN" hot-reload --workspace "$WORKSPACE_DIR"
`
if err := ensureGeneratedRepoPathIgnored(repoCopyPath, "hot-reload.sh"); err != nil {
return err
@ -3321,6 +3270,9 @@ func syncChromeForFeature(featurePath string, allowOpen bool) error {
}
if match != nil {
_, err := browserActivateExistingTabByURL(cfg.URL)
if err == nil {
_ = browserEnsureConsoleShim(match.WebSocketDebuggerURL)
}
return err
}
if !allowOpen {
@ -3329,7 +3281,13 @@ func syncChromeForFeature(featurePath string, allowOpen bool) error {
if _, err := browserCreateTarget(version.WebSocketDebuggerURL, cfg.URL, true); err != nil {
return err
}
return browserActivateLastTab()
if err := browserActivateLastTab(); err != nil {
return err
}
if target, err := browserTargetForURL(cfg.URL); err == nil && target != nil {
_ = browserEnsureConsoleShim(target.WebSocketDebuggerURL)
}
return nil
}
func refreshChromeForFeature(featurePath string) error {
@ -3975,6 +3933,108 @@ func browserConsoleArgsText(args []struct {
return strings.TrimSpace(strings.Join(parts, " "))
}
const browserConsoleShimJS = `(function(){if(window.__agentConsoleShimInstalled)return;window.__agentConsoleShimInstalled=true;var b=[];var MAX=500;function add(e){b.push(e);if(b.length>MAX)b=b.slice(-MAX)}['log','warn','error','info','debug','trace'].forEach(function(l){var o=console[l]?console[l].bind(console):function(){};console[l]=function(){var a=Array.prototype.slice.call(arguments);var t=a.map(function(x){try{if(x instanceof Error)return x.stack||(x.name+': '+x.message);if(typeof x==='object')return JSON.stringify(x);return String(x)}catch(e){return String(x)}}).join(' ');add({t:l,m:t,d:Date.now()});o.apply(console,a)}});window.addEventListener('error',function(e){var m=e.message||'Error';if(e.filename)m+=' ('+e.filename+':'+(e.lineno||0)+')';add({t:'error',m:m,d:Date.now()})});window.addEventListener('unhandledrejection',function(e){var r=e.reason;var t;try{if(r instanceof Error)t=r.stack||(r.name+': '+r.message);else if(typeof r==='object')t=JSON.stringify(r);else t=String(r)}catch(ex){t=String(r)}add({t:'error',m:'Unhandled rejection: '+t,d:Date.now()})});Object.defineProperty(window,'__agentConsoleBuffer',{configurable:true,get:function(){return b},set:function(v){b=Array.isArray(v)?v:[]}})})();`
type browserConsoleEntry struct {
Type string `json:"t"`
Msg string `json:"m"`
Time int64 `json:"d"`
}
func browserEnsureConsoleShim(pageWSURL string) error {
_ = browserCDPRequest(pageWSURL, "Page.enable", map[string]any{}, nil)
_ = browserCDPRequest(pageWSURL, "Page.addScriptToEvaluateOnNewDocument", map[string]any{
"source": browserConsoleShimJS,
}, nil)
return browserCDPRequest(pageWSURL, "Runtime.evaluate", map[string]any{
"expression": browserConsoleShimJS,
"silent": true,
}, nil)
}
func browserReadConsoleBuffer(pageWSURL string) ([]browserConsoleEntry, error) {
var result struct {
Result struct {
Value string `json:"value"`
} `json:"result"`
}
if err := browserCDPRequest(pageWSURL, "Runtime.evaluate", map[string]any{
"expression": "JSON.stringify(window.__agentConsoleBuffer||[])",
"returnByValue": true,
"silent": true,
}, &result); err != nil {
return nil, err
}
var entries []browserConsoleEntry
raw := strings.TrimSpace(result.Result.Value)
if raw == "" || raw == "[]" {
return entries, nil
}
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
return nil, fmt.Errorf("parse console buffer: %w", err)
}
return entries, nil
}
func browserClearConsoleBuffer(pageWSURL string) error {
return browserCDPRequest(pageWSURL, "Runtime.evaluate", map[string]any{
"expression": "window.__agentConsoleBuffer=[]",
"silent": true,
}, nil)
}
func formatConsoleEntries(entries []browserConsoleEntry, maxLines int) string {
if len(entries) == 0 {
return ""
}
start := 0
if maxLines > 0 && len(entries) > maxLines {
start = len(entries) - maxLines
}
var lines []string
for _, e := range entries[start:] {
level := firstNonEmpty(strings.TrimSpace(e.Type), "log")
msg := strings.TrimSpace(e.Msg)
if msg == "" {
continue
}
lines = append(lines, fmt.Sprintf("[%s] %s", level, msg))
}
return strings.Join(lines, "\n")
}
func browserClearLogsForFeature(featurePath string) error {
_, target, err := browserFeatureTarget(featurePath)
if err != nil {
return err
}
if target == nil {
return nil
}
if err := browserEnsureConsoleShim(target.WebSocketDebuggerURL); err != nil {
return err
}
return browserClearConsoleBuffer(target.WebSocketDebuggerURL)
}
func browserReadLogsForFeature(featurePath string, maxLines int) (string, error) {
_, target, err := browserFeatureTarget(featurePath)
if err != nil {
return "", err
}
if target == nil {
return "", nil
}
if err := browserEnsureConsoleShim(target.WebSocketDebuggerURL); err != nil {
return "", err
}
entries, err := browserReadConsoleBuffer(target.WebSocketDebuggerURL)
if err != nil {
return "", err
}
return formatConsoleEntries(entries, maxLines), nil
}
func runAppleScript(script string, args ...string) (string, error) {
cmdArgs := append([]string{"-e", script}, args...)
cmd := exec.Command("/usr/bin/osascript", cmdArgs...)

View file

@ -23,6 +23,8 @@ const (
paletteModeStatusRight
paletteModeTracker
paletteModeGoals
paletteModeAgent
paletteModeOpencodeFork
)
type palettePromptField int
@ -31,6 +33,7 @@ const (
palettePromptFieldName palettePromptField = iota
palettePromptFieldDevice
palettePromptFieldWorktree
palettePromptFieldPull
)
type palettePromptKind int
@ -54,6 +57,14 @@ const (
paletteActionOpenTracker
paletteActionOpenGoals
paletteActionOpenScratch
paletteActionBrowserLogs
paletteActionBrowserCopyLogs
paletteActionBrowserClearLogs
paletteActionBrowserReload
paletteActionOpenOpencodeFork
paletteActionForkOpencodeHorizontal
paletteActionForkOpencodeVertical
paletteActionForkOpencodeWindow
)
type paletteAction struct {
@ -81,6 +92,7 @@ type paletteResult struct {
Input string
Device string
KeepWorktree bool
Pull bool
State paletteUIState
}
@ -99,6 +111,7 @@ type paletteUIState struct {
PromptDevices []string
PromptDeviceIndex int
PromptKeepWorktree bool
PromptPull bool
ShowAltHints bool
Message string
ConfirmRequiresText bool

View file

@ -0,0 +1,173 @@
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type actionListPanel struct {
actions []paletteAction
hotKeys map[string]int
filter []rune
cursor int
selected int
offset int
}
func newActionListPanel(actions []paletteAction, hotKeys map[string]int) *actionListPanel {
return &actionListPanel{actions: actions, hotKeys: hotKeys, filter: []rune{}}
}
func filterActionsByQuery(actions []paletteAction, filter []rune) []paletteAction {
query := strings.ToLower(strings.TrimSpace(string(filter)))
if query == "" {
return actions
}
parts := strings.Fields(query)
filtered := make([]paletteAction, 0, len(actions))
for _, a := range actions {
haystack := strings.ToLower(a.Title)
matched := true
for _, part := range parts {
if !strings.Contains(haystack, part) {
matched = false
break
}
}
if matched {
filtered = append(filtered, a)
}
}
return filtered
}
func (p *actionListPanel) filtered() []paletteAction {
return filterActionsByQuery(p.actions, p.filter)
}
func (p *actionListPanel) navigate(delta int) {
actions := p.filtered()
if len(actions) == 0 {
p.selected = 0
return
}
next := clampInt(p.selected, 0, len(actions)-1) + delta
if next < 0 {
next = len(actions) - 1
} else if next >= len(actions) {
next = 0
}
p.selected = next
}
func (p *actionListPanel) handleKey(key string) (*paletteAction, bool) {
for hk, idx := range p.hotKeys {
if hk == key && idx >= 0 && idx < len(p.actions) {
p.selected = idx
action := p.actions[idx]
return &action, true
}
}
switch key {
case "ctrl+u", "alt+u", "up":
p.navigate(-1)
return nil, true
case "ctrl+e", "alt+e", "down":
p.navigate(1)
return nil, true
case "ctrl+n", "left":
p.cursor = clampInt(p.cursor-1, 0, len(p.filter))
return nil, true
case "ctrl+i", "tab", "right":
p.cursor = clampInt(p.cursor+1, 0, len(p.filter))
return nil, true
case "enter", "alt+i":
actions := p.filtered()
if len(actions) > 0 && p.selected >= 0 && p.selected < len(actions) {
action := actions[p.selected]
return &action, true
}
return nil, true
}
if applyPaletteInputKey(key, &p.filter, &p.cursor, false) {
p.selected = 0
p.offset = 0
return nil, true
}
return nil, false
}
func (p *actionListPanel) renderFilterLine(styles paletteStyles, width int) string {
return styles.searchBox.Width(width).Render(
lipgloss.JoinHorizontal(lipgloss.Center,
styles.searchPrompt.Render(">"),
" ",
styles.input.Render(renderInputValue(p.filter, p.cursor, styles)),
),
)
}
func renderActionItems(styles paletteStyles, actions []paletteAction, selectedPtr *int, offsetPtr *int, width, height int) string {
selected := *selectedPtr
entriesPerPage := maxInt(1, (height-2)/3)
selected = clampInt(selected, 0, maxInt(0, len(actions)-1))
offset := stableListOffset(*offsetPtr, selected, entriesPerPage, len(actions))
*offsetPtr = offset
*selectedPtr = selected
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 (p *actionListPanel) renderList(styles paletteStyles, width, height int) string {
actions := p.filtered()
return renderActionItems(styles, actions, &p.selected, &p.offset, width, height)
}

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,7 @@ type statusRightPanelEntry struct {
type statusRightPanelModel struct {
entries []statusRightPanelEntry
selected int
offset int
width int
height int
status string
@ -40,10 +41,6 @@ func (m *statusRightPanelModel) reload() {
for _, module := range statusRightModules() {
available := true
indented := false
if module == statusRightModuleTodoPreview {
available = statusRightModuleEnabled(statusRightModuleTodos)
indented = true
}
entries = append(entries, statusRightPanelEntry{
Module: module,
Title: statusRightModuleLabel(module),
@ -55,6 +52,7 @@ func (m *statusRightPanelModel) reload() {
}
m.entries = entries
m.selected = clampInt(m.selected, 0, maxInt(0, len(m.entries)-1))
m.offset = clampInt(m.offset, 0, maxInt(0, len(m.entries)-1))
}
func capitalizeStatusRightDescription(value string) string {
@ -145,7 +143,12 @@ func (m *statusRightPanelModel) render(styles paletteStyles, width, height int)
)
lines := []string{styles.meta.Render(fmt.Sprintf("%d controls", len(m.entries))), ""}
for idx, entry := range m.entries {
bodyHeight := maxInt(8, height-7)
visibleRows := maxInt(1, (bodyHeight-2)/4)
m.offset = stableListOffset(m.offset, m.selected, visibleRows, len(m.entries))
end := minInt(len(m.entries), m.offset+visibleRows)
for idx := m.offset; idx < end; idx++ {
entry := m.entries[idx]
rowStyle := styles.item.Width(maxInt(24, width-2))
titleStyle := styles.itemTitle
metaStyle := styles.itemSubtitle
@ -212,7 +215,6 @@ func (m *statusRightPanelModel) render(styles paletteStyles, width, height int)
)
lines = append(lines, rowStyle.Render(lipgloss.JoinVertical(lipgloss.Left, titleRow, detailRow, orderRow)))
}
bodyHeight := maxInt(8, height-7)
body := lipgloss.NewStyle().Height(bodyHeight).Render(strings.Join(lines, "\n"))
footer := m.renderFooter(styles, width)
view := lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer)
@ -230,18 +232,6 @@ func statusRightVisibilityText(enabled, available bool) string {
}
func statusRightOrderHint(module string, enabled, available bool) string {
if module == statusRightModuleTodoPreview {
if !available {
if enabled {
return "Will appear inside Todos when Todos is re-enabled"
}
return "Enable Todos to configure this preview"
}
if enabled {
return "Shown inside Todos when Todos is enabled"
}
return "Hidden inside Todos until re-enabled"
}
prefix := "Order slot: " + statusRightModuleLabel(module)
if enabled {
return prefix + " follows the fixed module order"
@ -252,7 +242,7 @@ func statusRightOrderHint(module string, enabled, available bool) string {
func (m *statusRightPanelModel) layoutSummary() string {
labels := make([]string, 0, len(m.entries))
for _, entry := range m.entries {
if entry.Enabled && entry.Module != statusRightModuleTodoPreview {
if entry.Enabled {
labels = append(labels, entry.Title)
}
}

View file

@ -13,19 +13,19 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
statusRightModuleCPU = "cpu"
statusRightModuleNetwork = "network"
statusRightModuleMemory = "memory"
statusRightModuleMemoryTotals = "memory_totals"
statusRightModuleAgent = "agent"
statusRightModuleTodoPreview = "todo_preview"
statusRightModuleTodos = "todos"
statusRightModuleFlashMoe = "flash_moe"
statusRightModuleHost = "host"
statusRightModuleGoal = "goal"
statusRightModuleCPU = "cpu"
statusRightModuleNetwork = "network"
statusRightModuleMemory = "memory"
statusRightModuleWindowMemory = "window_memory"
statusRightModuleSessionMemory = "session_memory"
statusRightModuleTotalMemory = "total_memory"
statusRightModuleScratch = "scratch"
statusRightModuleFlashMoe = "flash_moe"
statusRightModuleHost = "host"
)
const (
@ -36,9 +36,12 @@ const (
statusIconSession = ""
statusIconTotal = "󰍛"
statusIconAgent = "󰚩"
statusIconScratch = "✏️"
statusIconTodos = "󰎚"
statusIconFlashMoe = "󱙺"
statusIconGoal = "⌖"
statusIconNext = "󰁔"
statusIconLastMsg = "󰅻"
)
func statusRightModules() []string {
@ -46,13 +49,12 @@ func statusRightModules() []string {
statusRightModuleCPU,
statusRightModuleNetwork,
statusRightModuleMemory,
statusRightModuleMemoryTotals,
statusRightModuleAgent,
statusRightModuleTodos,
statusRightModuleTodoPreview,
statusRightModuleWindowMemory,
statusRightModuleSessionMemory,
statusRightModuleTotalMemory,
statusRightModuleScratch,
statusRightModuleFlashMoe,
statusRightModuleHost,
statusRightModuleGoal,
}
}
@ -97,15 +99,17 @@ type tmuxRightStatusArgs struct {
StatusBG string
SessionName string
WindowIndex string
WindowName string
PaneID string
WindowID string
}
type statusSegment struct {
FG string
BG string
Text string
Bold bool
FG string
BG string
Text string
Bold bool
NoRightPadding bool
}
type statusMemoryCache struct {
@ -121,6 +125,69 @@ type statusTodoCache struct {
Windows map[string][]statusTodoItem `json:"windows"`
}
type windowSnapshotEntry struct {
SessionName string `json:"session_name"`
WindowIndex string `json:"window_index"`
WindowName string `json:"window_name"`
}
func windowSnapshotPath() string {
stateDir := os.Getenv("XDG_STATE_HOME")
if stateDir == "" {
home, _ := os.UserHomeDir()
stateDir = filepath.Join(home, ".local", "state")
}
return filepath.Join(stateDir, "agent-tracker", "window-snapshot.json")
}
func saveWindowSnapshot(args tmuxRightStatusArgs) {
if args.WindowID == "" {
return
}
path := windowSnapshotPath()
data, err := os.ReadFile(path)
var snapshot map[string]windowSnapshotEntry
if err == nil {
_ = json.Unmarshal(data, &snapshot)
}
if snapshot == nil {
snapshot = make(map[string]windowSnapshotEntry)
}
out, err := runTmuxOutput("list-windows", "-a", "-F", "#{window_id}\t#{session_name}\t#{window_index}\t#{window_name}")
if err != nil {
entry := windowSnapshotEntry{
SessionName: args.SessionName,
WindowIndex: args.WindowIndex,
WindowName: args.WindowName,
}
if existing, ok := snapshot[args.WindowID]; ok && existing == entry {
return
}
snapshot[args.WindowID] = entry
} else {
for _, line := range strings.Split(out, "\n") {
fields := strings.SplitN(line, "\t", 4)
if len(fields) != 4 {
continue
}
wid := strings.TrimSpace(fields[0])
if wid == "" {
continue
}
snapshot[wid] = windowSnapshotEntry{
SessionName: strings.TrimSpace(fields[1]),
WindowIndex: strings.TrimSpace(fields[2]),
WindowName: strings.TrimSpace(fields[3]),
}
}
}
updated, _ := json.Marshal(snapshot)
_ = os.MkdirAll(filepath.Dir(path), 0755)
tmp := path + ".tmp"
_ = os.WriteFile(tmp, updated, 0644)
_ = os.Rename(tmp, path)
}
type statusTodoItem struct {
Title string `json:"title"`
Done bool `json:"done"`
@ -174,6 +241,34 @@ func runTmuxRightStatus(args []string) error {
return nil
}
func runTmuxWorkStatus(args []string) error {
fs := flag.NewFlagSet("agent tmux work-status", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
}
values := fs.Args()
parsed := tmuxRightStatusArgs{}
if len(values) > 0 {
parsed.Width, _ = strconv.Atoi(strings.TrimSpace(values[0]))
}
if len(values) > 1 {
parsed.WindowID = strings.TrimSpace(values[1])
}
if len(values) > 2 {
parsed.SessionName = strings.TrimSpace(values[2])
}
if len(values) > 3 {
parsed.WindowIndex = strings.TrimSpace(values[3])
}
if len(values) > 4 {
parsed.WindowName = strings.TrimSpace(values[4])
}
saveWindowSnapshot(parsed)
fmt.Print(renderTmuxWorkStatus(parsed))
return nil
}
func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
segments := make([]statusSegment, 0, 6)
if statusRightModuleEnabled(statusRightModuleCPU) {
@ -191,24 +286,7 @@ func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#5e81ac", Text: label})
}
}
if statusRightModuleEnabled(statusRightModuleMemoryTotals) {
segments = append(segments, loadMemoryTotalsStatusSegments(args)...)
}
if statusRightModuleEnabled(statusRightModuleAgent) {
if label := loadAgentStatusLabel(args.WindowID); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#81a1c1", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleTodos) {
if label := loadTodosStatusLabel(args.WindowID); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleGoal) {
if label := loadGoalStatusLabel(args.WindowID); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#a3be8c", Text: label, Bold: true})
}
}
segments = append(segments, loadSplitMemoryStatusSegments(args)...)
if statusRightModuleEnabled(statusRightModuleFlashMoe) {
if segment, ok := loadFlashMoeStatusSegment(); ok {
segments = append(segments, segment)
@ -219,9 +297,301 @@ func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: statusThemeColor(), Text: label})
}
}
if statusRightModuleEnabled(statusRightModuleScratch) {
if label := loadScratchStatusLabel(args.SessionName); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#d75f5f", Text: label, Bold: true, NoRightPadding: true})
}
}
return formatRightStatusSegments(args.StatusBG, segments)
}
func renderTmuxWorkStatus(args tmuxRightStatusArgs) string {
baseBG := "#232530"
leftBG := "#272535"
available := args.Width
if available <= 0 {
available = 120
}
leftParts := make([]string, 0, 3)
if label := loadGoalWorkStatusLabel(args.WindowID); label != "" {
leftParts = append(leftParts, fmt.Sprintf("#[fg=#f8f8f2,bg=#343746] %s %s #[fg=#343746,bg=%s]", statusIconGoal, label, leftBG))
}
if label := loadTodoCountWorkStatusLabel(args.WindowID); label != "" {
leftParts = append(leftParts, fmt.Sprintf("#[fg=#ff79c6,bg=%s] %s %s ", leftBG, statusIconTodos, label))
}
if label := loadTodoPreviewWorkStatusLabel(args.WindowID); label != "" {
leftParts = append(leftParts, fmt.Sprintf("#[fg=#ff79c6,bg=%s]%s #[fg=#ff79c6,bg=%s]%s", leftBG, statusIconNext, leftBG, label))
}
hasLeft := len(leftParts) > 0
msgLabel := loadLastUserMessageLabel(args.WindowID)
var agentPart string
if label := loadAgentWorkStatusLabel(args.WindowID); label != "" {
agentPart = fmt.Sprintf("#[fg=#8be9fd,bg=#233a45] %s %s ", statusIconAgent, label)
}
if len(leftParts) == 0 && msgLabel == "" && agentPart == "" {
return ""
}
left := ""
if hasLeft {
left = strings.Join(leftParts, fmt.Sprintf("#[fg=#c5c8c6,bg=%s] ", leftBG))
}
leftPlain := stripTmuxStyles(left)
agentPlain := stripTmuxStyles(agentPart)
leftMaxWidth := maxInt(1, available*60/100)
if len([]rune(leftPlain)) > leftMaxWidth {
left = truncateStyledWorkStatus(left, leftMaxWidth)
leftPlain = stripTmuxStyles(left)
}
var msgPart string
if msgLabel != "" {
msgSpace := available - len([]rune(leftPlain)) - len([]rune(agentPlain)) - 1
msgPrefixWidth := len([]rune(statusIconLastMsg)) + 1
if hasLeft {
msgPrefixWidth += len([]rune(" │ "))
}
msgMaxWidth := msgSpace - msgPrefixWidth
if msgMaxWidth > 0 {
msg := truncate(msgLabel, msgMaxWidth)
if hasLeft {
msgPart = fmt.Sprintf("#[fg=#3d4050,bg=%s] │ #[fg=#6c7086,bg=%s]%s #[fg=#c5c8c6,bg=%s]%s", baseBG, baseBG, statusIconLastMsg, baseBG, msg)
} else {
msgPart = fmt.Sprintf("#[fg=#6c7086,bg=%s]%s #[fg=#c5c8c6,bg=%s]%s", baseBG, statusIconLastMsg, baseBG, msg)
}
}
}
msgPlain := stripTmuxStyles(msgPart)
middle := left + msgPart
middlePlain := leftPlain + msgPlain
gapWidth := available - len([]rune(middlePlain)) - len([]rune(agentPlain))
if gapWidth < 1 {
gapWidth = 1
}
return fmt.Sprintf("#[fg=#c5c8c6,bg=%s,fill=%s]%s%s%s#[default]", baseBG, baseBG, middle, strings.Repeat(" ", gapWidth), agentPart)
}
var tmuxStylePattern = regexp.MustCompile(`#\[[^]]*\]`)
func stripTmuxStyles(text string) string {
return tmuxStylePattern.ReplaceAllString(text, "")
}
func truncateStyledWorkStatus(text string, width int) string {
plain := stripTmuxStyles(text)
if len([]rune(plain)) <= width {
return text
}
if width <= 0 {
return ""
}
limit := maxInt(0, width-1)
visible := 0
var b strings.Builder
for i := 0; i < len(text); {
if strings.HasPrefix(text[i:], "#[") {
end := strings.IndexByte(text[i:], ']')
if end >= 0 {
end += i
b.WriteString(text[i : end+1])
i = end + 1
continue
}
}
r, size := utf8.DecodeRuneInString(text[i:])
if r == utf8.RuneError && size == 0 {
break
}
if visible >= limit {
break
}
b.WriteRune(r)
visible++
i += size
}
b.WriteRune('…')
return b.String()
}
func loadLastUserMessageLabel(windowID string) string {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return ""
}
sanitized := sanitizeStateKey(windowID)
stateDir := os.Getenv("XDG_STATE_HOME")
if stateDir == "" {
home, _ := os.UserHomeDir()
stateDir = filepath.Join(home, ".local", "state")
}
path := filepath.Join(stateDir, "op", "lastmsg_"+sanitized)
data, err := os.ReadFile(path)
if err != nil {
return ""
}
text := strings.TrimSpace(string(data))
text = strings.Join(strings.Fields(text), " ")
return text
}
func sanitizeStateKey(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
func loadAgentWorkStatusLabel(windowID string) string {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return ""
}
ref, err := statusDetectCurrentAgentFromTmux(windowID)
if err != nil || strings.TrimSpace(ref.ID) == "" {
return ""
}
reg, err := statusLoadRegistry()
if err != nil || reg == nil {
return ""
}
record := reg.Agents[strings.TrimSpace(ref.ID)]
if record == nil {
return ""
}
device := strings.TrimSpace(record.Device)
if device == "" {
device = "no device"
}
return device
}
func loadGoalWorkStatusLabel(windowID string) string {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return ""
}
store, err := loadGoalStore()
if err != nil || store == nil {
return ""
}
thread := findThreadByWindow(store, windowID)
if thread == nil {
return ""
}
path := goalPathTitles(store, thread.GoalID)
if len(path) == 0 {
return ""
}
return strings.Join(path, " ")
}
func loadTodoCountWorkStatusLabel(windowID string) string {
items, ok := statusTodoItemsForWindow(windowID)
if !ok {
return ""
}
count := 0
for _, item := range items {
if !item.Done {
count++
}
}
if count == 0 {
return ""
}
return fmt.Sprintf("%d", count)
}
func loadTodoPreviewWorkStatusLabel(windowID string) string {
items, ok := statusTodoItemsForWindow(windowID)
if !ok {
return ""
}
title := firstOpenStatusTodoTitle(items)
if title == "" {
return ""
}
return title
}
func loadScratchStatusLabel(currentSessionName string) string {
if scratchSessionName(currentSessionName) {
return ""
}
if scratchTrackerWaiting() {
return fmt.Sprintf(" %s 🔔", statusIconScratch)
}
out, err := statusCommandOutput("tmux", "list-windows", "-t", "Scratch", "-F", "#{window_bell_flag} #{window_activity_flag} #{@unread} #{@watch_failed}")
if err != nil {
out, err = statusCommandOutput("tmux", "list-windows", "-t", "scratch", "-F", "#{window_bell_flag} #{window_activity_flag} #{@unread} #{@watch_failed}")
if err != nil {
return ""
}
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
for _, field := range strings.Fields(line) {
if field == "1" {
return fmt.Sprintf(" %s 🔔", statusIconScratch)
}
}
}
return ""
}
func scratchTrackerWaiting() bool {
data, err := os.ReadFile("/tmp/tmux-tracker-cache.json")
if err != nil {
return false
}
var state struct {
Tasks []struct {
SessionID string `json:"session_id"`
Session string `json:"session"`
Status string `json:"status"`
Acknowledged bool `json:"acknowledged"`
} `json:"tasks"`
}
if err := json.Unmarshal(data, &state); err != nil {
return false
}
for _, task := range state.Tasks {
if task.Status == "completed" && !task.Acknowledged && scratchTrackerTaskSession(task.Session, task.SessionID) {
return true
}
}
return false
}
func scratchTrackerTaskSession(sessionName, sessionID string) bool {
if scratchSessionName(sessionName) {
return true
}
for _, target := range []string{"Scratch", "scratch"} {
out, err := statusCommandOutput("tmux", "display-message", "-p", "-t", target, "#{session_id}")
if err == nil && strings.TrimSpace(string(out)) == strings.TrimSpace(sessionID) {
return true
}
}
return false
}
func scratchSessionName(name string) bool {
name = strings.TrimSpace(name)
if match := regexp.MustCompile(`^\d+-(.*)$`).FindStringSubmatch(name); len(match) == 2 {
name = match[1]
}
return strings.EqualFold(name, "scratch")
}
func formatRightStatusSegments(statusBG string, segments []statusSegment) string {
if len(segments) == 0 {
return ""
@ -239,6 +609,11 @@ func formatRightStatusSegments(statusBG string, segments []statusSegment) string
builder.WriteString(segment.Text)
prevBG = segment.BG
}
last := segments[len(segments)-1]
if last.NoRightPadding {
builder.WriteString(fmt.Sprintf("#[bg=%s]", statusBG))
return builder.String()
}
builder.WriteString(fmt.Sprintf(" #[fg=%s,bg=%s]%s", prevBG, statusBG, rightCap))
return builder.String()
}
@ -481,7 +856,7 @@ func loadMemoryStatusLabel(paneID string) string {
return fmt.Sprintf(" %s %s ", statusIconMemory, value)
}
func loadMemoryTotalsStatusSegments(args tmuxRightStatusArgs) []statusSegment {
func loadSplitMemoryStatusSegments(args tmuxRightStatusArgs) []statusSegment {
cache, ok := loadMemoryStatusCache()
if !ok {
return nil
@ -491,14 +866,20 @@ func loadMemoryTotalsStatusSegments(args tmuxRightStatusArgs) []statusSegment {
if windowKey != "" && strings.TrimSpace(args.WindowIndex) != "" {
windowKey = windowKey + ":" + strings.TrimSpace(args.WindowIndex)
}
if value := strings.TrimSpace(cache.Window[windowKey]); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#4c566a", Text: fmt.Sprintf(" %s %s ", statusIconWindow, value)})
if statusRightModuleEnabled(statusRightModuleWindowMemory) {
if value := strings.TrimSpace(cache.Window[windowKey]); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#4c566a", Text: fmt.Sprintf(" %s %s ", statusIconWindow, value)})
}
}
if value := strings.TrimSpace(cache.Session[strings.TrimSpace(args.SessionName)]); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#434c5e", Text: fmt.Sprintf(" %s %s ", statusIconSession, value)})
if statusRightModuleEnabled(statusRightModuleSessionMemory) {
if value := strings.TrimSpace(cache.Session[strings.TrimSpace(args.SessionName)]); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#434c5e", Text: fmt.Sprintf(" %s %s ", statusIconSession, value)})
}
}
if value := strings.TrimSpace(cache.Total); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#3b4252", Text: fmt.Sprintf(" %s %s ", statusIconTotal, value)})
if statusRightModuleEnabled(statusRightModuleTotalMemory) {
if value := strings.TrimSpace(cache.Total); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#3b4252", Text: fmt.Sprintf(" %s %s ", statusIconTotal, value)})
}
}
return segments
}
@ -604,13 +985,6 @@ func loadTodosStatusLabel(windowID string) string {
if count == 0 {
return ""
}
if !statusRightModuleEnabled(statusRightModuleTodoPreview) {
return fmt.Sprintf(" %s %d ", statusIconTodos, count)
}
title := firstOpenStatusTodoTitle(items)
if title != "" {
return fmt.Sprintf(" %s %d %s ", statusIconTodos, count, truncate(title, statusTodoMaxChars()))
}
return fmt.Sprintf(" %s %d ", statusIconTodos, count)
}
@ -740,9 +1114,7 @@ func loadHostStatusLabel() string {
func defaultStatusRightModuleEnabled(module string) bool {
switch module {
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
return true
case statusRightModuleMemoryTotals:
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleWindowMemory, statusRightModuleSessionMemory, statusRightModuleTotalMemory, statusRightModuleScratch, statusRightModuleFlashMoe, statusRightModuleHost:
return true
default:
return false
@ -751,7 +1123,7 @@ func defaultStatusRightModuleEnabled(module string) bool {
func isValidStatusRightModule(module string) bool {
switch module {
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleWindowMemory, statusRightModuleSessionMemory, statusRightModuleTotalMemory, statusRightModuleScratch, statusRightModuleFlashMoe, statusRightModuleHost:
return true
default:
return false
@ -777,20 +1149,18 @@ func (cfg statusRightConfig) moduleEnabled(module string) bool {
return derefBool(cfg.Network, defaultStatusRightModuleEnabled(module))
case statusRightModuleMemory:
return derefBool(cfg.Memory, defaultStatusRightModuleEnabled(module))
case statusRightModuleMemoryTotals:
return derefBool(cfg.MemoryTotals, defaultStatusRightModuleEnabled(module))
case statusRightModuleAgent:
return derefBool(cfg.Agent, defaultStatusRightModuleEnabled(module))
case statusRightModuleTodoPreview:
return derefBool(cfg.TodoPreview, defaultStatusRightModuleEnabled(module))
case statusRightModuleTodos:
return derefBool(cfg.Todos, defaultStatusRightModuleEnabled(module))
case statusRightModuleWindowMemory:
return derefBool(cfg.WindowMemory, derefBool(cfg.MemoryTotals, defaultStatusRightModuleEnabled(module)))
case statusRightModuleSessionMemory:
return derefBool(cfg.SessionMemory, derefBool(cfg.MemoryTotals, defaultStatusRightModuleEnabled(module)))
case statusRightModuleTotalMemory:
return derefBool(cfg.TotalMemory, derefBool(cfg.MemoryTotals, defaultStatusRightModuleEnabled(module)))
case statusRightModuleScratch:
return derefBool(cfg.Scratch, defaultStatusRightModuleEnabled(module))
case statusRightModuleFlashMoe:
return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module))
case statusRightModuleHost:
return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module))
case statusRightModuleGoal:
return derefBool(cfg.Goal, defaultStatusRightModuleEnabled(module))
default:
return false
}
@ -824,20 +1194,18 @@ func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) {
cfg.Network = value
case statusRightModuleMemory:
cfg.Memory = value
case statusRightModuleMemoryTotals:
cfg.MemoryTotals = value
case statusRightModuleAgent:
cfg.Agent = value
case statusRightModuleTodoPreview:
cfg.TodoPreview = value
case statusRightModuleTodos:
cfg.Todos = value
case statusRightModuleWindowMemory:
cfg.WindowMemory = value
case statusRightModuleSessionMemory:
cfg.SessionMemory = value
case statusRightModuleTotalMemory:
cfg.TotalMemory = value
case statusRightModuleScratch:
cfg.Scratch = value
case statusRightModuleFlashMoe:
cfg.FlashMoe = value
case statusRightModuleHost:
cfg.Host = value
case statusRightModuleGoal:
cfg.Goal = value
}
}
@ -845,7 +1213,7 @@ func (cfg *statusRightConfig) isDefault() bool {
if cfg == nil {
return true
}
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil && cfg.Goal == nil
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.WindowMemory == nil && cfg.SessionMemory == nil && cfg.TotalMemory == nil && cfg.Scratch == nil && cfg.FlashMoe == nil && cfg.Host == nil
}
func derefBool(value *bool, fallback bool) bool {

View file

@ -50,6 +50,7 @@ type todoPanelModel struct {
addText []rune
addCursor int
addScope todoScope
addPrepend bool
deleteEntry *tmuxTodoEntry
editEntry *tmuxTodoEntry
closePalette bool
@ -133,7 +134,7 @@ func runTodoPanel() error {
if err != nil {
return err
}
_, err = tea.NewProgram(model).Run()
_, err = tea.NewProgram(model, tea.WithoutBracketedPaste()).Run()
if err != nil {
return err
}
@ -280,6 +281,15 @@ func (m *todoPanelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.showAltHints = false
if msg.Paste {
if m.mode == todoPanelModeAdd || m.mode == todoPanelModeEdit {
pasted := strings.ReplaceAll(string(msg.Runes), "\n", " ")
runes := []rune(pasted)
m.addText = append(m.addText[:m.addCursor], append(runes, m.addText[m.addCursor:]...)...)
m.addCursor += len(runes)
}
return m, nil
}
key := msg.String()
if key == "alt+s" {
m.closePalette = true
@ -361,6 +371,13 @@ func (m *todoPanelModel) updateList(key string) (tea.Model, tea.Cmd) {
m.addText = nil
m.addCursor = 0
m.addScope = m.defaultAddScope()
m.addPrepend = false
case "k":
m.mode = todoPanelModeAdd
m.addText = nil
m.addCursor = 0
m.addScope = m.defaultAddScope()
m.addPrepend = true
case "E":
if entry, ok := m.selectedEntry(m.focusedPane); ok {
entryCopy := entry
@ -410,13 +427,24 @@ func (m *todoPanelModel) updateAdd(key string) (tea.Model, tea.Cmd) {
return m, nil
}
scopeID := m.scopeID(m.addScope)
if err := addTmuxTodo(m.addScope, scopeID, title); err != nil {
m.setStatus(err.Error(), 1500*time.Millisecond)
if m.addPrepend {
if err := addTmuxTodoTop(m.addScope, scopeID, title); err != nil {
m.setStatus(err.Error(), 1500*time.Millisecond)
} else {
m.reloadEntries()
targetPane := m.paneForScope(m.addScope)
m.setFocusedPane(targetPane)
m.setSelectedIndex(targetPane, 0)
}
} else {
m.reloadEntries()
targetPane := m.paneForScope(m.addScope)
m.setFocusedPane(targetPane)
m.setSelectedIndex(targetPane, maxInt(0, len(m.visibleEntries(targetPane))-1))
if err := addTmuxTodo(m.addScope, scopeID, title); err != nil {
m.setStatus(err.Error(), 1500*time.Millisecond)
} else {
m.reloadEntries()
targetPane := m.paneForScope(m.addScope)
m.setFocusedPane(targetPane)
m.setSelectedIndex(targetPane, maxInt(0, len(m.visibleEntries(targetPane))-1))
}
}
m.mode = todoPanelModeList
return m, nil
@ -798,11 +826,11 @@ func (m *todoPanelModel) renderFooter(w int) string {
)
} else {
footer = pickRenderedShortcutFooter(contentWidth, renderSegments,
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Ctrl-U/E", "reorder"}, {"n/i", "column"}, {"Tab", "window"}, {"Shift-N/I", "scope"}, {"Space", "toggle"}, {"a/Alt-A", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "delete"}, {"1/2/3", "priority"}, {"c", "completed"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"Space", "toggle"}, {"a/Alt-A", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"c", "done"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"Space", "toggle"}, {"a/Alt-A", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"Space", "toggle"}, {"a/Alt-A", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"a/Alt-A", "add"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Space", "toggle"}, {"c", "completed"}, {"Ctrl-U/E", "reorder"}, {"n/i", "column"}, {"Tab", "window"}, {"Shift-N/I", "scope"}, {"a/Alt-A", "add"}, {"k", "insert top"}, {"E", "edit"}, {"y", "copy"}, {"d", "delete"}, {"1/2/3", "priority"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Space", "toggle"}, {"c", "done"}, {"Ctrl-U/E", "reorder"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"a/Alt-A", "add"}, {"k", "insert top"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Space", "toggle"}, {"c", "done"}, {"n/i", "col"}, {"Tab", "win"}, {"N/I", "scope"}, {"a/Alt-A", "add"}, {"k", "insert top"}, {"E", "edit"}, {"y", "copy"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Space", "toggle"}, {"c", "done"}, {"n/i", "col"}, {"Tab", "win"}, {"a/Alt-A", "add"}, {"k", "insert top"}, {"E", "edit"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "goto"}, {"Space", "toggle"}, {"c", "done"}, {"a/Alt-A", "add"}, {"E", "edit"}, {"d", "del"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
[][2]string{{"Esc", "close"}, {footerHintToggleKey, "more"}},
)
}

View file

@ -273,12 +273,25 @@ func sortTmuxTodosByScope(entries []tmuxTodoEntry, scopePriority todoScope) {
}
func addTmuxTodo(scope todoScope, scopeID, title string) error {
return addTmuxTodoAt(scope, scopeID, title, false)
}
func addTmuxTodoTop(scope todoScope, scopeID, title string) error {
return addTmuxTodoAt(scope, scopeID, title, true)
}
func addTmuxTodoAt(scope todoScope, scopeID, title string, prepend bool) error {
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
items = append(items, tmuxTodoItem{Title: strings.TrimSpace(title), Done: false, Priority: 2, CreatedAt: time.Now()})
newItem := tmuxTodoItem{Title: strings.TrimSpace(title), Done: false, Priority: 2, CreatedAt: time.Now()}
if prepend {
items = append([]tmuxTodoItem{newItem}, items...)
} else {
items = append(items, newItem)
}
setTodoItemsForScope(store, scope, scopeID, items)
return saveTmuxTodoStore(store)
}