Project>project overview from the dashboard endpoint; tools chooser drawn in Board>board; docs for Shell and t

This commit is contained in:
Sam
2026-11-10 21:48:03 +11:00
parent 3d31243317
commit 3e90ca82ab
14 changed files with 1225 additions and 9 deletions

View File

@@ -355,6 +355,17 @@ func (m boardModel) View() string {
m.width, m.height))
}
if m.tools {
entries := make([][2]string, 0, len(tools))
for i, t := range tools {
mark := " "
if i == m.toolAt {
mark = "▸ "
}
entries = append(entries, [2]string{mark + t.name, t.about})
}
return helpOverlay("open a tool pane here", entries, m.width, m.height)
}
if m.showHelp {
return helpOverlay("keys on this panel", [][2]string{
{"↑ ↓ j k", "choose a task"},
@@ -517,7 +528,10 @@ func (m boardModel) treeLines(width int) []string {
// makes the groups visible at a glance without a box around every one.
func groupHeading(name string, count, width int) string {
label := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(strings.ToUpper(name))
n := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(fmt.Sprintf(" %d ", count))
n := ""
if count >= 0 {
n = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(fmt.Sprintf(" %d ", count))
}
rule := strings.Repeat("─", max(0, width-lipgloss.Width(label)-lipgloss.Width(n)))
return label + dimStyle().Render(rule) + n
}

View File

@@ -458,3 +458,44 @@ func (c *Client) AgentRaw(agentID string) (map[string]any, error) {
err := c.do("GET", "/api/agents/"+agentID, nil, &out)
return out, err
}
// Dashboard is the company's own summary: counts, spend and recent run activity.
// One call, 1.8 KB, cheap enough to poll.
type Dashboard struct {
Agents struct {
Active int `json:"active"`
Running int `json:"running"`
Paused int `json:"paused"`
Error int `json:"error"`
} `json:"agents"`
Tasks struct {
Open int `json:"open"`
InProgress int `json:"inProgress"`
Blocked int `json:"blocked"`
Done int `json:"done"`
} `json:"tasks"`
Costs struct {
MonthSpendCents int `json:"monthSpendCents"`
MonthBudgetCents int `json:"monthBudgetCents"`
MonthUtilizationPercent float64 `json:"monthUtilizationPercent"`
} `json:"costs"`
PendingApprovals int `json:"pendingApprovals"`
Budgets struct {
ActiveIncidents int `json:"activeIncidents"`
PausedAgents int `json:"pausedAgents"`
PausedProjects int `json:"pausedProjects"`
} `json:"budgets"`
RunActivity []struct {
Date string `json:"date"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
} `json:"runActivity"`
}
// DashboardSummary reads the company's dashboard.
func (c *Client) DashboardSummary() (Dashboard, error) {
var out Dashboard
err := c.do("GET", "/api/companies/"+c.Company+"/dashboard", nil, &out)
return out, err
}

View File

@@ -19,6 +19,7 @@ import (
)
type projectMsg struct {
dash paperclip.Dashboard
project paperclip.Project
cfg map[string]string
agents []paperclip.Agent
@@ -38,6 +39,7 @@ type projectModel struct {
client *paperclip.Client
folder string
dash paperclip.Dashboard
project paperclip.Project
cfg map[string]string
agents []paperclip.Agent
@@ -53,7 +55,7 @@ type projectModel struct {
width, height int
}
var projectTabs = []string{"facts", "costs", "settings"}
var projectTabs = []string{"overview", "facts", "costs", "settings"}
func newProjectModel(c *paperclip.Client, folder string) projectModel {
return projectModel{client: c, folder: folder}
@@ -96,7 +98,8 @@ func (m projectModel) load() tea.Cmd {
}
costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount})
}
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs}
dash, _ := c.DashboardSummary()
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, dash: dash}
}
}
@@ -182,6 +185,7 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case projectMsg:
m.dash = msg.dash
m.project = msg.project
m.cfg = msg.cfg
m.agents = msg.agents
@@ -225,8 +229,10 @@ func (m projectModel) View() string {
var body []string
switch m.tab {
case 0:
body = m.factLines()
body = m.overviewLines(m.width - 4)
case 1:
body = m.factLines()
case 2:
body = m.costLines()
default:
body = m.settingLines()
@@ -268,6 +274,82 @@ func (m projectModel) row(label, value string) string {
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 != "" {