# 定时任务输入框内嵌技能下拉框 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 让定时任务弹窗的"任务指令"输入框复用聊天输入框的技能下拉框 + 蓝色 tag 体验,并让选中的技能在任务执行时真正被调用。

**Architecture:** 抽出无状态共享模块 `skill-picker.ts`(解析/chip/下拉面板,chat 与 cron 共用);cron 弹窗 footer 内联渲染下拉(避开 top-layer `<dialog>` 遮挡 body-portal);后端在 cron 包裹消息前用 `expandCronSkillCommand` 把 `/skillname` 改写成技能 prompt。

**Tech Stack:** TypeScript, lit-html, Vitest。后端 Node + Vitest。

**设计文档:** `docs/superpowers/specs/2026-06-12-cron-task-skill-picker-design.md`

---

## File Structure

**前端(ui/):**
- Create `ui/src/ui/chat/skill-picker.ts` — 无状态共享模块:`SkillPickerData` 类型、`resolveActiveSkill`、`renderSkillChip`、`renderSkillDropdown`、`syncSkillChipIndent`。
- Create `ui/src/ui/chat/skill-picker.node.test.ts` — `resolveActiveSkill` 纯函数单测。
- Create `ui/src/styles/skill-picker.css` — 从 chat layout.css 抽出的通用 `.skill-selector-*` / `.skill-selector-chip` 类。
- Modify `ui/src/styles.css` — import 新 css。
- Modify `ui/src/ui/views/cron-task-dialog.ts` — footer 技能触发器 + 蓝色 tag + 内联下拉。
- Modify `ui/src/styles/cron-task-dialog.css` — cron 侧定位类。
- Modify `ui/src/ui/controllers/cron.ts` — `CronState` 增 `skillDropdownOpen`。
- Modify `ui/src/ui/app.ts` — `@state() skillDropdownOpen`。
- Modify `ui/src/ui/app-view-state.ts` — Pick 增 `skillDropdownOpen`。
- Modify `ui/src/ui/app-render.ts` — 给 cron 弹窗透传技能数据 props + 下拉开关回调。

**后端(src/):**
- Create `src/cron/isolated-agent/skill-command.ts` — `expandCronSkillCommand`。
- Create `src/cron/isolated-agent/skill-command.test.ts` — 改写逻辑单测。
- Modify `src/auto-reply/reply/get-reply-inline-actions.ts` — 导出 `expandBundleCommandPromptTemplate`(供复用)。
- Modify `src/cron/isolated-agent/run.ts` — 在 line 662 构造 `base` 前调用 `expandCronSkillCommand`。

**实现顺序:** 后端先行(独立、可单测),再共享模块,再 cron UI 接线。

---
## Task 1: 导出 expandBundleCommandPromptTemplate 供复用

改写逻辑目前是 `get-reply-inline-actions.ts` 里的私有函数,cron 后端要复用它,先导出。

**Files:**
- Modify: `src/auto-reply/reply/get-reply-inline-actions.ts:102`

- [ ] **Step 1: 给函数加 export**

把 `src/auto-reply/reply/get-reply-inline-actions.ts:102` 的:

```typescript
function expandBundleCommandPromptTemplate(template: string, args?: string): string {
```

改为:

```typescript
export function expandBundleCommandPromptTemplate(template: string, args?: string): string {
```

- [ ] **Step 2: 类型检查通过**

Run: `pnpm tsgo:test:src`
Expected: PASS(无新增报错)

- [ ] **Step 3: Commit**

```bash
git add src/auto-reply/reply/get-reply-inline-actions.ts
git commit -m "refactor(reply): 导出 expandBundleCommandPromptTemplate 供 cron 复用"
```

---

## Task 2: 后端 expandCronSkillCommand

把 cron 的 `payload.message` 开头的 `/skillname args` 改写成技能 prompt。匹配复用
`resolveSkillCommandInvocation` + `listSkillCommandsForWorkspace`(已有),改写复用 Task 1
导出的 `expandBundleCommandPromptTemplate`。

**Files:**
- Create: `src/cron/isolated-agent/skill-command.ts`
- Test: `src/cron/isolated-agent/skill-command.test.ts`

- [ ] **Step 1: 写失败测试**

Create `src/cron/isolated-agent/skill-command.test.ts`:

