opencode-translate 0.0.12 → 0.0.14

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/activation.ts +73 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "OpenCode plugin that lets the user chat in a configured source language while the main chat loop only sees English.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/activation.ts CHANGED
@@ -134,34 +134,28 @@ function mergeTranslatedMetadata(state: TranslateState, part: TextPartLike, engl
134
134
  }
135
135
  }
136
136
 
137
- // OpenCode's flag semantics, observed from packages/opencode and packages/ui:
137
+ // OpenCode's flag semantics are not uniform across every UI path:
138
138
  // synthetic: true -> hidden from the user UI, still sent to the LLM
139
- // ignored: true -> hidden from the LLM, still shown in the user UI
140
- // The translation preview, activation banner, and failure notices are
141
- // user-facing status/diagnostic parts that must not leak into the LLM
142
- // prompt, so they use synthetic:false + ignored:true.
143
- function createSyntheticTextPart(
144
- sessionID: string,
145
- messageID: string,
146
- text: string,
147
- metadata: Record<string, unknown>,
148
- ): TextPartLike {
149
- return {
150
- id: createSyntheticPartID(),
151
- sessionID,
152
- messageID,
153
- type: "text",
154
- text,
155
- synthetic: false,
156
- ignored: true,
157
- metadata,
158
- }
159
- }
139
+ // ignored: true -> hidden from the LLM, still shown in the main message UI
140
+ // both true -> hidden from both (used for metadata-only marker
141
+ // parts like the activation banner that exist purely
142
+ // to carry state across reloads)
143
+ // Some secondary UIs, including the web `/fork` dialog, only consider text
144
+ // parts that are neither synthetic nor ignored. Persist translated user display
145
+ // parts as non-ignored so those flows can find them; before model serialization,
146
+ // `experimental.chat.messages.transform` marks those display parts ignored and
147
+ // lets the synthetic English twins carry the actual prompt text.
148
+ // The plugin's user-facing status text (translation preview, activation
149
+ // banner, failure notice) lives inline on the source-language user part
150
+ // itself rather than as sibling parts, because OpenCode's
151
+ // `UserMessageDisplay` renders only the first non-synthetic text part
152
+ // per user message.
160
153
 
161
154
  // LLM-only text part: hidden from the TUI but the only LLM-visible
162
155
  // representation of the user's source-language text. The original
163
- // user-authored part is marked `ignored:true` so the LLM never sees it,
164
- // and this synthetic English twin carries the actual prompt content.
156
+ // user-authored part is marked `ignored:true` in the model-bound transform so
157
+ // the LLM never sees it, and this synthetic English twin carries the actual
158
+ // prompt content.
165
159
  function createLlmOnlyTextPart(
166
160
  sessionID: string,
167
161
  messageID: string,
@@ -180,6 +174,11 @@ function createLlmOnlyTextPart(
180
174
  }
181
175
  }
182
176
 
177
+ function isTranslatedUserDisplayPart(part: TextPartLike): boolean {
178
+ if (!isTextPart(part) || part.synthetic === true) return false
179
+ return extractStateFromMetadata(asMetadata(part)) !== undefined
180
+ }
181
+
183
182
  function escapeRegex(value: string): string {
184
183
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
185
184
  }
@@ -337,11 +336,21 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
337
336
  const nextParts: TextPartLike[] = []
338
337
  let eligibleIndex = 0
339
338
  const translationErrors: { part: TextPartLike; error: unknown }[] = []
339
+ // Track the first user-authored text part so the activation
340
+ // banner can be inlined onto it. OpenCode's `UserMessageDisplay`
341
+ // renders only the first non-synthetic text part per user
342
+ // message, so any standalone banner part is never visible in
343
+ // the TUI.
344
+ let firstUserTextPart: (TextPartLike & { text: string }) | undefined
340
345
 
