give page rows a detail pane and vents a title
30 unmodified lines31323334353536373839110 unmodified linesExpand all30 unmodified lines`ToolPlugin` supplies model-facing tools, python modules, host routes, and tool instructions. model tools declare a definition, an invocation callback, and a recovery callback. recovery must report a stored result or unknown outcome, not replay a potentially side-effecting operation.
`CommandPlugin` supplies [session commands](commands.md): the same catalog drives the CLI menu, user invocation, and the kernel's typed `commands` bindings. duplicate command names are rejected with the other capability collisions.
A command may also be an extension's own **page**: set `page: True` and, run with no arguments, answer a [`page.Document`](../src/albedo/harness/page.gleam) — a title, rows with a badge and tone, the actions available on them, and an optional short glance. The CLI opens a bare `/name` of a page command as that screen and renders it generically: each action runs the same command with `action` = the action's `run` and `details` = the selected row's id followed by any entered text, chosen option, or preset value, and the page is fetched again afterwards. A glance appears in the chat's right margin when the terminal leaves room beside the 100-column body, and as a count in the header when it does not. `/work` is the first page; a skills toggle or an MCP server list would declare theirs the same way, with no client changes.A command may also be an extension's own **page**: set `page: True` and, run with no arguments, answer a [`page.Document`](../src/albedo/harness/page.gleam) — a title, rows with a badge, a tone, and an optional detail a wide client shows beside the list, the actions available on them, and an optional short glance. The CLI opens a bare `/name` of a page command as that screen and renders it generically: each action runs the same command with `action` = the action's `run` and `details` = the selected row's id followed by any entered text, chosen option, or preset value, and the page is fetched again afterwards. A glance appears in the chat's right margin when the terminal leaves room beside the 100-column body, and as a count in the header when it does not. `/work` is the first page; a skills toggle or an MCP server list would declare theirs the same way, with no client changes.
A command that tells the agent about a user's change uses the `Note(origin, display, text)` state operation: the note waits in the session's queue, reaches the model at its next step if a run is active or ahead of the user's next message if not, and never starts a turn by itself.
`ManagedPlugin` prepares session-owned contributions together: context, tool instructions, tools, python modules, host routes, and commands. preparation returns a close callback. a failed replacement releases its prepared resources and leaves the old selection active; successful replacement releases the old resources after the swap. use this for connections or an immutable catalog shared by context and tools.110 unmodified linesExpand all10 unmodified lines111213141516151617181920212223212223242526272728293031322829303132333435363738394 unmodified lines10 unmodified lines
## python
```pythonawait vent(topic, message, suggestion="") # file one complaintawait vents(limit=20) # recent vents, newest firstawait vent(topic, message, suggestion="", title="")await vents(limit=20) # recent vents, newest first```
`topic` is one of `harness`, `workflow`, `bug`, `user`, or `other`. a ventcarries what happened, what it cost, and — in `suggestion` — what would fixit. rows return as dicts with `id`, `topic`, `message`, `suggestion`,`status`, `session`, and `created_at`. the model should list before filing soit does not report the same thing twice.it. the optional `title` is the short line the `/paperclips` list shows;without one the message stands in. rows return as dicts with `id`, `title`,`topic`, `message`, `suggestion`, `status`, `session`, and `created_at`. themodel should list before filing so it does not report the same thing twice.
## review
`/paperclips` lists the workspace's vents, open first. the user canacknowledge (`a`), reply (`n`), resolve (`r`), dismiss (`d`), or remove(`x`) one. a reply marks the vent acknowledged and queues its text as a notefor the model, so an answer reaches it at its next turn without starting oneahead of the user's message. open vents also appear in the page's glancebeside the conversation.`/paperclips` lists the workspace's vents, open first, one short titleper row; a wide client shows the highlighted vent's full text, suggestion,and meta in a pane beside the list, the way the folder picker previews afolder. the user can acknowledge (`a`), reply (`n`), resolve (`r`), dismiss(`d`), or remove (`x`) one. a reply marks the vent acknowledged and queuesits text as a note for the model, so an answer reaches it at its next turnwithout starting one ahead of the user's message. open vents also appear inthe page's glance beside the conversation.
## storage
vents live in one `paperclips` table in the shared ledger store, created when4 unmodified lines6 unmodified lines7891011111213141555 unmodified lines71727374757677787980818283848534 unmodified lines12012112212312412512612712812913013113213313413513613713813914014114214314414514614723 unmodified lines6 unmodified linesfrom harness import Albedo, Provider, python, text
VENT_AND_LIST = ( "await vent('harness', 'the build lock ate my afternoon'," " suggestion='prebuild the cli in test.sh')\n" " suggestion='prebuild the cli in test.sh', title='build lock stalls')\n" "print([(v['id'], v['topic'], v['status']) for v in await vents()])")
READ_BACK = "print([(v['id'], v['status']) for v in await vents()])"55 unmodified lines {"name": "/paperclips", "args": {}}, ) )["result"]["page"] self.assertEqual(len(page["rows"]), 1) # the list shows the model's title; the detail carries the vent self.assertEqual(page["rows"][0]["text"], "build lock stalls") self.assertEqual(page["rows"][0]["badge"], "open") self.assertIn("the build lock ate my afternoon", page["rows"][0]["detail"]) self.assertIn( "suggestion: prebuild the cli in test.sh", page["rows"][0]["detail"] ) self.assertEqual(len(page["glance"]["rows"]), 1)
triage = f"/sessions/{session}/commands" vent_id = page["rows"][0]["id"]34 unmodified lines for message in request["request"]["messages"] ) )
def test_a_titleless_vent_takes_its_title_from_the_message(self): provider = scripting( ["await vent('bug', 'wires crossed somewhere deep in the stack')\n"] ) self.addCleanup(provider.close) with Albedo(provider) as app: session = app.session() app.prompt(session, "vent").close() app.idle(session) page = json.load( app.api( f"/sessions/{session}/commands", {"name": "/paperclips", "args": {}}, ) )["result"]["page"] self.assertEqual( page["rows"][0]["text"], "wires crossed somewhere deep in the stack" ) self.assertIn("wires crossed", page["rows"][0]["detail"])
def test_a_vent_needs_a_known_topic_and_a_message(self): provider = scripting( [ "try:\n"23 unmodified lines23 unmodified lines2425262728293031282930313233343536116 unmodified linesExpand all153154155156157156158159160161162267 unmodified linesExpand all43043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147248 unmodified lines52152252352448852552652752852953053153253353453553653753853954054154254354454554654753 unmodified lines23 unmodified lines ToneMuted PageTone = "muted")
type PageRow struct { ID string `json:"id"` Text string `json:"text"` Badge string `json:"badge"` Tone PageTone `json:"tone"` ID string `json:"id"` Text string `json:"text"` Badge string `json:"badge"` Tone PageTone `json:"tone"` Detail string `json:"detail,omitempty"`}
type PageAction struct { Key string `json:"key"`116 unmodified linesExpand all case "active", "warning", "muted": default: tone = "plain" } detail, _ := obj["detail"].(string) result = append(result, PageRow{ID: id, Text: text, Badge: badge, Tone: PageTone(tone)}) result = append(result, PageRow{ID: id, Text: text, Badge: badge, Tone: PageTone(tone), Detail: detail}) } return result } doc.Rows = parseRows(page["rows"])267 unmodified linesExpand all } return m, nil}
// panes splits the width the way the folder picker does: the detail beside// the list only when there is room and some row carries one.func (m PageViewModel) panes() (list, pane int) { list = m.Width if m.Width >= 96 && m.Height >= 14 && slices.ContainsFunc(m.Doc.Rows, func(r PageRow) bool { return r.Detail != "" }) { list = m.Width / 2 pane = m.Width - list - ansi.StringWidth(svSep()) } return list, pane}
// detailPane draws the selected row's detail in exactly rows lines: the row// under a rule, then its detail wrapped to the pane.func (m PageViewModel) detailPane(width, rows int) []string { if rows <= 0 { return nil } filled := make([]string, rows) row := m.currentRow() if row == nil || row.Detail == "" { return filled } lines := []string{plainRule("#"+row.ID+" · "+row.Badge, width), ""} for _, paragraph := range strings.Split(row.Detail, "\n\n") { if paragraph == "" { continue } for _, wrapped := range wrapContextContent(paragraph, width-2) { lines = append(lines, " "+wrapped) } lines = append(lines, "") } return append(lines, make([]string, max(0, rows-len(lines)))...)[:rows]}
// pageNotice picks the wording for a finished action: the daemon's message// when it sent one, else the action's label.func pageNotice(msg pageActionExecutedMsg) string { notice := fmt.Sprintf("%s done", msg.Action.Label)48 unmodified lines if item.ID != item.Text { rows[i] += DefaultStyles.Faint.Render(" #" + item.ID) } } line(selectableRows(rows, m.currentIndex(), m.Height, m.Height, m.Width, m.Styles)) listWidth, paneWidth := m.panes() listed := selectableRows(rows, m.currentIndex(), m.Height, m.Height, listWidth, m.Styles) if paneWidth == 0 { line(listed) } else { // The pane fills the row window even when the list is shorter, // the way the folder picker previews at full body height. window := max(1, m.Height-8) list := strings.Split(listed, "\n") for len(list) < window { list = append(list, "") } pane := m.detailPane(paneWidth-1, len(list)) for i := range list { list[i] += svSep() + svFit(pane[i], paneWidth-1) + " " list[i] = ansi.Truncate(list[i], m.Width, "…") } line(strings.Join(list, "\n")) } } target := "" if row != nil { target = " " + row.Text53 unmodified lines1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162// The detail pane is a pure rendering decision: the daemon sends the same// document either way, so no e2e through the daemon can catch a wrong split// threshold, a misaligned pane, or detail dropped from parsing.package tui
import ( "strings" "testing")
func detailFixture() *PageDocument { return &PageDocument{ Title: "paperclips", Summary: "1 open", Empty: "nothing vented yet", Rows: []PageRow{{ ID: "1", Text: "ruff not on PATH", Badge: "open", Tone: ToneWarning, Detail: "ruff is missing from PATH; the binary lives in pre-commit's env.\n\nsuggestion: document the canonical invocation", }}, }}
func TestWidePageShowsDetailBesideTheList(t *testing.T) { m := NewPageViewModel(nil, "s", "/paperclips") m.SetSize(120, 30) m.Doc = detailFixture() view := m.View() for _, want := range []string{"ruff not on PATH", "│", "suggestion: document the canonical invocation", "#1 · open"} { if !strings.Contains(view, want) { t.Fatalf("wide view lost %q:\n%s", want, view) } }}
func TestNarrowPageKeepsTheListAlone(t *testing.T) { m := NewPageViewModel(nil, "s", "/paperclips") m.SetSize(80, 24) m.Doc = detailFixture() view := m.View() if !strings.Contains(view, "ruff not on PATH") { t.Fatalf("narrow view lost the list:\n%s", view) } for _, unwanted := range []string{"│", "pre-commit's env"} { if strings.Contains(view, unwanted) { t.Fatalf("narrow view should not show the detail pane, found %q:\n%s", unwanted, view) } }}
func TestRowsWithoutDetailKeepTheFullWidth(t *testing.T) { m := NewPageViewModel(nil, "s", "/work") m.SetSize(120, 30) doc := detailFixture() doc.Rows[0].Detail = "" m.Doc = doc if strings.Contains(m.View(), "│") { t.Fatal("a page without details should not split") }}5 unmodified lines67891010111213141516161718192021222324259 unmodified lines5 unmodified lines
host: Host
async def vent(topic: str, message: str, suggestion: str = "") -> dict:async def vent(topic: str, message: str, suggestion: str = "", title: str = "") -> dict: """Record one complaint for the user to review in /paperclips.""" return cast( dict, await host( "paperclips.vent", {"topic": topic, "message": message, "suggestion": suggestion}, { "topic": topic, "message": message, "suggestion": suggestion, "title": title, }, ), )
9 unmodified lines38 unmodified lines394041424343444546474849505152535455565758596061626364656667686970717273747559 unmodified lines13513613713813914014114214340 unmodified lines38 unmodified lines Muted}
pub type Row { Row(id: String, text: String, badge: String, tone: Tone) Row( id: String, /// The short title a list shows on one line. text: String, badge: String, tone: Tone, /// The full text a wide client shows beside the list, where `text` is /// the short title. Empty when the row has nothing more to show. detail: String, )}
/// A row with the full text a wide client shows beside the list, where/// `text` is the short title. `page.detail_row` fills it; `page.row` leaves/// the detail empty.pub fn row(id: String, text: String, badge: String, tone: Tone) -> Row { Row(id, text, badge, tone, "")}
pub fn detail_row( id: String, text: String, badge: String, tone: Tone, detail: String,) -> Row { Row(id, text, badge, tone, detail)}
pub type Input { NoInput /// Free text. `prefill` starts the field with the selected row's text. Text(prompt: String, prefill: Bool)59 unmodified lines json.object([ #("id", json.string(row.id)), #("text", json.string(row.text)), #("badge", json.string(row.badge)), #("detail", json.string(row.detail)), #( "tone", json.string(case row.tone { Plain -> "plain"40 unmodified lines8 unmodified lines910111213131415161779 unmodified lines979899100101102103104105101102107103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152142 unmodified linesExpand all8 unmodified linesimport albedo/harness/pageimport gleam/intimport gleam/jsonimport gleam/listimport gleam/option.{Some}import gleam/option.{None, Some}import gleam/resultimport gleam/string
pub fn command(store: paperclips.Store, cwd: String) -> Command {79 unmodified lines status_style(vent.status).0 * 1_000_000 - vent.id}
fn row(vent: paperclips.Vent) -> page.Row { let text = case vent.suggestion { "" -> vent.message suggestion -> vent.message <> " — " <> suggestion } page.Row( page.detail_row( int.to_string(vent.id), paperclips.topic_name(vent.topic) <> ": " <> text, case vent.title { "" -> short_title(vent.message) title -> title }, paperclips.status_name(vent.status), status_style(vent.status).1, detail(vent), )}
/// The columns of the list a titleless vent's title may take.const title_columns = 64
/// The list shows one short line per vent even when the model did not set/// a title; the full text belongs in the detail.fn short_title(message: String) -> String { let flat = string.replace(message, "\n", " ") case string.length(flat) <= title_columns { True -> flat False -> flat |> string.to_graphemes |> list.take(title_columns) |> string.concat <> "…" }}
/// Everything the detail pane shows for one vent.fn detail(vent: paperclips.Vent) -> String { [ vent.message, case vent.suggestion { "" -> "" suggestion -> "\n\nsuggestion: " <> suggestion }, "\n\nfiled " <> vent.created_at <> case vent.session { Some(session) -> " by session " <> string.slice(session, 0, 8) None -> "" }, ] |> string.concat}
fn summary(vents: List(paperclips.Vent)) -> String { [ paperclips.Open, paperclips.Acknowledged, paperclips.Resolved, paperclips.Dismissed,142 unmodified linesExpand all27 unmodified lines28293031323227 unmodified lines paperclips.initialise, )}
const vent_instructions = "You have a vent channel the user reviews instead of receiving as interruptions. When the harness, a workflow, a bug, or the user's own habits cost you real work, say so once where it can be triaged: await vent(topic, message, suggestion=\"\") files one, with topic one of harness, workflow, bug, user, or other. Keep it specific and actionable: what happened, what it cost you, and what would fix it; a user vent is reviewable workflow feedback, never an insult. The user reviews vents with /paperclips and can answer them, and the answer reaches you as a note. Check await vents(limit=20) before recording so you do not file the same complaint twice. Venting is never a substitute for telling the user something urgent in your reply."const vent_instructions = "You have a vent channel the user reviews instead of receiving as interruptions. When the harness, a workflow, a bug, or the user's own habits cost you real work, say so once where it can be triaged: await vent(topic, message, suggestion=\"\", title=\"\") files one, with topic one of harness, workflow, bug, user, or other; the optional title is the short line the /paperclips list shows, so make it a few specific words and put the rest in message. Keep it specific and actionable: what happened, what it cost you, and what would fix it; a user vent is reviewable workflow feedback, never an insult. The user reviews vents with /paperclips and can answer them, and the answer reaches you as a note. Check await vents(limit=20) before recording so you do not file the same complaint twice. Venting is never a substitute for telling the user something urgent in your reply."27 unmodified lines28293031323334353610 unmodified lines47484950505152535254555657585960616263646566676869703 unmodified lines7475767770787980818261 unmodified lines1441451461471481401411421431441451491501511521531541551561571581591601614 unmodified lines1661671681691701711721731749 unmodified lines1841851861871771881891901911921931941951811961971981992002012022032043 unmodified lines20820921021121221321421521619721721821922020122122222322422522622722822969 unmodified lines27 unmodified lines
pub type Vent { Vent( id: Int, title: String, topic: Topic, message: String, suggestion: String, status: Status,10 unmodified lines
pub type Store = storage.Store
/// Runs once at install against the shared ledger store./// Runs once at install against the shared ledger store. `add_columns`/// carries tables installed before a column existed.pub fn initialise(ledger: Store) -> Result(Nil, String) { storage.query(ledger, fn(db) { storage.exec(db, schema) }) storage.query(ledger, fn(db) { use _ <- result.try(storage.exec(db, schema)) storage.add_columns(db, "paperclips", [ #("title", "TEXT NOT NULL DEFAULT ''"), ]) })}
const schema = "CREATE TABLE IF NOT EXISTS paperclips ( id INTEGER PRIMARY KEY AUTOINCREMENT, topic TEXT NOT NULL CHECK(topic IN ('harness','workflow','bug','user','other')), title TEXT NOT NULL DEFAULT '', message TEXT NOT NULL CHECK(length(trim(message)) > 0), suggestion TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','acknowledged','resolved','dismissed')), session TEXT,3 unmodified lines);CREATE INDEX IF NOT EXISTS paperclips_cwd ON paperclips(cwd);"
const columns = "id,topic,message,suggestion,status,session,created_at"const columns = "id,title,topic,message,suggestion,status,session,created_at"
pub fn topic_name(topic: Topic) -> String { case topic { Harness -> "harness"61 unmodified lines}
fn decoder() { use id <- decode.field(0, decode.int) use title <- decode.field(1, decode.string) use topic <- decode.field(1, topic_decoder()) use message <- decode.field(2, decode.string) use suggestion <- decode.field(3, decode.string) use status <- decode.field(4, status_decoder()) use session <- decode.field(5, decode.optional(decode.string)) use created_at <- decode.field(6, decode.string) use topic <- decode.field(2, topic_decoder()) use message <- decode.field(3, decode.string) use suggestion <- decode.field(4, decode.string) use status <- decode.field(5, status_decoder()) use session <- decode.field(6, decode.optional(decode.string)) use created_at <- decode.field(7, decode.string) decode.success(Vent( id, title, topic, message, suggestion, status,4 unmodified lines
pub fn to_json(vent: Vent) -> json.Json { json.object([ #("id", json.int(vent.id)), #("title", json.string(vent.title)), #("topic", json.string(topic_name(vent.topic))), #("message", json.string(vent.message)), #("suggestion", json.string(vent.suggestion)), #("status", json.string(status_name(vent.status))),9 unmodified linesfn one(items: List(Vent)) -> Result(Vent, Error) { items |> list.first |> result.replace_error(NotFound)}
fn validate(message: String, suggestion: String) -> Result(Nil, Error) {fn validate( title: String, message: String, suggestion: String,) -> Result(Nil, Error) { case string.trim(message) == "" { True -> Error(Invalid("a vent needs a message")) False -> case string.length(suggestion) > 2000 || string.length(message) > 8000 { case string.length(title) > 200 || string.length(suggestion) > 2000 || string.length(message) > 8000 { True -> Error(Invalid("vent text is too long")) False -> Ok(Nil) } }3 unmodified linespub fn create( store: Store, cwd: String, topic: Topic, title: String, message: String, suggestion: String, session: Option(String),) -> Result(Vent, Error) { use _ <- result.try(validate(message, suggestion)) use _ <- result.try(validate(title, message, suggestion)) storage.query(store, fn(db) { rows( db, "INSERT INTO paperclips(cwd,topic,message,suggestion,session) VALUES(?,?,?,?,?) RETURNING " "INSERT INTO paperclips(cwd,title,topic,message,suggestion,session) VALUES(?,?,?,?,?,?) RETURNING " <> columns, [ sqlight.text(cwd), sqlight.text(string.trim(title)), sqlight.text(topic_name(topic)), sqlight.text(message), sqlight.text(suggestion), sqlight.nullable(sqlight.text, session),69 unmodified lines38 unmodified lines394041424344444546464748495051485253545556575859606162636410 unmodified lines38 unmodified lines "paperclips.vent" -> { let decoder = { use topic <- decode.field("topic", decode.string) use message <- decode.field("message", decode.string) use title <- decode.optional_field("title", "", decode.string) use suggestion <- decode.optional_field("suggestion", "", decode.string) decode.success(#(topic, message, suggestion)) decode.success(#(topic, message, title, suggestion)) } use #(topic, message, suggestion) <- result.try(parse(args, decoder)) use #(topic, message, title, suggestion) <- result.try(parse( args, decoder, )) use topic <- result.try(paperclips.parse_topic(topic)) paperclips.create(store, cwd, topic, message, suggestion, Some(session)) paperclips.create( store, cwd, topic, title, message, suggestion, Some(session), ) |> result.map(paperclips.to_json) } "paperclips.list" -> { let decoder = {10 unmodified lines105 unmodified linesExpand all106107108109110110111112113114115116117118119107 unmodified linesExpand all105 unmodified linesExpand all status_style(item.status).0}
fn row(item: work.Item) -> page.Row { page.Row( page.detail_row( int.to_string(item.id), item.title, work.status_name(item.status), status_style(item.status).1, item.notes, )}
fn summary(items: List(work.Item)) -> String {107 unmodified linesExpand all