lark-remote 0.1.2 → 0.1.3

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.
Files changed (44) hide show
  1. package/README.en.md +2 -5
  2. package/README.md +1 -4
  3. package/dist/bridge/index.js +9 -1
  4. package/dist/card/card-budget.js +1 -2
  5. package/dist/card/run-renderer.js +1 -0
  6. package/dist/card/run-state.d.ts +4 -0
  7. package/dist/config/codex-config.d.ts +3 -5
  8. package/dist/config/codex-config.js +7 -7
  9. package/dist/config/dir.d.ts +1 -0
  10. package/dist/config/dir.js +4 -0
  11. package/dist/config/index.d.ts +0 -10
  12. package/dist/config/index.js +0 -11
  13. package/dist/connector/index.js +28 -0
  14. package/dist/index.js +71 -20
  15. package/dist/router/config/codex.js +8 -12
  16. package/dist/router/index.d.ts +14 -1
  17. package/dist/router/index.js +276 -36
  18. package/dist/router/utils.d.ts +3 -0
  19. package/dist/router/utils.js +15 -3
  20. package/dist/runner/claude/runner.d.ts +0 -1
  21. package/dist/runner/claude/runner.js +1 -1
  22. package/dist/runner/codex/runner.d.ts +0 -2
  23. package/dist/runner/codex/runner.js +1 -1
  24. package/dist/runner/common/spawning-runner.d.ts +0 -1
  25. package/dist/runner/common/spawning-runner.js +2 -4
  26. package/dist/runner/kimi/runner.d.ts +0 -1
  27. package/dist/runner/kimi/runner.js +1 -1
  28. package/dist/runner/opencode/runner.d.ts +0 -2
  29. package/dist/runner/opencode/runner.js +1 -1
  30. package/dist/runner/pi/runner.d.ts +0 -1
  31. package/dist/runner/pi/runner.js +1 -1
  32. package/dist/runner/probe.d.ts +45 -0
  33. package/dist/runner/probe.js +87 -0
  34. package/dist/runner/types.d.ts +3 -0
  35. package/dist/session/claude/sessions.d.ts +1 -1
  36. package/dist/session/claude/sessions.js +77 -31
  37. package/dist/session/codex/rollout-reader.js +9 -0
  38. package/dist/session/kimi/sessions.d.ts +3 -1
  39. package/dist/session/kimi/sessions.js +9 -3
  40. package/dist/session/opencode/sessions.d.ts +0 -2
  41. package/dist/session/opencode/sessions.js +1 -1
  42. package/dist/startup-contact.d.ts +4 -2
  43. package/dist/startup-contact.js +7 -4
  44. package/package.json +39 -4
