pi-onlyne 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,11 @@ ordinary pi session: the plugin registers nothing and opens nothing.
17
17
  Only the session whose own id equals `ONLYNE_SESSION_ID` mounts. A pi process runs more
18
18
  than one session — a `task` subagent's sidechain, `/new` — and each of them loads this
19
19
  extension with the same inherited environment, so a session that is not the bound one
20
- stays inert and every onlyne tool it calls is refused.
20
+ stays inert and every onlyne tool it calls is refused. In resident mode (`ONLYNE_ROLE` or
21
+ `.pi/onlyne.json` `"resident": true` / `.onlyne/config.toml` `placement = "external"`), the
22
+ extension also tracks top-level `session_switch`, `session_branch`, and `session_tree`
23
+ events so `/resume`, `/new`, and context rollover keep the tools bound to the active
24
+ top-level session.
21
25
 
22
26
  ```
23
27
  pi session (spawned by onlyne-client)
@@ -30,14 +34,17 @@ hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{r
30
34
  ├─ report.ready ──► the barrier the task payload waits behind
31
35
  ◀── assign{envelope, prose, text, attachments, task_id, generation}
32
36
  ├─ prose, when the welcome did not already deliver it ──► the same section
33
- ├─ text ──► pi user message (deliverAs:"followUp"), byte for byte; `body.image`
34
- │ rides as a pi image part, and `attachments` names files the client wrote
37
+ ├─ text ──► one `onlyne_assign` card (`From <sender> · <kind> · <id>`), byte for byte:
38
+ │ `triggerTurn` starts the turn an idle session owes it and `deliverAs:"followUp"` queues
39
+ │ while busy, so the delivery is one message and never also a user prompt; a pi without
40
+ │ `sendMessage` takes it as a plain user message instead. `body.image` rides as a pi image
41
+ │ part, and `attachments` names files the client wrote
35
42
  ├─ assign_ack{accepted:true}
36
43
  ├─ report.heartbeat{agent} — `running` per turn and every 10s while a task is live,
37
44
  │ `idle` only while pi waits for input; every beat re-derives it from pi
38
45
  ├─ the client owns the turn-end rule: a turn that ends without a completion
39
46
  │ earns one nudge, and the second such ending settles the delivery
40
- ◀── nudge{task_id, text} ──► pi user message, byte for byte (the client's sentence)
47
+ ◀── nudge{task_id, text} ──► the same card route, byte for byte (the client's sentence)
41
48
  ├─ report.complete{outcome, head, details, files} — the ledger's terminal fact, and the last report
42
49
  │ └─ the client's answer is the handover: pi is asked to shut down, then detaches
43
50
  ├─ probe ──► one heartbeat
@@ -150,7 +157,7 @@ What happens when a pi API is missing, and what the host does then:
150
157
 
151
158
  ### Activity panel
152
159
 
153
- When the host reports a UI (`ctx.hasUI`, true in the TUI and RPC modes, false in print and JSON modes) and `ctx.ui.setWidget` is available, routine onlyne notices draw in the panel above the editor with widget key `onlyne`. The header shows role, connection state, generation, the current task id, and phase. Below it, up to six newest-first events use `<=` for inbound frames, `=>` for outbound frames, `!!` for warnings, `..` for state changes, and `~~` for duplicate deliveries. Repeated identical events fold into one line with `xN`; the panel holds at most eight lines, each capped at 96 cells, and `session_shutdown` clears it.
160
+ When the host reports a UI (`ctx.hasUI`, true in the TUI and RPC modes, false in print and JSON modes) and `ctx.ui.setWidget` is available, routine onlyne notices draw in the panel above the editor with widget key `onlyne`. The widget registers a dual-surface component: on ANSI terminals it renders theme-styled lines (`accent` role badge, status-toned connection state, dimmed timestamps, and semantic event markers), and on Tern Surface Protocol (TSP) hosts it implements `describe()` to emit a native collapsible `card` (`role: "omp.widget.onlyne"`, `variant: "bare"`) with structured spans. An inbound delivery (`assign` and `nudge`) travels as the visible `onlyne_assign` custom message card (`From <sender> · <kind> · <id>`) that `registerMessageRenderer("onlyne_assign", ...)` draws: it reaches the model as that one message, so a delivery is never both drawn twice and read twice. `wakeUser` passes `{ triggerTurn: true }` when `ctx.isIdle()` is true, which is what starts the turn an empty or settled resident session owes the card (and `{ deliverAs: "followUp" }` while busy). The header shows role, connection state, generation, the current task id, and phase. Below it, up to six newest-first events use `<=` for inbound frames, `=>` for outbound frames, `!!` for warnings, `..` for state changes, and `~~` for duplicate deliveries. Repeated identical events fold into one line with `xN` (and pre-connection resident polling stays quiet until a socket connects); the panel holds at most eight lines, each capped at 96 cells, and `session_shutdown` clears it.
154
161
 
155
162
  ## 3. Tools
156
163
 
@@ -172,8 +179,10 @@ connected.
172
179
 
173
180
  ### `onlyne_send{to, text, kind?, image?}`
174
181
 
175
- Sends one envelope on the `send` frame. `kind: "note"` (the default) is free text and
176
- carries no `op_id`. `kind: "task"` hands work to a role, so it carries an `o-<uuid>`
182
+ Sends one envelope on the `send` frame. `kind: "note"` (the default) sends stateless
183
+ conversational text to a working, idle, or parked resident session, carries no `op_id`,
184
+ opens no task record, and requires no `onlyne_complete`. `kind: "task"` hands tracked work
185
+ to a role, so it carries an `o-<uuid>`
177
186
  idempotency key and a fresh `causality.task`. `image` is an absolute path to a
178
187
  png/jpeg/gif/webp file: the plugin reads it, base64-encodes it and attaches it as
179
188
  `body.image`. The core caps that at 2 MiB and accepts four mime types. The result is
package/README.zh.md CHANGED
@@ -12,7 +12,10 @@ socket——机器级运行目录里的 `<digest>.sock`(`/tmp/onlyne-<uid>/`
12
12
 
13
13
  只有自身 session id 等于 `ONLYNE_SESSION_ID` 的那个 session 才会挂载。一个 pi 进程会跑不止一个
14
14
  session——`task` 子代理的 sidechain、`/new`——它们都会带着同一份继承来的环境变量加载本扩展;
15
- 因此不是被绑定 session 的那个保持静默,它发出的每一次 onlyne 工具调用都会被拒绝。
15
+ 因此不是被绑定 session 的那个保持静默,它发出的每一次 onlyne 工具调用都会被拒绝。在常驻模式
16
+ (`ONLYNE_ROLE`、`.pi/onlyne.json` `"resident": true` 或 `.onlyne/config.toml`
17
+ `placement = "external"`)下,扩展同时监听顶层 `session_switch`、`session_branch` 与
18
+ `session_tree` 事件,使 `/resume`、`/new` 及上下文切换后工具自动跟随当前顶层会话。
16
19
 
17
20
  ```
