Compare commits

...
Author SHA1 Message Date
yhh 80603bac4b feat: the receipt card's visibility is a kernel setting shared by Studio and the TUI
[ui] show_receipt (default off) is read and written through the controller
(ReceiptSettings, GET/POST /receipt-settings). Studio reads it instead of its
browser-local key and carries an old local "on" across once; the TUI draws one
line for a graded receipt unless the key asks for the card.
2026-10-10 21:35:00 -07:00
yhh 085da43e10 feat(studio): one line says a hidden receipt left something unproven
The kernel grades a receipt (event.CompletionReceipt.Severity, carried on the
wire as severity); with the card hidden, the transcript draws a single line only
when that grade is notice or warn. Nothing is drawn for a clean receipt.
2026-10-10 21:17:01 -07:00
esengine 861d6ce832 feat(studio): hide the delivery receipt card by default, setting in Session
The card is drawn at render time from a machine-local preference that
defaults to off, so the kernel's receipt, the completion verdict and the
continuation gates are untouched. Turning it on shows the cards already
in the transcript.
2026-10-10 09:23:26 -07:00
50 changed files with 792 additions and 76 deletions

No files matched your search

+1
View File
@@ -70,6 +70,7 @@ export const ACTIONS: UIAction[] = [
// The way out of a window whose assets an update replaced underneath it.
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
{ id: "chrome.receipt", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
+8 -2
View File
@@ -524,6 +524,10 @@ export const EN: Record<string, string> = {
"记的是 {had},但它答的是 {got}。": "Recorded as {had}, but it answers as {got}.",
// 收工回执
"没有未经验证的部分": "nothing left unverified",
"有未验证的改动": "Unverified changes",
"这一轮自己说明了风险或未验证项": "This turn declared risks or unverified items",
"收起交付验收": "Collapse the delivery receipt",
"点击查看交付验收:改了什么、哪些没有验证": "Click to see the delivery receipt: what changed and what was not verified",
"未验证": "not verified",
"交付验收": "delivery receipt",
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
@@ -984,8 +988,10 @@ export const EN: Record<string, string> = {
"上一处": "Previous match",
"下一处": "Next match",
"关闭查找": "Close find",
"回合结束时给出回执": "End a turn with a receipt",
"列出这一轮改了什么、验了什么、哪些没有验;无话可说时不出现。下一轮起生效": "Lists what this turn changed, what it verified and what went unverified; absent when there is nothing to say. Takes effect from the next turn",
"交付验收卡片": "Delivery receipt card",
"每一轮结束后,主机会在会话末尾列出这一轮改了什么、验证了什么、哪些没有验证。记录始终保存,这里只决定是否显示。": "At the end of each turn the host lists what the turn changed, what it verified and what it did not. The record is always kept; this only decides whether it is shown.",
"显示交付验收卡片": "Show the delivery receipt card",
"默认不显示;打开后,当前会话和已有会话里的卡片都会出现。终端界面读同一项设置,下次启动时生效": "Off by default. Turning it on shows the cards in the current session and in earlier ones. The terminal reads the same setting and picks it up the next time it starts",
"任务运行时阻止系统休眠": "Keep the computer awake while tasks run",
"有会话正在运行时,电脑不会自动进入睡眠(电池供电时也一样);屏幕仍按系统设置熄灭,全部空闲后恢复。合上笔记本盖子不受此项控制": "While a session is running the computer will not go to sleep on its own, on battery power too; the screen still turns off as the system says, and normal sleep resumes once everything is idle. Closing a laptop lid is not controlled by this",
"改写这条消息": "Rewrite this message",
@@ -375,6 +375,7 @@ export const EN_KERNEL: Record<string, string> = {
"不支持这个币种,只能选自动、CNY 或 USD:{detail}": "That currency is not supported; choose Auto, CNY or USD: {detail}",
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
"交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
+1
View File
@@ -349,6 +349,7 @@ const SAID: Record<string, string> = {
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
"receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
@@ -94,6 +94,13 @@ export interface DisplayCurrencySettings {
path: string;
}
// Whether the end-of-turn delivery receipt is drawn as a card; the user file
// holds it, so the terminal reads the same answer.
export interface ReceiptSettings {
show: boolean;
path: string;
}
export interface ProgressWatchSettings {
pause: boolean;
rounds: number;
@@ -1,5 +1,5 @@
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, ReceiptSettings } from "./boundary";
import { MockShell } from "./mock_shell";
// The boundary half of the fixture: what the agent is refused outright, and how
@@ -120,6 +120,17 @@ export class MockBoundary extends MockShell {
path: "/Users/you/.reasonix/config.toml",
};
private receipt: ReceiptSettings = { show: false, path: "/Users/you/.reasonix/config.toml" };
async receiptSettings(): Promise<ReceiptSettings> {
return { ...this.receipt };
}
async saveReceiptSettings(show: boolean): Promise<ReceiptSettings> {
this.receipt = { ...this.receipt, show };
return { ...this.receipt };
}
async progressWatch(): Promise<ProgressWatchSettings> {
return { ...this.watch };
}
+3 -1
View File
@@ -41,7 +41,7 @@ import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, ReceiptSettings, SandboxSettings } from "./boundary";
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
export type { ModelEffort, ModelLimit, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -267,6 +267,8 @@ export interface AgentPort {
progressWatch(): Promise<ProgressWatchSettings>;
// Applies to the running turn at its next round; no rebuild.
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
receiptSettings(): Promise<ReceiptSettings>;
saveReceiptSettings(show: boolean): Promise<ReceiptSettings>;
// null in a browser tab: there is no window to keep running.
trayPrefs(): Promise<TrayPrefs | null>;
setTrayPrefs(icon: boolean, closeToTray: boolean): Promise<TrayPrefs | null>;
@@ -1,6 +1,6 @@
import { SseShell } from "./sse_shell";
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, ReceiptSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
@@ -44,6 +44,12 @@ export class SseBoundary extends SseShell {
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">) {
return this.post0<ProgressWatchSettings>("/progress-watch", s);
}
receiptSettings() {
return this.get<ReceiptSettings>("/receipt-settings");
}
saveReceiptSettings(show: boolean) {
return this.post0<ReceiptSettings>("/receipt-settings", { show });
}
configProblem() {
return this.get<ConfigProblem | null>("/config/problem");
}
+2
View File
@@ -539,6 +539,8 @@ export interface Receipt {
// The kernel's own answer to "is this worth showing at all", carried rather
// than recomputed here so the windows and the terminal never drift apart.
saysSomething?: boolean;
// The kernel's grading of what was left unproven; absent when nothing was.
severity?: "notice" | "warn";
}
export interface ReceiptChange {
+2 -1
View File
@@ -66,11 +66,12 @@ function haystack(it: Item): string {
return made;
}
export function search(items: Item[], query: string): Hit[] {
export function search(items: Item[], query: string, receipts = false): Hit[] {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return [];
const out: Hit[] = [];
for (let row = 0; row < items.length; row++) {
if (items[row].t === "receipt" && !receipts) continue;
const hay = haystack(items[row]);
for (let at = hay.indexOf(needle); at >= 0; at = hay.indexOf(needle, at + needle.length)) {
out.push({ id: items[row].id, row, at });
+16 -2
View File
@@ -1,11 +1,25 @@
import { createContext, useRef, useSyncExternalStore } from "react";
import { createContext, useContext, useRef, useSyncExternalStore } from "react";
import type { Item } from "./session_types";
import { foldModes, onFoldModesChange, type Fold, type FoldMode, type FoldModes } from "./prefs";
import { foldModes, onFoldModesChange, receiptPrefOf, type ReceiptPref, type ReceiptState, type Fold, type FoldMode, type FoldModes } from "./prefs";
export function useFoldModes(): FoldModes {
return useSyncExternalStore(onFoldModesChange, foldModes, foldModes);
}
/** The receipt preference of the kernel this pane talks to; a transcript drawn
* outside a pane reads the standalone one. */
export const ReceiptPrefContext = createContext<ReceiptPref>(receiptPrefOf());
export function useReceiptState(pref?: ReceiptPref): ReceiptState {
const own = useContext(ReceiptPrefContext);
const source = pref ?? own;
return useSyncExternalStore(source.subscribe, source.state, source.state);
}
export function useShowsReceipt(pref?: ReceiptPref): boolean {
return useReceiptState(pref) === "on";
}
/** Whether a part starts open under its mode, given whether it is still being
* written, whether it failed and whether it changed a file. */
export function startsOpen(mode: FoldMode, live = false, failed = false, changed = false): boolean {
+88 -11
View File
@@ -1,25 +1,102 @@
import type { AgentPort } from "../port/port";
// Per-machine display choices. They live in localStorage rather than in the
// kernel's settings because they answer "what does this screen show me",
// which is not a fact about the session and does not travel with it.
// On unless this machine turned it off. A turn that changed files and verified
// none of them ends on the one card that says so, and the kernel already
// decides whether there is anything to say.
const RECEIPT_KEY = "rx-turn-receipt";
// Whether the delivery receipt is drawn as a card is the kernel's setting
// ([ui] show_receipt), so the terminal and every window answer alike. Each
// kernel a pane talks to has its own file, so the last answer is kept per port;
// until a kernel has answered nothing is drawn, and the record itself is kept
// in the session either way.
const LEGACY_RECEIPT_KEY = "rx-turn-receipt";
export function showsReceipt(): boolean {
export type ReceiptState = "unknown" | "on" | "off";
export interface ReceiptPref {
state(): ReceiptState;
subscribe(fn: () => void): () => void;
adopt(state: Exclude<ReceiptState, "unknown">): void;
}
function createReceiptPref(initial: ReceiptState): ReceiptPref {
let state = initial;
const listeners = new Set<() => void>();
return {
state: () => state,
subscribe: (fn) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
},
adopt: (next) => {
if (state === next) return;
state = next;
listeners.forEach((fn) => fn());
},
};
}
const standalone = createReceiptPref("off");
const perPort = new WeakMap<object, ReceiptPref>();
/** The preference of the kernel behind this port. Without a port — a transcript
* drawn outside a pane — it is the standalone one, which starts off. */
export function receiptPrefOf(port?: object): ReceiptPref {
if (!port) return standalone;
let pref = perPort.get(port);
if (!pref) {
pref = createReceiptPref("unknown");
perPort.set(port, pref);
}
return pref;
}
export const showsReceipt = (port?: object): boolean => receiptPrefOf(port).state() === "on";
type ReceiptPort = Pick<AgentPort, "receiptSettings" | "saveReceiptSettings">;
// Reads the kernel's setting. A choice this browser made before the setting
// moved belongs to this machine, so only this machine's own kernel (`local`)
// takes it over, once: the kernel gets the value only when it is "on" (off is
// its default), and the old key is dropped either way.
export async function loadReceiptPref(port: ReceiptPort, local: boolean): Promise<void> {
const pref = receiptPrefOf(port);
let legacy: string | null = null;
if (local) {
try {
legacy = localStorage.getItem(LEGACY_RECEIPT_KEY);
} catch {
/* no storage: nothing to carry over */
}
}
try {
return localStorage.getItem(RECEIPT_KEY) !== "off";
let state = await port.receiptSettings();
if (legacy === "on" && !state.show) state = await port.saveReceiptSettings(true);
pref.adopt(state.show ? "on" : "off");
if (legacy !== null) {
try {
localStorage.removeItem(LEGACY_RECEIPT_KEY);
} catch {
/* the migration re-runs and finds the kernel already agreeing */
}
}
} catch {
return true;
pref.adopt("off");
}
}
export function setShowsReceipt(on: boolean): void {
// Shows at once and asks the kernel to keep it; a refusal puts it back.
export async function saveReceiptPref(port: ReceiptPort, on: boolean): Promise<void> {
const pref = receiptPrefOf(port);
const before = pref.state() === "on" ? "on" : "off";
pref.adopt(on ? "on" : "off");
try {
localStorage.setItem(RECEIPT_KEY, on ? "on" : "off");
} catch {
/* a private window keeps the default, which is the same answer it gives */
pref.adopt((await port.saveReceiptSettings(on)).show ? "on" : "off");
} catch (e) {
pref.adopt(before);
throw e;
}
}
@@ -366,18 +366,15 @@ describe("the turn's verification receipt", () => {
const card = { saysSomething: true, verdict: "unproven", gaps: [{ kind: "unverified_change" }] };
const receipts = (s: SessionState) => s.items.filter((i) => i.t === "receipt");
// A turn that changed files and verified none of them ends on the one card
// that says so, so it is there unless this machine turned it off.
it("closes the turn by default", () => {
expect(receipts(run([done(card)]))).toHaveLength(1);
});
it("stays out when this machine turned it off", () => {
expect(receipts(hidingReceipts(() => run([done(card)])))).toEqual([]);
});
// The record is the transcript's; whether a card is drawn is decided at
// render time, so the preference never changes what the session holds.
it.each([["off", () => hidingReceipts(() => run([done(card)]))], ["default", () => run([done(card)])]])(
"closes the turn with the record when the preference is %s",
(_, go) => expect(receipts(go())).toHaveLength(1),
);
// The kernel still decides whether a receipt has content at all; the
// preference only decides whether a reader sees one that does.
// preference decides nothing about that.
it("still respects the kernel's own answer", () => {
expect(receipts(showingReceipts(() => run([done({ ...card, saysSomething: false })])))).toEqual([]);
});
+3 -10
View File
@@ -6,7 +6,6 @@ import { plural, t } from "../i18n";
import { currentStep, stepDone, stepLabel } from "./session_types";
import type { Item, Metrics, PlanStep, RememberedFact, RuntimeNotice, SessionState, TodoStatus, TurnTerminal, Waiting } from "./session_types";
import { foldUsage, quoteAmount } from "./usage";
import { setShowsReceipt, showsReceipt } from "./prefs";
// The types live next door; this stays their way in, so no reader of a
// session has to know they were split off.
@@ -24,7 +23,6 @@ import { nameQueued } from "./queued";
import { dropTool, foldLastRead, foldTool, isSubagentProgress, mergeReads, notePhase } from "./fold";
export { chipLabel };
export { quoteAmount };
export { setShowsReceipt, showsReceipt };
// doing is what the status chip prints. These two values are also read back by
// the reducer, so they get a name: a comparison against a sentence is one copy
@@ -628,15 +626,10 @@ function turnTerminal(ev: WireEvent): TurnTerminal {
}
// withReceipt appends the turn's completion record when the kernel says it has
// something to say — and when this reader wants to read it. Whether a receipt
// has content is the kernel's answer and is not restated here; whether it is
// wanted on this screen is the reader's, and belongs where the panel widths
// already live rather than in a config the whole install shares.
//
// The check itself is untouched either way: it still runs, still decides
// readiness, and still reaches the trajectory. Only the card is withheld.
// something to say. Whether the card is drawn is the reader's choice, made at
// render time, so the record stays in the transcript either way.
function withReceipt(items: Item[], r?: Receipt): Item[] {
if (!r?.saysSomething || !showsReceipt()) return items;
if (!r?.saysSomething) return items;
return [...items, { t: "receipt", id: nextId(), r }];
}
+2 -2
View File
@@ -3839,9 +3839,9 @@
.rc { margin: 4px 0 2px }
.rc-ok {
display: flex; flex-direction: row; align-items: baseline; gap: 8px;
font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--ghost);
font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--ghost); background: none; border: 0; padding: 0; text-align: start; cursor: pointer;
}
.rc-ok .rc-tick { flex: none; color: var(--ok) }
.rc-ok .rc-tick { flex: none; color: var(--rc-c, var(--ok)) }
.rc-ok .rc-t { flex: none }
.rc-ok .rc-src { min-width: 0; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.rc-hd { padding: 8px 0 2px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
+8 -1
View File
@@ -1,4 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { loadReceiptPref, receiptPrefOf } from "../state/prefs";
import { ReceiptPrefContext } from "../state/foldpref";
import { money } from "../i18n/format";
import { reason } from "../i18n/kernel";
import { t } from "../i18n";
@@ -139,6 +141,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
const [wallet, refreshWallet] = useWallet(port, accountOf(status?.modelRef));
const hideAmounts = useHidesAmounts();
useEffect(() => { void loadReceiptPref(port, !rt.host); }, [port, rt.host]);
const revalue = useCallback(() => {
port.status().then((st) => { applyStatus(st); dispatch(totalsOf(st) as never); }).catch(() => {});
refreshWallet();
@@ -330,7 +334,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
onRevise: () => setAskFocus((n) => n + 1),
});
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]));
const receiptPref = receiptPrefOf(port);
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]), receiptPref);
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running, model: status?.label, submit, reloadSession, onSettings, onRunDetail, onError: fail });
@@ -389,6 +394,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
<div className="pbody" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
<div className="pviews">
<ReceiptPrefContext.Provider value={receiptPref}>
<PaneShown.Provider value={shown}>
<LiveWork.Provider value={live}>
<Transcript port={port}
@@ -426,6 +432,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
/>
</LiveWork.Provider>
</PaneShown.Provider>
</ReceiptPrefContext.Provider>
<div className="scroll" data-pane="analysis" hidden={tab !== "analysis"}>
{tab === "analysis" && (
@@ -0,0 +1,29 @@
import { useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort } from "../port/port";
import { saveReceiptPref } from "../state/prefs";
import { useShowsReceipt } from "../state/foldpref";
import { receiptPrefOf } from "../state/prefs";
import { Group } from "./Group";
import { Switch } from "./Switch";
export function ReceiptSetting({ port }: { port: AgentPort }) {
const on = useShowsReceipt(receiptPrefOf(port));
const [error, setError] = useState("");
return (
<Group id="receipt" title={t("交付验收卡片")} hint={t("每一轮结束后,主机会在会话末尾列出这一轮改了什么、验证了什么、哪些没有验证。记录始终保存,这里只决定是否显示。")}>
<div className="lrow">
<span className="tx">
<span className="lb">{t("显示交付验收卡片")}</span>
<span className="ds">{t("默认不显示;打开后,当前会话和已有会话里的卡片都会出现。终端界面读同一项设置,下次启动时生效")}</span>
</span>
<Switch data-action="chrome.receipt" on={on} label={t("显示交付验收卡片")} onClick={() => {
setError("");
saveReceiptPref(port, !on).catch((e) => setError(reason(e)));
}} />
</div>
{error && <div className="why">{error}</div>}
</Group>
);
}
+2 -1
View File
@@ -30,6 +30,7 @@ import { Compaction } from "./Compaction";
import { Sandbox } from "./Sandbox";
import { BrowserTools } from "./BrowserTools";
import { ProgressWatch } from "./ProgressWatch";
import { ReceiptSetting } from "./ReceiptSetting";
import { Account } from "./Account";
import { Backup } from "./Backup";
import { Providers } from "./Providers";
@@ -423,7 +424,6 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
</div>
<ConfigTrouble port={port} onRepaired={onChanged} />
<div className="prefs-body">
<nav className="prefs-nav" role="tablist" aria-label={t("设置分类")} onKeyDown={arrowTabs}>
{NAV.map(([group, items]) => {
@@ -501,6 +501,7 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
/>
</div>
</Group>
<ReceiptSetting port={port} />
<ProgressWatch port={port} />
<Group id="session-dir" title={t("会话写入位置")}>
<div className="kv">
+12 -2
View File
@@ -15,7 +15,8 @@ import { AskCard } from "./cards/AskCard";
import { ElicitCard } from "./cards/ElicitCard";
import { SayCard, type ReplyActions } from "./cards/SayCard";
import { CompactionCard } from "./cards/CompactionCard";
import { ReceiptCard } from "./cards/ReceiptCard";
import { ReceiptCard, ReceiptHint } from "./cards/ReceiptCard";
import { useReceiptState } from "../state/foldpref";
import { ReadsCard } from "./cards/ReadsCard";
import { UserCard } from "./cards/UserCard";
import { NoticeCard } from "./cards/NoticeCard";
@@ -722,7 +723,7 @@ const Row = memo(function Row({
case "remember":
return <RememberCard m={it.m} forgotten={it.forgotten} onForget={(name) => onForget(it.id, name)} />;
case "receipt":
return <ReceiptCard r={it.r} />;
return <ReceiptSlot r={it.r} />;
case "extension":
return <ExtensionCard ext={it.ext} onInvoke={onExtInvoke} onSubmit={onExtSubmit} />;
case "notice":
@@ -762,3 +763,12 @@ function Hero({ needsProject, onOpen, onKeep }: HeroProps) {
</div>
);
}
function ReceiptSlot({ r }: { r: Extract<Item, { t: "receipt" }>["r"] }) {
const state = useReceiptState();
const shown = state === "on";
const [open, setOpen] = useState(false);
if (state === "unknown") return null;
if (shown || open) return <><ReceiptCard r={r} />{!shown && <ReceiptHint r={r} open onToggle={() => setOpen(false)} />}</>;
return <ReceiptHint r={r} open={false} onToggle={() => setOpen(true)} />;
}
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from "react";
import type { AgentPort, TrayPrefs } from "../port/port";
import { t } from "../i18n";
import { keepsAwake, setKeepsAwake } from "../state/prefs";
import { setShowsReceipt, showsReceipt } from "../state/session";
import { ApplyNote } from "./Group";
import { Switch } from "./Switch";
@@ -10,7 +9,6 @@ export function WindowSection({ port }: { port: AgentPort }) {
// null in a browser tab, where there is no window to keep running and no
// icon to bring one back. The whole section goes with it.
const [tray, setTray] = useState<TrayPrefs | null>(null);
const [receipt, setReceipt] = useState(showsReceipt);
const [awake, setAwake] = useState(keepsAwake);
useEffect(() => {
@@ -46,21 +44,6 @@ export function WindowSection({ port }: { port: AgentPort }) {
{t("关闭窗口后需通过托盘图标重新打开主界面,下方选项依赖该图标。")}
</p>
<div className="grp-items">
<div className="lrow">
<span className="tx">
<span className="lb">{t("回合结束时给出回执")}</span>
<span className="ds">{t("列出这一轮改了什么、验了什么、哪些没有验;无话可说时不出现。下一轮起生效")}</span>
</span>
<Switch
data-action="chrome.receipt"
on={receipt}
label={t("回合结束时给出回执")}
onClick={() => {
setShowsReceipt(!receipt);
setReceipt(!receipt);
}}
/>
</div>
<div className="lrow">
<span className="tx">
<span className="lb">{t("任务运行时阻止系统休眠")}</span>
@@ -104,3 +104,26 @@ export function ReceiptCard({ r }: { r: Receipt }) {
</div>
);
}
// What stands in for the card while it is hidden: one line, only when the
// kernel graded the receipt. The grade is the kernel's; this chooses a glyph
// and a colour for it. The words follow what the receipt holds: host-found gaps
// are unproven work, while declarations alone are the turn's own caveats.
export function ReceiptHint({ r, open, onToggle }: { r: Receipt; open: boolean; onToggle: () => void }) {
if (!r.severity) return null;
const warn = r.severity === "warn";
const text = r.gaps?.length ? t("有未验证的改动") : t("这一轮自己说明了风险或未验证项");
const label = `${warn ? t("警告") : t("提示")}:${text}`;
return (
<div className="rc" data-severity={r.severity}>
<button
type="button" className="rc-ok" data-action="chrome.receipt-line" aria-expanded={open} aria-label={label}
title={open ? t("收起交付验收") : t("点击查看交付验收:改了什么、哪些没有验证")}
onClick={onToggle}
>
<span className="rc-tick" style={{ "--rc-c": warn ? "var(--warn-ink)" : "var(--muted)" } as React.CSSProperties}>{warn ? "!" : "•"}</span>
<span className="rc-t">{text}</span>
</button>
</div>
);
}
@@ -54,5 +54,4 @@ it("reads a saved off back as off after a reload", async () => {
it("leaves the tray switches where they were", async () => {
await open();
expect(screen.getByRole("switch", { name: t("在托盘显示图标") })).toBeTruthy();
expect(screen.getByRole("switch", { name: t("回合结束时给出回执") })).toBeTruthy();
});
@@ -191,6 +191,7 @@ export const SETTINGS: SettingEntry[] = [
// an internal policy look like a required work mode.
{ section: "session", anchor: "preset", title: "完成标准", scope: "session", apply: "immediate", keywords: ["自动", "验证", "复核", "完成判定"] },
{ section: "session", anchor: "plan-mode", title: "计划模式", scope: "session", apply: "immediate", keywords: ["只读", "先规划"] },
{ section: "session", anchor: "receipt", title: "交付验收卡片", scope: "machine", apply: "immediate", keywords: ["回执", "验收", "验证", "未验证", "receipt"] },
// Written to the user file only, and read by the running turn at its next round.
{ section: "session", anchor: "progress-watch", title: "长时间无进展", scope: "machine", apply: "immediate", keywords: ["暂停", "无进展", "空转", "卡住", "循环", "轮次", "token"] },
{ section: "session", anchor: "session-dir", title: "会话写入位置", scope: "workspace", apply: "none", keywords: ["工作目录", "路径"] },
@@ -0,0 +1,240 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { useFind } from "./usefind";
import { Transcript } from "./Transcript";
import { Settings } from "./Settings";
import { MockPort } from "../port/mock";
import { MockHub } from "../port/mock_hub";
import { boot, STORAGE, t } from "../i18n";
import { loadReceiptPref, receiptPrefOf, showsReceipt } from "../state/prefs";
import { ReceiptPrefContext } from "../state/foldpref";
const adoptReceiptPref = (on: boolean) => receiptPrefOf().adopt(on ? "on" : "off");
import type { Item } from "../state/session";
import type { AgentPort, SessionStatus } from "../port/port";
beforeEach(() => {
adoptReceiptPref(false);
localStorage.clear();
localStorage.setItem(STORAGE, "zh");
boot();
});
afterEach(cleanup);
const noop = async () => undefined as never;
const user = (id: string, text: string): Item => ({ t: "user", id, text });
const say = (id: string, text: string): Item => ({ t: "say", id, text, done: true });
const receipt = (id: string, extra: Record<string, unknown> = {}): Item => ({
t: "receipt", id,
r: { saysSomething: true, verdict: "partial", severity: "warn", changes: [{ path: "a.py", reviewed: false }], gaps: [{ kind: "unverified_change", detail: "ruff check ." }], ...extra },
} as unknown as Item);
const hint = () => document.querySelector('[data-item="r1"] .rc');
function transcript(items: Item[]) {
return (
<Transcript
items={items} entering={[]} onEntered={() => {}} revision={1} waiting={{}} scroll={{ current: null }}
hidden={false} onPinned={() => {}} jump={0} focus={null} onApprove={noop} onFullAccess={noop} onPlan={noop}
onAnswer={noop} onForget={noop} onExtInvoke={() => {}} onExtSubmit={noop} checkpoints={new Map()}
onPrepareRewind={noop} onCommitRewind={noop} onUndoRewind={noop} onPrepareFileRevert={noop}
onCommitFileRevert={noop} needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
/>
);
}
const items = [user("u1", "survey"), say("s1", "surveyed"), receipt("r1")];
const card = () => document.querySelector('[data-item="r1"] .call');
describe("the delivery receipt card is a display choice", () => {
it("is hidden until this machine turns it on, and the kernel's grade stands in as one line", () => {
expect(showsReceipt()).toBe(false);
render(transcript(items));
expect(card()).toBeNull();
expect(hint()?.getAttribute("data-severity")).toBe("warn");
expect(hint()?.textContent).toBe("!" + t("有未验证的改动"));
expect(screen.getByRole("button", { name: t("警告") + ":" + t("有未验证的改动") })).toBeTruthy();
expect(screen.getByText("surveyed")).toBeTruthy();
});
it("says nothing when the kernel graded nothing", () => {
render(transcript([user("u1", "survey"), say("s1", "surveyed"), receipt("r1", { severity: undefined, gaps: [] })]));
expect(card()).toBeNull();
expect(document.querySelector('[data-item="r1"]')?.textContent ?? "").toBe("");
});
it("does not call declared caveats unverified changes, even when files changed and passed", () => {
render(transcript([user("u1", "survey"), say("s1", "surveyed"), receipt("r1", { severity: "notice", gaps: [], risks: ["one-way migration"] })]));
expect(hint()?.getAttribute("data-severity")).toBe("notice");
expect(hint()?.textContent).toContain(t("这一轮自己说明了风险或未验证项"));
expect(hint()?.textContent).not.toContain(t("有未验证的改动"));
});
it("opens the card on click and closes it again, saying what the click does", async () => {
render(transcript(items));
const line = screen.getByRole("button", { name: /未验证|unverified/i });
expect(line.getAttribute("title")).toBe(t("点击查看交付验收:改了什么、哪些没有验证"));
expect(line.getAttribute("aria-expanded")).toBe("false");
await userEvent.click(line);
expect(card()).not.toBeNull();
await userEvent.click(screen.getByRole("button", { name: /未验证|unverified/i }));
expect(card()).toBeNull();
});
it("is the card or the line, never both", () => {
render(transcript(items));
act(() => adoptReceiptPref(true));
expect(card()).not.toBeNull();
expect(hint()).toBeNull();
act(() => adoptReceiptPref(false));
expect(card()).toBeNull();
expect(hint()).not.toBeNull();
});
it("appears and disappears as the preference flips, on a transcript already drawn", () => {
render(transcript(items));
act(() => adoptReceiptPref(true));
expect(card()).not.toBeNull();
act(() => adoptReceiptPref(false));
expect(card()).toBeNull();
});
it("is there on a transcript drawn after the preference is read as on", () => {
adoptReceiptPref(true);
render(transcript(items));
expect(card()).not.toBeNull();
});
});
describe("find", () => {
it("follows the preference instead of a cached result", () => {
const { result } = renderHook(() => useFind(items, 1, true, () => {}));
act(() => result.current.ask("ruff"));
expect(result.current.total).toBe(0);
act(() => adoptReceiptPref(true));
expect(result.current.total).toBeGreaterThan(0);
act(() => adoptReceiptPref(false));
expect(result.current.total).toBe(0);
});
});
describe("the setting", () => {
it("is one switch in the session settings, off by default, and flips the card at once", async () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
const port = new MockPort() as unknown as AgentPort;
render(<><Settings
hub={new MockHub() as never} port={port}
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
look={{} as never} onLook={() => {}} reloadThemes={() => {}}
onClose={() => {}} onChanged={() => {}} onError={() => {}} at="session"
account={null} accountUnread="" reloadAccount={() => {}}
/> <ReceiptPrefContext.Provider value={receiptPrefOf(port)}>{transcript(items)}</ReceiptPrefContext.Provider></>);
const sw = await screen.findByRole("switch", { name: t("显示交付验收卡片") });
expect(sw.getAttribute("aria-checked")).toBe("false");
expect(card()).toBeNull();
await userEvent.click(sw);
expect(sw.getAttribute("aria-checked")).toBe("true");
expect(card()).not.toBeNull();
expect((await port.receiptSettings()).show).toBe(true);
await userEvent.click(sw);
expect(card()).toBeNull();
expect((await port.receiptSettings()).show).toBe(false);
});
it("puts the switch back and says why when the kernel refuses the save", async () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
const mock = new MockPort();
vi.spyOn(mock, "saveReceiptSettings").mockRejectedValue(new Error("disk full"));
const port = mock as unknown as AgentPort;
render(<Settings
hub={new MockHub() as never} port={port}
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
look={{} as never} onLook={() => {}} reloadThemes={() => {}}
onClose={() => {}} onChanged={() => {}} onError={() => {}} at="session"
account={null} accountUnread="" reloadAccount={() => {}}
/>);
const sw = await screen.findByRole("switch", { name: t("显示交付验收卡片") });
await userEvent.click(sw);
expect(await screen.findByText(/disk full/)).toBeTruthy();
expect(sw.getAttribute("aria-checked")).toBe("false");
});
});
describe("the setting lives in the kernel", () => {
it("is read from the kernel's answer", async () => {
const port = new MockPort();
await port.saveReceiptSettings(true);
await loadReceiptPref(port, true);
expect(showsReceipt(port)).toBe(true);
});
it("carries an old on from this browser to the kernel once, then drops the old key", async () => {
localStorage.setItem("rx-turn-receipt", "on");
const port = new MockPort();
await loadReceiptPref(port, true);
expect((await port.receiptSettings()).show).toBe(true);
expect(showsReceipt(port)).toBe(true);
expect(localStorage.getItem("rx-turn-receipt")).toBeNull();
});
it("drops an old off without writing anything: off is the kernel's default", async () => {
localStorage.setItem("rx-turn-receipt", "off");
const port = new MockPort();
const save = vi.spyOn(port, "saveReceiptSettings");
await loadReceiptPref(port, true);
expect(save).not.toHaveBeenCalled();
expect(showsReceipt(port)).toBe(false);
expect(localStorage.getItem("rx-turn-receipt")).toBeNull();
});
it("does not undo a kernel choice that is already on with an old off", async () => {
localStorage.setItem("rx-turn-receipt", "off");
const port = new MockPort();
await port.saveReceiptSettings(true);
await loadReceiptPref(port, true);
expect(showsReceipt(port)).toBe(true);
});
it("takes the old local choice only for this machine's own kernel", async () => {
localStorage.setItem("rx-turn-receipt", "on");
const remote = new MockPort();
await loadReceiptPref(remote, false);
expect((await remote.receiptSettings()).show).toBe(false);
expect(localStorage.getItem("rx-turn-receipt")).toBe("on");
const local = new MockPort();
await loadReceiptPref(local, true);
expect((await local.receiptSettings()).show).toBe(true);
expect(localStorage.getItem("rx-turn-receipt")).toBeNull();
});
it("keeps one answer per kernel instead of the last reader's", async () => {
const a = new MockPort();
const b = new MockPort();
await a.saveReceiptSettings(true);
await loadReceiptPref(a, false);
await loadReceiptPref(b, false);
expect(showsReceipt(a)).toBe(true);
expect(showsReceipt(b)).toBe(false);
});
it("draws nothing for a receipt until its kernel has answered", async () => {
const port = new MockPort();
const pref = receiptPrefOf(port);
expect(pref.state()).toBe("unknown");
render(<ReceiptPrefContext.Provider value={pref}>{transcript(items)}</ReceiptPrefContext.Provider>);
expect(document.querySelector('[data-item="r1"]')?.textContent ?? "").toBe("");
await port.saveReceiptSettings(true);
await act(async () => { await loadReceiptPref(port, false); });
expect(card()).not.toBeNull();
});
it("stays hidden when the kernel cannot be read", async () => {
const port = new MockPort();
vi.spyOn(port, "receiptSettings").mockRejectedValue(new Error("down"));
await loadReceiptPref(port, true);
expect(showsReceipt(port)).toBe(false);
});
});
+5 -2
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MAX, search, stepped, type Hit } from "../state/find";
import type { Item } from "../state/session";
import { useShowsReceipt } from "../state/foldpref";
import type { ReceiptPref } from "../state/prefs";
const NO_HITS: Hit[] = [];
@@ -20,9 +22,10 @@ export interface Finding {
/** The find bar's whole state. pulse is the window asking; only the pane that
* has focus answers it, so one chord does not open a bar in every session. */
export function useFind(items: Item[], pulse: number, active: boolean, onOpen: () => void): Finding {
export function useFind(items: Item[], pulse: number, active: boolean, onOpen: () => void, pref?: ReceiptPref): Finding {
const [held, setHeld] = useState<{ q: string; i: number; focus: number } | null>(null);
const hits = useMemo(() => (held ? search(items, held.q) : NO_HITS), [items, held]);
const receipts = useShowsReceipt(pref);
const hits = useMemo(() => (held ? search(items, held.q, receipts) : NO_HITS), [items, held, receipts]);
const i = held ? Math.min(held.i, hits.length - 1) : -1;
const at = i >= 0 ? hits[i] : null;
// Hits come in reading order, so a row's occurrences sit together.
+1 -1
View File
@@ -22,7 +22,7 @@ const ROUTES = [
"/changes", "/commit", "/attachments", "/drop", "/checkpoints", "/branches", "/compact", "/compaction", "/rewind",
"/extensions", "/themes", "/plugins", "/market", "/surfaces", "/feedback",
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
"/permissions", "/sandbox", "/progress-watch", "/display-currency", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update",
"/permissions", "/sandbox", "/progress-watch", "/receipt-settings", "/display-currency", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update",
"/host", "/notifications", "/share", "/pair", "/device",
"/slash", "/workspaces", "/welcome", "/usage", "/config", "/studio",
];
+2
View File
@@ -61,6 +61,8 @@ var English = Messages{
NoticeCompactFailedFmt: "Compaction failed: %s",
NoticeCompactHeldFmt: "Automatic compaction is paused, the last attempt did not finish: %s",
ReceiptGapKinds: map[string]string{
"hint:gaps": "changes left unverified",
"hint:declared": "this turn declared risks or unverified items",
"unbacked_claim": "claimed but unsupported",
"unproven_criterion": "criterion without proof",
"missing_check": "expected check never passed",
+2
View File
@@ -62,6 +62,8 @@ var Chinese = Messages{
NoticeCompactFailedFmt: "压缩失败:%s",
NoticeCompactHeldFmt: "自动压缩暂缓,上次尝试没有完成:%s",
ReceiptGapKinds: map[string]string{
"hint:gaps": "有未验证的改动",
"hint:declared": "这一轮自己说明了风险或未验证项",
"unbacked_claim": "声称过但账本不支持",
"unproven_criterion": "验收项没有证据",
"missing_check": "预期的检查从未通过",
+2
View File
@@ -62,6 +62,8 @@ var ChineseTraditional = Messages{
NoticeCompactFailedFmt: "壓縮失敗:%s",
NoticeCompactHeldFmt: "自動壓縮暫緩,上次嘗試沒有完成:%s",
ReceiptGapKinds: map[string]string{
"hint:gaps": "有未驗證的變更",
"hint:declared": "這一輪自己說明了風險或未驗證項",
"unbacked_claim": "聲稱過但帳本不支持",
"unproven_criterion": "驗收項沒有證據",
"missing_check": "預期的檢查從未通過",
+1
View File
@@ -252,6 +252,7 @@ type UIConfig struct {
CloseBehavior string `toml:"close_behavior"` // legacy desktop close behavior; prefer desktop.close_behavior
ShowReasoning bool `toml:"show_reasoning"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed
ShowTurnUsage bool `toml:"show_turn_usage"` // show per-request token/cost receipts in the CLI/TUI transcript
ShowReceipt bool `toml:"show_receipt"` // draw the end-of-turn delivery receipt card; false = one line when something is unproven
CursorShape string `toml:"cursor_shape"` // block|underline|bar; empty defaults to bar
CommandMode string `toml:"commandmode"` // ""|vi; vi gives the composer a vi command mode (empty = insert-always)
}
+3
View File
@@ -467,6 +467,9 @@ func RenderTOMLProjectDelta(c *Config) string {
if c.UI.ShowTurnUsage != d.UI.ShowTurnUsage {
fmt.Fprintf(&b, "show_turn_usage = %v\n", c.UI.ShowTurnUsage)
}
if c.UI.ShowReceipt != d.UI.ShowReceipt {
fmt.Fprintf(&b, "show_receipt = %v\n", c.UI.ShowReceipt)
}
b.WriteString("\n")
}
+19
View File
@@ -1609,3 +1609,22 @@ func TestBrowserSettingsSurviveASave(t *testing.T) {
t.Fatalf("default browser config = %+v", d.Browser)
}
}
func TestShowReceiptDefaultsOffAndRendersAnOnOverride(t *testing.T) {
c := Default()
if c.UI.ShowReceipt {
t.Fatal("ui.show_receipt must default to hidden")
}
c.UI.ShowReceipt = true
delta := RenderTOMLProjectDelta(c)
if !strings.Contains(delta, "show_receipt = true") {
t.Fatalf("project delta missing show_receipt:\n%s", delta)
}
got := Default()
if _, err := toml.Decode(delta, got); err != nil {
t.Fatalf("decode: %v\n%s", err, delta)
}
if !got.UI.ShowReceipt {
t.Fatal("ui.show_receipt did not round-trip")
}
}
+1
View File
@@ -33,5 +33,6 @@ func renderUISection(b *strings.Builder, c *Config, scope RenderScope) {
b.WriteString("# show_reasoning = true # CLI: show thinking text by default; false = collapsed (toggle with Ctrl+O)\n")
}
fmt.Fprintf(b, "show_turn_usage = %v # CLI/TUI: show per-request token and cost receipts in the transcript\n", c.UI.ShowTurnUsage)
fmt.Fprintf(b, "show_receipt = %v # TUI and Studio: draw the end-of-turn delivery receipt card; false = one line only when something is unproven\n", c.UI.ShowReceipt)
b.WriteString("\n")
}
+36
View File
@@ -50,3 +50,39 @@ func (r *CompletionReceipt) SaysSomething() bool {
}
return len(r.Gaps) > 0 || len(r.Risks) > 0 || len(r.Unverified) > 0 || r.Verdict == "done"
}
// Receipt severities, lightest first. None is the empty string so a receipt
// that says nothing needs no field on the wire.
const (
ReceiptSeverityNone = ""
ReceiptSeverityNotice = "notice"
ReceiptSeverityWarn = "warn"
)
// Severity is how hard this receipt asks to be read, decided here like
// SaysSomething so frontends never grade gaps apart. A gap kind the host does
// not know is warn: an unfamiliar finding must not drop to a whisper.
func (r *CompletionReceipt) Severity() string {
if r == nil {
return ReceiptSeverityNone
}
out := ReceiptSeverityNone
if len(r.Risks) > 0 || len(r.Unverified) > 0 {
out = ReceiptSeverityNotice
}
for _, g := range r.Gaps {
if gapSeverity(g.Kind) == ReceiptSeverityWarn {
return ReceiptSeverityWarn
}
out = ReceiptSeverityNotice
}
return out
}
func gapSeverity(kind string) string {
switch kind {
case "stale_verification", "inconclusive_verification", "unreviewed_change", "declared_unverified":
return ReceiptSeverityNotice
}
return ReceiptSeverityWarn
}
+25
View File
@@ -26,3 +26,28 @@ func TestReceiptSaysSomething(t *testing.T) {
}
}
}
func TestReceiptSeverity(t *testing.T) {
gap := func(kind string) *CompletionReceipt {
return &CompletionReceipt{Verdict: "partial", Gaps: []ReceiptGap{{Kind: kind}}}
}
for _, tc := range []struct {
name string
r *CompletionReceipt
want string
}{
{"nil", nil, ReceiptSeverityNone},
{"clean", &CompletionReceipt{Verdict: "done"}, ReceiptSeverityNone},
{"a change nothing proves", gap("unverified_change"), ReceiptSeverityWarn},
{"a failed check", gap("failed_verification"), ReceiptSeverityWarn},
{"a verification that predates the change", gap("stale_verification"), ReceiptSeverityNotice},
{"a gap kind this host does not know", gap("something_new"), ReceiptSeverityWarn},
{"a declared caveat alone", &CompletionReceipt{Verdict: "incomplete", Unverified: []string{"UI not run"}}, ReceiptSeverityNotice},
{"the worse of two gaps wins", &CompletionReceipt{Verdict: "partial", Gaps: []ReceiptGap{{Kind: "unreviewed_change"}, {Kind: "missing_check"}}}, ReceiptSeverityWarn},
{"a caveat does not soften a gap", &CompletionReceipt{Verdict: "partial", Risks: []string{"x"}, Gaps: []ReceiptGap{{Kind: "unverified_change"}}}, ReceiptSeverityWarn},
} {
if got := tc.r.Severity(); got != tc.want {
t.Errorf("%s: Severity() = %q, want %q", tc.name, got, tc.want)
}
}
}
+5
View File
@@ -17,6 +17,10 @@ type CompletionReceipt struct {
// SaysSomething is the kernel's answer to "is this worth showing at all",
// carried rather than recomputed so no frontend drifts from the others.
SaysSomething bool `json:"saysSomething"`
// Severity is the kernel's grading of what the receipt left unproven
// ("notice" or "warn"; absent when nothing was), so a frontend that does
// not draw the card can still say how much it would have said.
Severity string `json:"severity,omitempty"`
}
type ReceiptChange struct {
@@ -49,6 +53,7 @@ func completionReceiptWire(r *event.CompletionReceipt) *CompletionReceipt {
Risks: append([]string(nil), r.Risks...),
Unverified: append([]string(nil), r.Unverified...),
SaysSomething: r.SaysSomething(),
Severity: r.Severity(),
}
for _, c := range r.Changes {
out.Changes = append(out.Changes, ReceiptChange{Path: c.Path, Reviewed: c.Reviewed})
@@ -0,0 +1,23 @@
package eventwire
import (
"encoding/json"
"strings"
"testing"
"reasonix/internal/contract/event"
)
func TestReceiptWireCarriesTheKernelsSeverity(t *testing.T) {
warn := completionReceiptWire(&event.CompletionReceipt{Verdict: "partial", Gaps: []event.ReceiptGap{{Kind: "unverified_change"}}})
if warn.Severity != event.ReceiptSeverityWarn {
t.Fatalf("severity = %q, want warn", warn.Severity)
}
clean, err := json.Marshal(completionReceiptWire(&event.CompletionReceipt{Verdict: "done"}))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(clean), "severity") {
t.Fatalf("a clean receipt carries a severity field: %s", clean)
}
}
+1
View File
@@ -127,6 +127,7 @@ func runTUI(args []string, version string) int {
PickAmong: ambiguousSessionPaths(ambiguous),
Inline: *f.inline,
HideTurnUsage: cfg != nil && !cfg.UI.ShowTurnUsage,
ShowReceipt: cfg != nil && cfg.UI.ShowReceipt,
AutoSubmit: cfg != nil && cfg.AutoSubmit,
CommandMode: cfg != nil && cfg.UICommandMode(),
QuitCommands: builtinSlashNames("/quit"),
@@ -0,0 +1,35 @@
package serve
import (
"encoding/json"
"net/http"
)
func (s *Server) registerUserSettingsRoutes(mux *http.ServeMux) {
s.registerProgressWatchRoutes(mux)
s.registerReceiptRoutes(mux)
}
func (s *Server) registerReceiptRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /receipt-settings", s.receiptSettings)
mux.HandleFunc("POST /receipt-settings", s.saveReceiptSettings)
}
func (s *Server) receiptSettings(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, s.ctl().ReceiptSettings())
}
func (s *Server) saveReceiptSettings(w http.ResponseWriter, r *http.Request) {
var body struct {
Show *bool `json:"show"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body); err != nil || body.Show == nil {
badBody(w)
return
}
if err := s.ctl().SaveReceiptSettings(*body.Show); err != nil {
saveFailed(w, http.StatusInternalServerError, "receipt.save_failed", err)
return
}
writeJSON(w, s.ctl().ReceiptSettings())
}
@@ -0,0 +1,47 @@
package serve
import (
"net/http"
"net/http/httptest"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func TestReceiptSettingIsHiddenByDefaultAndPersistsInTheUserFile(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
if got := readJSON[control.ReceiptSettings](t, srv.URL, "/receipt-settings"); got.Show {
t.Fatalf("unset key reads %+v, want the card hidden", got)
}
resp := postProvider(t, srv.URL, "/receipt-settings", `{"show":true}`)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("save = %d", resp.StatusCode)
}
if !config.LoadForEdit(config.UserConfigPath()).UI.ShowReceipt {
t.Fatal("the choice did not reach [ui] show_receipt")
}
if got := readJSON[control.ReceiptSettings](t, srv.URL, "/receipt-settings"); !got.Show {
t.Fatalf("read back %+v, want shown", got)
}
off := postProvider(t, srv.URL, "/receipt-settings", `{"show":false}`)
off.Body.Close()
if config.LoadForEdit(config.UserConfigPath()).UI.ShowReceipt {
t.Fatal("turning it off did not reach the file")
}
}
func TestReceiptSettingRefusesABodyWithoutTheChoice(t *testing.T) {
s := newProviderEditServer(t)
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
resp := postProvider(t, srv.URL, "/receipt-settings", `{}`)
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty body = %d, want 400: a missing choice must not read as off", resp.StatusCode)
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /model-mode", s.modelMode)
mux.HandleFunc("POST /compact", s.compact)
s.registerCompactionRoutes(mux)
s.registerProgressWatchRoutes(mux)
s.registerUserSettingsRoutes(mux)
s.registerDisplayCurrencyRoutes(mux)
mux.HandleFunc("POST /new", s.newSession)
mux.HandleFunc("POST /rewind", s.rewind)
+4
View File
@@ -34,6 +34,9 @@ type Options struct {
Inline bool
// HideTurnUsage keeps each request's token and cost receipt off the transcript.
HideTurnUsage bool
// ShowReceipt draws each turn's delivery receipt as a card; off, a turn that
// left something unproven gets one line.
ShowReceipt bool
// AutoSubmit commits a multi-question ask once its last question is
// answered, instead of showing the Submit tab.
AutoSubmit bool
@@ -185,6 +188,7 @@ func newModel(ctx context.Context, opts Options) *model {
}
termrender.SetCells(ansi.WcWidth)
m.verbose = storedVerbose()
m.tr.ReceiptLine = !opts.ShowReceipt
if !opts.Inline {
m.scr = &screen{follow: true, mouseOff: mouseCaptureOffByDefault()}
}
+22 -1
View File
@@ -5,6 +5,7 @@ import (
"strings"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/event"
"reasonix/internal/contract/eventwire"
"reasonix/internal/frontend/termrender"
)
@@ -16,10 +17,13 @@ const maxReceiptGaps = 5
// renderReceipt is the end-of-turn card. A clean turn gets one quiet line
// naming what carried it; otherwise the card spends its lines on what went
// unproven, then on what the turn itself declared.
func renderReceipt(r *eventwire.CompletionReceipt, width int) string {
func renderReceipt(r *eventwire.CompletionReceipt, width int, line bool) string {
if r == nil {
return ""
}
if line {
return renderReceiptLine(r, width)
}
row := max(width-8, 24)
if len(r.Gaps) == 0 {
if r.Verdict != "done" {
@@ -77,3 +81,20 @@ func receiptEvidence(r *eventwire.CompletionReceipt) string {
}
return " · " + strings.Join(parts, " · ")
}
// renderReceiptLine is what stands in for the card: one line, only when the
// kernel graded the receipt. The grade is the kernel's; this picks the colour.
func renderReceiptLine(r *eventwire.CompletionReceipt, width int) string {
if r.Severity == "" {
return ""
}
text := i18n.M.ReceiptGapKinds["hint:declared"]
if len(r.Gaps) > 0 {
text = i18n.M.ReceiptGapKinds["hint:gaps"]
}
text = oneLine(text, max(width-4, 24))
if r.Severity == event.ReceiptSeverityWarn {
return "\n" + termrender.Yellow(" ! "+text)
}
return "\n" + termrender.Dim(" · "+text)
}
+42 -2
View File
@@ -16,7 +16,7 @@ func TestReceiptCardNamesGapsAndEvidence(t *testing.T) {
gaps := ansi.Strip(renderReceipt(&eventwire.CompletionReceipt{Verdict: "incomplete", Gaps: []eventwire.ReceiptGap{
{Kind: "unverified_mutation", Detail: "a.go"},
{Kind: "some_future_kind"},
}}, 80))
}}, 80, false))
for _, want := range []string{i18n.M.ReceiptGapsHeader, "a.go", "some_future_kind"} {
if !strings.Contains(gaps, want) {
t.Fatalf("gap card lacks %q:\n%s", want, gaps)
@@ -28,8 +28,48 @@ func TestReceiptCardNamesGapsAndEvidence(t *testing.T) {
clean := ansi.Strip(renderReceipt(&eventwire.CompletionReceipt{Verdict: "done",
Changes: []eventwire.ReceiptChange{{Path: "a.go"}},
Verifications: []eventwire.ReceiptVerification{{Command: "go test ./...", Passed: true}}}, 80))
Verifications: []eventwire.ReceiptVerification{{Command: "go test ./...", Passed: true}}}, 80, false))
if !strings.Contains(clean, i18n.M.ReceiptVerified) || !strings.Contains(clean, "go test ./...") {
t.Fatalf("clean card = %q", clean)
}
}
// Hidden, the card is replaced by one line that exists only when the kernel
// graded the receipt; the terminal never grades gaps itself.
func TestReceiptLineFollowsTheKernelsGrade(t *testing.T) {
warn := ansi.Strip(renderReceipt(&eventwire.CompletionReceipt{Verdict: "partial", Severity: "warn",
Changes: []eventwire.ReceiptChange{{Path: "a.go"}},
Gaps: []eventwire.ReceiptGap{{Kind: "unverified_change", Detail: "go vet"}}}, 80, true))
if !strings.Contains(warn, "! "+i18n.M.ReceiptGapKinds["hint:gaps"]) || strings.Contains(warn, "go vet") {
t.Fatalf("warn line = %q, want one line saying changes are unverified and no card", warn)
}
notice := ansi.Strip(renderReceipt(&eventwire.CompletionReceipt{Verdict: "incomplete", Severity: "notice",
Unverified: []string{"UI not run"}}, 80, true))
if !strings.Contains(notice, "· "+i18n.M.ReceiptGapKinds["hint:declared"]) {
t.Fatalf("notice line = %q", notice)
}
if got := renderReceipt(&eventwire.CompletionReceipt{Verdict: "done", Changes: []eventwire.ReceiptChange{{Path: "a.go"}}}, 80, true); got != "" {
t.Fatalf("an ungraded receipt drew %q while hidden", got)
}
}
func TestReceiptIsALineUnlessTheConfigAsksForTheCard(t *testing.T) {
ev := eventwire.Event{Kind: "turn_done", Receipt: &eventwire.CompletionReceipt{Verdict: "partial", SaysSomething: true, Severity: "warn",
Gaps: []eventwire.ReceiptGap{{Kind: "unverified_change", Detail: "go vet"}}}}
for _, tc := range []struct {
name string
show bool
}{{"default hides the card", false}, {"show_receipt draws it", true}} {
m := newModel(t.Context(), Options{ShowReceipt: tc.show})
m.tr.Apply(ev)
m.resetScreen()
if m.tr.ReceiptLine == tc.show {
t.Fatalf("%s: ReceiptLine = %v after a screen reset", tc.name, m.tr.ReceiptLine)
}
m.tr.Apply(ev)
got := ansi.Strip(renderItem(&m.tr.Items[0], 80, 0, false))
if card := strings.Contains(got, i18n.M.ReceiptGapsHeader); card != tc.show {
t.Fatalf("%s: rendered %q", tc.name, got)
}
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ func renderItem(it *Item, width, shown int, hideRail bool) string {
case ItemCompaction:
return renderCompaction(it, width)
case ItemReceipt:
return renderReceipt(it.Receipt, width)
return renderReceipt(it.Receipt, width, it.ReceiptLine)
case ItemUsage:
return renderUsage(it.Usage, width)
}
+1 -1
View File
@@ -176,7 +176,7 @@ func (m *model) onResumed(msg resumedMsg) tea.Cmd {
// resetScreen drops the transcript this screen drew, for a conversation that
// is about to be drawn again from its record.
func (m *model) resetScreen() {
m.tr = Transcript{next: m.tr.next}
m.tr = Transcript{next: m.tr.next, ReceiptLine: m.tr.ReceiptLine}
m.committed, m.sayShown = map[int]bool{}, map[int]int{}
m.todos, m.ask, m.menu = nil, nil, nil
if m.scr != nil {
+9 -4
View File
@@ -28,6 +28,9 @@ type Item struct {
ID int
Kind ItemKind
// ReceiptLine is set on an ItemReceipt drawn as one line.
ReceiptLine bool
// ItemUser. Pending is input the kernel has not taken into a turn yet;
// Steer is input a running turn read at a tool boundary. MsgIndex is the
// kernel's name for the message once a turn has started on it.
@@ -98,9 +101,11 @@ const (
// a conversation reads the same in both. Nothing on the wire echoes what the
// user typed: the client adds its own row (AddUser).
type Transcript struct {
Items []Item
Running bool
Terminal Terminal
Items []Item
// ReceiptLine draws a delivery receipt as at most one line, rather than the card.
ReceiptLine bool
Running bool
Terminal Terminal
// EndReason is the kernel's account of a turn that did not complete.
EndReason string
Usage *eventwire.Usage
@@ -262,7 +267,7 @@ func (t *Transcript) Apply(ev eventwire.Event) {
t.sealTools(ev.Err != "")
t.sealPrompts()
if ev.Receipt != nil && ev.Receipt.SaysSomething {
t.Items = append(t.Items, Item{ID: t.id(), Kind: ItemReceipt, Receipt: ev.Receipt})
t.Items = append(t.Items, Item{ID: t.id(), Kind: ItemReceipt, Receipt: ev.Receipt, ReceiptLine: t.ReceiptLine})
}
}
}
+2
View File
@@ -276,6 +276,8 @@ type RuntimeSettings interface {
SaveDisplayCurrency(mode string) error
ProgressWatchSettings() ProgressWatchSettings
SaveProgressWatchSettings(in ProgressWatchSettings) error
ReceiptSettings() ReceiptSettings
SaveReceiptSettings(show bool) error
ConfigProblem() *ConfigProblem
RepairConfigFile() (string, error)
}
@@ -0,0 +1,26 @@
package control
import "reasonix/internal/contract/config"
// ReceiptSettings is whether the end-of-turn delivery receipt is drawn as a
// card. The user file holds it, so Studio and the terminal answer alike.
type ReceiptSettings struct {
Show bool `json:"show"`
Path string `json:"path"`
}
func (c *Controller) ReceiptSettings() ReceiptSettings {
path := config.UserConfigPath()
return ReceiptSettings{Show: config.LoadForEdit(path).UI.ShowReceipt, Path: path}
}
// SaveReceiptSettings persists the choice. Nothing is rebuilt: the receipt is
// recorded either way and only its drawing depends on this.
func (c *Controller) SaveReceiptSettings(show bool) error {
unlock := config.LockUserConfigEdits()
defer unlock()
path := config.UserConfigPath()
cfg := config.LoadForEdit(path)
cfg.UI.ShowReceipt = show
return cfg.SaveTo(path)
}
+1
View File
@@ -107,6 +107,7 @@ var mirroredWireTypes = []wireMirror{
{"internal/contract/eventwire/progress_watch.go", "ProgressWatch", tsWireFile, "ProgressWatch"},
{"internal/session/control/display_currency.go", "DisplayCurrencySettings", tsBoundaryFile, "DisplayCurrencySettings"},
{"internal/session/control/progress_watch_settings.go", "ProgressWatchSettings", tsBoundaryFile, "ProgressWatchSettings"},
{"internal/session/control/receipt_settings.go", "ReceiptSettings", tsBoundaryFile, "ReceiptSettings"},
// RemoteHostEdit is left out on purpose: the kernel still takes the single
// `workspace` an old row was saved with, which the page deliberately does
// not send. An under-filled request is not a picture that cannot be read.