```typescript
import { describe, expect, it, vi } from "vitest";

vi.mock("../../auto-reply/skill-commands.js", () => ({
  listSkillCommandsForWorkspace: vi.fn(),
}));

import { listSkillCommandsForWorkspace } from "../../auto-reply/skill-commands.js";
import { expandCronSkillCommand } from "./skill-command.js";

const mockList = vi.mocked(listSkillCommandsForWorkspace);
const cfg = {} as never;
const base = { workspaceDir: "/ws", cfg, agentId: "main" };

describe("expandCronSkillCommand", () => {
  it("returns message unchanged when no leading slash", async () => {
    mockList.mockReturnValue([]);
    const out = await expandCronSkillCommand({ ...base, message: "just do it" });
    expect(out).toEqual({ message: "just do it" });
  });

  it("rewrites a matched skill with default template", async () => {
    mockList.mockReturnValue([
      { name: "weather", skillName: "weather", description: "" },
    ] as never);
    const out = await expandCronSkillCommand({ ...base, message: "/weather tokyo today" });
    expect(out.matchedSkill).toBe("weather");
    expect(out.message).toBe(
      'Use the "weather" skill for this request.\n\nUser input:\ntokyo today',
    );
  });

  it("uses promptTemplate when the skill provides one", async () => {
    mockList.mockReturnValue([
      {
        name: "memo",
        skillName: "memo",
        description: "",
        promptTemplate: "Summarize: $ARGUMENTS",
      },
    ] as never);
    const out = await expandCronSkillCommand({ ...base, message: "/memo the meeting" });
    expect(out.message).toBe("Summarize: the meeting");
  });

  it("falls back to raw text when skill is unknown", async () => {
    mockList.mockReturnValue([]);
    const out = await expandCronSkillCommand({ ...base, message: "/ghost do stuff" });
    expect(out).toEqual({ message: "/ghost do stuff" });
  });
});
```

- [ ] **Step 2: 跑测试确认失败**

Run: `node scripts/run-vitest.mjs run src/cron/isolated-agent/skill-command.test.ts`
Expected: FAIL("Cannot find module './skill-command.js'" 或 expandCronSkillCommand undefined)

- [ ] **Step 3: 实现 expandCronSkillCommand**

Create `src/cron/isolated-agent/skill-command.ts`:

```typescript
import { expandBundleCommandPromptTemplate } from "../../auto-reply/reply/get-reply-inline-actions.js";
import {
  listSkillCommandsForWorkspace,
  resolveSkillCommandInvocation,
} from "../../auto-reply/skill-commands.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logWarn } from "../../globals.js";

/**
 * 识别 cron payload.message 开头的 /skillname args,改写成技能 prompt。
 * 与 chat 的 get-reply-inline-actions 同款规则:有 promptTemplate 用之,
 * 否则用默认 `Use the "<skillName>" skill ...`。未匹配则原样返回。
 */
export async function expandCronSkillCommand(params: {
  message: string;
  workspaceDir: string;
  cfg: OpenClawConfig;
  agentId?: string;
}): Promise<{ message: string; matchedSkill?: string }> {
  const trimmed = params.message.trim();
  if (!trimmed.startsWith("/")) {
    return { message: params.message };
  }
  const skillCommands = listSkillCommandsForWorkspace({
    workspaceDir: params.workspaceDir,
    cfg: params.cfg,
    agentId: params.agentId,
  });
  if (skillCommands.length === 0) {
    return { message: params.message };
  }
  const invocation = resolveSkillCommandInvocation({
    commandBodyNormalized: trimmed,
    skillCommands,
  });
  if (!invocation) {
    logWarn(`[cron] skill command not matched, running as plain text: ${trimmed.slice(0, 40)}`);
    return { message: params.message };
  }
  const rewritten = invocation.command.promptTemplate
    ? expandBundleCommandPromptTemplate(invocation.command.promptTemplate, invocation.args)
    : [
        `Use the "${invocation.command.skillName}" skill for this request.`,
        invocation.args ? `User input:\n${invocation.args}` : null,
      ]
        .filter((entry): entry is string => Boolean(entry))
        .join("\n\n");
  return { message: rewritten, matchedSkill: invocation.command.name };
}
```

- [ ] **Step 4: 跑测试确认通过**

Run: `node scripts/run-vitest.mjs run src/cron/isolated-agent/skill-command.test.ts`
Expected: PASS(4 个用例)

- [ ] **Step 5: 类型检查**

Run: `pnpm tsgo:test:src`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/cron/isolated-agent/skill-command.ts src/cron/isolated-agent/skill-command.test.ts
git commit -m "feat(cron): expandCronSkillCommand 把 /skillname 改写成技能 prompt"
```

---
## Task 3: 共享技能选择器模块 skill-picker.ts

把 chat 里的技能解析 + 蓝色 chip 收拢成无状态模块。这一步只**新建**模块 + 单测,不改
chat(chat 迁移在 Task 4,降低单步风险)。

**Files:**
- Create: `ui/src/ui/chat/skill-picker.ts`
- Test: `ui/src/ui/chat/skill-picker.node.test.ts`

- [ ] **Step 1: 写失败测试**

Create `ui/src/ui/chat/skill-picker.node.test.ts`:

```typescript
import { describe, expect, it } from "vitest";
import { resolveActiveSkill } from "./skill-picker.ts";

const data = {
  storeSkills: [{ name: "weather", title: "天气", description: "" }],
  installedSkillNames: new Set(["weather"]),
  customInstalledSkills: [],
  storeSkillLabels: new Map([["weather", "天气"]]),
  recommendedSkills: [],
} as never;

describe("resolveActiveSkill", () => {
  it("returns null when value has no leading slash", () => {
    expect(resolveActiveSkill({ value: "just text", data })).toBeNull();
  });

  it("returns null when slash command has no trailing space", () => {
    expect(resolveActiveSkill({ value: "/weather", data })).toBeNull();
  });

  it("parses skill name, label and rest after the space", () => {
    const out = resolveActiveSkill({ value: "/weather tokyo today", data });
    expect(out).toMatchObject({
      name: "weather",
      label: "天气",
      rest: "tokyo today",
      prefix: "/weather ",
    });
  });

  it("returns null for an unknown skill", () => {
    expect(resolveActiveSkill({ value: "/ghost do it", data })).toBeNull();
  });
});
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm --dir ui exec vitest run src/ui/chat/skill-picker.node.test.ts`
Expected: FAIL("Cannot find module './skill-picker.ts'")

- [ ] **Step 3: 实现 skill-picker.ts(类型 + resolveActiveSkill + chip + indent)**

Create `ui/src/ui/chat/skill-picker.ts`:

```typescript
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { icons } from "../icons.ts";
import { normalizeSkillName, resolveSkillDisplayName } from "./skill-display.ts";

/** chat 与 cron 共需的技能数据字段。 */
export interface SkillPickerData {
  storeSkills?: import("../controllers/skill-store.ts").SkillListVO[] | null;
  storeSkillsLoading?: boolean;
  storeItemLoading?: string | null;
  installedSkillNames?: Set<string>;
  customInstalledSkills?: import("../types.ts").SkillStatusEntry[];
  storeSkillLabels?: Map<string, string>;
  recommendedSkills?: import("../controllers/skill-store.ts").SkillListVO[];
}

export interface ActiveSkill {
  name: string;
  label: string;
  rest: string;
  prefix: string;
}

function resolveSkillSelection(
  data: SkillPickerData,
  rawName: string,
): { name: string; label: string } | null {
  const normalized = normalizeSkillName(rawName);
  const displayName = resolveSkillDisplayName({
    rawName,
    labels: data.storeSkillLabels,
    storeSkills: data.storeSkills,
    customInstalledSkills: data.customInstalledSkills,
  });
  if (displayName) {
    return { name: normalized, label: displayName };
  }
  const installedMatch = [...(data.installedSkillNames ?? new Set<string>())].find(
    (name) => normalizeSkillName(name) === normalized,
  );
  if (installedMatch) {
    const storeLabel = data.storeSkillLabels?.get(normalized);
    return { name: normalized, label: storeLabel || installedMatch };
  }
  return null;
}

/** 从 value 解析选中技能;value 以 `/` 开头且含空格才算选中。 */
export function resolveActiveSkill(opts: {
  value: string;
  data: SkillPickerData;
}): ActiveSkill | null {
  const { value, data } = opts;
  if (!value.startsWith("/")) {
    return null;
  }
  const body = value.slice(1);
  const sep = body.search(/\s/u);
  if (sep === -1) {
    return null;
  }
  const resolved = resolveSkillSelection(data, body.slice(0, sep));
  if (!resolved) {
    return null;
  }
  return { ...resolved, rest: body.slice(sep + 1), prefix: value.slice(0, sep + 2) };
}

/** 同步 chip 宽度到 composer 的 text-indent 变量,避免文字压在 chip 下。 */
export function syncSkillChipIndent(el: Element | undefined): void {
  if (!(el instanceof HTMLElement)) {
    return;
  }
  requestAnimationFrame(() => {
    const composer = el.closest<HTMLElement>(".agent-chat__composer-combobox");
    if (!composer) {
      return;
    }
    const chip = composer.querySelector<HTMLElement>(".skill-selector-chip");
    composer.style.setProperty(
      "--skill-chip-indent",
      chip ? `${Math.ceil(chip.offsetWidth + 6)}px` : "0px",
    );
  });
}

/** 蓝色 chip 模板。 */
export function renderSkillChip(opts: {
  activeSkill: ActiveSkill;
  onClear: () => void;
}): TemplateResult {
  return html`<div class="agent-chat__skill-inline-chip">
    <span class="skill-selector-chip" ${ref(syncSkillChipIndent)}>
      ${icons.zap}<span>${opts.activeSkill.label}</span>
      <button
        type="button"
        class="skill-selector-chip__close"
        title="清除技能"
        aria-label="清除技能"
        @click=${(e: Event) => {
          e.preventDefault();
          e.stopPropagation();
          opts.onClear();
        }}
      >
        ${icons.x}
      </button>
    </span>
  </div>`;
}

export const __unused = nothing;
```

- [ ] **Step 4: 跑测试确认通过**

Run: `pnpm --dir ui exec vitest run src/ui/chat/skill-picker.node.test.ts`
Expected: PASS(4 个用例)

- [ ] **Step 5: 类型检查**

Run: `pnpm tsgo:test:ui`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add ui/src/ui/chat/skill-picker.ts ui/src/ui/chat/skill-picker.node.test.ts
git commit -m "feat(skill-picker): 抽出无状态技能解析 + 蓝色 chip 共享模块"
```

---
## Task 4: chat.ts 改用共享 chip/解析(回归保护)

让 chat 复用 Task 3 的 `resolveActiveSkill` / `renderSkillChip`,验证抽取无回归。
下拉面板(`renderSkillStoreDropdown`)和 body-portal 挂载**保持不动**——本任务只替换
chip 与解析,缩小改动面。

**Files:**
- Modify: `ui/src/ui/views/chat.ts` (activeSkillInfo 计算 + chip 渲染处)

- [ ] **Step 1: import 共享模块**

在 `ui/src/ui/views/chat.ts` 顶部 import 区(紧跟现有 skill-display import 之后)加:

```typescript
import { renderSkillChip, resolveActiveSkill } from "../chat/skill-picker.ts";
```

- [ ] **Step 2: 用 resolveActiveSkill 替换 activeSkillInfo 内联逻辑**

把 `ui/src/ui/views/chat.ts:2489-2500` 的:

```typescript
  const activeSkillInfo = (() => {
    if (props.proMode !== false || !props.draft.startsWith("/")) {
      return null;
    }
    const body = props.draft.slice(1);
    const sep = body.search(/\s/u);
    if (sep === -1) return null;
    const resolved = resolveSkillSelection(props, body.slice(0, sep));
    return resolved
      ? { ...resolved, rest: body.slice(sep + 1), prefix: props.draft.slice(0, sep + 2) }
      : null;
  })();
```

替换为:

```typescript
  const activeSkillInfo =
    props.proMode === false ? resolveActiveSkill({ value: props.draft, data: props }) : null;
```

- [ ] **Step 3: 用 renderSkillChip 替换内联 chip 模板**

把 `ui/src/ui/views/chat.ts:2534-2554` 的内联 chip 块(`${activeSkillInfo ? html\`<div class="agent-chat__skill-inline-chip">…</div>\` : nothing}`)替换为:

```typescript
        ${activeSkillInfo
          ? renderSkillChip({
              activeSkill: activeSkillInfo,
              onClear: () => {
                props.onDraftChange(activeSkillInfo.rest);
                requestUpdate();
              },
            })
          : nothing}
```

- [ ] **Step 4: 删除现已无用的本地 helper**

删除 `ui/src/ui/views/chat.ts` 里仅服务于上面两处的本地函数:`resolveSkillSelection`
(line ~363)、`syncSkillChipIndent`(line ~572)。`syncSkillComposerIndent` 若仍被
textarea 的 `ref` 回调引用(line ~2563)则保留;否则一并删除。删除前用编辑器搜索确认无
其他引用。

- [ ] **Step 5: 类型检查**

Run: `pnpm tsgo:test:ui`
Expected: PASS(无 "unused" 或 "not found" 报错)

- [ ] **Step 6: 跑 chat 相关测试确认无回归**

Run: `pnpm --dir ui exec vitest run src/ui/chat`
Expected: PASS(现有 chat 测试全绿)

- [ ] **Step 7: Commit**

```bash
git add ui/src/ui/views/chat.ts
git commit -m "refactor(chat): 输入框复用共享 skill-picker 的 chip 与解析"
```

---

## Task 5: 抽出共享 skill-picker.css

把与 chat 输入条无关的通用 `.skill-selector-*` / `.skill-selector-chip` 类从 chat 的
`layout.css` 移到独立 css,chat 与 cron 都能用。

**Files:**
- Create: `ui/src/styles/skill-picker.css`
- Modify: `ui/src/styles/chat/layout.css`(移除被搬走的类)
- Modify: `ui/src/styles.css`(import 新 css)

- [ ] **Step 1: 新建 skill-picker.css 并迁移通用类**

把 `ui/src/styles/chat/layout.css` 中以下选择器对应的规则块**整段剪切**到新文件
`ui/src/styles/skill-picker.css`(行号区间见 layout.css 现状):

- `.skill-selector-panel` 及其子类(`__header` `__title` `__center` `__body` 滚动条
  `__loading` `__empty` `__goto`)
- `.skill-selector-group` / `__label` / `__separator`
- `.skill-selector-row` 及其修饰/子类(`--selected` `--busy` `__content` `__title`
  `__desc` `__status` `__spinner`)
- `.skill-selector-chip` / `svg` / `__close` / `__close:hover`

**保留在 layout.css**(与 chat 输入条耦合,cron 不用):`.skill-selector-anchor`、
`.skill-selector-trigger` 系列、`.skill-selector-portal` 系列、
`.agent-chat__skill-inline-chip`、`.agent-chat__composer-combobox*`。

- [ ] **Step 2: import 新 css**

在 `ui/src/styles.css` 现有 `@import "./styles/cron-task-dialog.css";` 一行附近加:

```css
@import "./styles/skill-picker.css";
```

- [ ] **Step 3: 构建确认样式无丢失**

Run: `pnpm --dir ui build`
Expected: 构建成功,无 css 报错。

- [ ] **Step 4: Commit**

```bash
git add ui/src/styles/skill-picker.css ui/src/styles/chat/layout.css ui/src/styles.css
git commit -m "refactor(styles): 抽出共享 skill-picker.css 供 chat 与 cron 复用"
```

---
## Task 6: 接入 skillDropdownOpen 弹窗级状态

cron 弹窗的技能下拉开关。与 cronTaskSessionQuery 同级,放 app 状态。

**Files:**
- Modify: `ui/src/ui/app.ts:593`
- Modify: `ui/src/ui/controllers/cron.ts:99`
- Modify: `ui/src/ui/app-view-state.ts:436`

- [ ] **Step 1: app.ts 加 @state**

在 `ui/src/ui/app.ts:593`(cronTaskSessionQuery 那行)下一行加:

```typescript
  @state() skillDropdownOpen = false;
```

- [ ] **Step 2: CronState 类型加字段**

在 `ui/src/ui/controllers/cron.ts:99`(cronTaskSessionQuery 那行)下一行加:

```typescript
  skillDropdownOpen: boolean;
```

- [ ] **Step 3: AppViewState Pick 加字段**

在 `ui/src/ui/app-view-state.ts:436`(cronTaskSessionQuery 那行)下一行加:

```typescript
  | "skillDropdownOpen"
```

- [ ] **Step 4: 类型检查**

Run: `pnpm tsgo:test:ui`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add ui/src/ui/app.ts ui/src/ui/controllers/cron.ts ui/src/ui/app-view-state.ts
git commit -m "feat(cron): 新增 skillDropdownOpen 弹窗级状态"
```

---
## Task 7: 共享下拉面板 renderSkillDropdown

把 chat 的 `renderSkillStoreDropdown` 搬进 `skill-picker.ts` 成为无状态导出,供 cron 内联
渲染。`selectSkill`(切换/替换 `/skillname ` 前缀)内聚在此。chat 暂不切换到它(保持
现有 portal 调用),避免本任务牵动 portal 逻辑。

**Files:**
- Modify: `ui/src/ui/chat/skill-picker.ts`

- [ ] **Step 1: 追加 recency 辅助 + renderSkillDropdown + selectSkill**

在 `ui/src/ui/chat/skill-picker.ts` 末尾(`export const __unused` 之前)追加下面整段。
它是 chat `renderSkillStoreDropdown`(chat.ts:2832-3024)的无状态平移:`props` 换成
`opts.data` + `opts.value`,`closeDropdown` 换成 `opts.onClose`,`onDraftChange` 换成
`opts.onChange`。recency 用 localStorage(键 `sc:skill-recency`,与 chat 一致)。

```typescript
function loadSkillRecency(): Record<string, number> {
  try {
    return JSON.parse(localStorage.getItem("sc:skill-recency") ?? "{}") as Record<string, number>;
  } catch {
    return {};
  }
}

function recordSkillUse(name: string): void {
  try {
    const rec = loadSkillRecency();
    rec[name] = Date.now();
    localStorage.setItem("sc:skill-recency", JSON.stringify(rec));
  } catch {
    /* ignore */
  }
}

