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.
- package/package.json +1 -1
- package/src/activation.ts +73 -39
package/package.json
CHANGED
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
|
|
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
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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`
|
|
164
|
-
// and this synthetic English twin carries the actual
|
|
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
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
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
|