mirror of
https://github.com/theniceboy/.config.git
synced 2026-07-16 22:01:21 +08:00
tracker + 2nd tmux status line
This commit is contained in:
parent
f667ba7225
commit
91deb129a8
22 changed files with 2369 additions and 657 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
238
agent-tracker/cmd/agent/hot_reload.go
Normal file
238
agent-tracker/cmd/agent/hot_reload.go
Normal 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
|
||||
}
|
||||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
173
agent-tracker/cmd/agent/palette_action_list.go
Normal file
173
agent-tracker/cmd/agent/palette_action_list.go
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue