= {
查看命令说明: 'Show command help',
命令说明: 'Command help',
'团队技能来自技能页面(共 {count} 个)': 'Team skills come from the Skills page ({count} total)',
+ // #823 natural-language skill auto-suggest vocabulary.
+ '检测到 skill「{name}」,Tab 接受': 'Skill detected: {name} — Tab to accept',
+ '接受 skill 建议:{name}': 'Accept skill suggestion: {name}',
+ '忽略 skill 建议': 'Dismiss skill suggestion',
'停止当前这一轮?': 'Stop this round?',
'丢弃本轮修改——方案和代码回到上一个版本':
'Discard this round’s changes — plan and code revert to the previous version',
diff --git a/apps/web/src/overlay/composer-wire.ts b/apps/web/src/overlay/composer-wire.ts
index ca27a0852..0bb726ce1 100644
--- a/apps/web/src/overlay/composer-wire.ts
+++ b/apps/web/src/overlay/composer-wire.ts
@@ -44,6 +44,7 @@
// precedent (chat-page and chat-window share the conversation logic while
// each keeps its own shell).
+import { type SkillSuggestion, suggestSkill } from '@pacman/shared';
import type {
ChangeEvent,
ClipboardEvent,
@@ -234,6 +235,17 @@ export interface ComposerWire {
/** mentionGroups with the empty-groups fallback applied (the picker opens
* on empty groups so the user still sees the zero counts, r9 §2.2). */
groups: MentionGroups;
+ /** Natural-language skill suggestion (#823): the matched team skill for
+ * the current draft, or null. Live only while both completion popups are
+ * closed (the `/` and `@` faces own their keys — this face never steals
+ * Tab/Enter/Esc from them) and never on static faces. */
+ suggestion: SkillSuggestion | null;
+ /** Accept the suggestion the way Tab-with-hint would: inserts the skill
+ * token at the caret without consuming any typed text. */
+ acceptSuggestion: () => void;
+ /** Dismiss this suggestion instance (single-ignore: Esc or the strip's
+ * close button; the ignore lasts until the draft is sent). */
+ dismissSuggestion: () => void;
}
const EMPTY_GROUPS: MentionGroups = {
@@ -319,6 +331,12 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire {
const slashListboxRef = useRef(null);
const slashEnabled = editable && slashOptions !== undefined;
+ // Natural-language skill suggestion (#823): the skill dismissed by an
+ // explicit Esc / close-button stays dismissed until the draft is sent —
+ // typing more text never resurrects the same nudge, a different matched
+ // skill still shows.
+ const [dismissedSkillId, setDismissedSkillId] = useState(null);
+
// Mirror of the freshest committed draft (#729): an upload resolving
// seconds after the paste must insert into the text as it stands now —
// the render-scoped `draft` closure would be stale and eat everything
@@ -472,6 +490,34 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire {
const slashRows = useMemo(() => slashSections.flatMap((s) => s.rows), [slashSections]);
const slashOpen = slashRange !== null && slashEnabled;
const slashQuery = slashRange?.query ?? '';
+
+ // Skill suggestion (#823): draft-derived, over the same skill vocab that
+ // feeds the mention picker and the `/` menu (mentionGroups.skill — the
+ // useSkills projection, never a side table). Suppressed while either
+ // completion popup is open so Tab/Enter/Esc keep their menu meaning, and
+ // off entirely on static faces. A dismissed skill is excluded from the
+ // retry so a multi-intent draft falls through to the next live nudge
+ // instead of going quiet entirely.
+ const suggestion = useMemo(() => {
+ if (!editable) return null;
+ if (slashOpen || inlineOpen) return null;
+ const skills = (mentionGroups?.skill ?? []).map((s) => ({
+ id: s.id,
+ name: s.label,
+ ...(s.subtitle !== undefined ? { description: s.subtitle } : {}),
+ }));
+ const hit = suggestSkill(draft, skills);
+ if (hit === null) return null;
+ if (hit.skillId !== dismissedSkillId) return hit;
+ return suggestSkill(
+ draft,
+ skills.filter((s) => s.id !== dismissedSkillId),
+ );
+ }, [draft, mentionGroups, editable, slashOpen, inlineOpen, dismissedSkillId]);
+ // Synchronous mirror for event handlers (same React-state-lag rationale
+ // as the inline range ref above).
+ const suggestionRef = useRef(null);
+ suggestionRef.current = suggestion;
const helpSkillCount = mentionGroups?.skill.length ?? 0;
const helpRows = useMemo(
() =>
@@ -491,6 +537,9 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire {
// never leave without the tokens still being uploaded (#729 failure
// mode 3). The draft survives untouched; Enter sends once landed.
if (attachInFlightRef.current > 0) return;
+ // A sent draft retires the single-ignore (#823): the next draft judges
+ // its suggestion fresh.
+ setDismissedSkillId(null);
const text = draft.trim();
if (text === '' && !editable) {
// Static capture face: the fixture send stays callable with the empty
@@ -686,6 +735,25 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire {
insertTokensAt(tokens, inlineOpen ? inlineRangeRef.current : null);
};
+ /** Accept the live suggestion: the skill token lands at the caret, the
+ * typed prose stays untouched (range null = pure caret insert, nothing
+ * consumed). The new draft references the skill, so the matcher goes
+ * quiet on its own — no extra state to clear. Plain function (not
+ * useCallback): it must close over the current render's insertTokensAt,
+ * whose controlled-mode setDraft carries the fresh draft — a frozen
+ * first-render closure would insert into the empty initial draft. */
+ const acceptSuggestion = () => {
+ const hit = suggestionRef.current;
+ if (hit === null) return;
+ insertTokensAt([{ kind: 'skill', id: hit.skillId, label: hit.skillName }], null);
+ };
+
+ const dismissSuggestion = () => {
+ const hit = suggestionRef.current;
+ if (hit === null) return;
+ setDismissedSkillId(hit.skillId);
+ };
+
const insertToken = (token: MentionToken) => insertTokens([token]);
/** File path insert (#760): bare path text + trailing space (CC rule 26),
@@ -831,6 +899,25 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire {
}
return;
}
+ // intent === 'ignore': the skill-suggestion face (#823) runs here —
+ // reachable only with both completion popups closed (their open-state
+ // branches above all return first), so Tab/Esc can never steal a menu
+ // key. IME composition never triggers either layer (#728 failure
+ // mode 8 carries over).
+ if (!slashOpen && !inlineOpen && suggestionRef.current !== null && !isComposing) {
+ if (event.key === 'Tab') {
+ event.preventDefault();
+ acceptSuggestion();
+ return;
+ }
+ if (event.key === 'Escape') {
+ // preventDefault is load-bearing for the #634 Esc ladder (see the
+ // inline dismiss above): closing the hint consumes the key.
+ event.preventDefault();
+ dismissSuggestion();
+ return;
+ }
+ }
// intent === 'ignore': the composer's own keys. Enter sends — but never
// mid-IME-composition (the composing Enter belongs to the candidate
// window; #728 failure mode 8) and never with shift (newline). A plain
@@ -918,6 +1005,9 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire {
insertTokens,
insertFile,
groups,
+ suggestion,
+ acceptSuggestion,
+ dismissSuggestion,
slashOpen,
slashQuery,
slashCaret: slashRange?.start ?? null,
diff --git a/apps/web/src/overlay/skill-suggest-strip.tsx b/apps/web/src/overlay/skill-suggest-strip.tsx
new file mode 100644
index 000000000..50e1c38a9
--- /dev/null
+++ b/apps/web/src/overlay/skill-suggest-strip.tsx
@@ -0,0 +1,56 @@
+// Skill-suggestion hint bar (issue #823): the ghost-style strip shown when
+// the wire matches plain prose to a team skill. Tab / click accepts (the
+// skill token lands at the caret, the typed prose stays), the close button
+// or Esc single-ignores. Rows are non-focusable and mousedown is swallowed
+// so the textarea keeps DOM focus the whole time (the MentionInline combobox
+// contract — a focus jump would re-judge the token and flicker the strip).
+//
+// The strip only ever renders while both completion popups are closed (the
+// wire guarantees it), so it can sit in-flow under the textarea without
+// covering any listbox.
+
+import type { SkillSuggestion } from '@pacman/shared';
+import { useI18n } from '../i18n/provider.js';
+import { Puzzle, X } from '../icons/index.js';
+import './skill-suggest.css';
+
+interface SkillSuggestStripProps {
+ suggestion: SkillSuggestion | null;
+ onAccept: () => void;
+ onDismiss: () => void;
+}
+
+export function SkillSuggestStrip({ suggestion, onAccept, onDismiss }: SkillSuggestStripProps) {
+ const { t } = useI18n();
+ if (suggestion === null) return null;
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/overlay/skill-suggest.css b/apps/web/src/overlay/skill-suggest.css
new file mode 100644
index 000000000..b153ee1b1
--- /dev/null
+++ b/apps/web/src/overlay/skill-suggest.css
@@ -0,0 +1,86 @@
+/* Skill-suggestion hint bar (issue #823): a ghost-style strip — visible
+ enough to notice, quiet enough to ignore. In-flow under the textarea (it
+ only renders while both completion popups are closed, so it never covers
+ a listbox); the kbd chip mirrors the Tab-accept key. */
+
+.skill-suggest {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 4px;
+ color: var(--muted-foreground);
+ font-size: 12px;
+ line-height: 20px;
+}
+
+/* Detail composer float column (#757 contract): the column never
+ intercepts — interactive strips re-enable. The mention strip stays
+ inert (display-only); this strip has accept/dismiss buttons, so it
+ opts back in like the attachment strip. */
+.composer-float > .skill-suggest {
+ margin-top: 0;
+ pointer-events: auto;
+}
+
+.skill-suggest-body {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 100%;
+ padding: 2px 8px;
+ border: 1px dashed var(--border);
+ border-radius: 6px;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.skill-suggest-body:hover {
+ border-style: solid;
+ color: var(--foreground);
+}
+
+.skill-suggest-icon {
+ display: inline-flex;
+ flex: none;
+}
+
+.skill-suggest-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.skill-suggest-key {
+ flex: none;
+ padding: 0 5px;
+ border: 1px solid var(--border);
+ border-bottom-width: 2px;
+ border-radius: 4px;
+ font-family: inherit;
+ font-size: 11px;
+ line-height: 16px;
+ background: var(--muted);
+}
+
+.skill-suggest-close {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ opacity: 0.6;
+}
+
+.skill-suggest-close:hover {
+ opacity: 1;
+ background: var(--muted);
+}
diff --git a/apps/web/test/skill-suggest.test.ts b/apps/web/test/skill-suggest.test.ts
new file mode 100644
index 000000000..209fa85e7
--- /dev/null
+++ b/apps/web/test/skill-suggest.test.ts
@@ -0,0 +1,72 @@
+// Skill auto-suggest matcher (issue #823) — tests first per repo discipline.
+// Pins the failure-mode list decided before implementation:
+// F1 each first-batch intent fires on its trigger phrasing (ZH + EN)
+// F2 no trigger phrasing -> null (chatter never suggests)
+// F3 team has no matching skill -> null (never invents a candidate;
+// the vocab is useSkills-only, no side table)
+// F4 skill already referenced in the draft -> null (no duplicate nudge)
+// F5 empty / whitespace draft -> null
+// F6 case-insensitive on both faces (draft + skill name/description)
+// F7 multi-intent draft follows table order (first intent wins)
+// F8 slash-command line (`/…` at message start) -> null (the `/` face
+// owns that line; no key/intent clash by construction)
+import { describe, expect, test } from 'vitest';
+import { suggestSkill } from '@pacman/shared';
+
+const SKILLS = [
+ { id: 's-remind', name: 'remind', description: 'schedule reminders and cron notifications' },
+ { id: 's-trans', name: 'translate', description: '翻译文本' },
+ { id: 's-sum', name: 'summarize', description: '总结长文档' },
+ { id: 's-review', name: 'code-review', description: '代码审查' },
+];
+
+describe('suggestSkill first-batch intents', () => {
+ test('F1 reminder fires on time expressions', () => {
+ expect(suggestSkill('明早 9 点提醒我开会', SKILLS)?.skillId).toBe('s-remind');
+ expect(suggestSkill('remind me tomorrow at 9am', SKILLS)?.skillId).toBe('s-remind');
+ });
+ test('F1 translate fires', () => {
+ expect(suggestSkill('把这段翻译成英文', SKILLS)?.skillId).toBe('s-trans');
+ expect(suggestSkill('translate this paragraph', SKILLS)?.skillId).toBe('s-trans');
+ });
+ test('F1 summarize fires', () => {
+ expect(suggestSkill('帮我总结一下这篇文档', SKILLS)?.skillId).toBe('s-sum');
+ expect(suggestSkill('summarize this doc', SKILLS)?.skillId).toBe('s-sum');
+ });
+ test('F1 review fires', () => {
+ expect(suggestSkill('帮我 review 一下这段代码', SKILLS)?.skillId).toBe('s-review');
+ expect(suggestSkill('做一次代码审查', SKILLS)?.skillId).toBe('s-review');
+ });
+ test('F2 chatter without triggers stays silent', () => {
+ expect(suggestSkill('今天天气不错', SKILLS)).toBeNull();
+ expect(suggestSkill('hello world', SKILLS)).toBeNull();
+ });
+ test('F3 no matching skill in vocab stays silent', () => {
+ expect(suggestSkill('明早 9 点提醒我开会', [])).toBeNull();
+ expect(
+ suggestSkill('明早 9 点提醒我开会', [{ id: 'x', name: 'translate', description: '翻译' }]),
+ ).toBeNull();
+ });
+ test('F4 already-referenced skill is not re-suggested', () => {
+ expect(suggestSkill('/remind 明早 9 点提醒我', SKILLS)).toBeNull();
+ expect(suggestSkill('明早提醒我 [remind](skill:s-remind)', SKILLS)).toBeNull();
+ });
+ test('F5 empty draft stays silent', () => {
+ expect(suggestSkill('', SKILLS)).toBeNull();
+ expect(suggestSkill(' ', SKILLS)).toBeNull();
+ });
+ test('F6 matching is case-insensitive', () => {
+ expect(suggestSkill('REMIND me tomorrow', SKILLS)?.skillId).toBe('s-remind');
+ expect(
+ suggestSkill('明早提醒我', [{ id: 'r', name: 'Remind', description: 'SCHEDULE alerts' }])
+ ?.skillId,
+ ).toBe('r');
+ });
+ test('F7 first table intent wins on multi-intent drafts', () => {
+ expect(suggestSkill('明早提醒我,顺便翻译成英文', SKILLS)?.skillId).toBe('s-remind');
+ });
+ test('F8 slash-command line belongs to the slash face', () => {
+ expect(suggestSkill('/remind', SKILLS)).toBeNull();
+ expect(suggestSkill(' /translate hello', SKILLS)).toBeNull();
+ });
+});
diff --git a/docs/verify/823/suggest-accepted.png b/docs/verify/823/suggest-accepted.png
new file mode 100644
index 000000000..1cb4af80c
Binary files /dev/null and b/docs/verify/823/suggest-accepted.png differ
diff --git a/docs/verify/823/suggest-flow.gif b/docs/verify/823/suggest-flow.gif
new file mode 100644
index 000000000..a8640b40e
Binary files /dev/null and b/docs/verify/823/suggest-flow.gif differ
diff --git a/docs/verify/823/suggest-strip.png b/docs/verify/823/suggest-strip.png
new file mode 100644
index 000000000..716fa0dbf
Binary files /dev/null and b/docs/verify/823/suggest-strip.png differ
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 3782a3884..f1470fc3b 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -15,6 +15,7 @@ export * from './protocol/index.js';
export * from './records/index.js';
export * from './scheduler.js';
export * from './secret-box.js';
+export * from './skill-suggest.js';
export * from './tables.js';
// spec 15 #394:占位标题派生 + 固定标签词表(ADR 0002 自觉背离原站的面)。
export * from './task-meta.js';
diff --git a/packages/shared/src/skill-suggest.ts b/packages/shared/src/skill-suggest.ts
new file mode 100644
index 000000000..f13061c8f
--- /dev/null
+++ b/packages/shared/src/skill-suggest.ts
@@ -0,0 +1,121 @@
+// Natural-language skill auto-suggest (issue #823).
+//
+// When the user types plain prose ("明早 9 点提醒我"), pacman matches the
+// draft against a first-batch trigger table and suggests the best team
+// skill as a ghost hint bar (Tab accepts). The candidate pool is ALWAYS
+// the caller's skill vocab (the same `useSkills` projection that feeds the
+// mention picker and the `/` menu) — this module builds no side table, so
+// a team without a matching skill simply gets no suggestion.
+//
+// Home is shared (not apps/web/src) on purpose: the trigger phrases are
+// matcher data, not UI copy, so they stay out of the web i18n-coverage
+// gate (every CJK literal under apps/web/src must be an en-dict key).
+// Pure logic, no React — both the web composer wire and future server
+// consumers import it from here.
+//
+// Deliberately NOT here: key handling and rendering. The composer wire
+// suppresses the suggestion while the `/` or `@` menus are open and owns
+// the Tab-accept key, so the three faces never steal each other's keys;
+// the skin lives with the composer faces.
+
+export interface SuggestSkillEntry {
+ id: string;
+ name: string;
+ description?: string;
+}
+
+export interface SkillSuggestion {
+ skillId: string;
+ skillName: string;
+ intentId: string;
+}
+
+interface TriggerIntent {
+ id: string;
+ /** Draft-side trigger phrases (matched case-insensitively, substring). */
+ triggers: string[];
+ /** Skill-side keywords: a skill matches when its name or description
+ * contains any of these (case-insensitive). */
+ skillKeywords: string[];
+}
+
+/** First-batch trigger table (#823:首批映射,不许全量). Order = priority:
+ * a draft hitting several intents suggests the earliest one. */
+const TRIGGER_INTENTS: TriggerIntent[] = [
+ {
+ id: 'reminder',
+ triggers: [
+ '提醒',
+ '闹钟',
+ '定时',
+ '明早',
+ '今晚',
+ '明天',
+ '后天',
+ '每天',
+ '每周',
+ 'deadline',
+ 'remind',
+ 'schedule',
+ 'cron',
+ 'alarm',
+ 'notify me',
+ '点提醒',
+ '点叫我',
+ ],
+ skillKeywords: ['remind', 'schedule', 'cron', 'notif', 'alarm', '提醒', '定时', '日程'],
+ },
+ {
+ id: 'translate',
+ triggers: ['翻译', '译成', '翻成', 'translate', 'translation'],
+ skillKeywords: ['translat', '翻译'],
+ },
+ {
+ id: 'summarize',
+ triggers: ['总结', '摘要', '归纳', 'summar', 'tl;dr'],
+ skillKeywords: ['summar', '总结', '摘要'],
+ },
+ {
+ id: 'review',
+ triggers: ['代码审查', 'review', 'code review', '审核一下', '帮我看一下代码', '检查一下代码'],
+ skillKeywords: ['review', '审查', 'lint'],
+ },
+];
+
+/** True when the draft already references the skill — either as a slash
+ * token (`/name`) or as an inserted mention (`[label](skill:id)`). */
+function alreadyReferenced(draft: string, skill: SuggestSkillEntry): boolean {
+ const lowered = draft.toLowerCase();
+ if (lowered.includes(`/${skill.name.toLowerCase()}`)) return true;
+ return draft.includes(`(skill:${skill.id})`);
+}
+
+/** A message-start slash line belongs to the `/` face. */
+function isSlashLine(draft: string): boolean {
+ return draft.trimStart().startsWith('/');
+}
+
+/** First matching suggestion, or null when nothing should be shown. */
+export function suggestSkill(
+ draft: string,
+ skills: readonly SuggestSkillEntry[],
+): SkillSuggestion | null {
+ if (draft.trim() === '') return null;
+ if (isSlashLine(draft)) return null;
+ if (skills.length === 0) return null;
+ const loweredDraft = draft.toLowerCase();
+ for (const intent of TRIGGER_INTENTS) {
+ if (!intent.triggers.some((t) => loweredDraft.includes(t.toLowerCase()))) continue;
+ const hit = skills.find(
+ (s) =>
+ !alreadyReferenced(draft, s) &&
+ intent.skillKeywords.some(
+ (k) =>
+ s.name.toLowerCase().includes(k.toLowerCase()) ||
+ (s.description ?? '').toLowerCase().includes(k.toLowerCase()),
+ ),
+ );
+ if (hit !== undefined) return { skillId: hit.id, skillName: hit.name, intentId: intent.id };
+ }
+ return null;
+}