lark-remote 0.5.10 → 0.5.12
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/CHANGELOG.md +38 -0
- package/dist/binder.js +7 -1
- package/dist/bridge/index.js +12 -3
- package/dist/card/approval-render.d.ts +14 -0
- package/dist/card/approval-render.js +155 -29
- package/dist/card/bash-card-session.js +8 -0
- package/dist/card/bash-renderer.js +11 -23
- package/dist/card/card-budget.js +24 -4
- package/dist/card/run-renderer.js +31 -9
- package/dist/common/text-width.js +5 -0
- package/dist/common/tool-summary.js +4 -2
- package/dist/config/codex-config.js +5 -3
- package/dist/config/index.js +13 -1
- package/dist/index.js +42 -24
- package/dist/instance-lock.d.ts +9 -1
- package/dist/instance-lock.js +42 -7
- package/dist/platform/identity.js +4 -3
- package/dist/platform/path.js +3 -1
- package/dist/platform/terminator-win32.d.ts +13 -1
- package/dist/platform/terminator-win32.js +4 -14
- package/dist/router/index.js +52 -10
- package/dist/runner/codex/app-server/runner.js +12 -0
- package/dist/runner/common/acp/base-translator.d.ts +12 -0
- package/dist/runner/common/acp/base-translator.js +22 -0
- package/dist/runner/common/jsonl-stream.js +12 -1
- package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
- package/dist/runner/common/jsonrpc/transport.js +7 -1
- package/dist/runner/dsh/approval.js +16 -3
- package/dist/runner/dsh/client.d.ts +31 -1
- package/dist/runner/dsh/client.js +126 -6
- package/dist/runner/dsh/exit-cancel.js +62 -23
- package/dist/runner/dsh/runner.d.ts +7 -0
- package/dist/runner/dsh/runner.js +42 -5
- package/dist/runner/kimi/acp/runner.js +42 -5
- package/dist/runner/kimi/acp/translator.d.ts +8 -0
- package/dist/runner/kimi/acp/translator.js +10 -0
- package/dist/runner/pi/rpc/runner.js +33 -5
- package/dist/runner/resolve-agent-choices.js +5 -1
- package/dist/runner/sync-agent-choices.js +4 -1
- package/dist/session/claude/session-index.js +9 -2
- package/dist/session/codex/rollout-reader.js +51 -26
- package/dist/session/common/usage-accumulator.d.ts +10 -1
- package/dist/session/common/usage-accumulator.js +10 -2
- package/dist/session/dsh/sessions.js +29 -9
- package/dist/session/kimi/sessions.js +9 -5
- package/dist/session/opencode/sessions.js +13 -6
- package/dist/session/session-store.js +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.12] - 2026-10-24
|
|
9
|
+
|
|
10
|
+
### 修复
|
|
11
|
+
|
|
12
|
+
- **Bash 工具结果不再偶发丢失**:kimi 的终端子进程退出后,runner 会等 stdout 尾部 flush(最多 200ms)再推 Bash 输出;但 `terminal/wait_for_exit` 此前在 flush 之前就返回,服务端拿到响应后立即 release 并结束回合,回合结果可能先于延迟推送的 tool_result 到达,卡片上的 Bash 面板变成空输出。现在 wait_for_exit 在 tool_result 入队后才返回,保证输出先于回合结束送达
|
|
13
|
+
|
|
14
|
+
### 维护
|
|
15
|
+
|
|
16
|
+
- **测试同步原语收紧**:pid 文件改为等完整内容再读(避免读到空串后把 0 当 PID 轮询进程组);kimi 压缩完成用例改由 mock server 在收到 prompt 后延迟落记录,消除并行负载下的假红。无行为变化
|
|
17
|
+
|
|
18
|
+
## [0.5.11] - 2026-10-24
|
|
19
|
+
|
|
20
|
+
### 修复
|
|
21
|
+
|
|
22
|
+
- **审批卡在超长或超量输入下不再整卡消失**:审批理由、题面、选项、文件变更等 agent 产出的文本此前没有上限,一条超长理由(实测 3 万字符)或 8 题 × 5 选项就能把卡片顶出字节/元素预算,被飞书整卡拒收——审批彻底不可见,回合只能挂到超时。现在自由文本按字节截断、题数/选项/变更条目按渲染上限折叠,最底层兜底卡改用紧凑审批区:审批按钮在任何降级档都保留
|
|
23
|
+
- **停止 bash 任务后卡片稳定显示「已手动终止」**:停止路径与自然收尾会并发写同一张卡的终态,后写者胜出;现在首个终态胜出,不再被随后的「执行失败」覆盖
|
|
24
|
+
- **内容审核截断后的自动续跑接回本轮会话**:续跑此前用入队时的会话快照,新会话第一条消息被截断时会落到一个全新的空会话,本轮上下文全部丢失;现在按卡片状态 → 运行中会话 → 起点快照三级取本轮实际使用的会话
|
|
25
|
+
- **DSH 停止后残留的排队 prompt 不再被下一个回合隐形执行**:服务端 cancel 不会清空 inbox,被停的 prompt 可能仍留在里面等下一个无人订阅的 turn 认领(耗 token、改工作区、卡片不可见)。现在 cancel 确认关闭后补一次撤回探测,确认不在或撤回成功才清在途标记;退出路径的 cancel 走同一收尾
|
|
26
|
+
- **DSH 断线重连后的历史缺口会回填**:重连快照按消息截取,断连期间积压超过 200 条消息时快照首条会越过上次游标,中间的运行事件此前对本次订阅永久缺失。现在用 `session/page` 的游标向前翻页补齐(最多 20×200 条);尾部是用户消息时事件探测返回 `unknown`,不再误报「已停止」
|
|
27
|
+
- **pi 会话切换后旧连接不再误杀新回合**:`/new`、`/resume` 切换会替换连接,旧连接迟到的关闭/事件此前会被推进新回合的共享队列,导致新消息未发出即丢失或新回合空转「成功」。现在连接回调带轮次代际锚点,非当前轮的迟到回调直接丢弃
|
|
28
|
+
- **上下文占用统计包含末轮输出**:统一口径改为「输入 + 输出 + 缓存读 + 缓存写」,与文档和压缩水位一致;长收尾回合不再系统性低估数千到数万 token
|
|
29
|
+
- **会话列表不再把 subagent 子会话当成父会话**:codex 按 id 读取与列表/active 同口径过滤子线程;kimi 的工作目录判定改用同路径比较,大小写或尾分隔符差异不再让会话从列表消失
|
|
30
|
+
- **配置 YAML 语法错误不再静默退出**:与配置校验失败同路径在终端输出原因并退出,而不是零输出地无声退出
|
|
31
|
+
- **工作区兜底与路径竞态收紧**:没有当前目录时取「最近使用」的工作区而不是最早保存的别名;`/cd`、`ls.switch`、`/ws use` 的目录检查补 TOCTOU 防护,路径在检查与使用之间被删时给出明确回复而不是静默失败
|
|
32
|
+
- **只有 chatId 的旧绑定记录可以重新认领**:这类记录此前会让所有消息被静默拒绝且永远无法重新绑定,现在视为未绑定,任意首条私聊可重新认领
|
|
33
|
+
- **卡片动作不再被在途媒体下载卡住**:会话变更类卡片动作执行前的入站冲刷改为 2 秒有界等待,大文件下载在途时点「新会话」不再让整个聊天停摆到下载超时;clone 引导期间到达的附件直接忽略,不再落盘或与引导话术冲突
|
|
34
|
+
- **`job.kill` 等纯远程操作立即执行**:终态卡上的「停止后台任务」不再排进串行队列等当前 run 结束
|
|
35
|
+
- **显式清空的模型配置重启后不再被旧选择填回**:`/config` 里清空模型/服务商等于回到默认,残留的旧选择不会再把它填回去
|
|
36
|
+
- **其他可靠性修复**:实例锁改为先写完整临时文件再原子链接,消除并发启动时的空文件窗口;pi 大上下文压缩不再受 60 秒请求超时影响;kimi 非终态的空工具结果不再把运行中的工具提前标成完成;DSH 会话读取遇到单条畸形事件只跳过该条,不再整张恢复卡变空;JSONL 流按字节边界解码,中文/emoji 被 chunk 切开时不再出现乱码
|
|
37
|
+
|
|
38
|
+
### 安全
|
|
39
|
+
|
|
40
|
+
- **退出时取消 DSH 回合的凭据临时文件不再可预测**:此前把短时 cookie 写到 `<tmpdir>/lark-remote-exit-cancel-<pid>.hdr`,同机其他用户可预建软链把凭据引到自己可读的文件。现在写入仅本人可进入的随机 0700 临时目录、文件 0600,正常结束与异常路径都会清理
|
|
41
|
+
|
|
42
|
+
### 变更
|
|
43
|
+
|
|
44
|
+
- **架构文档同步**:last-session 允许 cwd 缺失、两处终端直写豁免、标题显示宽度常量、DSH follow 快照与事件落盘顺序,中英两侧一致
|
|
45
|
+
|
|
8
46
|
## [0.5.10] - 2026-10-24
|
|
9
47
|
|
|
10
48
|
### 修复
|
package/dist/binder.js
CHANGED
|
@@ -44,7 +44,13 @@ export class OwnerBinder {
|
|
|
44
44
|
*/
|
|
45
45
|
authorize(userId, chatId) {
|
|
46
46
|
const bound = this.store.getContact();
|
|
47
|
-
|
|
47
|
+
// 只有 chatId 没有 userId 的记录(老版本写入/手改文件)视同未绑定:
|
|
48
|
+
// bound.userId === undefined 时任何真实 openId 都判不等,一切消息被
|
|
49
|
+
// 静默 rejected 且永远无法重新认领——用户只能靠文档提示删文件。
|
|
50
|
+
if (bound && bound.userId === undefined) {
|
|
51
|
+
getLogger().warn('[binder] startup contact has chatId but no userId; treating as unbound (re-claimable)');
|
|
52
|
+
}
|
|
53
|
+
else if (bound) {
|
|
48
54
|
if (userId === bound.userId)
|
|
49
55
|
return { kind: 'owner' };
|
|
50
56
|
this.rejectedTotal++;
|
package/dist/bridge/index.js
CHANGED
|
@@ -682,8 +682,10 @@ export class Bridge {
|
|
|
682
682
|
for (const [runId, b] of bashTargets) {
|
|
683
683
|
getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
|
|
684
684
|
await b.bashRunner.stop({ immediate: true });
|
|
685
|
-
// Update card to show interrupted state
|
|
686
|
-
|
|
685
|
+
// Update card to show interrupted state. exitCode 不传(保持 null):
|
|
686
|
+
// bash-renderer 的「已手动终止」文案要求 exitCode === null;传 -1 会让
|
|
687
|
+
// 该分支不可达,中断卡退化成「退出码: -1」的失败样式。
|
|
688
|
+
await b.cardSession.finish('interrupted');
|
|
687
689
|
this.activeBashRuns.delete(runId);
|
|
688
690
|
getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
|
|
689
691
|
stoppedCount++;
|
|
@@ -1006,6 +1008,7 @@ export class Bridge {
|
|
|
1006
1008
|
// 放在 finalize 之后:activeRuns 槽位已释放,续跑能正常开新卡片。
|
|
1007
1009
|
await this.retryAfterContentFilter({
|
|
1008
1010
|
cardSession,
|
|
1011
|
+
activeRun,
|
|
1009
1012
|
ctx,
|
|
1010
1013
|
cwd,
|
|
1011
1014
|
agentKind,
|
|
@@ -1042,6 +1045,12 @@ export class Bridge {
|
|
|
1042
1045
|
getLogger().info(`[lark-remote] content_filter auto-retry skipped: session epoch moved runId=${state.runId}`);
|
|
1043
1046
|
return;
|
|
1044
1047
|
}
|
|
1048
|
+
// 续跑必须接「本轮实际使用的 session」而非入队时刻快照(与 finalize 路径
|
|
1049
|
+
// 的三级优先同口径:卡片状态 → activeRun → 起点快照)。快照只在 run 未建立
|
|
1050
|
+
// 任何新会话时兜底。可证场景:新会话第一条消息(快照 undefined)被截断——
|
|
1051
|
+
// init 已建立 S1,epoch 未动,传快照会让续跑落到全新空会话,本轮上下文
|
|
1052
|
+
// 全部丢失;claude --resume fork 场景同理(init 上报的 id 才是权威)。
|
|
1053
|
+
const retrySessionId = state.sessionId ?? params.activeRun.sessionId ?? params.sessionId;
|
|
1045
1054
|
getLogger().warn(`[lark-remote] content_filter detected, auto-retrying once runId=${state.runId} cwd=${params.cwd}`);
|
|
1046
1055
|
await this.sendResult({
|
|
1047
1056
|
text: '🔁 上一回合被服务方内容审核截断(content_filter,通常是误报),已自动重试一次。',
|
|
@@ -1052,7 +1061,7 @@ export class Bridge {
|
|
|
1052
1061
|
cwdOverride: params.cwd,
|
|
1053
1062
|
binding: {
|
|
1054
1063
|
agent: params.agentKind,
|
|
1055
|
-
sessionId:
|
|
1064
|
+
sessionId: retrySessionId,
|
|
1056
1065
|
sessionEpoch: params.sessionEpochAtStart,
|
|
1057
1066
|
},
|
|
1058
1067
|
autoRetry: true,
|
|
@@ -14,3 +14,17 @@ export declare function renderApprovalArea(approval: ApprovalView | undefined, o
|
|
|
14
14
|
runId?: string;
|
|
15
15
|
terminal?: string;
|
|
16
16
|
}): object[];
|
|
17
|
+
/**
|
|
18
|
+
* 紧凑审批区(run 卡 skeleton/truncatedSkeleton 兜底档专用):固定小体积的
|
|
19
|
+
* 「标题 + 摘要 reason + 决策按钮」,不渲染正文/选项/题面。
|
|
20
|
+
*
|
|
21
|
+
* 为什么需要:完整审批区是唯一不受估算器管辖、也不参与 degraded/extreme 分层
|
|
22
|
+
* 裁剪的区域; pathological 输入(超长 reason、题数×选项爆元素数)会让所有兜底
|
|
23
|
+
* 层一起超限,超限卡被飞书整卡拒收(11310)→ 审批不可见。兜底层换用本入口后
|
|
24
|
+
* 结构性保证 ≤ 几个元素 + ≤ 1KB 文本,审批入口(红线)在任何降级档都活着。
|
|
25
|
+
*/
|
|
26
|
+
export declare function renderApprovalAreaCompact(approval: ApprovalView | undefined, opts?: {
|
|
27
|
+
expired?: boolean;
|
|
28
|
+
runId?: string;
|
|
29
|
+
terminal?: string;
|
|
30
|
+
}): object[];
|
|
@@ -8,17 +8,50 @@ import { collapsibleMarkdownPanel } from './collapsible.js';
|
|
|
8
8
|
import { truncateMarkdownTables } from './text-truncate.js';
|
|
9
9
|
import { truncateUtf8 } from '../common/truncate.js';
|
|
10
10
|
import { cardButton } from './card-shared.js';
|
|
11
|
+
import { countCardElements } from './card-budget.js';
|
|
11
12
|
/** Unique nonce for card button callbacks (SDK dedup + router validation). */
|
|
12
13
|
function mkNonce() {
|
|
13
14
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
14
15
|
}
|
|
15
16
|
/** 计划面板正文字节预算(折叠展示 + 截断保护 28KB 卡片预算)。 */
|
|
16
17
|
const PLAN_PANEL_BYTES = 6000;
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* 审批区自由文本字段(reason/note/题面/描述/回执等 agent 产出的散文)的统一
|
|
20
|
+
* 字节预算。这些字段此前无上限:实测 reason 30,000 字符 → 整卡 31,450 字节被
|
|
21
|
+
* 飞书 11310 拒收,审批彻底不可见,run 只能挂到审批超时/看门狗。1KB 足以传达
|
|
22
|
+
* 语义并支撑决策。
|
|
23
|
+
*/
|
|
24
|
+
const APPROVAL_FIELD_BYTES = 1000;
|
|
25
|
+
/** 短标签类字段(题头/工具名/路径)的字节预算。 */
|
|
26
|
+
const APPROVAL_LABEL_BYTES = 300;
|
|
27
|
+
/**
|
|
28
|
+
* 提问批次的渲染上限:题数 × 每题元素(选项行 + 输入框 + 按钮)撑爆的是元素
|
|
29
|
+
* 数预算(实测 8 题 × 5 选项 = 144 元素 > 90 上限 → 11310 整卡报废)。超出的
|
|
30
|
+
* 题折叠为计数提示;被折叠的题无法在卡上作答(整批「跳过」与 Web UI/终端
|
|
31
|
+
* 兜底),仍远好于整卡被拒、一题都答不了。
|
|
32
|
+
*/
|
|
33
|
+
const MAX_RENDERED_QUESTIONS = 5;
|
|
34
|
+
/** 单题选项渲染上限(超过折叠为计数提示;选项 label 是回调 payload,不可截断)。 */
|
|
35
|
+
const MAX_RENDERED_OPTIONS = 8;
|
|
36
|
+
/** 文件变更 / 权限条目的渲染上限(每条 1–2 个元素,无上限时百条级直接爆元素预算)。 */
|
|
37
|
+
const MAX_RENDERED_CHANGE_ITEMS = 20;
|
|
38
|
+
/**
|
|
39
|
+
* 审批区自身的元素数预算:卡总预算(MAX_CARD_ELEMENTS=90)减去状态行 / goal /
|
|
40
|
+
* summary / 操作行等骨架(约 20 个,含极端情况的余量)后的安全份额。按已渲染
|
|
41
|
+
* 元素数**自适应**截断——固定题数 × 选项数的组合上限挡不住「少题多选项」与
|
|
42
|
+
* 「多题少选项」的两种组合(一行选项 = column_set + button + div 共 3 个
|
|
43
|
+
* 元素,5 题 × 5 选项就 ~92 个)。
|
|
44
|
+
*/
|
|
45
|
+
const APPROVAL_AREA_ELEMENT_BUDGET = 50;
|
|
46
|
+
/** 审批区自由文本统一入口:反引号中和(防 11311)+ 字节预算截断(防 11310)。 */
|
|
47
|
+
function capApprovalText(text, maxBytes = APPROVAL_FIELD_BYTES) {
|
|
48
|
+
return truncateUtf8(text.replace(/`/g, '·'), maxBytes);
|
|
49
|
+
}
|
|
50
|
+
/** reason div(3 处共用);反引号中和防 lark_md 解析错误 + 字节预算截断。 */
|
|
18
51
|
function reasonDiv(approval) {
|
|
19
52
|
if (!approval.reason)
|
|
20
53
|
return null;
|
|
21
|
-
const neutralized = approval.reason
|
|
54
|
+
const neutralized = capApprovalText(approval.reason);
|
|
22
55
|
return {
|
|
23
56
|
tag: 'div',
|
|
24
57
|
text: { tag: 'lark_md', content: `💡 ${neutralized}` },
|
|
@@ -41,12 +74,11 @@ export function renderApprovalArea(approval, opts) {
|
|
|
41
74
|
return [];
|
|
42
75
|
// 终态(done/error/interrupted/idle_timeout)下 run 已结束:coordinator 已
|
|
43
76
|
// 释放,按钮点击无处响应,隐藏整个审批区避免"点了没反应"。
|
|
44
|
-
if (opts
|
|
77
|
+
if (isTerminalHidden(opts))
|
|
45
78
|
return [];
|
|
46
|
-
}
|
|
47
79
|
const elements = [];
|
|
48
80
|
if (approval.note) {
|
|
49
|
-
const neutralized = approval.note
|
|
81
|
+
const neutralized = capApprovalText(approval.note);
|
|
50
82
|
elements.push({
|
|
51
83
|
tag: 'div',
|
|
52
84
|
text: { tag: 'lark_md', content: `ℹ️ ${neutralized}` },
|
|
@@ -69,7 +101,7 @@ export function renderApprovalArea(approval, opts) {
|
|
|
69
101
|
}
|
|
70
102
|
// 响应发送失败:明确告知原因 + 可重试,而不是静默停在可点状态。
|
|
71
103
|
if (approval.deliveryError) {
|
|
72
|
-
const neutralized = approval.deliveryError
|
|
104
|
+
const neutralized = capApprovalText(approval.deliveryError);
|
|
73
105
|
elements.push({
|
|
74
106
|
tag: 'div',
|
|
75
107
|
text: {
|
|
@@ -80,6 +112,54 @@ export function renderApprovalArea(approval, opts) {
|
|
|
80
112
|
}
|
|
81
113
|
return elements;
|
|
82
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* 紧凑审批区(run 卡 skeleton/truncatedSkeleton 兜底档专用):固定小体积的
|
|
117
|
+
* 「标题 + 摘要 reason + 决策按钮」,不渲染正文/选项/题面。
|
|
118
|
+
*
|
|
119
|
+
* 为什么需要:完整审批区是唯一不受估算器管辖、也不参与 degraded/extreme 分层
|
|
120
|
+
* 裁剪的区域; pathological 输入(超长 reason、题数×选项爆元素数)会让所有兜底
|
|
121
|
+
* 层一起超限,超限卡被飞书整卡拒收(11310)→ 审批不可见。兜底层换用本入口后
|
|
122
|
+
* 结构性保证 ≤ 几个元素 + ≤ 1KB 文本,审批入口(红线)在任何降级档都活着。
|
|
123
|
+
*/
|
|
124
|
+
export function renderApprovalAreaCompact(approval, opts) {
|
|
125
|
+
if (!approval)
|
|
126
|
+
return [];
|
|
127
|
+
if (isTerminalHidden(opts))
|
|
128
|
+
return [];
|
|
129
|
+
const elements = [
|
|
130
|
+
{
|
|
131
|
+
tag: 'div',
|
|
132
|
+
text: {
|
|
133
|
+
tag: 'lark_md',
|
|
134
|
+
content: '✋ **等待审批**(内容过长已折叠,完整内容请查终端 / Web UI)',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
pushIf(elements, reasonDiv(approval));
|
|
139
|
+
if (approval.kind === 'question') {
|
|
140
|
+
if (opts?.expired) {
|
|
141
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '⏰ **提问已超时**' } });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// 提问批次的紧凑形态只保留整批跳过(逐题作答 UI 被折叠)。
|
|
145
|
+
elements.push(cardButton('⏭️ 跳过回答', {
|
|
146
|
+
cmd: 'approval.respond',
|
|
147
|
+
decision: 'decline',
|
|
148
|
+
requestId: approval.requestId,
|
|
149
|
+
runId: opts?.runId,
|
|
150
|
+
nonce: mkNonce(),
|
|
151
|
+
}, { type: 'default' }));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
elements.push(...renderDecisionButtons(approval, opts?.expired, opts?.runId));
|
|
156
|
+
}
|
|
157
|
+
return elements;
|
|
158
|
+
}
|
|
159
|
+
/** 终态隐藏判定(renderApprovalArea 与 compact 共用)。 */
|
|
160
|
+
function isTerminalHidden(opts) {
|
|
161
|
+
return !!opts?.terminal && opts.terminal !== 'running' && opts.terminal !== 'finalizing';
|
|
162
|
+
}
|
|
83
163
|
/**
|
|
84
164
|
* Render approval action buttons from the real protocol decision list.
|
|
85
165
|
* accept and decline are always offered (decline is a universal safety
|
|
@@ -150,7 +230,7 @@ function renderToolApproval(approval, expired, runId) {
|
|
|
150
230
|
content: '**🔧 工具请求**',
|
|
151
231
|
},
|
|
152
232
|
});
|
|
153
|
-
const toolLabel = approval.toolName ?? '(unknown tool)';
|
|
233
|
+
const toolLabel = capApprovalText(approval.toolName ?? '(unknown tool)', APPROVAL_LABEL_BYTES);
|
|
154
234
|
elements.push({
|
|
155
235
|
tag: 'div',
|
|
156
236
|
text: { tag: 'lark_md', content: `🔧 \`${toolLabel}\`` },
|
|
@@ -158,10 +238,10 @@ function renderToolApproval(approval, expired, runId) {
|
|
|
158
238
|
pushIf(elements, reasonDiv(approval));
|
|
159
239
|
// 原始 input(非空时展示,截断保护卡片预算;ExitPlanMode 的 `{}` 不展示)。
|
|
160
240
|
if (approval.toolInput && approval.toolInput !== '{}') {
|
|
161
|
-
const neutralized = approval.toolInput
|
|
241
|
+
const neutralized = capApprovalText(approval.toolInput, 500);
|
|
162
242
|
elements.push({
|
|
163
243
|
tag: 'div',
|
|
164
|
-
text: { tag: 'lark_md', content: `\`${neutralized
|
|
244
|
+
text: { tag: 'lark_md', content: `\`${neutralized}\`` },
|
|
165
245
|
});
|
|
166
246
|
}
|
|
167
247
|
elements.push(...renderDecisionButtons(approval, expired, runId));
|
|
@@ -184,7 +264,7 @@ function renderPlanApproval(approval, expired, runId) {
|
|
|
184
264
|
});
|
|
185
265
|
pushIf(elements, reasonDiv(approval));
|
|
186
266
|
if (approval.planFilePath) {
|
|
187
|
-
const neutralizedPath = approval.planFilePath
|
|
267
|
+
const neutralizedPath = capApprovalText(approval.planFilePath, APPROVAL_LABEL_BYTES);
|
|
188
268
|
elements.push({
|
|
189
269
|
tag: 'div',
|
|
190
270
|
text: { tag: 'lark_md', content: `📄 ${neutralizedPath}` },
|
|
@@ -215,7 +295,7 @@ function renderPlanApproval(approval, expired, runId) {
|
|
|
215
295
|
if (!expired) {
|
|
216
296
|
elements.push(renderPlanFeedbackInput(approval.requestId, runId));
|
|
217
297
|
if (approval.planFeedback) {
|
|
218
|
-
const neutralized = approval.planFeedback
|
|
298
|
+
const neutralized = capApprovalText(approval.planFeedback);
|
|
219
299
|
elements.push({
|
|
220
300
|
tag: 'div',
|
|
221
301
|
text: { tag: 'lark_md', content: `📝 ${neutralized}` },
|
|
@@ -259,18 +339,17 @@ function renderCommandApproval(approval, expired, runId) {
|
|
|
259
339
|
// Command display
|
|
260
340
|
// 命令里的反引号会提前终止 lark_md 行内代码 span,奇数次
|
|
261
341
|
// 反引号极易触发 11311 解析错误导致整卡失败(同 renderFileDiff 的中和策略)。
|
|
262
|
-
const neutralizedCommand = (approval.command ?? ''
|
|
263
|
-
const cmdContent = neutralizedCommand
|
|
264
|
-
? `\`${neutralizedCommand.slice(0, 500)}\``
|
|
265
|
-
: '(no command)';
|
|
342
|
+
const neutralizedCommand = capApprovalText(approval.command ?? '', 500);
|
|
343
|
+
const cmdContent = neutralizedCommand ? `\`${neutralizedCommand}\`` : '(no command)';
|
|
266
344
|
elements.push({
|
|
267
345
|
tag: 'div',
|
|
268
346
|
text: { tag: 'lark_md', content: `**⚡ 命令审批**\n${cmdContent}` },
|
|
269
347
|
});
|
|
270
348
|
if (approval.commandCwd) {
|
|
349
|
+
const neutralizedCwd = capApprovalText(approval.commandCwd, APPROVAL_LABEL_BYTES);
|
|
271
350
|
elements.push({
|
|
272
351
|
tag: 'div',
|
|
273
|
-
text: { tag: 'lark_md', content: `📂 \`${
|
|
352
|
+
text: { tag: 'lark_md', content: `📂 \`${neutralizedCwd}\`` },
|
|
274
353
|
});
|
|
275
354
|
}
|
|
276
355
|
pushIf(elements, reasonDiv(approval));
|
|
@@ -297,11 +376,16 @@ function renderFileApproval(approval, expired, runId) {
|
|
|
297
376
|
const TOTAL_DIFF_CHARS = 2400;
|
|
298
377
|
let renderedDiffChars = 0;
|
|
299
378
|
let omittedDiffs = 0;
|
|
300
|
-
|
|
379
|
+
// 条目数上限(元素预算):每条 1 path div + 至多 1 diff div,无上限时
|
|
380
|
+
// 百条级变更直接撑爆元素数(>90 → 11310 整卡报废)。
|
|
381
|
+
const changes = approval.fileChanges.slice(0, MAX_RENDERED_CHANGE_ITEMS);
|
|
382
|
+
const omittedChanges = approval.fileChanges.length - changes.length;
|
|
383
|
+
for (const change of changes) {
|
|
301
384
|
const icon = change.kind === 'add' ? '🆕' : change.kind === 'delete' ? '🗑️' : '✏️';
|
|
385
|
+
const path = capApprovalText(change.path, APPROVAL_LABEL_BYTES);
|
|
302
386
|
elements.push({
|
|
303
387
|
tag: 'div',
|
|
304
|
-
text: { tag: 'lark_md', content: `${icon} \`${
|
|
388
|
+
text: { tag: 'lark_md', content: `${icon} \`${path}\` (${change.kind})` },
|
|
305
389
|
});
|
|
306
390
|
if (change.diff) {
|
|
307
391
|
if (renderedDiffChars < TOTAL_DIFF_CHARS) {
|
|
@@ -316,6 +400,12 @@ function renderFileApproval(approval, expired, runId) {
|
|
|
316
400
|
}
|
|
317
401
|
}
|
|
318
402
|
}
|
|
403
|
+
if (omittedChanges > 0) {
|
|
404
|
+
elements.push({
|
|
405
|
+
tag: 'div',
|
|
406
|
+
text: { tag: 'lark_md', content: `_…另有 ${omittedChanges} 个文件变更未展示(卡片预算)_` },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
319
409
|
if (omittedDiffs > 0) {
|
|
320
410
|
elements.push({
|
|
321
411
|
tag: 'div',
|
|
@@ -354,11 +444,16 @@ function renderPermissionsApproval(approval, expired, runId) {
|
|
|
354
444
|
pushIf(elements, reasonDiv(approval));
|
|
355
445
|
// Permission items with toggle buttons
|
|
356
446
|
if (approval.permissions?.items) {
|
|
357
|
-
|
|
447
|
+
// 条目数上限(元素预算):每条 label div + toggle button 共 2 个元素。
|
|
448
|
+
const items = approval.permissions.items.slice(0, MAX_RENDERED_CHANGE_ITEMS);
|
|
449
|
+
const omittedItems = approval.permissions.items.length - items.length;
|
|
450
|
+
for (const item of items) {
|
|
358
451
|
const icon = item.selected ? '✅' : '⬜';
|
|
452
|
+
// label 仅展示(payload 用 item.id),可安全截断。
|
|
453
|
+
const label = capApprovalText(item.label, APPROVAL_LABEL_BYTES);
|
|
359
454
|
elements.push({
|
|
360
455
|
tag: 'div',
|
|
361
|
-
text: { tag: 'lark_md', content: `${icon} ${
|
|
456
|
+
text: { tag: 'lark_md', content: `${icon} ${label}` },
|
|
362
457
|
});
|
|
363
458
|
elements.push(cardButton(item.selected ? '取消授予' : '授予', {
|
|
364
459
|
cmd: 'approval.toggle',
|
|
@@ -369,6 +464,12 @@ function renderPermissionsApproval(approval, expired, runId) {
|
|
|
369
464
|
nonce: mkNonce(),
|
|
370
465
|
}, { type: item.selected ? 'default' : 'primary', size: 'small' }));
|
|
371
466
|
}
|
|
467
|
+
if (omittedItems > 0) {
|
|
468
|
+
elements.push({
|
|
469
|
+
tag: 'div',
|
|
470
|
+
text: { tag: 'lark_md', content: `_…另有 ${omittedItems} 项权限未展示(卡片预算)_` },
|
|
471
|
+
});
|
|
472
|
+
}
|
|
372
473
|
}
|
|
373
474
|
// Action buttons
|
|
374
475
|
// Derived from the real protocol decision list (see renderDecisionButtons).
|
|
@@ -396,7 +497,7 @@ function renderQuestionApproval(approval, expired, runId) {
|
|
|
396
497
|
if (approval.intro) {
|
|
397
498
|
elements.push({
|
|
398
499
|
tag: 'div',
|
|
399
|
-
text: { tag: 'lark_md', content: `📝 ${approval.intro}` },
|
|
500
|
+
text: { tag: 'lark_md', content: `📝 ${capApprovalText(approval.intro)}` },
|
|
400
501
|
});
|
|
401
502
|
}
|
|
402
503
|
if (expired) {
|
|
@@ -410,22 +511,36 @@ function renderQuestionApproval(approval, expired, runId) {
|
|
|
410
511
|
if (approval.expiredHint) {
|
|
411
512
|
elements.push({
|
|
412
513
|
tag: 'div',
|
|
413
|
-
text: { tag: 'lark_md', content: approval.expiredHint
|
|
514
|
+
text: { tag: 'lark_md', content: capApprovalText(approval.expiredHint) },
|
|
414
515
|
});
|
|
415
516
|
}
|
|
416
517
|
return elements;
|
|
417
518
|
}
|
|
418
519
|
const questions = approval.questions ?? [];
|
|
520
|
+
let renderedQuestions = 0;
|
|
419
521
|
for (let i = 0; i < questions.length; i++) {
|
|
522
|
+
// 题数粗上限 + 审批区元素预算(自适应):任一触发即折叠剩余题。
|
|
523
|
+
if (i >= MAX_RENDERED_QUESTIONS ||
|
|
524
|
+
countCardElements({ elements }) > APPROVAL_AREA_ELEMENT_BUDGET) {
|
|
525
|
+
elements.push({
|
|
526
|
+
tag: 'div',
|
|
527
|
+
text: {
|
|
528
|
+
tag: 'lark_md',
|
|
529
|
+
content: `_…另有 ${questions.length - renderedQuestions} 题未展示(卡片元素预算);可在终端 / Web UI 回答,或点下方「跳过回答」跳过整批_`,
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
renderedQuestions++;
|
|
420
535
|
const q = questions[i];
|
|
421
536
|
const selected = q.selected ?? [];
|
|
422
|
-
const header = q.header || `问题 ${i + 1}
|
|
537
|
+
const header = capApprovalText(q.header || `问题 ${i + 1}`, APPROVAL_LABEL_BYTES);
|
|
423
538
|
const freeText = (q.options?.length ?? 0) === 0;
|
|
424
539
|
elements.push({
|
|
425
540
|
tag: 'div',
|
|
426
541
|
text: {
|
|
427
542
|
tag: 'lark_md',
|
|
428
|
-
content: `**${header}**${q.multiSelect ? '(可多选)' : ''}\n${q.question}`,
|
|
543
|
+
content: `**${header}**${q.multiSelect ? '(可多选)' : ''}\n${capApprovalText(q.question)}`,
|
|
429
544
|
},
|
|
430
545
|
});
|
|
431
546
|
if (q.detail) {
|
|
@@ -451,24 +566,35 @@ function renderQuestionApproval(approval, expired, runId) {
|
|
|
451
566
|
if (answered) {
|
|
452
567
|
elements.push({
|
|
453
568
|
tag: 'div',
|
|
454
|
-
text: { tag: 'lark_md', content: `✍️ 已答:${answered}` },
|
|
569
|
+
text: { tag: 'lark_md', content: `✍️ 已答:${capApprovalText(answered)}` },
|
|
455
570
|
});
|
|
456
571
|
}
|
|
457
572
|
continue;
|
|
458
573
|
}
|
|
459
|
-
|
|
574
|
+
// 选项渲染上限(label 是回调 payload 不可截断,只能按条数折叠)。
|
|
575
|
+
const options = q.options ?? [];
|
|
576
|
+
for (const option of options.slice(0, MAX_RENDERED_OPTIONS)) {
|
|
460
577
|
const isSelected = selected.includes(option.label);
|
|
461
578
|
// 单选/多选图标区分:单选 ⚪/🔵(radio 隐喻),多选 ⬜/☑️(checkbox 隐喻)。
|
|
462
579
|
const icon = q.multiSelect ? (isSelected ? '☑️' : '⬜') : isSelected ? '🔵' : '⚪';
|
|
463
580
|
elements.push(renderOptionRow(approval.requestId, i, option, isSelected, icon, runId));
|
|
464
581
|
}
|
|
582
|
+
if (options.length > MAX_RENDERED_OPTIONS) {
|
|
583
|
+
elements.push({
|
|
584
|
+
tag: 'div',
|
|
585
|
+
text: {
|
|
586
|
+
tag: 'lark_md',
|
|
587
|
+
content: `_…另有 ${options.length - MAX_RENDERED_OPTIONS} 个选项未展示(卡片元素预算)_`,
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
}
|
|
465
591
|
// 自定义答案(Other)的选中态展示:选项按钮无法表示自由文本,单独显示
|
|
466
592
|
// 已选文本,避免「点了没反应」的困惑。
|
|
467
593
|
const customSelected = q.selected?.find((s) => !q.options.some((o) => o.label === s));
|
|
468
594
|
if (customSelected) {
|
|
469
595
|
elements.push({
|
|
470
596
|
tag: 'div',
|
|
471
|
-
text: { tag: 'lark_md', content: `✍️ 自定义答案:${customSelected}` },
|
|
597
|
+
text: { tag: 'lark_md', content: `✍️ 自定义答案:${capApprovalText(customSelected)}` },
|
|
472
598
|
});
|
|
473
599
|
}
|
|
474
600
|
// 单选问题提供自定义答案(Other)输入——输入文本后提交
|
|
@@ -486,7 +612,7 @@ function renderQuestionApproval(approval, expired, runId) {
|
|
|
486
612
|
if (q.note) {
|
|
487
613
|
elements.push({
|
|
488
614
|
tag: 'div',
|
|
489
|
-
text: { tag: 'lark_md', content: `📝 ${q.note}` },
|
|
615
|
+
text: { tag: 'lark_md', content: `📝 ${capApprovalText(q.note)}` },
|
|
490
616
|
});
|
|
491
617
|
}
|
|
492
618
|
}
|
|
@@ -550,7 +676,7 @@ function renderOptionRow(requestId, questionIndex, option, isSelected, icon, run
|
|
|
550
676
|
tag: 'div',
|
|
551
677
|
text: {
|
|
552
678
|
tag: 'lark_md',
|
|
553
|
-
content: `${icon} ${option.label}${option.description ? `\n_${option.description}_` : ''}`,
|
|
679
|
+
content: `${icon} ${option.label}${option.description ? `\n_${capApprovalText(option.description)}_` : ''}`,
|
|
554
680
|
},
|
|
555
681
|
},
|
|
556
682
|
],
|
|
@@ -68,6 +68,14 @@ export class BashCardSession extends CardSession {
|
|
|
68
68
|
}
|
|
69
69
|
/** Transition to a terminal state and patch the card. */
|
|
70
70
|
async finish(terminal, meta = {}) {
|
|
71
|
+
// 终态守卫(与 run-state 的 finishRun 首终态优先同口径):/stop 路径
|
|
72
|
+
// (bridge.interruptCurrentRun)与 executeBashInternal 的自然收尾路径会对
|
|
73
|
+
// 同一张卡并发 finish——SIGKILL 后 exitCode 为 -1,自然路径的
|
|
74
|
+
// finish('error', { exitCode: -1 }) 会把 interrupt 路径刚写的 'interrupted'
|
|
75
|
+
// 覆盖成「执行失败」,后落者胜出且取决于时序。首终态胜出后忽略后续 finish。
|
|
76
|
+
if (this.state.terminal !== 'running') {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
71
79
|
this.cancelPendingFlush();
|
|
72
80
|
// 若有 in-flight flush 正在 await updateCard,必须等它完成后再发终态 patch,
|
|
73
81
|
// 保证顺序(pre-terminal 先落地 → terminal 后落地 → terminal 胜出)。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { terminalToColor, terminalToLabel, stopButton } from './card-shared.js';
|
|
2
2
|
import { markdownDiv } from './collapsible.js';
|
|
3
3
|
import { CARD_BUDGET_BYTES } from './text-truncate.js';
|
|
4
|
-
import { truncateUtf8 } from '../common/truncate.js';
|
|
4
|
+
import { truncateUtf8, truncate } from '../common/truncate.js';
|
|
5
5
|
/**
|
|
6
6
|
* 飞书卡片大小限制(安全阈值,与 run-renderer 一致)。
|
|
7
7
|
* 从共享的 text-truncate.ts 导入,避免三处各自定义后数值漂移。
|
|
@@ -20,36 +20,24 @@ const OUTPUT_MAX_BYTES = 12_000;
|
|
|
20
20
|
*/
|
|
21
21
|
const DEGRADED_OUTPUT_BYTES = 4_000;
|
|
22
22
|
/**
|
|
23
|
-
* Truncate long commands for display.
|
|
23
|
+
* Truncate long commands for display. 按码点截断(裸 slice 切在 emoji 中间会
|
|
24
|
+
* 留下孤立代理项进卡片),项目已有 truncate() 实现同语义。
|
|
24
25
|
*/
|
|
25
26
|
function truncateCommand(cmd, maxLen = 40) {
|
|
26
|
-
|
|
27
|
-
return cmd;
|
|
28
|
-
return cmd.slice(0, maxLen - 3) + '...';
|
|
27
|
+
return truncate(cmd, maxLen);
|
|
29
28
|
}
|
|
30
29
|
/**
|
|
31
|
-
* Build the status row
|
|
30
|
+
* Build the status row element.
|
|
31
|
+
*
|
|
32
|
+
* 单 div + ` · ` 分隔(窄屏硬闸,与 run-renderer 的 statusRow 同款):旧版把
|
|
33
|
+
* 状态标签(2 字短文本)放 `column_set` 的 auto 列、命令放 grow 列——手机
|
|
34
|
+
* 窄屏下 auto 列会被挤成竖排(run 卡踩过同型坑,bash 卡漏改)。
|
|
35
|
+
* 单行文本的换行点只落在 " · " 处,各分段自身不折行。
|
|
32
36
|
*/
|
|
33
37
|
function buildStatusRow(state) {
|
|
34
38
|
const { terminal, exitCode, command } = state;
|
|
35
39
|
const statusLabel = bashStatusTagLabel(terminal, exitCode);
|
|
36
|
-
return {
|
|
37
|
-
tag: 'column_set',
|
|
38
|
-
columns: [
|
|
39
|
-
{
|
|
40
|
-
tag: 'column',
|
|
41
|
-
width: 'auto',
|
|
42
|
-
elements: [{ tag: 'div', text: { content: statusLabel, tag: 'lark_md' } }],
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
tag: 'column',
|
|
46
|
-
width: 'grow',
|
|
47
|
-
elements: [
|
|
48
|
-
{ tag: 'div', text: { content: `⏱ ${truncateCommand(command, 40)}`, tag: 'lark_md' } },
|
|
49
|
-
],
|
|
50
|
-
},
|
|
51
|
-
],
|
|
52
|
-
};
|
|
40
|
+
return markdownDiv(`${statusLabel} · ⏱ ${truncateCommand(command, 40)}`, 'notation');
|
|
53
41
|
}
|
|
54
42
|
/**
|
|
55
43
|
* Build the exit code footer elements (terminal states only).
|
package/dist/card/card-budget.js
CHANGED
|
@@ -224,7 +224,12 @@ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
|
|
|
224
224
|
dropOldestPanels(panelCount - maxEventPanels);
|
|
225
225
|
reasons.push('event_count_limited');
|
|
226
226
|
}
|
|
227
|
-
|
|
227
|
+
// 元素数预算必须与字节预算同查(含 hint 预留位):字节路径的返回条件此前
|
|
228
|
+
// 只看字节——元素超限但卡内无 collapsible_panel 时(如 95 个小 div),
|
|
229
|
+
// 阶段1-4 全是空操作,超限卡原样直通(实测 wasTruncated:false、95 元素
|
|
230
|
+
// > 90 → 飞书 11310 整卡报废,正是文件头注释记载的 /ws、/order 故障形态)。
|
|
231
|
+
const elementsWithinBudgetWithHint = () => countCardElements(truncated) + (omitted > 0 ? 1 : 0) <= MAX_CARD_ELEMENTS;
|
|
232
|
+
if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
|
|
228
233
|
insertOmittedHint(elements, omitted, truncationHint);
|
|
229
234
|
return done(modifications > 0 || omitted > 0);
|
|
230
235
|
}
|
|
@@ -235,13 +240,12 @@ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
|
|
|
235
240
|
if (omitted > 0) {
|
|
236
241
|
reasons.push('panels_dropped');
|
|
237
242
|
}
|
|
238
|
-
if (size() <= CARD_BUDGET_BYTES) {
|
|
243
|
+
if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
|
|
239
244
|
insertOmittedHint(elements, omitted, truncationHint);
|
|
240
245
|
return done(true);
|
|
241
246
|
}
|
|
242
247
|
// ========== 阶段4:截断剩余顶层 div 文本 ==========
|
|
243
248
|
// 骨架元素(header/usage 等)自身超限的情况(如超大 displayTitle)。
|
|
244
|
-
insertOmittedHint(elements, omitted, truncationHint);
|
|
245
249
|
let textTruncations = 0;
|
|
246
250
|
for (const el of elements) {
|
|
247
251
|
if (el.tag === 'div' && el.text?.content) {
|
|
@@ -255,9 +259,25 @@ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
|
|
|
255
259
|
if (textTruncations > 0) {
|
|
256
260
|
reasons.push('text_truncated');
|
|
257
261
|
}
|
|
258
|
-
if (size() <= CARD_BUDGET_BYTES) {
|
|
262
|
+
if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
|
|
263
|
+
insertOmittedHint(elements, omitted, truncationHint);
|
|
259
264
|
return done(true);
|
|
260
265
|
}
|
|
266
|
+
// ========== 阶段4.5:元素数兜底(无面板可删的元素超限) ==========
|
|
267
|
+
// 阶段1-4 只会裁内容/删面板;元素超限且无面板时在此按序丢弃最旧的顶层
|
|
268
|
+
// 元素直到达标(与 table/panel 的「删最旧、留最新」口径一致,尾部操作
|
|
269
|
+
// 按钮保留)。留 ≥3 个元素兜底,仍不达标则交给阶段5 的最小卡。
|
|
270
|
+
if (countCardElements(truncated) > MAX_CARD_ELEMENTS) {
|
|
271
|
+
while (countCardElements(truncated) > MAX_CARD_ELEMENTS && elements.length > 3) {
|
|
272
|
+
elements.shift();
|
|
273
|
+
omitted++;
|
|
274
|
+
}
|
|
275
|
+
reasons.push('element_budget_elements_dropped');
|
|
276
|
+
if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
|
|
277
|
+
insertOmittedHint(elements, omitted, truncationHint);
|
|
278
|
+
return done(true);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
261
281
|
// ========== 阶段5:最终兜底(原 extreme_fallback) ==========
|
|
262
282
|
const minimalCard = cardWithHeader({
|
|
263
283
|
template: 'orange',
|