@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3

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.
Files changed (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -1,4 +1,7 @@
1
- import { interactionPanelScript } from './ui-interactions.mjs';
1
+ import { safeMarkdownScript, installSafeMarkdown } from './ui-safe-markdown.mjs';
2
+ import { interactionPanelScript, installInteractionPanel } from './ui-interactions.mjs';
3
+ import { codexAccountPanelHtml, codexAccountPanelCss, codexAccountPanelScript, installCodexAccountPanel } from './ui-codex-account.mjs';
4
+ import { sessionToolsCss, sessionToolsHtml, sessionToolsScript, metricsHtml, traceFilterHtml, installSessionTools } from './ui-session-tools.mjs';
2
5
 
3
6
  export function codexPanelHtml() {
4
7
  return `<style>
@@ -63,7 +66,11 @@ export function codexPanelHtml() {
63
66
  .hcc-interaction-form label { display:grid; gap:5px; overflow-wrap:anywhere; }
64
67
  .hcc-interaction-form label:has(input[type=checkbox]) { display:flex; align-items:flex-start; }
65
68
  .hcc-interaction-form input:not([type=checkbox]), .hcc-interaction-form select { width:100%; min-height:36px; min-width:0; background:var(--input-bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:6px; }
66
- .codex-approval-actions { display:flex; gap:8px; margin-top:12px; }
69
+ .hcc-mcp-field { min-width:0; border:1px solid var(--border); border-radius:6px; padding:10px; }
70
+ .hcc-mcp-field legend,.hcc-mcp-field p { overflow-wrap:anywhere; }
71
+ .hcc-mcp-field small { color:var(--muted); }
72
+ .hcc-mcp-field .sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
73
+ .codex-approval-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
67
74
  .codex-jump { flex:none; text-align:center; padding:6px; border-top:1px solid var(--border); }
68
75
  .codex-composer { flex:none; padding:12px 16px; border-top:1px solid var(--border); background:var(--panel); max-height:40%; overflow:auto; }
69
76
  .codex-composer-inner { max-width:900px; margin:auto; }
@@ -80,12 +87,19 @@ export function codexPanelHtml() {
80
87
  .codex-segment-nav button { min-height:44px; flex:1; }
81
88
  .codex-approval dl { grid-template-columns:1fr; gap:3px; } .codex-approval dd { margin-bottom:6px; }
82
89
  }
90
+ ${codexAccountPanelCss()}
91
+ ${sessionToolsCss}
92
+ .codex-event-source { width:min(780px,calc(100vw - 24px)); max-height:80dvh; overflow:auto; padding:16px; background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:8px; }
93
+ .codex-event-source textarea { width:100%; min-height:240px; max-height:50dvh; resize:vertical; font-family:var(--mono); color:var(--text); background:var(--input-bg); }
94
+ .codex-event-source .row { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
83
95
  </style>
84
96
  <section id="codexPanel" hidden aria-label="Codex">
85
97
  <header class="codex-header"><div id="codexStatus" role="status"></div><button id="codexApprovalJump" type="button" hidden>Approval required</button></header>
86
98
  <nav id="codexHistory" class="codex-history" hidden aria-label="Conversation history"><button id="codexEarlier" type="button" aria-controls="codexTimeline">Earlier</button><span id="codexHistoryRange" role="status"></span><button id="codexLater" type="button" aria-controls="codexTimeline">Newer</button></nav>
87
99
  <div id="codexScroll" class="codex-scroll" tabindex="0" aria-label="Conversation">
88
100
  <div id="codexApprovals" aria-live="polite"></div>
101
+ ${codexAccountPanelHtml('codex')}
102
+ ${traceFilterHtml('codex')}${metricsHtml('codex')}
89
103
  <div id="codexTimeline"></div>
90
104
  <details id="codexReview" class="codex-review" hidden data-details-key="review"><summary id="codexReviewLabel">Latest turn changes and plan</summary><div id="codexPlan"></div><div id="codexDiff"></div></details>
91
105
  </div>
@@ -93,30 +107,44 @@ export function codexPanelHtml() {
93
107
  <footer class="codex-composer"><div class="codex-composer-inner">
94
108
  <label for="codexDraft" id="codexDraftLabel">Message saved in this browser</label>
95
109
  <textarea id="codexDraft" rows="3"></textarea>
110
+ ${sessionToolsHtml('codex')}
96
111
  <div class="codex-composer-actions">
97
112
  <button id="codexSend" class="primary" type="button">Send</button><button id="codexInterrupt" type="button">Interrupt turn</button>
98
113
  <button id="codexRecover" type="button">Read current state</button><button id="codexClear" type="button">Clear draft</button>
99
114
  </div><p id="codexSubmission" role="status"></p>
100
115
  </div></footer>
116
+ <dialog id="codexEventDialog" class="codex-event-source" aria-labelledby="codexEventTitle"><h3 id="codexEventTitle">Reported event</h3><p id="codexEventBoundary"></p><textarea id="codexEventSource" readonly aria-label="Reported event JSON"></textarea><div class="row"><button id="codexEventCopy" type="button">Copy full event</button><button id="codexEventClose" type="button">Back to result</button></div></dialog>
101
117
  </section>`;
102
118
  }
103
119
 
104
120
  export function codexPanelScript() {
105
- return interactionPanelScript() + String.raw`(() => {
121
+ return sessionToolsScript() + safeMarkdownScript() + codexAccountPanelScript() + interactionPanelScript() + "(" + installCodexPanel.toString() + ")(false);";
122
+ }
123
+
124
+ export function installCodexPanel(installDependencies = true) {
125
+ if (installDependencies) { installSessionTools(); installSafeMarkdown(); installCodexAccountPanel(); installInteractionPanel(); }
106
126
  const panel = document.getElementById('codexPanel');
107
127
  const draft = document.getElementById('codexDraft');
108
128
  const scroller = document.getElementById('codexScroll');
109
129
  const views = new Map();
110
130
  const approving = new Set();
131
+ const accountPending = new Set(), accountReads = new Set();
111
132
  const PAGE_SIZE = 80;
112
133
  const SEGMENT_CHARS = 12000, SEGMENT_LINES = 200;
113
134
  let copies = [], timelineHtml = '', timelineSignature = '', timelineCopies = [], entries = [];
114
135
  let blocks = [], timelineBlocks = [], blockScope = '', blockOrder = 0;
115
136
  let state = null, key = '', pending = null, submitting = false;
116
137
  let stateRevision = 0, subjectRevision = 0, readSequence = 0;
138
+ let inspectedEvent = '';
117
139
  const text = (en, zh) => window.hccUi.language === 'zh' ? zh : en;
118
140
  const bridge = () => window.hccHandoff;
119
141
  const escape = (value) => bridge().esc(String(value ?? ''));
142
+ const contextTools = window.hccCreateSessionTools({ document, window, prefix: 'codex', bridge, draft, save: () => { save(); sync(); }, state: () => state, text, escape });
143
+ const traceFilter = window.hccCreateTraceFilter({ document, prefix: 'codex', text, escape, changed: () => {
144
+ if (!state) return;
145
+ const reading = view(); reading.follow = false; reading.startKey = ''; reading.top = 0; timelineSignature = '';
146
+ render(state, true);
147
+ } });
120
148
  function draftKey() { return 'hcc.codexDraft:' + bridge().projectRoot + ':' + bridge().active + (bridge().draftScope ? ':' + bridge().draftScope : ''); }
121
149
  function subject() { const b = bridge(); return { root: b.projectRoot, id: b.active, key: draftKey(), token: b.actionToken, epoch: b.epoch }; }
122
150
  function matches(target) { return target.root === bridge().projectRoot && target.id === bridge().active; }
@@ -207,74 +235,7 @@ export function codexPanelScript() {
207
235
  + '</span><button type="button" data-copy="' + index + '">' + escape(ranges.length > 1 ? text('Copy full text', '复制全文') : text('Copy', '复制'))
208
236
  + '</button></div>' + body + '</div>';
209
237
  }
210
- function inline(value, allowBold = true) {
211
- const tick = String.fromCharCode(96), source = String(value), fragments = [];
212
- let index = 0, plain = '';
213
- const flush = () => { if (plain) fragments.push(escape(plain)); plain = ''; };
214
- const codeEnd = start => {
215
- const end = source.indexOf(tick, start + 1), newline = source.indexOf('\n', start + 1);
216
- return end > start + 1 && (newline === -1 || end < newline) ? end : -1;
217
- };
218
- while (index < source.length) {
219
- if (source[index] === tick && codeEnd(index) !== -1) {
220
- const end = codeEnd(index);
221
- flush(); fragments.push('<code>' + escape(source.slice(index + 1, end)) + '</code>'); index = end + 1;
222
- continue;
223
- }
224
- const marker = source.slice(index, index + 2);
225
- if (allowBold && (marker === '**' || marker === '__')) {
226
- let end = index + 2;
227
- while (end < source.length && source[end] !== '\n') {
228
- // Code spans are opaque: bold markers inside them are literal text.
229
- if (source[end] === tick && codeEnd(end) !== -1) { end = codeEnd(end) + 1; continue; }
230
- if (source.startsWith(marker, end)) break;
231
- end += 1;
232
- }
233
- if (end > index + 2 && source.startsWith(marker, end)) {
234
- flush(); fragments.push('<strong>' + inline(source.slice(index + 2, end), false) + '</strong>'); index = end + 2;
235
- continue;
236
- }
237
- }
238
- plain += source[index]; index += 1;
239
- }
240
- flush(); return fragments.join('');
241
- }
242
- function markdown(value) {
243
- const lines = String(value).split('\n'), parts = [];
244
- let paragraph = [], list = [], listType = '', listStart = '', quote = [];
245
- const flushParagraph = () => { if (paragraph.length) parts.push('<p class="codex-prose">' + inline(paragraph.join('\n')) + '</p>'); paragraph = []; };
246
- const flushList = () => {
247
- if (list.length) parts.push('<' + listType + (listType === 'ol' && listStart !== '1' ? ' start="' + listStart + '"' : '') + '>'
248
- + list.map(line => '<li>' + inline(line) + '</li>').join('') + '</' + listType + '>');
249
- list = []; listType = ''; listStart = '';
250
- };
251
- const flushQuote = () => { if (quote.length) parts.push('<blockquote>' + quote.map(line => inline(line)).join('<br>') + '</blockquote>'); quote = []; };
252
- for (const line of lines) {
253
- const heading = /^(#{1,6})\s+(.+)$/.exec(line);
254
- const unordered = /^\s{0,3}[-+*]\s+(.+)$/.exec(line);
255
- const ordered = /^\s{0,3}(\d{1,9})[.)]\s+(.+)$/.exec(line);
256
- const quoted = /^\s{0,3}>\s?(.*)$/.exec(line);
257
- if (heading) {
258
- flushParagraph(); flushList(); flushQuote();
259
- const tag = 'h' + heading[1].length;
260
- parts.push('<' + tag + '>' + inline(heading[2].replace(/\s+#+\s*$/, '')) + '</' + tag + '>');
261
- } else if (unordered || ordered) {
262
- flushParagraph(); flushQuote();
263
- const nextType = unordered ? 'ul' : 'ol';
264
- if (listType && listType !== nextType) flushList();
265
- if (!listType) { listType = nextType; listStart = ordered ? String(Number(ordered[1])) : ''; }
266
- list.push(unordered ? unordered[1] : ordered[2]);
267
- } else if (quoted) {
268
- flushParagraph(); flushList(); quote.push(quoted[1]);
269
- } else {
270
- flushList(); flushQuote();
271
- if (!line.trim()) flushParagraph();
272
- else paragraph.push(line);
273
- }
274
- }
275
- flushParagraph(); flushList(); flushQuote();
276
- return '<div class="codex-markdown">' + parts.join('') + '</div>';
277
- }
238
+ const markdown = window.hccSafeMarkdown(escape);
278
239
  function prose(value) {
279
240
  const content = String(value ?? '');
280
241
  if (segmentRanges(content).length > 1) return codeBlock(content, text('Long message · Plain-text sections', '长消息 · 纯文本分段'), false, true);
@@ -309,7 +270,7 @@ export function codexPanelScript() {
309
270
  if (type === 'userMessage' || type === 'agentMessage') {
310
271
  return '<article class="codex-card codex-card-' + (type === 'userMessage' ? 'user' : 'agent') + '" data-card-key="' + escape(id)
311
272
  + '"><div class="codex-card-heading"><strong>' + escape(type === 'userMessage' ? text('You', '你') : text('Assistant', '助手'))
312
- + '</strong></div>' + prose(content) + '</article>';
273
+ + '</strong><button type="button" data-codex-event="' + escape(id) + '">' + escape(text('Event details', '事件详情')) + '</button></div>' + prose(content) + '</article>';
313
274
  }
314
275
  const titles = {
315
276
  commandExecution: text('Command', '命令'), fileChange: text('File changes', '文件修改'),
@@ -343,10 +304,24 @@ export function codexPanelScript() {
343
304
  const summary = '<strong>' + escape(titles[type] || type || text('Event', '事件')) + '</strong><span class="codex-tool-preview">'
344
305
  + escape(String(preview).slice(0, 180)) + '</span>' + status;
345
306
  return '<article class="codex-card' + (error ? ' codex-card-error' : '') + '" data-card-key="' + escape(id) + '">'
346
- + details(id, summary, body, Boolean(error) || !['completed', 'declined'].includes(item.status)) + '</article>';
307
+ + details(id, summary, body, Boolean(error) || !['completed', 'declined'].includes(item.status)) + '<button type="button" data-codex-event="' + escape(id) + '">' + escape(text('Event details', '事件详情')) + '</button></article>';
347
308
  }
348
309
  function bindReadActions(container) {
349
310
  const currentKey = key;
311
+ container.querySelectorAll('button[data-codex-event]').forEach(button => button.addEventListener('click', () => {
312
+ if (key !== currentKey) return;
313
+ const entry = entries.find(({turn, item, index}) => turn.id + ':' + (item?.id || index) === button.dataset.codexEvent);
314
+ if (!entry) return;
315
+ inspectedEvent = JSON.stringify({ threadId: activeThread()?.id, turnId: entry.turn.id, item: entry.item }, null, 2);
316
+ let end = Math.min(inspectedEvent.length, 12000);
317
+ if (end < inspectedEvent.length && /[\uD800-\uDBFF]/.test(inspectedEvent[end-1])) end--;
318
+ document.getElementById('codexEventSource').value = inspectedEvent.slice(0,end);
319
+ document.getElementById('codexEventTitle').textContent = text('Reported event', '执行器报告的事件') + ' · ' + entry.turn.id;
320
+ document.getElementById('codexEventBoundary').textContent = end + ' / ' + inspectedEvent.length + text(' characters shown. This is the retained item, not an inferred transcript.', ' 个字符已显示;这是保留的事件内容,不是推断的完整记录。');
321
+ document.getElementById('codexEventCopy').textContent = text('Copy full event', '复制完整事件');
322
+ document.getElementById('codexEventClose').textContent = text('Back to result', '回到结果');
323
+ document.getElementById('codexEventDialog').showModal();
324
+ }));
350
325
  container.querySelectorAll('details[data-details-key]').forEach(node => {
351
326
  node.addEventListener('toggle', () => { if (key === currentKey && node.isConnected !== false) view().details.set(node.dataset.detailsKey, node.open); });
352
327
  });
@@ -415,6 +390,12 @@ export function codexPanelScript() {
415
390
  entries = turns.flatMap((turn, turnIndex) => (turn.items?.length ? turn.items : [null]).map((item, index) => ({
416
391
  turn, turnIndex, item, index, id: JSON.stringify([turn.id, item?.id ?? index])
417
392
  })));
393
+ const total = entries.length;
394
+ entries = entries.filter(({ turn, item }) => window.hccTraceMatches({ turnId: turn.id,
395
+ kind: item?.type === 'fileChange' ? 'files' : ['commandExecution','mcpToolCall','dynamicToolCall','webSearch'].includes(item?.type) ? 'tool' : item?.error ? 'error' : 'message',
396
+ status: item?.status || turn.status, exitCode: item?.exitCode, title: item?.type, text: item ? JSON.stringify(item) : turn.error?.message || '',
397
+ changes: item?.changes || [] }, traceFilter.value()));
398
+ traceFilter.render(turns.map(turn => turn.id), entries.length, total);
418
399
  let start = reading.follow ? Math.max(0, entries.length - PAGE_SIZE)
419
400
  : reading.startKey ? entries.findIndex(entry => entry.id === reading.startKey) : 0;
420
401
  // If retention removed the old anchor, show the oldest surviving history.
@@ -465,14 +446,22 @@ export function codexPanelScript() {
465
446
  subjectRevision += 1;
466
447
  if (key && state) view().top = scroller.scrollTop || 0;
467
448
  key = next; pending = null; draft.value = ''; state = null;
449
+ traceFilter.reset(key);
450
+ traceFilter.render([], 0, 0);
451
+ inspectedEvent = ''; document.getElementById('codexEventSource').value = '';
452
+ document.getElementById('codexEventDialog').close?.();
468
453
  timelineHtml = ''; timelineSignature = ''; timelineCopies = []; copies = []; entries = []; blocks = []; timelineBlocks = [];
469
454
  document.getElementById('codexHistory').hidden = true;
470
455
  for (const id of ['codexStatus', 'codexTimeline', 'codexDiff', 'codexPlan', 'codexApprovals', 'codexSubmission']) document.getElementById(id).textContent = '';
471
456
  document.getElementById('codexReview').open = view().details.get('review') || false;
457
+ document.getElementById('codexAccount').open = view().details.get('account') || false;
472
458
  document.getElementById('codexReview').hidden = true;
473
459
  scroller.scrollTop = view().top;
474
460
  try { const stored = JSON.parse(window.hccUi.safeGet(key) || '{}'); draft.value = stored.text || ''; pending = stored.pending || null; } catch (_) {}
475
461
  }
462
+ window.hccCodexAccount.render('codex', state?.account, { connected: state?.status === 'ready', pending: accountPending.has(key + '/' + state?.executorId) });
463
+ contextTools.render();
464
+ window.hccRenderReportedMetrics({ document, prefix: 'codex', state, text, escape });
476
465
  document.getElementById('codexSend').textContent = turnId() ? text('Add instruction', '追加指令') : text('Send', '发送');
477
466
  document.getElementById('codexDraftLabel').textContent = text('Message saved in this browser', '消息草稿保存在当前浏览器');
478
467
  document.getElementById('codexInterrupt').textContent = text('Interrupt turn', '中断当前轮');
@@ -506,6 +495,12 @@ export function codexPanelScript() {
506
495
  // Local paging and preference redraws reuse state; incoming snapshots do not.
507
496
  if (value !== state) stateRevision += 1;
508
497
  state = value; sync();
498
+ const accountKey = key + '/' + state.executorId;
499
+ if (state.status === 'ready' && state.executorId && !accountReads.has(accountKey)) {
500
+ accountReads.add(accountKey);
501
+ if (accountReads.size > 100) accountReads.delete(accountReads.values().next().value);
502
+ void readAccount();
503
+ }
509
504
  const thread = activeThread();
510
505
  document.getElementById('codexStatus').textContent = [text('Executor', '执行器') + ': ' + statusLabel(state.status),
511
506
  text('Thread', '会话') + ': ' + (thread?.id || ''), turnId() ? text('Turn', '当前轮') + ': ' + turnId() : text('Idle', '空闲'),
@@ -536,12 +531,12 @@ export function codexPanelScript() {
536
531
  container.innerHTML = approvals.map((approval, index) => {
537
532
  blockScope = 'approval:' + executorId + ':' + approval.requestId; blockOrder = 0;
538
533
  const params = approval.params || {}, fields = [
539
- [text('Operation', '操作'), approval.kind === 'userInput' ? text('Answer questions', '回答问题') : approval.kind === 'permissions' ? text('Grant permissions', '授予权限') : approval.method?.includes('fileChange') ? text('Change files', '修改文件') : text('Run command', '执行命令')],
534
+ [text('Operation', '操作'), approval.kind === 'mcp' ? text('MCP request', 'MCP 请求') : approval.kind === 'userInput' ? text('Answer questions', '回答问题') : approval.kind === 'permissions' ? text('Grant permissions', '授予权限') : approval.method?.includes('fileChange') ? text('Change files', '修改文件') : text('Run command', '执行命令')],
540
535
  [text('Command', '命令'), params.command], [text('Directory', '目录'), params.cwd],
541
- [text('Reason', '原因'), params.reason], [text('Path', '路径'), params.path],
536
+ [text('MCP server', 'MCP 服务'), params.serverName], [text('Reason', '原因'), params.reason || params.message], [text('Path', '路径'), params.path],
542
537
  [text('Permission root', '权限目录'), params.grantRoot]
543
538
  ].filter(([, value]) => value != null && value !== '');
544
- return '<article class="codex-card codex-approval"><h3>' + escape(approval.kind === 'userInput' ? text('Your input is required', '等待您的答复') : text('Approval required', '等待人工审批'))
539
+ return '<article class="codex-card codex-approval"><h3>' + escape(window.hccInteractions.requiresInput(approval) ? text('Your input is required', '等待您的答复') : text('Approval required', '等待人工审批'))
545
540
  + '</h3><dl>' + fields.map(([label, value], fieldIndex) => {
546
541
  const fullValue = typeof value === 'string' ? value : JSON.stringify(value);
547
542
  return '<dt>' + escape(label) + '</dt><dd>'
@@ -550,11 +545,12 @@ export function codexPanelScript() {
550
545
  }).join('') + '</dl>'
551
546
  + (approval.truncated ? '<p class="codex-prose">' + escape(text('Parameters are truncated. Read the full operation before deciding.', '参数已截断,请核对完整操作后再决定。')) + '</p>' : '')
552
547
  + details('approval:' + approval.requestId, '<strong>' + escape(text('Raw parameters', '原始参数')) + '</strong>',
553
- codeBlock(JSON.stringify(params, null, 2), 'JSON'), false)
548
+ codeBlock(JSON.stringify(window.hccInteractions.previewParams(approval), null, 2), 'JSON'), false)
554
549
  + '<div class="hcc-interaction-form">' + window.hccInteractions.form(approval, approvalKey(target, executorId, approval.requestId), escape, text) + '</div>'
555
- + '<div class="codex-approval-actions"><button data-approval="' + index + '" data-decision="accept" data-truncated="' + !!approval.truncated + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button" class="primary">'
556
- + escape(approval.kind === 'userInput' ? text('Send answers', '提交答复') : approval.kind === 'permissions' ? text('Grant selected permissions', '授予所选权限') : text('Approve once', '批准本次')) + '</button><button data-approval="' + index + '" data-decision="' + (approval.kind === 'userInput' ? 'cancel' : 'decline') + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button">'
557
- + escape(approval.kind === 'userInput' ? text('Cancel questions', '取消问答') : text('Decline', '拒绝')) + '</button></div></article>';
550
+ + '<div class="codex-approval-actions"><button data-approval="' + index + '" data-decision="accept" data-truncated="' + window.hccInteractions.approvalBlocked(approval) + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button" class="primary">'
551
+ + escape(window.hccInteractions.acceptLabel(approval, text)) + '</button><button data-approval="' + index + '" data-decision="' + (approval.kind === 'userInput' ? 'cancel' : 'decline') + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button">'
552
+ + escape(approval.kind === 'userInput' ? text('Cancel questions', '取消问答') : text('Decline', '拒绝')) + '</button>'
553
+ + (approval.kind === 'mcp' ? '<button data-approval="' + index + '" data-decision="cancel" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button">' + escape(text('Cancel request', '取消请求')) + '</button>' : '') + '</div></article>';
558
554
  }).join('');
559
555
  window.hccInteractions.restore(container);
560
556
  bindReadActions(container);
@@ -566,11 +562,18 @@ export function codexPanelScript() {
566
562
  const requestKey = approvalKey(target, executorId, approval.requestId);
567
563
  approving.add(requestKey); sync();
568
564
  const requestTarget = { ...target, token: bridge().actionToken, epoch: bridge().epoch };
569
- try { await action('approve', { executorId, threadId: approval.threadId,
565
+ let authorization;
566
+ try {
567
+ const payload = window.hccInteractions.payload(approval, ['userInput','permissions','mcp'].includes(approval.kind) ? button.closest('article') : null, button.dataset.decision);
568
+ authorization = window.hccInteractions.prepareUrl(approval, button.dataset.decision, text);
569
+ await action('approve', { executorId, threadId: approval.threadId,
570
570
  turnId: approval.turnId, requestId: approval.requestId,
571
- ...window.hccInteractions.payload(approval, ['userInput','permissions'].includes(approval.kind) ? button.closest('article') : null, button.dataset.decision) }, requestTarget);
572
- window.hccInteractions.forget(requestKey); }
573
- catch (error) { if (matches(target)) message(error.detail || error.message); }
571
+ ...payload }, requestTarget);
572
+ window.hccInteractions.forget(requestKey);
573
+ const opened = authorization?.complete();
574
+ if (opened && matches(target)) message(opened);
575
+ }
576
+ catch (error) { authorization?.cancel(); if (matches(target)) message(error.detail || error.message); }
574
577
  finally { approving.delete(requestKey); if (matches(target)) sync(); }
575
578
  });
576
579
  });
@@ -611,7 +614,25 @@ export function codexPanelScript() {
611
614
  scroller.scrollTop = 0; view().top = 0; view().follow = pinned() && view().end === view().total; updateJump();
612
615
  });
613
616
  document.getElementById('codexReview').addEventListener('toggle', () => { view().details.set('review', document.getElementById('codexReview').open); });
617
+ document.getElementById('codexAccount').addEventListener('toggle', () => { view().details.set('account', document.getElementById('codexAccount').open); });
618
+ async function readAccount() {
619
+ if (!state?.executorId || state.status !== 'ready' || accountPending.has(key + '/' + state.executorId)) return;
620
+ const target = { ...subject(), executorId: state.executorId, revision: subjectRevision }, version = stateRevision;
621
+ const accountScope = target.key + '/' + target.executorId;
622
+ accountPending.add(accountScope); sync();
623
+ try {
624
+ const result = await bridge().api(route(target, 'account') + '&executorId=' + encodeURIComponent(target.executorId));
625
+ if (matches(target) && target.revision === subjectRevision && state?.executorId === result.executorId && version === stateRevision) render({ ...state, account: result.account });
626
+ } catch (error) { if (matches(target) && target.revision === subjectRevision && state?.executorId === target.executorId && version === stateRevision) message(error.detail || error.message); }
627
+ finally { accountPending.delete(accountScope); if (matches(target)) sync(); }
628
+ }
629
+ document.getElementById('codexAccountRead').addEventListener('click', readAccount);
614
630
  draft.addEventListener('input', save);
631
+ document.getElementById('codexEventClose').addEventListener('click', () => document.getElementById('codexEventDialog').close());
632
+ document.getElementById('codexEventCopy').addEventListener('click', async () => {
633
+ try { await navigator.clipboard.writeText(inspectedEvent); document.getElementById('codexEventCopy').textContent = text('Copied', '已复制'); }
634
+ catch { document.getElementById('codexEventCopy').textContent = text('Select text to copy', '请选择文本复制'); }
635
+ });
615
636
  draft.addEventListener('keydown', event => {
616
637
  if (!event.isComposing && event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
617
638
  event.preventDefault();
@@ -663,5 +684,4 @@ export function codexPanelScript() {
663
684
  });
664
685
  window.addEventListener('hcc:preferences', () => { if (state && !panel.hidden) render(state); });
665
686
  window.hccCodex = { render, sync };
666
- })();`;
667
687
  }
@@ -47,10 +47,26 @@ export function commandPaletteHtml() {
47
47
  </div>`;
48
48
  }
49
49
 
50
- export function commandPaletteScript() {
51
- return `(() => {
52
- const filter = ${filterCommands.toString()};
53
- const shortcut = ${shortcutAction.toString()};
50
+ export function commandPaletteScript() { return '(' + installCommandPalette.toString() + ')();'; }
51
+
52
+ export function installCommandPalette() {
53
+ (() => {
54
+ const filter = function filterCommands(commands, query) {
55
+ const words = String(query || '').toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
56
+ return commands.filter(command => {
57
+ const text = [command.id, command.label, command.detail, command.group, command.keywords].join(' ').toLocaleLowerCase();
58
+ return words.every(word => text.includes(word));
59
+ }).slice(0, 80);
60
+ };
61
+ const shortcut = function shortcutAction(event, { modalOpen = false, paletteOpen = false, terminalAvailable = false } = {}) {
62
+ if (event.isComposing || event.repeat || event.altKey || !(event.ctrlKey || event.metaKey) || !event.shiftKey) return null;
63
+ const key = String(event.key || '').toLowerCase();
64
+ if (key === 'p' && (!modalOpen || paletteOpen)) return 'palette';
65
+ if (modalOpen) return null;
66
+ if (key === 'l') return 'sessions';
67
+ if (key === 'f' && terminalAvailable) return 'terminal-find';
68
+ return null;
69
+ };
54
70
  const host = window.hccCommandHost;
55
71
  const dialog = document.getElementById('commandDialog');
56
72
  const query = document.getElementById('commandQuery');
@@ -115,5 +131,5 @@ export function commandPaletteScript() {
115
131
  document.getElementById('commandClose').addEventListener('click', () => host.closeDialog(dialog));
116
132
  window.addEventListener('hcc:preferences', refresh);
117
133
  window.hccCommandPalette = {open, refresh, captures};
118
- })();`;
134
+ })();
119
135
  }
@@ -0,0 +1,220 @@
1
+ export const fileWritesCss = `
2
+ .files-upload { border:1px solid var(--border); border-radius:8px; padding:8px; }
3
+ .files-upload form { display:grid; gap:6px; padding-top:8px; }
4
+ .files-upload input { width:100%; min-width:0; }
5
+ .files-upload input[type=file] { height:auto; font-size:var(--small-font); }
6
+ .files-editor { flex:1; min-height:0; display:flex; flex-direction:column; gap:8px; }
7
+ .files-editor textarea { flex:1; min-height:150px; width:100%; resize:vertical; background:var(--input-bg); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:10px; font:var(--small-font)/1.6 var(--mono); }
8
+ .files-write-status { margin:0; overflow-wrap:anywhere; font-size:var(--small-font); color:var(--muted); }
9
+ .files-server-copy { overflow:auto; max-height:160px; }
10
+ .files-server-copy pre { white-space:pre-wrap; overflow-wrap:anywhere; }
11
+ `;
12
+ export function fileUploadHtml() {
13
+ return `<details class="files-upload" id="filesUploadPanel"><summary data-i18n="files.upload">Upload file</summary><form id="filesUploadForm">
14
+ <input id="filesUploadInput" type="file" data-i18n-aria="files.chooseUpload" aria-label="Choose one file to upload">
15
+ <label><span data-i18n="files.uploadName">Target file name</span><input id="filesUploadName" type="text" autocomplete="off" maxlength="255"></label>
16
+ <p id="filesUploadTarget" class="files-write-status"></p><p class="files-write-status" data-i18n="files.uploadHelp">One file, up to 10 MiB. Existing files will not be replaced.</p>
17
+ <div class="files-toolbar"><button id="filesUploadBtn" type="submit" data-i18n="files.upload">Upload file</button><button id="filesUploadCheck" type="button" data-i18n="files.checkUpload" hidden>Check target file</button></div>
18
+ <p id="filesUploadStatus" class="files-write-status" role="status" aria-live="polite"></p></form></details>`;
19
+ }
20
+ export function fileEditorHtml() {
21
+ return `<div class="files-toolbar"><button id="filesEdit" type="button" data-i18n="files.edit" hidden>Edit text</button></div>
22
+ <div id="filesEditPanel" class="files-editor" hidden><p class="files-write-status" data-i18n="files.draftHelp">Drafts stay in this page when you switch files or close this window. Reloading the page can discard them.</p>
23
+ <textarea id="filesEditor" spellcheck="false" data-i18n-aria="files.editText" aria-label="File text"></textarea>
24
+ <div class="files-toolbar"><button id="filesSave" type="button" data-i18n="files.save">Save file</button><button id="filesCancelEdit" type="button" data-i18n="files.keepDraft">Back to preview</button><button id="filesVerifySave" type="button" data-i18n="files.verifySave" hidden>Read saved file</button><button id="filesDiscardEdit" type="button" data-i18n="files.discardEdit">Discard draft and reload</button></div>
25
+ <p id="filesEditStatus" class="files-write-status" role="status" aria-live="polite"></p><details id="filesServerCopy" class="files-server-copy" hidden><summary data-i18n="files.serverCopy">Current saved content</summary><pre id="filesServerContent"></pre></details>
26
+ </div>`;
27
+ }
28
+ export function editableFile(value) {
29
+ return Boolean(value && value.editable !== false && ['text','markdown','html'].includes(value.kind) && value.encoding === 'utf8' && !value.truncated
30
+ && typeof value.revision === 'string' && value.revision && typeof value.content === 'string' && value.size <= 1024*1024);
31
+ }
32
+
33
+ export function createFileWrites({browser=globalThis,context,updated,uploaded}) {
34
+ const {window,document}=browser, bridge=window.hccHandoff, node=id=>document.getElementById(id), tr=key=>bridge.tr(key);
35
+ const drafts=new Map(), uploads=new Map(); let active=null, upload=null;
36
+ const key=(root,path,identity='')=>JSON.stringify([root,identity,path]);
37
+ const route=(path,root)=>path+(path.includes('?') ? '&' : '?')+'root='+encodeURIComponent(root);
38
+ const writable=()=>bridge.draftScope!=='auxiliary';
39
+ const bound=now=>!now.hidden && now.root===bridge.projectRoot && (now.identity || '')===(bridge.projectIdentity || '');
40
+ const visible=target=>{ const now=context(); return bound(now) && now.root===target.root && now.identity===target.identity && now.path===target.path; };
41
+ const uploadVisible=target=>{ const now=context(); return bound(now) && now.root===target.root && now.identity===target.identity && now.directory===target.directory; };
42
+ const sameVisit=visit=>{const now=context();return bound(now) && now.root===visit.root && now.identity===visit.identity && now.visit===visit.visit && now.path===visit.path;};
43
+ const editorText=value=>value.replace(/\r\n?/g,'\n');
44
+ const utf8=value=>new browser.TextEncoder().encode(value);
45
+ const hash=async bytes=>Array.from(new browser.Uint8Array(await browser.crypto.subtle.digest('SHA-256',bytes)),byte=>byte.toString(16).padStart(2,'0')).join('');
46
+ const uncertain=error=>!error.status || error.status>=500 || ['REQUEST_TIMEOUT','REQUEST_SUPERSEDED','FILE_WRITE_UNCONFIRMED'].includes(error.code);
47
+ function saveUploadRecovery(target) {
48
+ const value=target.uncertain ? {path:target.path,name:target.name,size:target.size,hash:target.hash,uncertain:true} : null;
49
+ window.hccUi?.safeSet('hcc.fileUpload:'+key(target.root,target.directory,target.identity),JSON.stringify(value));
50
+ }
51
+ function uploadFor(root,directory,identity) {
52
+ const storageKey=key(root,directory,identity);
53
+ if (!uploads.has(storageKey)) {
54
+ const target={root,directory,identity,file:null,name:'',status:'',error:'',busy:false,uncertain:false};
55
+ try {
56
+ const saved=JSON.parse(window.hccUi?.safeGet('hcc.fileUpload:'+storageKey)||'null');
57
+ if (saved?.uncertain && typeof saved.path==='string' && typeof saved.hash==='string' && /^[a-f0-9]{64}$/.test(saved.hash) && Number.isSafeInteger(saved.size)) Object.assign(target,saved,{status:'files.uploadUncertain'});
58
+ } catch {}
59
+ uploads.set(storageKey,target);
60
+ }
61
+ return uploads.get(storageKey);
62
+ }
63
+ function editorRender() {
64
+ const now=context(), candidate=now.value, target=active;
65
+ const eligible=writable() && (editableFile(candidate) || Boolean(target));
66
+ node('filesEdit').hidden=!eligible || Boolean(target?.editing);
67
+ node('filesEdit').textContent=tr(target?.content!==target?.base ? 'files.resumeDraft' : 'files.edit');
68
+ const editing=Boolean(target?.editing && visible(target));
69
+ node('filesEditPanel').hidden=!editing; node('filesPreview').hidden=editing;
70
+ if (!editing) return;
71
+ if (node('filesEditor').value!==target.content) node('filesEditor').value=target.content;
72
+ node('filesEditor').disabled=Boolean(target.discarding);
73
+ node('filesSave').disabled=target.busy || target.checking || target.conflict || target.uncertain || target.content===target.base;
74
+ node('filesDiscardEdit').disabled=target.busy || target.checking;
75
+ node('filesVerifySave').hidden=!target.conflict && !target.uncertain;
76
+ node('filesVerifySave').disabled=target.busy || target.checking;
77
+ node('filesEditStatus').textContent=tr(target.status || (target.content===target.base ? 'files.editReady' : 'files.unsaved'))
78
+ +(target.newline==='mixed' ? ' '+tr('files.mixedNewlines') : '')+(target.error ? ' '+target.error : '');
79
+ node('filesServerCopy').hidden=!target.latest;
80
+ node('filesServerContent').textContent=target.latest?.content || '';
81
+ }
82
+ function uploadRender() {
83
+ const now=context();
84
+ node('filesUploadPanel').hidden=!writable();
85
+ if (!writable() || !now.root) return;
86
+ const next=uploadFor(now.root,now.directory,now.identity);
87
+ if (upload!==next) { upload=next; node('filesUploadInput').value=''; }
88
+ node('filesUploadName').value=upload.name;
89
+ const locked=upload.busy || upload.uncertain;
90
+ node('filesUploadInput').disabled=locked; node('filesUploadName').disabled=locked;
91
+ node('filesUploadBtn').disabled=locked || !upload.file || !upload.name || upload.blockedName===upload.name;
92
+ node('filesUploadCheck').hidden=!upload.uncertain; node('filesUploadCheck').disabled=upload.busy;
93
+ const path=upload.path && locked ? upload.path : [now.directory,upload.name].filter(Boolean).join('/');
94
+ node('filesUploadTarget').textContent=path ? tr('files.uploadTarget')+' '+path : '';
95
+ node('filesUploadStatus').textContent=(upload.status ? tr(upload.status) : '')+(upload.error ? ' '+upload.error : '');
96
+ }
97
+ function sync() { editorRender(); uploadRender(); }
98
+ function select() {
99
+ const now=context(); active=drafts.get(key(now.root,now.path,now.identity)) || null;
100
+ if (active && now.value && !active.busy && !active.uncertain && now.value.revision!==active.revision) {
101
+ if (active.content===active.base && editableFile(now.value)) {
102
+ active.content=editorText(now.value.content); active.base=active.content; active.revision=now.value.revision; active.newline=now.value.newline;
103
+ } else { active.conflict=true; active.status='files.editConflict'; }
104
+ }
105
+ sync();
106
+ }
107
+ function suspend() { active=null; node('filesEditPanel').hidden=true; node('filesEdit').hidden=true; node('filesPreview').hidden=false; }
108
+ function begin() {
109
+ const now=context(); if (!writable() || !bound(now)) return;
110
+ if (!active) {
111
+ if (!editableFile(now.value)) return;
112
+ active={root:now.root,identity:now.identity,path:now.path,revision:now.value.revision,base:editorText(now.value.content),content:editorText(now.value.content),newline:now.value.newline,status:'',error:'',editing:true,busy:false,conflict:false,uncertain:false};
113
+ drafts.set(key(now.root,now.path,now.identity),active);
114
+ }
115
+ active.editing=true; editorRender(); node('filesEditor').focus();
116
+ }
117
+ async function save() {
118
+ const target=active, visit={...context()};
119
+ if (!writable() || !target || !visible(target) || target.busy || target.checking || target.conflict || target.uncertain || target.content===target.base) return;
120
+ const submittedEditor=target.content, content=target.newline==='crlf' ? target.content.replace(/\n/g,'\r\n') : target.content;
121
+ if (utf8(content).length>1024*1024) { target.status='files.textTooLarge'; editorRender(); return; }
122
+ target.submitted=content; target.busy=true; target.status='files.saving'; target.error=''; editorRender();
123
+ try {
124
+ const result=await bridge.api(route('/api/files/content',target.root),{method:'PUT',body:JSON.stringify({path:target.path,revision:target.revision,content})});
125
+ if (result.saved!==true || result.path!==target.path || typeof result.revision!=='string') throw new Error(tr('files.saveUncertain'));
126
+ const next={...visit.value,...result,content,bom:content.startsWith('\uFEFF'),newline:content.includes('\r\n') ? 'crlf' : content.includes('\n') ? 'lf' : 'none'};
127
+ target.base=submittedEditor; target.revision=next.revision; target.newline=next.newline; target.status=target.content===submittedEditor ? 'files.saved' : 'files.savedWithDraft'; target.submitted=null;
128
+ if (sameVisit(visit)) updated(next);
129
+ } catch (error) {
130
+ target.error=error.detail || error.message;
131
+ if (error.status===409) { target.conflict=true; target.status='files.editConflict'; }
132
+ else if (uncertain(error)) { target.uncertain=true; target.status='files.saveUncertain'; }
133
+ else { target.status='files.saveFailed'; target.submitted=null; }
134
+ } finally { target.busy=false; if (visible(target)) editorRender(); }
135
+ }
136
+ async function verify() {
137
+ const target=active, visit={...context()};
138
+ if (!writable() || !target || !visible(target) || target.busy || target.checking) return;
139
+ target.checking=true; target.error=''; editorRender();
140
+ try {
141
+ const next=await bridge.api(route('/api/files/preview?path='+encodeURIComponent(target.path),target.root));
142
+ target.latest=next;
143
+ if (editableFile(next) && target.submitted!==null && target.submitted!==undefined && next.content===target.submitted) {
144
+ target.base=editorText(next.content); target.revision=next.revision; target.newline=next.newline; target.uncertain=false; target.conflict=false; target.submitted=null;
145
+ target.status=target.content===target.base ? 'files.savedVerified' : 'files.savedWithDraft';
146
+ if (sameVisit(visit)) updated(next);
147
+ } else { target.conflict=true; target.uncertain=false; target.status='files.readbackDifferent'; }
148
+ } catch (error) { target.error=error.detail || error.message; }
149
+ finally { target.checking=false; if (visible(target)) editorRender(); }
150
+ }
151
+ async function discard() {
152
+ const target=active, visit={...context()};
153
+ if (!writable() || !target || !visible(target) || target.busy || target.checking) return;
154
+ if ((target.content!==target.base || target.uncertain || target.conflict) && !window.confirm(tr('files.discardConfirm'))) return;
155
+ const confirmedContent=target.content;
156
+ target.checking=true; target.discarding=true; target.error=''; editorRender();
157
+ try {
158
+ const next=await bridge.api(route('/api/files/preview?path='+encodeURIComponent(target.path),target.root));
159
+ if (!sameVisit(visit)) return;
160
+ if (target.content!==confirmedContent) { target.latest=next; target.status='files.discardChanged'; return; }
161
+ drafts.delete(key(target.root,target.path,target.identity)); active=null; updated(next); select();
162
+ } catch (error) { target.error=error.detail || error.message; }
163
+ finally { target.checking=false; target.discarding=false; if (visible(target)) editorRender(); }
164
+ }
165
+ async function uploadFile(event) {
166
+ event?.preventDefault(); const target=upload, visit={...context()};
167
+ if (!writable() || !target || !uploadVisible(target) || target.busy || target.uncertain || !target.file || target.blockedName===target.name) return;
168
+ if (!target.name || ['.','..'].includes(target.name) || /[\\/\u0000-\u001f\u007f]/.test(target.name)) { target.status='files.badUploadName'; uploadRender(); return; }
169
+ if (target.file.size>10*1024*1024) { target.status='files.uploadTooLarge'; uploadRender(); return; }
170
+ target.path=[target.directory,target.name].filter(Boolean).join('/'); target.size=target.file.size; target.busy=true; target.status='files.uploading'; target.error=''; uploadRender();
171
+ let sent=false;
172
+ try {
173
+ const bytes=new browser.Uint8Array(await target.file.arrayBuffer());
174
+ target.hash=await hash(bytes);
175
+ // File reading and hashing can outlive the selection. Only this visit may
176
+ // begin a write; a started request then retains its original root/path.
177
+ if (!sameVisit(visit) || context().directory!==target.directory) { target.status='files.uploadReady'; return; }
178
+ let binary=''; for (let offset=0;offset<bytes.length;offset+=32768) binary+=String.fromCharCode(...bytes.subarray(offset,offset+32768));
179
+ target.uncertain=true; saveUploadRecovery(target); sent=true;
180
+ const result=await bridge.api(route('/api/files/upload',target.root),{method:'POST',body:JSON.stringify({path:target.path,encoding:'base64',content:browser.btoa(binary)})});
181
+ if (result.created!==true || result.path!==target.path || result.contentHash!==target.hash || result.size!==target.size) throw new Error(tr('files.uploadUncertain'));
182
+ target.uncertain=false; saveUploadRecovery(target); target.status='files.uploaded'; target.file=null;
183
+ if (uploadVisible(target)) node('filesUploadInput').value='';
184
+ if (sameVisit(visit) && context().directory===target.directory) uploaded(result.path || target.path);
185
+ } catch (error) {
186
+ target.error=error.detail || error.message;
187
+ target.uncertain=sent && uncertain(error); saveUploadRecovery(target);
188
+ target.status=target.uncertain ? 'files.uploadUncertain' : error.status===409 ? 'files.uploadExists' : 'files.uploadFailed';
189
+ } finally { target.busy=false; if (uploadVisible(target)) uploadRender(); }
190
+ }
191
+ async function checkUpload() {
192
+ const target=upload, visit={...context()};
193
+ if (!writable() || !target?.uncertain || target.busy || !uploadVisible(target)) return;
194
+ target.busy=true; target.error=''; uploadRender();
195
+ try {
196
+ const result=await bridge.api(route('/api/files/status?path='+encodeURIComponent(target.path),target.root));
197
+ if (result.size===target.size && result.contentHash===target.hash) {
198
+ target.uncertain=false; target.file=null; target.status='files.uploadVerified'; saveUploadRecovery(target);
199
+ if (sameVisit(visit) && context().directory===target.directory) uploaded(target.path);
200
+ } else { target.status='files.uploadDifferent'; target.uncertain=false; target.blockedName=target.name; saveUploadRecovery(target); }
201
+ } catch (error) {
202
+ target.error=error.detail || error.message;
203
+ if (error.status===404) { target.uncertain=false; target.status='files.uploadAbsent'; saveUploadRecovery(target); }
204
+ } finally { target.busy=false; if (uploadVisible(target)) uploadRender(); }
205
+ }
206
+ node('filesEdit').addEventListener('click',begin);
207
+ node('filesEditor').addEventListener('input',()=>{if(active) {active.content=node('filesEditor').value; if(!active.busy&&!active.conflict&&!active.uncertain) active.status=''; editorRender();}});
208
+ node('filesSave').addEventListener('click',save); node('filesVerifySave').addEventListener('click',verify); node('filesDiscardEdit').addEventListener('click',discard);
209
+ node('filesCancelEdit').addEventListener('click',()=>{if(active) {active.editing=false;editorRender();}});
210
+ node('filesUploadForm').addEventListener('submit',uploadFile); node('filesUploadCheck').addEventListener('click',checkUpload);
211
+ node('filesUploadInput').addEventListener('change',()=>{
212
+ if(!upload || upload.busy || upload.uncertain) return;
213
+ upload.file=node('filesUploadInput').files?.[0] || null; upload.name=upload.file?.name || ''; upload.status=''; upload.error=''; upload.path=''; uploadRender();
214
+ });
215
+ node('filesUploadName').addEventListener('input',()=>{if(upload&&!upload.busy&&!upload.uncertain) {upload.name=node('filesUploadName').value;upload.path='';uploadRender();}});
216
+ window.addEventListener('beforeunload',event=>{
217
+ if ([...drafts.values()].some(draft=>draft.content!==draft.base || draft.busy || draft.uncertain)) { event.preventDefault(); event.returnValue=''; }
218
+ });
219
+ return {select,suspend,sync};
220
+ }