18
21
  pi session(由 onlyne-client spawn)
@@ -25,8 +28,11 @@ hello{protocol:1, plugin:"pi-onlyne", kind:"agent", capabilities:[…], mount:{r
25
28
  ├─ report.ready ──► 载荷等待的那道 barrier
26
29
  ◀── assign{envelope, prose, text, attachments, task_id, generation}
27
30
  ├─ prose(仅当 welcome 尚未投递过) ──► 同一个 section
28
- ├─ text ──► pi user message(deliverAs:"followUp"),逐字节原样;`body.image`
29
- │ 作为 pi image part 同行,`attachments` 里的路径是 client 已写好的文件
31
+ ├─ text ──► 一张 `onlyne_assign` 卡片(`From <sender> · <kind> · <id>`),逐字节原样:
32
+ │ `triggerTurn` 让空闲会话就此起一个 turn,繁忙时改 `deliverAs:"followUp"` 排队,
33
+ │ 于是投递只有这一条消息、不会同时再是一条 user prompt;没有 `sendMessage` 的 pi 退回
34
+ │ 普通 user message。`body.image` 作为 pi image part 同行,`attachments` 里的路径是
35
+ │ client 已写好的文件
30
36
  ├─ assign_ack{accepted:true}
31
37
  ├─ report.heartbeat{agent} —— 每个 turn 以及任务存续期间每 10 秒发 `running`,
32
38
  │ 只有 pi 等待输入时才发 `idle`;每次心跳都重新向 pi 推导
@@ -139,7 +145,7 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
139
145
 
140
146
  ### 活动面板
141
147
 
142
- 宿主报告有 UI 时(`ctx.hasUI`:TUI 与 RPC 模式为 true,print 与 JSON 模式为 false)且 `ctx.ui.setWidget` 可用,日常 onlyne 通知显示在编辑器上方,widget key 为 `onlyne`。标题行显示 role、连接状态、generation、当前 task id 与阶段。其下最多六条事件,按最新在前排列:`<=` 入站,`=>` 出站,`!!` 警告,`..` 状态,`~~` 重复投递。连续相同事件折成一行并带 `xN`;面板最多八行,每行最多 96 个显示单元,`session_shutdown` 时清除。
148
+ 宿主报告有 UI 时(`ctx.hasUI`:TUI 与 RPC 模式为 true,print 与 JSON 模式为 false)且 `ctx.ui.setWidget` 可用,日常 onlyne 通知显示在编辑器上方,widget key 为 `onlyne`。小组件支持 ANSI 与 Tern Surface Protocol(TSP)双渲染面:在 ANSI 终端下使用主题令牌为标题、连接状态、时间戳和方向标记着色排版;在支持 TSP 的 Tern 终端下通过 `describe()` 输出原生可折叠 `card` 组件(`role: "omp.widget.onlyne"`,`variant: "bare"`)与语义化 span。入站投递(`assign` 与 `nudge`)走的就是那张带 `From <sender> · <kind> · <id>` 抬头的 `onlyne_assign` 卡片:`wakeUser` 用 `pi.sendMessage({ customType: "onlyne_assign", display: true, details })` 发出它,`registerMessageRenderer("onlyne_assign", ...)` 画它,模型读到的也正是这一条——投递不会被画两遍又读两遍。`ctx.isIdle()` 为真时 `wakeUser` 传 `{ triggerTurn: true }`,把空闲或已结算的常驻会话欠这张卡片的那个 turn 起起来;繁忙时改传 `{ deliverAs: "followUp" }` 排队。标题行显示 role、连接状态、generation、当前 task id 与阶段。其下最多六条事件,按最新在前排列:`<=` 入站,`=>` 出站,`!!` 警告,`..` 状态,`~~` 重复投递。连续相同事件折成一行并带 `xN`(常驻会话在客户端首次连通前的后台探测保持静默,不刷屏 `reconnecting`);面板最多八行,每行最多 96 个显示单元,`session_shutdown` 时清除。
143
149
 
144
150
  ## 3. 工具面
145
151
 
@@ -157,7 +163,8 @@ sidechain、`/new`——它们都会带着同一份继承来的环境变量加
157
163
 
158
164
  ### `onlyne_send{to, text, kind?, image?}`
159
165
 
160
- 经 `send` 帧提交一个 envelope。`kind: "note"`(默认)是自由文本,不带 `op_id`。
166
+ 经 `send` 帧提交一个 envelope。`kind: "note"`(默认)向运行中、空闲或常驻待命的会话发送无状态交流消息,
167
+ 不带 `op_id`,不创建任务记录,也不要求调用 `onlyne_complete`。
161
168
  `kind: "task"` 是派人办事,因此带 `o-<uuid>` 幂等键和新生成的 `causality.task`。`image` 是
162
169
  png/jpeg/gif/webp 的绝对路径:插件读出内容,base64 编码后挂成 `body.image`。核心限 2 MiB,
163
170
  只收四种 mime。结果是 `sent to <role>`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/activity.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  /** The widget key shared with pi-surface. */
2
2
  export const WIDGET_KEY = "onlyne";
3
+ /** The custom message type registered for visible inbound delivery cards. */
4
+ export const ASSIGN_MESSAGE_TYPE = "onlyne_assign";
3
5
  /** Render budget below pi's own ten-line cap. */
4
6
  export const MAX_LINES = 8;
5
7
  /** Line budget that keeps the panel inside one terminal row. */
@@ -19,6 +21,21 @@ const MARKERS = {
19
21
  state: "..",
20
22
  };
21
23
 
24
+ const EVENT_STYLES = {
25
+ in: { fg: "accent" },
26
+ dup: { fg: "dim" },
27
+ out: { fg: "success" },
28
+ warn: { fg: "warning" },
29
+ state: { fg: "muted" },
30
+ };
31
+
32
+ const CONNECTION_STYLES = {
33
+ connected: { fg: "success", status: "done" },
34
+ reconnecting: { fg: "warning", status: "running" },
35
+ detached: { fg: "muted", status: "cancelled" },
36
+ connecting: { fg: "accent", status: "pending" },
37
+ };
38
+
22
39
  const clean = (value) => String(value ?? "").replace(/\s+/g, " ").trim();
23
40
  const points = (value) => Array.from(String(value ?? ""));
24
41
 
@@ -62,6 +79,237 @@ function eventLine(event) {
62
79
  return cut(`${timeOf(event.at)} ${marker} ${event.text}${count}`, MAX_WIDTH);
63
80
  }
64
81
 
82
+ function toneFg(theme, color, text) {
83
+ if (!theme || typeof theme.fg !== "function") return text;
84
+ try {
85
+ return theme.fg(color, text);
86
+ } catch {
87
+ return text;
88
+ }
89
+ }
90
+
91
+ function toneBold(theme, text) {
92
+ if (!theme || typeof theme.bold !== "function") return text;
93
+ try {
94
+ return theme.bold(text);
95
+ } catch {
96
+ return text;
97
+ }
98
+ }
99
+
100
+ function styledHeaderLine(state, theme, width) {
101
+ if (!theme || typeof theme.fg !== "function") return headerLine(state);
102
+ const role = clean(state.role);
103
+ const connection = clean(state.connection) || "connecting";
104
+ const connStyle = CONNECTION_STYLES[connection] ?? CONNECTION_STYLES.connecting;
105
+ const sep = toneFg(theme, "dim", " · ");
106
+ const head = role
107
+ ? `${toneBold(theme, toneFg(theme, "accent", "onlyne"))} ${toneBold(theme, cut(role, 16, false))}`
108
+ : toneBold(theme, toneFg(theme, "accent", "onlyne"));
109
+ const parts = [head, toneFg(theme, connStyle.fg, connection)];
110
+ if (state.generation !== undefined && state.generation !== null) {
111
+ parts.push(toneFg(theme, "muted", `gen ${state.generation}`));
112
+ }
113
+ const taskId = clean(state.taskId);
114
+ if (taskId) {
115
+ const phase = clean(state.phase);
116
+ const phaseTone = phase === "done"
117
+ ? "success"
118
+ : phase === "failed" || phase === "blocked"
119
+ ? "error"
120
+ : phase.includes("queued") || phase === "running"
121
+ ? "warning"
122
+ : "muted";
123
+ const taskPart = `${toneFg(theme, "muted", "task")} ${toneFg(theme, "accent", cut(taskId, 8, false))}${
124
+ phase ? ` ${toneFg(theme, phaseTone, phase)}` : ""
125
+ }`;
126
+ parts.push(taskPart);
127
+ }
128
+ return parts.join(sep);
129
+ }
130
+
131
+ function styledEventLine(event, theme, maxTextWidth) {
132
+ if (!theme || typeof theme.fg !== "function") return eventLine(event);
133
+ const marker = MARKERS[event.kind] ?? MARKERS.state;
134
+ const style = EVENT_STYLES[event.kind] ?? EVENT_STYLES.state;
135
+ const count = event.repeats > 0 ? toneFg(theme, "warning", ` ×${event.repeats + 1}`) : "";
136
+ const bodyBudget = Math.max(12, maxTextWidth - 14 - (event.repeats > 0 ? String(event.repeats + 1).length + 2 : 0));
137
+ const body = cut(event.text, bodyBudget);
138
+ const bodyStyled = event.kind === "warn"
139
+ ? toneFg(theme, "warning", body)
140
+ : event.kind === "dup"
141
+ ? toneFg(theme, "dim", body)
142
+ : body;
143
+ return `${toneFg(theme, "dim", timeOf(event.at))} ${toneFg(theme, style.fg, marker)} ${bodyStyled}${count}`;
144
+ }
145
+
146
+ function describeHeaderSpans(state) {
147
+ const role = clean(state.role);
148
+ const connection = clean(state.connection) || "connecting";
149
+ const connStyle = CONNECTION_STYLES[connection] ?? CONNECTION_STYLES.connecting;
150
+ const spans = [{ t: "onlyne", s: "accent strong" }];
151
+ if (role) spans.push({ t: ` ${cut(role, 16, false)}`, s: "strong" });
152
+ spans.push({ t: " · ", s: "dim" }, { t: connection, s: connStyle.fg });
153
+ if (state.generation !== undefined && state.generation !== null) {
154
+ spans.push({ t: " · ", s: "dim" }, { t: `gen ${state.generation}`, s: "muted" });
155
+ }
156
+ const taskId = clean(state.taskId);
157
+ if (taskId) {
158
+ const phase = clean(state.phase);
159
+ const phaseTone = phase === "done"
160
+ ? "success"
161
+ : phase === "failed" || phase === "blocked"
162
+ ? "error"
163
+ : phase.includes("queued") || phase === "running"
164
+ ? "warning"
165
+ : "muted";
166
+ spans.push(
167
+ { t: " · ", s: "dim" },
168
+ { t: "task ", s: "muted" },
169
+ { t: cut(taskId, 8, false), s: "accent mono" },
170
+ );
171
+ if (phase) spans.push({ t: ` ${phase}`, s: phaseTone });
172
+ }
173
+ return spans;
174
+ }
175
+
176
+ function describeEventRow(event, index) {
177
+ const marker = MARKERS[event.kind] ?? MARKERS.state;
178
+ const style = EVENT_STYLES[event.kind] ?? EVENT_STYLES.state;
179
+ const bodyTone = event.kind === "warn" ? "warning" : event.kind === "dup" ? "dim" : undefined;
180
+ const spans = [
181
+ { t: timeOf(event.at), s: "dim mono" },
182
+ { t: ` ${marker} `, s: `${style.fg} mono strong` },
183
+ bodyTone ? { t: event.text, s: bodyTone } : { t: event.text },
184
+ ];
185
+ if (event.repeats > 0) {
186
+ spans.push({ t: ` ×${event.repeats + 1}`, s: "warning mono" });
187
+ }
188
+ return {
189
+ k: "text",
190
+ p: { spans, truncate: "end", role: `onlyne.event.${event.kind}` },
191
+ key: `ev-${index}`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Build a TSP `NativeNode` card for Tern surfaces (`Component.describe()`).
197
+ */
198
+ export function describeActivityNode(state, events, shown = SHOW_EVENTS, collapsed = false) {
199
+ const connection = clean(state.connection) || "connecting";
200
+ const connStyle = CONNECTION_STYLES[connection] ?? CONNECTION_STYLES.connecting;
201
+ const visible = events.slice(0, shown);
202
+ return {
203
+ k: "card",
204
+ p: {
205
+ role: "omp.widget.onlyne",
206
+ variant: "bare",
207
+ status: connStyle.status,
208
+ head: describeHeaderSpans(state),
209
+ collapsible: visible.length > 0,
210
+ collapsed: visible.length > 0 ? Boolean(collapsed) : false,
211
+ },
212
+ c: visible.map((ev, index) => describeEventRow(ev, index)),
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Render an inbound Onlyne delivery (`customType: "onlyne_assign"`) as a
218
+ * framed transcript block on ANSI terminals and a native TSP card on Tern.
219
+ */
220
+ export function createAssignMessageComponent(details, messageContent, theme, collapsed = false) {
221
+ const from = clean(details?.from) || "cluster";
222
+ const kind = clean(details?.kind) || "task";
223
+ const taskId = clean(details?.taskId);
224
+ const shortId = taskId ? cut(taskId, 8, false) : "";
225
+ const rawBody = clean(details?.bodyText)
226
+ || (typeof messageContent === "string"
227
+ ? messageContent
228
+ : Array.isArray(messageContent)
229
+ ? messageContent.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n")
230
+ : "");
231
+ const bodyLines = String(details?.bodyText ?? (typeof messageContent === "string" ? messageContent : rawBody))
232
+ .replace(/\r\n/g, "\n")
233
+ .split("\n");
234
+ const headLabel = `From ${from} · ${kind}${shortId ? ` · ${shortId}` : ""}`;
235
+ const kindTone = kind === "note" ? "info" : kind === "nudge" ? "warning" : "accent";
236
+ let cachedWidth = -1;
237
+ let cachedRows = null;
238
+ let cachedNode = null;
239
+ let isCollapsed = Boolean(collapsed);
240
+
241
+ const buildNode = () => {
242
+ const headSpans = [
243
+ { t: `From ${from}`, s: "accent strong" },
244
+ { t: " · ", s: "dim" },
245
+ { t: kind, s: `${kindTone} mono` },
246
+ ];
247
+ if (shortId) {
248
+ headSpans.push({ t: " · ", s: "dim" }, { t: shortId, s: "muted mono" });
249
+ }
250
+ const mdText = String(details?.bodyText ?? (typeof messageContent === "string" ? messageContent : rawBody)).trim();
251
+ return {
252
+ k: "card",
253
+ p: {
254
+ role: `omp.custom.onlyne.${kind}`,
255
+ tone: kind === "nudge" ? "warning" : "accent",
256
+ head: headSpans,
257
+ collapsible: true,
258
+ collapsed: isCollapsed,
259
+ },
260
+ c: mdText ? [{ k: "md", p: { text: mdText }, key: "body" }] : [],
261
+ };
262
+ };
263
+
264
+ const buildRows = (width = MAX_WIDTH) => {
265
+ const w = Math.max(16, Math.min(width, MAX_WIDTH));
266
+ const inner = Math.max(12, w - 4);
267
+ const topText = ` ${cut(headLabel, inner - 2)} `;
268
+ const topFill = "─".repeat(Math.max(0, w - 2 - points(topText).length));
269
+ const rows = [
270
+ `${toneFg(theme, "muted", "╭")}${toneBold(theme, toneFg(theme, "accent", topText))}${toneFg(theme, "muted", `${topFill}╮`)}`,
271
+ ];
272
+ const visibleBody = isCollapsed
273
+ ? [cut(clean(rawBody), inner)]
274
+ : bodyLines.flatMap((line) => {
275
+ const trimmed = line.replace(/\t/g, " ");
276
+ return trimmed.length === 0 ? [""] : [cut(trimmed, inner)];
277
+ });
278
+ for (const line of visibleBody) {
279
+ const pad = " ".repeat(Math.max(0, inner - points(line).length));
280
+ rows.push(`${toneFg(theme, "muted", "│")} ${toneFg(theme, "text", line)}${pad} ${toneFg(theme, "muted", "│")}`);
281
+ }
282
+ rows.push(`${toneFg(theme, "muted", `╰${"─".repeat(Math.max(0, w - 2))}╯`)}`);
283
+ return rows;
284
+ };
285
+
286
+ return {
287
+ render(width = MAX_WIDTH) {
288
+ if (cachedRows && cachedWidth === width) return cachedRows;
289
+ cachedWidth = width;
290
+ cachedRows = buildRows(width);
291
+ return cachedRows;
292
+ },
293
+ describe() {
294
+ cachedNode ??= buildNode();
295
+ return cachedNode;
296
+ },
297
+ handleNativeEvent(event) {
298
+ if (event?.type === "toggle" && typeof event.collapsed === "boolean") {
299
+ isCollapsed = event.collapsed;
300
+ cachedRows = null;
301
+ cachedNode = null;
302
+ }
303
+ },
304
+ invalidate() {
305
+ cachedWidth = -1;
306
+ cachedRows = null;
307
+ cachedNode = null;
308
+ },
309
+ dispose() {},
310
+ };
311
+ }
312
+
65
313
  /**
66
314
  * Activity is a pure panel model: header state plus newest-first event history.
67
315
  * `lines()` reads stored state only, so identical state gives identical output.
@@ -70,6 +318,7 @@ export function createActivity({ maxEvents = SHOW_EVENTS, clock = () => new Date
70
318
  const state = { connection: "connecting" };
71
319
  const events = [];
72
320
  const shown = Math.max(0, Math.min(Number(maxEvents) || 0, MAX_LINES - 1));
321
+ let collapsed = false;
73
322
  const api = {
74
323
  note(kind, text) {
75
324
  const next = noteText(text);
@@ -90,6 +339,51 @@ export function createActivity({ maxEvents = SHOW_EVENTS, clock = () => new Date
90
339
  lines() {
91
340
  return [headerLine(state), ...events.slice(0, shown).map(eventLine)].slice(0, MAX_LINES);
92
341
  },
342
+ ansiLines(theme, width = MAX_WIDTH) {
343
+ const padX = width >= 4 ? " " : "";
344
+ const innerWidth = Math.max(24, Math.min(MAX_WIDTH, width - padX.length * 2));
345
+ if (collapsed) {
346
+ return [`${padX}${styledHeaderLine(state, theme, innerWidth)}`];
347
+ }
348
+ return [
349
+ `${padX}${styledHeaderLine(state, theme, innerWidth)}`,
350
+ ...events.slice(0, shown).map((ev) => `${padX}${styledEventLine(ev, theme, innerWidth)}`),
351
+ ].slice(0, MAX_LINES);
352
+ },
353
+ describe() {
354
+ return describeActivityNode(state, events, shown, collapsed);
355
+ },
356
+ component(theme, tui = null) {
357
+ let cachedWidth = -1;
358
+ let cachedRows = null;
359
+ let cachedNode = null;
360
+ return {
361
+ render(width = MAX_WIDTH) {
362
+ if (cachedRows && cachedWidth === width) return cachedRows;
363
+ cachedWidth = width;
364
+ cachedRows = api.ansiLines(theme, width);
365
+ return cachedRows;
366
+ },
367
+ describe() {
368
+ cachedNode ??= api.describe();
369
+ return cachedNode;
370
+ },
371
+ handleNativeEvent(event) {
372
+ if (event?.type === "toggle" && typeof event.collapsed === "boolean") {
373
+ collapsed = event.collapsed;
374
+ cachedRows = null;
375
+ cachedNode = null;
376
+ tui?.requestRender?.();
377
+ }
378
+ },
379
+ invalidate() {
380
+ cachedWidth = -1;
381
+ cachedRows = null;
382
+ cachedNode = null;
383
+ },
384
+ dispose() {},
385
+ };
386
+ },
93
387
  events,
94
388
  };
95
389
  return api;
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
- import { createActivity, MAX_LINES, MAX_TEXT, MAX_WIDTH, SHOW_EVENTS } from "./activity.mjs";
4
+ import { createActivity, createAssignMessageComponent, MAX_LINES, MAX_TEXT, MAX_WIDTH, SHOW_EVENTS } from "./activity.mjs";
5
5
 
6
6
  const clock = () => new Date("2026-09-13T09:08:07");
7
7
 
@@ -61,3 +61,71 @@ test("default render shows the newest SHOW_EVENTS events", () => {
61
61
  assert.match(lines[1], /event 9$/);
62
62
  assert.match(lines.at(-1), new RegExp(`event ${SHOW_EVENTS + 4 - SHOW_EVENTS}$`));
63
63
  });
64
+
65
+ test("describe() produces a Tern TSP card node and component() supports toggle", () => {
66
+ const activity = createActivity({ clock }).set({
67
+ role: "dpdp",
68
+ connection: "connected",
69
+ generation: 1,
70
+ taskId: "abcdef012345",
71
+ phase: "running",
72
+ });
73
+ activity.note("state", "connected role=dpdp gen=1 host=probe,recycle");
74
+ activity.note("warn", "heartbeat refused").note("warn", "heartbeat refused");
75
+
76
+ const node = activity.describe();
77
+ assert.equal(node.k, "card");
78
+ assert.equal(node.p.role, "omp.widget.onlyne");
79
+ assert.equal(node.p.variant, "bare");
80
+ assert.equal(node.p.status, "done");
81
+ assert.equal(node.p.collapsible, true);
82
+ assert.equal(node.p.collapsed, false);
83
+ assert.equal(node.c.length, 2);
84
+ assert.equal(node.c[0].k, "text");
85
+ assert.equal(node.c[0].p.role, "onlyne.event.warn");
86
+ assert.ok(node.c[0].p.spans.some((s) => s.t === " ×2"));
87
+
88
+ const theme = {
89
+ fg: (color, text) => `<${color}>${text}</${color}>`,
90
+ bold: (text) => `<b>${text}</b>`,
91
+ };
92
+ let rendered = 0;
93
+ const comp = activity.component(theme, { requestRender: () => { rendered += 1; } });
94
+ const rows = comp.render(80);
95
+ assert.equal(rows.length, 3);
96
+ assert.ok(rows[0].startsWith(" <b><accent>onlyne</accent></b> <b>dpdp</b>"));
97
+ assert.equal(comp.render(80), rows, "same width returns cached rows reference");
98
+
99
+ comp.handleNativeEvent({ type: "toggle", collapsed: true });
100
+ assert.equal(rendered, 1);
101
+ assert.equal(comp.describe().p.collapsed, true);
102
+ assert.equal(comp.render(80).length, 1);
103
+ });
104
+
105
+ test("createAssignMessageComponent renders ANSI box and TSP card for onlyne_assign", () => {
106
+ const theme = {
107
+ fg: (color, text) => `<${color}>${text}</${color}>`,
108
+ bold: (text) => `<b>${text}</b>`,
109
+ };
110
+ const comp = createAssignMessageComponent(
111
+ {
112
+ role: "caerulea",
113
+ taskId: "97971b93-1111-2222-3333-444444444444",
114
+ kind: "note",
115
+ from: "role:_supervisor",
116
+ bodyText: "hello caerulea",
117
+ },
118
+ "From _supervisor:\n\nhello caerulea",
119
+ theme,
120
+ false,
121
+ );
122
+ const rows = comp.render(72);
123
+ assert.ok(rows[0].includes("From role:_supervisor · note · 97971b93"));
124
+ assert.ok(rows.some((r) => r.includes("hello caerulea")));
125
+
126
+ const node = comp.describe();
127
+ assert.equal(node.k, "card");
128
+ assert.equal(node.p.role, "omp.custom.onlyne.note");
129
+ assert.equal(node.c[0].k, "md");
130
+ assert.equal(node.c[0].p.text, "hello caerulea");
131
+ });