codexmate 0.1.21 → 0.1.22

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
package/web-ui/app.js CHANGED
@@ -102,6 +102,14 @@ document.addEventListener('DOMContentLoaded', () => {
102
102
  showConfigTemplateModal: false,
103
103
  showAgentsModal: false,
104
104
  promptsSubTab: 'codex',
105
+ promptsPreviewEnabled: true,
106
+ promptsPreviewCollapsed: false,
107
+ promptsMobileView: 'edit',
108
+ promptsPreviewLibsMissing: false,
109
+ agentsHighlightHtml: '',
110
+ sysPromptHighlightHtml: '',
111
+ agentsPreviewHtml: '',
112
+ sysPromptPreviewHtml: '',
105
113
  projectClaudeMdPath: '',
106
114
  promptHistoryVisible: false,
107
115
  promptHistoryLoading: false,
@@ -812,6 +820,12 @@ document.addEventListener('DOMContentLoaded', () => {
812
820
  if (typeof this.loadPromptsTabContent === 'function') this.loadPromptsTabContent();
813
821
  }
814
822
  },
823
+ agentsContent() {
824
+ if (typeof this.schedulePromptsEditorRefresh === 'function') this.schedulePromptsEditorRefresh('agents');
825
+ },
826
+ sysPromptContent() {
827
+ if (typeof this.schedulePromptsEditorRefresh === 'function') this.schedulePromptsEditorRefresh('sys');
828
+ },
815
829
  projectClaudeMdPath(newPath) {
816
830
  if (typeof this.persistWebUiPreferences === 'function') {
817
831
  this.persistWebUiPreferences({ projectClaudeMdPath: newPath || '' });
package/web-ui/index.html CHANGED
@@ -8,6 +8,9 @@
8
8
  <link rel="apple-touch-icon" href="/res/logo-pack.webp">
9
9
  <script src="/res/vue.runtime.global.prod.js"></script>
10
10
  <script src="/res/json5.min.js"></script>
11
+ <script src="/res/highlight.min.js"></script>
12
+ <script src="/res/marked.min.js"></script>
13
+ <script src="/res/purify.min.js"></script>
11
14
  <link rel="stylesheet" href="/web-ui/styles.css">
12
15
  </head>
13
16
  <body>
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Pure logic for the prompts-panel Markdown editor.
3
+ * No DOM access in this module so every function stays unit-testable.
4
+ *
5
+ * Syntax highlighting delegates to the vendored highlight.js UMD build
6
+ * (web-ui/res/highlight.min.js, "markdown" grammar is included in the
7
+ * common build) and the rendered highlight markup is layered behind a
8
+ * transparent textarea. Preview rendering delegates to the vendored
9
+ * marked + DOMPurify pair; raw markdown may embed arbitrary HTML, so
10
+ * sanitize is mandatory before the result reaches v-html.
11
+ */
12
+
13
+ const HIGHLIGHT_MAX_CHARS = 200000;
14
+ const HTML_ESCAPE_MAP = {
15
+ '&': '&amp;',
16
+ '<': '&lt;',
17
+ '>': '&gt;',
18
+ '"': '&quot;',
19
+ "'": '&#39;'
20
+ };
21
+
22
+
23
+ let hasWarnedHighlightFallback = false;
24
+
25
+ export function escapeHtmlText(text) {
26
+ return String(text === null || text === undefined ? '' : text)
27
+ .replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]);
28
+ }
29
+
30
+ function plainHighlightFallback(text) {
31
+ if (!hasWarnedHighlightFallback) {
32
+ hasWarnedHighlightFallback = true;
33
+ // eslint-disable-next-line no-console
34
+ console.warn('prompts editor: highlight.js unavailable, editor falls back to plain text rendering');
35
+ }
36
+ return escapeHtmlText(text) + '\n';
37
+ }
38
+
39
+ /**
40
+ * Produce the highlighted HTML layer for a markdown source string.
41
+ * highlight.js escapes HTML entities itself; the trailing newline keeps
42
+ * the <pre> backdrop visually aligned with the textarea's final line.
43
+ * Returns escaped plain text when hljs is missing or the input exceeds
44
+ * the highlight budget (observable degradation, never a fake success).
45
+ */
46
+ export function highlightMarkdownText(text, hljs) {
47
+ const source = typeof text === 'string' ? text : '';
48
+ if (!hljs || typeof hljs.highlight !== 'function' || typeof hljs.getLanguage !== 'function' || !hljs.getLanguage('markdown')) {
49
+ return plainHighlightFallback(source);
50
+ }
51
+ if (source.length > HIGHLIGHT_MAX_CHARS) {
52
+ return plainHighlightFallback(source);
53
+ }
54
+ try {
55
+ return hljs.highlight(source, { language: 'markdown', ignoreUnescapedHTML: true }).value + '\n';
56
+ } catch (e) {
57
+ return plainHighlightFallback(source);
58
+ }
59
+ }
60
+
61
+
62
+ /**
63
+ * Render a markdown preview. Returns a state object instead of raw HTML so
64
+ * the UI can surface "libs missing" and "empty input" explicitly instead
65
+ * of faking a successful render.
66
+ */
67
+ export function buildMarkdownPreviewHtml(text, marked, purify) {
68
+ const source = typeof text === 'string' ? text : '';
69
+ if (!source.trim()) {
70
+ return { empty: true, html: '' };
71
+ }
72
+ if (!marked || typeof marked.parse !== 'function' || !purify || typeof purify.sanitize !== 'function') {
73
+ return { unavailable: true, html: '' };
74
+ }
75
+ const rawHtml = marked.parse(source, { gfm: true, breaks: false });
76
+ return { html: purify.sanitize(String(rawHtml)) };
77
+ }
@@ -112,30 +112,39 @@ function extractWorkspaceArtifacts(line, target) {
112
112
  }
113
113
  }
114
114
 
115
- function buildWorkspaceBriefText(summary, labels = {}) {
115
+ function buildWorkspaceBriefText(summary, labels = {}, metricLabels = {}) {
116
116
  if (!summary || !summary.available) return '';
117
117
  const label = (key, fallback) => (labels && labels[key]) || fallback;
118
+ const metricLabel = (key, fallback) => (metricLabels && metricLabels[key]) || fallback;
118
119
  const lines = [
119
120
  `# ${label('title', 'Session workspace brief')}`,
120
121
  '',
121
122
  `${label('source', 'Source')}: ${summary.sourceLabel || summary.source || '-'}`,
122
123
  `${label('messages', 'Messages')}: ${summary.messageCount}`,
124
+ `${metricLabel('userLabel', 'User')}: ${summary.roleCounts.user}`,
125
+ `${metricLabel('assistantLabel', 'Assistant')}: ${summary.roleCounts.assistant}`,
126
+ `${metricLabel('commandsLabel', 'Commands')}: ${summary.commands.length}`,
127
+ `${metricLabel('artifactsLabel', 'Artifacts')}: ${summary.files.length + summary.links.length}`,
128
+ `${metricLabel('risksLabel', 'Risks')}: ${summary.risks.length + summary.nextSteps.length}`,
123
129
  `${label('path', 'Path')}: ${summary.cwd || '-'}`,
124
130
  ''
125
131
  ];
126
132
  const sections = [
127
- ['signals', label('signals', 'Signals'), summary.signals],
128
- ['commands', label('commands', 'Reusable commands'), summary.commands],
129
- ['files', label('files', 'Files'), summary.files],
130
- ['links', label('links', 'Links'), summary.links],
131
- ['risks', label('risks', 'Risks / blockers'), summary.risks],
132
- ['nextSteps', label('nextSteps', 'Next steps'), summary.nextSteps]
133
+ ['signals', label('signals', 'Signals'), summary.signals, label('noneSignals', 'No strong signals yet')],
134
+ ['commands', label('commands', 'Reusable commands'), summary.commands, label('noneCommands', 'No command signals')],
135
+ ['files', label('files', 'Files'), summary.files, label('noneFiles', 'No file signals')],
136
+ ['links', label('links', 'Links'), summary.links, label('noneLinks', 'No link signals')],
137
+ ['risks', label('risks', 'Risks / blockers'), summary.risks, label('noneRisks', 'No risks or todos detected')],
138
+ ['nextSteps', label('nextSteps', 'Next steps'), summary.nextSteps, label('noneRisks', 'No risks or todos detected')]
133
139
  ];
134
- for (const [, sectionTitle, items] of sections) {
135
- if (!Array.isArray(items) || !items.length) continue;
140
+ for (const [, sectionTitle, items, emptyText] of sections) {
136
141
  lines.push(`## ${sectionTitle}`);
137
- for (const item of items) {
138
- lines.push(`- ${item}`);
142
+ if (Array.isArray(items) && items.length) {
143
+ for (const item of items) {
144
+ lines.push(`- ${item}`);
145
+ }
146
+ } else {
147
+ lines.push(`- ${emptyText}`);
139
148
  }
140
149
  lines.push('');
141
150
  }
@@ -231,7 +240,13 @@ export function buildSessionWorkspaceSummary(session, messages, options = {}) {
231
240
  risks,
232
241
  nextSteps
233
242
  };
234
- summary.briefText = buildWorkspaceBriefText(summary, options.briefLabels || {});
243
+ summary.briefText = buildWorkspaceBriefText(summary, options.briefLabels || {}, {
244
+ userLabel: options.userLabel,
245
+ assistantLabel: options.assistantLabel,
246
+ commandsLabel: options.commandsLabel,
247
+ artifactsLabel: options.artifactsLabel,
248
+ risksLabel: options.risksLabel
249
+ });
235
250
  return summary;
236
251
  }
237
252
 
@@ -474,7 +489,12 @@ export function createSessionComputed() {
474
489
  files: t ? t('sessions.workspace.files') : 'Files',
475
490
  links: t ? t('sessions.workspace.links') : 'Links',
476
491
  risks: t ? t('sessions.workspace.risks') : 'Risks / blockers',
477
- nextSteps: t ? t('sessions.workspace.nextSteps') : 'Next steps'
492
+ nextSteps: t ? t('sessions.workspace.nextSteps') : 'Next steps',
493
+ noneSignals: t ? t('sessions.workspace.empty') : 'No strong signals yet',
494
+ noneCommands: t ? t('sessions.workspace.noneCommands') : 'No command signals',
495
+ noneFiles: t ? t('sessions.workspace.noneFiles') : 'No file signals',
496
+ noneLinks: t ? t('sessions.workspace.noneLinks') : 'No link signals',
497
+ noneRisks: t ? t('sessions.workspace.noneRisks') : 'No risks or todos detected'
478
498
  }
479
499
  });
480
500
  },
@@ -13,6 +13,7 @@ import {
13
13
  } from './app.constants.mjs';
14
14
  import { createAgentsMethods } from './app.methods.agents.mjs';
15
15
  import { createSystemPromptMethods } from './app.methods.system-prompt.mjs';
16
+ import { createPromptsEditorMethods } from './app.methods.prompts-editor.mjs';
16
17
  import { createClaudeConfigMethods } from './app.methods.claude-config.mjs';
17
18
  import { createCodexConfigMethods } from './app.methods.codex-config.mjs';
18
19
  import { createInstallMethods } from './app.methods.install.mjs';
@@ -51,6 +52,7 @@ import {
51
52
  export function createAppMethods() {
52
53
  return {
53
54
  ...createI18nMethods(),
55
+ ...createPromptsEditorMethods(),
54
56
  ...createWebhookMethods(),
55
57
  ...createStartupClaudeMethods({
56
58
  api,
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Prompts-panel Markdown editor methods: overlay highlight sync,
3
+ * mobile view switching, and the live split preview (marked + DOMPurify,
4
+ * debounced). One implementation is shared by the agentsContent editor
5
+ * and the sysPromptContent editor.
6
+ *
7
+ * State contract (declared in app.js data()):
8
+ * promptsPreviewEnabled, promptsPreviewCollapsed, promptsMobileView,
9
+ * promptsPreviewLibsMissing, agentsHighlightHtml, sysPromptHighlightHtml,
10
+ * agentsPreviewHtml, sysPromptPreviewHtml
11
+ * Template contract:
12
+ * refs promptsAgentsTextarea / promptsSysTextarea on the two textareas,
13
+ * refs promptsAgentsHighlight / promptsSysHighlight on the backdrop <pre>.
14
+ */
15
+
16
+ import {
17
+ buildMarkdownPreviewHtml,
18
+ highlightMarkdownText
19
+ } from '../logic.markdown-editor.mjs';
20
+
21
+ const PROMPTS_HIGHLIGHT_DEBOUNCE_MS = 150;
22
+ const PROMPTS_PREVIEW_DEBOUNCE_MS = 300;
23
+
24
+ const PROMPTS_EDITOR_META = {
25
+ agents: {
26
+ contentField: 'agentsContent',
27
+ highlightField: 'agentsHighlightHtml',
28
+ previewField: 'agentsPreviewHtml',
29
+ textareaRef: 'promptsAgentsTextarea',
30
+ highlightRef: 'promptsAgentsHighlight'
31
+ },
32
+ sys: {
33
+ contentField: 'sysPromptContent',
34
+ highlightField: 'sysPromptHighlightHtml',
35
+ previewField: 'sysPromptPreviewHtml',
36
+ textareaRef: 'promptsSysTextarea',
37
+ highlightRef: 'promptsSysHighlight'
38
+ }
39
+ };
40
+
41
+ function resolvePromptsHljs() {
42
+ if (typeof window !== 'undefined' && window.hljs) {
43
+ return window.hljs;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function resolvePromptsPreviewLibs() {
49
+ if (typeof window === 'undefined') {
50
+ return { marked: null, purify: null };
51
+ }
52
+ return { marked: window.marked || null, purify: window.DOMPurify || null };
53
+ }
54
+
55
+ export function createPromptsEditorMethods() {
56
+ return {
57
+ promptsEditorMeta(key) {
58
+ return PROMPTS_EDITOR_META[key] || null;
59
+ },
60
+
61
+
62
+ refreshPromptsHighlight(key) {
63
+ const meta = this.promptsEditorMeta(key);
64
+ if (!meta) {
65
+ return;
66
+ }
67
+ this[meta.highlightField] = highlightMarkdownText(this[meta.contentField], resolvePromptsHljs());
68
+ this.syncPromptsOverlayScroll(key);
69
+ },
70
+
71
+ schedulePromptsHighlight(key) {
72
+ const meta = this.promptsEditorMeta(key);
73
+ if (!meta) {
74
+ return;
75
+ }
76
+ if (!this._promptsHighlightTimers) {
77
+ this._promptsHighlightTimers = {};
78
+ }
79
+ if (this._promptsHighlightTimers[key]) {
80
+ clearTimeout(this._promptsHighlightTimers[key]);
81
+ }
82
+ this._promptsHighlightTimers[key] = setTimeout(() => {
83
+ this._promptsHighlightTimers[key] = null;
84
+ this.refreshPromptsHighlight(key);
85
+ }, PROMPTS_HIGHLIGHT_DEBOUNCE_MS);
86
+ },
87
+
88
+ syncPromptsOverlayScroll(key) {
89
+ const meta = this.promptsEditorMeta(key);
90
+ if (!meta || typeof this.$refs !== 'object' || !this.$refs) {
91
+ return;
92
+ }
93
+ const textarea = this.$refs[meta.textareaRef];
94
+ const backdrop = this.$refs[meta.highlightRef];
95
+ if (!textarea || !backdrop) {
96
+ return;
97
+ }
98
+ backdrop.scrollTop = textarea.scrollTop;
99
+ backdrop.scrollLeft = textarea.scrollLeft;
100
+ },
101
+
102
+ refreshPromptsPreview(key) {
103
+ const meta = this.promptsEditorMeta(key);
104
+ if (!meta) {
105
+ return;
106
+ }
107
+ if (!this.promptsPreviewEnabled) {
108
+ return;
109
+ }
110
+ const libs = resolvePromptsPreviewLibs();
111
+ const state = buildMarkdownPreviewHtml(this[meta.contentField], libs.marked, libs.purify);
112
+ if (state.unavailable) {
113
+ this.promptsPreviewLibsMissing = true;
114
+ this[meta.previewField] = '';
115
+ return;
116
+ }
117
+ this.promptsPreviewLibsMissing = false;
118
+ this[meta.previewField] = state.html;
119
+ },
120
+
121
+ schedulePromptsPreview(key) {
122
+ const meta = this.promptsEditorMeta(key);
123
+ if (!meta) {
124
+ return;
125
+ }
126
+ if (!this._promptsPreviewTimers) {
127
+ this._promptsPreviewTimers = {};
128
+ }
129
+ if (this._promptsPreviewTimers[key]) {
130
+ clearTimeout(this._promptsPreviewTimers[key]);
131
+ }
132
+ this._promptsPreviewTimers[key] = setTimeout(() => {
133
+ this._promptsPreviewTimers[key] = null;
134
+ this.refreshPromptsPreview(key);
135
+ }, PROMPTS_PREVIEW_DEBOUNCE_MS);
136
+ },
137
+
138
+ schedulePromptsEditorRefresh(key) {
139
+ this.schedulePromptsHighlight(key);
140
+ this.schedulePromptsPreview(key);
141
+ },
142
+
143
+ setPromptsMobileView(view) {
144
+ if (view === 'edit' || view === 'preview') {
145
+ this.promptsMobileView = view;
146
+ }
147
+ },
148
+
149
+ togglePromptsPreviewCollapsed() {
150
+ this.promptsPreviewCollapsed = !this.promptsPreviewCollapsed;
151
+ }
152
+ };
153
+ }
@@ -174,28 +174,61 @@ export function createSessionActionMethods(options = {}) {
174
174
 
175
175
  async copySessionWorkspaceBrief() {
176
176
  const summary = this.activeSessionWorkspaceSummary;
177
- const text = summary && typeof summary.briefText === 'string'
177
+ const brief = summary && typeof summary.briefText === 'string'
178
178
  ? summary.briefText.trim()
179
179
  : '';
180
- if (!text) {
180
+ if (!brief) {
181
181
  this.showMessage(this.t('sessions.workspace.copy.empty'), 'error');
182
182
  return;
183
183
  }
184
+ const session = this.activeSession;
185
+ let exported;
186
+ let exportResult;
187
+ try {
188
+ const res = await api('export-session', {
189
+ source: session.source,
190
+ sessionId: session.sessionId,
191
+ filePath: session.filePath
192
+ });
193
+ if (!res || typeof res !== 'object' || (typeof res.error === 'string' && res.error.trim())) {
194
+ this.showMessage((res && res.error) || this.t('toast.copy.fail'), 'error');
195
+ return;
196
+ }
197
+ exported = typeof res.content === 'string' ? res.content.trim() : '';
198
+ if (!exported) {
199
+ this.showMessage(this.t('toast.copy.fail'), 'error');
200
+ return;
201
+ }
202
+ exportResult = res;
203
+ } catch (e) {
204
+ this.showMessage(this.t('toast.copy.fail'), 'error');
205
+ return;
206
+ }
207
+ const text = `${brief}\n\n---\n\n${exported}`;
184
208
  const ok = this.fallbackCopyText(text);
185
209
  if (ok) {
186
- this.showMessage(this.t('sessions.workspace.copy.success'), 'success');
210
+ this.notifyBriefCopied(exportResult);
187
211
  return;
188
212
  }
189
213
  try {
190
214
  if (navigator.clipboard && window.isSecureContext) {
191
215
  await navigator.clipboard.writeText(text);
192
- this.showMessage(this.t('sessions.workspace.copy.success'), 'success');
216
+ this.notifyBriefCopied(exportResult);
193
217
  return;
194
218
  }
195
219
  } catch (_) {}
196
220
  this.showMessage(this.t('toast.copy.fail'), 'error');
197
221
  },
198
222
 
223
+ notifyBriefCopied(exportResult) {
224
+ if (exportResult && exportResult.truncated) {
225
+ const maxLabel = exportResult.maxMessages === 'all' ? 'all' : exportResult.maxMessages;
226
+ this.showMessage(`已复制工作简报(会话全文已截断:最多 ${maxLabel} 条消息)`, 'info');
227
+ return;
228
+ }
229
+ this.showMessage(this.t('sessions.workspace.copy.success'), 'success');
230
+ },
231
+
199
232
  getSessionExportKey(session) {
200
233
  return `${session.source || 'unknown'}:${session.sessionId || ''}:${session.filePath || ''}`;
201
234
  },
@@ -233,6 +233,14 @@ const en = Object.freeze({
233
233
  'prompts.subTab.project': 'CLAUDE.md',
234
234
  'prompts.subTab.system': 'System Prompt (Pi)',
235
235
  'prompts.subTab.presets': 'Preset pool',
236
+ 'prompts.editor.toolbar': 'Markdown toolbar',
237
+ 'prompts.editor.collapsePreview': 'Collapse preview',
238
+ 'prompts.editor.expandPreview': 'Expand preview',
239
+ 'prompts.editor.previewToggle': 'Live preview',
240
+ 'prompts.editor.edit': 'Edit',
241
+ 'prompts.editor.preview': 'Preview',
242
+ 'prompts.editor.previewUnavailable': 'Preview components missing (marked / DOMPurify), please refresh the page',
243
+ 'prompts.editor.previewEmpty': 'Nothing to preview yet',
236
244
  'prompts.presets.title': 'Presets',
237
245
  'prompts.presets.hint': 'Manage presets.',
238
246
  'prompts.presets.namePlaceholder': 'Preset name',
@@ -234,6 +234,14 @@ const ja = Object.freeze({
234
234
  'prompts.subTab.project': 'CLAUDE.md',
235
235
  'prompts.subTab.system': 'System Prompt (Pi)',
236
236
  'prompts.subTab.presets': 'プリセット',
237
+ 'prompts.editor.toolbar': 'Markdown ツールバー',
238
+ 'prompts.editor.collapsePreview': 'プレビューを折りたたむ',
239
+ 'prompts.editor.expandPreview': 'プレビューを展開',
240
+ 'prompts.editor.previewToggle': 'ライブプレビュー',
241
+ 'prompts.editor.edit': '編集',
242
+ 'prompts.editor.preview': 'プレビュー',
243
+ 'prompts.editor.previewUnavailable': 'プレビュー用コンポーネント(marked / DOMPurify)が読み込まれていません。ページを再読み込みしてください',
244
+ 'prompts.editor.previewEmpty': 'プレビューする内容がありません',
237
245
  'prompts.presets.title': 'プリセット',
238
246
  'prompts.presets.hint': 'プリセットを管理します。',
239
247
  'prompts.presets.namePlaceholder': 'プリセット名',
@@ -251,6 +251,14 @@ const vi = Object.freeze({
251
251
  'prompts.subTab.project': 'CLAUDE.md',
252
252
  'prompts.subTab.system': 'System Prompt (Pi)',
253
253
  'prompts.subTab.presets': 'Kho preset',
254
+ 'prompts.editor.toolbar': 'Thanh công cụ Markdown',
255
+ 'prompts.editor.collapsePreview': 'Thu gọn bản xem trước',
256
+ 'prompts.editor.expandPreview': 'Mở rộng bản xem trước',
257
+ 'prompts.editor.previewToggle': 'Xem trước trực tiếp',
258
+ 'prompts.editor.edit': 'Chỉnh sửa',
259
+ 'prompts.editor.preview': 'Xem trước',
260
+ 'prompts.editor.previewUnavailable': 'Thư viện xem trước (marked / DOMPurify) chưa được tải, hãy tải lại trang',
261
+ 'prompts.editor.previewEmpty': 'Chưa có nội dung để xem trước',
254
262
  'prompts.presets.title': 'Preset',
255
263
  'prompts.presets.hint': 'Quản lý preset.',
256
264
  'prompts.presets.namePlaceholder': 'Tên preset',
@@ -233,6 +233,14 @@ const zhTw = Object.freeze({
233
233
  'prompts.subTab.project': 'CLAUDE.md',
234
234
  'prompts.subTab.system': 'System Prompt (Pi)',
235
235
  'prompts.subTab.presets': '預設池',
236
+ 'prompts.editor.toolbar': 'Markdown 工具列',
237
+ 'prompts.editor.collapsePreview': '收起預覽',
238
+ 'prompts.editor.expandPreview': '展開預覽',
239
+ 'prompts.editor.previewToggle': '即時預覽',
240
+ 'prompts.editor.edit': '編輯',
241
+ 'prompts.editor.preview': '預覽',
242
+ 'prompts.editor.previewUnavailable': '預覽元件未載入(marked / DOMPurify 缺失),請重新整理頁面',
243
+ 'prompts.editor.previewEmpty': '尚無內容可預覽',
236
244
  'prompts.presets.title': '預設池',
237
245
  'prompts.presets.hint': '管理預設。',
238
246
  'prompts.presets.namePlaceholder': '預設名稱',
@@ -233,6 +233,14 @@ const zh = Object.freeze({
233
233
  'prompts.subTab.project': 'CLAUDE.md',
234
234
  'prompts.subTab.system': 'System Prompt (Pi)',
235
235
  'prompts.subTab.presets': '预设池',
236
+ 'prompts.editor.toolbar': 'Markdown 工具栏',
237
+ 'prompts.editor.collapsePreview': '收起预览',
238
+ 'prompts.editor.expandPreview': '展开预览',
239
+ 'prompts.editor.previewToggle': '实时预览',
240
+ 'prompts.editor.edit': '编辑',
241
+ 'prompts.editor.preview': '预览',
242
+ 'prompts.editor.previewUnavailable': '预览组件未加载(marked / DOMPurify 缺失),请刷新页面',
243
+ 'prompts.editor.previewEmpty': '暂无内容可预览',
236
244
  'prompts.presets.title': '预设池',
237
245
  'prompts.presets.hint': '管理预设。',
238
246
  'prompts.presets.namePlaceholder': '预设名称',
@@ -201,17 +201,40 @@
201
201
  </div>
202
202
  </div>
203
203
  </div>
204
- <div :class="['editor-frame', { 'editor-frame--loading': agentsLoading }]">
204
+ <div class="prompts-md-toolbar" role="toolbar" :aria-label="t('prompts.editor.toolbar')">
205
+ <div class="prompts-md-toolbar-group prompts-md-toolbar-group--view">
206
+ <div v-if="promptsPreviewEnabled" class="prompts-mobile-view-switch" role="tablist" :aria-label="t('prompts.editor.previewToggle')">
207
+ <button type="button" role="tab" :aria-selected="promptsMobileView === 'edit'" :class="['prompts-mobile-view-btn', { active: promptsMobileView === 'edit' }]" @click="setPromptsMobileView('edit')">{{ t('prompts.editor.edit') }}</button>
208
+ <button type="button" role="tab" :aria-selected="promptsMobileView === 'preview'" :class="['prompts-mobile-view-btn', { active: promptsMobileView === 'preview' }]" @click="setPromptsMobileView('preview')">{{ t('prompts.editor.preview') }}</button>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ <div :class="['editor-frame prompts-editor-frame', { 'editor-frame--loading': agentsLoading, 'prompts-editor-frame--split': promptsPreviewEnabled, 'prompts-editor-frame--preview-collapsed': promptsPreviewCollapsed, 'prompts-editor-frame--mobile-preview': promptsPreviewEnabled && promptsMobileView === 'preview' }]">
213
+ <button v-if="promptsPreviewEnabled" type="button" class="prompts-split-collapse-btn" :class="{ 'prompts-split-collapse-btn--collapsed': promptsPreviewCollapsed }" :title="promptsPreviewCollapsed ? t('prompts.editor.expandPreview') : t('prompts.editor.collapsePreview')" :aria-label="promptsPreviewCollapsed ? t('prompts.editor.expandPreview') : t('prompts.editor.collapsePreview')" @click="togglePromptsPreviewCollapsed()">
214
+ <svg class="prompts-split-collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6"/></svg>
215
+ </button>
205
216
  <div v-if="agentsLoading" class="editor-skeleton">
206
217
  <div class="skeleton-line" v-for="i in 6" :key="i"></div>
207
218
  </div>
208
- <textarea
209
- v-model="agentsContent"
210
- class="form-input template-editor"
211
- spellcheck="false"
212
- :readonly="agentsLoading || agentsSaving || agentsDiffVisible"
213
- @input="onAgentsContentInput"
214
- :placeholder="t(promptsSubTab === 'claude-project' ? 'modal.agents.placeholder.claudeProject' : 'modal.agents.placeholder')"></textarea>
219
+ <div class="prompts-editor-columns">
220
+ <div class="prompts-edit-pane">
221
+ <pre ref="promptsAgentsHighlight" class="prompts-highlight-backdrop" aria-hidden="true" v-html="agentsHighlightHtml"></pre>
222
+ <textarea
223
+ ref="promptsAgentsTextarea"
224
+ v-model="agentsContent"
225
+ class="form-input template-editor"
226
+ spellcheck="false"
227
+ :readonly="agentsLoading || agentsSaving || agentsDiffVisible"
228
+ @input="onAgentsContentInput"
229
+ @scroll="syncPromptsOverlayScroll('agents')"
230
+ :placeholder="t(promptsSubTab === 'claude-project' ? 'modal.agents.placeholder.claudeProject' : 'modal.agents.placeholder')"></textarea>
231
+ </div>
232
+ <div v-if="promptsPreviewEnabled" class="prompts-preview-pane">
233
+ <div v-if="promptsPreviewLibsMissing" class="state-message error">{{ t('prompts.editor.previewUnavailable') }}</div>
234
+ <div v-else-if="!agentsPreviewHtml" class="state-message">{{ t('prompts.editor.previewEmpty') }}</div>
235
+ <div v-else class="prompts-preview-body" v-html="agentsPreviewHtml"></div>
236
+ </div>
237
+ </div>
215
238
  </div>
216
239
  <div v-if="promptsContextHint" :class="['prompts-context-hint', { 'prompts-context-hint--warn': promptsContextHint.warn }]">
217
240
  {{ promptsContextHint.text }}
@@ -320,16 +343,39 @@
320
343
  </div>
321
344
  </div>
322
345
  </div>
323
- <div :class="['editor-frame', { 'editor-frame--loading': sysPromptLoading }]">
346
+ <div class="prompts-md-toolbar" role="toolbar" :aria-label="t('prompts.editor.toolbar')">
347
+ <div class="prompts-md-toolbar-group prompts-md-toolbar-group--view">
348
+ <div v-if="promptsPreviewEnabled" class="prompts-mobile-view-switch" role="tablist" :aria-label="t('prompts.editor.previewToggle')">
349
+ <button type="button" role="tab" :aria-selected="promptsMobileView === 'edit'" :class="['prompts-mobile-view-btn', { active: promptsMobileView === 'edit' }]" @click="setPromptsMobileView('edit')">{{ t('prompts.editor.edit') }}</button>
350
+ <button type="button" role="tab" :aria-selected="promptsMobileView === 'preview'" :class="['prompts-mobile-view-btn', { active: promptsMobileView === 'preview' }]" @click="setPromptsMobileView('preview')">{{ t('prompts.editor.preview') }}</button>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ <div :class="['editor-frame prompts-editor-frame', { 'editor-frame--loading': sysPromptLoading, 'prompts-editor-frame--split': promptsPreviewEnabled, 'prompts-editor-frame--preview-collapsed': promptsPreviewCollapsed, 'prompts-editor-frame--mobile-preview': promptsPreviewEnabled && promptsMobileView === 'preview' }]">
355
+ <button v-if="promptsPreviewEnabled" type="button" class="prompts-split-collapse-btn" :class="{ 'prompts-split-collapse-btn--collapsed': promptsPreviewCollapsed }" :title="promptsPreviewCollapsed ? t('prompts.editor.expandPreview') : t('prompts.editor.collapsePreview')" :aria-label="promptsPreviewCollapsed ? t('prompts.editor.expandPreview') : t('prompts.editor.collapsePreview')" @click="togglePromptsPreviewCollapsed()">
356
+ <svg class="prompts-split-collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6"/></svg>
357
+ </button>
324
358
  <div v-if="sysPromptLoading" class="editor-skeleton">
325
359
  <div class="skeleton-line" v-for="i in 6" :key="i"></div>
326
360
  </div>
327
- <textarea
328
- v-model="sysPromptContent"
329
- class="form-input template-editor"
330
- spellcheck="false"
331
- :readonly="sysPromptLoading || sysPromptSaving || sysPromptDiffVisible"
332
- :placeholder="t('sysPrompt.placeholder')"></textarea>
361
+ <div class="prompts-editor-columns">
362
+ <div class="prompts-edit-pane">
363
+ <pre ref="promptsSysHighlight" class="prompts-highlight-backdrop" aria-hidden="true" v-html="sysPromptHighlightHtml"></pre>
364
+ <textarea
365
+ ref="promptsSysTextarea"
366
+ v-model="sysPromptContent"
367
+ class="form-input template-editor"
368
+ spellcheck="false"
369
+ :readonly="sysPromptLoading || sysPromptSaving || sysPromptDiffVisible"
370
+ @scroll="syncPromptsOverlayScroll('sys')"
371
+ :placeholder="t('sysPrompt.placeholder')"></textarea>
372
+ </div>
373
+ <div v-if="promptsPreviewEnabled" class="prompts-preview-pane">
374
+ <div v-if="promptsPreviewLibsMissing" class="state-message error">{{ t('prompts.editor.previewUnavailable') }}</div>
375
+ <div v-else-if="!sysPromptPreviewHtml" class="state-message">{{ t('prompts.editor.previewEmpty') }}</div>
376
+ <div v-else class="prompts-preview-body" v-html="sysPromptPreviewHtml"></div>
377
+ </div>
378
+ </div>
333
379
  </div>
334
380
  <div v-if="sysPromptContextHint()" :class="['prompts-context-hint', { 'prompts-context-hint--warn': sysPromptContextHint().warn }]">
335
381
  {{ sysPromptContextHint().text }}