export function renderSkillDropdown(opts: {
  data: SkillPickerData;
  value: string;
  onChange: (next: string) => void;
  onClose: () => void;
  onNavigateToSkillCenter?: () => void;
  onInstallAndUseSkill?: (skill: import("../controllers/skill-store.ts").SkillListVO) => void;
}): TemplateResult {
  const { data } = opts;
  const installed = data.installedSkillNames ?? new Set<string>();
  const allStore = data.storeSkills ?? [];
  const allStoreUninstalled = allStore.filter((s) => !installed.has(s.name));
  type MergedItem = { name: string; displayName: string; description?: string };
  const storeItems: MergedItem[] = allStore
    .filter((s) => installed.has(s.name))
    .map((s) => ({ name: s.name, displayName: s.title || s.name, description: s.description }));
  const customItems: MergedItem[] = (data.customInstalledSkills ?? []).map((s) => ({
    name: s.name,
    displayName:
      resolveSkillDisplayName({
        rawName: s.name,
        labels: data.storeSkillLabels,
        storeSkills: data.storeSkills,
        customInstalledSkills: data.customInstalledSkills,
      }) ?? s.name,
    description: s.description,
  }));
  const recency = loadSkillRecency();
  const sortByRecency = <T extends { name: string }>(items: T[]): T[] =>
    [...items].sort((a, b) => (recency[b.name] ?? 0) - (recency[a.name] ?? 0));
  const allInstalledItems = sortByRecency([...storeItems, ...customItems]);
  const loading = data.storeSkillsLoading ?? false;
  const recentItems = allStore
    .filter(
      (s) => recency[s.name] !== undefined || recency[normalizeSkillName(s.name)] !== undefined,
    )
    .sort(
      (a, b) =>
        (recency[b.name] ?? recency[normalizeSkillName(b.name)] ?? 0) -
        (recency[a.name] ?? recency[normalizeSkillName(a.name)] ?? 0),
    );
  const leadingItems =
    recentItems.length > 0
      ? recentItems
      : data.recommendedSkills && data.recommendedSkills.length > 0
        ? data.recommendedSkills
        : allStoreUninstalled;
  const leadingTitle = recentItems.length > 0 ? "最近使用" : "推荐技能";
  const active = resolveActiveSkill({ value: opts.value, data });
  const selectedSkillName = active?.name ?? null;

  const goToSkillCenter = () => {
    opts.onClose();
    opts.onNavigateToSkillCenter?.();
  };
  const selectSkill = (skillName: string) => {
    const nextName = normalizeSkillName(skillName);
    const rest = active ? active.rest : opts.value;
    recordSkillUse(skillName);
    opts.onChange(active?.name === nextName ? rest : `/${skillName} ${rest}`);
    opts.onClose();
  };
  const itemLoading = data.storeItemLoading ?? null;
  const renderStatus = (busy: boolean, selected: boolean) =>
    html`<span class="skill-selector-row__status"
      >${busy
        ? html`<span class="skill-selector-row__spinner"></span>`
        : selected
          ? icons.check
          : nothing}</span
    >`;
```

- [ ] **Step 2: 追加行渲染 + 分组 + 面板模板(接 Step 1 同一函数体)**

紧接 Step 1 的代码,在 `renderSkillDropdown` 函数体内继续:

```typescript
  const renderInstalledRow = (item: MergedItem) => {
    const busy = itemLoading === item.name;
    const selected = selectedSkillName === normalizeSkillName(item.name);
    return html`<button
      class="skill-selector-row ${busy ? "skill-selector-row--busy" : ""} ${selected
        ? "skill-selector-row--selected"
        : ""}"
      ?disabled=${busy}
      @click=${() => selectSkill(item.name)}
    >
      <div class="skill-selector-row__content">
        <div class="skill-selector-row__title" title=${item.displayName}>${item.displayName}</div>
        ${item.description
          ? html`<div class="skill-selector-row__desc" title=${item.description}>
              ${item.description}
            </div>`
          : nothing}
      </div>
      ${renderStatus(busy, selected)}
    </button>`;
  };
  const renderUninstalledRow = (skill: (typeof allStore)[number]) => {
    const busy = itemLoading === skill.name;
    const selected = selectedSkillName === normalizeSkillName(skill.name);
    return html`<button
      class="skill-selector-row ${busy ? "skill-selector-row--busy" : ""} ${selected
        ? "skill-selector-row--selected"
        : ""}"
      ?disabled=${busy}
      @click=${() => {
        if (installed.has(skill.name)) {
          selectSkill(skill.name);
        } else if (opts.onInstallAndUseSkill) {
          opts.onInstallAndUseSkill(skill);
        } else {
          goToSkillCenter();
        }
      }}
    >
      <div class="skill-selector-row__content">
        <div class="skill-selector-row__title" title=${skill.title || skill.name}>
          ${skill.title || skill.name}
        </div>
        ${skill.description
          ? html`<div class="skill-selector-row__desc" title=${skill.description}>
              ${skill.description}
            </div>`
          : nothing}
      </div>
      ${renderStatus(busy, selected)}
    </button>`;
  };
  const renderGroup = (
    title: string,
    rows: TemplateResult[],
    options: { separator?: boolean } = {},
  ) =>
    rows.length > 0
      ? html`${options.separator
            ? html`<div class="skill-selector-group__separator"></div>`
            : nothing}
          <div class="skill-selector-group">
            <div class="skill-selector-group__label">${icons.zap}<span>${title}</span></div>
            ${rows}
          </div>`
      : nothing;

  return html`<div class="skill-selector-panel" role="dialog" aria-label="选择技能">
    <div class="skill-selector-panel__header">
      <span class="skill-selector-panel__title">选择技能</span>
      <button class="skill-selector-panel__center" @click=${goToSkillCenter}>
        <span>技能中心</span>${icons.chevronRight}
      </button>
    </div>
    <div class="skill-selector-panel__body">
      ${loading
        ? html`<div class="skill-selector-panel__loading">
            <span class="skill-selector-row__spinner"></span>
          </div>`
        : allStore.length === 0 && allInstalledItems.length === 0
          ? html`<div class="skill-selector-panel__empty">
              ${icons.zap}
              <p>暂无技能</p>
              <button class="skill-selector-panel__goto" @click=${goToSkillCenter}>
                前往安装
              </button>
            </div>`
          : html`${renderGroup(
                leadingTitle,
                leadingItems.map((skill) => renderUninstalledRow(skill)),
              )}
              ${renderGroup(
                "我的技能",
                allInstalledItems.map((item) => renderInstalledRow(item)),
                { separator: leadingItems.length > 0 },
              )}`}
    </div>
  </div>`;
}
```

- [ ] **Step 3: 类型检查**

Run: `pnpm tsgo:test:ui`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add ui/src/ui/chat/skill-picker.ts
git commit -m "feat(skill-picker): 追加无状态 renderSkillDropdown 下拉面板"
```

---
## Task 8: cron 弹窗 UI 接线

`cron-task-dialog.ts`:任务指令区叠加蓝色 tag,footer 加技能触发器 + 内联下拉。

**Files:**
- Modify: `ui/src/ui/views/cron-task-dialog.ts`
- Modify: `ui/src/styles/cron-task-dialog.css`

- [ ] **Step 1: import 共享模块 + 扩展 props**

在 `ui/src/ui/views/cron-task-dialog.ts` 顶部 import 区加:

```typescript
import {
  renderSkillChip,
  renderSkillDropdown,
  resolveActiveSkill,
  type SkillPickerData,
} from "../chat/skill-picker.ts";
```

在 `CronTaskDialogProps`(line 26-45)末尾、`proMode?` 之前加字段:

```typescript
  /** 技能数据(从 app-render 透传,与 chat 同源)。 */
  skills: SkillPickerData;
  /** 弹窗级下拉开关。 */
  skillDropdownOpen: boolean;
  onToggleSkillDropdown: (open: boolean) => void;
  onNavigateToSkillCenter?: () => void;
```

- [ ] **Step 2: 重写"任务指令"区为带 chip 的 combobox**

