pi-mono/apps/p2/plugins/logs/tui.ts
Mario Zechner 20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00

213 lines
6.9 KiB
TypeScript

// Owner diagnostics in the TUI (p2-tui-port.md P11d), after the web's logs sheet and owner notice lane: `/logs`
// shows a log source (server, terminals, dev supervisor, session workers) and follows its tail; owner notices from
// the server (`LogNotices`) become status lines as they arrive, and `/notices` lists and dismisses them. Owner only:
// guests never acquire these objects.
import type { Held } from "@p2/app/v1";
import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, liveIn, whenLive } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
ListOverlay,
lineText,
type OptimisticAdd,
type TuiCommand,
TuiCommands,
type TuiRuntime,
TuiScope,
TuiSlots,
type TuiSurface,
} from "@p2/app/v1/tui";
import { type LogLine, LogNotices, Logs } from "./contract.ts";
type Notice = { id: number; time: number; source: string; message: string };
/** The web's `sourceLabel`. */
function sourceLabel(source: string): string {
if (source.startsWith("session:")) return `Worker ${source.slice(8)}`;
return source === "dev" ? "Dev supervisor" : source === "terminals" ? "Terminal host" : "Server";
}
/** Owner notices as they arrive (a non-screen slot); the list for `/notices`. */
export class NoticeWatch implements TuiSurface {
readonly runtime: TuiRuntime;
private scope: TuiScope | undefined;
private service: Held<typeof LogNotices> | undefined;
/** The notices as shown (those dismissed while their calls are in flight left out), and how to dismiss one. */
private shown: () => Notice[] = () => this.received;
private dismissing: OptimisticAdd<number> | undefined;
private received: Notice[] = [];
private seen = new Set<number>();
private first = true;
private stop: () => void;
constructor(runtime: TuiRuntime) {
this.runtime = runtime;
const changed = (): void => {
if (runtime.role !== "owner" || this.scope) return;
this.scope = new TuiScope(runtime);
[this.shown, this.dismissing] = this.scope.optimistic(
() => this.received,
(notices, id: number) => notices.filter((notice) => notice.id !== id),
);
this.service = this.scope.watch(LogNotices, {}, (ref, follow) =>
follow(ref.notices, (notices) => this.arrived(notices ?? [])),
);
};
runtime.listeners.add(changed);
this.stop = () => runtime.listeners.delete(changed);
changed();
}
/** The notices as shown: those dismissed meanwhile left out. */
get notices(): Notice[] {
return this.shown();
}
/** Dismisses notice `id`: it leaves the list at once and comes back if the server refuses. */
dismiss(id: number): void {
const service = liveIn(this.service, "owner");
if (service && this.dismissing)
this.runtime.perform(this.dismissing(id, service.dismiss({ id }, BACKGROUND_CONTEXT)));
}
private arrived(notices: Notice[]): void {
this.received = notices;
const fresh = notices.filter((notice) => !this.seen.has(notice.id));
for (const notice of notices) this.seen.add(notice.id);
if (!fresh.length) return;
// Notices pending from before this TUI opened are summarized once; later ones show as they arrive.
if (this.first)
this.runtime.notify(`${fresh.length} owner notice${fresh.length === 1 ? "" : "s"} · /notices`, "warning");
else
for (const notice of fresh)
this.runtime.notify(`${sourceLabel(notice.source)}: ${lineText(notice.message)}`, "warning");
this.first = false;
}
render(): string[] {
return [];
}
invalidate(): void {}
dispose(): void {
this.stop();
this.scope?.dispose();
}
}
const WATCH = "service:notices";
const watchOf = (ctx: TuiContext): NoticeWatch | undefined => {
const surface = ctx.surface(WATCH);
return surface instanceof NoticeWatch ? surface : undefined;
};
const time = (ms: number) => new Date(ms).toLocaleTimeString();
function openNotices(ctx: TuiContext): void {
const watch = watchOf(ctx);
if (!watch) return;
const handle = ctx.overlay(
"notices",
new ListOverlay(ctx, {
title: () => `Owner notices (${watch.notices.length})`,
rows: () =>
watch.notices.map((notice) => ({
key: String(notice.id),
label: notice.message,
detail: `${sourceLabel(notice.source)} ${time(notice.time)}`,
description: notice.message,
color: "warning" as const,
})),
actions: [
{
id: "app.session.delete",
label: "dismiss",
run: (row) => {
if (row) watch.dismiss(Number(row.key));
},
},
],
onClose: () => handle.close(),
}),
{ width: "100%", anchor: "bottom-center" },
);
}
/** `/logs [source]`: a source's lines, newest at the bottom; Tab picks another source. */
function openLogs(ctx: TuiContext, initial: string | undefined): void {
let source = initial?.trim() || (ctx.identity.session ? `session:${ctx.identity.session}` : "server");
let ref: Held<typeof Logs> | undefined;
let lines: LogLine[] = [];
let sources: string[] = [];
let stops: (() => void)[] = [];
const acquire = () => {
for (const stop of stops.splice(0)) stop();
ref?.release();
lines = [];
ref = ctx.host.hold(Logs, { source }, BACKGROUND_CONTEXT);
const current = ref;
stops = [
follow(current.lines, (value) => {
lines = value ?? [];
ctx.requestRender();
}),
follow(current.sources, (value) => {
sources = [...new Set(["server", "terminals", "dev", source, ...(value ?? []).map((entry) => entry.id)])];
ctx.requestRender();
}),
];
void whenLive(current).catch((error: unknown) => ctx.fail(error));
};
acquire();
const overlay = ctx.overlay(
"logs",
new ListOverlay(ctx, {
title: () => `Logs · ${sourceLabel(source)} (${lines.length} line${lines.length === 1 ? "" : "s"})`,
rows: () =>
lines.map((line) => ({
key: String(line.id),
label: line.text,
detail: `${line.level} ${time(line.time)}`,
...(line.level === "stderr" || line.level === "error" ? { color: "error" as const } : {}),
})),
// The newest line selected: the list follows the tail until the reader moves up.
follow: true,
height: 18,
actions: [
{
id: "tui.input.tab",
label: "next source",
run: () => {
const next = sources[(sources.indexOf(source) + 1) % Math.max(1, sources.length)];
if (!next || next === source) return;
source = next;
acquire();
},
},
],
onClose: () => {
for (const stop of stops.splice(0)) stop();
ref?.release();
overlay.close();
},
}),
{ width: "100%", anchor: "bottom-center" },
);
}
export const LOG_COMMANDS: Record<string, TuiCommand> = {
"p2.logs": {
title: "Logs",
category: "Owner",
when: "owner && !overlay",
slash: { name: "logs", argumentHint: "[source]" },
run: (ctx, args) => openLogs(ctx, args),
},
"p2.notices": {
title: "Owner notices",
category: "Owner",
when: "owner && !overlay",
slash: { name: "notices" },
run: (ctx) => openNotices(ctx),
},
};
export default defineFacet((host) =>
contribute(host, "p2.tui-logs", (self) => {
self.contribute(TuiSlots, { [WATCH]: (runtime: TuiRuntime): TuiSurface => new NoticeWatch(runtime) });
self.contribute(TuiCommands, LOG_COMMANDS);
}),
);