Repository navigation
Expand file tree
/
Copy pathplugin.js
More file actions
362 lines (332 loc) · 21.3 KB
/
Copy pathplugin.js
File metadata and controls
362 lines (332 loc) · 21.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
// AI Git Writer for PromptOps. Section: pages.
//
// The plugin never draws anything. `open` and `event` return a description of the page
// (plain data) and PromptOps draws it with its own components.
// It never sees a path: `repo` is a handle that exists because the person picked a
// repository on the page. Writing a file and running the model both ask the person first.
(() => {
'use strict';
const PAGE = 'writer';
const COMMIT_LIMIT = 5;
const FILE_LIST_LIMIT = 30;
const DIFF_CHAR_BUDGET = 6000;
const TYPES = [
{ value: 'mail', label: 'Email', icon: 'ti-mail', hint: 'Subject and body, ready to send', prefix: 'mail', needsDraft: true },
{ value: 'documentation', label: 'Documentation', icon: 'ti-book-2', hint: 'Developer docs for the work done', prefix: 'documentation' },
{ value: 'changelog', label: 'Changelog', icon: 'ti-list-details', hint: 'Added, Changed, Fixed', prefix: 'changelog' },
{ value: 'report', label: 'Status report', icon: 'ti-report', hint: 'For non-technical readers', prefix: 'report' },
{ value: 'handover', label: 'Handover', icon: 'ti-transfer', hint: 'For whoever takes over', prefix: 'handover' },
];
const TONES = [
{ value: 'professional', label: 'Professional', icon: 'ti-briefcase', text: 'professional but approachable' },
{ value: 'formal', label: 'Formal', icon: 'ti-building-bank', text: 'formal and respectful' },
{ value: 'informal', label: 'Informal', icon: 'ti-mood-smile', text: 'informal and friendly' },
];
const LANGUAGES = [
{ value: 'auto', label: 'Same as my notes' }, { value: 'en', label: 'English', name: 'English' }, { value: 'it', label: 'Italian', name: 'Italian' },
{ value: 'de', label: 'German', name: 'German' }, { value: 'fr', label: 'French', name: 'French' }, { value: 'es', label: 'Spanish', name: 'Spanish' },
];
const MODELS = [
{ value: '', label: 'Default model' }, { value: 'claude-fable-5-1', label: 'Fable 5.1' }, { value: 'claude-fable-5', label: 'Fable 5' },
{ value: 'claude-opus-5', label: 'Opus 5' }, { value: 'claude-opus-4-8', label: 'Opus 4.8' }, { value: 'claude-sonnet-5', label: 'Sonnet 5' },
{ value: 'claude-haiku-4-5', label: 'Haiku 4.5' },
];
const SETTINGS = ['type', 'tone', 'language', 'model', 'includeFiles', 'includeCommits', 'includeDiff'];
const state = {
type: 'mail', tone: 'professional', language: 'auto', model: '', recipient: '', draft: '',
repo: null, context: null, includeFiles: true, includeCommits: true, includeDiff: true,
subject: '', body: '', generatedType: null, fileName: '', refine: '', saved: '', notice: null, restored: false,
};
const typeOf = (value) => TYPES.find((t) => t.value === value) || TYPES[0];
const oneOf = (list, value, fallback) => (list.some((o) => o.value === value) ? value : fallback);
const text = (v, max) => String(v == null ? '' : v).slice(0, max);
function todayStamp(now) {
const d = now || new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
// ── What comes from the page ────────────────────────────────────────────────
/** Copies the form values into the state. Everything is validated: values are data from outside. */
function absorb(values) {
if (!values || typeof values !== 'object') return;
if ('type' in values) state.type = oneOf(TYPES, values.type, state.type);
if ('tone' in values) state.tone = oneOf(TONES, values.tone, state.tone);
if ('language' in values) state.language = oneOf(LANGUAGES, values.language, state.language);
if ('model' in values) state.model = oneOf(MODELS, values.model, '');
if ('recipient' in values) state.recipient = text(values.recipient, 200);
if ('draft' in values) state.draft = text(values.draft, 20000);
if ('refine' in values) state.refine = text(values.refine, 2000);
if ('subject' in values) state.subject = text(values.subject, 300);
if ('body' in values) state.body = text(values.body, 200000);
if ('fileName' in values) state.fileName = text(values.fileName, 120);
for (const key of ['includeFiles', 'includeCommits', 'includeDiff']) if (key in values) state[key] = !!values[key];
}
function sameRepo(a, b) {
return (a && a.handle) === (b && b.handle);
}
async function loadContext(sdk) {
state.context = null;
if (!state.repo) return;
const [status, commits] = await Promise.all([sdk.git.status(state.repo.handle), sdk.git.log(state.repo.handle, COMMIT_LIMIT)]);
state.context = {
branch: text(status && status.branch, 200),
files: (status && Array.isArray(status.files) ? status.files : []).map((f) => text(f.status || 'M', 4) + ' ' + text(f.path, 300)),
commits: (Array.isArray(commits) ? commits : []).slice(0, COMMIT_LIMIT).map((c) => ({
shortHash: text(c.shortHash, 16), message: text(c.message, 300), relativeDate: text(c.relativeDate, 60),
})),
};
}
// ── Prompts ─────────────────────────────────────────────────────────────────
function languageInstruction() {
const lang = LANGUAGES.find((l) => l.value === state.language);
if (lang && lang.name) return lang.name;
return state.draft.trim() ? "the same language as the user's notes below" : 'English';
}
function instructions(type) {
const language = '- Write in ' + languageInstruction() + '.';
const markdownRules = [
'- Output ONLY the Markdown document: no explanations, no surrounding code fences.',
'- Be precise and grounded: only state what the notes and development context support, never invent behavior, APIs or dates.',
];
switch (type) {
case 'documentation':
return ['You are an expert technical writer.', 'Write developer documentation in Markdown for the work described below.', '', 'Rules:', language,
'- Start with a single "# <title>" heading, then well-structured "##" sections.',
'- Explain what it does, how it works and how to use it; include short code or command examples only where they genuinely help.'].concat(markdownRules);
case 'changelog':
return ['You are a release-notes writer.', 'Write a changelog entry in Markdown for the work described below.', '', 'Rules:', language,
'- Start with "# Changelog — ' + todayStamp() + '", then group entries under "## Added", "## Changed", "## Fixed" (only the groups that apply).',
'- One concise bullet per user-visible change; merge related commits into a single entry.',
'- Skip noise such as merge commits, version bumps and CI chores.'].concat(markdownRules);
case 'report':
return ['You are writing a concise status report for stakeholders.', 'Write it in Markdown based on the work described below.', '', 'Rules:', language,
'- Start with a single "# <title>" heading, then a 2-3 sentence summary, then "## Completed", "## In progress" and "## Next steps" sections ("## Risks" only if the material shows real risks).',
'- Non-technical readers must understand it: explain impact and progress, not implementation details.'].concat(markdownRules);
case 'handover':
return ['You are writing a handover document for a developer taking over this work.', 'Write it in Markdown based on the work described below.', '', 'Rules:', language,
'- Start with a single "# <title>" heading, then sections: "## Current state", "## What changed", "## Open threads", "## Gotchas", "## Where things live".',
'- Be concrete: reference the actual files, branches and commits from the context; flag anything unfinished or fragile.'].concat(markdownRules);
default: {
const tone = (TONES.find((t) => t.value === state.tone) || TONES[0]).text;
return ['You are an expert business-email writer.', 'Write a ' + tone + " email based on the user's notes below.", '', 'Rules:', language,
'- Keep it clear and well structured; no filler.',
'- Start with a single line "Subject: <the subject>", then a blank line, then the email body.',
'- Write the body as plain text: short paragraphs separated by a blank line, "- " for list items. No HTML, no markdown, no asterisks, no # headers, no backticks.',
'- Output ONLY the email (subject line + body). No explanations, no code fences.'];
}
}
}
async function contextBlock(sdk, type) {
const ctx = state.context;
if (!state.repo || !ctx) return '';
const lines = ['Development context from the repository "' + state.repo.name + '" (git branch "' + ctx.branch + '"):'];
if (state.includeFiles && ctx.files.length) {
lines.push('Changed files (' + ctx.files.length + '):');
for (const f of ctx.files.slice(0, FILE_LIST_LIMIT)) lines.push('- ' + f);
if (ctx.files.length > FILE_LIST_LIMIT) lines.push('- ... and ' + (ctx.files.length - FILE_LIST_LIMIT) + ' more');
}
if (state.includeCommits && ctx.commits.length) {
lines.push('Recent commits:');
for (const c of ctx.commits) lines.push('- ' + c.shortHash + ' ' + c.message + ' (' + c.relativeDate + ')');
}
if (state.includeDiff) {
let raw = '';
try { raw = String((await sdk.git.diff(state.repo.handle)).diff || ''); } catch (e) { raw = ''; }
if (raw.trim()) {
lines.push('Code changes (git diff, may be truncated):');
lines.push(raw.length > DIFF_CHAR_BUDGET ? raw.slice(0, DIFF_CHAR_BUDGET) + '\n[... diff truncated ...]' : raw);
}
}
lines.push('', type === 'mail'
? 'Use this context to reference the actual development work where it strengthens the email; do not paste raw code or the diff into the email.'
: 'Base the document primarily on this development context. Describe the changes; do not paste the raw diff verbatim.');
return lines.join('\n');
}
async function generatePrompt(sdk) {
const type = state.type;
const parts = instructions(type);
if (type === 'mail' && state.recipient.trim()) parts.push('- The email is addressed to: ' + state.recipient.trim() + '.');
const draft = state.draft.trim();
if (draft) parts.push('', 'User notes / key points:', draft);
else parts.push('', 'The user provided no notes: base the document entirely on the development context below.');
const block = await contextBlock(sdk, type);
if (block) parts.push('', block);
return parts.join('\n');
}
function refinePrompt() {
const asked = state.refine.trim();
if (state.generatedType !== 'mail') {
return ['Below is a Markdown document. Revise it according to the instructions, keeping its language and overall structure unless the instructions say otherwise.',
'Output ONLY the revised Markdown document: no explanations, no surrounding code fences.', '', 'Instructions: ' + asked, '', 'Document:', state.body.trim()].join('\n');
}
const current = state.subject.trim() ? 'Subject: ' + state.subject.trim() + '\n\n' + state.body.trim() : state.body.trim();
return ['You are an expert business-email writer.', 'Below is an email draft. Revise it according to the instructions, keeping its language and overall intent.',
'Output ONLY the revised email: a single line "Subject: <the subject>", a blank line, then the body as plain text. No HTML, no markdown, no code fences. No explanations.',
'', 'Instructions: ' + asked, '', 'Email draft:', current].join('\n');
}
// ── Result ──────────────────────────────────────────────────────────────────
function defaultFileName(type) {
return typeOf(type).prefix + '-' + todayStamp() + '.md';
}
const DEFAULT_NAME = /^(mail|documentation|changelog|report|handover)-\d{4}-\d{2}-\d{2}\.md$/;
/** The model's answer is text from outside: fences and chatter are stripped, nothing is interpreted. */
function applyResult(raw, type) {
const cleaned = String(raw || '').trim().replace(/^```(?:html|markdown|md|text)?\s*/i, '').replace(/```\s*$/, '').trim();
if (type !== 'mail') {
const h1 = cleaned.search(/^# /m);
state.subject = '';
state.body = h1 > 0 ? cleaned.slice(h1) : cleaned;
} else {
const lines = cleaned.split('\n');
const first = lines.findIndex((l) => l.trim().length > 0);
const match = first >= 0 ? lines[first].trim().match(/^(?:subject|oggetto|betreff|objet|asunto)\s*:\s*(.*)$/i) : null;
state.subject = match ? match[1].trim() : '';
state.body = match ? lines.slice(first + 1).join('\n').trim() : cleaned;
}
state.generatedType = type;
state.saved = '';
if (!state.fileName.trim() || DEFAULT_NAME.test(state.fileName.trim())) state.fileName = defaultFileName(type);
}
/** `docs/<name>`: separators become dashes, leading dots go, a text extension is enforced. */
function docsPath() {
let name = state.fileName.trim().replace(/[\\/\s]+/g, '-').replace(/[^A-Za-z0-9._-]/g, '').replace(/^\.+/, '');
if (!name) name = defaultFileName(state.generatedType || state.type);
if (!/\.(md|markdown|txt)$/i.test(name)) name += '.md';
return 'docs/' + name;
}
function fileContent() {
const body = state.body.replace(/\s+$/, '');
if (state.generatedType !== 'mail' || !state.subject.trim()) return body + '\n';
return '# ' + state.subject.trim() + '\n\n' + body + '\n';
}
// ── The page, as data ───────────────────────────────────────────────────────
function view() {
const type = typeOf(state.type);
const left = [
{ type: 'field', kind: 'choice', id: 'type', label: 'What do you need?', live: true, value: state.type,
options: TYPES.map((t) => ({ value: t.value, label: t.label, icon: t.icon, hint: t.hint })) },
{ type: 'field', kind: 'repo', id: 'repo', label: 'Repository', live: true, value: state.repo,
help: type.needsDraft ? 'Optional. Adds the branch, the changed files, the recent commits and the diff to what the writer knows.'
: 'Pick the repository this document is about. Its git activity is what the writer works from.' },
];
if (state.repo && state.context) {
left.push({ type: 'stats', items: [
{ label: 'Branch', value: state.context.branch || '—' },
{ label: 'Changed files', value: String(state.context.files.length) },
{ label: 'Recent commits', value: String(state.context.commits.length) },
] });
left.push({ type: 'field', kind: 'toggle', id: 'includeFiles', label: 'Include the list of changed files', value: state.includeFiles });
left.push({ type: 'field', kind: 'toggle', id: 'includeCommits', label: 'Include the recent commits', value: state.includeCommits });
left.push({ type: 'field', kind: 'toggle', id: 'includeDiff', label: 'Include the code changes (git diff)', value: state.includeDiff,
help: 'The diff is part of the prompt you approve before anything runs.' });
}
if (state.type === 'mail') {
left.push({ type: 'field', kind: 'text', id: 'recipient', label: 'Who is it for?', placeholder: 'Marta, product owner at Acme', value: state.recipient });
left.push({ type: 'field', kind: 'choice', id: 'tone', label: 'Tone', value: state.tone, options: TONES.map((t) => ({ value: t.value, label: t.label, icon: t.icon })) });
}
left.push({ type: 'field', kind: 'textarea', id: 'draft', rows: 7, value: state.draft,
label: type.needsDraft ? 'Your notes' : 'Your notes (optional with a repository)',
placeholder: type.needsDraft ? 'Key points, in any order. The writer turns them into the email.' : 'Anything the git history does not say: goals, decisions, what is still open.' });
// `half` puts two short fields on one row. Columns never nest.
left.push({ type: 'field', kind: 'select', id: 'language', label: 'Language', half: true, value: state.language, options: LANGUAGES.map((l) => ({ value: l.value, label: l.label })) });
left.push({ type: 'field', kind: 'select', id: 'model', label: 'Claude model', half: true, value: state.model, options: MODELS });
left.push({ type: 'actions', items: [{ id: 'generate', label: state.body ? 'Generate again' : 'Generate', style: 'primary', icon: 'ti-sparkles' }] });
const right = [];
if (state.notice) right.push({ type: 'notice', tone: state.notice.tone, text: state.notice.text });
if (!state.body) {
right.push({ type: 'text', muted: true, text: 'Your ' + type.label.toLowerCase() + ' appears here. Before anything runs you see the full prompt and confirm it.' });
} else {
const made = typeOf(state.generatedType);
right.push({ type: 'heading', text: made.label });
if (state.generatedType === 'mail') right.push({ type: 'field', kind: 'text', id: 'subject', label: 'Subject', value: state.subject });
right.push({ type: 'output', id: 'body', label: state.generatedType === 'mail' ? 'Body' : 'Markdown', text: state.body, editable: true, copy: true, mono: state.generatedType !== 'mail' });
right.push({ type: 'field', kind: 'text', id: 'refine', label: 'Ask for a change', placeholder: 'Shorter, and mention the deadline', value: state.refine });
right.push({ type: 'actions', items: [{ id: 'refine', label: 'Revise', icon: 'ti-wand' }, { id: 'clear', label: 'Clear', style: 'ghost' }] });
right.push({ type: 'divider' });
if (state.repo) {
right.push({ type: 'field', kind: 'text', id: 'fileName', label: 'Save in docs/ of ' + state.repo.name, value: state.fileName, help: 'You see the file and confirm before it is written.' });
right.push({ type: 'actions', items: [{ id: 'save', label: 'Save to repository', icon: 'ti-device-floppy' }] });
} else {
right.push({ type: 'text', muted: true, text: 'Pick a repository to save this in its docs/ folder.' });
}
if (state.saved) right.push({ type: 'notice', tone: 'success', text: 'Saved as ' + state.saved });
}
return {
title: 'AI Git Writer',
subtitle: 'From your notes and your git activity to a document. It runs on your own Claude, with no tools and no access to your files.',
blocks: [{ type: 'columns', columns: [left, right] }],
};
}
const fail = (e) => {
const message = String(e && e.message || e);
state.notice = /^(user_denied|confirm_timeout)\b/.test(message) ? { tone: 'info', text: 'Cancelled. Nothing was run or written.' } : { tone: 'danger', text: message };
};
async function persist(sdk) {
const settings = {};
for (const key of SETTINGS) settings[key] = state[key];
try { await sdk.storage.set('settings', settings); } catch (e) { /* settings are a convenience */ }
}
async function restore(sdk) {
if (state.restored) return;
state.restored = true;
try { absorb(await sdk.storage.get('settings')); } catch (e) { /* first run */ }
}
promptops.pages.register({
async open(pageId, _ctx, sdk) {
if (pageId !== PAGE) throw new Error('unknown page');
await restore(sdk);
state.notice = null;
return view();
},
async event(pageId, ev, sdk) {
if (pageId !== PAGE) throw new Error('unknown page');
const values = (ev && ev.values) || {};
absorb(values);
state.notice = null;
const id = ev && ev.id;
try {
if ('repo' in values && !sameRepo(values.repo, state.repo)) {
const picked = values.repo;
state.repo = picked && typeof picked.handle === 'string' ? { handle: picked.handle, name: text(picked.name, 120) } : null;
await loadContext(sdk);
}
if (ev && ev.type === 'change') {
await persist(sdk);
return view();
}
if (id === 'generate') {
const type = typeOf(state.type);
if (!state.draft.trim() && (type.needsDraft || !state.repo)) {
state.notice = { tone: 'warning', text: type.needsDraft ? 'Write a few notes first: an email needs them.' : 'Write a few notes or pick a repository.' };
return view();
}
await persist(sdk);
const res = await sdk.ai.generate({ prompt: await generatePrompt(sdk), model: state.model || undefined });
applyResult(res && res.text, state.type);
state.refine = '';
} else if (id === 'refine') {
if (!state.body.trim() || !state.refine.trim()) {
state.notice = { tone: 'warning', text: 'Say what to change first.' };
return view();
}
const res = await sdk.ai.generate({ prompt: refinePrompt(), model: state.model || undefined });
applyResult(res && res.text, state.generatedType);
state.refine = '';
} else if (id === 'save') {
if (!state.repo || !state.body.trim()) {
state.notice = { tone: 'warning', text: 'Nothing to save yet.' };
return view();
}
const path = docsPath();
await sdk.workspace.writeFile(state.repo.handle, path, fileContent());
state.saved = state.repo.name + '/' + path;
} else if (id === 'clear') {
state.subject = ''; state.body = ''; state.generatedType = null; state.fileName = ''; state.refine = ''; state.saved = '';
}
} catch (e) {
fail(e);
}
return view();
},
});
})();