letagents 0.12.34 → 0.12.36

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 (68) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/server/runtime/messages.js +18 -1
  3. package/dist/mcp/server/runtime/workspace-capture.js +16 -5
  4. package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
  5. package/package.json +5 -3
  6. package/shared/activation-routing.d.mts +21 -3
  7. package/shared/activation-routing.mjs +37 -0
  8. package/shared/code-block.css +193 -0
  9. package/shared/code-highlighting.d.mts +109 -0
  10. package/shared/code-highlighting.mjs +345 -0
  11. package/shared/codex-owner-isolation.mjs +5 -3
  12. package/shared/codex-read-only-room-tools.d.mts +2 -0
  13. package/shared/codex-read-only-room-tools.mjs +24 -0
  14. package/shared/mcp-workspace-capture-worker.mjs +2 -1
  15. package/shared/message-link-preview-store.d.mts +10 -0
  16. package/shared/message-link-preview-store.mjs +77 -0
  17. package/shared/message-link-previews.d.mts +17 -0
  18. package/shared/message-link-previews.mjs +76 -0
  19. package/shared/message-pin-store.d.mts +20 -0
  20. package/shared/message-pin-store.mjs +102 -0
  21. package/shared/message-pins.d.mts +25 -0
  22. package/shared/message-pins.mjs +16 -0
  23. package/shared/message-reaction-store.d.mts +51 -0
  24. package/shared/message-reaction-store.mjs +290 -0
  25. package/shared/message-reactions.d.mts +73 -0
  26. package/shared/message-reactions.mjs +130 -0
  27. package/shared/message-search-controller.d.mts +38 -0
  28. package/shared/message-search-controller.mjs +99 -0
  29. package/shared/message-search.d.mts +46 -0
  30. package/shared/message-search.mjs +114 -0
  31. package/shared/room-message-urls.d.mts +25 -0
  32. package/shared/room-message-urls.mjs +119 -0
  33. package/shared/room-notification-client.d.mts +18 -0
  34. package/shared/room-notification-client.mjs +140 -0
  35. package/shared/room-notification-preferences.d.mts +17 -0
  36. package/shared/room-notification-preferences.mjs +34 -0
  37. package/shared/room-resource-invalidation.d.mts +5 -0
  38. package/shared/room-resource-invalidation.mjs +6 -0
  39. package/shared/room-settings.d.mts +16 -0
  40. package/shared/room-settings.mjs +14 -0
  41. package/shared/room-typing.d.mts +18 -0
  42. package/shared/room-typing.mjs +129 -0
  43. package/shared/room-unread-client.ts +128 -0
  44. package/shared/room-unread.d.mts +25 -0
  45. package/shared/room-unread.mjs +57 -0
  46. package/shared/room-unread.test.mjs +91 -0
  47. package/shared/rooms/RoomsDirectory.vue +1 -1
  48. package/shared/rooms/directory.ts +1 -0
  49. package/shared/slash-commands.d.mts +31 -0
  50. package/shared/slash-commands.mjs +48 -0
  51. package/shared/slash-commands.test.mjs +26 -0
  52. package/shared/ui/GitHubEventCard.vue +229 -0
  53. package/shared/ui/MessageReactionBar.vue +65 -0
  54. package/shared/ui/MessageReactionPicker.vue +291 -0
  55. package/shared/ui/MessageSearchResults.vue +106 -0
  56. package/shared/ui/PinnedMessages.vue +129 -0
  57. package/shared/ui/TypingIndicator.vue +134 -0
  58. package/shared/ui/message-reactions.css +254 -0
  59. package/shared/ui/message-search.css +186 -0
  60. package/shared/ui/room-agent-work.css +178 -0
  61. package/shared/ui/room-message-motion.ts +143 -0
  62. package/shared/ui/useComposerSlashCommands.ts +92 -0
  63. package/shared/ui/usePinnedMessageVisibility.ts +20 -0
  64. package/shared/ui/useRoomMessageMotion.ts +195 -0
  65. package/shared/ui/useSlidingTabIndicator.ts +72 -0
  66. package/shared/workspace-change-capture.mjs +3 -1
  67. package/shared/workspace-turn-capture.d.mts +4 -1
  68. package/shared/workspace-turn-capture.mjs +203 -6
