Skip to content
Closed
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
10 changes: 9 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,21 @@ Delivery messages are UTF-8 JSON objects with this shape (`CotalMessage`):
| `replyTo` | string | MAY | id of the message replied to |
| `contextId` | string | MAY | thread/conversation correlation id |

`Part` is one of the two core shapes, or an extension object whose `kind` is namespaced
`Part` is one of the three core shapes, or an extension object whose `kind` is namespaced
as described in §11:

- `{ "kind": "text", "text": string }`
- `{ "kind": "data", "data": <any JSON value> }`
- `{ "kind": "view", "spec": ViewSpec }`
- `{ "kind": "<reverse-DNS extension kind>", ... }`

`ViewSpec` is a renderable view — a json-render flat spec: `{ "root": string, "elements":
{ [key]: { "type": string, "props"?: object, "children"?: string[] } }, "state"?: object }`,
where `root` MUST name a key in `elements`. A viewer renders it against its own fixed
component catalog (declared components only, validated props, never code); a text-only
consumer MAY ignore the part or show a placeholder. Senders SHOULD pair a `view` part with
a `text` part so text-only consumers still see something.

`EndpointRef` is `{ "id": string, "name": string, "role"?: string }`.

On receive, a client MUST verify `from.id` equals the subject sender (§3). On mismatch, a
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ the reasoning: [presence & delivery](presence-and-delivery.md)). Isolation is on
ACLs ([identity & auth](identity-and-auth.md)). Large artifacts are reserved for a
per-space Object Store ([roadmap](roadmap.md)).

