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.
- package/package.json +1 -1
- package/src/activation.ts +78 -51
package/package.json
CHANGED
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
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
|
-
//
|
|
390
|
-
//
|
|
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
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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))
|