pi-onlyne 2.2.0 → 2.4.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
@@ -114,6 +121,9 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
114
121
  | --- | --- | --- |
115
122
  | `enabled` | `true` | `false` turns the extension off for this workspace |
116
123
  | `watch.autoStart` | `true` | `false` registers the tools but opens no socket until `/onlyne connect` |
124
+ | `role` | — | the role this session serves when the client injected no `ONLYNE_ROLE`; also what makes the file a resident opt-in |
125
+ | `workspace` | — | the role workspace, when the session did not start inside it; a relative path resolves against the pi working directory |
126
+ | `cluster` | — | the cluster whose client this session dials — the serving server's `[server] name`; unset means the tree decides |
117
127
 
118
128
  A missing file means every default. A malformed file prints one warning on stderr and
119
129
  keeps the defaults: a typo must not silently disable a role. The client does not read
@@ -121,6 +131,13 @@ this file (§11 of the plan downgraded the old readiness gates to generate-time
121
131
  advice), so only this extension consumes it; the key shape stays the one the templates
122
132
  carry.
123
133
 
134
+ `cluster` is the answer for a session whose body runs in one cluster's tree while the
135
+ role workspace belongs to another: with the key set, the client registered for this role
136
+ in that cluster is dialed first, whatever tree the session runs in. A cluster holding two
137
+ clients of the role is refused by name rather than picked from. With the key absent — or
138
+ naming a cluster no client has registered — resolution falls back to the tree, exactly as
139
+ it did before the key existed.
140
+
124
141
  Nothing else is needed. The workspace's `session_command` in `spec.toml` already spawns
125
142
  `pi` per task (`["pi", "--session-id", "{session}"]`), and the client injects the
126
143
  environment this extension keys on.
@@ -150,7 +167,7 @@ What happens when a pi API is missing, and what the host does then:
150
167
 
151
168
  ### Activity panel
152
169
 
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.
170
+ 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
171
 
155
172
  ## 3. Tools
156
173
 
@@ -172,8 +189,10 @@ connected.
172
189
 
173
190
  ### `onlyne_send{to, text, kind?, image?}`
174
191
 
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>`
192
+ Sends one envelope on the `send` frame. `kind: "note"` (the default) sends stateless
193
+ conversational text to a working, idle, or parked resident session, carries no `op_id`,
194
+ opens no task record, and requires no `onlyne_complete`. `kind: "task"` hands tracked work
195
+ to a role, so it carries an `o-<uuid>`
177
196
  idempotency key and a fresh `causality.task`. `image` is an absolute path to a
178
197
  png/jpeg/gif/webp file: the plugin reads it, base64-encodes it and attaches it as
179
198
  `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 推导
@@ -106,6 +112,8 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
106
112
  | --- | --- | --- |
107
113
  | `enabled` | `true` | `false` 时该工作区禁用扩展 |
108
114
  | `watch.autoStart` | `true` | `false` 时注册工具但不建连接,需 `/onlyne connect` |
115
+ | `workspace` | — | 角色工作区路径(当会话未在其中启动时生效;相对路径相对 pi 工作目录解析) |
116
+ | `cluster` | — | 本会话要连接的集群名(对应服务端的 `[server] name`;未设时由工作区目录树决定) |
109
117
 
110
118
  文件缺失即取各自默认值。文件格式错误时打印一行警告,并保留默认值:一个笔误不该静默关掉一个
111
119
  role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为 generate 期模板提示),所以
@@ -114,6 +122,11 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
114
122
  其余无需配置。工作区 `spec.toml` 的 `session_command` 已经按任务拉起 `pi`
115
123
  (`["pi", "--session-id", "{session}"]`),client 负责注入本扩展识别的环境变量。
116
124
 
125
+ `cluster` 专门解决“会话主体在一个集群的目录树下运行、但挂载的角色工作区属于另一个集群”的夹心代理场景:
126
+ 配置该键后,无论会话当前处于哪棵目录树下,都会优先连接该集群中为该角色注册的客户端。若同一集群内存在
127
+ 两个同名角色客户端,会直接显式报错拒绝,绝不随意选取。若未配置该键(或指定的集群未发现注册客户端),
128
+ 则优雅回退至原有的工作区目录树收敛规则。
129
+
117
130
  ## 2. 能力表
118
131
 
119
132
  `hello` 只声明真实实现的能力:
@@ -139,7 +152,7 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
139
152
 
140
153
  ### 活动面板
141
154
 
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` 时清除。
155
+ 宿主报告有 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
156
 
144
157
  ## 3. 工具面
145
158
 
@@ -157,7 +170,8 @@ sidechain、`/new`——它们都会带着同一份继承来的环境变量加
157
170
 
158
171
  ### `onlyne_send{to, text, kind?, image?}`
159
172
 
160
- 经 `send` 帧提交一个 envelope。`kind: "note"`(默认)是自由文本,不带 `op_id`。
173
+ 经 `send` 帧提交一个 envelope。`kind: "note"`(默认)向运行中、空闲或常驻待命的会话发送无状态交流消息,
174
+ 不带 `op_id`,不创建任务记录,也不要求调用 `onlyne_complete`。
161
175
  `kind: "task"` 是派人办事,因此带 `o-<uuid>` 幂等键和新生成的 `causality.task`。`image` 是
162
176
  png/jpeg/gif/webp 的绝对路径:插件读出内容,base64 编码后挂成 `body.image`。核心限 2 MiB,
163
177
  只收四种 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.4.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
+ });