Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Usage:
--follow/-f streams messages as they arrive (resilient long-poll, like tail -f; Ctrl-C to stop)
--peek reads without consuming (no ack advance); --all aggregates unread across every scope in one call
termchart suggest [flags] Push clickable suggestion chips to the human's console (--project --agent --items '[...]' / --item)
termchart reply [flags] Post an agent→human message into a scope's console thread (--project --agent --message)
termchart reply [flags] [text] Post an agent→human message into a scope's console thread (--project --agent, message via --message or positional)
termchart subscribe [flags] Claim ownership of a board so sessions coordinate (advisory; --project --agent --session); takeover wins
termchart unsubscribe … Release a board you own (--project --agent --session)
termchart subscriptions List which boards are currently owned (--project optional; [--json])
Expand Down
34 changes: 29 additions & 5 deletions packages/cli/src/inbox-nudge.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
/**
* Surface unread console messages on the agent's normal write loop. The viewer sets an
* `x-termchart-inbox-unread` header on a successful `push` / `patch` / `status` when the human has
* typed into the console (issue #108) since the agent last read it. We print a one-line hint to
* stderr (not stdout, so it never pollutes piped output) telling the agent how to read them β€” so it
* notices unprompted human input without having to poll the inbox itself.
* typed into the console (issue #108) since the agent last read it.
*
* The inbox eval showed a count-only hint gets ignored: agents read the line, then finish the
* literal task anyway. So we fetch the unread content (a peek β€” only a real `termchart inbox` read
* advances the ack/receipt) and print the messages themselves plus an explicit instruction, on
* stderr (never polluting piped stdout). Feedback inside the tool output the agent is already
* reading is the one channel that reliably lands.
*/
export function notifyInbox(r: Response, project: string, agent: string): void {
interface UnreadScope { project: string; agent: string; events?: { kind: string; text?: string; ref?: string }[] }

export async function notifyInbox(r: Response, base: string, project: string, agent: string): Promise<void> {
const n = Number(r.headers.get("x-termchart-inbox-unread") ?? "0");
if (!Number.isFinite(n) || n <= 0) return;
const noun = n === 1 ? "message" : "messages";
let lines = "";
try {
// ?all=1 is unread-only and always a peek; URL-gated like list, so no token needed here.
const rr = await fetch(`${base}/inbox?all=1`, { method: "GET", signal: AbortSignal.timeout(4000) });
if (rr.ok) {
const data = (await rr.json()) as { scopes?: UnreadScope[] };
const mine = data.scopes?.find((s) => s.project === project && s.agent === agent);
lines = (mine?.events ?? [])
.slice(-3)
.map((e) => ` β€Ί ${e.kind === "action" ? `[action] ${e.ref ?? ""}` : (e.text ?? "").slice(0, 300)}\n`)
.join("");
}
} catch {
// Content fetch is best-effort β€” the imperative nudge below fires regardless.
}
process.stderr.write(
`πŸ“¬ ${n} console ${noun} from the human β€” run: termchart inbox --project ${project} --agent ${agent}\n`,
`πŸ“¬ ${n} unread console ${noun} from the human β€” ACT ON THIS BEFORE FINISHING:\n` +
lines +
` Apply what it asks in this scope, mark it read with \`termchart inbox --project ${project} --agent ${agent}\`,\n` +
` then acknowledge: termchart reply --project ${project} --agent ${agent} --message "<what you changed>"\n`,
);
}
2 changes: 1 addition & 1 deletion packages/cli/src/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export async function patch(argv: string[], deps: PatchDeps): Promise<number> {
const d = (await r.json().catch(() => null)) as { warnings?: unknown } | null;
if (d && Array.isArray(d.warnings)) for (const w of d.warnings) process.stderr.write(`patch warning: ${w}\n`);
}
notifyInbox(r, a.project, a.agent); // nudge if the human typed into the console since we last looked
await notifyInbox(r, base, a.project, a.agent); // nudge if the human typed into the console since we last looked
return 0;
} catch (e) {
if (isConnError(e)) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export async function push(argv: string[], deps: PushDeps): Promise<number> {
// The push succeeded, but the server may return non-fatal readability findings (e.g. flow
// edges running over nodes / crossings) β€” severity-tagged β€” for the caller to fix or weigh.
if (r.status !== 204) printGeometry((await r.json().catch(() => null)) as { findings?: PushFinding[]; warnings?: unknown } | null);
notifyInbox(r, a.project, a.agent); // nudge if the human typed into the console since we last looked
await notifyInbox(r, base, a.project, a.agent); // nudge if the human typed into the console since we last looked
if (a.focus) {
const fr = await post("focus", { project: a.project, agent: a.agent });
if (!fr.ok) process.stderr.write(`focus failed: HTTP ${fr.status}\n`);
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ interface Args { project?: string; agent?: string; message?: string; error?: str

function parse(argv: string[]): Args {
const a: Args = {};
const positional: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--project") a.project = argv[++i];
else if (arg === "--agent") a.agent = argv[++i];
else if (arg === "--message" || arg === "-m") a.message = argv[++i];
else a.error = `Unknown flag: ${arg}`;
else if (arg.startsWith("-")) a.error = `Unknown flag: ${arg}`;
else positional.push(arg); // bare words are the message β€” agents reach for `reply <scope> "text"` first
}
if (!a.message && positional.length) a.message = positional.join(" ");
return a;
}

Expand All @@ -31,7 +34,7 @@ export async function reply(argv: string[], deps: ReplyDeps): Promise<number> {
const token = deps.env.TERMCHART_VIEWER_TOKEN;
if (!base || !token) { process.stderr.write(missingConfigMessage()); return EXIT_NO_VIEWER; }
if (!a.project || !a.agent) { process.stderr.write("--project and --agent are required.\n"); return 3; }
if (!a.message) { process.stderr.write("--message (-m) is required.\n"); return 3; }
if (!a.message) { process.stderr.write("a message is required: --message (-m) \"…\" or positional text.\n"); return 3; }

try {
const r = await fetch(`${base}/reply`, {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export async function status(argv: string[], deps: StatusDeps): Promise<number>
process.stderr.write(`status failed: HTTP ${r.status}\n`);
return 1;
}
if (a.project && a.agent) notifyInbox(r, a.project, a.agent); // scoped status carries the unread nudge
if (a.project && a.agent) await notifyInbox(r, base, a.project, a.agent); // scoped status carries the unread nudge
return 0;
} catch (e) {
if (isConnError(e)) {
Expand Down
80 changes: 80 additions & 0 deletions packages/cli/test/inbox-nudge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createServer, type Server } from "node:http";
import { type AddressInfo } from "node:net";
import { notifyInbox } from "../src/inbox-nudge.js";

let server: Server | undefined;
afterEach(() => server?.close());

/** Serves GET /w/ws1/inbox?all=1 with the given unread scopes. */
function inboxStub(scopes: unknown[]): Promise<string> {
return new Promise((resolve) => {
server = createServer((req, res) => {
if (req.url?.includes("/inbox") && req.url.includes("all=1")) {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ scopes }));
} else res.writeHead(404).end();
});
server.listen(0, () => resolve(`http://127.0.0.1:${(server!.address() as AddressInfo).port}/w/ws1`));
});
}

const withUnread = (n: string | null) =>
new Response(null, { headers: n === null ? {} : { "x-termchart-inbox-unread": n } });

function captureStderr(): { errs: string[]; restore: () => void } {
const errs: string[] = [];
const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true));
return { errs, restore: () => spy.mockRestore() };
}

describe("notifyInbox", () => {
it("is silent when the header is absent or zero", async () => {
const { errs, restore } = captureStderr();
await notifyInbox(withUnread(null), "http://127.0.0.1:1/w/x", "p", "a");
await notifyInbox(withUnread("0"), "http://127.0.0.1:1/w/x", "p", "a");
restore();
expect(errs).toEqual([]);
});

it("prints the unread message content inline plus the act-before-finishing contract", async () => {
const base = await inboxStub([
{ project: "p", agent: "a", events: [{ seq: 1, kind: "message", text: "add a maintenance banner" }] },
{ project: "other", agent: "x", events: [{ seq: 9, kind: "message", text: "not ours" }] },
]);
const { errs, restore } = captureStderr();
await notifyInbox(withUnread("1"), base, "p", "a");
restore();
const out = errs.join("");
expect(out).toContain("ACT ON THIS BEFORE FINISHING");
expect(out).toContain("add a maintenance banner");
expect(out).not.toContain("not ours");
expect(out).toContain("termchart inbox --project p --agent a");
expect(out).toContain("termchart reply --project p --agent a");
});

it("renders action events by ref and caps at the last 3 events", async () => {
const events = [
{ seq: 1, kind: "message", text: "oldest β€” dropped" },
{ seq: 2, kind: "message", text: "two" },
{ seq: 3, kind: "message", text: "three" },
{ seq: 4, kind: "action", ref: "approve" },
];
const base = await inboxStub([{ project: "p", agent: "a", events }]);
const { errs, restore } = captureStderr();
await notifyInbox(withUnread("4"), base, "p", "a");
restore();
const out = errs.join("");
expect(out).toContain("[action] approve");
expect(out).toContain("two");
expect(out).not.toContain("oldest β€” dropped");
});

it("still prints the imperative nudge when the content fetch fails", async () => {
const { errs, restore } = captureStderr();
await notifyInbox(withUnread("2"), "http://127.0.0.1:1/w/dead", "p", "a");
restore();
const out = errs.join("");
expect(out).toContain("πŸ“¬ 2 unread console messages");
expect(out).toContain("ACT ON THIS BEFORE FINISHING");
});
});
3 changes: 2 additions & 1 deletion packages/cli/test/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ describe("push", () => {
spy.mockRestore();
expect(code).toBe(0);
const out = errs.join("");
expect(out).toContain("πŸ“¬ 2 console messages");
expect(out).toContain("πŸ“¬ 2 unread console messages");
expect(out).toContain("ACT ON THIS BEFORE FINISHING");
expect(out).toContain("termchart inbox --project p --agent a");
});

Expand Down
14 changes: 14 additions & 0 deletions packages/cli/test/reply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ describe("reply", () => {
expect(JSON.parse((await received).body).text).toBe("short");
});

it("accepts positional message text (agents reach for `reply … \"text\"` first)", async () => {
const { url, received } = await stub();
const code = await reply(["--project", "p", "--agent", "a", "did", "the", "thing"], { env: env(url) });
expect(code).toBe(0);
expect(JSON.parse((await received).body).text).toBe("did the thing");
});

it("--message wins over positional text", async () => {
const { url, received } = await stub();
const code = await reply(["--project", "p", "--agent", "a", "-m", "flagged", "stray"], { env: env(url) });
expect(code).toBe(0);
expect(JSON.parse((await received).body).text).toBe("flagged");
});

it("requires URL+token (no-viewer code) and project/agent/message (exit 3)", async () => {
expect(await reply(["--project", "p", "--agent", "a", "-m", "x"], { env: {} })).toBe(EXIT_NO_VIEWER);
const { url } = await stub();
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "termchart",
"displayName": "termchart",
"description": "A live canvas your AI draws on. Instead of walls of text, your agent pushes rich, native visuals to a browser tab / iPad / second screen in real time \u2014 React Flow graphs (architecture, sequence, call-graph, ER/class, state machine, PR-review, journeys, recursion trees), Vega-Lite charts (incl. scientific + Big-O), Mantine UI dashboards & product comparisons, deep-code walkthroughs, markdown, and split panes \u2014 with animated edges and live status overlays (toasts, progress bars, loaders). Ships persona recipes, a showcase gallery, fullscreen panes, and a collapsible sidebar. Adds /termchart commands + skills, backed by the termchart CLI. (A deterministic Mermaid\u2192ASCII/Unicode terminal renderer is still bundled but deprecated.)",
"version": "0.16.2",
"version": "0.16.3",
"author": {
"name": "Ivan Cheung"
},
Expand Down
5 changes: 5 additions & 0 deletions plugin/skills/termchart/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ For **ER / schema / class** stay on `--type flow` β€” make each node `"type":"en
**No viewer configured?** `push` exits 4 β€” run `termchart serve`, export the printed
`TERMCHART_VIEWER_URL`/`TERMCHART_VIEWER_TOKEN`, and retry.

**πŸ“¬ in any termchart output = the human left you a console message. It is part of your task.**
Before finishing, run the printed `termchart inbox …` command, apply what the message asks to the
same scope (edit + re-push or patch), and acknowledge with
`termchart reply --project <p> --agent <a> --message "<what you changed>"`.

For richer per-persona recipes (zones, swimlanes, legends, sparklines, sequence layouts, ~30
worked examples), **load the `diagram-recipes` skill** β€” this skill is the quick front door;
`diagram-recipes` is the deep library.
Loading