A message part can also be a renderable **view**: a json-render spec that rides the normal
delivery modes (`endpoint.publishView`, no new subject) and is painted against the viewer's
own fixed component catalog (declared components with validated props, never code), with a
plain-text label alongside so text-only consumers still see something
([SPEC §5](../SPEC.md#5-envelopes)). Core owns the wire shape; each renderer owns its
catalog.

Whether any of this *requires* NATS is answered in
[transport vs protocol](transport.md): the contract is transport-agnostic; NATS/JetStream
is the reference binding.
Expand Down
9 changes: 7 additions & 2 deletions docs/mesh-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface MeshSnapshot {
endpoints: Presence[]; // everything else
channels: { channel: string; messages: number }[];
feed: FeedEntry[]; // classified + coalesced + windowed
views: ViewItem[]; // peer-published renderable views (json-render specs), newest last
rates: { msgsPerSec: number };
status: { connected: boolean; space: string; dmVisible: boolean; error?: string };
signals: MeshSignals; // derived operator signals (below)
Expand Down Expand Up @@ -101,14 +102,18 @@ visible (god-view / open mode); a chat-only observer leaves it empty.
| needs-you / blocked | `signals.waiting` | ✓ rail (`n`) | | ✓ NEEDS-YOU rail |
| direct-message lens | `signals.dms` | ✓ lens (`d`) | | ✓ DM view |
| topology (who-talks-to-whom) | `feed` + `agents` (derived) | ✓ lens (`t`, 3 variants) | | |
| peer-pushed views (json-render) | `views` | ✓ lens (`V`) | | |
| message / agent **detail** | `feed` / `agents` | ✓ select → detail | | ✓ row / thread |
| search / filter | client | ✓ `/` | (grep) | ✓ mode chips |
| msgs/s, connected, dmVisible | `rates` / `status` | ✓ status bar | | ✓ conn pill |
| msgs/s + activity sparkline, connected, dmVisible | `rates` / `status` | ✓ status bar | | ✓ conn pill |

Both interactive surfaces render every model field. The console adds the signals as an always-on
tiles strip, a NEEDS-YOU rail (`n`), and a DM lens (`d`); the topology lens (`t`) folds the feed
plus roster into a who-talks-to-whom graph client-side and renders it three switchable ways
(`v` / `1`–`3`): swimlane sequence, adjacency heat matrix, and a ring node-link map. The stream is
(`v` / `1`–`3`): swimlane sequence, adjacency heat matrix, and a ring node-link map. The views
lens (`V`) shows the latest peer-published json-render view, validated against the console's
fixed Ink component catalog (an invalid spec shows its rejection reason instead); the tiles strip
renders through the same catalog, so the console dogfoods its own guardrail. The stream is
line-oriented, so the signals stay out of it.

## Future: not yet on the wire
Expand Down
2 changes: 1 addition & 1 deletion extensions/connector-core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ export class MeshAgent extends EventEmitter {
* focus recall ({@link recallAmbient}). */
private toInboxItem(m: CotalMessage, kind: InboxItem["kind"], historical: boolean): InboxItem {
const text = m.parts
.map((p) => (p.kind === "text" ? p.text : JSON.stringify(p.data)))
.map((p) => (p.kind === "text" ? p.text : p.kind === "view" ? "[view]" : JSON.stringify(p.data)))
.join(" ");
return {
id: m.id,
Expand Down
2 changes: 2 additions & 0 deletions implementations/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
},
"dependencies": {
"@cotal-ai/core": "workspace:*",
"@json-render/core": "0.19.0",
"@json-render/ink": "0.19.0",
"@cotal-ai/workspace": "workspace:*",
"@clack/prompts": "^1.0.0",
"ink": "^6.0.0",
Expand Down
2 changes: 1 addition & 1 deletion implementations/cli/src/commands/join.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export async function join(args: ParsedArgs): Promise<void> {

ep.on("message", (m: CotalMessage, d: Delivery) => {
const text = m.parts
.map((p) => (p.kind === "text" ? p.text : JSON.stringify(p.data)))
.map((p) => (p.kind === "text" ? p.text : p.kind === "view" ? "[view]" : JSON.stringify(p.data)))
.join(" ");
if (m.to === me)
print(`${c.magenta("(DM)")} ${who(m.from)} ${c.dim("→ you:")} ${text}`);
Expand Down
18 changes: 13 additions & 5 deletions implementations/cli/src/console/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Box, Text, useApp, useFocusManager, useInput, useStdout } from "ink";
import type { CotalEndpoint, Presence } from "@cotal-ai/core";
import { useMesh } from "./mesh.js";
import { Tabs } from "./ui/Tabs.js";
import { Tiles } from "./ui/Tiles.js";
import { Roster } from "./ui/Roster.js";
import { Feed } from "./ui/Feed.js";
import { NeedsYou } from "./ui/NeedsYou.js";
Expand All @@ -16,6 +15,9 @@ import { CommandPalette } from "./ui/CommandPalette.js";
import { Confirm, type ConfirmTarget } from "./ui/Confirm.js";
import { Prompt } from "./ui/Prompt.js";
import { Detail, type DetailTarget } from "./ui/Detail.js";
import { Views } from "./ui/Views.js";
import { SpecView } from "./render/SpecView.js";
import { tilesSpec } from "./render/spec.js";
import { runCommand, type CommandCtx } from "./commands.js";
import { mentionsIn } from "../lib/mentions.js";
import type { FeedEntry, FocusId } from "./mesh.js";
Expand Down Expand Up @@ -55,7 +57,7 @@ export function App({
const [detail, setDetail] = useState<DetailTarget | null>(null);
const [search, setSearch] = useState({ active: false, query: "" });
const [focusedId, setFocusedId] = useState<FocusId>("feed");
const [mode, setMode] = useState<"normal" | "dm" | "topo">("normal");
const [mode, setMode] = useState<"normal" | "dm" | "topo" | "views">("normal");
const [topoVariant, setTopoVariant] = useState<TopoVariant>(0);
const [railOpen, setRailOpen] = useState(false);
const [palette, setPalette] = useState({ active: false, query: "" });
Expand Down Expand Up @@ -104,6 +106,7 @@ export function App({
if (railOverlay) focus("needsyou");
else if (mode === "dm") focus("dmpeers");
else if (mode === "topo") focus("topo");
else if (mode === "views") return; // the view lens has no focusable child
else focus(normalFocus);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [helpOpen, detail, mode, railOpen, confirm]);
Expand Down Expand Up @@ -217,7 +220,7 @@ export function App({
if (input === ":") return setPalette({ active: true, query: "" });
if (key.escape) {
// lazygit-style "back": pop one level per press, then return to the space overview.
if (mode === "dm" || mode === "topo") return setMode("normal");
if (mode === "dm" || mode === "topo" || mode === "views") return setMode("normal");
if (railOverlay) return setRailOpen(false);
if (search.query) return setSearch({ active: false, query: "" });
if (onBack) return onBack();
Expand All @@ -227,6 +230,7 @@ export function App({
if (onBack && input === "b" && mode === "normal") return onBack(); // quick back to the overview
if (input === "d" && !key.ctrl) return setMode((m) => (m === "dm" ? "normal" : "dm")); // Ctrl-d = scroll
if (input === "t") return setMode((m) => (m === "topo" ? "normal" : "topo"));
if (input === "V") return setMode((m) => (m === "views" ? "normal" : "views")); // peer-pushed views
if (input === "v" && mode === "topo") return setTopoVariant((v) => ((v + 1) % 3) as TopoVariant);
if (mode === "topo" && input >= "1" && input <= "3")
return setTopoVariant((Number(input) - 1) as TopoVariant);
Expand Down Expand Up @@ -267,8 +271,12 @@ export function App({
return (
<Box flexDirection="column" width={size.cols} height={size.rows}>
<Tabs tabs={tabs} active={activeChannel} counts={counts} width={size.cols} />
<Tiles counts={mesh.signals.counts} oldestWaitingTs={mesh.signals.oldestWaitingTs} width={size.cols} />
{mode === "dm" ? (
{/* Golden-signal strip — rendered through json-render's Ink catalog (dogfoods the same
renderer that paints peer-pushed views). */}
<SpecView spec={tilesSpec(mesh.signals.counts, mesh.signals.oldestWaitingTs, size.cols)} />
{mode === "views" ? (
<Views views={mesh.views} width={size.cols} height={bodyH} />
) : mode === "dm" ? (
<Dm
dms={mesh.signals.dms}
dmVisible={mesh.status.dmVisible}
Expand Down
2 changes: 1 addition & 1 deletion implementations/cli/src/console/mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { MeshSnapshot, MeshViewOptions } from "../view/mesh-view.js";
import { MeshView } from "../view/mesh-view.js";

// Re-exported so the UI components keep importing the model shape from one place.
export type { FeedEntry, MeshViewOptions, FeedDelivery } from "../view/mesh-view.js";
export type { FeedEntry, MeshViewOptions, FeedDelivery, ViewItem } from "../view/mesh-view.js";
export type MeshState = MeshSnapshot;

/** Focusable panes across the console (normal panels + the DM and topology lenses). */
Expand Down
20 changes: 20 additions & 0 deletions implementations/cli/src/console/render/SpecView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SpecView — the single seam between Cotal's MeshView data and json-render's Ink Renderer.
// The console hands it a flat spec (its own chrome, or a view a peer published) and it paints
// it with the standard Ink catalog (Box/Text/Table/StatusLine/…), included by default. An
// unknown component type renders as an inert notice via `fallback` rather than throwing.

import { Text } from "ink";
import { JSONUIProvider, Renderer } from "@json-render/ink";
import type { Spec } from "@json-render/ink";

export function SpecView({ spec }: { spec: Spec }) {
return (
<JSONUIProvider initialState={spec.state ?? {}}>
<Renderer spec={spec} fallback={Unsupported} />
</JSONUIProvider>
);
}

function Unsupported() {
return <Text dimColor>[unsupported component]</Text>;
}
36 changes: 36 additions & 0 deletions implementations/cli/src/console/render/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// The view catalog + render guardrail. An agent-pushed view (a json-render flat spec) may only
// use the standard Ink component vocabulary — Box/Text/Table/StatusLine/Sparkline/Badge/… — so
// a peer can publish *data*, never code. `validateView` rejects a spec that is malformed or
// references a component outside the catalog BEFORE it reaches the Renderer; the Renderer's
// `fallback` (see SpecView) is the second line of defense at render time.

import { validateSpec, type Spec } from "@json-render/core";
import { standardComponentDefinitions } from "@json-render/ink/catalog";
import type { ViewSpec } from "@cotal-ai/core";

/** Coerce a wire {@link ViewSpec} (core keeps `props` optional) into a json-render {@link Spec}
* (which requires `props`) — the single ViewSpec→Spec boundary for the renderer. */
export function asSpec(view: ViewSpec): Spec {
const elements: Spec["elements"] = {};
for (const [key, el] of Object.entries(view.elements))
elements[key] = { type: el.type, props: el.props ?? {}, children: el.children };
return { root: view.root, elements, state: view.state };
}

/** The allowed component vocabulary — every standard Ink catalog component, by name. */
export const ALLOWED_COMPONENTS = new Set(Object.keys(standardComponentDefinitions));

export type ViewCheck = { ok: true } | { ok: false; reason: string };

/** Structurally validate a spec, then enforce the catalog: every element's `type` must be a
* known component. Returns `{ ok: false, reason }` on the first violation. */
export function validateView(spec: Spec): ViewCheck {
const structural = validateSpec(spec);
if (!structural.valid)
return { ok: false, reason: structural.issues.map((i) => i.code).join(", ") };
for (const [key, el] of Object.entries(spec.elements)) {
if (!ALLOWED_COMPONENTS.has(el.type))
return { ok: false, reason: `unknown component "${el.type}" at "${key}"` };
}
return { ok: true };
}
36 changes: 36 additions & 0 deletions implementations/cli/src/console/render/spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Spec emitters for the console's OWN chrome — building json-render specs from the live
// MeshSnapshot so Cotal's dashboard renders through the very same catalog an agent's pushed
// view does (dogfooding the guardrail). These never leave the process, so they're plain Spec
// objects, not wire ViewSpecs.

import type { Spec } from "@json-render/ink";
import type { StatusCounts } from "../../view/mesh-view.js";
import { STATUS, ago } from "../ui/theme.js";

/** The golden-signal strip: working/waiting/idle/offline counts + oldest-unattended age, as a
* row of standard Text components (one color each) inside a Box — the spec form of `Tiles`. */
export function tilesSpec(counts: StatusCounts, oldestWaitingTs: number | undefined, width: number): Spec {
const order: (keyof StatusCounts)[] = ["working", "waiting", "idle", "offline"];
const elements: Spec["elements"] = {};
const children: string[] = [];
for (const [i, k] of order.entries()) {
const id = `tile-${k}`;
elements[id] = {
type: "Text",
props: {
text: (i > 0 ? " " : "") + STATUS[k].dot + " " + counts[k] + " " + STATUS[k].word,
color: STATUS[k].color,
wrap: "truncate-end",
},
};
children.push(id);
}
elements["tile-label"] = { type: "Text", props: { text: " oldest unattended ", dimColor: true } };
elements["tile-age"] = {
type: "Text",
props: { text: oldestWaitingTs ? ago(oldestWaitingTs) : "—", color: oldestWaitingTs ? "yellow" : "gray" },
};
children.push("tile-label", "tile-age");
elements["tiles"] = { type: "Box", props: { flexDirection: "row", width, paddingX: 1 }, children };
return { root: "tiles", elements };
}
1 change: 1 addition & 0 deletions implementations/cli/src/console/ui/Help.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function Help({
["n", "toggle needs-you rail"],
["d", "direct-message lens"],
["t", "topology lens (v / 1-3 variants)"],
["V", "views lens (peer-pushed views)"],
[":", "command palette (send / call / ask)"],
["c", "compose to channel / DM selected agent"],
["r", "reply to current message"],
Expand Down
26 changes: 26 additions & 0 deletions implementations/cli/src/console/ui/Sparkline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Text } from "ink";

const BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];

/** A compact, self-scaling unicode sparkline. Renders the most-recent `width` values as block
* bars ▁▂▃▄▅▆▇█, each scaled to the series' own max (so a quiet mesh and a busy one both fill
* the bars). Fixed width — it shows only the most recent buckets, never grows with the terminal. */
export function Sparkline({
values,
width = 15,
color = "cyan",
}: {
values: number[];
width?: number;
color?: string;
}) {
// Most-recent `width` buckets, left-padded with zeros so the bar is always full-width. A
// non-finite or negative value would poison the max and skew every bar — clamp, don't trust.
const recent = values.slice(-width).map((v) => (Number.isFinite(v) && v > 0 ? v : 0));
const padded = recent.length < width ? [...new Array(width - recent.length).fill(0), ...recent] : recent;
const max = Math.max(0, ...padded);
const spark = padded
.map((v) => BARS[max <= 0 ? 0 : Math.min(BARS.length - 1, Math.round((v / max) * (BARS.length - 1)))])
.join("");
return <Text color={color}>{spark}</Text>;
}
25 changes: 17 additions & 8 deletions implementations/cli/src/console/ui/StatusBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Box, Text } from "ink";
import type { MeshState } from "../mesh.js";
import { Sparkline } from "./Sparkline.js";

/** Bottom bar: connection + space + active channel + msgs/s, then context keybindings. */
export function StatusBar({
Expand All @@ -17,7 +18,7 @@ export function StatusBar({
rates: MeshState["rates"];
activeChannel: string;
agentCount: number;
mode: "normal" | "dm" | "topo";
mode: "normal" | "dm" | "topo" | "views";
railOpen: boolean;
canBack?: boolean;
canWrite?: boolean;
Expand All @@ -28,20 +29,28 @@ export function StatusBar({
? "j/k scroll · ←→ pane · esc back · / search · ? help · q quit"
: mode === "topo"
? "v / 1-3 variant · j/k h/l move · Enter detail · esc back · ? help · q quit"
: (canBack ? "esc back · " : "") +
": cmd · j/k select · Enter detail · " +
(railOpen ? "n hide-rail" : "n needs-you") +
" · d DMs" +
(canWrite ? " · c compose · D kill" : "") +
" · / search · [ ] chan · ? help · q quit";
: mode === "views"
? "V/esc back · ? help · q quit"
: (canBack ? "esc back · " : "") +
": cmd · j/k select · Enter detail · " +
(railOpen ? "n hide-rail" : "n needs-you") +
" · d DMs · V views" +
(canWrite ? " · c compose · D kill" : "") +
" · / search · [ ] chan · ? help · q quit";
return (
<Box width={width} paddingX={1}>
<Text wrap="truncate-end">
<Text color={status.connected ? "green" : "red"}>{status.connected ? "● " : "⨯ "}</Text>
<Text dimColor>
{status.space + " · #" + activeChannel + " · " + agentCount + " agents · " +
rates.msgsPerSec.toFixed(1) + " msg/s"}
rates.msgsPerSec.toFixed(1) + " msg/s "}
</Text>
{rates.activity ? (
<>
<Sparkline values={rates.activity} />
<Text dimColor>{" 60s"}</Text>
</>
) : null}
{status.dmVisible ? null : <Text color="yellow">{" chat-only"}</Text>}
{canWrite ? null : <Text color="yellow">{" read-only"}</Text>}
{status.error ? (
Expand Down
32 changes: 0 additions & 32 deletions implementations/cli/src/console/ui/Tiles.tsx

This file was deleted.

Loading
Loading