把 `ui/src/ui/views/cron-task-dialog.ts:383-397`(`<div class="ctd-field">` 包住"任务
指令" label + textarea 的整块)替换为:

```typescript
          ${(() => {
            const active = resolveActiveSkill({ value: props.form.payloadText, data: props.skills });
            const onText = (next: string) => {
              const value = active ? `${active.prefix}${next}` : next;
              props.onFormChange({ payloadText: value, payloadKind: "agentTurn" });
            };
            return html`<div class="ctd-field">
              <label class="ctd-field__label">${"任务指令"}</label>
              <div
                class="ctd-composer agent-chat__composer-combobox ${active
                  ? "agent-chat__composer-combobox--skill"
                  : ""}"
              >
                ${active
                  ? renderSkillChip({
                      activeSkill: active,
                      onClear: () =>
                        props.onFormChange({ payloadText: active.rest, payloadKind: "agentTurn" }),
                    })
                  : nothing}
                <textarea
                  class="ctd-textarea ${promptError ? "ctd-input--error" : ""}"
                  rows="4"
                  placeholder=${"描述你希望 AI 定时执行的任务..."}
                  .value=${active ? active.rest : props.form.payloadText}
                  @input=${(e: Event) => onText((e.target as HTMLTextAreaElement).value)}
                ></textarea>
              </div>
              ${promptError ? html`<div class="ctd-error">${promptError}</div>` : nothing}
            </div>`;
          })()}
```

- [ ] **Step 3: footer 加技能触发器 + 内联下拉**

在 `ui/src/ui/views/cron-task-dialog.ts` 的 `.ctd-footer`(line 402)内,`.ctd-footer__actions`
之前插入触发器 + 下拉。下拉内联渲染在 `.ctd-modal` 里(absolute 锚定),避开 top-layer
遮挡。先在 `renderCronTaskDialog` 顶部算出 `activeSkill`:

```typescript
  const activeSkill = resolveActiveSkill({
    value: props.form.payloadText,
    data: props.skills,
  });
```

footer 内插入:

```typescript
          <div class="ctd-skill-picker">
            <button
              type="button"
              class="skill-selector-trigger ${activeSkill ? "skill-selector-trigger--selected" : ""} ${props.skillDropdownOpen ? "skill-selector-trigger--open" : ""}"
              @click=${() => props.onToggleSkillDropdown(!props.skillDropdownOpen)}
            >
              ${icons.zap}
              <span class="skill-selector-trigger__label">${activeSkill?.label ?? "技能"}</span>
              <span class="skill-selector-trigger__chevron">${icons.chevronDown}</span>
            </button>
            ${props.skillDropdownOpen
              ? html`<div class="ctd-skill-picker__panel">
                  ${renderSkillDropdown({
                    data: props.skills,
                    value: props.form.payloadText,
                    onChange: (next) =>
                      props.onFormChange({ payloadText: next, payloadKind: "agentTurn" }),
                    onClose: () => props.onToggleSkillDropdown(false),
                    onNavigateToSkillCenter: props.onNavigateToSkillCenter,
                  })}
                </div>`
              : nothing}
          </div>
```

- [ ] **Step 4: cron-task-dialog.css 加定位类**

在 `ui/src/styles/cron-task-dialog.css` 末尾追加:

```css
.ctd-skill-picker {
  position: relative;
  margin-right: auto;
}

.ctd-skill-picker__panel {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  z-index: 30;
  width: 320px;
  max-width: min(360px, 80vw);
}
```

- [ ] **Step 5: 类型检查**

Run: `pnpm tsgo:test:ui`
Expected: PASS

- [ ] **Step 6: 构建确认**

Run: `pnpm --dir ui build`
Expected: 构建成功。

- [ ] **Step 7: Commit**

```bash
git add ui/src/ui/views/cron-task-dialog.ts ui/src/styles/cron-task-dialog.css
git commit -m "feat(cron): 弹窗 footer 内嵌技能下拉框 + 蓝色 tag"
```

---
## Task 9: app-render.ts 透传技能数据给 cron 弹窗

把 chat 已用的技能数据 + 下拉开关回调接到 `renderCronTaskDialog`。

**Files:**
- Modify: `ui/src/ui/app-render.ts` (`renderCronTaskDialogForTab`, line 844-910 区间)

- [ ] **Step 1: 在 renderCronTaskDialog({...}) 调用里补 props**

在 `ui/src/ui/app-render.ts` 的 `renderCronTaskDialog({ ... })`(line 854 起)对象里,
追加下列字段(放在 `proMode` 等现有字段附近)。技能数据与 chat props 同源
(参照 app-render.ts:3645-3763 的构造方式):

