write-ai-prompt-better
项目概述
write-ai-prompt-better 是一个 VS Code 扩展,核心功能是帮开发者在编辑器里快速收集上下文(文件路径、代码片段、终端输出),组装成结构化的 Markdown Prompt,一键复制给 AI 编程助手(Claude Code、GitHub Copilot、Cursor、Cline、Windsurf 等)。
技术栈:
| 层次 | 技术 |
|---|---|
| 语言 | TypeScript(strict 模式) |
| 运行时 | VS Code Extension Host (Node.js) |
| UI | 原生 HTML + CSS + Vanilla JS(内联嵌入到 Provider 单文件) |
| 构建 | tsc 直接编译为 CommonJS,无打包器 |
| 存储 | vscode.ExtensionContext.globalState(4 个持久化键) |
| 安全 | CSP nonce 保护 Webview 脚本 |
| 通信 | postMessage 双向消息协议(16 种 Webview 消息 + 6 种 Extension 消息) |
| i18n | 自定义模块,EN / zh-CN 双语完整覆盖(130+ 翻译键) |
结构化 Prompt 格式
扩展的核心输出是一个四段式 Markdown Prompt,每一段有明确的语义边界。
构建函数位于 WriteBetterPromptProvider.ts:700-740:
ts
function buildPrompt(){
var parts = [];
// 1. Skills — 引用的 AI Skill 文件路径
if (state.selectedSkills.length > 0){
var lines = [];
for (var i=0;i<state.selectedSkills.length;i++){
lines.push('- ' + BT + state.selectedSkills[i].path + BT);
}
parts.push(MSG.promptSkillHeader + NL + NL + lines.join(NL));
}
// 2. Background — 上下文(文件 / 终端 / 笔记)
if (state.contextItems.length > 0){
var body = [MSG.promptBackgroundIntro];
for (var j=0;j<state.contextItems.length;j++){
var item = state.contextItems[j];
if (item.type === 'file'){
body.push('📄 ' + BT + item.filePath + BT + (item.lineRange ? '#' + item.lineRange : ''));
} else if (item.type === 'terminal'){
body.push('💻 Terminal output:\n' + FENCE + '\n' + item.content + '\n' + FENCE);
} else if (item.type === 'manual'){
body.push('📝 ' + item.content);
}
}
parts.push(MSG.promptBackgroundHeader + NL + NL + body.join(NL));
}
// 3. Requirements — 需求描述
var req = $('req-textarea').value.trim();
if (req) parts.push(MSG.promptRequirementHeader + NL + NL + req);
// 4. Validation — 验证方法
var val = $('val-textarea').value.trim();
if (val) parts.push(MSG.promptValidationHeader + NL + NL + val);
return parts.join(NL + NL);
}关键设计:文件类型上下文在侧边栏卡片中显示完整内容供人工审查,但生成的 Prompt 只输出文件路径引用——AI 工具自行读取文件内容。这避免了把整个代码库塞进 prompt 造成的 token 浪费。只有终端输出的文本内容会原样嵌入 prompt。
架构设计
整体架构
核心模式:集中状态 + 多视图同步
Provider 在 Extension Host 进程中维护一份 _contextItems: ContextItem[] 作为唯一状态源。每当命令处理器添加新的上下文条目,Provider 通过 _broadcast() 将最新状态推送给所有打开的 Webview(侧边栏 + 独立编辑器窗口)。多视图之间实时同步,无需手动刷新。
消息协议
types.ts:56-81 定义了 16 种 Webview→Extension 消息和 6 种 Extension→Webview 消息:
| 方向 | 主要消息类型 |
|---|---|
| Webview → Extension | ready / copyToClipboard / saveHistory / deleteHistoryItem / savePresets / getSkills / removeContextItem / addManualItem / changeLanguage |
| Extension → Webview | syncContextItems / historyData / presetsData / skillsData / clearAll |
上下文收集机制
扩展注册了 4 条右键菜单命令,覆盖了开发者日常与 AI 交互时最需要的上下文来源。
编辑器选中内容 — addFileContent
extension.ts:75-101 — 读取 editor.selection 的文本内容,附带文件绝对路径和行号范围(如 L12-20)。同时将代码片段复制到剪贴板(用于即时粘贴):
ts
const text = doc.getText(selection);
const lineRange = startLine === endLine ? 'L' + startLine : 'L' + startLine + '-' + endLine;
const item: ContextItem = {
id: genId(),
type: 'file',
content: text,
filePath,
lineRange,
timestamp: Date.now(),
};
void vscode.env.clipboard.writeText(text); // 顺便写入剪贴板
provider.addContextItem(item);完整文件 — addFileToContext
extension.ts:103-124 — 通过 vscode.workspace.fs.readFile() 读取文件全部内容,适用于在标签页右键添加整个文件。
终端输出 — addTerminalContent(巧妙的 workaround)
extension.ts:126-144 — VS Code API 无法直接读取终端选中文本,所以扩展采用了一个变通方案:
ts
// 1. 执行系统命令:复制终端选中内容到剪贴板
await vscode.commands.executeCommand('workbench.action.terminal.copySelection');
// 2. 等待 150ms 让系统完成剪贴板写入
await delay(150);
// 3. 从剪贴板读回
const text = await vscode.env.clipboard.readText();这是一个经典的「API 限制下的迂回策略」——借道剪贴板实现了 VS Code API 不直接暴露的能力。
资源管理器文件/目录引用 — addPathToContext
extension.ts:146-167 — 通过 vscode.workspace.fs.stat() 判断是文件还是目录,分别创建 fileRef 或 folder 类型的上下文条目。这两类条目不携带内容,只提供路径引用供 AI 工具自行读取。
上下文类型总览
| type | 来源 | 内容 |
|---|---|---|
file | 编辑器选中 / 标签页右键 | 完整代码文本 + 行号范围 |
terminal | 终端右键选中 | 终端输出文本(剪贴板 workaround) |
manual | Webview 内手动输入 | 自由文本 |
folder | Explorer 目录右键 | 仅路径引用 |
fileRef | Explorer 文件右键 | 仅路径引用 |
Webview 内联 UI 的工程技巧
这个项目最特殊的工程决策是:整套 UI(HTML + CSS + JS)作为模板字符串内联在一个 TypeScript 文件的三个方法中。
为什么内联
VSCode Webview 扩展通常可以将 HTML/CSS/JS 拆分为独立文件,但本项目选择了单文件嵌入。好处是:
- 零外部依赖、零构建步骤(只靠
tsc编译) - 所有字符串可以直接访问 i18n 模块的翻译
- 部署和分发极其轻量(单个
.vsix只有几个 JS 文件)
转义约定
由于整个 JavaScript 代码嵌入在模板字符串中,反引号(backtick)和换行符会引发解析冲突。项目定义了全局简写来规避:
ts
// WriteBetterPromptProvider.ts 内部
const NL = String.fromCharCode(10); // 换行符
const BT = String.fromCharCode(96); // 反引号
const FENCE = BT + BT + BT; // 三反引号代码块对应地在 webview 中:
js
// 注入到 webview 的 JS
var NL = String.fromCharCode(10);
var BT = String.fromCharCode(96);
var FENCE = BT + BT + BT;因此 buildPrompt() 中的代码 '📄 ' + BT + item.filePath + BT 实际生成的是 📄 `filePath`。
国际化注入
i18n.ts:376-378 的 getWebviewMessages() 将当前语言的完整翻译对象(72 个键)序列化注入到 webview 的 MSG 全局变量中:
ts
// Provider 生成 HTML 时注入
const messages = getWebviewMessages();
const MSG_JSON = JSON.stringify(messages); // {"promptSkillHeader":"## Reference...", ...}Webview 中的 JS 通过 MSG.promptSkillHeader 等直接访问翻译字符串,切换语言时 Extension Host 重新发送完整翻译对象。
语言切换
用户在 Webview UI 中切换 EN / zh-CN,发送 changeLanguage 消息到 Extension Host → Provider 调用 setLang(lang) 写入 globalState → 重新注入翻译对象并发送到所有视图。
AI Skill 自动发现
扩展能自动扫描工作区中的 AI 技能文件,并将其引用到 Prompt 的 Skills 段落中。
扫描策略
WriteBetterPromptProvider.ts:303-334 遍历 11 条路径,覆盖 5 种 AI 工具:
ts
private async _scanSkills(force = false): Promise<SkillItem[]> {
if (!force && this._skills.length > 0) return this._skills;
const skills: SkillItem[] = [];
const home = os.homedir();
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
// --- 全局级(用户主目录)---
await this._scanSkillDirs(path.join(home, '.claude', 'skills'), L.claudeGlobal, skills);
await this._readSingle(path.join(home, '.claude', 'CLAUDE.md'), L.claudeGlobal, 'CLAUDE.md', skills);
await this._scanDir(path.join(home, '.cursor', 'rules'), '.mdc', L.cursorGlobal, skills);
await this._readSingle(path.join(home, '.cursorrules'), L.cursorGlobal, '.cursorrules', skills);
this._readCopilotInstructions(L.copilotGlobal, skills);
// --- 项目级(工作区根目录)---
if (workspaceRoot) {
await this._scanSkillDirs(path.join(workspaceRoot, '.claude', 'skills'), L.claudeProject, skills);
await this._scanDir(path.join(workspaceRoot, '.cursor', 'rules'), '.mdc', L.cursorProject, skills);
await this._readSingle(path.join(workspaceRoot, '.cursorrules'), L.cursorProject, '.cursorrules', skills);
await this._readSingle(path.join(workspaceRoot, '.clinerules'), L.clineProject, '.clinerules', skills);
await this._readSingle(path.join(workspaceRoot, '.windsurfrules'), L.windsurfProject, '.windsurfrules', skills);
await this._readSingle(path.join(workspaceRoot, '.github', 'copilot-instructions.md'), L.copilotProject, 'copilot-instructions.md', skills);
}
this._skills = skills;
return skills;
}三类扫描器
| 方法 | 用途 | 示例 |
|---|---|---|
_scanSkillDirs() | 读取子目录中的 SKILL.md | Claude Code ~/.claude/skills/<name>/SKILL.md |
_scanDir() | 读取指定扩展名的文件 | Cursor ~/.cursor/rules/*.mdc |
_readSingle() | 读取单个固定路径文件 | .cursorrules、.clinerules、.windsurfrules |
YAML Frontmatter 解析
每个 skill 文件可能包含 YAML frontmatter(--- 分隔)。Provider 的 _parseFrontmatter() 方法提取 name 和 description 字段用于 UI 展示,同时保留完整 content 供 prompt 引用。
工具分组
扫描结果按工具和层级分组,通过 i18n.ts:400-413 的 getSkillAgentLabels() 生成中文标签:
| Agent 标签 | 示例路径 |
|---|---|
| Claude Code · 全局 | ~/.claude/skills/*/SKILL.md |
| Cursor · 项目 | .cursor/rules/*.mdc |
| Cline · 项目 | .clinerules |
| Windsurf · 项目 | .windsurfrules |
| Copilot · 项目 | .github/copilot-instructions.md |
模糊搜索
Webview 中的 skill 下拉框实现了字符级别的模糊匹配(fuzzyMatch),用户输入时不需要完整拼写即可快速筛选技能文件。
预设系统
快速填充 Requirement 和 Validation 文本框的短语模板。
三级优先级
README.md:259-261 定义了三层预设优先级:
- UI 管理预设(
globalState键wbp.presets/wbp.validationPresets)——最高优先级,用户通过 ⚙ 面板自行添加/编辑 - settings.json 预设(VS Code 配置
writeBetterPrompt.presets)——仅作为首次安装时的种子数据 - 内置默认预设(由
i18n.ts:381-397按当前语言生成)——兜底默认值
内置预设列表
i18n.ts:381-391 定义了 6 个内置需求预设:
ts
export function getDefaultPresets() {
const m = getWebviewMessages();
return [
{ label: m.presetAdjustStyleLabel, value: m.presetAdjustStyleValue }, // 调整样式/UI
{ label: m.presetTroubleshootLabel, value: m.presetTroubleshootValue }, // 排查 bug
{ label: m.presetImplementFeatureLabel, value: m.presetImplementFeatureValue }, // 实现功能
{ label: m.presetRefineFeatureLabel, value: m.presetRefineFeatureValue }, // 优化功能
{ label: m.presetCodeReviewLabel, value: m.presetCodeReviewValue }, // 代码审查
{ label: m.presetAddCommentsLabel, value: m.presetAddCommentsValue }, // 添加注释
];
}验证方法有一个内置预设:「运行 npm run build 无错误通过」。
预设管理面板
用户点击 ⚙ 按钮打开预设管理弹窗——可以添加、编辑、删除需求预设和验证预设。所有修改通过 savePresets / saveValidationPresets 消息回写 Extension Host,持久化到 globalState。
历史记录与状态恢复
每次生成 Prompt 时自动保存一份快照,支持无限历史、预览、恢复、删除。
数据结构
types.ts:37-47 定义 HistoryItem:
ts
export interface HistoryItem {
id: string;
prompt: string; // 完整 Markdown Prompt
preview: string; // 前 ~100 字符(去除 Markdown 标题)
timestamp: number;
contextItems: ContextItem[]; // 生成此 prompt 时的上下文快照
}核心设计:Context Snapshot
每条历史记录不仅保存最终 Prompt 文本,还保存当时所有 ContextItem 的快照。这使得「恢复」功能可以精确重建当时的所有上下文卡片——包括文件路径、代码内容、行号、终端输出。
跨语言恢复
恢复时不仅解析当前语言的 Requirements / Validation 段落,还会尝试用另一种语言的段落标题做 fallback(promptRequirementHeaderAlt / promptValidationHeaderAlt),确保切换语言后的历史记录也能被正确还原。
预览
makePreview() 方法(WriteBetterPromptProvider.ts:742-749)去除 Markdown 标题行和空行,截取前约 100 个字符作为列表预览文本——保持历史列表的简洁可读。
总结
write-ai-prompt-better 是一个「用最朴素的技术栈解决最高频的开发痛点」的典型案例:不依赖 React/Vue/任何打包器,4 个源文件,单一 Provider 类承载 1209 行的完整 UI 逻辑,却能高效地把「跟 AI 描述当前开发任务」这件事从手工低效操作变成右键点击的机械动作。
几个值得借鉴的设计点:
- 集中状态 + 多视图广播:一份
_contextItems数组驱动侧边栏和独立窗口的实时同步,干净利落 - 终端剪贴板 workaround:借道剪贴板实现 VS Code API 不暴露的能力,150ms 延迟换取功能完整性
- 模板字符串内联 UI:反向工程中的约束美学——为了零依赖和 i18n 直接访问,接受了
var/NL/BT的编码约定 - YAML Frontmatter 解析:统一了对 Claude Code / Cursor / Cline / Windsurf 技能文件的发现和元数据提取
- Context Snapshot 历史:不只是保存文本,而是保存完整的可恢复上下文状态
- 三级预设优先级:UI 管理 > settings.json > 内置默认,兼顾可配置性和开箱即用
站点:https://write-ai-prompt-better.tangzixuan.cc/
源码:github.com/tangzixuan/write-ai-prompt-better