harness-mix 0.1.2 → 0.1.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 (96) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +60 -15
  3. package/docs/images/codex-desktop-home.png +0 -0
  4. package/docs/images/codex-desktop-session.png +0 -0
  5. package/docs/multi-agent-collaboration.md +68 -68
  6. package/output/native-build/renderer-extension.js +2225 -1761
  7. package/package.json +1 -2
  8. package/scripts/collaboration-recovery-test.cjs +22 -2
  9. package/scripts/collaboration-ui-smoke.cjs +10 -5
  10. package/scripts/native-protocol-test.cjs +20 -0
  11. package/scripts/workspace-test.cjs +2 -14
  12. package/src/main/adapters/antigravity.js +49 -18
  13. package/src/main/adapters/claude.js +3 -2
  14. package/src/main/adapters/codex.js +10 -8
  15. package/src/main/adapters/dsh.js +3 -2
  16. package/src/main/adapters/opencode.js +21 -13
  17. package/src/main/adapters/pi-family.js +3 -3
  18. package/src/main/host/collaboration-worktree.js +63 -4
  19. package/src/main/host/collaboration.js +28 -2
  20. package/src/main/host/runtime.js +22 -1
  21. package/src/main/native/protocol.js +250 -24
  22. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  23. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
  24. package/src/native-ui/renderer-extension/dist/types/index.d.ts +2 -0
  25. package/src/native-ui/renderer-extension/dist/types/index.d.ts.map +1 -1
  26. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  27. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +40 -0
  28. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -0
  29. package/src/native-ui/renderer-extension/dist/types/renderer-credits-control.d.ts.map +1 -1
  30. package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
  31. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +8 -0
  32. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  33. package/src/native-ui/renderer-extension/dist/types/renderer-usage-control.d.ts.map +1 -1
  34. package/src/native-ui/renderer-extension/dist/types/settings/connections-page.d.ts +3 -1
  35. package/src/native-ui/renderer-extension/dist/types/settings/connections-page.d.ts.map +1 -1
  36. package/src/native-ui/renderer-extension/dist/types/settings/harness-accounts.d.ts +8 -1
  37. package/src/native-ui/renderer-extension/dist/types/settings/harness-accounts.d.ts.map +1 -1
  38. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts +16 -0
  39. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  40. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +1 -0
  41. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  42. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  43. package/src/native-ui/renderer-extension/src/assets/README.md +8 -38
  44. package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +9 -1
  45. package/src/native-ui/renderer-extension/src/index.ts +9 -0
  46. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +67 -6
  47. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +198 -0
  48. package/src/native-ui/renderer-extension/src/renderer-credits-control.ts +32 -12
  49. package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +139 -46
  50. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +7 -2
  51. package/src/native-ui/renderer-extension/src/renderer-usage-control.ts +34 -3
  52. package/src/native-ui/renderer-extension/src/settings/accounts.css +80 -4
  53. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +68 -29
  54. package/src/native-ui/renderer-extension/src/settings/harness-accounts.ts +89 -28
  55. package/src/native-ui/renderer-extension/src/settings/localization.ts +48 -0
  56. package/src/native-ui/renderer-extension/src/settings/pages.ts +19 -2
  57. package/src/native-ui/renderer-extension/src/settings/shell.css +62 -0
  58. package/src/native-ui/renderer-extension/src/settings/shell.ts +1 -1
  59. package/src/native-ui/renderer-extension/test/renderer-collab-cards.test.ts +45 -0
  60. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +1 -0
  61. package/src/native-ui/shared-contracts/dist/harness-accounts.d.ts +20 -6
  62. package/src/native-ui/shared-contracts/dist/harness-accounts.d.ts.map +1 -1
  63. package/src/native-ui/shared-contracts/dist/harness-accounts.js +4 -1
  64. package/src/native-ui/shared-contracts/dist/harness-accounts.js.map +1 -1
  65. package/src/native-ui/shared-contracts/dist/harness-models.d.ts +22 -0
  66. package/src/native-ui/shared-contracts/dist/harness-models.d.ts.map +1 -1
  67. package/src/native-ui/shared-contracts/dist/harness-models.js +2 -1
  68. package/src/native-ui/shared-contracts/dist/harness-models.js.map +1 -1
  69. package/src/native-ui/shared-contracts/dist/tsconfig.tsbuildinfo +1 -1
  70. package/src/native-ui/shared-contracts/src/harness-accounts.ts +4 -1
  71. package/src/native-ui/shared-contracts/src/harness-models.ts +6 -1
  72. package/docs/images/harness-mix-home.png +0 -0
  73. package/docs/images/harness-mix-session.png +0 -0
  74. package/scripts/git-test.cjs +0 -33
  75. package/src/assets/icons/activity-compact.svg +0 -1
  76. package/src/assets/icons/activity-edit.svg +0 -1
  77. package/src/assets/icons/activity-terminal.svg +0 -1
  78. package/src/assets/icons/commands.svg +0 -1
  79. package/src/assets/icons/home-chat.svg +0 -1
  80. package/src/assets/icons/home-folder.svg +0 -1
  81. package/src/assets/icons/thread-fork.svg +0 -1
  82. package/src/main/workspace/git.js +0 -73
  83. package/src/main/workspace/ipc.js +0 -50
  84. package/src/main/workspace/terminal.js +0 -34
  85. package/src/native-ui/renderer-extension/src/assets/claude-agent.svg +0 -3
  86. package/src/native-ui/renderer-extension/src/assets/codex-logo-bright.png +0 -0
  87. package/src/native-ui/renderer-extension/src/assets/codex-logo-transparent.png +0 -0
  88. package/src/native-ui/renderer-extension/src/assets/codex-logo.png +0 -0
  89. package/src/native-ui/renderer-extension/src/assets/codexhost-icon.png +0 -0
  90. package/src/native-ui/renderer-extension/src/assets/codexhost-logo-transparent.png +0 -0
  91. package/src/native-ui/renderer-extension/src/assets/codexhost-logo.png +0 -0
  92. package/src/native-ui/renderer-extension/src/assets/codexhost-readme.svg +0 -8
  93. package/src/native-ui/renderer-extension/src/assets/pi-agent.svg +0 -4
  94. package/src/native-ui/renderer-extension/src/settings/accounts-quota-interactive.prototype.html +0 -1418
  95. package/src/native-ui/renderer-extension/src/settings/update-notes.preview.html +0 -491
  96. package/src/native-ui/renderer-extension/src/settings/windows-update.preview.html +0 -2135
