package main // The Repair workspace: is the layout sound, and what is broken. // // Every line is a check that was run, with three outcomes and never two: it // passed, it failed with a reason, or it has not been run. "Not run" is not a // pass, and a screen that says otherwise is how an afternoon disappears. import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "paperclip-harness/tui/paperclip" ) type checkResult struct { name string state string // pass, fail, not run detail string } type repairMsg struct { sessions []sessionRow checks []checkResult workspaces []layoutRow runs int err error } // sessionRow is one TUIOS session as the cleanup tab sees it. type sessionRow struct { name string panes int lastUsed time.Time attached bool protected bool // this project's own session, or nothing to clean } type layoutRow struct { number int name string panes int } type repairModel struct { client *paperclip.Client folder string checks []checkResult workspaces []layoutRow runs int readAt time.Time err error tab int scroll int showHelp bool width, height int sessions []sessionRow sessAt int ticked map[string]bool confirmKill bool killNote string } var repairTabs = []string{"health", "layout", "rebuild log", "cleanup"} func newRepairModel(c *paperclip.Client, folder string) repairModel { return repairModel{client: c, folder: folder} } func (m repairModel) Init() tea.Cmd { return tea.Batch(m.load(), tickEvery(8*time.Second), tea.WindowSize()) } func (m repairModel) load() tea.Cmd { c, folder := m.client, m.folder return func() tea.Msg { var checks []checkResult add := func(name, state, detail string) { checks = append(checks, checkResult{name, state, detail}) } if err := c.Health(); err != nil { add("Paperclip answers", "fail", err.Error()) } else { add("Paperclip answers", "pass", "the control plane replied") } session := "paperclip-harness" cfgPath := filepath.Join(folder, ".harness", "harness.toml") if _, err := os.Stat(cfgPath); err != nil { add("project file", "fail", "no .harness/harness.toml: run bin/office") } else if cfg, err := readHarnessFile(folder); err != nil { add("project file", "fail", err.Error()) } else if cfg["paperclip.project"] == "" { add("project file", "fail", "no paperclip project recorded") } else { add("project file", "pass", cfg["paperclip.project"]) if cfg["session"] != "" { session = cfg["session"] } } bin, _ := os.Executable() if _, err := os.Stat(bin); err != nil { add("harness binary", "fail", "not found") } else { add("harness binary", "pass", bin) } for _, tool := range []string{"git", "go", "ocr", "tuios", "lazygit", "hunk"} { if path, ok := findTool(tool); ok { add(tool, "pass", path) } else { add(tool, "fail", "not found on the path or in the usual places") } } // Fonts: the icons are drawn from a Nerd Font, so say whether it is here. out, _ := exec.Command("fc-list").Output() nerd := strings.Count(strings.ToLower(string(out)), "nerd") if nerd == 0 { add("Nerd Font", "fail", "no Nerd Font installed; icons will be boxes") } else { add("Nerd Font", "pass", fmt.Sprintf("%d font files", nerd)) } ws, layoutErr := layoutRows(session) sess := listSessions(session) res := repairMsg{checks: checks, workspaces: ws, sessions: sess} if layoutErr != nil { res.err = layoutErr } return res } } // findTool looks where a person's tools actually live. A pane started by TUIOS // gets a smaller PATH than an interactive shell, so a tool that works in the // shell can look missing from in here. func findTool(name string) (string, bool) { if p, err := exec.LookPath(name); err == nil { return p, true } home, _ := os.UserHomeDir() for _, dir := range []string{ filepath.Join(home, ".local", "bin"), filepath.Join(home, ".nix-profile", "bin"), "/run/current-system/sw/bin", "/etc/profiles/per-user/" + os.Getenv("USER") + "/bin", } { p := filepath.Join(dir, name) if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, true } } return "", false } // layoutRows reads what TUIOS actually has, not what we think it has. func layoutRows(session string) ([]layoutRow, error) { out, err := exec.Command("tuios", "list-workspaces", "-s", session, "--json").Output() if err != nil { return nil, err } return parseWorkspaces(string(out)), nil } func parseWorkspaces(raw string) []layoutRow { var rows []layoutRow for _, chunk := range strings.Split(raw, "{") { if !strings.Contains(chunk, "\"workspace\"") { continue } var number, panes int var name string fmt.Sscanf(strings.TrimSpace(chunk), "%d", &number) for _, field := range strings.Split(chunk, ",") { field = strings.TrimSpace(field) if strings.HasPrefix(field, "\"workspace\":") { fmt.Sscanf(strings.TrimPrefix(field, "\"workspace\":"), "%d", &number) } if strings.HasPrefix(field, "\"window_count\":") { fmt.Sscanf(strings.TrimPrefix(field, "\"window_count\":"), "%d", &panes) } if strings.HasPrefix(field, "\"name\":") { name = strings.Trim(strings.TrimPrefix(field, "\"name\":"), `" `) } } if number > 0 { rows = append(rows, layoutRow{number, name, panes}) } } return rows } func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height return m, nil case tickMsg: return m, tea.Batch(m.load(), tickEvery(8*time.Second), tea.WindowSize()) case tea.KeyMsg: if m.confirmKill { switch msg.String() { case "y": m.confirmKill = false killed := 0 for _, row := range m.sessions { if !m.ticked[row.name] || row.protected || row.attached { continue } out, err := exec.Command("tuios", "kill-session", row.name).CombinedOutput() if err != nil { m.err = fmt.Errorf("could not kill %q: %v %s", row.name, err, strings.TrimSpace(string(out))) continue } killed++ } m.ticked = map[string]bool{} m.killNote = fmt.Sprintf("%d session(s) closed", killed) return m, m.load() case "esc", "n", "q": m.confirmKill = false } return m, nil } if m.showHelp { m.showHelp = false return m, nil } switch msg.String() { case "?": m.showHelp = true return m, nil case "q", "ctrl+c": return m, tea.Quit case "r": return m, m.load() case "R": if err := startRebuild(m.folder); err != nil { m.err = err return m, nil } return m, tea.Quit case "B": // Add what is missing without replacing what is there. Safe to press // from inside a pane, because it never closes one. out, err := exec.Command(filepath.Join(exeDir(), "board"), "--folder", m.folder).CombinedOutput() if err != nil { m.err = fmt.Errorf("adding the missing panes failed: %v: %s", err, strings.TrimSpace(string(out))) return m, nil } m.err = nil m.tab = 1 return m, m.load() case "(", "[", "h": m.tab = (m.tab + len(repairTabs) - 1) % len(repairTabs) m.scroll = 0 return m, nil case ")", "]", "l", "tab": m.tab = (m.tab + 1) % len(repairTabs) m.scroll = 0 return m, nil case " ": if m.tab == 3 && len(m.sessions) > 0 { row := m.sessions[max(0, min(m.sessAt, len(m.sessions)-1))] if row.protected || row.attached { m.killNote = row.name + " is attached or is this project's session; it is not touched" return m, nil } if m.ticked == nil { m.ticked = map[string]bool{} } m.ticked[row.name] = !m.ticked[row.name] return m, nil } return m, nil case "x": if m.tab == 3 && len(m.ticked) > 0 { m.confirmKill = true } return m, nil case "up", "k": if m.tab == 3 { if m.sessAt > 0 { m.sessAt-- } m.scroll = max(0, min(m.scroll, m.sessAt-2)) return m, nil } m.scroll-- return m, nil case "down", "j": if m.tab == 3 { if m.sessAt < len(m.sessions)-1 { m.sessAt++ } visible := max(3, m.height-6) if m.sessAt > m.scroll+visible-3 { m.scroll = m.sessAt - visible + 3 } return m, nil } m.scroll++ return m, nil case "esc": return m, nil } case repairMsg: if msg.sessions != nil { m.sessions = msg.sessions } m.checks = msg.checks m.workspaces = msg.workspaces m.err = msg.err m.readAt = time.Now() return m, nil } return m, nil } func (m repairModel) View() string { if m.width == 0 || m.height == 0 { return "reading…" } if m.width < 80 || m.height < 24 { return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height)) } if m.confirmKill { var names []string for name := range m.ticked { names = append(names, name) } sort.Strings(names) entries := [][2]string{ {"what happens", "these TUIOS sessions are closed and their panes end"}, {"", "nothing in ~/.pi/paperclips is deleted; that is your Pi history"}, {"", "this project's session and attached sessions are never touched"}, {"y", "close them"}, {"esc or n", "do nothing"}, } for _, n := range names { entries = append(entries, [2]string{"", n}) } return helpOverlay(fmt.Sprintf("close %s?", fmtCount(len(names), "session", "sessions")), entries, m.width, m.height) } if m.showHelp { return helpOverlay("repair", [][2]string{ {"[ ] h l", "health, layout, rebuild log"}, {"R", "rebuild every pane"}, {"B", "add only the missing panes"}, {"↑ ↓ j k", "scroll"}, {"r", "check again"}, {"q", "leave"}, {"?", "this list"}, }, m.width, m.height) } head := titleStyle.Render("Repair") + mutedStyle2().Render(" · layout and health") state := "read " + m.readAt.Format("15:04:05") if m.readAt.IsZero() { state = "checking…" } body := m.healthLines() switch m.tab { case 1: body = m.layoutLines() case 2: body = m.logLines() case 3: body = m.cleanupLines(m.width - 3) } p := panel{title: "checks", width: m.width, height: m.height - 2, tabs: repairTabs, active: m.tab, focused: true} visible := p.bodyHeight() maxScroll := max(0, len(body)-visible) if m.scroll > maxScroll { m.scroll = maxScroll } if m.scroll < 0 { m.scroll = 0 } from := m.scroll to := min(len(body), from+visible) footer := hintLine([]string{ keyHint("( )", "tab"), keyHint("R", "rebuild"), keyHint("↑↓", "scroll"), keyHint("r", "recheck"), keyHint("q", "quit"), }, m.width) gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1) return strings.Join([]string{ head + strings.Repeat(" ", gap) + mutedStyle2().Render(state), p.render(body[from:to]), footer, }, "\n") } func (m repairModel) healthLines() []string { out := []string{""} if m.err != nil { out = append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("layout: "+m.err.Error())) out = append(out, "") } failed := 0 for _, c := range m.checks { mark, colour := icons.Done, active.Success switch c.state { case "fail": mark, colour = icons.Cancelled, active.Error failed++ case "not run": mark, colour = icons.Todo, active.Warning } out = append(out, fmt.Sprintf(" %s %-20s %s", lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark), lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(c.name), mutedStyle2().Render(truncateVisible(c.detail, max(10, m.width-30))))) } out = append(out, "") if failed == 0 { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("everything checked passes")) } else { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render( fmt.Sprintf("%d check(s) failing", failed))) } return out } // logLines shows what the last rebuild did. It is read from the log the rebuild // writes, so the record of a repair survives the panes it replaced. func (m repairModel) logLines() []string { out := []string{"", dimStyle().Render(" " + rebuildLogPath()), ""} for _, ln := range rebuildLogTail(m.height - 6) { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(ln)) } out = append(out, "") out = append(out, dimStyle().Render(" press R on any screen to rebuild every pane")) return out } func (m repairModel) layoutLines() []string { out := []string{""} out = append(out, dimStyle().Render(fmt.Sprintf(" %-9s %-14s %s", "workspace", "name", "panes"))) for _, w := range m.workspaces { name := w.name if name == "" { name = "(unnamed)" } if w.panes == 0 { continue } out = append(out, fmt.Sprintf(" %-9d %-14s %d", w.number, name, w.panes)) } out = append(out, "") out = append(out, dimStyle().Render(" a workspace with no panes is removed by TUIOS and the rest renumber,")) out = append(out, dimStyle().Render(" so every panel is found by name, never by number.")) return out } // listSessions reads every TUIOS session on this machine, newest activity first. // It never reads or touches the Pi conversation files: those are separate and // are the person's history. func listSessions(current string) []sessionRow { out, err := exec.Command("tuios", "ls", "--all-hosts", "--json").Output() if err != nil { return nil } var doc struct { Hosts []struct { Host string `json:"host"` Sessions []struct { Name string `json:"name"` Windows int `json:"window_count"` LastActive int64 `json:"last_active"` Attached bool `json:"attached"` } `json:"sessions"` } `json:"hosts"` } if json.Unmarshal(out, &doc) != nil { return nil } var rows []sessionRow for _, host := range doc.Hosts { for _, s := range host.Sessions { // A session on another machine is listed but never touched from here. row := sessionRow{ name: s.Name, panes: s.Windows, lastUsed: time.Unix(s.LastActive, 0), attached: s.Attached || host.Host != "local", } if s.Name == current { row.protected = true } rows = append(rows, row) } } sort.Slice(rows, func(i, j int) bool { return rows[i].lastUsed.After(rows[j].lastUsed) }) return rows } // cleanupLines lists TUIOS sessions, marks what is protected, and lets a person // tick the ones to close. It never touches ~/.pi/paperclips: closing a TUIOS // session ends panes, and the Pi conversations are files that stay. func (m repairModel) cleanupLines(width int) []string { out := []string{"", dimStyle().Render(fmt.Sprintf(" %-34s %6s %-12s %s", "session", "panes", "last used", "state"))} for i, r := range m.sessions { state := "" switch { case r.protected: state = "this project's session" case r.attached: state = "attached, or on another machine" case m.ticked[r.name]: state = "ticked for closing" } line := fmt.Sprintf(" %-34s %6d %-12s %s", truncateVisible(r.name, 34), r.panes, r.lastUsed.Format("2 Jan 15:04"), state) mark := " " if m.ticked[r.name] { mark = "* " } if i == m.sessAt { out = append(out, selectedStyle2().Render(padTo("▸"+line[1:], width))) continue } out = append(out, mark+line) } out = append(out, "") if m.killNote != "" { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(m.killNote)) out = append(out, "") } out = append(out, dimStyle().Render(" space ticks a session, x closes the ticked ones after asking.")) out = append(out, dimStyle().Render(" closing a TUIOS session ends its panes. It does not delete anything in")) out = append(out, dimStyle().Render(" ~/.pi/paperclips, where every Pi conversation is kept.")) return out }