lark-remote 0.4.0 → 0.4.2
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 +35 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/inbound-media.d.ts +13 -20
- package/dist/bridge/inbound-media.js +107 -93
- package/dist/bridge/index.d.ts +6 -6
- package/dist/bridge/index.js +6 -12
- package/dist/card/approval-render.js +9 -8
- package/dist/cli.js +5 -1
- package/dist/config/dir.d.ts +9 -0
- package/dist/config/dir.js +69 -0
- package/dist/config/index.d.ts +22 -3
- package/dist/config/index.js +25 -3
- package/dist/connector/index.d.ts +22 -1
- package/dist/connector/index.js +79 -14
- package/dist/inbound/event.d.ts +41 -0
- package/dist/inbound/event.js +11 -0
- package/dist/inbound/placeholder.d.ts +26 -0
- package/dist/inbound/placeholder.js +149 -0
- package/dist/inbound/prompt.d.ts +20 -0
- package/dist/inbound/prompt.js +47 -0
- package/dist/inbound/receipt.d.ts +28 -0
- package/dist/inbound/receipt.js +62 -0
- package/dist/inbound/turn-assembler.d.ts +63 -0
- package/dist/inbound/turn-assembler.js +233 -0
- package/dist/inbound/turn.d.ts +48 -0
- package/dist/inbound/turn.js +10 -0
- package/dist/index.js +96 -71
- package/dist/router/card-helpers.d.ts +38 -2
- package/dist/router/card-helpers.js +79 -2
- package/dist/router/config/opencode.d.ts +1 -1
- package/dist/router/config/opencode.js +11 -0
- package/dist/router/index.d.ts +56 -6
- package/dist/router/index.js +256 -78
- package/dist/runner/common/connection-based-runner.js +6 -2
- package/dist/runner/dsh/fake-dsh-server.d.ts +6 -0
- package/dist/runner/dsh/fake-dsh-server.js +6 -0
- package/dist/runner/kimi/acp/runner.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.js +1 -1
- package/dist/runner/opencode/acp/runner.d.ts +1 -1
- package/package.json +1 -1
package/dist/router/index.js
CHANGED
|
@@ -9,8 +9,9 @@ import { OrderStore } from '../order/index.js';
|
|
|
9
9
|
import { resolveAlias } from '../order/alias-resolve.js';
|
|
10
10
|
import { diffConfig, setNestedValue } from './config-diff.js';
|
|
11
11
|
import { getLogger } from '../logger/index.js';
|
|
12
|
+
import { stripPlaceholders } from '../inbound/placeholder.js';
|
|
12
13
|
import { activeRunUsage, clampInt } from './utils.js';
|
|
13
|
-
import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
|
|
14
|
+
import { markdownDiv, buildSessionHistoryCard, paginationBar, searchBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
|
|
14
15
|
import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
|
|
15
16
|
import { atomicWrite } from '../persistence/atomic-write.js';
|
|
16
17
|
import { checkLatestVersion as defaultCheckLatestVersion, isNewer as defaultIsNewer, runInstallLatest as defaultRunInstallLatest, } from '../update/index.js';
|
|
@@ -289,9 +290,11 @@ const IMMEDIATE_ACTION_CMDS = new Set([
|
|
|
289
290
|
'ls.browse',
|
|
290
291
|
'ls.switch',
|
|
291
292
|
'ls.page', // control operation: paginate only, never spawns claude
|
|
293
|
+
'ls.filter', // control operation: refilter the /ls list only
|
|
292
294
|
'resume.page', // control operation: paginate only, never spawns claude
|
|
293
295
|
'active.page', // control operation: paginate active card
|
|
294
296
|
'ws.page', // control operation: paginate /ws list only
|
|
297
|
+
'ws.filter', // control operation: refilter the /ws list only
|
|
295
298
|
'ws.remove',
|
|
296
299
|
'ws.sort', // control operation: toggle sort mode only
|
|
297
300
|
'resume.use',
|
|
@@ -321,6 +324,19 @@ const IMMEDIATE_ACTION_CMDS = new Set([
|
|
|
321
324
|
function payloadOffset(value) {
|
|
322
325
|
return Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
323
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* 搜索框提交值单源读取:`inputValue` 主路(CardKit 2.0 input 提交(键盘回车/
|
|
329
|
+
* 完成键)经 raw `action.input_value` 回传,SDK normalizer 会丢弃
|
|
330
|
+
* `action.input_value`,依赖 connector 的 includeRawEvent)+
|
|
331
|
+
* `formValue[searchInput]` 回退。空串/纯空白一律归一为 undefined = 清除筛选,
|
|
332
|
+
* 不是报错也不是无操作。「清除筛选」按钮带 `clearQuery`,直接短路为 undefined。
|
|
333
|
+
*/
|
|
334
|
+
function searchQueryFrom(value) {
|
|
335
|
+
if (value.clearQuery)
|
|
336
|
+
return undefined;
|
|
337
|
+
const raw = value.inputValue ?? value.formValue?.[SEARCH_INPUT_NAME];
|
|
338
|
+
return raw?.trim() || undefined;
|
|
339
|
+
}
|
|
324
340
|
/** 卡片 cardAction payload 缺字段的统一报错文案(原先 12 处手写且已漂移出两种前缀)。 */
|
|
325
341
|
const CARD_PAYLOAD_MISSING = '⚠️ 卡片 payload 缺少必要信息';
|
|
326
342
|
export class CommandRouter {
|
|
@@ -342,6 +358,11 @@ export class CommandRouter {
|
|
|
342
358
|
* returns its pid. Injected from index.ts; /restart is unavailable without it.
|
|
343
359
|
*/
|
|
344
360
|
restartSpawner;
|
|
361
|
+
/**
|
|
362
|
+
* 冲刷装配器窗口内未提交的入站 turn(/exit、/restart 干净退出前调用,
|
|
363
|
+
* 避免静默期窗口内的内容丢失)。index.ts 注入。
|
|
364
|
+
*/
|
|
365
|
+
flushInbound;
|
|
345
366
|
idleTimeoutMs;
|
|
346
367
|
/** Dev mode flag: --dev means the bridge was started from source (bun src/index.ts). */
|
|
347
368
|
devMode;
|
|
@@ -416,13 +437,33 @@ export class CommandRouter {
|
|
|
416
437
|
get sessionReader() {
|
|
417
438
|
return this.sessionReaderRegistry.get(this.config.defaultAgent);
|
|
418
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* 注入入站装配器的冲刷回调(/exit、/restart 干净退出前调用,
|
|
442
|
+
* 避免静默期窗口内未提交的 turn 丢失)。index.ts 在构造后接线。
|
|
443
|
+
*/
|
|
444
|
+
setInboundFlusher(flush) {
|
|
445
|
+
this.flushInbound = flush;
|
|
446
|
+
}
|
|
419
447
|
/**
|
|
420
448
|
* Route a message: if it starts with /, handle as command; if it starts with !, execute as bash; otherwise forward to Claude.
|
|
449
|
+
*
|
|
450
|
+
* 命令识别前置条件(2026-09-15 P0 修复):
|
|
451
|
+
* 裸前缀判定会被结构占位符命中 —— SDK 把富文本里的图片渲染成 ``,
|
|
452
|
+
* 首字符恰好是 `!`,整段用户消息被当 shell 命令执行(`executeBash "[image](img_v3_…"`)。
|
|
453
|
+
* 因此命令前缀只在「调用方声明纯文本 + 剥离占位符后非空且无占位符 + 首字符是 / 或 !」
|
|
454
|
+
* 三条同时成立时生效,其余一律按文本转发。
|
|
421
455
|
*/
|
|
422
456
|
async handle(message, ctx, opts) {
|
|
423
457
|
const trimmed = message.trim();
|
|
424
|
-
|
|
425
|
-
|
|
458
|
+
// 双保险:任何调用点都必须先剥离结构占位符,再判命令前缀
|
|
459
|
+
//(index.ts 传 allowCommandPrefix = rawContentType === 'text',但那层不是唯一入口)。
|
|
460
|
+
const guard = stripPlaceholders(trimmed);
|
|
461
|
+
// 默认 true:order.exec 等既有内部调用点传的是用户已确认的指令文本,
|
|
462
|
+
// 且剥离后无占位符 —— 默认放行不改变它们的语义(见 command-guard.test.ts)。
|
|
463
|
+
const allowCommandPrefix = opts?.allowCommandPrefix ?? true;
|
|
464
|
+
const commandEligible = allowCommandPrefix && guard.kinds.length === 0 && guard.clean !== '';
|
|
465
|
+
const startsWithBang = commandEligible && guard.clean.startsWith('!');
|
|
466
|
+
const startsWithSlash = commandEligible && guard.clean.startsWith('/');
|
|
426
467
|
getLogger().info(`[router] handle message="${message.slice(0, 50)}..." trimmed="${trimmed.slice(0, 50)}..." startsWithSlash=${startsWithSlash} startsWithBang=${startsWithBang}`);
|
|
427
468
|
// 复制分身流程活跃期:一切消息(含命令)交给 clone 状态机处理,
|
|
428
469
|
// 不转发 coding agent、不进命令分发(index.ts 已在入队前拦截,这里
|
|
@@ -432,20 +473,20 @@ export class CommandRouter {
|
|
|
432
473
|
return null;
|
|
433
474
|
}
|
|
434
475
|
if (startsWithSlash) {
|
|
435
|
-
const result = await this.executeCommand(
|
|
476
|
+
const result = await this.executeCommand(guard.clean, ctx);
|
|
436
477
|
if (result) {
|
|
437
478
|
await this.bridge.sendResult(result, ctx);
|
|
438
479
|
}
|
|
439
480
|
if (this.pendingExit) {
|
|
440
|
-
//
|
|
441
|
-
await this.
|
|
481
|
+
// 干净退出前冲刷装配器窗口内未提交的入站 turn,避免静默期窗口内的内容丢失。
|
|
482
|
+
await this.flushInbound?.();
|
|
442
483
|
this.exitHandler();
|
|
443
484
|
}
|
|
444
485
|
return result;
|
|
445
486
|
}
|
|
446
487
|
// Handle bang commands (!command)
|
|
447
|
-
if (
|
|
448
|
-
const cmd =
|
|
488
|
+
if (startsWithBang) {
|
|
489
|
+
const cmd = guard.clean.slice(1).trim();
|
|
449
490
|
if (!cmd) {
|
|
450
491
|
await this.bridge.sendResult({ text: '请输入要执行的命令,例如 !ls' }, ctx);
|
|
451
492
|
return null;
|
|
@@ -456,7 +497,9 @@ export class CommandRouter {
|
|
|
456
497
|
// Forward to Claude (P1-14: pass the enqueue-time workspace through so the
|
|
457
498
|
// run uses the same cwd as the serial queue lane, even if /cd ran while
|
|
458
499
|
// the message was queued)
|
|
459
|
-
|
|
500
|
+
// 剥离占位符后再转发:agent 只该看到用户文本(附图已转成 attachments 块,
|
|
501
|
+
// 那是装配器生成的协议块,stripPlaceholders 会原样保留)。
|
|
502
|
+
await this.bridge.forwardToClaude(guard.clean, ctx, opts);
|
|
460
503
|
return null;
|
|
461
504
|
}
|
|
462
505
|
/**
|
|
@@ -496,8 +539,12 @@ export class CommandRouter {
|
|
|
496
539
|
return;
|
|
497
540
|
case 'ls.page':
|
|
498
541
|
return this.handleLsPage(value, ctx);
|
|
542
|
+
case 'ls.filter':
|
|
543
|
+
return this.handleLsFilter(value, ctx);
|
|
499
544
|
case 'ws.page':
|
|
500
545
|
return this.handleWsPage(value, ctx);
|
|
546
|
+
case 'ws.filter':
|
|
547
|
+
return this.handleWsFilter(value, ctx);
|
|
501
548
|
case 'ws.use':
|
|
502
549
|
return await this.handleWsUse(value, ctx);
|
|
503
550
|
case 'ws.sort':
|
|
@@ -607,7 +654,7 @@ export class CommandRouter {
|
|
|
607
654
|
// 必须同样消费,否则点击 /restart 按钮 spawn 成功后旧进程不退出,
|
|
608
655
|
// 新进程撞单例锁退出 → 重启两头落空(2026-08-01 红绿 anchor 锁定)。
|
|
609
656
|
if (this.pendingExit) {
|
|
610
|
-
await this.
|
|
657
|
+
await this.flushInbound?.();
|
|
611
658
|
this.exitHandler();
|
|
612
659
|
}
|
|
613
660
|
return;
|
|
@@ -904,7 +951,7 @@ export class CommandRouter {
|
|
|
904
951
|
return;
|
|
905
952
|
}
|
|
906
953
|
// Build edit card
|
|
907
|
-
// CardKit 2.0 input
|
|
954
|
+
// CardKit 2.0 input 提交(回车/完成键)时输入值走 raw.action.input_value
|
|
908
955
|
// (SDK normalizer 丢弃,需 connector includeRawEvent: true + index.ts 从 raw 提取)。
|
|
909
956
|
// 用 column_set + input + behaviors,不用 form 容器(form 触发 300123 无 submit button /
|
|
910
957
|
// 200621 嵌套 column,submit-typed button 也被 CardKit 2.0 拒绝 HTTP 400)。
|
|
@@ -924,7 +971,7 @@ export class CommandRouter {
|
|
|
924
971
|
{ tag: 'hr' },
|
|
925
972
|
{
|
|
926
973
|
tag: 'div',
|
|
927
|
-
text: { tag: 'lark_md', content: '💡
|
|
974
|
+
text: { tag: 'lark_md', content: '💡 输入新内容后按回车提交' },
|
|
928
975
|
},
|
|
929
976
|
{
|
|
930
977
|
tag: 'column_set',
|
|
@@ -1098,11 +1145,11 @@ export class CommandRouter {
|
|
|
1098
1145
|
try {
|
|
1099
1146
|
card =
|
|
1100
1147
|
targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
|
|
1101
|
-
? this.cmdLs([targetPath], ctx, offset, value.root)
|
|
1102
|
-
: this.cmdLs([], ctx, offset, value.root);
|
|
1148
|
+
? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
|
|
1149
|
+
: this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1103
1150
|
}
|
|
1104
1151
|
catch {
|
|
1105
|
-
card = this.cmdLs([], ctx, offset, value.root);
|
|
1152
|
+
card = this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1106
1153
|
}
|
|
1107
1154
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1108
1155
|
}
|
|
@@ -1132,11 +1179,39 @@ export class CommandRouter {
|
|
|
1132
1179
|
if (!isDir) {
|
|
1133
1180
|
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
1134
1181
|
}
|
|
1135
|
-
const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root);
|
|
1182
|
+
const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root, value.q);
|
|
1136
1183
|
// 直接更新卡片,与 ls.browse/ls.refresh 行为一致
|
|
1137
1184
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1138
1185
|
return { toast: { type: 'success', content: '' } };
|
|
1139
1186
|
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Handle ls.filter: 按搜索框提交的关键词重新渲染当前目录卡。
|
|
1189
|
+
*
|
|
1190
|
+
* offset 恒重置为 0(新筛选从第 1 页开始)。路径校验与 TOCTOU 兜底照
|
|
1191
|
+
* handleLsPage——校验失败只回错误 toast,绝不动卡片(否则用户看到一张空卡)。
|
|
1192
|
+
* ls.browse / ls.switch 的 value 里刻意不带 q,换目录即清除筛选(设计文档 §2.3)。
|
|
1193
|
+
*/
|
|
1194
|
+
async handleLsFilter(value, ctx) {
|
|
1195
|
+
const targetPath = value.path;
|
|
1196
|
+
if (!targetPath) {
|
|
1197
|
+
return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
|
|
1198
|
+
}
|
|
1199
|
+
const q = searchQueryFrom(value);
|
|
1200
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
1201
|
+
let isDir;
|
|
1202
|
+
try {
|
|
1203
|
+
isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
isDir = false;
|
|
1207
|
+
}
|
|
1208
|
+
if (!isDir) {
|
|
1209
|
+
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
1210
|
+
}
|
|
1211
|
+
const card = this.cmdLs([resolvedTarget], ctx, 0, value.root, q);
|
|
1212
|
+
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1213
|
+
return { toast: { type: 'success', content: q ? `筛选:"${q}"` : '已清除筛选' } };
|
|
1214
|
+
}
|
|
1140
1215
|
/**
|
|
1141
1216
|
* Handle resume.page: paginate the /resume session list in place.
|
|
1142
1217
|
* Mirrors handleLsPage — updates the same card, never sends a new one.
|
|
@@ -1259,18 +1334,19 @@ export class CommandRouter {
|
|
|
1259
1334
|
}
|
|
1260
1335
|
/**
|
|
1261
1336
|
* W2.8 单源:重建 order/ws 列表卡(不投递,card 进 callback 响应体用)。
|
|
1337
|
+
* `q` 只服务 ws 卡的关键词筛选;order 卡无此功能,不透传。
|
|
1262
1338
|
*/
|
|
1263
|
-
rebuildListCard(kind, offset, ctx) {
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1339
|
+
rebuildListCard(kind, offset, ctx, q) {
|
|
1340
|
+
if (kind === 'order')
|
|
1341
|
+
return this.cmdOrder([], ctx, offset).card;
|
|
1342
|
+
return this.cmdWs([], ctx, offset, q).card;
|
|
1267
1343
|
}
|
|
1268
1344
|
/**
|
|
1269
1345
|
* W2.8 单源:「重建列表卡 → updateCardInPlace」原地刷新骨架(列表卡删除/
|
|
1270
|
-
*
|
|
1346
|
+
* 翻页/移除/排序/筛选动作共用)。返回重建后的卡片;无卡时返回 undefined 且跳过更新。
|
|
1271
1347
|
*/
|
|
1272
1348
|
async refreshListCard(kind, value, ctx) {
|
|
1273
|
-
const card = this.rebuildListCard(kind, payloadOffset(value), ctx);
|
|
1349
|
+
const card = this.rebuildListCard(kind, payloadOffset(value), ctx, value.q);
|
|
1274
1350
|
if (!card)
|
|
1275
1351
|
return undefined;
|
|
1276
1352
|
await this.bridge.updateCardInPlace(card, ctx);
|
|
@@ -1333,7 +1409,7 @@ export class CommandRouter {
|
|
|
1333
1409
|
return { toast: { type: 'success', content: '' } };
|
|
1334
1410
|
}
|
|
1335
1411
|
/**
|
|
1336
|
-
* Handle order.aliasEdit: 展示别名编辑卡(input
|
|
1412
|
+
* Handle order.aliasEdit: 展示别名编辑卡(input,回车/完成键提交)。
|
|
1337
1413
|
* 有别名时预填;提交 `order.aliasInput` 处理(含校验与唯一性)。
|
|
1338
1414
|
*/
|
|
1339
1415
|
/**
|
|
@@ -1353,7 +1429,7 @@ export class CommandRouter {
|
|
|
1353
1429
|
}
|
|
1354
1430
|
// 列表卡显示的是截断版(≤100 字符 + ...),编辑卡预览保持一致
|
|
1355
1431
|
const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
|
|
1356
|
-
// CardKit 2.0 input
|
|
1432
|
+
// CardKit 2.0 input 提交(回车/完成键;input_value 经 raw 回传,红线)。
|
|
1357
1433
|
// 不用 form 容器(触发 300123 无 submit button / 200621 嵌套 column)。
|
|
1358
1434
|
const editCard = {
|
|
1359
1435
|
schema: '2.0',
|
|
@@ -1408,7 +1484,7 @@ export class CommandRouter {
|
|
|
1408
1484
|
const order = this.orderStore.get().find((o) => o.id === value.orderId);
|
|
1409
1485
|
return this.showOrderEditCard(value, ctx, {
|
|
1410
1486
|
title: '✏️ 给指令起别名',
|
|
1411
|
-
hint: '💡
|
|
1487
|
+
hint: '💡 输入别名名后按回车提交;删除别名请点列表卡别名旁的 ✕',
|
|
1412
1488
|
inputName: 'aliasName',
|
|
1413
1489
|
placeholder: '输入 $别名名(如 all)...',
|
|
1414
1490
|
defaultValue: order?.alias ?? '',
|
|
@@ -1452,7 +1528,7 @@ export class CommandRouter {
|
|
|
1452
1528
|
});
|
|
1453
1529
|
}
|
|
1454
1530
|
/**
|
|
1455
|
-
* Handle order.textEdit: 展示指令文本编辑卡(input
|
|
1531
|
+
* Handle order.textEdit: 展示指令文本编辑卡(input,回车/完成键提交)。
|
|
1456
1532
|
* 预填当前 text(完整内容,不截断——input 组件有自有滚动条);
|
|
1457
1533
|
* 提交 `order.textInput` 处理(trim + 长度校验 + OrderStore.updateText)。
|
|
1458
1534
|
* 完全镜像 handleOrderAliasEdit 的 4 段结构(reload → find → 构造 editCard →
|
|
@@ -1464,7 +1540,7 @@ export class CommandRouter {
|
|
|
1464
1540
|
// input 组件 default_value 预填完整 text(不受截断影响),提交后生效完整文本
|
|
1465
1541
|
return this.showOrderEditCard(value, ctx, {
|
|
1466
1542
|
title: '✏️ 编辑指令',
|
|
1467
|
-
hint: '💡
|
|
1543
|
+
hint: '💡 修改后按回车提交;空白提交 = 报错;最多 200 字符',
|
|
1468
1544
|
inputName: 'text',
|
|
1469
1545
|
placeholder: '输入新的指令文本...',
|
|
1470
1546
|
defaultValue: order?.text ?? '',
|
|
@@ -1566,9 +1642,18 @@ export class CommandRouter {
|
|
|
1566
1642
|
return { toast: { type: 'error', content: paging.error } };
|
|
1567
1643
|
}
|
|
1568
1644
|
// cmdWs internally clamps stale/out-of-range offsets
|
|
1569
|
-
await this.refreshListCard('ws', { offset: paging.offset }, ctx);
|
|
1645
|
+
await this.refreshListCard('ws', { offset: paging.offset, q: value.q }, ctx);
|
|
1570
1646
|
return { toast: { type: 'success', content: '' } };
|
|
1571
1647
|
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Handle ws.filter: 按搜索框提交的关键词重新渲染列表卡。
|
|
1650
|
+
* offset 恒重置为 0(新筛选从第 1 页开始);提交空串 = 清除筛选。
|
|
1651
|
+
*/
|
|
1652
|
+
async handleWsFilter(value, ctx) {
|
|
1653
|
+
const q = searchQueryFrom(value);
|
|
1654
|
+
await this.refreshListCard('ws', { offset: 0, q }, ctx);
|
|
1655
|
+
return { toast: { type: 'success', content: q ? `筛选:"${q}"` : '已清除筛选' } };
|
|
1656
|
+
}
|
|
1572
1657
|
/**
|
|
1573
1658
|
* Handle ws.sort: toggle sort preference and refresh the /ws list card.
|
|
1574
1659
|
* Sort preference is memory-only (not persisted); restart resets to 'recent'.
|
|
@@ -1578,7 +1663,7 @@ export class CommandRouter {
|
|
|
1578
1663
|
const current = this.wsSortPreference.get(userId) ?? 'recent';
|
|
1579
1664
|
const next = current === 'recent' ? 'alpha' : 'recent';
|
|
1580
1665
|
this.wsSortPreference.set(userId, next);
|
|
1581
|
-
await this.refreshListCard('ws', { offset: 0 }, ctx);
|
|
1666
|
+
await this.refreshListCard('ws', { offset: 0, q: value.q }, ctx);
|
|
1582
1667
|
const nextLabel = next === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
|
|
1583
1668
|
return {
|
|
1584
1669
|
toast: { type: 'success', content: `已切换为 ${nextLabel}` },
|
|
@@ -1713,7 +1798,7 @@ export class CommandRouter {
|
|
|
1713
1798
|
}
|
|
1714
1799
|
case 'config.input': {
|
|
1715
1800
|
// Handle input field: value.key is the field key.
|
|
1716
|
-
// 2026-07-04: CardKit 2.0 input
|
|
1801
|
+
// 2026-07-04: CardKit 2.0 input 提交(回车/完成键)触发 callback,回传 input_value。
|
|
1717
1802
|
// Fall back to formValue[key]: the SDK currently reports input values
|
|
1718
1803
|
// via action.formValue for form-wrapped inputs.
|
|
1719
1804
|
const key = value.key;
|
|
@@ -2581,12 +2666,15 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2581
2666
|
* @param rootDir 浏览起点:卡片「返回」按钮回到这里。省略时默认 = 本次列出的
|
|
2582
2667
|
* 目录(`/ls <dir>` 的起点即该目录),因此「返回」不会把用户
|
|
2583
2668
|
* 丢回 workspace cwd(用户明确反馈过)。
|
|
2669
|
+
* @param q 关键词筛选:只匹配**当前层**条目名(不递归),大小写不敏感。
|
|
2670
|
+
* 省略/空 = 无筛选。
|
|
2584
2671
|
*/
|
|
2585
|
-
cmdLs(args, ctx, offset = 0, rootDir) {
|
|
2672
|
+
cmdLs(args, ctx, offset = 0, rootDir, q) {
|
|
2586
2673
|
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
2587
2674
|
if (!cwd) {
|
|
2588
2675
|
return { text: '请先使用 /cd <path> 设置工作目录' };
|
|
2589
2676
|
}
|
|
2677
|
+
const filter = q?.trim() || undefined;
|
|
2590
2678
|
// args can be:
|
|
2591
2679
|
// - [] : list current cwd
|
|
2592
2680
|
// - [dirName] : list subdirectory if it exists
|
|
@@ -2637,8 +2725,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2637
2725
|
size: fs.statSync(path.join(targetDir, f.name)).size,
|
|
2638
2726
|
})),
|
|
2639
2727
|
];
|
|
2728
|
+
// 关键词筛选:只匹配当前层条目名(不递归子目录)。大小写不敏感的子串包含
|
|
2729
|
+
// ——禁止 new RegExp(q)(用户搜 `foo(` 会直接抛)。插在排序之后、分页之前。
|
|
2730
|
+
const ql = filter?.toLowerCase();
|
|
2731
|
+
const visibleItems = ql
|
|
2732
|
+
? allItems.filter((i) => i.name.toLowerCase().includes(ql))
|
|
2733
|
+
: allItems;
|
|
2734
|
+
const visibleDirCount = visibleItems.reduce((n, i) => n + (i.isDir ? 1 : 0), 0);
|
|
2735
|
+
const visibleFileCount = visibleItems.length - visibleDirCount;
|
|
2640
2736
|
// Pagination calculations(clamp 到最后一页边界,原先不 clamp 会显示空页)
|
|
2641
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(
|
|
2737
|
+
const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(visibleItems, offset, CommandRouter.LS_PAGE_SIZE);
|
|
2642
2738
|
// Check if we need to show parent directory button
|
|
2643
2739
|
const parentDir = path.dirname(targetDir);
|
|
2644
2740
|
const hasParent = parentDir !== targetDir;
|
|
@@ -2684,6 +2780,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2684
2780
|
path: targetDir,
|
|
2685
2781
|
offset: safeOffset,
|
|
2686
2782
|
root: browseRoot,
|
|
2783
|
+
...(filter ? { q: filter } : {}),
|
|
2687
2784
|
},
|
|
2688
2785
|
},
|
|
2689
2786
|
],
|
|
@@ -2735,8 +2832,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2735
2832
|
// Note: div+elements is not supported in CardKit 2.0, use column_set instead
|
|
2736
2833
|
const elements = [];
|
|
2737
2834
|
// Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
|
|
2738
|
-
// 渲染两遍——2026-09-10
|
|
2739
|
-
|
|
2835
|
+
// 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。筛选态一律用
|
|
2836
|
+
// 过滤后的计数,否则标题说 12 项、列表只有 3 项,卡片自相矛盾。
|
|
2837
|
+
const status = `\n共 ${visibleDirCount} 目录, ${visibleFileCount} 文件`;
|
|
2740
2838
|
// Header info + navigation buttons - show targetDir in header
|
|
2741
2839
|
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
|
|
2742
2840
|
elements.push({
|
|
@@ -2744,54 +2842,81 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2744
2842
|
columns: headerElements.map((e) => ({ tag: 'column', width: 'auto', elements: [e] })),
|
|
2745
2843
|
});
|
|
2746
2844
|
elements.push({ tag: 'hr' });
|
|
2747
|
-
//
|
|
2845
|
+
// 搜索行:头部导航按钮之下、内容区之上(主导航在上、过滤紧随其后、内容区
|
|
2846
|
+
// 在下)。path/root 必须进 callback value,否则提交后 handler 不知道刷哪。
|
|
2847
|
+
elements.push(searchBar({
|
|
2848
|
+
cmd: 'ls.filter',
|
|
2849
|
+
placeholder: '搜索本层目录/文件名,输完回车',
|
|
2850
|
+
currentQuery: filter,
|
|
2851
|
+
extra: { path: targetDir, root: browseRoot },
|
|
2852
|
+
}));
|
|
2853
|
+
if (filter) {
|
|
2854
|
+
elements.push({
|
|
2855
|
+
tag: 'div',
|
|
2856
|
+
text: {
|
|
2857
|
+
tag: 'lark_md',
|
|
2858
|
+
content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 项(点「清除筛选」取消)`,
|
|
2859
|
+
},
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
// Section 1 + 2: Directories / Files (show only items on current page)
|
|
2748
2863
|
const pageDirs = pageItems.filter((i) => i.isDir);
|
|
2749
2864
|
const pageFiles = pageItems.filter((i) => !i.isDir);
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
tag: 'column_set',
|
|
2758
|
-
flex_mode: 'none',
|
|
2759
|
-
columns: [{ tag: 'column', width: 'auto', elements: [dirButton(d.name)] }],
|
|
2760
|
-
});
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
else if (dirs.length === 0) {
|
|
2764
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无子目录' } });
|
|
2865
|
+
if (filter && visibleItems.length === 0) {
|
|
2866
|
+
// 筛选零命中:两个区标题都不渲染(两行空区标题只是噪音),搜索行仍在,
|
|
2867
|
+
// 用户可直接改词再回车,或点「清除筛选」。
|
|
2868
|
+
elements.push({
|
|
2869
|
+
tag: 'div',
|
|
2870
|
+
text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
|
|
2871
|
+
});
|
|
2765
2872
|
}
|
|
2766
2873
|
else {
|
|
2767
2874
|
elements.push({
|
|
2768
2875
|
tag: 'div',
|
|
2769
|
-
text: { tag: 'lark_md', content:
|
|
2876
|
+
text: { tag: 'lark_md', content: `**📂 目录 (${visibleDirCount})**` },
|
|
2770
2877
|
});
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2878
|
+
if (pageDirs.length > 0) {
|
|
2879
|
+
for (const d of pageDirs) {
|
|
2880
|
+
elements.push({
|
|
2881
|
+
tag: 'column_set',
|
|
2882
|
+
flex_mode: 'none',
|
|
2883
|
+
columns: [{ tag: 'column', width: 'auto', elements: [dirButton(d.name)] }],
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
else if (visibleDirCount === 0) {
|
|
2888
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无子目录' } });
|
|
2889
|
+
}
|
|
2890
|
+
else {
|
|
2780
2891
|
elements.push({
|
|
2781
|
-
tag: '
|
|
2782
|
-
|
|
2783
|
-
columns: [{ tag: 'column', width: 'auto', elements: [fileButton(f.name, f.size)] }],
|
|
2892
|
+
tag: 'div',
|
|
2893
|
+
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无目录)` },
|
|
2784
2894
|
});
|
|
2785
2895
|
}
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无文件' } });
|
|
2789
|
-
}
|
|
2790
|
-
else {
|
|
2896
|
+
elements.push({ tag: 'hr' });
|
|
2897
|
+
// Section 2: Files
|
|
2791
2898
|
elements.push({
|
|
2792
2899
|
tag: 'div',
|
|
2793
|
-
text: { tag: 'lark_md', content:
|
|
2900
|
+
text: { tag: 'lark_md', content: `**📄 文件 (${visibleFileCount})**` },
|
|
2794
2901
|
});
|
|
2902
|
+
if (pageFiles.length > 0) {
|
|
2903
|
+
for (const f of pageFiles) {
|
|
2904
|
+
elements.push({
|
|
2905
|
+
tag: 'column_set',
|
|
2906
|
+
flex_mode: 'none',
|
|
2907
|
+
columns: [{ tag: 'column', width: 'auto', elements: [fileButton(f.name, f.size)] }],
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
else if (visibleFileCount === 0) {
|
|
2912
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无文件' } });
|
|
2913
|
+
}
|
|
2914
|
+
else {
|
|
2915
|
+
elements.push({
|
|
2916
|
+
tag: 'div',
|
|
2917
|
+
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无文件)` },
|
|
2918
|
+
});
|
|
2919
|
+
}
|
|
2795
2920
|
}
|
|
2796
2921
|
// Pagination bar (only shown when there are more items than PAGE_SIZE)
|
|
2797
2922
|
if (hasPagination) {
|
|
@@ -2800,7 +2925,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2800
2925
|
offset: safeOffset,
|
|
2801
2926
|
pageSize: CommandRouter.LS_PAGE_SIZE,
|
|
2802
2927
|
total: totalCount,
|
|
2803
|
-
extra: { path: targetDir, root: browseRoot },
|
|
2928
|
+
extra: { path: targetDir, root: browseRoot, ...(filter ? { q: filter } : {}) },
|
|
2804
2929
|
label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
|
|
2805
2930
|
});
|
|
2806
2931
|
elements.push({ tag: 'hr' });
|
|
@@ -2917,7 +3042,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2917
3042
|
},
|
|
2918
3043
|
};
|
|
2919
3044
|
}
|
|
2920
|
-
|
|
3045
|
+
/**
|
|
3046
|
+
* `/ws` 实现(public:测试直接调用,替代 as unknown as,与 cmdLs 同款)。
|
|
3047
|
+
*
|
|
3048
|
+
* @param offset list 分支的分页起点(条目数)
|
|
3049
|
+
* @param q list 分支的关键词筛选:name 或完整 path 命中即保留,大小写
|
|
3050
|
+
* 不敏感。省略/空 = 无筛选。
|
|
3051
|
+
*/
|
|
3052
|
+
cmdWs(args, ctx, offset = 0, q) {
|
|
2921
3053
|
const sub = args[0]?.toLowerCase();
|
|
2922
3054
|
switch (sub) {
|
|
2923
3055
|
case 'save': {
|
|
@@ -2966,6 +3098,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2966
3098
|
}
|
|
2967
3099
|
case 'list':
|
|
2968
3100
|
default: {
|
|
3101
|
+
const filter = q?.trim() || undefined;
|
|
2969
3102
|
const entries = this.workspaceStore.list();
|
|
2970
3103
|
const currentCwd = this.sessionStore.getCwd(ctx.userId);
|
|
2971
3104
|
const sortMode = this.wsSortPreference.get(ctx.userId) ?? 'recent';
|
|
@@ -2982,9 +3115,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2982
3115
|
// Alphabetical by alias name
|
|
2983
3116
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
2984
3117
|
}
|
|
3118
|
+
// 关键词筛选:alias name 或完整 path 命中即保留,大小写不敏感的子串包含
|
|
3119
|
+
// (禁止 new RegExp(q)——用户搜 `foo(` 会直接抛)。插在排序之后、分页
|
|
3120
|
+
// 之前:先分页再过滤会出现某页只剩 1 条的残页,且翻页时筛选像是丢了。
|
|
3121
|
+
const ql = filter?.toLowerCase();
|
|
3122
|
+
const visible = ql
|
|
3123
|
+
? entries.filter((e) => e.name.toLowerCase().includes(ql) || e.path.toLowerCase().includes(ql))
|
|
3124
|
+
: entries;
|
|
2985
3125
|
// Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
|
|
2986
3126
|
// offsets so prev/next always step by WS_PAGE_SIZE.
|
|
2987
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(
|
|
3127
|
+
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(visible, offset, WS_PAGE_SIZE);
|
|
2988
3128
|
// Build body elements: current cwd + workspace list with dividers
|
|
2989
3129
|
const bodyElements = [];
|
|
2990
3130
|
// Current working directory section
|
|
@@ -2992,13 +3132,35 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2992
3132
|
tag: 'div',
|
|
2993
3133
|
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
|
|
2994
3134
|
});
|
|
2995
|
-
|
|
2996
|
-
|
|
3135
|
+
bodyElements.push({ tag: 'hr' });
|
|
3136
|
+
// 搜索行:「当前工作目录」之下、排序行之上(与 /ls 同一视觉动线:主信息
|
|
3137
|
+
// 在上、过滤紧随其后、列表在下)
|
|
3138
|
+
bodyElements.push(searchBar({
|
|
3139
|
+
cmd: 'ws.filter',
|
|
3140
|
+
placeholder: '搜索名称或路径,输完回车',
|
|
3141
|
+
currentQuery: filter,
|
|
3142
|
+
}));
|
|
3143
|
+
if (filter) {
|
|
3144
|
+
bodyElements.push({
|
|
3145
|
+
tag: 'div',
|
|
3146
|
+
text: {
|
|
3147
|
+
tag: 'lark_md',
|
|
3148
|
+
content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 个 workspace(点「清除筛选」取消)`,
|
|
3149
|
+
},
|
|
3150
|
+
});
|
|
3151
|
+
}
|
|
3152
|
+
if (entries.length === 0 && !filter) {
|
|
2997
3153
|
bodyElements.push({
|
|
2998
3154
|
tag: 'div',
|
|
2999
3155
|
text: { tag: 'lark_md', content: '没有保存的 workspace' },
|
|
3000
3156
|
});
|
|
3001
3157
|
}
|
|
3158
|
+
else if (filter && visible.length === 0) {
|
|
3159
|
+
bodyElements.push({
|
|
3160
|
+
tag: 'div',
|
|
3161
|
+
text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3002
3164
|
else {
|
|
3003
3165
|
// Sort mode indicator + toggle button: placed above the list so the user
|
|
3004
3166
|
// sees "current mode" and "switch to X" before scanning entries.
|
|
@@ -3031,7 +3193,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3031
3193
|
text: { tag: 'plain_text', content: `切换为 ${switchLabel}` },
|
|
3032
3194
|
type: 'default',
|
|
3033
3195
|
size: 'small',
|
|
3034
|
-
behaviors: [
|
|
3196
|
+
behaviors: [
|
|
3197
|
+
{
|
|
3198
|
+
type: 'callback',
|
|
3199
|
+
value: { cmd: 'ws.sort', ...(filter ? { q: filter } : {}) },
|
|
3200
|
+
},
|
|
3201
|
+
],
|
|
3035
3202
|
},
|
|
3036
3203
|
],
|
|
3037
3204
|
},
|
|
@@ -3059,7 +3226,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3059
3226
|
behaviors: [
|
|
3060
3227
|
{
|
|
3061
3228
|
type: 'callback',
|
|
3062
|
-
value: {
|
|
3229
|
+
value: {
|
|
3230
|
+
cmd: 'ws.use',
|
|
3231
|
+
name: entry.name,
|
|
3232
|
+
offset: safeOffset,
|
|
3233
|
+
...(filter ? { q: filter } : {}),
|
|
3234
|
+
},
|
|
3063
3235
|
},
|
|
3064
3236
|
],
|
|
3065
3237
|
},
|
|
@@ -3077,7 +3249,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3077
3249
|
behaviors: [
|
|
3078
3250
|
{
|
|
3079
3251
|
type: 'callback',
|
|
3080
|
-
value: {
|
|
3252
|
+
value: {
|
|
3253
|
+
cmd: 'ws.remove',
|
|
3254
|
+
name: entry.name,
|
|
3255
|
+
offset: safeOffset,
|
|
3256
|
+
...(filter ? { q: filter } : {}),
|
|
3257
|
+
},
|
|
3081
3258
|
},
|
|
3082
3259
|
],
|
|
3083
3260
|
},
|
|
@@ -3096,6 +3273,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3096
3273
|
offset: safeOffset,
|
|
3097
3274
|
pageSize: WS_PAGE_SIZE,
|
|
3098
3275
|
total: totalCount,
|
|
3276
|
+
extra: filter ? { q: filter } : {},
|
|
3099
3277
|
label: `**${currentPage}/${totalPages}**(${totalCount})`,
|
|
3100
3278
|
prevText: '⬅',
|
|
3101
3279
|
nextText: '➡',
|
|
@@ -3808,7 +3986,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3808
3986
|
],
|
|
3809
3987
|
},
|
|
3810
3988
|
{
|
|
3811
|
-
// 编辑:弹蓝色 header 输入卡(CardKit 2.0 input
|
|
3989
|
+
// 编辑:弹蓝色 header 输入卡(CardKit 2.0 input,回车提交),
|
|
3812
3990
|
// 镜像 order.aliasEdit 流程。order.textEdit 是 control-only action
|
|
3813
3991
|
// (isImmediateAction 注册),不 spawn agent。
|
|
3814
3992
|
tag: 'column',
|