Improve the transcript: tool grouping, report auto-ack, context meter, spinner, Enter to send

This commit is contained in:
Ian Gulliver
2026-10-14 16:03:46 -07:00
parent ca2644374d
commit ab4c3d2c6f
19 changed files with 734 additions and 92 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ export class Pane {
b.onclick = () => this.show(v);
return b;
}));
const root = h("div", { class: `view ${view}` });
const root = h("div", { class: `view view-${view}` });
replace(this.body, root);
this.dispose = views[view].mount(root);
}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAIxC,MAAM,KAAK,GAA8E;IACvF,UAAU,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe,EAAE;IAC3D,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE;IAC5C,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE;CAC7C,CAAC;AAEF,MAAM,KAAK,GAAW,CAAC,YAAY,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAEvD,MAAM,OAAO,IAAI;IAOc;IANpB,EAAE,CAAc;IACR,IAAI,CAAc;IAClB,IAAI,CAAc;IAC3B,IAAI,CAAO;IACX,OAAO,GAAe,GAAG,EAAE,GAAE,CAAC,CAAC;IAEvC,YAA6B,GAAW,EAAE,QAAc;QAA3B,QAAG,GAAH,GAAG,CAAQ;QACtC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAEhE,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,CAAgB,CAAC;QACrE,IAAI,CAAC,IAAI,GAAG,KAAK,IAAI,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC;QACvD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvB,CAAC;IAED,IAAI,CAAC,IAAU;QACb,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,IAAI,CAAC,CAAC;QAE9C,OAAO,CACL,IAAI,CAAC,IAAI,EACT,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;YACjB,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAC7E,CAAC,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAC/B,OAAO,CAAC,CAAC;QACX,CAAC,CAAC,CACH,CAAC;QAEF,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,QAAQ,IAAI,EAAE,EAAE,CAAC,CAAC;QACjD,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACzB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACzC,CAAC;IAEO,UAAU;QAChB,OAAO,aAAa,IAAI,CAAC,GAAG,EAAE,CAAC;IACjC,CAAC;CACF"}
{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAIxC,MAAM,KAAK,GAA8E;IACvF,UAAU,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe,EAAE;IAC3D,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE;IAC5C,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE;CAC7C,CAAC;AAEF,MAAM,KAAK,GAAW,CAAC,YAAY,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAEvD,MAAM,OAAO,IAAI;IAOc;IANpB,EAAE,CAAc;IACR,IAAI,CAAc;IAClB,IAAI,CAAc;IAC3B,IAAI,CAAO;IACX,OAAO,GAAe,GAAG,EAAE,GAAE,CAAC,CAAC;IAEvC,YAA6B,GAAW,EAAE,QAAc;QAA3B,QAAG,GAAH,GAAG,CAAQ;QACtC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAEhE,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,CAAgB,CAAC;QACrE,IAAI,CAAC,IAAI,GAAG,KAAK,IAAI,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC;QACvD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvB,CAAC;IAED,IAAI,CAAC,IAAU;QACb,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,IAAI,CAAC,CAAC;QAE9C,OAAO,CACL,IAAI,CAAC,IAAI,EACT,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;YACjB,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAC7E,CAAC,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAC/B,OAAO,CAAC,CAAC;QACX,CAAC,CAAC,CACH,CAAC;QAEF,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,aAAa,IAAI,EAAE,EAAE,CAAC,CAAC;QACtD,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACzB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACzC,CAAC;IAEO,UAAU;QAChB,OAAO,aAAa,IAAI,CAAC,GAAG,EAAE,CAAC;IACjC,CAAC;CACF"}
+14 -3
View File
@@ -71,24 +71,30 @@ export async function loadInbox() {
state.inbox = await get("/api/inbox");
notify("inbox");
}
let liveDuringLoad;
export async function selectTask(taskId) {
state.selectedTaskId = taskId;
state.transcript = [];
state.lastTranscriptSeq = 0;
liveDuringLoad = undefined;
notify("selection");
if (!taskId) {
return;
}
const held = [];
liveDuringLoad = held;
for (;;) {
const page = await get("/api/tasks/{taskId}/events", { path: { taskId }, query: { after: state.lastTranscriptSeq, limit: 1000 } });
if (state.selectedTaskId !== taskId) {
if (state.selectedTaskId !== taskId || liveDuringLoad !== held) {
return;
}
appendTranscript(page);
if (page.length < 1000) {
return;
break;
}
}
liveDuringLoad = undefined;
appendTranscript(held);
}
function appendTranscript(events) {
const fresh = events.filter((ev) => ev.seq > state.lastTranscriptSeq);
@@ -101,7 +107,12 @@ function appendTranscript(events) {
}
export function handleEvent(ev) {
if (ev.task_id && ev.task_id === state.selectedTaskId) {
appendTranscript([ev]);
if (liveDuringLoad) {
liveDuringLoad.push(ev);
}
else {
appendTranscript([ev]);
}
}
switch (ev.kind) {
case "user.updated":
+1 -1
View File
File diff suppressed because one or more lines are too long
+216 -25
View File
@@ -7,9 +7,78 @@ export function mountTranscript(root) {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" });
let tools = new Map();
const visibleReports = new Map();
const acked = new Set();
const ackVisible = () => {
if (document.visibilityState !== "visible") {
return;
}
const ids = [...new Set(visibleReports.values())].filter((id) => !acked.has(id) && state.inbox.some((i) => i.id === id));
if (ids.length === 0) {
return;
}
for (const id of ids) {
acked.add(id);
}
post("/api/inbox/responses", { body: { responses: ids.map((id) => ({ item_id: id, type: "dismiss" })) } }).catch((err) => {
for (const id of ids) {
acked.delete(id);
}
console.error("acknowledge reports", err);
});
};
const observer = new IntersectionObserver((entries) => {
for (const e of entries) {
const id = e.target.dataset["inboxItem"];
if (!id) {
continue;
}
if (e.isIntersecting) {
visibleReports.set(e.target, id);
}
else {
visibleReports.delete(e.target);
}
}
ackVisible();
}, { root: transcript, threshold: 0.5 });
const newCtx = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
return {
tools: new Map(),
run: undefined,
root: task?.worktree_path ?? "",
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
observer.observe(el);
},
};
};
let ctx = newCtx();
let context = new Map();
const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running) => {
transcript.classList.toggle("running", running);
if (running === !spinner.hidden) {
return;
}
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
spinner.hidden = !running;
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const put = (el) => {
if (spinner.parentNode === transcript) {
transcript.insertBefore(el, spinner);
}
else {
transcript.append(el);
}
};
const renderHead = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
setRunning(task !== undefined && currentSession(task.id)?.state === "running");
if (!task) {
replace(head);
return;
@@ -28,7 +97,7 @@ export function mountTranscript(root) {
alert(errorText(err));
}
};
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch), h("code", { class: "path" }, task.worktree_path)), h("div", { class: "head-actions" }, sess && h("span", { class: `state ${sess.state}` }, sess.state), complete));
replace(head, h("div", { class: "meta" }, h("code", {}, task.branch)), h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), complete));
};
const renderComposer = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
@@ -41,29 +110,78 @@ export function mountTranscript(root) {
replace(composer, startForm(task.id));
return;
}
replace(composer, messageForm(sess.id, sess.state));
replace(composer, messageForm(task.id, sess.id, sess.state));
};
const addToRun = (el, call) => {
const run = ctx.run;
if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [] };
put(el);
return;
}
if (call) {
run.calls.push(call);
}
if (!run.group) {
const summary = h("summary");
const body = h("div", { class: "tool-group-body" });
const group = h("details", { class: "tool-group" }, summary, body);
run.first.replaceWith(group);
body.append(run.first);
if (run.first.classList.contains("errored")) {
group.classList.add("errored");
}
run.group = { el: group, summary, body };
}
run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls));
};
const appendEvents = (events) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
for (const ev of events) {
const el = renderEvent(ev, tools);
if (el) {
transcript.append(el);
const p = ev.payload;
if (ev.kind === "session.context" && ev.agent_session_id) {
const prev = context.get(ev.agent_session_id);
const window = Number(p["window"]) || prev?.window || 0;
context.set(ev.agent_session_id, { used: Number(p["used"]), window, model: String(p["model"] ?? "") });
contextChanged = true;
continue;
}
if (ev.kind === "agent.tool_call") {
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
continue;
}
const el = renderEvent(ev, ctx);
if (!el) {
continue;
}
if (ev.kind === "agent.thinking") {
addToRun(el, undefined);
continue;
}
ctx.run = undefined;
put(el);
}
if (contextChanged) {
renderHead();
}
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const renderAll = () => {
tools = new Map();
observer.disconnect();
visibleReports.clear();
ctx = newCtx();
context = new Map();
renderHead();
renderComposer();
if (!state.selectedTaskId) {
replace(transcript, h("div", { class: "empty" }, "No task selected. Pick one with ⌘K or from the Tasks tab."));
return;
}
replace(transcript);
replace(transcript, spinner);
appendEvents(state.transcript);
};
const offs = [
@@ -79,33 +197,53 @@ export function mountTranscript(root) {
}
}),
onEach(["transcript"], (fresh) => appendEvents(fresh)),
onEach(["inbox"], ackVisible),
];
document.addEventListener("visibilitychange", ackVisible);
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
renderAll();
return () => {
for (const off of offs) {
off();
}
document.removeEventListener("visibilitychange", ackVisible);
observer.disconnect();
};
}
const composerDrafts = new Map();
function keepDraft(input, key) {
input.value = composerDrafts.get(key) ?? "";
input.addEventListener("input", () => {
if (input.value) {
composerDrafts.set(key, input.value);
}
else {
composerDrafts.delete(key);
}
});
return () => composerDrafts.delete(key);
}
function startForm(taskId) {
const prompt = h("textarea", { placeholder: "What should the agent do?", rows: 4, required: true });
const clearDraft = keepDraft(prompt, `start:${taskId}`);
const model = h("select", {}, ...models.map((m) => h("option", { value: m }, m || "default model")));
const form = h("form", { class: "compose" }, prompt, h("div", { class: "compose-actions" }, model, h("button", { type: "submit" }, "Start agent")));
form.onsubmit = async (e) => {
e.preventDefault();
try {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: prompt.value, model: model.value || undefined } });
clearDraft();
}
catch (err) {
alert(errorText(err));
}
};
submitOnModEnter(prompt, form);
submitOnEnter(prompt, form);
return form;
}
function messageForm(sessionId, sessionState) {
function messageForm(taskId, sessionId, sessionState) {
const text = h("textarea", { placeholder: sessionState === "idle" ? "Message the agent" : "Wait for the turn to end, or interrupt it", rows: 3 });
const clearDraft = keepDraft(text, `message:${taskId}`);
const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send");
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" && sessionState !== "queued" }, sessionState === "queued" ? "Cancel queued turn" : "Interrupt");
interrupt.onclick = async () => {
@@ -125,39 +263,76 @@ function messageForm(sessionId, sessionState) {
try {
await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text: text.value } });
text.value = "";
clearDraft();
}
catch (err) {
alert(errorText(err));
}
};
submitOnModEnter(text, form);
submitOnEnter(text, form);
return form;
}
function submitOnModEnter(input, form) {
function submitOnEnter(input, form) {
input.onkeydown = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
form.requestSubmit();
}
};
}
function renderEvent(ev, tools) {
function contextMeter(c) {
if (!c || c.window <= 0) {
return null;
}
const pct = Math.min(100, (c.used / c.window) * 100);
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const title = `context: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}`;
return h("span", { class: `context-meter ${level}`, title }, `${Math.round(pct)}%`);
}
function renderToolCall(p, ctx) {
const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result);
return h("details", { class: "tool" }, h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result);
}
function runSummary(calls) {
const counts = new Map();
for (const c of calls) {
const key = `${c.name}\0${c.target}`;
const entry = counts.get(key);
if (entry) {
entry.n++;
}
else {
counts.set(key, { call: c, n: 1 });
}
}
if (counts.size === 0) {
return h("span", { class: "tool-brief" }, "thinking");
}
const parts = [];
for (const { call, n } of counts.values()) {
if (parts.length > 0) {
parts.push(", ");
}
parts.push(h("span", {}, h("span", { class: "tool-name" }, call.name), call.target ? h("span", { class: "tool-brief" }, " ", call.target) : null, n > 1 ? h("span", { class: "tool-count" }, ` ×${n}`) : null));
}
return h("span", {}, ...parts);
}
function renderEvent(ev, ctx) {
const p = ev.payload;
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
switch (ev.kind) {
case "user.message":
if (ev.actor === "byre") {
return h("div", { class: "note" }, String(p["text"]), " ", when);
}
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), markdown(String(p["text"]), "text md"));
case "agent.message":
return h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md"));
case "agent.thinking":
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md"));
case "agent.tool_call": {
const result = h("div", { class: "tool-result pending" }, "running…");
tools.set(String(p["id"]), result);
return h("details", { class: "tool" }, h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"]))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result);
}
case "agent.tool_result": {
const target = tools.get(String(p["tool_use_id"]));
const target = ctx.tools.get(String(p["tool_use_id"]));
const body = h("pre", {}, resultText(p["content"]));
if (!target) {
return h("div", { class: "tool-result orphan" }, body);
@@ -165,12 +340,18 @@ function renderEvent(ev, tools) {
target.className = p["is_error"] ? "tool-result error" : "tool-result";
replace(target, body);
if (p["is_error"]) {
target.closest("details")?.classList.add("errored");
target.closest("details.tool")?.classList.add("errored");
target.closest("details.tool-group")?.classList.add("errored");
}
return null;
}
case "agent.report":
return reportCard(p);
case "agent.report": {
const card = reportCard(p);
if (typeof p["inbox_item_id"] === "string" && p["inbox_item_id"]) {
ctx.watchReport(card, p["inbox_item_id"]);
}
return card;
}
case "turn.ended":
return h("div", { class: `turn-end ${p["is_error"] ? "error" : ""}` }, p["is_error"] ? `turn ended: ${String(p["subtype"]).replaceAll("_", " ")}` : "");
case "session.created":
@@ -209,15 +390,25 @@ export function reportCard(p) {
return h("div", { class: `report-section ${key}` }, h("h4", {}, label), h("ul", {}, ...items.map((i) => h("li", {}, inlineMarkdown(i)))));
}));
}
function toolBrief(input) {
function toolBrief(input, root) {
if (typeof input !== "object" || input === null) {
return "";
}
const i = input;
const brief = i["command"] ?? i["file_path"] ?? i["pattern"] ?? i["url"] ?? i["question"] ?? i["description"] ?? "";
const text = String(brief);
const text = relative(String(brief), root);
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
}
function toolTarget(input, root) {
if (typeof input !== "object" || input === null) {
return "";
}
const path = input["file_path"];
return typeof path === "string" ? relative(path, root) : "";
}
function relative(path, root) {
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
}
function resultText(content) {
if (typeof content === "string") {
return content;
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -4,6 +4,7 @@ import "embed"
//go:generate npm ci --silent
//go:generate npm run --silent generate
//go:generate go run ./img/spinner.go
//go:embed index.html style.css dist src fonts
//go:embed index.html style.css dist src fonts img/*.gif
var FS embed.FS
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+89
View File
@@ -0,0 +1,89 @@
//go:build ignore
package main
import (
"image"
"image/color"
"image/gif"
"log"
"math"
"os"
)
const (
width = 16
height = 32
radius = 2.6
levels = 16
samples = 4
delay = 8
)
var frames = []rune("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
var dots = [6][2]float64{{4.5, 8}, {4.5, 16}, {4.5, 24}, {11.5, 8}, {11.5, 16}, {11.5, 24}}
func main() {
render("img/spinner-light.gif", color.RGBA{0x1a, 0x45, 0xbd, 0xff}, color.RGBA{0xfb, 0xfb, 0xfa, 0xff})
render("img/spinner-dark.gif", color.RGBA{0x8a, 0xb4, 0xff, 0xff}, color.RGBA{0x15, 0x17, 0x1a, 0xff})
}
func render(path string, fg, bg color.RGBA) {
palette := color.Palette{color.RGBA{}}
for i := 1; i <= levels; i++ {
palette = append(palette, blend(bg, fg, float64(i)/levels))
}
anim := &gif.GIF{}
for _, r := range frames {
bits := int(r - 0x2800)
img := image.NewPaletted(image.Rect(0, 0, width, height), palette)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
level := int(math.Round(coverage(bits, x, y) * levels))
img.SetColorIndex(x, y, uint8(level))
}
}
anim.Image = append(anim.Image, img)
anim.Delay = append(anim.Delay, delay)
anim.Disposal = append(anim.Disposal, gif.DisposalBackground)
}
f, err := os.Create(path)
if err != nil {
log.Fatal(err)
}
defer f.Close()
err = gif.EncodeAll(f, anim)
if err != nil {
log.Fatal(err)
}
}
func coverage(bits, x, y int) float64 {
hit := 0
for sy := 0; sy < samples; sy++ {
for sx := 0; sx < samples; sx++ {
px := float64(x) + (float64(sx)+0.5)/samples
py := float64(y) + (float64(sy)+0.5)/samples
for i, d := range dots {
if bits&(1<<i) == 0 {
continue
}
if math.Hypot(px-d[0], py-d[1]) <= radius {
hit++
break
}
}
}
}
return float64(hit) / (samples * samples)
}
func blend(a, b color.RGBA, t float64) color.RGBA {
mix := func(p, q uint8) uint8 {
return uint8(math.Round(float64(p) + (float64(q)-float64(p))*t))
}
return color.RGBA{mix(a.R, b.R), mix(a.G, b.G), mix(a.B, b.B), 0xff}
}
+1 -1
View File
@@ -44,7 +44,7 @@ export class Pane {
}),
);
const root = h("div", { class: `view ${view}` });
const root = h("div", { class: `view view-${view}` });
replace(this.body, root);
this.dispose = views[view].mount(root);
}
+14 -3
View File
@@ -87,26 +87,33 @@ export async function loadInbox(): Promise<void> {
notify("inbox");
}
let liveDuringLoad: ByreEvent[] | undefined;
export async function selectTask(taskId: string | undefined): Promise<void> {
state.selectedTaskId = taskId;
state.transcript = [];
state.lastTranscriptSeq = 0;
liveDuringLoad = undefined;
notify("selection");
if (!taskId) {
return;
}
const held: ByreEvent[] = [];
liveDuringLoad = held;
for (;;) {
const page = await get("/api/tasks/{taskId}/events", { path: { taskId }, query: { after: state.lastTranscriptSeq, limit: 1000 } });
if (state.selectedTaskId !== taskId) {
if (state.selectedTaskId !== taskId || liveDuringLoad !== held) {
return;
}
appendTranscript(page);
if (page.length < 1000) {
return;
break;
}
}
liveDuringLoad = undefined;
appendTranscript(held);
}
function appendTranscript(events: ByreEvent[]): void {
@@ -121,7 +128,11 @@ function appendTranscript(events: ByreEvent[]): void {
export function handleEvent(ev: ByreEvent): void {
if (ev.task_id && ev.task_id === state.selectedTaskId) {
appendTranscript([ev]);
if (liveDuringLoad) {
liveDuringLoad.push(ev);
} else {
appendTranscript([ev]);
}
}
switch (ev.kind) {
+250 -33
View File
@@ -5,14 +5,102 @@ import { currentSession, findTask, onEach, state } from "./state.js";
const models = ["", "opus", "sonnet", "haiku"];
type Call = { name: string; target: string };
type Run = {
first: HTMLElement;
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
calls: Call[];
};
type Ctx = {
tools: Map<string, HTMLElement>;
run: Run | undefined;
root: string;
watchReport: (el: HTMLElement, itemId: string) => void;
};
export function mountTranscript(root: HTMLElement): () => void {
const head = h("header", { class: "task-header" });
const transcript = h("div", { class: "transcript" });
const composer = h("div", { class: "composer" });
let tools = new Map<string, HTMLElement>();
const visibleReports = new Map<Element, string>();
const acked = new Set<string>();
const ackVisible = () => {
if (document.visibilityState !== "visible") {
return;
}
const ids = [...new Set(visibleReports.values())].filter((id) => !acked.has(id) && state.inbox.some((i) => i.id === id));
if (ids.length === 0) {
return;
}
for (const id of ids) {
acked.add(id);
}
post("/api/inbox/responses", { body: { responses: ids.map((id) => ({ item_id: id, type: "dismiss" as const })) } }).catch((err) => {
for (const id of ids) {
acked.delete(id);
}
console.error("acknowledge reports", err);
});
};
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const id = (e.target as HTMLElement).dataset["inboxItem"];
if (!id) {
continue;
}
if (e.isIntersecting) {
visibleReports.set(e.target, id);
} else {
visibleReports.delete(e.target);
}
}
ackVisible();
},
{ root: transcript, threshold: 0.5 },
);
const newCtx = (): Ctx => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
return {
tools: new Map(),
run: undefined,
root: task?.worktree_path ?? "",
watchReport: (el, itemId) => {
el.dataset["inboxItem"] = itemId;
observer.observe(el);
},
};
};
let ctx = newCtx();
let context = new Map<string, { used: number; window: number; model: string }>();
const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running: boolean) => {
transcript.classList.toggle("running", running);
if (running === !spinner.hidden) {
return;
}
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
spinner.hidden = !running;
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
}
};
const put = (el: HTMLElement) => {
if (spinner.parentNode === transcript) {
transcript.insertBefore(el, spinner);
} else {
transcript.append(el);
}
};
const renderHead = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
setRunning(task !== undefined && currentSession(task.id)?.state === "running");
if (!task) {
replace(head);
return;
@@ -32,8 +120,8 @@ export function mountTranscript(root: HTMLElement): () => void {
};
replace(
head,
h("div", { class: "meta" }, h("code", {}, task.branch), h("code", { class: "path" }, task.worktree_path)),
h("div", { class: "head-actions" }, sess && h("span", { class: `state ${sess.state}` }, sess.state), complete),
h("div", { class: "meta" }, h("code", {}, task.branch)),
h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), complete),
);
};
@@ -48,16 +136,63 @@ export function mountTranscript(root: HTMLElement): () => void {
replace(composer, startForm(task.id));
return;
}
replace(composer, messageForm(sess.id, sess.state));
replace(composer, messageForm(task.id, sess.id, sess.state));
};
const addToRun = (el: HTMLElement, call: Call | undefined) => {
const run = ctx.run;
if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [] };
put(el);
return;
}
if (call) {
run.calls.push(call);
}
if (!run.group) {
const summary = h("summary");
const body = h("div", { class: "tool-group-body" });
const group = h("details", { class: "tool-group" }, summary, body);
run.first.replaceWith(group);
body.append(run.first);
if (run.first.classList.contains("errored")) {
group.classList.add("errored");
}
run.group = { el: group, summary, body };
}
run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls));
};
const appendEvents = (events: ByreEvent[]) => {
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
let contextChanged = false;
for (const ev of events) {
const el = renderEvent(ev, tools);
if (el) {
transcript.append(el);
const p = ev.payload as Record<string, unknown>;
if (ev.kind === "session.context" && ev.agent_session_id) {
const prev = context.get(ev.agent_session_id);
const window = Number(p["window"]) || prev?.window || 0;
context.set(ev.agent_session_id, { used: Number(p["used"]), window, model: String(p["model"] ?? "") });
contextChanged = true;
continue;
}
if (ev.kind === "agent.tool_call") {
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
continue;
}
const el = renderEvent(ev, ctx);
if (!el) {
continue;
}
if (ev.kind === "agent.thinking") {
addToRun(el, undefined);
continue;
}
ctx.run = undefined;
put(el);
}
if (contextChanged) {
renderHead();
}
if (pinned) {
transcript.scrollTop = transcript.scrollHeight;
@@ -65,14 +200,17 @@ export function mountTranscript(root: HTMLElement): () => void {
};
const renderAll = () => {
tools = new Map();
observer.disconnect();
visibleReports.clear();
ctx = newCtx();
context = new Map();
renderHead();
renderComposer();
if (!state.selectedTaskId) {
replace(transcript, h("div", { class: "empty" }, "No task selected. Pick one with ⌘K or from the Tasks tab."));
return;
}
replace(transcript);
replace(transcript, spinner);
appendEvents(state.transcript);
};
@@ -89,7 +227,9 @@ export function mountTranscript(root: HTMLElement): () => void {
}
}),
onEach(["transcript"], (fresh) => appendEvents(fresh as ByreEvent[])),
onEach(["inbox"], ackVisible),
];
document.addEventListener("visibilitychange", ackVisible);
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
renderAll();
@@ -97,27 +237,46 @@ export function mountTranscript(root: HTMLElement): () => void {
for (const off of offs) {
off();
}
document.removeEventListener("visibilitychange", ackVisible);
observer.disconnect();
};
}
const composerDrafts = new Map<string, string>();
function keepDraft(input: HTMLTextAreaElement, key: string): () => void {
input.value = composerDrafts.get(key) ?? "";
input.addEventListener("input", () => {
if (input.value) {
composerDrafts.set(key, input.value);
} else {
composerDrafts.delete(key);
}
});
return () => composerDrafts.delete(key);
}
function startForm(taskId: string): HTMLElement {
const prompt = h("textarea", { placeholder: "What should the agent do?", rows: 4, required: true });
const clearDraft = keepDraft(prompt, `start:${taskId}`);
const model = h("select", {}, ...models.map((m) => h("option", { value: m }, m || "default model")));
const form = h("form", { class: "compose" }, prompt, h("div", { class: "compose-actions" }, model, h("button", { type: "submit" }, "Start agent")));
form.onsubmit = async (e) => {
e.preventDefault();
try {
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: prompt.value, model: model.value || undefined } });
clearDraft();
} catch (err) {
alert(errorText(err));
}
};
submitOnModEnter(prompt, form);
submitOnEnter(prompt, form);
return form;
}
function messageForm(sessionId: string, sessionState: string): HTMLElement {
function messageForm(taskId: string, sessionId: string, sessionState: string): HTMLElement {
const text = h("textarea", { placeholder: sessionState === "idle" ? "Message the agent" : "Wait for the turn to end, or interrupt it", rows: 3 });
const clearDraft = keepDraft(text, `message:${taskId}`);
const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send");
const interrupt = h(
"button",
@@ -141,29 +300,81 @@ function messageForm(sessionId: string, sessionState: string): HTMLElement {
try {
await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text: text.value } });
text.value = "";
clearDraft();
} catch (err) {
alert(errorText(err));
}
};
submitOnModEnter(text, form);
submitOnEnter(text, form);
return form;
}
function submitOnModEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void {
function submitOnEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void {
input.onkeydown = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
form.requestSubmit();
}
};
}
function renderEvent(ev: ByreEvent, tools: Map<string, HTMLElement>): HTMLElement | null {
function contextMeter(c: { used: number; window: number; model: string } | undefined): HTMLElement | null {
if (!c || c.window <= 0) {
return null;
}
const pct = Math.min(100, (c.used / c.window) * 100);
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const title = `context: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}`;
return h("span", { class: `context-meter ${level}`, title }, `${Math.round(pct)}%`);
}
function renderToolCall(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result);
return h(
"details",
{ class: "tool" },
h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))),
h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)),
result,
);
}
function runSummary(calls: Call[]): HTMLElement {
const counts = new Map<string, { call: Call; n: number }>();
for (const c of calls) {
const key = `${c.name}\0${c.target}`;
const entry = counts.get(key);
if (entry) {
entry.n++;
} else {
counts.set(key, { call: c, n: 1 });
}
}
if (counts.size === 0) {
return h("span", { class: "tool-brief" }, "thinking");
}
const parts: (HTMLElement | string)[] = [];
for (const { call, n } of counts.values()) {
if (parts.length > 0) {
parts.push(", ");
}
parts.push(
h("span", {}, h("span", { class: "tool-name" }, call.name), call.target ? h("span", { class: "tool-brief" }, " ", call.target) : null, n > 1 ? h("span", { class: "tool-count" }, ` ×${n}`) : null),
);
}
return h("span", {}, ...parts);
}
function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
const p = ev.payload as Record<string, unknown>;
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
switch (ev.kind) {
case "user.message":
if (ev.actor === "byre") {
return h("div", { class: "note" }, String(p["text"]), " ", when);
}
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), markdown(String(p["text"]), "text md"));
case "agent.message":
@@ -172,20 +383,8 @@ function renderEvent(ev: ByreEvent, tools: Map<string, HTMLElement>): HTMLElemen
case "agent.thinking":
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md"));
case "agent.tool_call": {
const result = h("div", { class: "tool-result pending" }, "running…");
tools.set(String(p["id"]), result);
return h(
"details",
{ class: "tool" },
h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"]))),
h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)),
result,
);
}
case "agent.tool_result": {
const target = tools.get(String(p["tool_use_id"]));
const target = ctx.tools.get(String(p["tool_use_id"]));
const body = h("pre", {}, resultText(p["content"]));
if (!target) {
return h("div", { class: "tool-result orphan" }, body);
@@ -193,13 +392,19 @@ function renderEvent(ev: ByreEvent, tools: Map<string, HTMLElement>): HTMLElemen
target.className = p["is_error"] ? "tool-result error" : "tool-result";
replace(target, body);
if (p["is_error"]) {
target.closest("details")?.classList.add("errored");
target.closest("details.tool")?.classList.add("errored");
target.closest("details.tool-group")?.classList.add("errored");
}
return null;
}
case "agent.report":
return reportCard(p);
case "agent.report": {
const card = reportCard(p);
if (typeof p["inbox_item_id"] === "string" && p["inbox_item_id"]) {
ctx.watchReport(card, p["inbox_item_id"]);
}
return card;
}
case "turn.ended":
return h("div", { class: `turn-end ${p["is_error"] ? "error" : ""}` }, p["is_error"] ? `turn ended: ${String(p["subtype"]).replaceAll("_", " ")}` : "");
@@ -250,16 +455,28 @@ export function reportCard(p: Record<string, unknown>): HTMLElement {
);
}
function toolBrief(input: unknown): string {
function toolBrief(input: unknown, root: string): string {
if (typeof input !== "object" || input === null) {
return "";
}
const i = input as Record<string, unknown>;
const brief = i["command"] ?? i["file_path"] ?? i["pattern"] ?? i["url"] ?? i["question"] ?? i["description"] ?? "";
const text = String(brief);
const text = relative(String(brief), root);
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
}
function toolTarget(input: unknown, root: string): string {
if (typeof input !== "object" || input === null) {
return "";
}
const path = (input as Record<string, unknown>)["file_path"];
return typeof path === "string" ? relative(path, root) : "";
}
function relative(path: string, root: string): string {
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
}
function resultText(content: unknown): string {
if (typeof content === "string") {
return content;
+55 -10
View File
@@ -449,23 +449,23 @@ textarea {
flex-shrink: 0;
}
.state {
.context-meter {
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
background: var(--surface);
color: var(--muted);
}
.state.running {
.context-meter.ok {
color: var(--ok);
}
.state.queued {
background: var(--warn-tint);
.context-meter.warn {
color: var(--warn);
}
.context-meter.danger {
color: var(--danger);
font-weight: 700;
}
.transcript {
flex: 1;
overflow-y: auto;
@@ -473,6 +473,29 @@ textarea {
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid transparent;
transition: border-color 0.3s;
}
.transcript.running {
border-color: var(--accent);
}
.spinner {
flex-shrink: 0;
width: 8px;
height: 16px;
background: url(/img/spinner-light.gif) no-repeat 0 0 / 8px 16px;
}
.spinner[hidden] {
display: none;
}
@media (prefers-color-scheme: dark) {
.spinner {
background-image: url(/img/spinner-dark.gif);
}
}
.empty {
@@ -591,7 +614,8 @@ textarea {
}
.thinking,
.tool {
.tool,
.tool-group {
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 10px;
@@ -599,10 +623,31 @@ textarea {
}
.thinking summary,
.tool summary {
.tool summary,
.tool-group summary {
cursor: pointer;
}
.tool-group-body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 0 4px;
}
.tool-group-body .thinking,
.tool-group-body .tool {
background: var(--raised);
}
.tool-group.errored {
border-color: var(--danger);
}
.tool-count {
color: var(--muted);
}
.thinking summary {
color: var(--muted);
}