From d3c44392db7f56f2bc96503650d8e398ef4a7774 Mon Sep 17 00:00:00 2001 From: Codestz Date: Mon, 5 Oct 2026 11:38:49 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Trust:=20/trust=20opens=20on=20the=20ledger?= =?UTF-8?q?,=20grouped=20by=20kind=20=E2=80=94=20edits=20by=20folder,=20wh?= =?UTF-8?q?at=20was=20seen=20once=20folded=20away?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From feedback that the ledger and the activity read as disorder. - /trust opens on the ledger. Today's answers are one row above it; selected, its card lists them, and `a` (or enter on the strip) opens the full activity. esc steps back, then closes. - The tree is grouped by kind first — Commands, Edits, Tools & fetches, OpenCode's own — each with a heading, the current one kept at the top as the list scrolls. An edit's family is its folder, and every folder is a row of its own under Edits, with its file count: three edits in three folders were three unrelated rows between `git status` and `jq`. - Families seen only once fold into one `seen once` row per kind, which opens like a family. - Every row ends in the same column and shape: `✓` trusted, `▰` learning, `○` seen once. - Paths are cut in the middle (`rows.squeeze`), so the file name stays: five `tail` commands on one long path read alike when cut at the end. - A family's card lists one command a line with its standing in one column, and the card's buttons sit on its last row. - The activity has one left edge — the mark, the time, the command — and names the agent once in the header when there is only one. - A new sample, `storefront` (a fictional project), as crowded as the ledger the feedback showed. Co-Authored-By: Claude Opus 5.5 --- packages/trust/src/cli/preview.ts | 22 ++- packages/trust/src/core/sample.ts | 90 ++++++++- packages/trust/src/core/view/activity.ts | 128 +++++++------ packages/trust/src/core/view/card.ts | 226 +++++++++++++++++++---- packages/trust/src/core/view/explorer.ts | 147 +++++++++++---- packages/trust/src/core/view/rows.ts | 47 +++++ packages/trust/src/core/view/tree.ts | 205 ++++++++++++++------ packages/trust/src/tui/ledger.tsx | 46 ++++- packages/trust/test/tui.test.ts | 6 +- packages/trust/test/view.test.ts | 102 +++++++++- 10 files changed, 814 insertions(+), 205 deletions(-) diff --git a/packages/trust/src/cli/preview.ts b/packages/trust/src/cli/preview.ts index 9ed09ccf..b28cedc9 100644 --- a/packages/trust/src/cli/preview.ts +++ b/packages/trust/src/cli/preview.ts @@ -1,13 +1,13 @@ #!/usr/bin/env bun /** - * The see-it loop: the sidebar block and both Trust screens — the activity `/trust` opens on and the - * ledger behind `l` — drawn in this terminal from sample worlds, with no OpenCode running. The same + * The see-it loop: the sidebar block and both Trust screens — the ledger `/trust` opens on and the + * activity behind `a` — drawn in this terminal from sample worlds, with no OpenCode running. The same * rows OpenCode draws; only the colours come from a fixed palette (OpenCode's default theme) instead * of the user's. * * bunx @opencode-cockpit/trust preview every sample, both screens * bunx @opencode-cockpit/trust preview --sample busy one of them - * bunx @opencode-cockpit/trust preview --view activity only the screen /trust opens on + * bunx @opencode-cockpit/trust preview --view activity only the activity, behind `a` * bunx @opencode-cockpit/trust preview --view ledger only the ledger (the explorer and its card) * bunx @opencode-cockpit/trust preview --columns 80 the dialog at another width * bunx @opencode-cockpit/trust preview --rows 20 the dialog in a short window @@ -165,7 +165,7 @@ for (const name of names) { out.push("", title, "") out.push(...frame(rows)) } - drawActivity("Activity — as /trust opens") + drawActivity("Activity — behind a") if (keys) drawActivity("Activity — ? keys", undefined, true) } @@ -180,7 +180,8 @@ for (const name of names) { extra: { focus?: { button: number }; keys?: boolean; typing?: string; filter?: string } = {}, ) => { const filter = extra.filter ?? "" - const model = explorerModel({ ...reading, open, full, filter }) + /** `today`: the strip is a node the cursor can stop on; its count does not matter here. */ + const model = explorerModel({ ...reading, open, full, filter, today: 0 }) const selected = pick(model.nodes)?.key const { rows } = explorerRows({ ...reading, @@ -198,7 +199,16 @@ for (const name of names) { out.push("", title, "") out.push(...frame(rows)) } - drawLedger("Ledger — as l opens it", (nodes) => nodes[0]) + drawLedger("Ledger — as /trust opens it", () => undefined) + drawLedger("Ledger — the Today strip", (nodes) => nodes.find((node) => node.kind === "today")) + const fold = explorerModel({ ...reading, ...tree }).nodes.find((node) => node.kind === "once") + if (fold) { + open.add(fold.key) + drawLedger("Ledger — what was seen once, opened", (nodes) => + nodes.find((node) => node.key === fold.key), + ) + open.delete(fold.key) + } /** A trusted command in a family of several, revealed the way `enter` on the activity does. */ const trusted = families .flatMap((family) => (family.commands.length > 1 ? family.commands : [])) diff --git a/packages/trust/src/core/sample.ts b/packages/trust/src/core/sample.ts index 52f9521c..20e3e9c7 100644 --- a/packages/trust/src/core/sample.ts +++ b/packages/trust/src/core/sample.ts @@ -9,7 +9,7 @@ import { createEngine, type Engine } from "./engine.ts" import type { Request } from "./keys.ts" -import { DAY } from "./ledger.ts" +import { DAY, type Event } from "./ledger.ts" import { rulesFrom } from "./rules.ts" export const SAMPLE_ROOT = "/work/app" @@ -25,8 +25,14 @@ const RULES = rulesFrom({ permission: { bash: { "*": "ask", "npm publish *": "ask" }, edit: "ask", webfetch: "ask" }, }) -function build(script: (step: Steps) => void): Engine { - const engine = createEngine(SAMPLE_SETTINGS) +/** Runs `script` on a fresh engine; `record` gets every event it loads, in order — a ledger file's lines. */ +function build(script: (step: Steps) => void, record?: Event[]): Engine { + const real = createEngine(SAMPLE_SETTINGS) + const engine: Engine = Object.create(real) + engine.load = (events, options) => { + record?.push(...events) + real.load(events, options) + } let n = 0 let clock = SAMPLE_NOW - 3 * DAY const ask = (line: string, permission = "bash", patterns = [line], agent = "build") => { @@ -135,6 +141,81 @@ function crowd(s: Steps): void { s.pending("git push origin feat/trust") } +/** + * A crowded storefront project (`acme-store`), as a user's ledger looked: one agent, `orchestrator`; a + * `tail` family on one long ledger path, widened by hand; `ls` widened too; edits in five folders, + * one of them outside the project; MCP calls through `mcpx`, `gh` and `jq`; a fetch and a subagent + * type; a long tail of commands seen once; and OpenCode's own "always" given twice. + */ +function storefront(s: Steps): void { + const agent = "orchestrator" + const run = (line: string, times: number, how: "once" | "always" = "once") => + s.approve(line, times, how, "bash", agent) + const edit = (path: string, times: number) => s.approve(path, times, "once", "edit", agent) + const hour = 3_600_000 + const ledger = "~/.local/share/opencode-cockpit/trust/Projects-acme-store/events.ndjson" + const review = "/var/folders/ab/x7k2q9/T/opencode-review" + s.at(SAMPLE_NOW - 2 * DAY) + run(`tail -4 ${ledger}`, 3) + run(`tail -10 ${ledger}`, 2) + for (const n of [3, 9, 8]) run(`tail -${n} ${ledger}`, 1) + run(`ls -la ${review}`, 3) + run(`ls -l ${review}/screens`, 2) + run("git status --short", 2) + for (const line of ["git log --oneline -20", "git log -1 --format=%H", "git log --stat -3"]) run(line, 1) + run("git merge-base HEAD origin/main", 1) + run("git merge-base --is-ancestor HEAD main", 1) + run("gh pr view 482 --json url,title,state", 2) + run("gh pr list --search SHOP-21 --state all", 2) + run("gh api --method PATCH /repos/acme/store/pulls/482 -f body=@body.md", 2) + run("sed -n 1p .env.example", 2) + for (const line of [ + "jq '.items | length' report.json", + "jq -r '.name' package.json", + "jq '.scripts' package.json", + "jq -c '.[]' out.json", + ]) + run(line, 1) + for (const line of ["head -40 README.md", "head -5 CHANGELOG.md", "echo $STORE_URL", "echo done"]) + run(line, 1) + for (const line of ["grep -rn TODO src", "grep -c error build.log", "git branch -a"]) run(line, 1) + run("mcpx jira getJiraIssue --site acme --issue SHOP-34", 2) + run("mcpx jira searchIssues --jql 'project = SHOP'", 1) + run("mcpx github list_pull_requests --repo acme/store", 1) + edit(`../../../../../var/folders/ab/x7k2q9/T/opencode-review/notes.md`, 3) + edit("packages/api/src/customers.ts", 2) + edit("packages/api/src/shopify.ts", 1) + edit("packages/api/src/orders.ts", 1) + edit("packages/web/context/auth.tsx", 1) + edit("packages/web/context/cart.tsx", 1) + edit("packages/web/app/checkout/page.tsx", 2) + s.approve("https://docs.example.com/api", 3, "once", "webfetch", agent) + s.approve("explore", 2, "once", "task", agent) + s.approve("find . -name '*.md'", 1, "always", "bash", agent) + s.approve("rg --files", 1, "always", "bash", agent) + s.at(SAMPLE_NOW - 2 * hour) + s.widen("tail", agent) + s.widen("ls", agent) + s.at(SAMPLE_NOW - 75 * 60_000) + s.auto(`tail -4 ${ledger}`, 1, "bash", agent) + s.at(SAMPLE_NOW - 30 * 60_000) + s.auto(`ls -la ${review}`, 2, "bash", agent) + s.auto(`../../../../../var/folders/ab/x7k2q9/T/opencode-review/notes.md`, 1, "edit", agent) + s.auto(`ls -l ${review}`, 1, "bash", agent) + s.auto(`tail -4 ${ledger}`, 1, "bash", agent) + s.auto(`tail -10 ${ledger}`, 1, "bash", agent) + s.auto("https://docs.example.com/api", 1, "webfetch", agent) + s.at(SAMPLE_NOW - 2_000) + s.pending("gh api --method PATCH /repos/acme/store/pulls/482 -f body=@body.md") +} + +/** The `storefront` sample's events, as its ledger file would hold them, for a test project's ledger. */ +export function storefrontEvents(): Event[] { + const events: Event[] = [] + build(storefront, events) + return events +} + /** A week of a project, as steps: rules earned early on, answered on most days since, the newest today. */ function week(s: Steps): void { const general = (line: string, times: number, how: "once" | "always" = "once") => @@ -262,6 +343,9 @@ export const SAMPLES: Record Sample> = { */ crowded: () => ({ engine: build(crowd) }), + /** The project the ledger's redesign was drawn from: kinds, folders of edits, a long tail seen once. */ + storefront: () => ({ engine: build(storefront) }), + /** The same afternoon, paused: the dialog has to say it before anything else. */ "crowded-paused": () => { const engine = build(crowd) diff --git a/packages/trust/src/core/view/activity.ts b/packages/trust/src/core/view/activity.ts index 0a4b5fa7..ac8b37d0 100644 --- a/packages/trust/src/core/view/activity.ts +++ b/packages/trust/src/core/view/activity.ts @@ -1,28 +1,31 @@ /** - * The screen `/trust` opens on: what Trust did for you, newest first, then what it is about to do. + * The activity, behind `a` in the ledger: what Trust did for you, newest first, then what it is about + * to do. * - * Trust · opencode-cockpit ● answering + * Trust · opencode-cockpit · activity ● answering * * TODAY Trust answered 4 prompts for you ▁▁▃▁▅▂█ last 7 days - * ▌09:41 ✓ git status --short build trusted since yesterday, 3 in a row - * 09:40 ✓ ls -la general in a family you widened: ls + * ▌✓ 09:41 git status --short build trusted since yesterday, 3 in a row + * ✓ 09:40 ls -la general in a family you widened: ls * * ALMOST THERE one more approval and Trust answers these - * ○ bun --version build ▰▰▱ 2 of 3 - * ○ git push origin feat/trust build ▰▰▰▰▰▱▱▱ 5 of 8 dangerous + * ○ bun --version build ▰▰▱ 2 of 3 + * ○ git push origin feat/… build ▰▰▰▰▰▱▱▱ 5 of 8 dangerous * * ! WATCH OUT OpenCode's own "always" approves more than it looks, until it restarts - * ! find * sed * general [enter] what it covers + * ! find * sed * general [enter] what it covers * * RULES 11 trusted · 14 learning · 101 seen once l Open the ledger * - * [enter] Why [x] Revoke [w] Trust Family [l] Ledger [p] Pause [?] Keys [esc] Close + * [enter] Why [x] Revoke [w] Trust Family [a] Ledger [p] Pause [?] Keys [esc] Back * - * Transparency is the point of Trust, so it leads with it: the question a person opens it with is - * "what did it do while I was not looking?", and the first line answers it. What is close comes - * next, because that is what will happen next; OpenCode's own broad approvals get a band of their - * own, so they cannot be mistaken for Trust's rules; and managing rules is one key away, in the - * ledger (explorer.ts). Every list is a window that follows the cursor, so a short dialog still + * One left edge: every row starts with its mark (`✓` answered, `○` close, `!` OpenCode's own), then + * the time column, then the command, so the three lists read as one. The agent has a column only + * when there is more than one; with one, the header names it and its chip is not repeated on every + * row. Paths are cut in the middle (`rows.squeeze`), so the file each command touched stays visible. + * + * The ledger (explorer.ts) is what `/trust` opens on; this is the "what happened" view, reached from + * its Today strip or `a`. Every list is a window that follows the cursor, so a short dialog still * reaches everything. */ @@ -62,7 +65,7 @@ import { sparkline, wrapRuns, } from "./parts.ts" -import { cursorRow, cut, fit, type Row, type Run, spread, widthOf } from "./rows.ts" +import { cursorRow, fit, type Row, type Run, spread, squeeze, widthOf } from "./rows.ts" export type ActivityItem = /** An answer Trust gave; `count` answers of the same line in a row are one item. */ @@ -87,8 +90,11 @@ export interface ActivityModel { /** Answers the feed holds: far more than a screen shows, for the cursor to scroll through. */ const FEED_MAX = 60 +/** What was answered, as the other lists say it: `edit src/app.ts`, `webfetch …`, a command bare. */ const labelOf = (answer: Answer) => - answer.items.map((item) => showSubject(answer.permission, item.subject)).join(" && ") + `${answer.permission === "bash" ? "" : `${answer.permission} `}${answer.items + .map((item) => showSubject(answer.permission, item.subject)) + .join(" && ")}` export function activityModel(input: Reading & { history: History }): ActivityModel { const { history, now } = input @@ -226,10 +232,10 @@ export const ACTIVITY_KEYS: readonly KeyLine[] = [ { keys: ["x"], does: "Revoke what answered, or forget a count still learning" }, { keys: ["w"], does: "Trust any command of the family, or go back to exact rules" }, { keys: ["c"], does: "Copy the rule as opencode.json config" }, - { keys: ["l"], does: "Open the ledger: every rule, by family" }, - { keys: ["/"], does: "Open the ledger and filter it" }, + { keys: ["a", "l"], does: "Back to the ledger: every rule, by kind and family" }, + { keys: ["/"], does: "Back to the ledger and filter it" }, { keys: ["p"], does: "Pause Trust in this project, or resume it" }, - { keys: ["?", "esc"], does: "Hide these keys; esc closes the dialog" }, + { keys: ["?", "esc"], does: "Hide these keys; esc goes back to the ledger" }, ] /** One section of the screen: a heading and the rows of its items, a window that follows the cursor. */ @@ -251,9 +257,19 @@ export function activityRows(input: ActivityInput): ActivityView { model.items.findIndex((item) => item.key === input.selected), ) const item = model.items[index] + const columns = columnsOf(model, width, now) /** Paused, the badge alone does not say what that means: it keeps counting, and answers nothing. */ const rows: Row[] = [ - headerRow(input.project, state.paused, state.paused ? [muted("counting, answering nothing")] : [], width), + headerRow( + input.project, + state.paused, + state.paused + ? [muted("counting, answering nothing")] + : columns.only + ? [muted(`activity · all by ${columns.only}`)] + : [muted("activity")], + width, + ), ] const hits: Hit[] = [] @@ -265,7 +281,6 @@ export function activityRows(input: ActivityInput): ActivityView { return { rows, ...(item ? { item } : {}), model, hits } } - const columns = columnsOf(model, width, now) const sections: Section[] = [] /* TODAY */ @@ -457,14 +472,16 @@ interface Columns { time: number /** Where every list's agent chip starts, so the chips read straight down the screen. */ chipAt: number - /** The widest chip, padded to. */ + /** The widest chip, padded to; 0 when there is one agent, and no chip is drawn. */ chip: number /** The longest meter among what is close. */ meter: number + /** The one agent every row is for, said once in the header instead of on every row. */ + only?: string } -const FEED_LEAD = 1 + 2 + 2 -const LIST_LEAD = 4 +/** ` ✓ ` and the time column, then two cells: where every list's command starts. */ +const rowLead = (columns: { time: number }) => 3 + columns.time + 2 function columnsOf(model: ActivityModel, width: number, now: number): Columns { const time = Math.max( @@ -478,21 +495,27 @@ function columnsOf(model: ActivityModel, width: number, now: number): Columns { ? leadOf(item.command).entry.agent : item.group.agent, ) - const chipWidth = Math.max(0, ...agents.map((agent) => widthOf(agent) + 2)) + const distinct = [...new Set(agents)] + const only = distinct.length === 1 ? distinct[0] : undefined + const chipWidth = only ? 0 : Math.max(0, ...agents.map((agent) => widthOf(agent) + 2)) + const lead = 3 + time + 2 const leads = model.items.map((item) => { - if (item.kind === "answer") - return FEED_LEAD + time + widthOf(labelOf(item.answer)) + (item.count > 1 ? 4 : 0) + if (item.kind === "answer") return lead + widthOf(labelOf(item.answer)) + (item.count > 1 ? 4 : 0) if (item.kind === "almost") - return LIST_LEAD + widthOf(showSubject(item.command.permission, item.command.subject)) - return LIST_LEAD + widthOf(item.group.patterns.join(" ")) + return lead + widthOf(showSubject(item.command.permission, item.command.subject)) + return lead + widthOf(item.group.patterns.join(" ")) }) /** The commands get what they need up to about half the row; the reasons take the rest. */ - const chipAt = Math.min(Math.max(24, ...leads.map((lead) => lead + 2)), Math.floor(width * 0.42)) + /** Without an agent column the commands take more of the row. */ + const chipAt = Math.min( + Math.max(24, ...leads.map((lead) => lead + 2)), + Math.floor(width * (only ? 0.55 : 0.42)), + ) const meterWidth = Math.max( 0, ...model.almost.map((item) => (item.kind === "almost" ? needOf(item.command) : 0)), ) - return { time, chipAt, chip: chipWidth, meter: meterWidth } + return { time, chipAt, chip: chipWidth, meter: meterWidth, ...(only ? { only } : {}) } } function needOf(command: Command): number { @@ -500,35 +523,40 @@ function needOf(command: Command): number { return stand.kind === "counting" ? stand.need : 0 } -/** `text` in exactly `room` columns: cut with `…`, or padded. */ +/** `text` in exactly `room` columns: its paths cut in the middle, or padded. */ const cell = (text: string, room: number) => { - const shown = cut(text, room) + const shown = squeeze(text, room) return `${shown}${" ".repeat(Math.max(0, room - widthOf(shown)))}` } +/** The agent's chip and two cells after it; nothing when there is one agent. */ function chipCell(agent: string, columns: Columns): Run[] { - return [chip(agent), { text: " ".repeat(Math.max(0, columns.chip - widthOf(agent) - 2)) }] + if (columns.chip === 0) return [] + return [chip(agent), { text: " ".repeat(Math.max(0, columns.chip - widthOf(agent) - 2)) }, { text: " " }] } function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, width: number): Row { + const lead = rowLead(columns) + /** The mark, then the time column (blank on a list without times), as every row starts. */ + const start = (mark: string, tone: Run["tone"], time = ""): Run[] => [ + { text: " " }, + { text: `${mark} `, tone }, + muted(time.padStart(columns.time)), + { text: " " }, + ] if (item.kind === "answer") { const { answer } = item - const lead = FEED_LEAD + columns.time const count = item.count > 1 ? ` ${item.count}×` : "" const room = Math.max(4, columns.chipAt - lead - 2 - count.length) - const label = labelOf(answer) + const label = squeeze(labelOf(answer), room) return fit( [ - { text: " " }, - muted(clock(answer.at, input.now).padStart(columns.time)), - { text: " " }, - { text: "✓ ", tone: "success" }, - plain(cut(label, room)), + ...start("✓", "success", clock(answer.at, input.now)), + plain(label), muted(count), - { text: " ".repeat(Math.max(0, room - widthOf(cut(label, room)))) }, + { text: " ".repeat(Math.max(0, room - widthOf(label))) }, { text: " " }, ...chipCell(answer.agent, columns), - { text: " " }, muted(answerWhy(answer, input)), ], width, @@ -538,17 +566,15 @@ function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, wid const { command } = item const { stand, entry } = leadOf(command) const danger = command.danger !== undefined - const room = Math.max(4, columns.chipAt - LIST_LEAD - 2) + const room = Math.max(4, columns.chipAt - lead - 2) const counting = stand.kind === "counting" ? stand : undefined const name = `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}` return fit( [ - { text: " " }, - { text: "○ ", tone: danger ? "error" : "warning" }, + ...start("○", danger ? "error" : "warning"), plain(cell(name, room)), { text: " " }, ...chipCell(entry.agent, columns), - { text: " " }, ...(counting ? meter(counting.have, counting.need, danger) : []), { text: " ".repeat(Math.max(0, columns.meter - (counting?.need ?? 0))) }, muted(counting ? ` ${counting.have} of ${counting.need}` : ""), @@ -558,11 +584,10 @@ function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, wid ) } const { group } = item - const room = Math.max(4, columns.chipAt - LIST_LEAD - 2) + const room = Math.max(4, columns.chipAt - lead - 2) return fit( [ - { text: " " }, - { text: "! ", tone: "warning" }, + ...start("!", "warning"), plain( cell( `${group.permission === "bash" ? "" : `${group.permission} `}${group.patterns.join(" ")}`, @@ -571,7 +596,6 @@ function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, wid ), { text: " " }, ...chipCell(group.agent, columns), - { text: " " }, ...(width - columns.chipAt - columns.chip > 26 ? [muted(" "), keyRun("enter"), muted(" what it covers")] : []), @@ -599,9 +623,9 @@ function hintsFor(item: ActivityItem | undefined, model: ActivityModel, paused: ...(w.off ? { off: true } : {}), }) } - hints.push({ key: "l", label: "Ledger", priority: 6 }) + hints.push({ key: "a", label: "Ledger", priority: 6 }) hints.push({ key: "p", label: paused ? "Resume" : "Pause", priority: paused ? 5.5 : 2 }) hints.push({ key: "?", label: "Keys", priority: 4.5 }) - hints.push(closeHint()) + hints.push(closeHint("Back")) return hints } diff --git a/packages/trust/src/core/view/card.ts b/packages/trust/src/core/view/card.ts index 4361c0e7..97087c05 100644 --- a/packages/trust/src/core/view/card.ts +++ b/packages/trust/src/core/view/card.ts @@ -4,21 +4,35 @@ */ import { anyOf, narrower, outside, redirected, showSubject, spelledWords, widenable } from "../family.ts" -import { earned, type History, type Mark } from "../history.ts" +import { dayOf, earned, type History, latestAnswers, type Mark } from "../history.ts" import { keyOf, type Thresholds } from "../ledger.ts" import { NOT_COVERED, revokeLabel, widenLabel, widenScope } from "./actions.ts" import { type AlwaysGroup, + alwaysGroups, type Command, + commandsOf, countsOf, type Family, leadOf, type Reading, type Standing, } from "./model.ts" -import { agentsText, button, chip, meter, muted, plain, plural, since, when, wrapRuns } from "./parts.ts" -import { filled, fit, type Row, type Run, rowText, widthOf } from "./rows.ts" -import { commandText, familyText, type Node, nodeTarget } from "./tree.ts" +import { + agentsText, + button, + chip, + clock, + meter, + muted, + plain, + plural, + since, + when, + wrapRuns, +} from "./parts.ts" +import { filled, fit, type Row, type Run, rowText, squeeze, widthOf } from "./rows.ts" +import { commandText, familyText, type Node, nodeTarget, SECTION_TITLES } from "./tree.ts" /** `Still asks` and two spaces: the longest label. */ export const LABEL = 12 @@ -30,20 +44,54 @@ interface Fact { keep: number /** The first line is a row of moments: it keeps its newest end on one row rather than wrapping. */ newest?: boolean + /** + * Each line is one row: its first run (a command, a path) cut in the middle to fit, the rest — its + * standing — kept whole at the row's end. A list of commands reads down the card, one a row. + */ + table?: boolean } /** A fact's rows at `width`: each line wrapped, a row of moments cut from its oldest end. */ export function factLines(fact: Fact, width: number): Row[] { + if (fact.table) { + /** One width for every line's first cell, so the standings line up down the card. */ + const rest = Math.max(0, ...fact.lines.map((line) => widthOf(rowText(line.slice(1))))) + return fact.lines.map((line) => tableRow(line, width, rest)) + } return fact.lines.flatMap((line, at) => at === 0 && fact.newest ? [fit(tail(line, width), width)] : wrapRuns(line, width), ) } +/** + * A table line in `width`: its first run cut in the middle and padded to the column every line of the + * table shares (`width` less the widest `rest`), then the rest whole. + */ +function tableRow(line: readonly Run[], width: number, restWidth: number): Row { + const [first, ...rest] = line + if (!first) return fit([], width) + const room = Math.max(4, width - restWidth - 1) + const text = squeeze(first.text, room) + return fit([{ ...first, text }, { text: " ".repeat(Math.max(0, room - widthOf(text))) }, ...rest], width) +} + +/** How a command stands, in a few cells, as the tree's column says it. */ +function standingShort(command: Command): Run[] { + const { stand } = leadOf(command) + if (stand.kind === "trusted") return [{ text: "✓ trusted", tone: "success" }] + if (stand.kind === "widened") return [{ text: "✓ any", tone: "success" }] + if (stand.kind === "counting" && stand.expired) return [muted("expired")] + if (command.phase === "once") return [muted("○ once")] + return stand.kind === "counting" + ? [...meter(stand.have, stand.need, command.danger !== undefined), muted(` ${stand.have}/${stand.need}`)] + : [] +} + export interface Button { key: string label: string off: boolean - action: "revoke" | "widen" | "copy" + action: "revoke" | "widen" | "copy" | "activity" } export interface CardParts { @@ -58,9 +106,11 @@ export interface CardReading extends Reading { families: readonly Family[] } -/** The buttons a node offers: `x`, `w` where a family can widen, `c`. */ +/** The buttons a node offers: `x`, `w` where a family can widen, `c`; today's strip, the activity. */ export function buttonsOf(node: Node, families: readonly Family[]): Button[] { + if (node.kind === "today") return [{ key: "a", label: "Full activity", off: false, action: "activity" }] const target = nodeTarget(node) + if (!target) return [] const x = revokeLabel(target) const out: Button[] = [{ key: "x", label: x.label, off: x.off, action: "revoke" }] if (node.kind !== "always") { @@ -372,30 +422,18 @@ function familyCard(family: Family, reading: CardReading): CardParts { ], ] const facts: Fact[] = [] - const list: Run[] = [] - family.commands.forEach((command, at) => { - if (at > 0) list.push(muted(" · ")) - list.push({ - text: showSubject(command.permission, command.subject), - tone: command.phase === "answering" ? "success" : "text", + /** One command a line, its standing at the end of it: a list joined by `·` wrapped mid-command. */ + if (family.commands.length > 0) + facts.push({ + label: family.permission === "edit" ? "Files" : "Commands", + keep: 7, + lines: family.commands.map((command) => [ + plain(showSubject(command.permission, command.subject)), + { text: " " }, + ...standingShort(command), + ]), + table: true, }) - }) - if (list.length > 0) facts.push({ label: "Commands", keep: 7, lines: [list] }) - const best = family.commands.find((command) => command.phase === "learning") - if (best) { - const { stand, entry } = leadOf(best) - if (stand.kind === "counting") - facts.push({ - label: "Closest", - keep: 5, - lines: [ - [ - plain(showSubject(best.permission, best.subject)), - muted(`, ${stand.have} of ${stand.need} for ${entry.agent}`), - ], - ], - }) - } if (family.widened.length > 0) facts.push({ label: "Still asks", keep: 6, lines: [[muted(`${NOT_COVERED}.`)]] }) const fam = familyFact(family, undefined, reading) @@ -457,13 +495,122 @@ function alwaysCard(groups: readonly AlwaysGroup[], reading: CardReading): CardP } } +/** Today's strip, selected: what Trust answered today, what is one approval away, and OpenCode's own. */ +function todayCard(reading: CardReading): CardParts { + const { history, now } = reading + const today = latestAnswers(history, 200).filter((answer) => dayOf(answer.at) === dayOf(now)) + const facts: Fact[] = [] + if (today.length > 0) + facts.push({ + label: "Answered", + keep: 7, + table: true, + /** What and when; why it answered is the activity's to say, one key away. */ + lines: today.map((answer) => [ + plain( + `${answer.permission === "bash" ? "" : `${answer.permission} `}${answer.items + .map((item) => showSubject(answer.permission, item.subject)) + .join(" && ")}`, + ), + { text: " " }, + muted(clock(answer.at, now)), + ]), + }) + const almost = commandsOf(reading).filter((command) => { + const { stand } = leadOf(command) + return command.phase === "learning" && stand.kind === "counting" && stand.need - stand.have === 1 + }) + if (almost.length > 0) + facts.push({ + label: "Almost", + keep: 5, + lines: [ + [ + { text: `${almost.length}`, tone: "warning", bold: true }, + muted(` ${almost.length === 1 ? "is" : "are"} one approval away`), + ...(almost[0] + ? [muted(" · newest "), plain(showSubject(almost[0].permission, almost[0].subject))] + : []), + ], + ], + }) + const always = alwaysGroups(reading.state) + if (always.length > 0) + facts.push({ + label: "Watch", + keep: 4, + lines: [ + [ + { text: "! ", tone: "warning" }, + muted(`OpenCode's own "always" for `), + plain(always.map((group) => group.patterns.join(" ")).join(", ")), + ], + ], + }) + return { + title: [ + { text: "Today", tone: "text", bold: true }, + muted( + today.length > 0 + ? ` ${plural(today.length, "prompt")} Trust answered for you` + : " nothing answered yet", + ), + ], + standing: [], + facts, + buttons: [], + } +} + +/** A kind's families seen once, folded: what they are, and that there is nothing to do with them yet. */ +function onceCard(node: Extract, reading: CardReading): CardParts { + const commands = node.families.flatMap((family) => family.commands) + return { + title: [ + { text: "Seen once", tone: "text", bold: true }, + muted(` ${SECTION_TITLES[node.section].toLowerCase()} · ${plural(commands.length, "approval")}`), + ], + standing: [], + facts: [ + { + label: "Families", + keep: 7, + table: true, + lines: node.families.map((family) => [ + plain(family.permission === "edit" ? family.family : familyText(family)), + { text: " " }, + muted(plural(family.commands.length, family.permission === "edit" ? "file" : "command")), + ]), + }, + { + label: "Means", + keep: 5, + lines: [ + [ + muted( + `Approved once and not again: nothing to act on until one is approved ${reading.settings.threshold} times in a row. → opens them, / finds one.`, + ), + ], + ], + }, + ], + buttons: [], + } +} + export function cardOf(node: Node, reading: CardReading): CardParts { const parts = - node.kind === "always" - ? alwaysCard(node.groups, reading) - : node.kind === "command" - ? commandCard(node.command, node.family, reading) - : familyCard(node.family, reading) + node.kind === "today" + ? todayCard(reading) + : node.kind === "once" + ? onceCard(node, reading) + : node.kind === "always" + ? alwaysCard(node.groups, reading) + : node.kind === "command" + ? commandCard(node.command, node.family, reading) + : node.kind === "family" || node.kind === "more" + ? familyCard(node.family, reading) + : { title: [], standing: [], facts: [], buttons: [] } return { ...parts, buttons: buttonsOf(node, reading.families) } } @@ -523,14 +670,17 @@ export function cardRows(parts: CardParts, width: number, room: number, focus: n } }) const rows: Row[] = [...title, ...stand] - if (air >= 1) rows.push(fit([], width)) + if (air >= 1 && stand.length > 0) rows.push(fit([], width)) facts.forEach((fact, index) => { rows.push(...factRows(fact, give[index] as number)) }) - /** The buttons sit right under the facts, and the card's spare room is below them. */ - if (air >= 2) rows.push(fit([], width)) + /** The card's spare room is above the buttons: they sit on its last row, where every card has them. */ + if (parts.buttons.length === 0) { + while (rows.length < room) rows.push(fit([], width)) + return rows.slice(0, room) + } + while (rows.length < room - 1) rows.push(fit([], width)) rows.push(buttons) - while (rows.length < room) rows.push(fit([], width)) return rows.slice(0, room) } diff --git a/packages/trust/src/core/view/explorer.ts b/packages/trust/src/core/view/explorer.ts index 2f72afc7..1c070626 100644 --- a/packages/trust/src/core/view/explorer.ts +++ b/packages/trust/src/core/view/explorer.ts @@ -1,10 +1,12 @@ /** - * The ledger, behind `l`: every rule Trust holds, as a tree of families, and a card that explains the - * one selected — always on screen, so there is no details key to find. + * The ledger, the screen `/trust` opens on: every rule Trust holds, by kind and then by family, and a + * card that explains the one selected — always on screen, so there is no details key to find. Today's + * answers are one row above it; selected, the card lists them, and `a` opens the full activity. * * Trust · opencode-cockpit 11 trusted · 14 learning · 101 seen once ● answering + * Today ✓ 6 answered · last 10:08 git status --short [a] Activity * ────────────────────────────────────────┬───────────────────────────────────────────────────── - * FAMILIES / filter │ head -30 + * COMMANDS / filter │ head -30 * ▾ head 1 ✓ 5 ○ │ ✓ Trusted for general · ready, not used yet * ▌ head -30 ✓ trusted │ * head -40 ▰▰▱ 2 of 3 │ Exactly head -30 @@ -31,7 +33,8 @@ */ import { closeHint, type Hint } from "@opencode-cockpit/client/design" -import type { History } from "../history.ts" +import { showSubject } from "../family.ts" +import { dayOf, type History, latestAnswers } from "../history.ts" import { type Button, buttonsAt, @@ -45,20 +48,26 @@ import { import type { Reading } from "./model.ts" import { buttonHits, + clock, footerRow, type Hit, headerRow, type KeyLine, + key, keyListBody, muted, + plain, wrapRuns, } from "./parts.ts" -import { fit, type Row, type Run, spread, type Tone } from "./rows.ts" +import { cursorRow, fit, type Row, type Run, spread, squeeze, type Tone, widthOf } from "./rows.ts" import { badgesOf, type ExplorerModel, explorerModel, + type Line, type Node, + SECTION_TITLES, + type Section, statusWidthOf, type Tree, treeRow, @@ -95,7 +104,11 @@ export const WIDE = 90 export const MIN_HEIGHT = 11 export const EXPLORER_KEYS: readonly KeyLine[] = [ - { keys: ["j/k", "↑/↓"], does: "Move through the families and their commands; the wheel moves too" }, + { + keys: ["j/k", "↑/↓"], + does: "Move through today's answers, the families and their commands; the wheel moves too", + }, + { keys: ["a"], does: "The full activity: what Trust answered, what is close, OpenCode's own approvals" }, { keys: ["←/h", "→/l"], does: "Fold or open a family; ← on a command goes to its family" }, { keys: ["space", "enter"], does: "Open or fold a family, or list the rest of it" }, { keys: ["tab"], does: "Into the card's buttons, and back; ←/→ choose one, enter presses it" }, @@ -104,20 +117,26 @@ export const EXPLORER_KEYS: readonly KeyLine[] = [ { keys: ["c"], does: "Copy the rule as opencode.json config" }, { keys: ["/"], does: "Filter by text; esc clears it" }, { keys: ["p"], does: "Pause Trust in this project, or resume it" }, - { keys: ["?", "esc"], does: "Hide these keys; esc goes back to the activity, and closes from there" }, + { keys: ["?", "esc"], does: "Hide these keys; esc steps back (the card, the filter), then closes" }, ] const rule = (width: number) => fit([{ text: "─".repeat(width), tone: "border" }], width) export function explorerRows(input: ExplorerInput): ExplorerView { - const { width, state } = input + const { width, state, now } = input const height = Math.max(MIN_HEIGHT, input.height) - const model = explorerModel(input) - const index = Math.max( - 0, - model.nodes.findIndex((node) => node.key === input.selected), - ) - const node = model.nodes[index] + const answers = latestAnswers(input.history, 200) + const today = answers.filter((answer) => dayOf(answer.at) === dayOf(now)) + const model = explorerModel({ ...input, today: today.length }) + /** + * Where the cursor starts: on the first rule, the Today strip being one row up. With no rule at all, + * on nothing, so the card says how Trust starts learning. + */ + const first = model.nodes.findIndex((each) => each.kind !== "today") + const found = model.nodes.findIndex((each) => each.key === input.selected) + const index = found >= 0 ? found : first + const node = index >= 0 ? model.nodes[index] : undefined + const strip = model.nodes[0]?.kind === "today" ? model.nodes[0] : undefined const reading: CardReading = { ...input, families: model.families } const card = node ? cardOf(node, reading) : undefined const buttons = card?.buttons ?? [] @@ -130,6 +149,10 @@ export function explorerRows(input: ExplorerInput): ExplorerView { ] const rows: Row[] = [headerRow(input.project, state.paused, said, width)] const hits: Hit[] = [] + if (strip) { + rows.push(todayRow(today[0], today.length, now, width, node === strip)) + hits.push({ kind: "row", y: rows.length - 1, key: strip.key }) + } const wide = width >= WIDE const focus = input.focus && buttons.length > 0 ? Math.min(input.focus.button, buttons.length - 1) : undefined @@ -142,7 +165,7 @@ export function explorerRows(input: ExplorerInput): ExplorerView { return { rows, ...(node ? { node } : {}), model, buttons, hits, wide } } - const body = height - 4 + const body = height - 4 - (strip ? 1 : 0) const treeWidth = wide ? Math.max(34, Math.min(46, Math.round(width * 0.4))) : width const cardWidth = wide ? width - treeWidth - 1 : width /** Stacked: the card under the tree, as tall as it needs up to a little over half, the tree keeping three rows. */ @@ -152,19 +175,30 @@ export function explorerRows(input: ExplorerInput): ExplorerView { : Math.max(3, Math.min(cardNeed, Math.max(6, Math.ceil((body - 1) * 0.55)), body - 1 - 3)) const treeRoom = wide ? body : body - 1 - cardRoom - /* the tree: a heading, then a window of nodes around the cursor */ + /* + * The tree: the kind the window starts in as its top line (it stays as the list scrolls), then a + * window of lines around the cursor — the other kinds' headings, rows of air, the nodes. + */ const listRoom = Math.max(1, treeRoom - 1) - const top = Math.max(0, Math.min(index - Math.floor(listRoom / 2), model.nodes.length - listRoom)) - const shown = model.nodes.slice(top, top + listRoom) + const lines = model.lines + /** The cursor's line; on the Today strip, which is not in the tree, the first rule's. */ + const inTree = lines.findIndex((line) => line.kind === "node" && line.node === node) + const at = Math.max(0, inTree >= 0 ? inTree : lines.findIndex((line) => line.kind === "node")) + let top = Math.max(0, Math.min(at - Math.floor(listRoom / 2), lines.length - listRoom)) + /** The top line already names the kind: its heading, or a row of air before one, is not drawn twice. */ + while (top < lines.length - 1 && (lines[top] as Line).kind !== "node" && top < at) top++ + const window = lines.slice(top, top + listRoom) + const shown = window.flatMap((line) => (line.kind === "node" ? [line.node] : [])) + const section = sectionAt(lines, top) const headRight: Run[] = input.typing !== undefined ? [{ text: `/ ${input.typing}`, tone: "text" }, { text: "▍", tone: "accent" }, { text: " " }] : input.filter !== "" ? [{ text: `/ ${input.filter}`, tone: "accent" }, { text: " " }] - : model.nodes.length > listRoom - ? [muted(`${top + 1}–${top + shown.length} of ${model.nodes.length}`), { text: " " }] - : [muted("/ filter"), { text: " " }] - const treeLines: Row[] = [spread([muted(" FAMILIES")], headRight, treeWidth)] + : [muted("/ filter"), { text: " " }] + const treeLines: Row[] = [ + spread([muted(` ${section ? SECTION_TITLES[section] : "RULES"}`)], headRight, treeWidth), + ] const statusWidth = statusWidthOf(shown, treeWidth) const badges = badgesOf(shown) /** Nothing to list: a word in the tree, and in the card — where there is room — what to do about it. */ @@ -172,9 +206,16 @@ export function explorerRows(input: ExplorerInput): ExplorerView { input.filter !== "" ? `Nothing matches "${input.filter}". esc clears the filter.` : `Nothing learned yet. Approve the same command ${input.settings.threshold} times in a row and Trust answers it for you from then on — that exact command, for that agent. A dangerous one takes ${input.settings.threshold + input.settings.dangerExtra}.` - if (model.nodes.length === 0) + if (lines.length === 0) treeLines.push(fit([muted(input.filter !== "" ? " No match." : " Nothing yet.")], treeWidth)) - for (const each of shown) treeLines.push(treeRow(each, treeWidth, statusWidth, badges, each === node)) + /** Which node each tree row draws, for clicks. */ + const rowNodes: (Node | undefined)[] = [undefined] + for (const line of window) { + if (line.kind === "heading") treeLines.push(fit([muted(` ${SECTION_TITLES[line.section]}`)], treeWidth)) + else if (line.kind === "gap") treeLines.push(fit([], treeWidth)) + else treeLines.push(treeRow(line.node, treeWidth, statusWidth, badges, line.node === node)) + rowNodes.push(line.kind === "node" ? line.node : undefined) + } while (treeLines.length < treeRoom) treeLines.push(fit([], treeWidth)) const cardLines = card @@ -193,9 +234,8 @@ export function explorerRows(input: ExplorerInput): ExplorerView { for (let i = 0; i < body; i++) { rows.push([...(treeLines[i] as Row), { text: "│", tone: "border" }, ...(cardLines[i] as Row)]) const y = rows.length - 1 - const at = i - 1 - if (i >= 1 && at < shown.length) - hits.push({ kind: "row", y, key: (shown[at] as Node).key, x0: 0, x1: treeWidth }) + const drawn = rowNodes[i] + if (drawn) hits.push({ kind: "row", y, key: drawn.key, x0: 0, x1: treeWidth }) if (i === buttonsRow) for (const hit of buttonHits(cardLines[i] as Row, y, actions)) if (hit.kind === "button") @@ -210,9 +250,8 @@ export function explorerRows(input: ExplorerInput): ExplorerView { rows.push(rule(width)) treeLines.forEach((line, i) => { rows.push(line) - const at = i - 1 - if (i >= 1 && at < shown.length) - hits.push({ kind: "row", y: rows.length - 1, key: (shown[at] as Node).key }) + const drawn = rowNodes[i] + if (drawn) hits.push({ kind: "row", y: rows.length - 1, key: drawn.key }) }) rows.push(rule(width)) cardLines.forEach((line, i) => { @@ -227,6 +266,45 @@ export function explorerRows(input: ExplorerInput): ExplorerView { return { rows, ...(node ? { node } : {}), model, buttons, hits, wide } } +/** The kind the line at `top` belongs to: the nearest heading at or above it. */ +function sectionAt(lines: readonly Line[], top: number): Section | undefined { + for (let i = Math.min(top, lines.length - 1); i >= 0; i--) { + const line = lines[i] as Line + if (line.kind === "heading") return line.section + } + const firstHeading = lines.find((line) => line.kind === "heading") + return firstHeading?.kind === "heading" ? firstHeading.section : undefined +} + +/** + * The Today strip: how many prompts Trust answered today and the last one, `[a] Activity` at the end. + * Selected, the card lists today's answers. + */ +function todayRow( + last: { at: number; permission: string; items: { subject: string }[] } | undefined, + count: number, + now: number, + width: number, + selected: boolean, +): Row { + const right: Run[] = [key("a"), muted(" Activity "), { text: " " }] + const lead: Run[] = [muted(" Today ")] + const said: Run[] = last + ? [ + { text: `✓ ${count} answered`, tone: "success" }, + muted(" · last "), + muted(clock(last.at, now)), + { text: " " }, + ] + : [muted("nothing answered yet")] + const label = last + ? `${last.permission === "bash" ? "" : `${last.permission} `}${last.items.map((item) => showSubject(last.permission, item.subject)).join(" && ")}` + : "" + const room = Math.max(0, width - widthOf([...lead, ...said, ...right].map((run) => run.text).join("")) - 2) + const row = spread([...lead, ...said, ...(label ? [plain(squeeze(label, room))] : [])], right, width) + return selected ? cursorRow(row, width) : row +} + /** Rows the card wants at this width, nothing cut. */ function cardHeight(card: CardParts, width: number): number { const textWidth = Math.max(1, width - 2 - LABEL) @@ -255,12 +333,15 @@ function hintsFor( ] const hints: Hint[] = [ { key: "↑/↓", label: "Move", priority: 6 }, - ...(node && node.kind !== "always" ? [{ key: "←/→", label: "Fold", priority: 4 }] : []), - ...(buttons.length > 0 ? [{ key: "tab", label: "Card", priority: 3 }] : []), + ...(node && node.kind !== "always" && node.kind !== "today" + ? [{ key: "←/→", label: "Fold", priority: 4 }] + : []), + ...(buttons.length > 0 && node?.kind !== "today" ? [{ key: "tab", label: "Card", priority: 3 }] : []), { key: "/", label: "Filter", priority: 2 }, + { key: "a", label: "Activity", priority: 4.5 }, { key: "p", label: input.state.paused ? "Resume" : "Pause", priority: input.state.paused ? 5.5 : 1 }, { key: "?", label: "Keys", priority: 5 }, - closeHint(input.filter ? "Clear" : "Back"), + closeHint(input.filter ? "Clear" : "Close"), ] return hints } diff --git a/packages/trust/src/core/view/rows.ts b/packages/trust/src/core/view/rows.ts index 776fff4a..d0f7519d 100644 --- a/packages/trust/src/core/view/rows.ts +++ b/packages/trust/src/core/view/rows.ts @@ -79,6 +79,53 @@ export function widthOf(text: string, limit = Number.POSITIVE_INFINITY): number } /** `text` in at most `width` columns, ending in `…` when cut. */ +/** + * `text` in `width` columns with its paths cut in the middle, not at the end: the start says where, + * the end names the file — `tail -10 ~/…/trust/events.ndjson`. Cut at the end, five `tail` commands + * on one ledger file read as the same `~/.local/share/opencode-cockpit/trust/Projects-acme-store…`, + * and the file name, the only part that told them apart, was the part that was cut. Each path gives up + * one middle folder at a time, the longest path first; only then is the end cut. + */ +export function squeeze(text: string, width: number): string { + if (widthOf(text, width) <= width) return text + const words = text.split(" ") + while (widthOf(words.join(" ")) > width) { + let best = -1 + for (let i = 0; i < words.length; i++) { + const word = words[i] as string + if (shrinkPath(word) === word) continue + if (best < 0 || widthOf(word) > widthOf(words[best] as string)) best = i + } + if (best < 0) break + words[best] = shrinkPath(words[best] as string) + } + return cut(words.join(" "), width) +} + +/** + * One middle folder of a path given up: `~/.local/share/x/f` → `~/…/share/x/f` → `~/…/x/f` → + * `~/…/f`. Its first part (`~`, `..`, `/var`, `packages`) and its last (the file) are kept. + */ +function shrinkPath(path: string): string { + /** A folder's own name is its last part: `/var/a/b/T/` keeps `T/`. */ + if (path.endsWith("/") && path.length > 1) { + const inner = shrinkPath(path.slice(0, -1)) + return inner === path.slice(0, -1) ? path : `${inner}/` + } + const parts = path.split("/") + if (parts.length < 3) return path + const gap = parts.indexOf("…") + if (gap < 0) { + const head = parts[0] === "" ? 2 : 1 + if (parts.length - head < 2) return path + parts.splice(head, 1, "…") + return parts.join("/") + } + if (parts.length - gap - 1 <= 1) return path + parts.splice(gap + 1, 1) + return parts.join("/") +} + export function cut(text: string, width: number): string { if (width <= 0) return "" if (widthOf(text, width) <= width) return text diff --git a/packages/trust/src/core/view/tree.ts b/packages/trust/src/core/view/tree.ts index bbfbed44..498230e4 100644 --- a/packages/trust/src/core/view/tree.ts +++ b/packages/trust/src/core/view/tree.ts @@ -1,3 +1,20 @@ +/** + * The ledger's tree: what Trust holds, by what was asked — commands, edits, tools and fetches — then + * by family, a command one row in its family whatever its agents say. + * + * **Kinds before families.** One list ordered only by standing put `edit packages/api/…` between + * `git status` and `jq`, and three edits in three folders read as three unrelated rows. Each kind + * has its own heading now, in a fixed order, and an edit's family — its folder — is always a row of + * its own under Edits, with how many files it holds. + * + * **What was seen once folds away.** Most of a busy ledger is commands approved once and never again + * (34 of 45 in the project that asked for this), none of them anything to act on yet. Within a kind, + * every family of them is one `seen once` row that opens like any other; a filter still finds them. + * + * **One column, one shape.** Every row ends in the same slot: `✓` trusted, `▰` learning, `○` seen + * once — counts on a family's row, the standing itself on a command's. + */ + import { showSubject } from "../family.ts" import type { Target } from "./actions.ts" import { @@ -13,17 +30,35 @@ import { type Reading, } from "./model.ts" import { badge, meter, muted, plain, plural } from "./parts.ts" -import { cursorRow, fit, type Row, type Run, rowText, spread, type Tone, widthOf } from "./rows.ts" +import { cursorRow, fit, type Row, type Run, rowText, spread, squeeze, type Tone, widthOf } from "./rows.ts" + +/** The kinds a ledger is grouped by, in the order they are listed. */ +export type Section = "commands" | "edits" | "tools" +export const SECTIONS: readonly Section[] = ["commands", "edits", "tools"] +export const SECTION_TITLES: Record = { + commands: "COMMANDS", + edits: "EDITS", + tools: "TOOLS & FETCHES", +} +export const sectionOf = (permission: string): Section => + permission === "bash" ? "commands" : permission === "edit" ? "edits" : "tools" export type Node = + /** Today's answers, the strip above the tree: selected, the card lists them. */ + | { kind: "today"; key: string; answers: number } | { kind: "family"; key: string; family: Family; open: boolean } | { kind: "command"; key: string; command: Command; family: Family; nested: boolean } /** The folded tail of an open family: `+ 3 more`. */ | { kind: "more"; key: string; family: Family; hidden: number } + /** A kind's families seen only once, folded into one row. */ + | { kind: "once"; key: string; section: Section; families: Family[]; open: boolean } | { kind: "always"; key: string; groups: AlwaysGroup[] } +/** A row of the tree as drawn: a kind's heading, a row of air, or a node the cursor can stop on. */ +export type Line = { kind: "heading"; section: Section } | { kind: "gap" } | { kind: "node"; node: Node } + export interface Tree { - /** Families opened, by `Family.key`. */ + /** Families opened, by `Family.key`; `seen once` rows too, by their node key. */ open: ReadonlySet /** Families whose tail is shown too, by `Family.key`. */ full: ReadonlySet @@ -32,7 +67,10 @@ export interface Tree { } export interface ExplorerModel { + /** Every node the cursor moves over, in screen order: the Today strip first when there is one. */ nodes: Node[] + /** The tree as drawn, the Today strip left out (it sits above the tree). */ + lines: Line[] families: Family[] commands: Command[] counts: Counts @@ -43,56 +81,103 @@ export const TAIL = 3 export const familyNodeKey = (family: Family): string => `f:${family.key}` export const commandNodeKey = (command: Command): string => `c:${command.key}` +export const onceNodeKey = (section: Section): string => `o:${section}` export const ALWAYS_KEY = "o:always" +export const TODAY_KEY = "t:today" -/** A family drawn as its one command: one command, and no widening a heading would have to say. */ -const single = (family: Family) => family.commands.length === 1 && family.widened.length === 0 +/** A family drawn as its one command: one command, and no widening a heading would have to say. Never an edit's folder. */ +const single = (family: Family) => + family.permission !== "edit" && family.commands.length === 1 && family.widened.length === 0 + +/** Nothing in it trusted or learning, and nothing widened: a family seen once, folded away. */ +const seenOnce = (family: Family) => + family.widened.length === 0 && family.commands.every((command) => command.phase === "once") export const commandText = (command: Command) => `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}` export const familyText = (family: Family) => `${family.permission === "bash" ? "" : `${family.permission} `}${showSubject(family.permission, family.family)}` -export function explorerModel(input: Reading & Tree): ExplorerModel { +/** A command as its row says it: under Edits the path alone, the heading says what kind it is. */ +const rowCommandText = (command: Command) => + command.permission === "edit" ? command.subject : commandText(command) +const rowFamilyText = (family: Family) => (family.permission === "edit" ? family.family : familyText(family)) + +export function explorerModel(input: Reading & Tree & { today?: number }): ExplorerModel { const commands = commandsOf(input) const families = familiesOf(input, commands) const needle = input.filter.trim().toLowerCase() const nodes: Node[] = [] - for (const family of families) { - const named = needle !== "" && familyText(family).toLowerCase().includes(needle) - const listed = - needle === "" || named - ? family.commands - : family.commands.filter((command) => commandText(command).toLowerCase().includes(needle)) - if (needle !== "" && listed.length === 0 && !named) continue + const lines: Line[] = [] + if (input.today !== undefined && needle === "") + nodes.push({ kind: "today", key: TODAY_KEY, answers: input.today }) + + /** A family's nodes: its row (or its one command), and when open its commands and `+ N more`. */ + const familyNodes = (family: Family, listed: readonly Command[]): Node[] => { if (single(family)) { const command = family.commands[0] as Command - nodes.push({ kind: "command", key: commandNodeKey(command), command, family, nested: false }) - continue + return [{ kind: "command", key: commandNodeKey(command), command, family, nested: false }] } const open = needle !== "" || input.open.has(family.key) - nodes.push({ kind: "family", key: familyNodeKey(family), family, open }) - if (!open) continue + const out: Node[] = [{ kind: "family", key: familyNodeKey(family), family, open }] + if (!open) return out const whole = needle !== "" || input.full.has(family.key) || listed.length <= TAIL + 1 const shown = whole ? listed : listed.slice(0, TAIL) for (const command of shown) - nodes.push({ kind: "command", key: commandNodeKey(command), command, family, nested: true }) + out.push({ kind: "command", key: commandNodeKey(command), command, family, nested: true }) if (!whole) - nodes.push({ kind: "more", key: `m:${family.key}`, family, hidden: listed.length - shown.length }) + out.push({ kind: "more", key: `m:${family.key}`, family, hidden: listed.length - shown.length }) + return out + } + + for (const section of SECTIONS) { + const mine: Node[] = [] + const once: Family[] = [] + for (const family of families) { + if (sectionOf(family.permission) !== section) continue + const named = needle !== "" && familyText(family).toLowerCase().includes(needle) + const listed = + needle === "" || named + ? family.commands + : family.commands.filter((command) => commandText(command).toLowerCase().includes(needle)) + if (needle !== "" && listed.length === 0 && !named) continue + if (needle === "" && seenOnce(family)) once.push(family) + else mine.push(...familyNodes(family, listed)) + } + if (once.length === 1) mine.push(...familyNodes(once[0] as Family, (once[0] as Family).commands)) + else if (once.length > 1) { + const key = onceNodeKey(section) + const open = input.open.has(key) + mine.push({ kind: "once", key, section, families: once, open }) + if (open) for (const family of once) mine.push(...familyNodes(family, family.commands)) + } + if (mine.length === 0) continue + if (lines.length > 0) lines.push({ kind: "gap" }) + lines.push({ kind: "heading", section }) + for (const node of mine) { + nodes.push(node) + lines.push({ kind: "node", node }) + } } + const groups = alwaysGroups(input.state) if ( groups.length > 0 && (needle === "" || "opencode always".includes(needle) || groups.some((group) => group.patterns.join(" ").toLowerCase().includes(needle))) - ) - nodes.push({ kind: "always", key: ALWAYS_KEY, groups }) - return { nodes, families, commands, counts: countsOf(commands) } + ) { + const node: Node = { kind: "always", key: ALWAYS_KEY, groups } + if (lines.length > 0) lines.push({ kind: "gap" }) + nodes.push(node) + lines.push({ kind: "node", node }) + } + return { nodes, lines, families, commands, counts: countsOf(commands) } } -/** What `x`, `w` and `c` act on, for a node. */ -export function nodeTarget(node: Node): Target { +/** What `x`, `w` and `c` act on, for a node. Today's strip and a `seen once` row act on nothing. */ +export function nodeTarget(node: Node): Target | undefined { + if (node.kind === "today" || node.kind === "once") return undefined if (node.kind === "always") return { kind: "always", groups: node.groups } if (node.kind === "command") return { kind: "command", command: node.command, family: node.family } return { kind: "family", family: node.family } @@ -109,6 +194,7 @@ export function reveal( (command) => command.permission === subject.permission && command.subject === subject.subject, ) if (at < 0) continue + if (seenOnce(family)) tree.open.add(onceNodeKey(sectionOf(family.permission))) if (!single(family)) { tree.open.add(family.key) if (at >= TAIL && family.commands.length > TAIL + 1) tree.full.add(family.key) @@ -118,9 +204,23 @@ export function reveal( return undefined } +/** `1 ✓ 2 ▰ 3 ○`: what a family, or a fold of families, holds — only what it has. */ +function tallyRuns(commands: readonly Command[]): Run[] { + const parts: Run[] = [] + const add = (count: number, mark: string, tone: Tone) => { + if (count === 0) return + if (parts.length > 0) parts.push({ text: " " }) + parts.push({ text: `${count} ${mark}`, tone }) + } + add(commands.filter((command) => command.phase === "answering").length, "✓", "success") + add(commands.filter((command) => command.phase === "learning").length, "▰", "warning") + add(commands.filter((command) => command.phase === "once").length, "○", "muted") + return parts +} + /** The right-hand column of a tree row: how the command stands, or what a family holds. */ function statusRuns(node: Node): Run[] { - if (node.kind === "more") return [] + if (node.kind === "more" || node.kind === "today") return [] if (node.kind === "always") return [ { @@ -131,28 +231,17 @@ function statusRuns(node: Node): Run[] { tone: "warning", }, ] - if (node.kind === "family") { - const trusted = node.family.commands.filter((command) => command.phase === "answering").length - const learning = node.family.commands.filter((command) => command.phase === "learning").length - const rest = node.family.commands.length - trusted - return [ - ...(trusted > 0 ? [{ text: `${trusted} ✓`, tone: "success" as Tone }] : []), - ...(trusted > 0 && rest > 0 ? [{ text: " " }] : []), - ...(rest > 0 ? [{ text: `${rest} ○`, tone: (learning > 0 ? "warning" : "muted") as Tone }] : []), - ] - } + if (node.kind === "once") return tallyRuns(node.families.flatMap((family) => family.commands)) + if (node.kind === "family") return tallyRuns(node.family.commands) const { command } = node const { stand } = leadOf(command) - const autos = command.standings.reduce((sum, each) => sum + each.entry.autos, 0) - if (stand.kind === "trusted") - return [{ text: "✓ trusted", tone: "success" }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])] - if (stand.kind === "widened") - return [{ text: "✓ widened", tone: "success" }, ...(autos > 0 ? [muted(` ${autos}×`)] : [])] + if (stand.kind === "trusted") return [{ text: "✓ trusted", tone: "success" }] + if (stand.kind === "widened") return [{ text: "✓ any", tone: "success" }] if (stand.expired) return [muted("expired")] - /** A long meter says its count in the short form, so the command keeps the room. */ + if (command.phase === "once") return [muted("○ once")] return [ ...meter(stand.have, stand.need, command.danger !== undefined), - muted(stand.need > 4 ? ` ${stand.have}/${stand.need}` : ` ${stand.have} of ${stand.need}`), + muted(` ${stand.have}/${stand.need}`), ] } @@ -167,31 +256,38 @@ export function treeRow( badges: number, selected: boolean, ): Row { + const room = Math.max(4, width - statusWidth - badges - 2) let left: Run[] if (node.kind === "family") { + const files = + node.family.permission === "edit" ? [muted(` ${plural(node.family.commands.length, "file")}`)] : [] + const any = node.family.widened.length > 0 ? [{ text: " " }, badge("any", "success")] : [] + const name = squeeze(rowFamilyText(node.family), room - 3 - widthOf(rowText([...files, ...any]))) left = [ muted(` ${node.open ? "▾" : "▸"} `), - { - text: familyText(node.family), - tone: - node.family.commands.every((c) => c.phase === "once") && node.family.widened.length === 0 - ? "muted" - : "text", - bold: true, - }, - ...(node.family.widened.length > 0 ? [{ text: " " }, badge("any", "success")] : []), + { text: name, tone: seenOnce(node.family) ? "muted" : "text", bold: true }, + ...any, + ...files, + ] + } else if (node.kind === "once") { + const names = node.families.map((family) => rowFamilyText(family)).join(" ") + left = [ + muted(` ${node.open ? "▾" : "▸"} `), + plain("seen once"), + ...(node.open ? [] : [muted(` ${squeeze(names, Math.max(1, room - 14))}`)]), ] } else if (node.kind === "command") { const indent = node.nested ? 5 : 3 left = [ { text: " ".repeat(indent) }, { - text: nestedText(node, width - indent - statusWidth - badges - 3), + text: squeeze(nestedText(node, room - indent), room - indent), tone: node.command.phase === "once" ? "muted" : "text", }, ] } else if (node.kind === "more") left = [muted(` + ${node.hidden} more`)] - else left = [{ text: " ! ", tone: "warning" }, plain("OpenCode always")] + else if (node.kind === "always") left = [{ text: " ! ", tone: "warning" }, plain("OpenCode always")] + else left = [] const status = statusRuns(node) const danger = node.kind === "command" && node.command.danger !== undefined && node.command.phase !== "answering" @@ -213,9 +309,10 @@ export function treeRow( * words first — the heading above already says them: `… --short -uno`. */ function nestedText(node: Extract, room: number): string { - const text = commandText(node.command) + const text = rowCommandText(node.command) if (widthOf(text) <= room || !node.nested) return text - const family = familyText(node.family) + const family = rowFamilyText(node.family) + if (node.family.permission === "edit" && text.startsWith(family)) return text.slice(family.length) return text.startsWith(`${family} `) ? `…${text.slice(family.length)}` : text } diff --git a/packages/trust/src/tui/ledger.tsx b/packages/trust/src/tui/ledger.tsx index 85507cd2..636c6f0d 100644 --- a/packages/trust/src/tui/ledger.tsx +++ b/packages/trust/src/tui/ledger.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ /** - * `/trust`: the activity it opens on and the ledger behind `l` — the keys, the clicks, the filter + * `/trust`: the ledger it opens on and the activity behind `a` — the keys, the clicks, the filter * typed with `/`, and what `x`, `w`, `c` and `p` do to the ledger. Painted by `paint.ts`, from the * `DialogState` this changes. */ @@ -53,7 +53,8 @@ export function createLedger(input: { } const view = painter.shown().ledger if (!view?.node) return undefined - return { target: nodeTarget(view.node), families: view.model.families } + const target = nodeTarget(view.node) + return target ? { target, families: view.model.families } : undefined } /** What `w` and `x` decided, appended and said. Nothing is ever rewritten in the ledger. */ @@ -115,9 +116,10 @@ export function createLedger(input: { dialog.activity = items[Math.max(0, Math.min(items.length - 1, at + by))]?.key } else { const nodes = painter.shown().ledger?.model.nodes ?? [] + const current = dialog.node ?? painter.shown().ledger?.node?.key const at = Math.max( 0, - nodes.findIndex((node) => node.key === dialog.node), + nodes.findIndex((node) => node.key === current), ) dialog.node = nodes[Math.max(0, Math.min(nodes.length - 1, at + by))]?.key dialog.button = undefined @@ -134,6 +136,14 @@ export function createLedger(input: { draw() } + /** The full activity, from the ledger: `a`, the Today strip's `enter`, or its button. */ + const toActivity = () => { + dialog.view = "activity" + dialog.button = undefined + dialog.notice = undefined + draw() + } + /** `enter` on the activity: why — the card of the rule that answered, or that is close, in the ledger. */ const why = () => { const view = painter.shown().activity @@ -155,8 +165,14 @@ export function createLedger(input: { */ const fold = (way: "toggle" | "open" | "close" = "toggle") => { const at = node() - if (!at || at.kind === "always") return + if (!at || at.kind === "always" || at.kind === "today") return dialog.notice = undefined + /** A kind's `seen once` row opens and folds like a family, by its own key. */ + if (at.kind === "once") { + if (dialog.opened.has(at.key) && way !== "open") dialog.opened.delete(at.key) + else if (!dialog.opened.has(at.key) && way !== "close") dialog.opened.add(at.key) + return draw() + } const key = at.family.key if (at.kind === "more") { if (way !== "close") dialog.full.add(key) @@ -183,6 +199,7 @@ export function createLedger(input: { else if (action === "widen") widenSelected() else if (action === "copy") copy() else if (action === "ledger") toLedger() + else if (action === "activity") toActivity() } const enter = () => { @@ -193,6 +210,7 @@ export function createLedger(input: { return } const at = node() + if (at?.kind === "today") return toActivity() if (at?.kind === "command" || at?.kind === "always") { if (buttons().length > 0) dialog.button = 0 return draw() @@ -268,6 +286,11 @@ export function createLedger(input: { run: listed(() => (dialog.view === "activity" ? toLedger() : sideways(1))), }, { name: "cockpit.trust.tab", title: "Into the card and back", run: listed(() => tab()) }, + { + name: "cockpit.trust.activity", + title: "The activity, or back to the ledger", + run: listed(() => (dialog.view === "activity" ? toLedger() : toActivity())), + }, { name: "cockpit.trust.filter", title: "Filter the ledger", run: listed(() => filter()) }, { name: "cockpit.trust.keys", @@ -301,6 +324,7 @@ export function createLedger(input: { { key: "h,left", cmd: "cockpit.trust.left" }, { key: "l", cmd: "cockpit.trust.l" }, { key: "tab", cmd: "cockpit.trust.tab" }, + { key: "a", cmd: "cockpit.trust.activity" }, { key: "/", cmd: "cockpit.trust.filter" }, { key: "?,shift+/", cmd: "cockpit.trust.keys" }, { key: "x", cmd: "cockpit.trust.revoke" }, @@ -314,8 +338,8 @@ export function createLedger(input: { /** * Ahead of the keymap, because the host's dialog takes `esc` before any layer hears it (Shell's * search does the same): the filter being typed gets every key; and `esc` steps back one thing at - * a time — the key list, the card's buttons, the filter, the ledger — before the host closes the - * dialog from the activity, as it always has. + * a time — the key list, the activity, the card's buttons, the filter — before the host closes the + * dialog from the ledger. */ api.lifecycle.onDispose( api.keymap.intercept( @@ -336,12 +360,12 @@ export function createLedger(input: { } if (event.name !== "escape") return if (dialog.keys) dialog.keys = false - else if (dialog.view === "activity") return + else if (dialog.view === "activity") dialog.view = "ledger" else if (dialog.button !== undefined) dialog.button = undefined else if (dialog.filter !== "") { dialog.filter = "" dialog.node = undefined - } else dialog.view = "activity" + } else return ctx.consume({ preventDefault: true, stopPropagation: true }) dialog.notice = undefined draw() @@ -353,12 +377,14 @@ export function createLedger(input: { const openLedger = () => { /** Config may have changed since: what the dialog says about "ask" rules should be today's. */ void loadRules() + /** The ledger first, the cursor on its first rule; today's answers are the strip above it. */ Object.assign(dialog, { open: true, - view: "activity", + view: "ledger", keys: false, notice: undefined, activity: undefined, + node: undefined, typing: undefined, button: undefined, }) @@ -379,7 +405,7 @@ export function createLedger(input: { }, ) api.ui.dialog.setSize("xlarge") - log.debug("trust: open", { items: painter.shown().activity?.model.items.length ?? 0 }) + log.debug("trust: open", { rules: painter.shown().ledger?.model.nodes.length ?? 0 }) } const togglePause = () => { diff --git a/packages/trust/test/tui.test.ts b/packages/trust/test/tui.test.ts index 9b78df6f..76c51b2a 100644 --- a/packages/trust/test/tui.test.ts +++ b/packages/trust/test/tui.test.ts @@ -286,7 +286,7 @@ describe("the dialog", () => { return consumed } - test("/trust opens on the activity; l the ledger; esc back to the activity, then the host closes", async () => { + test("/trust opens on the ledger; a the activity; esc back to the ledger, then the host closes", async () => { const fake = await start() command(fake, "cockpit.trust.ledger").run() expect(fake.dialog.open).toBe(true) @@ -295,7 +295,7 @@ describe("the dialog", () => { fake.dialog.render?.() } catch {} expect(pressEscape(fake)).toBe(false) - command(fake, "cockpit.trust.l").run() + command(fake, "cockpit.trust.activity").run() expect(pressEscape(fake)).toBe(true) expect(pressEscape(fake)).toBe(false) }) @@ -311,7 +311,7 @@ describe("the dialog", () => { for (const handler of fake.intercepts) handler({ event: { name: "q", sequence: "q" }, consume: () => (consumed = true) }) expect(consumed).toBe(true) - expect(pressEscape(fake)).toBe(true) + /** esc cancels what is typed; the ledger is what opened, so the next esc is the host's. */ expect(pressEscape(fake)).toBe(true) expect(pressEscape(fake)).toBe(false) }) diff --git a/packages/trust/test/view.test.ts b/packages/trust/test/view.test.ts index 316cee16..43b24116 100644 --- a/packages/trust/test/view.test.ts +++ b/packages/trust/test/view.test.ts @@ -7,7 +7,7 @@ import { activityModel, activityRows, answerWhy, targetOf } from "../src/core/vi import { historyRuns } from "../src/core/view/card.ts" import { explorerRows } from "../src/core/view/explorer.ts" import { countsOf } from "../src/core/view/model.ts" -import { type Row, rowText, widthOf } from "../src/core/view/rows.ts" +import { type Row, rowText, squeeze, widthOf } from "../src/core/view/rows.ts" import { sidebarRows, tally } from "../src/core/view/sidebar.ts" import { ALWAYS_KEY, explorerModel, type Node, nodeTarget, reveal, TAIL } from "../src/core/view/tree.ts" @@ -68,8 +68,10 @@ const ledger = ( full.add(family.key) } const filter = options.filter ?? "" - const model = explorerModel({ ...reading, open, full, filter }) - const selected = (options.pick ?? ((nodes) => nodes[0]))(model.nodes)?.key + const model = explorerModel({ ...reading, open, full, filter, today: 0 }) + const selected = (options.pick ?? ((nodes) => nodes.find((node) => node.kind !== "today")))( + model.nodes, + )?.key return { ...reading, open, @@ -250,7 +252,7 @@ describe("the activity: what Trust did, then what it is about to do", () => { expect(text).toContain("! WATCH OUT") expect(text).toMatch(/RULES {2}\d+ trusted · \d+ learning · \d+ seen once/) expect(text).toContain(" l Open the ledger ") - expect(footerOf(activity("busy").rows)).toMatch(/^ \[enter\] Why .*\[esc\] Close$/) + expect(footerOf(activity("busy").rows)).toMatch(/^ \[enter\] Why .*\[a\] Ledger.*\[esc\] Back$/) }) test("why each answer was given, in words", () => { @@ -417,9 +419,9 @@ describe("the ledger: families as a tree, a card for the selection", () => { expect(view.hits.filter((hit) => hit.kind === "button")).toHaveLength(3) }) - test("the footer: move, fold, card, filter, keys, and esc goes back", () => { + test("the footer: move, fold, card, filter, the activity, keys, and esc closes", () => { expect(footerOf(ledger("busy", { width: 116 }).rows)).toBe( - " [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [p] Pause [?] Keys [esc] Back", + " [↑/↓] Move [←/→] Fold [tab] Card [/] Filter [a] Activity [p] Pause [?] Keys [esc] Close", ) }) @@ -473,3 +475,91 @@ describe("acting on a selection", () => { expect(snippet.note).toContain("config cannot say which agent") }) }) + +describe("paths cut in the middle", () => { + const path = "tail -10 ~/.local/share/opencode-cockpit/trust/Projects-acme-store/events.ndjson" + + test("the start says where, the end names the file", () => { + expect(squeeze(path, 60)).toBe("tail -10 ~/…/trust/Projects-acme-store/events.ndjson") + expect(squeeze(path, 30)).toBe("tail -10 ~/…/events.ndjson") + expect(squeeze(path, 200)).toBe(path) + }) + + test("only then is the end cut, and every result fits", () => { + for (const width of [10, 18, 25, 40, 70]) expect(widthOf(squeeze(path, width))).toBeLessThanOrEqual(width) + expect(squeeze(path, 20).endsWith("…")).toBe(true) + }) + + test("the longest path gives way first, and relative or absolute heads are kept", () => { + expect(squeeze("edit ../../../../../var/folders/tq/x/T/opencode-review/a.ts", 34)).toBe( + "edit ../…/x/T/opencode-review/a.ts", + ) + expect(squeeze("cp /var/folders/tq/x/a.ts src/b.ts", 26)).toBe("cp /var/…/x/a.ts src/b.ts") + }) +}) + +describe("a folder cut in the middle keeps its own name", () => { + test("`../…/T/` rather than `../…/`", () => { + expect(squeeze("../../../../var/folders/ab/x7k2q9/T/", 12)).toBe("../…/T/") + }) +}) + +describe("the ledger by kind: commands, edits, tools and fetches, OpenCode's own", () => { + const model = () => { + const reading = readingOf("storefront") + return explorerModel({ + ...reading, + open: new Set(), + full: new Set(), + filter: "", + today: 0, + }) + } + + test("each kind has one heading, in a fixed order, OpenCode's own last", () => { + const headings = model().lines.flatMap((line) => (line.kind === "heading" ? [line.section] : [])) + expect(headings).toEqual(["commands", "edits", "tools"]) + expect(model().nodes.at(-1)?.kind).toBe("always") + }) + + test("every edit is under its folder, and every folder is a row, even with one file", () => { + const { nodes } = model() + const edits = nodes.filter((node) => node.kind === "family" && node.family.permission === "edit") + expect(edits.length).toBeGreaterThanOrEqual(4) + expect(nodes.some((node) => node.kind === "command" && node.command.permission === "edit")).toBe(false) + }) + + test("families seen only once fold into one row per kind, which opens like a family", () => { + const reading = readingOf("storefront") + const folded = model().nodes.filter((node) => node.kind === "once") + expect(folded.length).toBeGreaterThan(0) + const key = (folded[0] as Node).key + const opened = explorerModel({ ...reading, open: new Set([key]), full: new Set(), filter: "" }) + expect(opened.nodes.length).toBeGreaterThan(model().nodes.length - 1) + }) + + test("the Today strip is the first stop, the cursor starts on the first rule, and its card lists today", () => { + const { rows, node } = ledger("storefront", { pick: () => undefined, width: 116, height: 30 }) + expect(rowText(rows[1] as Row)).toMatch(/^ Today {2}✓ \d+ answered · last /) + expect(node?.kind).not.toBe("today") + const today = ledger("storefront", { + pick: (nodes) => nodes.find((each) => each.kind === "today"), + width: 116, + }) + expect(textOf(today.rows)).toContain("prompts Trust answered for you") + }) + + test("a family's card lists one command a line, its standing in one column", () => { + const { rows } = ledger("storefront", { + width: 116, + height: 30, + pick: (nodes) => nodes.find((node) => node.kind === "family" && node.family.family === "tail"), + }) + const columns = rows + .map(rowText) + .filter((line) => /tail -\d+ .*✓/.test(line)) + .map((line) => line.indexOf("✓")) + expect(columns.length).toBeGreaterThanOrEqual(3) + expect(new Set(columns).size).toBe(1) + }) +}) From 89cc30fcf0709d4e65da417613e6f36e2b14a491 Mon Sep 17 00:00:00 2001 From: Codestz Date: Mon, 5 Oct 2026 11:43:04 -0500 Subject: [PATCH 2/2] Trust: the smoke expects the ledger first, and the changelog says what changed Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++++++++++++++++++ scripts/smoke/probes/trust.ts | 10 +++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd545305..557a633c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Changed + +- **Trust: `/trust` opens on the ledger, and the ledger reads in order.** The ledger and the activity + read as disorder in a busy project; both are rebuilt around what you came to do. + - **The ledger first.** What Trust holds, and where you act on it, is what `/trust` opens on. + Today's answers are one row above it: select it and the card lists them; `a` (or `enter` on it) + opens the full activity, and `esc` comes back. + - **Grouped by kind, then by family.** Commands, Edits, Tools & fetches and OpenCode's own each have a + heading, the current one kept at the top as the list scrolls. Every edit sits under its folder, + one row per folder with its file count, instead of scattered between commands. + - **What was seen once is folded away.** Families approved once and never again — most of a busy + ledger — are one `seen once` row per kind; `→` opens it and `/` still finds them. + - **One column, one shape.** Every row ends in the same column: `✓` trusted, `▰` learning, `○` seen + once. A family's card lists one command a line with its standing, and its buttons sit on its last + row. + - **Paths cut in the middle.** `tail -10 ~/…/events.ndjson`, not `~/.local/share/opencode-cockpit/…` + five times with the file name cut off. + - **The activity lines up.** Every row starts with its mark, then the time, then the command; with one + agent, the header names it once instead of a chip on every row. + ## [0.10.0] - 2026-10-05 ### Changed diff --git a/scripts/smoke/probes/trust.ts b/scripts/smoke/probes/trust.ts index c81f1907..0ab1f029 100644 --- a/scripts/smoke/probes/trust.ts +++ b/scripts/smoke/probes/trust.ts @@ -1,4 +1,4 @@ -/** Trust: its sidebar toggle says what it did, and its activity screen opens, from the palette. */ +/** Trust: its sidebar toggle says what it did, and its ledger opens, from the palette. */ import { palette } from "../commands.ts" import { expect, keep, type } from "../harness.ts" @@ -17,7 +17,11 @@ export const trust: Probe = { "the palette's Trust sidebar toggle said nothing", toggled, ) - /** /trust opens on what Trust did; the ledger is one key further, behind `l`. */ - expect(ledger.includes("Open the ledger"), "the palette never opened Trust's activity screen", ledger) + /** /trust opens on the ledger, today's answers one row above it and the activity behind `a`. */ + expect( + ledger.includes("Today") && ledger.includes("[a] Activity"), + "the palette never opened Trust's ledger", + ledger, + ) }, }