Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201package tui
import ( "cmp" "fmt" "slices" "strings"
"charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi")
// View draws the list beside the highlighted hook's detail. The form and the// secret shown once take the list's place, and a question takes the footer.func (m WebhooksPageModel) View() string { frame := m.listView.frame(brand("albedo")+" "+DefaultStyles.Muted.Render("/webhooks"), DefaultStyles.Faint.Render("all sessions · agent access "+onOff(m.AgentManagement)), m.footer(max(1, m.Width))) switch { case m.Reveal != nil: frame.filter, frame.pane, frame.list = "", nil, m.revealRows case m.Form != nil: frame.filter, frame.pane, frame.list = "", nil, m.formRows case !m.Loaded || len(m.Hooks) == 0: frame.filter, frame.pane = "", nil frame.list = func(width, _ int) []string { return m.guide(width) } } return frame.view(m.Width, m.Height)}
// guide stands in for the list while there is nothing to list: loading, a// failed load, the extension off, or no hooks yet.func (m WebhooksPageModel) guide(width int) []string { faint := DefaultStyles.Faint.Render switch { case m.Loading && !m.Loaded: return []string{faint(" loading webhooks…")} case !m.Loaded: return []string{faint(" webhooks did not load · ctrl+r retries")} case !m.Mounted: return slices.Concat( guideText(width, DefaultStyles.Bold, "Webhooks are off."), []string{""}, guideText(width, lipgloss.NewStyle(), "Open /extensions, select webhooks and press enter to turn it on for every session."), guideText(width, DefaultStyles.Faint, "The daemon listens for deliveries as soon as it is on; come back here to add a hook."), ) } rows := slices.Concat( guideText(width, DefaultStyles.Bold, "Wake a session with a signed request"), guideText(width, DefaultStyles.Faint, "Each hook has its own URL and secret; a delivery it accepts wakes the session it belongs to."), []string{""}, ) rows = append(rows, guideStep(width, "1", "Add a hook with ctrl+o", "Name it and pick the session it wakes. A generated secret is shown once, right after you add it.")...) rows = append(rows, guideStep(width, "2", "Copy its URL with ctrl+l", "The intake listens on 127.0.0.1; put a tunnel or proxy in front of it for a sender elsewhere.")...) rows = append(rows, guideStep(width, "3", "Sign every POST", "X-Albedo-Signature: sha256=<HMAC-SHA256 of the exact body, hex>, keyed with the secret.")...) return rows}
// footer is the keys for what is open and one status: a failure, a notice,// work in flight, or what the hooks are doing.func (m WebhooksPageModel) footer(width int) string { switch { case m.Reveal != nil: status, urgent := m.status() return footerLine(width, []hint{{"ctrl+l", "copy"}, {"enter", "done"}}, status, urgent) case m.Form != nil: return m.formFooter(width) case m.confirm.asking(): return m.confirm.footer(width, m.Error) } status, urgent := m.status() return footerLine(width, m.browseHints(), status, urgent)}
// status is the footer's state beside the keys. An urgent status takes a row// of its own when the keys do not leave room for it.func (m WebhooksPageModel) status() (string, bool) { switch { case m.Error != "": return DefaultStyles.Error.Render(m.Error), true case m.Saving: return DefaultStyles.Busy.Render("saving…"), true case m.Loading: return DefaultStyles.Faint.Render("loading…"), false case m.Notice != "": return DefaultStyles.Success.Render(m.Notice), true case !m.Mounted && len(m.Hooks) > 0: return DefaultStyles.Warning.Render("Webhooks are not listening · enable them in /extensions"), true } return "", false}
// browseHints keeps the main hook action and session agent access visible// first, then the hook's own actions.func (m WebhooksPageModel) browseHints() []hint { if !m.Loaded { return []hint{{"ctrl+r", "retry"}, {"esc", "back"}} } _, selected := m.current() var hints []hint if selected { hints = append(hints, hint{"enter", "on/off"}) } hints = append(hints, hint{"ctrl+t", "agent access"}, hint{"ctrl+o", "add hook"}) if selected { hints = append(hints, hint{"ctrl+e", "edit"}, hint{"ctrl+l", "copy url"}, hint{"ctrl+d", "delete"}, hint{"ctrl+g", "new secret"}) } return append(hints, hint{"ctrl+r", "refresh"}, hint{"esc", "back"})}
// formFooter is the form's keys, with a failure or the save in flight above them.func (m WebhooksPageModel) formFooter(width int) string { line := m.Form.footer(width) switch { case m.Error != "": var rows []string for _, l := range svWrap(m.Error, max(1, width-2), 2) { rows = append(rows, " "+DefaultStyles.Error.Render(l)) } return strings.Join(append(rows, line), "\n") case m.Saving: return footerLine(width, nil, DefaultStyles.Busy.Render("saving…"), true) + "\n" + line } return line}
// formRows is the form in the list's place, scrolled to keep its focused field in view.func (m WebhooksPageModel) formRows(width, height int) []string { return inset(scrolled(m.Form.view(width-1), 1+m.Form.Focus, height))}
// revealRows is the generated secret, shown once, in the list's place. Its// session and warning sit on rows of their own so a narrow terminal keeps them.func (m WebhooksPageModel) revealRows(_, _ int) []string { r := m.Reveal return inset([]string{ DefaultStyles.Bold.Render("signing secret for " + r.Hook), DefaultStyles.Faint.Render("→ " + sessionLabel(m.Sessions, r.Session, m.SessionID)), DefaultStyles.Warning.Render("shown only once; copy it now"), "", " " + DefaultStyles.Prompt.Render(r.Secret), })}
// intakeStopped stands in for a hook's URL once the daemon's webhook intake// listener gave up; senders reach nothing until the daemon restarts.const intakeStopped = "intake stopped · restart the daemon"
// entry is how a hook reads in the list: its state in front, its URL under// its name, and the deliveries waiting for its session at the edge.func (m WebhooksPageModel) entry(hook webhookEntry) listEntry { lead, tag := DefaultStyles.Faint.Render("off"), "" if hook.Enabled { lead = DefaultStyles.Success.Render("on") } if hook.Queued > 0 { tag = DefaultStyles.Warning.Render(fmt.Sprintf("%d queued", hook.Queued)) } section := sessionLabel(m.Sessions, hook.Session, m.SessionID) return listEntry{ key: hook.ID, section: section, lead: lead, name: hook.Name, desc: cmp.Or(hook.URL, intakeStopped), tag: tag, search: []string{section}, detail: func(width int) []string { return m.details(hook, width) }, }}
// details is the pane: the url first, so a short stacked pane keeps it, then// the session it wakes, how deliveries are signed, and what is waiting.func (m WebhooksPageModel) details(hook webhookEntry, width int) []string { state := "off · new deliveries answer 404" if hook.Enabled { state = "on · accepting signed deliveries" } lines := paneTitle(hook.Name, state, width) url := intakeStopped if address := cmp.Or(hook.Address, hook.URL); address != "" { url = "POST " + address } lines = append(lines, factRows("url", ansi.Truncate(url, max(1, width-factLabelW), "…"), width)...) lines = append(lines, factRows("wakes", sessionLabel(m.Sessions, hook.Session, m.SessionID), width)...) lines = append(lines, factRows("signature", hook.Header+": "+hook.Prefix+"<hex HMAC-SHA256 of the body>", width)...) lines = append(lines, factRows("secret", "stored · ctrl+g makes a new one", width)...) inbox := "empty" if hook.Queued > 0 { inbox = fmt.Sprintf("%d waiting for the session", hook.Queued) if hook.Deferred != "" { inbox += " · last attempt: " + hook.Deferred } } return append(lines, factRows("inbox", inbox, width)...)}
// scrolled is the window of rows lines that keeps line at in view.func scrolled(lines []string, at, rows int) []string { start := max(0, at-rows+1) return lines[start:min(len(lines), start+rows)]}