package tui
import (
"albedo/cli/internal/daemon"
"cmp"
"context"
"errors"
"fmt"
"regexp"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
// WebhooksPageModel is the Webhooks screen: every signed endpoint grouped by
// the session it wakes, their inboxes, and whether the agent of the session it
// was opened from may manage its own. Every change runs /webhooks on the
// daemon and then reloads, so the list is never patched locally.
type WebhooksPageModel struct {
Conn *daemon.Connection
Form *webhookForm
// Reveal holds a generated secret until it is dismissed; it is never
// fetched again.
Reveal *webhookSecret
SessionID string
Confirm string // "delete" or "rotate"
// Sessions are the ones a hook can target, the current one first.
Sessions []daemon.Session
Hooks []webhookEntry
page
AgentManagement bool
// Mounted reports whether the extension is on globally; the daemon only
// listens for deliveries then.
Mounted bool
Loaded bool
}
type webhookEntry struct {
ID string
Session string
Name string
Address string
URL string
Header string
Prefix string
Deferred string
Queued int
Enabled bool
}
type webhookSecret struct{ Hook, Session, Secret string }
type webhooksLoadedMsg struct {
Err error
Sessions []daemon.Session
Hooks []webhookEntry
Gen int
Agent bool
Mounted bool
}
type webhooksSavedMsg struct {
Err error
Reveal *webhookSecret
Notice string
Gen int
}
type WebhooksPageDoneMsg struct{}
type ChatOpenWebhooksPageMsg struct{}
const (
defaultSignatureHeader = "x-albedo-signature"
defaultSignaturePrefix = "sha256="
)
func NewWebhooksPageModel(conn *daemon.Connection, sessionID string) WebhooksPageModel {
return WebhooksPageModel{Conn: conn, SessionID: sessionID, Mounted: true, page: page{Loading: true, Generation: nextPageGeneration()}}
}
func (m WebhooksPageModel) Init() tea.Cmd { return m.loadCmd(m.Generation) }
func (m WebhooksPageModel) loadCmd(gen int) tea.Cmd {
return func() tea.Msg {
if m.Conn == nil {
return webhooksLoadedMsg{Gen: gen, Err: errors.New("daemon connection unavailable")}
}
mounted := true
extensions, err := daemon.ListExtensions(context.Background(), m.Conn, m.SessionID)
if err != nil {
return webhooksLoadedMsg{Gen: gen, Err: err}
}
if i := slices.IndexFunc(extensions, func(ext ExtensionItem) bool { return ext.Name == "webhooks" }); i >= 0 {
mounted = extensions[i].GlobalEnabled
if !extensions[i].Enabled {
return webhooksLoadedMsg{Gen: gen, Err: errors.New("webhooks are off for this session; turn them on in /extensions")}
}
}
all, err := daemon.ListSessions(context.Background(), m.Conn)
if err != nil {
return webhooksLoadedMsg{Gen: gen, Err: err}
}
var sessions []daemon.Session
for _, s := range all {
if s.ID == m.SessionID {
sessions = slices.Insert(sessions, 0, s)
} else {
sessions = append(sessions, s)
}
}
res, err := daemon.RunWebhook(context.Background(), m.Conn, m.SessionID, daemon.WebhookRequest{Action: daemon.WebhookList})
if err != nil {
return webhooksLoadedMsg{Gen: gen, Err: err}
}
agent := res.AgentManagement
hooks := make([]webhookEntry, len(res.Hooks))
for i, entry := range res.Hooks {
hook := entry.Hook
deferred := ""
if entry.Deferred != nil {
deferred = *entry.Deferred
}
hooks[i] = webhookEntry{
ID: hook.ID, Session: hook.Session, Name: hook.Name, URL: hook.URL, Address: hook.Address,
Header: hook.Header, Prefix: hook.Prefix, Enabled: hook.Enabled,
Queued: entry.Queued, Deferred: deferred,
}
}
// Group by session in the order they are offered, current first.
rank := func(id string) int {
if i := slices.IndexFunc(sessions, func(s daemon.Session) bool { return s.ID == id }); i >= 0 {
return i
}
return len(sessions)
}
slices.SortStableFunc(hooks, func(a, b webhookEntry) int { return cmp.Compare(rank(a.Session), rank(b.Session)) })
return webhooksLoadedMsg{Sessions: sessions, Hooks: hooks, Agent: agent, Mounted: mounted, Gen: gen}
}
}
// save runs steps in order and stops at the first failure; a generated secret
// from any step is carried back to be shown once.
func (m WebhooksPageModel) save(gen int, notice string, steps ...daemon.WebhookRequest) tea.Cmd {
return func() tea.Msg {
var reveal *webhookSecret
created := ""
for _, step := range steps {
if step.HookID == "" {
step.HookID = created
}
res, err := daemon.RunWebhook(context.Background(), m.Conn, m.SessionID, step)
if err != nil {
// The hook exists with this secret even though a later step
// failed, so it still has to be shown.
return webhooksSavedMsg{Reveal: reveal, Gen: gen, Err: err}
}
if res.Hook != nil {
created = res.Hook.ID
if res.Secret != "" {
reveal = &webhookSecret{Hook: res.Hook.Name, Session: res.Hook.Session, Secret: res.Secret}
}
}
}
return webhooksSavedMsg{Notice: notice, Reveal: reveal, Gen: gen}
}
}
func (m WebhooksPageModel) selected() *webhookEntry {
if m.Cursor < len(m.Hooks) {
return &m.Hooks[m.Cursor]
}
return nil
}
// sessionLabel names a session plainly enough to tell it apart from the
// others. Whether it is the session this screen was opened from comes first,
// and the title is capped, so a long title never hides the rest.
func sessionLabel(sessions []daemon.Session, id, current string) string {
parts := []string{"session " + id[:min(8, len(id))]}
if i := slices.IndexFunc(sessions, func(s daemon.Session) bool { return s.ID == id }); i >= 0 {
s := sessions[i]
parts = []string{ansi.Truncate(sessionTitle(s), 32, "…")}
if s.Workspace != "" {
parts = append(parts, homePath(s.Workspace))
}
}
if id == current {
parts = append([]string{"this session"}, parts...)
}
return strings.Join(parts, " · ")
}
func (m WebhooksPageModel) begin(notice string, steps ...daemon.WebhookRequest) (WebhooksPageModel, tea.Cmd) {
m.Error, m.Notice = "", ""
m.Saving = true
m.Generation = nextPageGeneration()
return m, m.save(m.Generation, notice, steps...)
}
func (m WebhooksPageModel) Update(msg tea.Msg) (WebhooksPageModel, tea.Cmd) {
switch msg := msg.(type) {
case webhooksLoadedMsg:
if !m.settle(msg.Gen, msg.Err, &m.Loading) {
return m, nil
}
m.Loaded = true
m.Cursor = reselect(m.Cursor, m.Hooks, msg.Hooks, func(hook webhookEntry) string { return hook.ID })
m.Sessions, m.Hooks, m.AgentManagement, m.Mounted = msg.Sessions, msg.Hooks, msg.Agent, msg.Mounted
return m, nil
case webhooksSavedMsg:
if msg.Gen != m.Generation {
return m, nil
}
m.Saving, m.Reveal = false, msg.Reveal
if msg.Err != nil {
m.Error = msg.Err.Error()
if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](msg.Err); uncertain {
return m, nil
}
if msg.Reveal == nil {
return m, nil
}
} else {
m.Form, m.Notice = nil, msg.Notice
}
m.Loading, m.Generation = true, nextPageGeneration()
return m, m.loadCmd(m.Generation)
case tea.KeyPressMsg:
key := msg.String()
if m.Saving {
return m, nil
}
if m.Reveal != nil {
switch key {
case "c":
m.Error, m.Notice = "", "Secret copied."
return m, CopyText(m.Reveal.Secret)
case "enter", "esc":
m.Reveal = nil
}
return m, nil
}
if m.Form != nil {
if key == "esc" {
m.Form = nil
m.Error = ""
return m, nil
}
submit, cmd := m.Form.update(msg)
if !submit {
return m, cmd
}
steps, notice, err := m.Form.steps()
if err != nil {
m.Error = err.Error()
return m, nil
}
if len(steps) == 0 {
m.Form = nil
m.Error, m.Notice = "", "No changes made."
return m, nil
}
return m.begin(notice, steps...)
}
if m.Confirm != "" {
action := m.Confirm
m.Confirm = ""
hook := m.selected()
if key != "enter" || hook == nil {
return m, nil
}
note := "New secret for " + hook.Name
if action == "delete" {
note = "Deleted " + hook.Name
}
return m.begin(note, daemon.WebhookRequest{Action: daemon.WebhookAction(action), HookID: hook.ID})
}
if key == "esc" || key == "ctrl+c" {
return m, func() tea.Msg { return WebhooksPageDoneMsg{} }
}
if m.Loading {
return m, nil
}
if key == "r" {
m.Error, m.Notice = "", ""
m.Loading = true
m.Generation = nextPageGeneration()
return m, m.loadCmd(m.Generation)
}
if !m.Loaded {
return m, nil
}
if m.step(key, len(m.Hooks)) {
return m, nil
}
hook := m.selected()
switch key {
case "enter":
if hook != nil {
m.Error, m.Notice = "", ""
m.Form = newWebhookForm(hook, m.Sessions, m.SessionID)
}
case "space":
if hook != nil {
action, note := "enable", hook.Name+" enabled."
if hook.Enabled {
action, note = "disable", hook.Name+" disabled. New deliveries will receive a 404 response."
}
return m.begin(note, daemon.WebhookRequest{Action: daemon.WebhookAction(action), HookID: hook.ID})
}
case "n":
m.Error, m.Notice = "", ""
m.Form = newWebhookForm(nil, m.Sessions, m.SessionID)
case "a":
action, note := daemon.WebhookAgentEnable, "This session’s agent can now manage its own webhooks."
if m.AgentManagement {
action, note = daemon.WebhookAgentDisable, "This session’s agent can no longer manage its webhooks."
}
return m.begin(note, daemon.WebhookRequest{Action: action})
case "d", "k":
if hook != nil {
if key == "d" {
m.Confirm = "delete"
} else {
m.Confirm = "rotate"
}
}
case "y":
if hook != nil {
m.Error, m.Notice = "", "URL copied."
return m, CopyText(cmp.Or(hook.Address, hook.URL))
}
}
case tea.PasteMsg:
if m.Saving || m.Reveal != nil || m.Form == nil {
return m, nil
}
_, cmd := m.Form.update(msg)
return m, cmd
}
return m, nil
}
func (m WebhooksPageModel) View() string {
width := max(1, m.Width)
rows := m.header("/webhooks", "all sessions")
if m.Loading && !m.Loaded {
rows = append(rows, DefaultStyles.Faint.Render("loading webhooks…"))
return strings.Join(rows, "\n")
}
if !m.Loaded {
return strings.Join(append(rows, "", keyHints(hint{"r", "retry"}, hint{"esc", "back"})), "\n")
}
// Give the confirmation the screen instead of truncating its consequence or target.
if m.Confirm != "" && m.selected() != nil {
question := "Its URL will stop working; accepted deliveries stay in the inbox. Delete " + m.selected().Name + "?"
if m.Confirm == "rotate" {
question = "The sender must use the new secret. Replace the secret for " + m.selected().Name + "?"
}
rows = append(rows, "")
for line := range strings.SplitSeq(ansi.Wrap(question, width, " "), "\n") {
rows = append(rows, DefaultStyles.Warning.Render(line))
}
rows = append(rows, keyHints(hint{"enter", "confirm"}, hint{"any other key", "cancels"}))
return m.fit(rows)
}
if !m.Mounted {
rows = append(rows, DefaultStyles.Warning.Render("Webhooks are not listening")+DefaultStyles.Faint.Render(" · enable webhooks globally in /extensions to accept deliveries"))
}
agent := DefaultStyles.Faint.Render("off") + DefaultStyles.Faint.Render(" · this session's agent can't touch webhooks")
if m.AgentManagement {
agent = DefaultStyles.Success.Render("on ") + DefaultStyles.Faint.Render(" · this session's agent can add, rotate and delete its own hooks")
}
rows = append(rows, ansi.Truncate(DefaultStyles.Muted.Render("agent access ")+agent, width, "…"), "")
if len(m.Hooks) == 0 {
rows = append(rows, DefaultStyles.Faint.Render("No webhooks yet. Add one to wake a session with a signed request."))
}
nameWidth := 4
for _, hook := range m.Hooks {
nameWidth = max(nameWidth, ansi.StringWidth(hook.Name))
}
// Hooks sit under the session they wake; the cursor line is kept in view.
counts := map[string]int{}
for _, hook := range m.Hooks {
counts[hook.Session]++
}
var list []string
cursorLine := 0
for i, hook := range m.Hooks {
if i == 0 || hook.Session != m.Hooks[i-1].Session {
if i > 0 {
list = append(list, "")
}
list = append(list, sectionRule(ansi.Truncate(sessionLabel(m.Sessions, hook.Session, m.SessionID), max(8, width-12), "…"), counts[hook.Session], width))
}
var label string
if hook.Enabled {
label = DefaultStyles.Success.Render("on ")
} else {
label = DefaultStyles.Faint.Render("off")
}
row := label + " " + padRight(hook.Name, nameWidth+1) + DefaultStyles.Faint.Render(hook.URL)
if hook.Queued > 0 {
row += DefaultStyles.Decor.Render(" · ") + DefaultStyles.Warning.Render(fmt.Sprintf("%d queued", hook.Queued))
}
if i == m.Cursor {
cursorLine = len(list)
}
list = append(list, listRow(i == m.Cursor, row, width))
}
rows = append(rows, scrolled(list, cursorLine, max(2, m.Height-17))...)
switch {
case m.Reveal != nil:
rows = append(rows, "", ansi.Truncate(DefaultStyles.Bold.Render("signing secret for "+m.Reveal.Hook)+DefaultStyles.Faint.Render(" → "+sessionLabel(m.Sessions, m.Reveal.Session, m.SessionID)+" · shown only once; copy it now"), width, "…"))
rows = append(rows, " "+DefaultStyles.Prompt.Render(m.Reveal.Secret), "")
rows = append(rows, keyHints(hint{"c", "copy"}, hint{"enter", "done"}))
case m.Form != nil:
rows = append(rows, "")
rows = append(rows, m.Form.view(width)...)
if m.Saving {
rows = append(rows, DefaultStyles.Faint.Render("saving…"))
}
case m.Saving:
rows = append(rows, "", DefaultStyles.Faint.Render("saving…"))
default:
if hook := m.selected(); hook != nil {
rows = append(rows, "")
rows = append(rows, m.detail(*hook, width)...)
}
browse := []hint{{"r", "refresh"}, {"esc", "back"}}
keys := []hint{{"n", "add hook"}, {"a", "agent access"}}
if len(m.Hooks) > 0 {
browse = []hint{{"↑↓", "select"}, {"space", "on/off"}, {"y", "copy url"}, {"r", "refresh"}, {"esc", "back"}}
keys = []hint{{"n", "add hook"}, {"enter", "edit"}, {"k", "new secret"}, {"d", "delete"}, {"a", "agent access"}}
}
rows = append(rows, "", keyHints(browse...), keyHints(keys...))
}
return m.fit(rows)
}
// detail explains the selected hook: where to send, how to sign, and what is
// waiting for the session.
func (m WebhooksPageModel) detail(hook webhookEntry, width int) []string {
field := func(label, value string) string {
return ansi.Truncate(DefaultStyles.Muted.Render(padRight(label, 11))+value, width, "…")
}
rows := []string{
field("wakes", sessionLabel(m.Sessions, hook.Session, m.SessionID)),
field("url", "POST "+cmp.Or(hook.Address, hook.URL)),
field("signature", hook.Header+": "+hook.Prefix+DefaultStyles.Faint.Render("<hex HMAC-SHA256 of the body>")),
}
inbox := DefaultStyles.Faint.Render("empty")
if hook.Queued > 0 {
inbox = DefaultStyles.Warning.Render(fmt.Sprintf("%d waiting for the session", hook.Queued))
if hook.Deferred != "" {
inbox += DefaultStyles.Faint.Render(" · last attempt: " + hook.Deferred)
}
}
if !hook.Enabled {
inbox += DefaultStyles.Faint.Render(" · off, new deliveries answer 404")
}
return append(rows, field("inbox", inbox))
}
// webhookForm adds a hook or edits one on a single screen: the session it
// wakes, its name, signing secret, and the header and prefix its sender signs
// with. The session and name are fixed once the hook exists.
type webhookForm struct {
form
// Editing is the hook being edited; nil for a new hook.
Editing *webhookEntry
Current, Chosen string
// Sessions are offered in order; Chosen starts at Current, the session
// the screen was opened from.
Sessions []daemon.Session
}
const (
hookFieldSession = "session"
hookFieldName = "name"
hookFieldSecret = "secret"
hookFieldHeader = "header"
hookFieldPrefix = "prefix"
)
var hookFields = []string{hookFieldSession, hookFieldName, hookFieldSecret, hookFieldHeader, hookFieldPrefix}
// sessionChoices is how many matching sessions the picker shows at once.
const sessionChoices = 5
var (
hookName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
hookHeader = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`)
)
func newWebhookForm(editing *webhookEntry, sessions []daemon.Session, current string) *webhookForm {
f := &webhookForm{form: newForm(hookFields, hookFieldSecret), Sessions: sessions, Current: current, Chosen: current}
f.Inputs[hookFieldSession].Placeholder = "type to filter"
f.Inputs[hookFieldName].Placeholder = "github-deploys"
f.Inputs[hookFieldHeader].SetValue(defaultSignatureHeader)
f.Inputs[hookFieldPrefix].SetValue(defaultSignaturePrefix)
f.Inputs[hookFieldSecret].Placeholder = "leave blank to generate one"
f.Focus = 1 // the session already defaults to this one
if editing != nil {
hook := *editing
f.Editing, f.Chosen, f.Focus = &hook, hook.Session, 2
f.Inputs[hookFieldName].SetValue(hook.Name)
f.Inputs[hookFieldHeader].SetValue(hook.Header)
f.Inputs[hookFieldPrefix].SetValue(hook.Prefix)
f.Inputs[hookFieldSecret].Placeholder = "stored · leave blank to keep it"
}
f.focus(f.current())
return f
}
func (f *webhookForm) current() string { return hookFields[f.Focus] }
// matches are the sessions the picker's filter leaves, in order.
func (f *webhookForm) matches() []daemon.Session {
query := strings.ToLower(strings.TrimSpace(f.Inputs[hookFieldSession].Value()))
var out []daemon.Session
for _, s := range f.Sessions {
if query == "" || strings.Contains(strings.ToLower(sessionTitle(s)+" "+homePath(s.Workspace)+" "+s.ID), query) {
out = append(out, s)
}
}
return out
}
func (f *webhookForm) chosenIndex(matches []daemon.Session) int {
return slices.IndexFunc(matches, func(s daemon.Session) bool { return s.ID == f.Chosen })
}
// update handles one key or paste; submit reports that the form should be saved.
func (f *webhookForm) update(msg tea.Msg) (submit bool, cmd tea.Cmd) {
firstField := 0
if f.Editing != nil {
firstField = 2
}
if submit, handled := f.key(msg, hookFields, firstField); handled {
return submit, nil
}
if key, ok := msg.(tea.KeyPressMsg); ok {
if f.current() == hookFieldSession && (key.String() == "left" || key.String() == "right") {
if matches := f.matches(); len(matches) > 0 {
i := f.chosenIndex(matches)
if i < 0 {
i = 0
} else {
step := 1
if key.String() == "left" {
step = -1
}
i = (i + step + len(matches)) % len(matches)
}
f.Chosen = matches[i].ID
return false, nil
}
}
}
input := f.Inputs[f.current()]
next, cmd := input.Update(msg)
*input = next
if f.current() == hookFieldSession {
if matches := f.matches(); len(matches) > 0 && f.chosenIndex(matches) < 0 {
f.Chosen = matches[0].ID
}
}
return false, cmd
}
// steps turns the form into /webhooks calls, after checking what the daemon
// would otherwise reject one step in.
func (f *webhookForm) steps() ([]daemon.WebhookRequest, string, error) {
name, secret, header, prefix := f.value(hookFieldName), f.value(hookFieldSecret), f.value(hookFieldHeader), f.value(hookFieldPrefix)
switch {
case f.Editing == nil && f.Chosen == "":
return nil, "", errors.New("choose the session this webhook will wake")
case f.Editing == nil && !hookName.MatchString(name):
return nil, "", errors.New("use 1–64 letters, digits, underscores, or hyphens for the webhook name")
case secret != "" && (len(secret) < 16 || len(secret) > 4096):
if f.Editing != nil {
return nil, "", errors.New("use 16–4096 bytes for the secret, or leave it blank to keep the existing secret")
}
return nil, "", errors.New("use 16–4096 bytes for the secret, or leave it blank to generate one")
case strings.ContainsAny(secret, " \t"):
return nil, "", errors.New("remove spaces from the secret")
case !hookHeader.MatchString(header):
return nil, "", errors.New("use 1–64 letters, digits, or hyphens for the signature header")
case len(prefix) > 32 || strings.ContainsAny(prefix, " \t"):
return nil, "", errors.New("use at most 32 characters without spaces for the signature prefix")
}
if f.Editing == nil {
steps := []daemon.WebhookRequest{{Action: daemon.WebhookCreate, SessionID: f.Chosen, Name: name, Secret: secret}}
if !strings.EqualFold(header, defaultSignatureHeader) || prefix != defaultSignaturePrefix {
steps = append(steps, daemon.WebhookRequest{Action: daemon.WebhookSignature, Header: header, Prefix: prefix})
}
return steps, "added " + name + " · wakes " + sessionLabel(f.Sessions, f.Chosen, f.Current), nil
}
var steps []daemon.WebhookRequest
if !strings.EqualFold(header, f.Editing.Header) || prefix != f.Editing.Prefix {
steps = append(steps, daemon.WebhookRequest{Action: daemon.WebhookSignature, HookID: f.Editing.ID, Header: header, Prefix: prefix})
}
if secret != "" {
steps = append(steps, daemon.WebhookRequest{Action: daemon.WebhookRotate, HookID: f.Editing.ID, Secret: secret})
}
return steps, "saved " + f.Editing.Name, nil
}
func (f *webhookForm) view(width int) []string {
title := "add webhook"
if f.Editing != nil {
title = "edit " + f.Editing.Name
}
f.fit(width - 14)
rows := []string{DefaultStyles.Bold.Render(title)}
indent := strings.Repeat(" ", 13)
for i, key := range hookFields {
value := f.Inputs[key].View()
switch {
case key == hookFieldSession && f.Editing != nil:
value = DefaultStyles.Faint.Render(sessionLabel(f.Sessions, f.Chosen, f.Current))
case key == hookFieldSession && i != f.Focus:
value = sessionLabel(f.Sessions, f.Chosen, f.Current)
case key == hookFieldName && f.Editing != nil:
value = DefaultStyles.Faint.Render(f.Editing.Name)
}
rows = append(rows, formRow(i == f.Focus, key, 11, value, width))
if key == hookFieldSession && i == f.Focus {
rows = append(rows, f.pickerRows(indent, width)...)
}
}
var own []hint
var note string
switch f.current() {
case hookFieldSession:
note, own = "which session deliveries wake", []hint{{"←→", "choose"}}
case hookFieldName:
note = "letters, digits, _ or -"
case hookFieldSecret:
note = "16+ bytes from the sender, or blank to generate one"
if f.Editing != nil {
note = "enter a new secret to replace the stored one"
}
case hookFieldHeader:
note = "the header the sender signs with · GitHub uses x-hub-signature-256"
case hookFieldPrefix:
note = "text before the hex digest · blank for none"
}
return append(rows, "", formFooter(note, width, own...))
}
// pickerRows lists the matching sessions around the chosen one.
func (f *webhookForm) pickerRows(indent string, width int) []string {
matches := f.matches()
if len(matches) == 0 {
return []string{indent + DefaultStyles.Faint.Render("No sessions match. Try another search.")}
}
chosen := max(0, f.chosenIndex(matches))
start := max(0, min(chosen-sessionChoices/2, len(matches)-sessionChoices))
var rows []string
w := width - len(indent)
for i := start; i < min(len(matches), start+sessionChoices); i++ {
label := sessionLabel(f.Sessions, matches[i].ID, f.Current)
if i == chosen {
rows = append(rows, indent+selectedLine(ansi.Truncate(selectBar()+" "+label, w, "…"), w))
} else {
rows = append(rows, indent+DefaultStyles.Faint.Render(ansi.Truncate(" "+label, w, "…")))
}
}
if more := len(matches) - sessionChoices; more > 0 {
rows = append(rows, indent+DefaultStyles.Faint.Render(fmt.Sprintf(" %d more · type to filter", more)))
}
return rows
}