@@ -0,0 +1,198 @@
1
+ import { type HostThreadId, hostThreadIdSchema } from '@codexhost/shared-contracts';
2
+ import { openRendererThread } from './renderer-fork-control.js';
3
+ import { collaborationIcon } from './collaboration-icon.js';
4
+
5
+ export interface CollabCardPayload {
6
+ task_id?: string;
7
+ parent_thread_id?: string;
8
+ child_thread_id?: string;
9
+ agent_type?: string;
10
+ status?: string;
11
+ display_status?: string;
12
+ task?: string;
13
+ result?: string;
14
+ diff?: string;
15
+ digest?: string;
16
+ branch?: string;
17
+ workspace?: { mode: string; cwd?: string; branch?: string };
18
+ applied?: boolean;
19
+ }
20
+
21
+ export interface CollabCardOptions {
22
+ openThread?: (threadId: HostThreadId) => Promise<void>;
23
+ reviewWorkspace?: (threadId: string) => Promise<{ patch: string; digest: string; hasConflict?: boolean; conflictingFiles?: string[] }>;
24
+ applyWorkspace?: (threadId: string, digest?: string) => Promise<{ patch: string; digest: string }>;
25
+ }
26
+
27
+ export function parseCollabPayload(text: string): CollabCardPayload | null {
28
+ if (!text || (!text.includes('child_thread_id') && !text.includes('task_id') && !text.includes('Agent 协作'))) {
29
+ return null;
30
+ }
31
+ const match = /\{[\s\S]*?"task_id"[\s\S]*?\}/.exec(text);
32
+ if (!match) return null;
33
+ try {
34
+ const parsed = JSON.parse(match[0]);
35
+ if (parsed && (parsed.task_id || parsed.child_thread_id)) return parsed as CollabCardPayload;
36
+ } catch {}
37
+ return null;
38
+ }
39
+
40
+ export function formatDiffHtml(diff: string): string {
41
+ if (!diff) return '<div style="padding:8px;color:#888;font-size:12px">暂无文件改动</div>';
42
+ const lines = diff.split(/\r?\n/);
43
+ const rows = lines.map(line => {
44
+ let bg = 'transparent';
45
+ let color = 'inherit';
46
+ if (line.startsWith('+') && !line.startsWith('+++')) {
47
+ bg = '#22c55e1a';
48
+ color = '#16a34a';
49
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
50
+ bg = '#ef44441a';
51
+ color = '#dc2626';
52
+ } else if (line.startsWith('@@')) {
53
+ bg = '#8888881a';
54
+ color = '#64748b';
55
+ }
56
+ const escaped = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
57
+ return `<div style="background:${bg};color:${color};padding:1px 8px;font-family:ui-monospace,monospace;white-space:pre">${escaped || ' '}</div>`;
58
+ });
59
+ return `<div style="font-size:12px;line-height:1.45;max-height:360px;overflow:auto;border-radius:6px;border:1px solid #8883;background:#0001;margin-top:6px">${rows.join('')}</div>`;
60
+ }
61
+
62
+ export function installCollabCards(options: CollabCardOptions = {}) {
63
+ if (typeof document === 'undefined') {
64
+ return { scan: () => {}, dispose: () => {} };
65
+ }
66
+ let disposed = false;
67
+ const enhancedElements = new WeakSet<Element>();
68
+ const openFn = options.openThread ?? ((threadId: HostThreadId) => openRendererThread(threadId));
69
+
70
+ const enhanceElement = (card: HTMLElement, payload: CollabCardPayload) => {
71
+ if (enhancedElements.has(card)) return;
72
+ enhancedElements.add(card);
73
+
74
+ const strip = document.createElement('div');
75
+ strip.className = 'harness-mix-collab-actions';
76
+ strip.style.cssText = 'display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid #8882;font:13px system-ui';
77
+
78
+ const agent = payload.agent_type || 'agent';
79
+ const icon = collaborationIcon(agent, agent, 18);
80
+ strip.append(icon);
81
+
82
+ if (payload.child_thread_id) {
83
+ const childId = payload.child_thread_id;
84
+ const jumpBtn = document.createElement('button');
85
+ jumpBtn.type = 'button';
86
+ jumpBtn.className = 'harness-mix-collab-jump';
87
+ jumpBtn.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;border:1px solid #8884;background:#8881;color:inherit;font:inherit;cursor:pointer;font-size:12px';
88
+ jumpBtn.innerHTML = `↗ 查看 <b>@${agent}</b> 会话`;
89
+ jumpBtn.title = `切换定位至子任务会话 (${childId})`;
90
+ jumpBtn.addEventListener('click', async (e) => {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ const parsed = hostThreadIdSchema.safeParse(childId);
94
+ if (parsed.success) {
95
+ try {
96
+ await openFn(parsed.data);
97
+ } catch (err) {
98
+ console.warn('[CollabCard] Jump to thread failed:', err);
99
+ }
100
+ }
101
+ });
102
+ strip.append(jumpBtn);
103
+ }
104
+
105
+ if (payload.diff || payload.workspace?.mode === 'worktree') {
106
+ const diffContainer = document.createElement('div');
107
+ diffContainer.className = 'harness-mix-collab-diff-wrap';
108
+ diffContainer.style.cssText = 'width:100%;display:none;margin-top:6px';
109
+ if (payload.diff) {
110
+ diffContainer.innerHTML = formatDiffHtml(payload.diff);
111
+ }
112
+
113
+ const toggleDiffBtn = document.createElement('button');
114
+ toggleDiffBtn.type = 'button';
115
+ toggleDiffBtn.className = 'harness-mix-collab-diff-toggle';
116
+ toggleDiffBtn.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;border:1px solid #8884;background:#8881;color:inherit;font:inherit;cursor:pointer;font-size:12px';
117
+ toggleDiffBtn.textContent = '🔍 查看产物 Diff';
118
+ toggleDiffBtn.addEventListener('click', async (e) => {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ const isHidden = diffContainer.style.display === 'none';
122
+ if (isHidden && !diffContainer.innerHTML.trim() && payload.child_thread_id && options.reviewWorkspace) {
123
+ toggleDiffBtn.textContent = '加载 Diff 中…';
124
+ try {
125
+ const review = await options.reviewWorkspace(payload.child_thread_id);
126
+ diffContainer.innerHTML = formatDiffHtml(review.patch);
127
+ } catch (err: any) {
128
+ diffContainer.innerHTML = `<div style="padding:8px;color:#ef4444;font-size:12px">加载 Diff 失败: ${err?.message || err}</div>`;
129
+ }
130
+ }
131
+ diffContainer.style.display = isHidden ? 'block' : 'none';
132
+ toggleDiffBtn.textContent = isHidden ? '收起产物 Diff' : '🔍 查看产物 Diff';
133
+ });
134
+ strip.append(toggleDiffBtn);
135
+ strip.append(diffContainer);
136
+
137
+ if (payload.workspace?.mode === 'worktree' && !payload.applied && options.applyWorkspace && payload.child_thread_id) {
138
+ const applyBtn = document.createElement('button');
139
+ applyBtn.type = 'button';
140
+ applyBtn.className = 'harness-mix-collab-apply';
141
+ applyBtn.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;border:1px solid #16a34a88;background:#16a34a1a;color:#16a34a;font:inherit;cursor:pointer;font-size:12px;font-weight:500';
142
+ applyBtn.textContent = '✓ 合并改动到主项目';
143
+ applyBtn.title = '将该子任务的独立隔离分支改动应用到主工作区';
144
+ applyBtn.addEventListener('click', async (e) => {
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ applyBtn.disabled = true;
148
+ applyBtn.textContent = '合并中…';
149
+ try {
150
+ await options.applyWorkspace!(payload.child_thread_id!, payload.digest);
151
+ applyBtn.textContent = '✓ 已应用';
152
+ applyBtn.style.border = '1px solid #8884';
153
+ applyBtn.style.color = '#888';
154
+ payload.applied = true;
155
+ } catch (err: any) {
156
+ applyBtn.disabled = false;
157
+ applyBtn.textContent = '合并失败(重试)';
158
+ alert(`合并失败:${err?.message || err}`);
159
+ }
160
+ });
161
+ strip.append(applyBtn);
162
+ }
163
+ }
164
+
165
+ card.append(strip);
166
+ };
167
+
168
+ const scan = () => {
169
+ if (disposed) return;
170
+ const candidates = document.querySelectorAll<HTMLElement>(
171
+ '[data-local-conversation-item-target-ids], [data-turn-key], [data-testid*="tool"], .prose, pre'
172
+ );
173
+ for (const el of candidates) {
174
+ if (enhancedElements.has(el)) continue;
175
+ const text = el.textContent || '';
176
+ const payload = parseCollabPayload(text);
177
+ if (payload) {
178
+ enhanceElement(el, payload);
179
+ }
180
+ }
181
+ };
182
+
183
+ const observer = new MutationObserver(() => scan());
184
+ observer.observe(document.body || document.documentElement, {
185
+ childList: true,
186
+ subtree: true,
187
+ });
188
+ scan();
189
+
190
+ return {
191
+ scan,
192
+ dispose() {
193
+ disposed = true;
194
+ observer.disconnect();
195
+ document.querySelectorAll('.harness-mix-collab-actions').forEach(el => el.remove());
196
+ },
197
+ };
198
+ }
@@ -114,13 +114,20 @@ function productLabel(product: string, locale: RendererSettingsLocale): string {
114
114
  if (product === "GrokImagine") return "Imagine";
115
115
  if (product === "GrokVoice") return "Voice";
116
116
  const messages = rendererCreditsMessages(locale);
117
- if (product === "5-hour window") return messages.fiveHour;
118
- if (product === "7-day window") return messages.sevenDay;
119
- if (product.endsWith(" · 5-hour window")) {
120
- return `${product.slice(0, -"5-hour window".length)}${messages.fiveHour}`;
117
+ if (product === "5-hour window" || product === "5h window") return messages.fiveHour;
118
+ if (product === "7-day window" || product === "7d window") return messages.sevenDay;
119
+ if (product === "Weekly window" || product === "weekly window") return messages.weekly;
120
+ if (product.endsWith(" · 5-hour window") || product.endsWith(" · 5h window")) {
121
+ const prefix = product.replace(/ · 5(-hour|h) window$/i, "");
122
+ return `${prefix} · ${messages.fiveHour}`;
121
123
  }
122
- if (product.endsWith(" · 7-day window")) {
123
- return `${product.slice(0, -"7-day window".length)}${messages.sevenDay}`;
124
+ if (product.endsWith(" · 7-day window") || product.endsWith(" · 7d window")) {
125
+ const prefix = product.replace(/ · 7(-day|d) window$/i, "");
126
+ return `${prefix} · ${messages.sevenDay}`;
127
+ }
128
+ if (product.endsWith(" · Weekly window") || product.endsWith(" · weekly window")) {
129
+ const prefix = product.replace(/ · (weekly|Weekly) window$/i, "");
130
+ return `${prefix} · ${messages.weekly}`;
124
131
  }
125
132
  return product;
126
133
  }
@@ -140,7 +147,7 @@ function renderCreditsBar(usagePercent: number, color: string): HTMLDivElement {
140
147
  track.dataset.codexhostCreditsBar = "";
141
148
  track.style.height = "6px";
142
149
  track.style.borderRadius = "9999px";
143
- track.style.background = "color-mix(in srgb, currentColor 16%, transparent)";
150
+ track.style.background = "color-mix(in srgb, currentColor 12%, transparent)";
144
151
  track.style.overflow = "hidden";
145
152
  const fill = document.createElement("span");
146
153
  fill.style.display = "block";
@@ -148,6 +155,7 @@ function renderCreditsBar(usagePercent: number, color: string): HTMLDivElement {
148
155
  fill.style.borderRadius = "9999px";
149
156
  fill.style.width = `${Math.min(100, Math.max(0, usagePercent))}%`;
150
157
  fill.style.background = color;
158
+ fill.style.transition = "width 0.25s ease";
151
159
  track.append(fill);
152
160
  return track;
153
161
  }
@@ -167,21 +175,25 @@ function renderCreditsHeader(
167
175
  messages: RendererCreditsMessages,
168
176
  ): HTMLDivElement {
169
177
  const wrapper = document.createElement("div");
170
- wrapper.style.marginBottom = "11px";
178
+ wrapper.style.marginBottom = "10px";
179
+ wrapper.style.padding = "8px 10px";
180
+ wrapper.style.borderRadius = "10px";
181
+ wrapper.style.background = "color-mix(in srgb, currentColor 5%, transparent)";
182
+ wrapper.style.border = "1px solid color-mix(in srgb, currentColor 7%, transparent)";
171
183
 
172
184
  const top = document.createElement("div");
173
185
  top.style.display = "flex";
174
186
  top.style.alignItems = "flex-start";
175
187
  top.style.justifyContent = "space-between";
176
188
  top.style.gap = "12px";
177
- top.style.marginBottom = "5px";
189
+ top.style.marginBottom = "6px";
178
190
 
179
191
  // Same left-label / right-percent column order as each tile below, so the
180
192
  // reset line always lands under its own label instead of zig-zagging sides.
181
193
  const left = document.createElement("div");
182
194
  const label = document.createElement("div");
183
195
  label.textContent = creditsPeriodLabel(credits.periodType, locale);
184
- label.style.fontSize = "12.5px";
196
+ label.style.fontSize = "13px";
185
197
  label.style.fontWeight = "600";
186
198
  left.append(label);
187
199
  if (credits.resetsAt) {
@@ -189,6 +201,7 @@ function renderCreditsHeader(
189
201
  reset.textContent = resetLabel(credits.resetsAt, locale, messages);
190
202
  reset.style.fontSize = "11px";
191
203
  reset.style.color = "color-mix(in srgb, currentColor 62%, transparent)";
204
+ reset.style.marginTop = "2px";
192
205
  left.append(reset);
193
206
  }
194
207
 
@@ -207,7 +220,7 @@ function renderCreditsHeader(
207
220
  remainingLabel.style.opacity = "0.8";
208
221
  const remainingValue = document.createElement("span");
209
222
  remainingValue.textContent = formatRendererCreditsPercent(remaining);
210
- remainingValue.style.fontSize = "26px";
223
+ remainingValue.style.fontSize = "24px";
211
224
  remainingValue.style.fontWeight = "700";
212
225
  remainingValue.style.fontVariantNumeric = "tabular-nums";
213
226
  percent.append(remainingLabel, remainingValue);
@@ -229,7 +242,11 @@ function renderCreditsTile(
229
242
  const remaining = remainingPercent(usagePercent);
230
243
 
231
244
  const tile = document.createElement("div");
232
- tile.style.marginBottom = "11px";
245
+ tile.style.marginBottom = "7px";
246
+ tile.style.padding = "7px 9px";
247
+ tile.style.borderRadius = "8px";
248
+ tile.style.background = "color-mix(in srgb, currentColor 4%, transparent)";
249
+ tile.style.border = "1px solid color-mix(in srgb, currentColor 6%, transparent)";
233
250
 
234
251
  const top = document.createElement("div");
235
252
  top.style.display = "flex";
@@ -242,18 +259,21 @@ function renderCreditsTile(
242
259
  const name = document.createElement("span");
243
260
  name.textContent = label;
244
261
  name.style.fontSize = "12px";
262
+ name.style.fontWeight = "500";
245
263
  left.append(name);
246
264
  if (resetsAt) {
247
265
  const reset = document.createElement("div");
248
266
  reset.textContent = resetLabel(resetsAt, locale, messages);
249
267
  reset.style.fontSize = "10.5px";
250
268
  reset.style.color = "color-mix(in srgb, currentColor 62%, transparent)";
269
+ reset.style.marginTop = "2px";
251
270
  left.append(reset);
252
271
  }
253
272
 
254
273
  const percent = document.createElement("span");
255
274
  percent.textContent = `${messages.remaining} ${formatRendererCreditsPercent(remaining)}`;
256
275
  percent.style.fontSize = "12px";
276
+ percent.style.fontWeight = "600";
257
277
  percent.style.fontVariantNumeric = "tabular-nums";
258
278
  percent.style.color = color;
259
279
  top.append(left, percent);
@@ -4,6 +4,13 @@ export interface CollaborationSession { id: string; title: string; harnessId: st
4
4
  export interface CollaborationMentionCatalog { agents: CollaborationAgent[]; sessions: CollaborationSession[]; canDelegate?: boolean }
5
5
  type MentionEntry = ({ kind: 'agent' } & CollaborationAgent) | ({ kind: 'session' } & CollaborationSession);
6
6
 
7
+ const entryKey = (entry: MentionEntry): string => entry.kind === 'agent' ? `agent:${entry.id}` : `session:${entry.id}`;
8
+
9
+ // Encode an entry as the @agent literal the Host expects in the submitted text.
10
+ const entryLiteral = (entry: MentionEntry): string => entry.kind === 'agent'
11
+ ? `@${entry.id} `
12
+ : `@[${entry.title.replace(/[\[\]()\r\n]/g, '')}](harness-mix://session/${entry.id}) `;
13
+
7
14
  /** Adds native-Harness suggestions while preserving the original composer editor. */
8
15
  export function installHarnessMentions(load: (editor: Element, query: string) => Promise<CollaborationMentionCatalog>) {
9
16
  const menu = document.createElement('div');
@@ -26,68 +33,108 @@ export function installHarnessMentions(load: (editor: Element, query: string) =>
26
33
  let matches: MentionEntry[] = [], activeKind: 'agent' | 'session' = 'agent', canDelegate = true;
27
34
  const catalogs = new Map<HTMLElement, CollaborationMentionCatalog>();
28
35
  const badges = new Map<HTMLElement, HTMLElement>();
36
+
37
+ // Per-editor mention state, decoupled from editor text so the literal
38
+ // @agent handles never leak into the visible composer.
39
+ const selections = new Map<HTMLElement, Map<string, MentionEntry>>();
40
+ const selectionsFor = (target: HTMLElement): Map<string, MentionEntry> => {
41
+ let perEditor = selections.get(target);
42
+ if (!perEditor) {
43
+ perEditor = new Map();
44
+ selections.set(target, perEditor);
45
+ }
46
+ return perEditor;
47
+ };
48
+
49
+ const replaceSelectionWith = (target: HTMLElement, replacement: string): boolean => {
50
+ if (target instanceof HTMLTextAreaElement) {
51
+ target.setSelectionRange(start, end);
52
+ target.setRangeText(replacement, start, end, 'end');
53
+ target.dispatchEvent(new Event('input', { bubbles: true }));
54
+ return true;
55
+ }
56
+ // ProseMirror may replace text nodes while its own suggestions render.
57
+ // Rebuild the saved text offsets instead of reusing a live, stale Range.
58
+ const fresh = document.createRange();
59
+ const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
60
+ let offset = 0, foundStart = false, foundEnd = false;
61
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
62
+ const length = node.textContent?.length ?? 0;
63
+ if (!foundStart && start <= offset + length) { fresh.setStart(node, start - offset); foundStart = true; }
64
+ if (foundStart && end <= offset + length) { fresh.setEnd(node, end - offset); foundEnd = true; break; }
65
+ offset += length;
66
+ }
67
+ if (!foundEnd) return false;
68
+ const selection = window.getSelection();
69
+ selection?.removeAllRanges(); selection?.addRange(fresh);
70
+ document.dispatchEvent(new Event('selectionchange'));
71
+ document.execCommand('insertText', false, replacement);
72
+ return true;
73
+ };
74
+
29
75
  const syncBadges = (target: HTMLElement) => {
30
76
  for (const [old, strip] of badges) if (!old.isConnected) { strip.remove(); badges.delete(old); catalogs.delete(old); }
31
- const text = (target instanceof HTMLTextAreaElement ? target.value : target.textContent ?? '').replace(/```[\s\S]*?```|`[^`\n]*`/g, '');
32
- const ids = new Set([...text.matchAll(/(?:^|[\s,。;:])@([\w-]+)(?=$|[\s,。;:])/g)].map(m => m[1]));
33
- const catalog = catalogs.get(target) ?? { agents: [], sessions: [] };
34
- const selectedAgents = catalog.agents.filter(a => ids.has(a.id));
35
- const sessionIds = new Set([...text.matchAll(/\]\(harness-mix:\/\/session\/([A-Za-z0-9_-]+)\)/g)].map(match => match[1]));
36
- const selectedSessions = catalog.sessions.filter(session => sessionIds.has(session.id));
77
+ const perEditor = selections.get(target);
78
+ const hasAny = !!perEditor && perEditor.size > 0;
37
79
  let strip = badges.get(target);
38
- if (!strip && !selectedAgents.length && !selectedSessions.length) return;
80
+ if (!strip && !hasAny) return;
39
81
  if (!strip) {
40
82
  strip = document.createElement('div'); strip.dataset.harnessMixSelectedMentions = 'true';
41
83
  strip.setAttribute('aria-label', '已提及的协作者');
42
84
  strip.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;padding:6px 0';
43
85
  target.after(strip); badges.set(target, strip);
44
86
  }
45
- strip.replaceChildren(); strip.style.display = selectedAgents.length || selectedSessions.length ? 'flex' : 'none';
46
- for (const agent of selectedAgents) {
47
- const badge = document.createElement('span');
48
- badge.style.cssText = 'display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid #8883;border-radius:8px;font:12px inherit;background:#8881';
49
- badge.append(collaborationIcon(agent.id, agent.name, 16), document.createTextNode(agent.name));
50
- badge.title = `@${agent.id}${agent.available ? '' : ' · 未就绪'}`; strip.append(badge);
51
- }
52
- for (const session of selectedSessions) {
87
+ strip.replaceChildren(); strip.style.display = hasAny ? 'flex' : 'none';
88
+ if (!perEditor) return;
89
+ for (const entry of perEditor.values()) {
53
90
  const badge = document.createElement('span');
91
+ badge.dataset.harnessMixMentionBadge = entry.kind === 'agent' ? `agent:${entry.id}` : `session:${entry.id}`;
54
92
  badge.style.cssText = 'display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid #8883;border-radius:8px;font:12px inherit;background:#8881';
55
- badge.append(collaborationIcon(session.harnessId, session.harnessId, 16), document.createTextNode(session.title));
56
- badge.title = `历史会话 · ${session.cwd}`; strip.append(badge);
93
+ const iconWrap = document.createElement('span');
94
+ iconWrap.style.cssText = 'display:inline-flex;align-items:center;gap:6px';
95
+ iconWrap.append(
96
+ collaborationIcon(entry.kind === 'agent' ? entry.id : entry.harnessId, entry.kind === 'agent' ? entry.name : entry.title, 16),
97
+ document.createTextNode(entry.kind === 'agent' ? entry.name : entry.title),
98
+ );
99
+ const remove = document.createElement('button');
100
+ remove.type = 'button';
101
+ remove.dataset.harnessMixMentionRemove = entry.kind === 'agent' ? `agent:${entry.id}` : `session:${entry.id}`;
102
+ const removeLabel = entry.kind === 'agent' ? `移除 ${entry.name}` : `移除会话 ${entry.title}`;
103
+ remove.setAttribute('aria-label', removeLabel);
104
+ remove.title = removeLabel;
105
+ remove.textContent = '×';
106
+ remove.style.cssText = 'border:0;background:transparent;color:inherit;cursor:pointer;padding:0 0 0 2px;margin:0;font:600 14px/1 inherit;opacity:.55;line-height:1;display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%';
107
+ remove.addEventListener('mouseenter', () => { remove.style.opacity = '1'; remove.style.background = '#8883'; });
108
+ remove.addEventListener('mouseleave', () => { remove.style.opacity = '.55'; remove.style.background = 'transparent'; });
109
+ remove.addEventListener('mousedown', event => { event.preventDefault(); event.stopPropagation(); });
110
+ remove.addEventListener('click', event => {
111
+ event.preventDefault();
112
+ event.stopPropagation();
113
+ const live = selections.get(target);
114
+ if (!live) return;
115
+ live.delete(entryKey(entry));
116
+ syncBadges(target);
117
+ });
118
+ badge.append(iconWrap, remove);
119
+ badge.title = entry.kind === 'agent' ? `@${entry.id}${entry.available ? '' : ' · 未就绪'}` : `历史会话 · ${entry.cwd}`;
120
+ strip.append(badge);
57
121
  }
58
122
  };
59
123
  const close = () => { generation++; menu.hidden = true; activeComposer?.removeAttribute('data-harness-mix-mention-active'); activeComposer = null; };
60
124
  const choose = (entry: MentionEntry) => {
61
125
  if (!editor || (entry.kind === 'agent' ? !entry.available || !canDelegate : entry.running === true)) return;
62
126
  const target = editor;
63
- const inserted = entry.kind === 'agent' ? `@${entry.id} ` : `@[${entry.title.replace(/[\[\]()\r\n]/g, '')}](harness-mix://session/${entry.id}) `;
64
- close(); target.focus();
65
- if (target instanceof HTMLTextAreaElement) {
66
- target.setSelectionRange(start, end);
67
- if (!document.execCommand('insertText', false, inserted)) {
68
- target.setRangeText(inserted, start, end, 'end');
69
- target.dispatchEvent(new Event('input', { bubbles: true }));
70
- }
71
- } else if (range) {
72
- // ProseMirror may replace text nodes while its own suggestions render.
73
- // Rebuild the saved text offsets instead of reusing a live, stale Range.
74
- const replacement = document.createRange();
75
- const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
76
- let offset = 0, foundStart = false, foundEnd = false;
77
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
78
- const length = node.textContent?.length ?? 0;
79
- if (!foundStart && start <= offset + length) { replacement.setStart(node, start - offset); foundStart = true; }
80
- if (foundStart && end <= offset + length) { replacement.setEnd(node, end - offset); foundEnd = true; break; }
81
- offset += length;
82
- }
83
- if (!foundEnd) return;
84
- const selection = window.getSelection();
85
- selection?.removeAllRanges(); selection?.addRange(replacement);
86
- document.dispatchEvent(new Event('selectionchange'));
87
- document.execCommand('insertText', false, inserted);
88
- }
89
- // Dismiss the native search opened by the inserted literal @ mention.
90
- target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
127
+ close();
128
+ target.focus();
129
+ // Drop the @query trigger text the user typed to open the picker so the
130
+ // editor stays free of literal @agent handles. The chip below carries
131
+ // the same intent and is the only visible signal.
132
+ replaceSelectionWith(target, '');
133
+ // Toggle the entry in the per-editor selections; a second click removes it.
134
+ const perEditor = selectionsFor(target);
135
+ const key = entryKey(entry);
136
+ if (perEditor.has(key)) perEditor.delete(key);
137
+ else perEditor.set(key, entry);
91
138
  syncBadges(target);
92
139
  };
93
140
  const render = () => {
@@ -198,16 +245,62 @@ export function installHarnessMentions(load: (editor: Element, query: string) =>
198
245
  }
199
246
  };
200
247
  const outside = (event: Event) => { if (event.target instanceof Node && !menu.contains(event.target)) close(); };
248
+
249
+ // The Host detects mentions by parsing `@agent` literals out of the submitted
250
+ // text. The composer no longer carries those literals while the user is
251
+ // typing, so before the native form submit reads the editor we re-attach
252
+ // the mentions at the start of the value. The editor then clears itself
253
+ // after Codex Desktop finishes dispatching the turn, hiding the literal
254
+ // text again.
255
+ const findEditorIn = (root: Element | null): HTMLElement | null => {
256
+ if (!root) return null;
257
+ if (root.matches('textarea,[contenteditable="true"]')) return root as HTMLElement;
258
+ return root.querySelector<HTMLElement>('textarea,[contenteditable="true"]');
259
+ };
260
+ const injectMentionsInto = (target: HTMLElement): boolean => {
261
+ const perEditor = selections.get(target);
262
+ if (!perEditor || perEditor.size === 0) return false;
263
+ const text = [...perEditor.values()].map(entryLiteral).join('');
264
+ if (!text) return false;
265
+ target.focus();
266
+ if (target instanceof HTMLTextAreaElement) {
267
+ target.value = `${text}${target.value}`;
268
+ target.dispatchEvent(new Event('input', { bubbles: true }));
269
+ target.dispatchEvent(new Event('change', { bubbles: true }));
270
+ return true;
271
+ }
272
+ const selection = window.getSelection();
273
+ const fresh = document.createRange();
274
+ fresh.selectNodeContents(target);
275
+ fresh.collapse(true);
276
+ selection?.removeAllRanges();
277
+ selection?.addRange(fresh);
278
+ document.dispatchEvent(new Event('selectionchange'));
279
+ document.execCommand('insertText', false, text);
280
+ return true;
281
+ };
282
+ const onSubmitCapture = (event: Event) => {
283
+ const composerRoot = (event.target instanceof Element ? event.target : null)?.closest?.('[data-codex-composer-root]');
284
+ const candidate = composerRoot ?? (event.target instanceof Element ? event.target : null);
285
+ const editorEl = findEditorIn(candidate);
286
+ if (!editorEl) return;
287
+ if (!editorEl.closest('[data-codex-composer-root]')) return;
288
+ injectMentionsInto(editorEl);
289
+ };
290
+
201
291
  document.addEventListener('input', input, true);
202
292
  document.addEventListener('keydown', keydown, true);
203
293
  document.addEventListener('mousedown', outside);
294
+ document.addEventListener('submit', onSubmitCapture, true);
204
295
  window.addEventListener('blur', close);
205
296
  return { dispose() {
206
297
  disposed = true; close(); menu.remove(); style.remove();
207
298
  for (const strip of badges.values()) strip.remove(); badges.clear(); catalogs.clear();
299
+ for (const [target, perEditor] of selections) if (!target.isConnected) selections.delete(target);
208
300
  document.removeEventListener('input', input, true);
209
301
  document.removeEventListener('keydown', keydown, true);
210
302
  document.removeEventListener('mousedown', outside);
303
+ document.removeEventListener('submit', onSubmitCapture, true);
211
304
  window.removeEventListener('blur', close);
212
305
  } };
213
- }
306
+ }
@@ -179,7 +179,8 @@ export interface RendererModelClient extends Partial<RendererSessionImportClient
179
179
  forkThread(input: ExternalThreadForkParams): Promise<ExternalThreadForkResult>;
180
180
  switchHarness(input: ThreadHarnessSwitchParams): Promise<ThreadHarnessSwitchResult>;
181
181
  inspectHarness(input: HarnessInspectParams): Promise<HarnessInspection>;
182
- installHarness?(input: { harnessId: string }): Promise<{ success: boolean; command?: string; stdout?: string; stderr?: string; error?: string }>;
182
+ installHarness?(input: { harnessId: string; terminal?: boolean | undefined }): Promise<{ success: boolean; command?: string; stdout?: string; stderr?: string; error?: string }>;
183
+ loginHarnessAccount?(input: { harnessId: string }): Promise<{ success: boolean; command?: string; error?: string }>;
183
184
  openHarnessWebUi?(input: HarnessWebUiOpenParams): Promise<void>;
184
185
  inspectThread(input: ThreadInspectionParams): Promise<ThreadInspection>;
185
186
  inspectHarnessCommands(input: HarnessCommandsInspectParams): Promise<HarnessCommandCatalog>;
@@ -356,10 +357,14 @@ export function createRendererModelClient(
356
357
  return threadDelegationResultSchema.parse(result);
357
358
  },
358
359
  inspectHarness,
359
- async installHarness(input: { harnessId: string }): Promise<{ success: boolean; command?: string; stdout?: string; stderr?: string; error?: string }> {
360
+ async installHarness(input: { harnessId: string; terminal?: boolean | undefined }): Promise<{ success: boolean; command?: string; stdout?: string; stderr?: string; error?: string }> {
360
361
  const result = await manager.sendRequest(HARNESS_INSTALL_METHOD, input);
361
362
  return result as { success: boolean; command?: string; stdout?: string; stderr?: string; error?: string };
362
363
  },
364
+ async loginHarnessAccount(input: { harnessId: string }): Promise<{ success: boolean; command?: string; error?: string }> {
365
+ const result = await manager.sendRequest("codexhost/harness/account/login", input);
366
+ return result as { success: boolean; command?: string; error?: string };
367
+ },
363
368
  async listHarnessPlugins(): Promise<HarnessPluginListResult> {
364
369
  return harnessPluginListResultSchema.parse(
365
370
  await manager.sendRequest(HARNESS_PLUGIN_LIST_METHOD, {}),
@@ -435,13 +435,19 @@ export function mountRendererUsageControl(
435
435
  trigger.style.fontVariantNumeric = "tabular-nums";
436
436
  trigger.style.letterSpacing = "0";
437
437
 
438
+ const ringSlot = document.createElement("span");
439
+ ringSlot.dataset.codexhostUsageRing = "";
440
+ ringSlot.style.display = "inline-flex";
441
+ ringSlot.style.flex = "0 0 auto";
442
+ ringSlot.style.alignItems = "center";
443
+
438
444
  const label = document.createElement("span");
439
445
  label.style.display = "inline-block";
440
446
  label.style.maxWidth = "100%";
441
447
  label.style.overflow = "hidden";
442
448
  label.style.textOverflow = "ellipsis";
443
449
  label.style.whiteSpace = "nowrap";
444
- trigger.append(label);
450
+ trigger.append(ringSlot, label);
445
451
 
446
452
  const popover = document.createElement("div");
447
453
  popover.id = `${composerId}-usage-popover`;
@@ -561,7 +567,33 @@ export function renderRendererUsageControl(
561
567
  return false;
562
568
  }
563
569
 
570
+ const ringSlot = control.trigger.querySelector<HTMLElement>("[data-codexhost-usage-ring]");
571
+ const contextPercent =
572
+ usage?.contextUsagePercent !== undefined
573
+ ? usage.contextUsagePercent
574
+ : (hasContext && usage?.contextWindowTokens !== undefined && usage.contextWindowTokens > 0
575
+ ? Math.round((100 * (usage.contextUsedTokens ?? 0) / usage.contextWindowTokens) * 10) / 10
576
+ : undefined);
577
+
578
+ if (contextPercent !== undefined) {
579
+ const tone = contextPercent >= 90 ? "hot" : contextPercent >= 70 ? "warn" : "ok";
580
+ const color = tone === "hot" ? "#c45c4a" : tone === "warn" ? "#c9a227" : "#3d9a64";
581
+ if (ringSlot) {
582
+ ringSlot.replaceChildren(
583
+ createRendererUsageRing(contextPercent, {
584
+ size: 14,
585
+ strokeWidth: 2.4,
586
+ color,
587
+ }),
588
+ );
589
+ }
590
+ } else if (ringSlot) {
591
+ ringSlot.replaceChildren();
592
+ }
593
+
594
+ const percentLabel = contextPercent !== undefined ? `${decimal(contextPercent, 1)}%` : null;
564
595
  const summary = [
596
+ percentLabel,
565
597
  usage?.totalCredits !== undefined ? formatRendererCredits(usage.totalCredits) : null,
566
598
  cacheHitRatePercent !== undefined ? formatRendererCacheHitRate(cacheHitRatePercent) : null,
567
599
  outputTokensPerSecond !== undefined
@@ -569,8 +601,7 @@ export function renderRendererUsageControl(
569
601
  : null,
570
602
  totalCostUsd !== undefined ? formatRendererCost(totalCostUsd) : null,
571
603
  ].filter((value): value is string => value !== null);
572
- const contextPercent = usage?.contextUsagePercent;
573
- if (contextPercent !== undefined && summary.length === 0) summary.push(messages.usage);
604
+
574
605
  if (
575
606
  summary.length === 0 &&
576
607
  hasContext &&