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 ( "fmt" "os" "os/exec" "path/filepath" "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 { checks []checkResult workspaces []layoutRow runs int err error } 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 } var repairTabs = []string{"health", "layout", "rebuild log"} 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)) } 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) res := repairMsg{checks: checks, workspaces: ws} 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)) case tea.KeyMsg: 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 "up", "k": m.scroll-- return m, nil case "down", "j": m.scroll++ return m, nil case "esc": return m, nil } case repairMsg: 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.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() } 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("[ ]", "switch tab"), keyHint("R", "rebuild every pane"), keyHint("↑↓ or j k", "scroll"), keyHint("r", "check again"), keyHint("q", "leave"), }, 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, "") // The one way to tell whether the terminal is drawing the font: show the // glyphs themselves. Nothing else on this screen answers that question. out = append(out, " "+mutedStyle2().Render("icons ")+ icons.Done+" "+icons.Running+" "+icons.Blocked+" "+icons.Review+" "+ icons.Branch+" "+icons.Folder+" "+icons.Cost+" "+ dimStyle().Render("if these are boxes, this terminal is not using a Nerd Font")) 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 }