341
346
  for (const part of output.parts as TextPartLike[]) {
342
347
  nextParts.push(part)
343
348
  if (!isUserAuthoredTextPart(part)) continue
344
349
 
350
+ if (firstUserTextPart === undefined) {
351
+ firstUserTextPart = part
352
+ }
353
+
345
354
  const currentEligibleIndex = eligibleIndex
346
355
  eligibleIndex += 1
347
356
  if (part.text.trim().length === 0) continue
@@ -369,15 +378,14 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
369
378
  // ONE text part per user message — the first non-synthetic —
370
379
  // so a sibling `synthetic:false + ignored:true` preview part
371
380
  // would never reach the screen. Combining them here keeps
372
- // the original visible alongside the English twin while
373
- // `ignored:true` still hides the whole thing from the LLM
374
- // serializer (`message-v2.ts:773`); the LLM-only synthetic
375
- // twin below carries the clean English prompt.
381
+ // the original visible alongside the English twin while still
382
+ // satisfying secondary UI filters like `/fork`, which require
383
+ // non-synthetic, non-ignored text. The transform hook marks this
384
+ // display part ignored in the model-bound copy.
376
385
  part.text = `${part.text}\n\n_→ EN: ${english}_`
377
- part.ignored = true
378
386
 
379
387
  // LLM-only English twin. This is the actual prompt the model
380
- // sees in place of the now-`ignored` source-language part.
388
+ // sees in place of the source-language display part.
381
389
  nextParts.push(
382
390
  createLlmOnlyTextPart(part.sessionID, part.messageID, english, {
383
391
  translate_role: "llm_only_translation",
@@ -421,13 +429,36 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
421
429
  }
422
430
 
423
431
  if (activatedThisTurn) {
424
- nextParts.push(
425
- createSyntheticTextPart(input.sessionID, output.message.id, createActivationBannerText(options), {
432
+ const bannerText = createActivationBannerText(options)
433
+ // Inline the activation banner into the first user-authored
434
+ // text part so it actually reaches the TUI (see comment above
435
+ // for the single-text-part rendering constraint). The banner
436
+ // sits underneath the inline `→ EN: ...` preview that was
437
+ // appended during translation, giving the user a one-time
438
+ // confirmation that translation mode just turned on.
439
+ if (firstUserTextPart !== undefined) {
440
+ firstUserTextPart.text = `${firstUserTextPart.text}\n\n_${bannerText}_`
441
+ }
442
+ // Also emit the banner as a metadata-only synthetic part so
443
+ // `extractStoredState`'s canonical `translate_role ===
444
+ // "activation_banner"` marker is preserved across reloads.
445
+ // `synthetic:true + ignored:true` keeps it hidden from both
446
+ // the TUI and the LLM serializer; it exists purely as a
447
+ // database row carrying state metadata.
448
+ nextParts.push({
449
+ id: createSyntheticPartID(),
450
+ sessionID: input.sessionID,
451
+ messageID: output.message.id,
452
+ type: "text",
453
+ text: bannerText,
454
+ synthetic: true,
455
+ ignored: true,
456
+ metadata: {
426
457
  ...activeState,
427
458
  translate_role: "activation_banner",
428
459
  translate_spec_version: SPEC_VERSION,
429
- }),
430
- )
460
+ },
461
+ })
431
462
  }
432
463
 
433
464
  output.parts.splice(0, output.parts.length, ...(nextParts as typeof output.parts))
@@ -444,12 +475,15 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
444
475
  const activeState = resolved.state
445
476
  if (!activeState) return
446
477
 
447
- // User parts need no in-place rewriting: the source-language text
448
- // part is `ignored:true` so the LLM serializer skips it, and a
449
- // synthetic English twin (created in `chat.message`) carries the
450
- // actual prompt content. Only assistant parts still need their
451
- // localized trailer stripped before re-entering the model.
452
478
  for (const message of output.messages as MessageWithPartsLike[]) {
479
+ if (message.info.role === "user") {
480
+ for (const part of message.parts) {
481
+ if (!isTranslatedUserDisplayPart(part)) continue
482
+ part.ignored = true
483
+ }
484
+ continue
485
+ }
486
+
453
487
  if (message.info.role !== "assistant") continue
454
488
  for (const part of message.parts) {
455
489
  if (!isTextPart(part)) continue