opencode-translate 0.0.11 → 0.0.13

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 +78 -51
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
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
@@ -137,26 +137,14 @@ function mergeTranslatedMetadata(state: TranslateState, part: TextPartLike, engl
137
137
  // OpenCode's flag semantics, observed from packages/opencode and packages/ui:
138
138
  // synthetic: true -> hidden from the user UI, still sent to the LLM
139
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
- }
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
+ // The plugin's user-facing status text (translation preview, activation
144
+ // banner, failure notice) lives inline on the source-language user part
145
+ // itself rather than as sibling parts, because OpenCode's
146
+ // `UserMessageDisplay` renders only the first non-synthetic text part
147
+ // per user message.
160
148
 
161
149
  // LLM-only text part: hidden from the TUI but the only LLM-visible
162
150
  // representation of the user's source-language text. The original
@@ -337,11 +325,21 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
337
325
  const nextParts: TextPartLike[] = []
338
326
  let eligibleIndex = 0
339
327
  const translationErrors: { part: TextPartLike; error: unknown }[] = []
328
+ // Track the first user-authored text part so the activation
329
+ // banner can be inlined onto it. OpenCode's `UserMessageDisplay`
330
+ // renders only the first non-synthetic text part per user
331
+ // message, so any standalone banner part is never visible in
332
+ // the TUI.
333
+ let firstUserTextPart: (TextPartLike & { text: string }) | undefined
340
334
 
341
335
  for (const part of output.parts as TextPartLike[]) {
342
336
  nextParts.push(part)
343
337
  if (!isUserAuthoredTextPart(part)) continue
344
338
 
339
+ if (firstUserTextPart === undefined) {
340
+ firstUserTextPart = part
341
+ }
342
+
345
343
  const currentEligibleIndex = eligibleIndex
346
344
  eligibleIndex += 1
347
345
  if (part.text.trim().length === 0) continue
@@ -355,26 +353,27 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
355
353
  })
356
354
 
357
355
  const sourceHash = hashText(part.text)
356
+ // Compute metadata against the ORIGINAL `part.text` so the
357
+ // stored `translate_source_hash` keeps a stable round-trip
358
+ // identity even after we splice the preview into the part
359
+ // text below.
358
360
  part.metadata = {
359
361
  ...(part.metadata ?? {}),
360
362
  ...mergeTranslatedMetadata(activeState, part, english),
361
363
  }
362
- // Hide the user's source-language text from the LLM. The TUI
363
- // still renders it because `ignored:true` only affects the
364
- // user-side LLM serializer (`message-v2.ts:773`).
364
+ // Inline the `→ EN: ...` preview into the source-language
365
+ // part. OpenCode's `UserMessageDisplay`
366
+ // (packages/ui/src/components/message-part.tsx) renders only
367
+ // ONE text part per user message — the first non-synthetic —
368
+ // so a sibling `synthetic:false + ignored:true` preview part
369
+ // would never reach the screen. Combining them here keeps
370
+ // the original visible alongside the English twin while
371
+ // `ignored:true` still hides the whole thing from the LLM
372
+ // serializer (`message-v2.ts:773`); the LLM-only synthetic
373
+ // twin below carries the clean English prompt.
374
+ part.text = `${part.text}\n\n_→ EN: ${english}_`
365
375
  part.ignored = true
366
376
 
367
- // UI-only preview so the user can verify the translation that
368
- // was sent to the LLM.
369
- nextParts.push(
370
- createSyntheticTextPart(part.sessionID, part.messageID, `→ EN: ${english}`, {
371
- translate_role: "translation_preview",
372
- translate_nonce: activeState.translate_nonce,
373
- translate_source_hash: sourceHash,
374
- translate_part_index: currentEligibleIndex,
375
- }),
376
- )
377
-
378
377
  // LLM-only English twin. This is the actual prompt the model
379
378
  // sees in place of the now-`ignored` source-language part.
380
379
  nextParts.push(
@@ -386,22 +385,27 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
386
385
  }),
387
386
  )
388
387
  } catch (error) {
389
- // Fall back to sending the original text to the LLM so the user
390
- // still gets a response. Surface the error as a synthetic part.
388
+ // Translation failed. Append a visible warning to the
389
+ // source-language part (same UI constraint as the success
390
+ // path: only one text part per user message renders) and
391
+ // route the original, untranslated text to the LLM via a
392
+ // dedicated fallback twin so the model still gets a clean
393
+ // prompt without the warning text leaking into context.
391
394
  translationErrors.push({ part, error })
392
- const wrapped = buildInboundTranslationError(activeState.translate_source_lang, normalizeReason(error))
395
+ const reason = normalizeReason(error)
396
+ const wrapped = buildInboundTranslationError(activeState.translate_source_lang, reason)
393
397
  await logError(client, wrapped)
398
+
399
+ const originalText = part.text
400
+ part.text = `${originalText}\n\n_⚠️ Translation failed: ${reason}. Original text was sent to the model._`
401
+ part.ignored = true
402
+
394
403
  nextParts.push(
395
- createSyntheticTextPart(
396
- part.sessionID,
397
- part.messageID,
398
- `⚠️ Translation failed: ${normalizeReason(error)}. Original text will be sent to the model.`,
399
- {
400
- translate_role: "translation_failure",
401
- translate_nonce: activeState.translate_nonce,
402
- translate_part_index: currentEligibleIndex,
403
- },
404
- ),
404
+ createLlmOnlyTextPart(part.sessionID, part.messageID, originalText, {
405
+ translate_role: "llm_only_fallback",
406
+ translate_nonce: activeState.translate_nonce,
407
+ translate_part_index: currentEligibleIndex,
408
+ }),
405
409
  )
406
410
  }
407
411
  }
@@ -415,13 +419,36 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
415
419
  }
416
420
 
417
421
  if (activatedThisTurn) {
418
- nextParts.push(
419
- createSyntheticTextPart(input.sessionID, output.message.id, createActivationBannerText(options), {
422
+ const bannerText = createActivationBannerText(options)
423
+ // Inline the activation banner into the first user-authored
424
+ // text part so it actually reaches the TUI (see comment above
425
+ // for the single-text-part rendering constraint). The banner
426
+ // sits underneath the inline `→ EN: ...` preview that was
427
+ // appended during translation, giving the user a one-time
428
+ // confirmation that translation mode just turned on.
429
+ if (firstUserTextPart !== undefined) {
430
+ firstUserTextPart.text = `${firstUserTextPart.text}\n\n_${bannerText}_`
431
+ }
432
+ // Also emit the banner as a metadata-only synthetic part so
433
+ // `extractStoredState`'s canonical `translate_role ===
434
+ // "activation_banner"` marker is preserved across reloads.
435
+ // `synthetic:true + ignored:true` keeps it hidden from both
436
+ // the TUI and the LLM serializer; it exists purely as a
437
+ // database row carrying state metadata.
438
+ nextParts.push({
439
+ id: createSyntheticPartID(),
440
+ sessionID: input.sessionID,
441
+ messageID: output.message.id,
442
+ type: "text",
443
+ text: bannerText,
444
+ synthetic: true,
445
+ ignored: true,
446
+ metadata: {
420
447
  ...activeState,
421
448
  translate_role: "activation_banner",
422
449
  translate_spec_version: SPEC_VERSION,
423
- }),
424
- )
450
+ },
451
+ })
425
452
  }
426
453
 
427
454
  output.parts.splice(0, output.parts.length, ...(nextParts as typeof output.parts))