@@ -6,14 +6,12 @@ export class CodexConfigBuilder {
6
6
  buildFields(displayConfig) {
7
7
  const fields = [];
8
8
  // 从 codex config.toml 动态读取 provider 和 model
9
- const codexBinary = displayConfig.agents?.codex?.binary ?? 'codex';
10
- const codexCfg = loadCodexConfig({ binary: codexBinary });
9
+ const codexCfg = loadCodexConfig();
11
10
  const codexProviderNames = codexCfg.providerNames;
12
11
  const currentModelProvider = displayConfig.agents?.codex?.modelProvider ?? codexCfg.currentProvider;
13
12
  const currentCodexModel = displayConfig.agents?.codex?.model ?? codexCfg.currentModel;
14
- const currentReasoningEffort = displayConfig.agents?.codex?.reasoningEffort ??
15
- getDefaultReasoningEffort(currentCodexModel, codexBinary);
16
- // 传入当前 provider 过滤模型列���
13
+ const currentReasoningEffort = displayConfig.agents?.codex?.reasoningEffort ?? getDefaultReasoningEffort(currentCodexModel);
14
+ // 传入当前 provider 过滤模型列表
17
15
  const codexModelOptions = codexCfg.modelOptions(currentModelProvider);
18
16
  fields.push({
19
17
  key: 'agents.codex.modelProvider',
@@ -42,7 +40,7 @@ export class CodexConfigBuilder {
42
40
  label: '推理强度',
43
41
  type: 'select',
44
42
  // 档位按目录实际声明透传(P2-5):codex 支持 none/Custom,不再按标准枚举过滤
45
- options: getReasoningEffortOptions(currentCodexModel, codexBinary),
43
+ options: getReasoningEffortOptions(currentCodexModel),
46
44
  currentValue: currentReasoningEffort,
47
45
  });
48
46
  return fields;
@@ -50,10 +48,9 @@ export class CodexConfigBuilder {
50
48
  handleFieldChange(key, value, config) {
51
49
  const patches = [];
52
50
  patches.push({ key, value });
53
- // provider 变更时,重置 model 为�� provider 的首个模型
51
+ // provider 变更时,重置 model 为新 provider 的首个模型
54
52
  if (key === 'agents.codex.modelProvider' && typeof value === 'string') {
55
- const codexBinary = config.agents?.codex?.binary ?? 'codex';
56
- const codexCfg = loadCodexConfig({ binary: codexBinary });
53
+ const codexCfg = loadCodexConfig();
57
54
  const newModelOptions = codexCfg.modelOptions(value);
58
55
  const currentModel = config.agents?.codex?.model;
59
56
  const currentModelIsValid = newModelOptions.some((m) => m === currentModel);
@@ -86,14 +83,13 @@ export class CodexConfigBuilder {
86
83
  * 对 undefined 做键删除,P1)。
87
84
  */
88
85
  effortPatchForModel(config, newModel) {
89
- const codexBinary = config.agents?.codex?.binary ?? 'codex';
90
86
  const currentReasoningEffort = config.agents?.codex?.reasoningEffort;
91
- const newModelSupportedEfforts = getReasoningEffortOptions(newModel, codexBinary);
87
+ const newModelSupportedEfforts = getReasoningEffortOptions(newModel);
92
88
  const isCurrentEffortValid = currentReasoningEffort && newModelSupportedEfforts.includes(currentReasoningEffort);
93
89
  if (isCurrentEffortValid)
94
90
  return null;
95
91
  const middle = newModelSupportedEfforts[Math.floor((newModelSupportedEfforts.length - 1) / 2)];
96
- const newEffort = middle ?? getDefaultReasoningEffort(newModel, codexBinary);
92
+ const newEffort = middle ?? getDefaultReasoningEffort(newModel);
97
93
  return { key: 'agents.codex.reasoningEffort', value: newEffort };
98
94
  }
99
95
  }
@@ -73,6 +73,8 @@ interface CardActionResponse {
73
73
  [key: string]: unknown;
74
74
  }
75
75
  export declare class CommandRouter {
76
+ /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
77
+ static readonly VALID_AGENTS: readonly ["claude", "codex", "opencode", "pi", "kimi"];
76
78
  private sessionStore;
77
79
  private bridge;
78
80
  private config;
@@ -187,6 +189,11 @@ export declare class CommandRouter {
187
189
  * Mirrors handleLsPage — updates the same card, never sends a new one.
188
190
  */
189
191
  private handleResumePage;
192
+ /**
193
+ * Handle active.page: paginate the /active run list in place.
194
+ * Mirrors handleResumePage — updates the same card, never sends a new one.
195
+ */
196
+ private handleActivePage;
190
197
  /**
191
198
  * Handle ls.browse: browse directory without switching cwd.
192
199
  */
@@ -223,6 +230,11 @@ export declare class CommandRouter {
223
230
  * Handle order.delete: remove the order and update card in place.
224
231
  */
225
232
  private handleOrderDelete;
233
+ /**
234
+ * Handle order.page: paginate the /order list in place.
235
+ * Mirrors handleLsPage — updates the same card, never sends a new one.
236
+ */
237
+ private handleOrderPage;
226
238
  /**
227
239
  * Handle ws.remove: remove the workspace alias and refresh the /ws list card
228
240
  * in place (mirrors handleOrderDelete). Without this the stale alias would
@@ -273,7 +285,7 @@ export declare class CommandRouter {
273
285
  */
274
286
  private propagateConfigSave;
275
287
  private executeCommand;
276
- private cmdHelp;
288
+ cmdHelp(): CommandResult;
277
289
  private cmdStatus;
278
290
  private cmdPs;
279
291
  private cmdStop;
@@ -307,6 +319,7 @@ export declare class CommandRouter {
307
319
  /**
308
320
  * Build CardKit 2.0 /active card from bridge memory (not file system).
309
321
  * Shows only runs that were started by THIS bridge process.
322
+ * Paginates with ACTIVE_PAGE_SIZE (default 20) items per page.
310
323
  */
311
324
  private buildActiveCardFromMemory;
312
325
  private cmdReconnect;
@@ -13,6 +13,7 @@ import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
13
13
  import { atomicWrite } from '../persistence/atomic-write.js';
14
14
  /** Config card builder - delegates to per-agent builders */
15
15
  import { getConfigBuilder, listRegisteredAgents } from './config/index.js';
16
+ import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
16
17
  import { buildConfigCardFromTabs } from './config/common/render.js';
17
18
  /** Max events to read for auto-resume and /resume cards.
18
19
  * Limits events at the read stage so enforceCardBudget rarely needs to truncate.
@@ -20,6 +21,10 @@ import { buildConfigCardFromTabs } from './config/common/render.js';
20
21
  const AUTO_RESUME_MAX_EVENTS = 5;
21
22
  /** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
22
23
  const RESUME_PAGE_SIZE = 5;
24
+ /** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
25
+ const ACTIVE_PAGE_SIZE = 20;
26
+ /** /order 列表页大小;指令超过此数量时显示分页导航栏。 */
27
+ const ORDER_PAGE_SIZE = 20;
23
28
  /** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
24
29
  const RESUME_CONTENT_PREFETCH = 5;
25
30
  /**
@@ -61,6 +66,7 @@ export function isImmediateAction(cmd) {
61
66
  cmd === 'ls.switch' ||
62
67
  cmd === 'ls.page' ||
63
68
  cmd === 'resume.page' || // control operation: paginate only, never spawns claude
69
+ cmd === 'active.page' || // control operation: paginate active card
64
70
  cmd === 'ws.remove' ||
65
71
  cmd === 'resume.use' ||
66
72
  cmd === 'ws.use' ||
@@ -70,12 +76,15 @@ export function isImmediateAction(cmd) {
70
76
  cmd === 'queue.edit' ||
71
77
  cmd === 'queue.input' ||
72
78
  cmd === 'order.delete' ||
79
+ cmd === 'order.page' ||
73
80
  cmd === 'config.toggle' ||
74
81
  cmd === 'config.set' ||
75
82
  cmd === 'config.input' ||
76
83
  cmd === 'config.save');
77
84
  }
78
85
  export class CommandRouter {
86
+ /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
87
+ static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
79
88
  sessionStore;
80
89
  bridge;
81
90
  config;
@@ -200,10 +209,10 @@ export class CommandRouter {
200
209
  case 'ws.remove':
201
210
  return await this.handleWsRemove(value, ctx);
202
211
  case 'resume.use': {
203
- // resume.use can carry an agent field when called from /resume [agent] list
212
+ // resume.use carries an agent field from /resume [agent] list cards
213
+ // (both active and completed cards include the agent field).
204
214
  const resumeAgent = value.agent ?? this.config.defaultAgent;
205
- // Validate agent is a valid type
206
- const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
215
+ const validAgents = CommandRouter.VALID_AGENTS;
207
216
  const resolvedAgent = validAgents.includes(resumeAgent)
208
217
  ? resumeAgent
209
218
  : this.config.defaultAgent;
@@ -226,6 +235,8 @@ export class CommandRouter {
226
235
  }
227
236
  case 'resume.page':
228
237
  return this.handleResumePage(value, ctx);
238
+ case 'active.page':
239
+ return this.handleActivePage(value, ctx);
229
240
  case 'new-session':
230
241
  this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
231
242
  clearSessionCwd: true,
@@ -260,6 +271,8 @@ export class CommandRouter {
260
271
  return await this.handleQueueInput(value, ctx);
261
272
  case 'order.delete':
262
273
  return await this.handleOrderDelete(value, ctx);
274
+ case 'order.page':
275
+ return await this.handleOrderPage(value, ctx);
263
276
  case 'config.toggle':
264
277
  case 'config.set':
265
278
  case 'config.input':
@@ -633,9 +646,11 @@ export class CommandRouter {
633
646
  const inputTask = this.bridge.getQueuedTask(workspace, messageId);
634
647
  if (!inputTask) {
635
648
  // Task already left the queue (began or was cancelled) before we could
636
- // register the replacement. The user must be told the edit failed.
637
- await this.bridge.sendResult({ text: '⚠️ 任务已不在队列中(可能已开始执行或被撤销)' }, ctx);
638
- return;
649
+ // register the replacement. Return a toast so the edit-form card gets
650
+ // dismissed — sendResult alone leaves the card stuck in the edit state
651
+ // (Feishu keeps the pre-click card when the callback response has no
652
+ // toast/card field).
653
+ return { toast: { type: 'info', content: '任务已不在队列中(可能已开始执行或被撤销)' } };
639
654
  }
640
655
  this.bridge.setTaskReplacement(workspace, messageId, async () => {
641
656
  await this.handle(newMessage, ctx, { cwdOverride: workspace, binding: inputTask.binding });
@@ -767,8 +782,7 @@ export class CommandRouter {
767
782
  */
768
783
  async handleResumePage(value, ctx) {
769
784
  const resumeAgent = value.agent ?? this.config.defaultAgent;
770
- // Validate agent is a valid type (same as resume.use); fall back to default.
771
- const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
785
+ const validAgents = CommandRouter.VALID_AGENTS;
772
786
  const resolvedAgent = validAgents.includes(resumeAgent)
773
787
  ? resumeAgent
774
788
  : this.config.defaultAgent;
@@ -804,6 +818,21 @@ export class CommandRouter {
804
818
  await this.bridge.updateCardInPlace(result.card, ctx);
805
819
  return { toast: { type: 'success', content: '' } };
806
820
  }
821
+ /**
822
+ * Handle active.page: paginate the /active run list in place.
823
+ * Mirrors handleResumePage — updates the same card, never sends a new one.
824
+ */
825
+ async handleActivePage(value, ctx) {
826
+ const rawOffset = Number(value.offset);
827
+ const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0;
828
+ const alignedOffset = Math.floor(offset / ACTIVE_PAGE_SIZE) * ACTIVE_PAGE_SIZE;
829
+ const result = this.cmdActive([], ctx, alignedOffset);
830
+ if (!result.card) {
831
+ return { toast: { type: 'error', content: '当前没有正在进行中的任务' } };
832
+ }
833
+ await this.bridge.updateCardInPlace(result.card, ctx);
834
+ return { toast: { type: 'success', content: '' } };
835
+ }
807
836
  /**
808
837
  * Handle ls.browse: browse directory without switching cwd.
809
838
  */
@@ -886,11 +915,23 @@ export class CommandRouter {
886
915
  }
887
916
  // Delete the order
888
917
  this.orderStore.remove(orderId);
889
- // Refresh the order list and update card in place
890
- const result = this.cmdOrder([], ctx);
918
+ // Refresh the order list and update card in place, preserving current page
919
+ const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
920
+ const result = this.cmdOrder([], ctx, currentOffset);
891
921
  await this.bridge.updateCardInPlace(result.card, ctx);
892
922
  return { toast: { type: 'success', content: '已删除指令' } };
893
923
  }
924
+ /**
925
+ * Handle order.page: paginate the /order list in place.
926
+ * Mirrors handleLsPage — updates the same card, never sends a new one.
927
+ */
928
+ async handleOrderPage(value, ctx) {
929
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
930
+ // cmdOrder internally clamps stale/out-of-range offsets
931
+ const result = this.cmdOrder([], ctx, offset);
932
+ await this.bridge.updateCardInPlace(result.card, ctx);
933
+ return { toast: { type: 'success', content: '' } };
934
+ }
894
935
  /**
895
936
  * Handle ws.remove: remove the workspace alias and refresh the /ws list card
896
937
  * in place (mirrors handleOrderDelete). Without this the stale alias would
@@ -1323,7 +1364,7 @@ export class CommandRouter {
1323
1364
  return await this.cmdReconnect();
1324
1365
  case 'config':
1325
1366
  case 'c':
1326
- return this.cmdConfig(args, ctx);
1367
+ return await this.cmdConfig(args, ctx);
1327
1368
  case 'order':
1328
1369
  case 'o':
1329
1370
  return this.cmdOrder(args, ctx);
@@ -2115,7 +2156,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2115
2156
  const entry = this.sessionStore.get(ctx.userId);
2116
2157
  const cwd = entry?.cwd;
2117
2158
  // Valid agent kinds for the /resume [agent] [N] feature
2118
- const VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
2159
+ const VALID_AGENTS = CommandRouter.VALID_AGENTS;
2119
2160
  // Parse arguments: /resume [agent] [N] or /resume [sessionId] or /resume [agent] [sessionId]
2120
2161
  let agentKind = this.config.defaultAgent;
2121
2162
  let limit;
@@ -2470,7 +2511,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2470
2511
  };
2471
2512
  return { card };
2472
2513
  }
2473
- cmdActive(_args, _ctx) {
2514
+ cmdActive(_args, _ctx, offset = 0) {
2474
2515
  // List all active runs from bridge memory (not from file system scan)
2475
2516
  // New semantics (2026-07-20): only shows runs started by THIS bridge process
2476
2517
  const activeRuns = this.bridge.getActiveRuns();
@@ -2479,18 +2520,40 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2479
2520
  return { text: '当前没有正在进行中的任务' };
2480
2521
  }
2481
2522
  // Build CardKit 2.0 card showing memory-based active runs
2482
- return this.buildActiveCardFromMemory(activeRuns, activeBashRuns);
2523
+ return this.buildActiveCardFromMemory(activeRuns, activeBashRuns, offset);
2483
2524
  }
2484
2525
  /**
2485
2526
  * Build CardKit 2.0 /active card from bridge memory (not file system).
2486
2527
  * Shows only runs that were started by THIS bridge process.
2528
+ * Paginates with ACTIVE_PAGE_SIZE (default 20) items per page.
2487
2529
  */
2488
- buildActiveCardFromMemory(activeRuns, activeBashRuns) {
2530
+ buildActiveCardFromMemory(activeRuns, activeBashRuns, offset = 0) {
2531
+ const totalCount = activeRuns.length + activeBashRuns.length;
2532
+ const pageSize = ACTIVE_PAGE_SIZE;
2533
+ const safeOffset = Math.max(0, Math.min(offset, Math.max(0, totalCount - 1)));
2534
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
2535
+ const currentPage = Math.floor(safeOffset / pageSize) + 1;
2536
+ // Slice: distribute offset across agent runs first, then bash runs
2489
2537
  const elements = [];
2538
+ // Page info
2539
+ if (totalPages > 1) {
2540
+ elements.push({
2541
+ tag: 'div',
2542
+ text: {
2543
+ tag: 'lark_md',
2544
+ content: `**第 ${currentPage}/${totalPages} 页** (共 ${totalCount} 项)`,
2545
+ },
2546
+ });
2547
+ }
2548
+ let remaining = pageSize;
2549
+ let skipped = safeOffset;
2490
2550
  // Agent runs section
2491
- if (activeRuns.length > 0) {
2551
+ if (activeRuns.length > 0 && skipped < activeRuns.length && remaining > 0) {
2492
2552
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 🤖 Agent 任务' } });
2493
- for (const run of activeRuns) {
2553
+ const start = skipped;
2554
+ const end = Math.min(start + remaining, activeRuns.length);
2555
+ for (let i = start; i < end; i++) {
2556
+ const run = activeRuns[i];
2494
2557
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2495
2558
  elements.push({
2496
2559
  tag: 'div',
@@ -2513,11 +2576,19 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2513
2576
  });
2514
2577
  elements.push({ tag: 'hr' });
2515
2578
  }
2579
+ remaining -= end - start;
2580
+ skipped = 0;
2581
+ }
2582
+ else {
2583
+ skipped -= activeRuns.length;
2516
2584
  }
2517
2585
  // Bash runs section
2518
- if (activeBashRuns.length > 0) {
2586
+ if (activeBashRuns.length > 0 && skipped < activeBashRuns.length && remaining > 0) {
2519
2587
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 💻 Bash 命令' } });
2520
- for (const run of activeBashRuns) {
2588
+ const start = skipped;
2589
+ const end = Math.min(start + remaining, activeBashRuns.length);
2590
+ for (let i = start; i < end; i++) {
2591
+ const run = activeBashRuns[i];
2521
2592
  const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2522
2593
  elements.push({
2523
2594
  tag: 'div',
@@ -2538,6 +2609,38 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2538
2609
  elements.push({ tag: 'hr' });
2539
2610
  }
2540
2611
  }
2612
+ // Pagination buttons
2613
+ if (totalPages > 1) {
2614
+ const paginationButtons = [];
2615
+ if (currentPage > 1) {
2616
+ const prevOffset = (currentPage - 2) * pageSize;
2617
+ paginationButtons.push({
2618
+ tag: 'button',
2619
+ text: { tag: 'plain_text', content: '◀ 上一页' },
2620
+ type: 'default',
2621
+ size: 'small',
2622
+ behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: prevOffset } }],
2623
+ });
2624
+ }
2625
+ if (currentPage < totalPages) {
2626
+ const nextOffset = currentPage * pageSize;
2627
+ paginationButtons.push({
2628
+ tag: 'button',
2629
+ text: { tag: 'plain_text', content: '下一页 ▶' },
2630
+ type: 'primary',
2631
+ size: 'small',
2632
+ behaviors: [{ type: 'callback', value: { cmd: 'active.page', offset: nextOffset } }],
2633
+ });
2634
+ }
2635
+ elements.push({
2636
+ tag: 'column_set',
2637
+ columns: paginationButtons.map((btn) => ({
2638
+ tag: 'column',
2639
+ width: 'auto',
2640
+ elements: [btn],
2641
+ })),
2642
+ });
2643
+ }
2541
2644
  const card = {
2542
2645
  schema: '2.0',
2543
2646
  config: { wide_screen_mode: true },
@@ -2558,7 +2661,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2558
2661
  return { text: '重连失败,请检查网络和配置' };
2559
2662
  }
2560
2663
  }
2561
- cmdConfig(args, ctx) {
2664
+ async cmdConfig(args, ctx) {
2665
+ // Ensure probe cache is populated before building the card.
2666
+ await probeAllAgents();
2562
2667
  // Check if this is a direct set command: /config <key> <value>
2563
2668
  // Command-style set: immediate write to disk, clear pendingConfig
2564
2669
  if (args.length >= 2) {
@@ -2604,25 +2709,58 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2604
2709
  // 使用 pendingConfig(若存在)或当前 config
2605
2710
  const displayConfig = this.pendingConfig ?? this.config;
2606
2711
  // 定义字段分组:defaultAgent 选择器 + 该 agent 的配置字段 + idle.watchdogMinutes +
2607
- // output/logging。stopGraceMs / claude.binary 等实现细节走 YAML,不在卡片暴露。
2712
+ // output/logging。stopGraceMs 等实现细节走 YAML,不在卡片暴露。
2608
2713
  // 根据 defaultAgent 构建完整的 Agent 配置字段组
2609
2714
  // 使用 agent config builder 获取该 agent 的配置字段
2610
2715
  const defaultAgent = displayConfig.defaultAgent ?? 'claude';
2611
2716
  const configBuilder = getConfigBuilder(defaultAgent);
2612
- const agentConfigFields = configBuilder.buildFields(displayConfig);
2613
- // 使用共享渲染模块构建 tabs 和卡片
2614
- // 添加 defaultAgent 选择器作为第一个字段
2615
- agentConfigFields.unshift({
2616
- key: 'defaultAgent',
2617
- label: '默认 Agent',
2618
- type: 'select',
2619
- options: listRegisteredAgents(),
2717
+ // Build defaultAgent selector with availability annotations.
2718
+ // Direct inline construction (not via ConfigField) so that text (display label)
2719
+ // and value (agentKind for config storage) can differ — uninstalled agents get
2720
+ // a "⚠️ (未安装)" suffix in the label while the value stays clean.
2721
+ const allAgents = listRegisteredAgents();
2722
+ const agentOptions = allAgents.map((kind) => {
2723
+ const available = getCachedAvailability(kind);
2724
+ const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
2725
+ return { text: { tag: 'plain_text', content: label }, value: kind };
2620
2726
  });
2727
+ const selectedAgent = displayConfig.defaultAgent ?? 'claude';
2728
+ const agentSelector = {
2729
+ tag: 'column_set',
2730
+ flex_mode: 'none',
2731
+ columns: [
2732
+ {
2733
+ tag: 'column',
2734
+ width: 'weighted',
2735
+ weight: 2,
2736
+ vertical_align: 'center',
2737
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: '默认 Agent' } }],
2738
+ },
2739
+ {
2740
+ tag: 'column',
2741
+ width: 'weighted',
2742
+ weight: 3,
2743
+ vertical_align: 'center',
2744
+ elements: [
2745
+ {
2746
+ tag: 'select_static',
2747
+ placeholder: { tag: 'plain_text', content: '请选择' },
2748
+ options: agentOptions,
2749
+ initial_option: selectedAgent,
2750
+ behaviors: [{ type: 'callback', value: { cmd: 'config.set', key: 'defaultAgent' } }],
2751
+ },
2752
+ ],
2753
+ },
2754
+ ],
2755
+ };
2756
+ // Agent config fields — no longer include defaultAgent as a ConfigField
2757
+ // (it's rendered inline above with text/value separation).
2758
+ const agentConfigFieldsNoSelector = configBuilder.buildFields(displayConfig);
2621
2759
  const tabs = [
2622
2760
  {
2623
2761
  id: 'agent',
2624
2762
  label: `🤖 ${agentDisplayName(defaultAgent)}`,
2625
- fields: agentConfigFields,
2763
+ fields: agentConfigFieldsNoSelector,
2626
2764
  },
2627
2765
  {
2628
2766
  id: 'idle',
@@ -2653,11 +2791,30 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2653
2791
  ];
2654
2792
  // 使用共享渲染模块构建卡片
2655
2793
  const card = buildConfigCardFromTabs(tabs, displayConfig);
2794
+ // Inject the agent selector as the first element in the agent tab section,
2795
+ // right after the "**🤖 ..." header, so text/value can differ for uninstalled agents.
2796
+ // NOTE: findIndex relies on the agent tab header starting with "**🤖" — if the
2797
+ // tab label format changes, this fallback will fire and the selector moves to top.
2798
+ const body = card.body;
2799
+ if (body && Array.isArray(body.elements)) {
2800
+ const headerIdx = body.elements.findIndex((el) => typeof el === 'object' &&
2801
+ el !== null &&
2802
+ el.tag === 'div' &&
2803
+ el.text?.tag === 'lark_md' &&
2804
+ (el.text?.content ?? '').startsWith('**🤖'));
2805
+ if (headerIdx >= 0) {
2806
+ body.elements.splice(headerIdx + 1, 0, agentSelector);
2807
+ }
2808
+ else {
2809
+ getLogger().warn('[buildConfigCard] agent tab header not found, falling back to unshift');
2810
+ body.elements.unshift(agentSelector);
2811
+ }
2812
+ }
2656
2813
  return { card };
2657
2814
  }
2658
- cmdOrder(args, _ctx) {
2815
+ cmdOrder(args, _ctx, offset = 0) {
2659
2816
  const sub = args[0]?.toLowerCase();
2660
- // /order — list orders (simple CardKit 2.0 list, no tabs)
2817
+ // /order — list orders (CardKit 2.0 with pagination)
2661
2818
  if (!sub || sub === 'list') {
2662
2819
  this.orderStore.reload();
2663
2820
  const allOrders = this.orderStore.get();
@@ -2666,8 +2823,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2666
2823
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: '暂无指令' } });
2667
2824
  }
2668
2825
  else {
2669
- for (let i = 0; i < allOrders.length; i++) {
2670
- const order = allOrders[i];
2826
+ // Pagination calculations
2827
+ const totalCount = allOrders.length;
2828
+ const totalPages = Math.max(1, Math.ceil(totalCount / ORDER_PAGE_SIZE));
2829
+ // Clamp offset to last page boundary to avoid empty pages
2830
+ const maxOffset = Math.max(0, (totalPages - 1) * ORDER_PAGE_SIZE);
2831
+ const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
2832
+ const currentPage = Math.floor(safeOffset / ORDER_PAGE_SIZE) + 1;
2833
+ const pageOrders = allOrders.slice(safeOffset, safeOffset + ORDER_PAGE_SIZE);
2834
+ const hasPagination = totalCount > ORDER_PAGE_SIZE;
2835
+ for (let i = 0; i < pageOrders.length; i++) {
2836
+ const order = pageOrders[i];
2671
2837
  const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
2672
2838
  // 文字在上方
2673
2839
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: displayText } });
@@ -2700,7 +2866,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2700
2866
  type: 'danger',
2701
2867
  size: 'small',
2702
2868
  behaviors: [
2703
- { type: 'callback', value: { cmd: 'order.delete', orderId: order.id } },
2869
+ {
2870
+ type: 'callback',
2871
+ value: { cmd: 'order.delete', orderId: order.id, offset: safeOffset },
2872
+ },
2704
2873
  ],
2705
2874
  },
2706
2875
  ],
@@ -2708,10 +2877,81 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2708
2877
  ],
2709
2878
  });
2710
2879
  // 非最后一项,添加分隔线
2711
- if (i < allOrders.length - 1) {
2880
+ if (i < pageOrders.length - 1) {
2712
2881
  elements.push({ tag: 'hr' });
2713
2882
  }
2714
2883
  }
2884
+ // Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
2885
+ if (hasPagination) {
2886
+ const hasPrev = safeOffset > 0;
2887
+ const hasNext = safeOffset + ORDER_PAGE_SIZE < totalCount;
2888
+ const pageColumns = [];
2889
+ if (hasPrev) {
2890
+ pageColumns.push({
2891
+ tag: 'column',
2892
+ width: 'auto',
2893
+ vertical_align: 'center',
2894
+ elements: [
2895
+ {
2896
+ tag: 'button',
2897
+ text: { tag: 'plain_text', content: '⬅ 上一页' },
2898
+ type: 'default',
2899
+ size: 'small',
2900
+ behaviors: [
2901
+ {
2902
+ type: 'callback',
2903
+ value: {
2904
+ cmd: 'order.page',
2905
+ offset: safeOffset - ORDER_PAGE_SIZE,
2906
+ },
2907
+ },
2908
+ ],
2909
+ },
2910
+ ],
2911
+ });
2912
+ }
2913
+ pageColumns.push({
2914
+ tag: 'column',
2915
+ width: 'weighted',
2916
+ weight: 1,
2917
+ vertical_align: 'center',
2918
+ elements: [
2919
+ {
2920
+ tag: 'div',
2921
+ text: {
2922
+ tag: 'lark_md',
2923
+ content: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
2924
+ },
2925
+ },
2926
+ ],
2927
+ });
2928
+ if (hasNext) {
2929
+ pageColumns.push({
2930
+ tag: 'column',
2931
+ width: 'auto',
2932
+ vertical_align: 'center',
2933
+ elements: [
2934
+ {
2935
+ tag: 'button',
2936
+ text: { tag: 'plain_text', content: '下一页 ➡' },
2937
+ type: 'default',
2938
+ size: 'small',
2939
+ behaviors: [
2940
+ {
2941
+ type: 'callback',
2942
+ value: {
2943
+ cmd: 'order.page',
2944
+ offset: safeOffset + ORDER_PAGE_SIZE,
2945
+ },
2946
+ },
2947
+ ],
2948
+ },
2949
+ ],
2950
+ });
2951
+ }
2952
+ elements.push({ tag: 'hr' });
2953
+ elements.push({ tag: 'column_set', columns: pageColumns });
2954
+ }
2715
2955
  }
2716
2956
  return {
2717
2957
  card: {
@@ -5,6 +5,8 @@
5
5
  import type { ActiveRunSnapshot } from '../bridge/index.js';
6
6
  export interface SessionDisplayUsage {
7
7
  contextLength?: number;
8
+ /** 当前模型 context window 上限(仅 codex 提供);用于渲染 "Context - X (Y%)"。 */
9
+ contextLimit?: number;
8
10
  compactCount?: number;
9
11
  cacheReadTokens?: number;
10
12
  cacheCreationTokens?: number;
@@ -18,6 +20,7 @@ export declare function activeRunUsage(activeRun: ActiveRunSnapshot | undefined)
18
20
  */
19
21
  export declare function formatUsageStats(usage?: {
20
22
  contextLength?: number;
23
+ contextLimit?: number;
21
24
  compactCount?: number;
22
25
  cacheReadTokens?: number;
23
26
  cacheCreationTokens?: number;
@@ -14,10 +14,18 @@ export function activeRunUsage(activeRun) {
14
14
  }
15
15
  // --- Extracted helpers for refactoring ---
16
16
  /**
17
- * Format number to K unit string, rounding to the nearest integer K
18
- * (e.g., 120000 → 120K, 1500 → 2K, 1950 → 2K). Values < 1000 are returned as-is.
17
+ * Format number to human-readable unit string.
18
+ * - >= 1M: one decimal, M suffix (e.g., 1200000 → 1.2M, 25000000 → 25M)
19
+ * - >= 1K: rounded to nearest integer K (e.g., 1500 → 2K, 120000 → 120K)
20
+ * - < 1K: as-is (e.g., 500 → "500")
19
21
  */
20
22
  function formatTokenK(n) {
23
+ if (n >= 1_000_000) {
24
+ const m = n / 1_000_000;
25
+ const rounded = Math.round(m * 10) / 10;
26
+ // Drop unnecessary ".0" for whole numbers (2.0M → 2M)
27
+ return rounded === Math.floor(rounded) ? `${Math.floor(rounded)}M` : `${rounded}M`;
28
+ }
21
29
  if (n >= 1000) {
22
30
  const k = Math.round(n / 1000);
23
31
  return `${k}K`;
@@ -40,7 +48,11 @@ export function formatUsageStats(usage, options) {
40
48
  }
41
49
  // 2. Context 长度
42
50
  if (usage?.contextLength !== undefined) {
43
- lines.push(`Context - ${formatTokenK(usage.contextLength)}`);
51
+ const ctxLimit = usage.contextLimit;
52
+ const ctxPercent = ctxLimit !== undefined && ctxLimit > 0
53
+ ? ` (${Math.round((usage.contextLength / ctxLimit) * 100)}%)`
54
+ : '';
55
+ lines.push(`Context - ${formatTokenK(usage.contextLength)}${ctxPercent}`);
44
56
  }
45
57
  // 3. Compact 次数
46
58
  if (usage?.compactCount) {