```typescript
    skills: {
      storeSkills: state.storeSkills,
      storeSkillsLoading: state.storeSkillsLoading,
      storeItemLoading: state.storeItemLoading,
      installedSkillNames: new Set(
        state.skillsReport?.skills?.filter((s) => s.extra !== undefined).map((s) => s.name) ?? [],
      ),
      customInstalledSkills:
        state.skillsReport?.skills?.filter(
          (s) =>
            s.extra === undefined &&
            (s.source === "openclaw-workspace" || s.source === "openclaw-extra"),
        ) ?? [],
      storeSkillLabels: new Map(
        (state.skillsReport?.skills ?? [])
          .filter((s) => s.storeExtra)
          .map((s) => [
            s.name.toLowerCase().replace(/-/g, "_"),
            ((s.storeExtra as Record<string, unknown>)?.title as string | undefined) ?? s.name,
          ]),
      ),
      recommendedSkills: [],
    },
    skillDropdownOpen: state.skillDropdownOpen,
    onToggleSkillDropdown: (open: boolean) => {
      state.skillDropdownOpen = open;
      if (open && !state.storeSkills && !state.storeSkillsLoading) {
        void loadSkills(state, {});
      }
      requestHostUpdate?.();
    },
    onNavigateToSkillCenter: () => {
      state.cronTaskDialogOpen = false;
      state.skillDropdownOpen = false;
      state.setTab("skillcenter" as import("./navigation.ts").Tab);
      requestHostUpdate?.();
    },
```

- [ ] **Step 2: 确认 loadSkills 已 import**

在 `ui/src/ui/app-render.ts` 顶部确认有 `loadSkills` 的 import(chat 接线已用,通常
来自 `./controllers/skill-store.ts`)。若没有则补:用编辑器搜索 `loadSkills` 确认来源
后 import。

- [ ] **Step 3: 关闭弹窗时复位下拉开关**

在 `ui/src/ui/app-render.ts` 的 `onCancel`(line 896)和 `onSubmit` 成功分支
(line 891 `state.cronTaskDialogOpen = false;`)附近,各加一行:

```typescript
      state.skillDropdownOpen = false;
```

- [ ] **Step 4: 类型检查**

Run: `pnpm tsgo:test:ui`
Expected: PASS

- [ ] **Step 5: 构建确认**

Run: `pnpm --dir ui build`
Expected: 构建成功。

- [ ] **Step 6: Commit**

```bash
git add ui/src/ui/app-render.ts
git commit -m "feat(cron): app-render 透传技能数据与下拉开关给定时任务弹窗"
```

---

## Task 10: cron 后端接入 expandCronSkillCommand

让定时任务触发时,选中的技能真正被调用。在 `run.ts` 包裹消息前展开 `/skillname`。

**Files:**
- Modify: `src/cron/isolated-agent/run.ts` (import + line 662 之前)

- [ ] **Step 1: import**

在 `src/cron/isolated-agent/run.ts` 顶部 import 区(紧跟 `resolveCronSkillsSnapshot`
import,line 69 附近)加:

```typescript
import { expandCronSkillCommand } from "./skill-command.js";
```

- [ ] **Step 2: 在构造 base 前展开技能命令**

把 `src/cron/isolated-agent/run.ts:662`:

```typescript
  const base = `[cron:${input.job.id} ${input.job.name}] ${input.message}`.trim();
```

替换为:

```typescript
  const expanded = await expandCronSkillCommand({
    message: input.message,
    workspaceDir,
    cfg: input.cfg,
    agentId,
  });
  const base = `[cron:${input.job.id} ${input.job.name}] ${expanded.message}`.trim();
```

(此处 `workspaceDir`、`input.cfg`、`agentId` 均已在 line 662 之前的作用域内——见
run.ts:518-525 与函数签名。)

- [ ] **Step 3: 类型检查**

Run: `pnpm tsgo:test:src`
Expected: PASS

- [ ] **Step 4: 跑 cron run 相关测试确认无回归**

Run: `node scripts/run-vitest.mjs run src/cron/isolated-agent/run.skill-filter.test.ts`
Expected: PASS(现有 skill-filter 测试不受影响;`expandCronSkillCommand` 在无 `/`
前缀的普通 message 上原样返回)

- [ ] **Step 5: Commit**

```bash
git add src/cron/isolated-agent/run.ts
git commit -m "feat(cron): 执行前把 /skillname 展开成技能 prompt 以触发技能"
```

---

## Self-Review

**Spec coverage:**
- 共享模块(spec §共享技能选择器模块)→ Task 3 + Task 7。
- cron 弹窗 UI(spec §cron 弹窗 UI 接线)→ Task 8。
- app-render 接线(spec 架构点 3)→ Task 9 + Task 6(状态)。
- cron 后端执行(spec §cron 后端技能执行)→ Task 1 + Task 2 + Task 10。
- CSS 抽取(spec §CSS)→ Task 5。
- 错误处理降级(spec §错误处理)→ Task 2 的未匹配 `logWarn` 分支 + Task 7 loading/empty
  复用。
- 测试(spec §测试)→ Task 2 后端单测、Task 3 `resolveActiveSkill` 单测、Task 4 chat
  回归。
- 回归保护(spec 验收 5)→ Task 4。

**类型一致性:** `SkillPickerData` / `ActiveSkill` 在 Task 3 定义,Task 7、Task 8 复用同名。
`resolveActiveSkill` / `renderSkillChip` / `renderSkillDropdown` 跨任务签名一致。
`expandCronSkillCommand` 在 Task 2 定义、Task 10 调用,参数对齐。

**Placeholder 扫描:** 无 TBD/TODO;每个代码步骤都有完整代码。

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-06-12-cron-task-skill-picker.md`.
