package main // The Project workspace: which folder, which machine, which session, what it // has cost. Read-only on purpose. A screen that can change a project's settings // is a screen that can break it while nobody is looking; changing them belongs // to a command with a name, not a keystroke. import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "paperclip-harness/tui/paperclip" ) type projectMsg struct { git gitState wts []worktreeRow dash paperclip.Dashboard project paperclip.Project cfg map[string]string agents []paperclip.Agent costs []taskCost verified string err error } type taskCost struct { Identifier string Status string Cents int Runs int } type projectModel struct { client *paperclip.Client folder string dash paperclip.Dashboard project paperclip.Project cfg map[string]string agents []paperclip.Agent costs []taskCost verified string err error readAt time.Time git gitState wts []worktreeRow tab int scroll int regAt int regs []registryRow confirmSwitch bool showHelp bool width, height int } // Tab order is also the reading order, and the numbers are named because they // are used in the key handling: a bare 4 broke once already. const ( tabOverview = iota tabGit tabFacts tabCosts tabSettings tabProjects ) var projectTabs = []string{"overview", "git", "facts", "costs", "settings", "projects"} func newProjectModel(c *paperclip.Client, folder string) projectModel { return projectModel{client: c, folder: folder} } func (m projectModel) Init() tea.Cmd { return tea.Batch(m.load(), tickEvery(10*time.Second), tea.WindowSize()) } func (m projectModel) load() tea.Cmd { c, folder := m.client, m.folder return func() tea.Msg { cfg, err := readHarnessFile(folder) if err != nil { return projectMsg{err: err} } proj, err := c.ResolveProject(cfg["paperclip.project"]) if err != nil { return projectMsg{cfg: cfg, err: err} } agents, _ := c.Agents() issues, err := c.Issues() if err != nil { return projectMsg{cfg: cfg, project: proj, err: err} } var costs []taskCost // The most recent tasks only: a cost per task is one request each, and // a whole project's history would be hundreds. var mine []paperclip.Issue for _, i := range issues { if i.ProjectID == proj.ID { mine = append(mine, i) } } for i := len(mine) - 1; i >= 0 && len(costs) < 12; i-- { t := mine[i] cost, err := c.CostSummary(t.ID) if err != nil { continue } costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount}) } dash, _ := c.DashboardSummary() git := readGitState(folder) wts, _ := readWorktrees(c, proj.ID, 8) return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, dash: dash, git: git, wts: wts} } } // readHarnessFile reads the one file this project keeps for itself. func readHarnessFile(folder string) (map[string]string, error) { b, err := os.ReadFile(projectFilePath(folder)) if err != nil { return nil, fmt.Errorf("no .harness/harness.toml in %s: run bin/office first", folder) } out := map[string]string{} section := "" for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "[") { section = strings.Trim(line, "[]") continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) if section != "" { key = section + "." + key } out[key] = val } return out, nil } func (m projectModel) 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(10*time.Second)) case tea.KeyMsg: if m.confirmSwitch { switch msg.String() { case "y": m.confirmSwitch = false if m.regAt < len(m.regs) { r := m.regs[m.regAt] params := map[string]any{"name": r.session} me, _ := os.Hostname() if r.host != "" && r.host != me && r.host != strings.Split(me, ".")[0] { params["host"] = r.host } body, _ := json.Marshal(params) out, err := exec.Command("tuios", "call", "switch-session", string(body)).CombinedOutput() if err != nil { m.err = fmt.Errorf("could not switch to %q: %v %s", r.session, err, strings.TrimSpace(string(out))) } } return m, nil case "esc", "n", "q": m.confirmSwitch = 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 "t": // lazygit, in the project folder: it is the tool that can stage, // exclude and commit. Nothing here reimplements it. if err := openTool(m.folder, "lazygit"); err != nil { m.err = err } return m, nil case "R": if err := startRebuild(m.folder); err != nil { m.err = err return m, nil } return m, tea.Quit case "enter": if m.tab == tabProjects { m.regs = readRegistry() if len(m.regs) > 0 && m.regAt < len(m.regs) { m.confirmSwitch = true } } return m, nil case "(", "[", "h": if m.tab > 0 { m.tab-- m.scroll = 0 } return m, nil case ")", "]", "l", "tab": if m.tab < len(projectTabs)-1 { m.tab++ m.scroll = 0 } return m, nil case "up", "k": if m.tab == tabProjects && m.regAt > 0 { m.regAt-- } m.scroll-- return m, nil case "down", "j": if m.tab == tabProjects && m.regAt < len(m.regs)-1 { m.regAt++ } m.scroll++ return m, nil case "esc": return m, nil } case projectMsg: m.regs = readRegistry() m.regAt = min(m.regAt, max(0, len(m.regs)-1)) m.dash = msg.dash m.project = msg.project m.cfg = msg.cfg m.agents = msg.agents m.costs = msg.costs m.git = msg.git m.wts = msg.wts m.err = msg.err m.readAt = time.Now() return m, nil } return m, nil } func (m projectModel) 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.confirmSwitch && m.regAt < len(m.regs) { r := m.regs[m.regAt] return helpOverlay("switch to "+r.session+"?", [][2]string{ {"what happens", "your TUIOS client moves to that session"}, {"session", r.session}, {"machine", r.host}, {"folder", r.folder}, {"y", "switch"}, {"esc or n", "stay here"}, }, m.width, m.height) } if m.showHelp { return helpOverlay("the project", [][2]string{ {"( )", "overview, git, facts, costs, settings, projects"}, {"t", "open lazygit in this folder"}, {"[ ] h l", "previous and next tab"}, {"↑ ↓ j k", "scroll"}, {"r", "read again"}, {"R", "rebuild every pane"}, {"q", "leave"}, {"?", "this list"}, }, m.width, m.height) } head := titleStyle.Render("Project") + mutedStyle2().Render(" · ") if m.project.Name != "" { head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.project.Name) } state := "read " + m.readAt.Format("15:04:05") if m.readAt.IsZero() { state = "reading…" } var body []string switch m.tab { case tabOverview: body = m.overviewLines(m.width - 4) case tabGit: body = m.gitLines(m.width - 4) case tabFacts: body = m.factLines() case tabCosts: body = m.costLines() case tabProjects: body = m.projectListLines(m.width - 4) default: body = m.settingLines() } if m.err != nil { body = append([]string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error()), ""}, body...) } p := panel{title: "the project", width: m.width, height: m.height - 2, tabs: projectTabs, 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("t", "lazygit"), keyHint("↑↓", "scroll"), keyHint("r", "refresh"), keyHint("R", "rebuild"), 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 projectModel) row(label, value string) string { return mutedStyle2().Render(fmt.Sprintf("%-16s", label)) + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value) } // overviewLines is the company dashboard, drawn as numbers a person can act on // and a sparkline of the last two weeks of runs. Everything here comes from // GET /api/companies/{id}/dashboard in one call. func (m projectModel) overviewLines(width int) []string { d := m.dash out := []string{"", groupHeading("agents", -1, width), ""} out = append(out, " "+stat("active", d.Agents.Active, active.Text)+ " "+stat("running", d.Agents.Running, active.Info)+ " "+stat("paused", d.Agents.Paused, active.Warning)+ " "+stat("error", d.Agents.Error, active.Error)) out = append(out, "", groupHeading("tasks", -1, width), "") out = append(out, " "+stat("open", d.Tasks.Open, active.Text)+ " "+stat("in progress", d.Tasks.InProgress, active.Info)+ " "+stat("blocked", d.Tasks.Blocked, active.Error)+ " "+stat("done", d.Tasks.Done, active.Success)) out = append(out, "", groupHeading("money, this month", -1, width), "") budget := "no company budget set" if d.Costs.MonthBudgetCents > 0 { budget = fmt.Sprintf("%.0f%% of %d cents", d.Costs.MonthUtilizationPercent, d.Costs.MonthBudgetCents) } out = append(out, " "+stat("cents spent", d.Costs.MonthSpendCents, active.Text)+" "+ mutedStyle2().Render(budget)) out = append(out, " "+stat("pending approvals", d.PendingApprovals, active.Warning)) if d.Budgets.ActiveIncidents > 0 || d.Budgets.PausedAgents > 0 || d.Budgets.PausedProjects > 0 { out = append(out, " "+stat("budget incidents", d.Budgets.ActiveIncidents, active.Error)+ mutedStyle2().Render(fmt.Sprintf(" paused: %d agent(s), %d project(s)", d.Budgets.PausedAgents, d.Budgets.PausedProjects))) } out = append(out, "", groupHeading("runs, last two weeks", -1, width), "") out = append(out, " "+sparkline(d.RunActivity)) if n := len(d.RunActivity); n > 0 { first, last := d.RunActivity[0].Date, d.RunActivity[n-1].Date out = append(out, dimStyle().Render(" "+first+" → "+last+" one bar per day, taller is more runs")) } out = append(out, "") out = append(out, dimStyle().Render(" read from Paperclip's own dashboard, not counted here.")) return out } // stat is a label and a number, the number first because it is what you look at. func stat(label string, value int, colour string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Bold(true).Render(fmt.Sprintf("%d", value)) + " " + mutedStyle2().Render(label) } // sparkline draws one bar per day using block characters, scaled so the busiest // day fills the height. No chart library, no second language: the data is in // memory and the terminal already knows how to draw blocks. func sparkline(points []struct { Date string `json:"date"` Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` }) string { bars := []rune("▁▂▃▄▅▆▇█") peak := 0 for _, p := range points { if p.Total > peak { peak = p.Total } } if peak == 0 { return mutedStyle2().Render("no runs in the last two weeks") } var b strings.Builder for _, p := range points { idx := int(float64(p.Total) / float64(peak) * float64(len(bars)-1)) colour := active.Success if p.Failed > 0 { colour = active.Warning } b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(string(bars[idx]))) } return b.String() } func (m projectModel) factLines() []string { out := []string{""} if m.project.Name != "" { out = append(out, m.row("project", m.project.Name)) out = append(out, m.row("project id", m.project.ID)) out = append(out, m.row("status", m.project.Status)) } out = append(out, m.row("folder", m.folder)) out = append(out, m.row("machine", m.cfg["machine.host"])) out = append(out, m.row("session", m.cfg["session"])) out = append(out, "") out = append(out, groupHeading("agents", len(m.agents), m.width-4)) for _, a := range m.agents { mark := icons.Todo if a.Status == "idle" { mark = icons.Done } out = append(out, fmt.Sprintf(" %s %-16s %s", mark, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Name), mutedStyle2().Render(a.Status))) } return out } func (m projectModel) costLines() []string { if len(m.costs) == 0 { return []string{"", mutedStyle2().Render("no recorded spend for recent tasks")} } total := 0 runs := 0 peak := 1 for _, c := range m.costs { if c.Cents > peak { peak = c.Cents } } out := []string{"", dimStyle().Render(fmt.Sprintf("%-10s %-10s %6s %5s %s", "task", "state", "cents", "runs", "share of the largest"))} for _, c := range m.costs { total += c.Cents runs += c.Runs bars := int(float64(c.Cents) / float64(peak) * 24) bar := strings.Repeat("█", bars) if c.Cents > 0 && bars == 0 { bar = "▏" } out = append(out, fmt.Sprintf("%-10s %-10s %6d %5d %s", c.Identifier, c.Status, c.Cents, c.Runs, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render(bar))) } out = append(out, "") out = append(out, m.row("total, recent", fmt.Sprintf("%d cent(s) over %d run(s)", total, runs))) out = append(out, dimStyle().Render(" costs come from Paperclip; nothing is added up twice")) return out } func (m projectModel) settingLines() []string { out := []string{"", groupHeading("this project's own file", 6, m.width-4)} out = append(out, m.row("session", m.cfg["session"])) out = append(out, m.row("machine", m.cfg["machine.host"])) out = append(out, m.row("folder", m.cfg["machine.folder"])) out = append(out, m.row("paperclip", m.cfg["paperclip.project"])) out = append(out, m.row("watching", m.cfg["watching"])) out = append(out, m.row("verify command", m.verifyCommand())) out = append(out, "") out = append(out, dimStyle().Render(" .harness/harness.toml is the only thing this project keeps for itself.")) out = append(out, dimStyle().Render(" Paperclip owns tasks, blockers, comments, runs, cost and worktrees.")) out = append(out, "") out = append(out, groupHeading("limits held by Paperclip", len(m.agents), m.width-4)) for _, a := range m.agents { out = append(out, m.row(a.Name, fmt.Sprintf("%s · monthly budget %d cents · spent %d", a.Status, a.BudgetCents, a.SpentCents))) } out = append(out, "", dimStyle().Render(" change a budget in Paperclip, not here: it is their fact, and a second")) out = append(out, dimStyle().Render(" copy of it is how two screens start disagreeing.")) return out } func (m projectModel) verifyCommand() string { issues, err := m.client.Issues() if err == nil { for _, i := range issues { if i.ProjectID == m.project.ID { if cmd := verifyFromDescription(i.Description); cmd != "" { return cmd + " (from " + i.Identifier + ")" } } } } path := m.folder + "/.harness/verify.sh" if _, err := os.Stat(path); err == nil { return ".harness/verify.sh" } return "none agreed: this would pass on an agent's word" } var _ = exec.Command // registryRow is one adopted project, from the file bin/office writes. type registryRow struct { name, folder, host, session, id string } // readRegistry lists adopted projects. It is the only thing this program keeps // besides the project file: where a folder is, and on which machine. func readRegistry() []registryRow { home, _ := os.UserHomeDir() path := filepath.Join(home, ".config", "paperclip-harness", "projects.toml") b, err := os.ReadFile(path) if err != nil { return nil } var rows []registryRow cur := map[string]string{} id := "" flush := func() { if id != "" && cur["folder"] != "" { rows = append(rows, registryRow{ name: cur["name"], folder: cur["folder"], host: cur["host"], session: cur["session"], id: id, }) } cur = map[string]string{} } for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "[projects.") { flush() id = strings.Trim(strings.TrimPrefix(line, "[projects."), "]'\"") continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } cur[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'") } flush() return rows } // projectListLines shows every adopted project and where it lives. func (m projectModel) projectListLines(width int) []string { rows := readRegistry() out := []string{"", dimStyle().Render(fmt.Sprintf(" %-26s %-12s %-14s %s", "project", "machine", "session", "folder"))} me, _ := os.Hostname() for i, r := range rows { here := "" if r.host == me || r.host == strings.Split(me, ".")[0] { here = " (this machine)" } line := fmt.Sprintf(" %-26s %-12s %-14s %s%s", truncateVisible(r.name, 26), r.host, truncateVisible(r.session, 14), truncateVisible(r.folder, max(10, width-60)), here) if i == m.regAt { out = append(out, selectedStyle2().Render(padTo("▸"+line[1:], width))) continue } out = append(out, line) } if len(rows) == 0 { out = append(out, " "+mutedStyle2().Render("none adopted yet: bin/office adds one")) } out = append(out, "") out = append(out, dimStyle().Render(" enter switches your TUIOS client to that project's session.")) out = append(out, dimStyle().Render(" a session on another machine switches only when that machine is in your")) out = append(out, dimStyle().Render(" ~/.config/tuios hosts table; otherwise attach to it by hand.")) return out } // gitLines is the git tab: the folder read locally, then what Paperclip knows // about each task's worktree. The two halves answer different questions and are // not merged into one verdict. func (m projectModel) gitLines(width int) []string { var out []string g := m.git report := func(label, value string, bad bool) { line := " " + mutedStyle2().Render(fmt.Sprintf("%-14s", label)) style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)) if bad { style = style.Foreground(lipgloss.Color(active.Warning)) } out = append(out, line+style.Render(value)) } // --- the folder --- out = append(out, mutedStyle2().Render(" THIS FOLDER")) if !g.IsRepo { report("repository", "no — this folder is not a git repository", true) } else { branch := g.Branch if branch == "" { branch = "(no branch yet)" } report("repository", "yes · branch "+branch, false) report("commits", fmt.Sprintf("%d", g.Commits), !g.HasCommit) if g.Commits == 0 { out = append(out, " no commit, so nothing to cut a worktree from") } remotes := "none — not needed for worktrees" if len(g.Remotes) > 0 { remotes = strings.Join(g.Remotes, ", ") } report("remote", remotes, false) report("changed", fmt.Sprintf("%d staged · %d unstaged · %d untracked", g.Staged, g.Unstaged, g.Untracked), false) if g.HasIgnore { shown := strings.Join(g.Ignore, " ") if len(shown) > width-24 { shown = shown[:width-25] + "…" } if shown == "" { shown = "(no entries)" } report(".gitignore", shown, !g.ignoresPaperclipWorktrees()) } else { report(".gitignore", "missing — .paperclip/ must be in it", true) } } out = append(out, "") // --- what Paperclip will do with each task --- policy := "not set" if p := m.project.ExecutionWorkspacePolicy; p != nil && p.Enabled { policy = p.DefaultMode if p.Strategy.Type != "" { policy += " · " + p.Strategy.Type } if p.Strategy.Base != "" { policy += " · base " + p.Strategy.Base } } report("worktrees", policy, m.project.ExecutionWorkspacePolicy == nil || (m.project.ExecutionWorkspacePolicy != nil && m.project.ExecutionWorkspacePolicy.Strategy.Type != "git_worktree")) out = append(out, "") // --- what to do, when something is wrong --- if !g.ready() || !g.ignoresPaperclipWorktrees() { out = append(out, mutedStyle2().Render(" WHAT TO DO")) folder := m.folder if !g.ignoresPaperclipWorktrees() { out = append(out, " .paperclip/ is not ignored: a commit would swallow every worktree.") out = append(out, " "+dimStyle().Render("t lazygit · select .gitignore · e to edit · add .paperclip/")) out = append(out, "") } if !g.IsRepo { out = append(out, " This folder is not a repository, so no task can have its own branch.") out = append(out, " "+dimStyle().Render("cd "+folder+" && git init -b main")) out = append(out, " "+dimStyle().Render("then commit something — in lazygit: t, then c")) } else if !g.HasCommit { out = append(out, " This repository has no commit, so there is nothing to cut a worktree from.") out = append(out, " "+dimStyle().Render("t lazygit · space to stage · c to commit")) } out = append(out, "") out = append(out, " Then run "+keyHint("R", "rebuild")+" so every pane is built against the new state.") out = append(out, "") } // --- Paperclip's own scan of the task worktrees --- out = append(out, mutedStyle2().Render(" WORKTREES, AS PAPERCLIP SEES THEM")) if len(m.wts) == 0 { out = append(out, dimStyle().Render(" none yet — one is made when a task runs")) } for _, w := range m.wts { state := "clean" if w.Dirty > 0 { state = fmt.Sprintf("%d changed", w.Dirty) } extra := "" if w.Ahead > 0 { extra += fmt.Sprintf(" · %d ahead", w.Ahead) } if w.Behind > 0 { extra += fmt.Sprintf(" · %d behind", w.Behind) } if w.Merged { extra += " · merged into base" } branch := w.Branch if branch == "" { branch = "(no branch)" } if len(branch) > width-34 { branch = branch[:width-35] + "…" } out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(branch)+ dimStyle().Render(" "+state+extra)) if w.Warning != "" { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(w.Warning)) } } out = append(out, "") out = append(out, dimStyle().Render(" read from git in this folder, and from Paperclip's scan of each worktree")) return out }