package/README.md CHANGED
@@ -212,8 +212,6 @@ The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your serve
212
212
 
213
213
  Optional — **long room long-polls** (multi-hour `wait_for_messages` / `GET …/messages/poll`): set the **same** `LETAGENTS_POLL_MAX_MS` on **both** the API process and any MCP client you run from source (milliseconds; default `180000`).
214
214
 
215
- Optional — **offline-with-work notice grace**: set `LETAGENTS_LIVENESS_NOTICE_AFTER_MS` on the API process (milliseconds; default `1800000`, or 30 minutes). The room is told about an unreachable worker only while it holds an active work lease, and only after this grace; ordinary connects and disconnects are never posted (the roster shows presence). Internal transport staleness remains 2 minutes for routing and diagnostics.
216
-
217
215
  The API now uses PostgreSQL with Drizzle ORM. `DB_URL` must be set before starting the server or running migrations.
218
216
 
219
217
  Useful database commands:
@@ -122,11 +122,28 @@ function withThreadMetadata(record, recordsById, summaries) {
122
122
  },
123
123
  };
124
124
  }
125
+ /**
126
+ * Who reacted is worth an agent's attention; an empty list on every message,
127
+ * or an avatar address per reactor, is not.
128
+ */
129
+ function withAgentReadableReactions(record) {
130
+ const { reactions, ...rest } = record;
131
+ if (!Array.isArray(reactions))
132
+ return record;
133
+ const readable = reactions.filter(isRecord).map((reaction) => ({
134
+ emoji: reaction.emoji,
135
+ count: reaction.count,
136
+ by: (Array.isArray(reaction.reactors) ? reaction.reactors : [])
137
+ .filter(isRecord)
138
+ .map((reactor) => reactor.name ?? reactor.login),
139
+ }));
140
+ return readable.length ? { ...rest, reactions: readable } : rest;
141
+ }
125
142
  function toAgentReadableMessage(message, recordsById, summaries) {
126
143
  if (!message || typeof message !== "object") {
127
144
  return message;
128
145
  }
129
- const record = withThreadMetadata(message, recordsById, summaries);
146
+ const record = withThreadMetadata(withAgentReadableReactions(message), recordsById, summaries);
130
147
  const kind = normalizeAgentPromptKind(record.agent_prompt_kind);
131
148
  const text = typeof record.text === "string" ? record.text : null;
132
149
  if (!kind || text === null) {
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { Worker } from 'node:worker_threads';
7
7
  import { parseRoomAgentWorkSummary } from '../../../../shared/room-agent-work.mjs';
8
8
  import { REVIEW_LIMIT, REVIEW_PAGE_SIZE } from '../../../../shared/workspace-review.mjs';
9
- import { releaseWorkspaceTree } from '../../../../shared/workspace-turn-capture.mjs';
9
+ import { captureRemoteTips, releaseWorkspaceTree } from '../../../../shared/workspace-turn-capture.mjs';
10
10
  import { getLocalStatePath, readLocalStateSnapshot, updateLocalState } from '../../local-state/storage.js';
11
11
  import { assertWorkerConnection } from '../../worker-call-context.js';
12
12
  import { encodeRoomIdPath } from '../../room-id.js';
@@ -18,6 +18,8 @@ import { requireValidWorkerBearerRuntime } from './worker-bearer.js';
18
18
  const execute = promisify(execFile);
19
19
  const activeCalls = new Set();
20
20
  let captureWorkerActive = false;
21
+ // What the remotes held when each capture began. Memory only: after a restart the capture is compared plainly.
22
+ const remoteTips = new Map();
21
23
  const validId = (id) => /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id);
22
24
  const directory = (id) => join(`${getLocalStatePath()}.workspaces`, id);
23
25
  const preparationDirectory = (capture) => {
@@ -70,7 +72,7 @@ async function exclusive(key, operation) {
70
72
  activeCalls.delete(key);
71
73
  }
72
74
  }
73
- async function runCapture(operation, capture, text) {
75
+ async function runCapture(operation, capture, text, tips = null) {
74
76
  if (captureWorkerActive)
75
77
  throw new Error('Another workspace is being captured. Retry shortly; the original baseline is preserved.');
76
78
  captureWorkerActive = true;
@@ -78,7 +80,7 @@ async function runCapture(operation, capture, text) {
78
80
  try {
79
81
  return await new Promise((resolve, reject) => {
80
82
  worker = new Worker(new URL('../../../../shared/mcp-workspace-capture-worker.mjs', import.meta.url), {
81
- execArgv: [], workerData: { operation, capture, text, directory: operation === 'finish' ? preparationDirectory(capture) : null },
83
+ execArgv: [], workerData: { operation, capture, text, remoteTips: tips, directory: operation === 'finish' ? preparationDirectory(capture) : null },
82
84
  });
83
85
  const timeout = setTimeout(() => { reject(new Error('Capture exceeded its time limit. Retry using the same capture_id.')); void worker?.terminate(); }, 120_000);
84
86
  worker.once('message', result => { clearTimeout(timeout); result.error ? reject(new Error(result.error)) : resolve(result); });
@@ -111,6 +113,10 @@ export async function beginWorkspaceCapture(input) {
111
113
  instruction: 'Continue this capture. Keep its capture_id and call publish_workspace_capture when finished.',
112
114
  ...(prior.baseline ? {} : { warning: 'The starting snapshot is unavailable. Exact changes for this piece of work cannot be attributed.' }) };
113
115
  }
116
+ // Asked before the work starts, and not awaited: a slow remote must not delay it. The remotes
117
+ // are the ones this clone configures: nothing outside the clone records where it came from,
118
+ // and an independent worker reports its own work. (The supervisor daemon asks its own record.)
119
+ const tips = captureRemoteTips(workspace);
114
120
  const baseRevision = await git(['rev-parse', '--verify', 'HEAD']);
115
121
  const capture = { capture_id: randomUUID(), api_url: getApiUrl(), room_id: session.room_id,
116
122
  agent_key: session.agent_key, agent_instance_id: session.agent_instance_id, workspace, base_revision: baseRevision,
@@ -125,8 +131,11 @@ export async function beginWorkspaceCapture(input) {
125
131
  delete records[id];
126
132
  records[capture.capture_id] = capture;
127
133
  });
128
- for (const record of obsolete)
134
+ for (const record of obsolete) {
135
+ remoteTips.delete(record.capture_id);
129
136
  await rm(directory(record.capture_id), { recursive: true, force: true });
137
+ }
138
+ remoteTips.set(capture.capture_id, tips);
130
139
  try {
131
140
  const result = await runCapture('begin', capture);
132
141
  capture.baseline = result.baseline ?? null;
@@ -176,7 +185,7 @@ export async function publishWorkspaceCapture(input) {
176
185
  throw new Error('Supply a short summary of at most 400 characters.');
177
186
  const candidate = { ...capture, preparation_id: randomUUID() };
178
187
  try {
179
- await runCapture('finish', candidate, text);
188
+ await runCapture('finish', candidate, text, await remoteTips.get(capture.capture_id) ?? null);
180
189
  await preparedCapture(candidate);
181
190
  // Commit one immutable preparation under the same lock that fences
182
191
  // reconnects. Stale processes only write their own disposable directory.
@@ -247,6 +256,7 @@ export async function publishWorkspaceCapture(input) {
247
256
  if (capture.next_page === total) {
248
257
  capture = { ...capture, phase: 'published' };
249
258
  persist();
259
+ remoteTips.delete(capture.capture_id);
250
260
  await rm(directory(capture.capture_id), { recursive: true, force: true });
251
261
  }
252
262
  return { status: capture.phase === 'published' ? 'published' : 'uploading', capture_id: capture.capture_id,
@@ -259,6 +269,7 @@ export async function publishWorkspaceCapture(input) {
259
269
  if (error instanceof ApiError && error.status === 410) {
260
270
  capture = { ...capture, phase: 'blocked' };
261
271
  persist();
272
+ remoteTips.delete(capture.capture_id);
262
273
  await rm(directory(capture.capture_id), { recursive: true, force: true });
263
274
  }
264
275
  throw error;
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  const MAX_ROOM_REPLY_BYTES = 32 * 1024;
3
3
  /** Select routing only; the daemon remains the sole final-answer publisher. */
4
4
  export function registerReplyThreadTool(server) {
5
- const tool = server.tool("set_reply_thread", "Place your final answer in the activating room message's thread. Takes no arguments, sends no message, and does not finish the turn. Continue working and use your normal final completion when ready. Unavailable for synthetic task continuations or corrections.", {}, async () => { throw new Error("set_reply_thread requires the active daemon-owned room turn."); });
5
+ const tool = server.tool("set_reply_thread", "Opt into a new thread for your final answer only when the user asks for one or a sustained side discussion would distract from the room. Answer ordinary top-level questions in the main room by default, without this tool; existing thread replies stay threaded automatically. Takes no arguments, sends no message, and does not finish the turn. Continue working and use your normal final completion when ready. Unavailable for synthetic task continuations or corrections.", {}, async () => { throw new Error("set_reply_thread requires the active daemon-owned room turn."); });
6
6
  // The legacy raw-shape overload strips unknown keys. Preserve a strict schema
7
7
  // so a caller cannot mistake ignored room/recipient/text arguments for authority.
8
8
  tool.inputSchema = z.object({}).strict();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.34",
3
+ "version": "0.12.36",
4
4
  "description": "Let Agents Chat \u2014 MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "license": "BUSL-1.1",
52
52
  "dependencies": {
53
- "@modelcontextprotocol/sdk": "1.27.1",
53
+ "@modelcontextprotocol/sdk": "1.31.0",
54
54
  "drizzle-orm": "0.45.2",
55
55
  "express": "5.2.1",
56
56
  "pg": "8.20.0",
@@ -71,6 +71,8 @@
71
71
  },
72
72
  "fast-uri": "3.1.8",
73
73
  "hono": "4.13.8",
74
- "ip-address": "10.7.2"
74
+ "ip-address": "10.7.2",
75
+ "@modelcontextprotocol/sdk": "1.31.0",
76
+ "proxy-addr": "2.0.8"
75
77
  }
76
78
  }
@@ -18,13 +18,33 @@ export declare function humanConversationFallback(input: {
18
18
  * channel for a managed local worker.
19
19
  */
20
20
  export declare function isUntrustedExternalActivationSource(source: unknown): boolean;
21
+ export interface AgentMessageReplyTurn {
22
+ /** 1-based position in the reply order; only positions 2 and later are sent. */
23
+ position: number;
24
+ count: number;
25
+ /** Labels of the agents before this one that answered, in turn order. */
26
+ prior_speakers: string[];
27
+ }
21
28
  export interface AgentMessageActivation {
22
29
  for_current_agent: {
23
30
  decision: AgentMessageActivationDecision;
24
31
  reason: AgentMessageActivationReason;
25
32
  addressed: boolean;
33
+ turn?: AgentMessageReplyTurn;
34
+ guidance?: string;
26
35
  };
27
36
  }
37
+ /** A receipt as the activation authority sees it. Turn fields are optional. */
38
+ export type AgentMessageActivationReceipt = {
39
+ activation_reason: string;
40
+ turn_position?: number | null;
41
+ turn_count?: number | null;
42
+ prior_speakers?: readonly string[] | null;
43
+ /** Why the held turn was released: "turn", "deadline", "activation" or "skipped". */
44
+ hold_release_reason?: string | null;
45
+ };
46
+ /** The turn note for an agent that answers a shared message after others. */
47
+ export declare function replyTurnGuidance(position: number, count: number, priorSpeakers?: readonly string[], releaseReason?: string | null): string;
28
48
  type MessageLike = {
29
49
  id?: unknown;
30
50
  sender?: unknown;
@@ -85,9 +105,7 @@ export declare function attachAgentMessageActivation<T extends MessageLike>(mess
85
105
  * Only messages that predate routing snapshots keep the lazy per-reader
86
106
  * decision, so legacy backlog mentions still activate rotated sessions.
87
107
  */
88
- export declare function attachAgentMessageActivationsFromReceipts<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, receiptsMap: ReadonlyMap<number | string, {
89
- activation_reason: string;
90
- }>, snapshotNumbers: ReadonlySet<number>, context?: AgentMessageActivationContext): T[] | Array<T & {
108
+ export declare function attachAgentMessageActivationsFromReceipts<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, receiptsMap: ReadonlyMap<number | string, AgentMessageActivationReceipt>, snapshotNumbers: ReadonlySet<number>, context?: AgentMessageActivationContext): T[] | Array<T & {
91
109
  activation: AgentMessageActivation;
92
110
  }>;
93
111
  export declare function attachAgentMessageActivations<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, context?: AgentMessageActivationContext): T[] | Array<T & {
@@ -80,6 +80,7 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
80
80
  decision: "activate",
81
81
  reason: reason || "explicit_mention",
82
82
  addressed: true,
83
+ ...replyTurnActivationFields(receipt),
83
84
  },
84
85
  },
85
86
  };
@@ -103,6 +104,42 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
103
104
  return attachAgentMessageActivation(message, identity, context);
104
105
  });
105
106
  }
107
+ /**
108
+ * The turn note for an agent that answers a shared message after others.
109
+ * Desktop adapters print `for_current_agent` into the prompt verbatim, so this
110
+ * text reaches the model without a client change.
111
+ */
112
+ export function replyTurnGuidance(position, count, priorSpeakers = [], releaseReason = null) {
113
+ const opening = `Turn order: you answer this message in position ${position} of ${count}.`;
114
+ const speakers = priorSpeakers.filter((speaker) => typeof speaker === "string" && speaker.trim());
115
+ if (speakers.length === 0) {
116
+ // Say "did not answer in time" only when that is what happened. A
117
+ // turn opened by a direct activation, or after agents that ended with
118
+ // nothing to say, may still have earlier agents at work.
119
+ return releaseReason === "deadline"
120
+ ? `${opening} The agent before you did not answer in time. Answer now.`
121
+ : `${opening} The agents before you have not answered yet. Cover only what they are unlikely to say.`;
122
+ }
123
+ const names = speakers.length === 1
124
+ ? speakers[0]
125
+ : `${speakers.slice(0, -1).join(", ")} and ${speakers.at(-1)}`;
126
+ const replies = speakers.length === 1 ? "their reply is" : "their replies are";
127
+ return `${opening} ${names} answered before you; ${replies} in the recent room context.`
128
+ + " Do not repeat their points. Add only new points, agree in one short line, or disagree and give the reason.";
129
+ }
130
+ /** Position 1 and parallel receipts add nothing; later positions add turn and guidance. */
131
+ function replyTurnActivationFields(receipt) {
132
+ const position = Number(receipt.turn_position);
133
+ const count = Number(receipt.turn_count);
134
+ if (!Number.isSafeInteger(position) || !Number.isSafeInteger(count) || position < 2 || count < position) {
135
+ return {};
136
+ }
137
+ const priorSpeakers = Array.isArray(receipt.prior_speakers) ? receipt.prior_speakers : [];
138
+ return {
139
+ turn: { position, count, prior_speakers: [...priorSpeakers] },
140
+ guidance: replyTurnGuidance(position, count, priorSpeakers, receipt.hold_release_reason ?? null),
141
+ };
142
+ }
106
143
  export function attachAgentMessageActivations(messages, identity, context = {}) {
107
144
  if (!identity || identity.session_kind !== "worker") {
108
145
  return [...messages];
@@ -0,0 +1,193 @@
1
+ /* ─── Shared Fenced Code Block & Syntax Highlighting Styles ─── */
2
+
3
+ .fenced-code-block {
4
+ position: relative;
5
+ max-width: 100%;
6
+ margin: 0.75em 0;
7
+ padding: 0;
8
+ overflow: hidden;
9
+ border: 1px solid var(--border);
10
+ border-radius: var(--radius-md, 8px);
11
+ background: var(--code-bg, color-mix(in srgb, var(--bg) 88%, transparent));
12
+ color: var(--code-text, var(--text));
13
+ font-family: var(--font-mono);
14
+ font-size: 0.82rem;
15
+ line-height: 1.55;
16
+ }
17
+
18
+ .fenced-code-block-header {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ padding: 6px 12px;
23
+ background: var(--code-header-bg, color-mix(in srgb, var(--text) 4%, transparent));
24
+ border-bottom: 1px solid var(--border);
25
+ user-select: none;
26
+ font-family: var(--font-sans);
27
+ font-size: 0.75rem;
28
+ line-height: 1.2;
29
+ }
30
+
31
+ .fenced-code-block-lang {
32
+ color: var(--code-lang, var(--text-secondary));
33
+ font-weight: 500;
34
+ text-transform: lowercase;
35
+ }
36
+
37
+ .fenced-code-block-lang[data-lang]::before {
38
+ content: attr(data-lang);
39
+ }
40
+
41
+ .fenced-code-block-copy {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ margin-left: auto;
46
+ padding: 3px 8px;
47
+ border: 1px solid var(--border);
48
+ border-radius: var(--radius-sm, 4px);
49
+ background: var(--code-copy-bg, transparent);
50
+ color: var(--code-copy-text, var(--text-secondary));
51
+ font-family: var(--font-sans);
52
+ font-size: 0.72rem;
53
+ font-weight: 500;
54
+ line-height: 1.2;
55
+ cursor: pointer;
56
+ transition: color var(--duration-fast, 150ms) var(--ease-out, ease),
57
+ background var(--duration-fast, 150ms) var(--ease-out, ease),
58
+ border-color var(--duration-fast, 150ms) var(--ease-out, ease);
59
+ }
60
+
61
+ @media (hover: hover) and (pointer: fine) {
62
+ .fenced-code-block-copy:hover {
63
+ background: var(--accent-hover, rgba(255, 255, 255, 0.08));
64
+ color: var(--text);
65
+ border-color: var(--border-strong);
66
+ }
67
+ }
68
+
69
+ .fenced-code-block-copy:focus-visible {
70
+ outline: 2px solid var(--blue, #3b82f6);
71
+ outline-offset: 1px;
72
+ }
73
+
74
+ .fenced-code-block-copy::before {
75
+ content: "Copy";
76
+ }
77
+
78
+ .fenced-code-block-copy[data-copied="true"] {
79
+ color: var(--green-text, #4ade80);
80
+ border-color: color-mix(in srgb, var(--green-text, #4ade80) 40%, transparent);
81
+ }
82
+
83
+ .fenced-code-block-copy[data-copied="true"]::before {
84
+ content: "Copied";
85
+ }
86
+
87
+ .fenced-code-block-copy[data-copied="error"] {
88
+ color: var(--red-text, #f87171);
89
+ border-color: color-mix(in srgb, var(--red-text, #f87171) 40%, transparent);
90
+ }
91
+
92
+ .fenced-code-block-copy[data-copied="error"]::before {
93
+ content: "Failed";
94
+ }
95
+
96
+ /*
97
+ * Eleven different surfaces across desktop and web (e.g. chat messages, long message reader,
98
+ * board task inspector, thread panel, events view, markdown editor preview, agent inspector)
99
+ * define conflicting styles for `pre` with varying selector specificity.
100
+ * The `!important` declarations below ensure the fenced code block pre maintains a clean,
101
+ * borderless, transparent container with standard monospace sizing across all surfaces.
102
+ */
103
+ .fenced-code-block pre {
104
+ margin: 0 !important;
105
+ padding: 10px 14px !important;
106
+ overflow-x: auto !important;
107
+ border: none !important;
108
+ border-radius: 0 !important;
109
+ background: transparent !important;
110
+ line-height: 1.55;
111
+ font-family: var(--font-mono);
112
+ font-size: 0.82rem;
113
+ white-space: pre;
114
+ }
115
+
116
+ .fenced-code-block code.fenced-code-block-content {
117
+ display: block;
118
+ padding: 0;
119
+ overflow-x: visible;
120
+ background: transparent;
121
+ color: inherit;
122
+ font-family: inherit;
123
+ font-size: inherit;
124
+ line-height: inherit;
125
+ white-space: pre;
126
+ }
127
+
128
+ /* ─── Syntax Highlighting Token Mappings ─── */
129
+ .hljs-comment,
130
+ .hljs-quote {
131
+ color: var(--code-comment, var(--text-secondary));
132
+ font-style: italic;
133
+ }
134
+
135
+ .hljs-keyword,
136
+ .hljs-selector-tag,
137
+ .hljs-subst {
138
+ color: var(--code-keyword, var(--task-assigned));
139
+ }
140
+
141
+ .hljs-string,
142
+ .hljs-doctag,
143
+ .hljs-regexp {
144
+ color: var(--code-string, var(--green-text));
145
+ }
146
+
147
+ .hljs-number,
148
+ .hljs-literal {
149
+ color: var(--code-number, var(--amber-text));
150
+ }
151
+
152
+ .hljs-title,
153
+ .hljs-title.function_,
154
+ .hljs-title.class_,
155
+ .hljs-section {
156
+ color: var(--code-function, var(--blue-text));
157
+ }
158
+
159
+ .hljs-variable,
160
+ .hljs-template-variable,
161
+ .hljs-attr,
162
+ .hljs-attribute {
163
+ color: var(--code-variable, var(--task-in-review));
164
+ }
165
+
166
+ .hljs-type,
167
+ .hljs-built_in,
168
+ .hljs-name {
169
+ color: var(--code-type, var(--red-text));
170
+ }
171
+
172
+ .hljs-meta,
173
+ .hljs-tag {
174
+ color: var(--code-comment, var(--text-secondary));
175
+ }
176
+
177
+ .hljs-addition {
178
+ color: var(--code-string, var(--green-text));
179
+ background-color: var(--green-dim, rgba(34, 197, 94, 0.12));
180
+ }
181
+
182
+ .hljs-deletion {
183
+ color: var(--code-type, var(--red-text));
184
+ background-color: var(--red-dim, rgba(239, 68, 68, 0.12));
185
+ }
186
+
187
+ .hljs-emphasis {
188
+ font-style: italic;
189
+ }
190
+
191
+ .hljs-strong {
192
+ font-weight: bold;
193
+ }
@@ -0,0 +1,109 @@
1
+ export type CanonicalLanguage =
2
+ | 'javascript'
3
+ | 'typescript'
4
+ | 'python'
5
+ | 'bash'
6
+ | 'json'
7
+ | 'yaml'
8
+ | 'xml'
9
+ | 'css'
10
+ | 'sql'
11
+ | 'diff'
12
+ | 'rust'
13
+ | 'go'
14
+ | 'c'
15
+ | 'cpp'
16
+ | 'java'
17
+ | 'csharp'
18
+ | 'dockerfile'
19
+ | 'ruby'
20
+ | 'php'
21
+ | 'swift'
22
+ | 'kotlin'
23
+ | 'ini';
24
+
25
+ export type LanguageAlias =
26
+ | 'js'
27
+ | 'jsx'
28
+ | 'mjs'
29
+ | 'cjs'
30
+ | 'ts'
31
+ | 'tsx'
32
+ | 'mts'
33
+ | 'cts'
34
+ | 'py'
35
+ | 'sh'
36
+ | 'zsh'
37
+ | 'shell'
38
+ | 'yml'
39
+ | 'html'
40
+ | 'xhtml'
41
+ | 'svg'
42
+ | 'rs'
43
+ | 'golang'
44
+ | 'c++'
45
+ | 'cc'
46
+ | 'hpp'
47
+ | 'h'
48
+ | 'cs'
49
+ | 'docker'
50
+ | 'rb'
51
+ | 'kt'
52
+ | 'kts'
53
+ | 'toml';
54
+
55
+ export type CodeHighlightLanguage = CanonicalLanguage;
56
+
57
+ export interface ResolvedLanguage {
58
+ canonical: CanonicalLanguage;
59
+ label: string;
60
+ }
61
+
62
+ export interface CodeHighlighterOptions {
63
+ highlight: (code: string, canonicalLanguage: CanonicalLanguage) => string;
64
+ now?: () => number;
65
+ maxBudgetMs?: number;
66
+ windowMs?: number;
67
+ }
68
+
69
+ export interface CodeHighlighter {
70
+ renderCodeBlock(code: string, rawTag?: string): string;
71
+ cache: CodeHighlightCache;
72
+ resolveLanguage(rawTag?: string): ResolvedLanguage | null;
73
+ }
74
+
75
+ export const CANONICAL_LANGUAGES: readonly CanonicalLanguage[];
76
+ export const LANGUAGE_ALIASES: ReadonlyMap<string, CanonicalLanguage>;
77
+
78
+ export const MAX_HIGHLIGHT_CHARS: number;
79
+ export const MAX_HIGHLIGHT_LINES: number;
80
+ export const MAX_HIGHLIGHT_LINE_LENGTH: number;
81
+ export const MAX_CACHE_ENTRIES: number;
82
+ export const MAX_CACHE_CHARS: number;
83
+
84
+ export const DEFAULT_TIME_BUDGET_MS: number;
85
+ export const DEFAULT_TIME_WINDOW_MS: number;
86
+
87
+ export function escapeHtml(value: unknown): string;
88
+ export function escapeAttribute(value: unknown): string;
89
+ export function resolveLanguage(rawTag?: string): ResolvedLanguage | null;
90
+
91
+ export class CodeHighlightCache {
92
+ constructor(maxEntries?: number, maxChars?: number);
93
+ get(key: string): string | undefined;
94
+ set(key: string, value: string): void;
95
+ clear(): void;
96
+ readonly size: number;
97
+ readonly totalChars: number;
98
+ }
99
+
100
+ export function createCodeHighlighter(options: CodeHighlighterOptions): CodeHighlighter;
101
+ export function executeCodeBlockCopy(
102
+ button: HTMLButtonElement,
103
+ textToCopy: string | null,
104
+ copyFn: (text: string) => Promise<boolean> | boolean,
105
+ copyTimers?: WeakMap<HTMLButtonElement, number>
106
+ ): Promise<{ success: boolean; timerId: number | null }>;
107
+ export function setupCodeBlockCopyListener(
108
+ copyFn: (text: string) => Promise<boolean> | boolean
109
+ ): () => void;