opencode-translate 0.0.4 → 0.0.5

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/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  - Stores the original user text, plus a cached English translation in part metadata.
10
10
  - Shows a visible `→ EN: ...` preview under each translated user text part.
11
11
  - Translates assistant text parts from English into `displayLanguage` when each text part completes.
12
+ - Translates the built-in `question` tool's question text, header, and every option's label and description into `displayLanguage` so the TUI confirmation dialog is in the user's language. The tool output string returned to the LLM is restored to English, keeping the chat history English-only.
12
13
  - Stores assistant text as:
13
14
 
14
15
  ```md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
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
@@ -23,12 +23,23 @@ import {
23
23
  } from "./constants"
24
24
  import { composeTranslatedAssistantText, composeTranslationFailureText, extractEnglishHistoryText } from "./formatting"
25
25
  import { getDisplayLanguageLabel } from "./labels"
26
+ import {
27
+ isQuestionArgs,
28
+ type QuestionSnapshot,
29
+ type QuestionToolOutput,
30
+ restoreQuestionOutput,
31
+ snapshotQuestions,
32
+ translateQuestionArgs,
33
+ } from "./question-tool"
26
34
  import { createSyntheticPartID, createTranslator, hashText } from "./translator"
27
35
 
28
36
  const sessionStateCache = new Map<string, TranslateState | null>()
37
+ const questionSnapshots = new Map<string, QuestionSnapshot>()
38
+ const QUESTION_TOOL_ID = "question"
29
39
 
30
40
  export function __resetActivationCacheForTest() {
31
41
  sessionStateCache.clear()
42
+ questionSnapshots.clear()
32
43
  }
33
44
 
34
45
  interface ResolvedSessionState {
@@ -452,5 +463,55 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
452
463
  await logError(client, error)
453
464
  }
454
465
  },
466
+ // Translate the built-in `question` tool so the TUI dialog renders in
467
+ // the user's displayLanguage. The tool output string is restored back
468
+ // to English in `tool.execute.after` so the main LLM context stays
469
+ // English-only.
470
+ "tool.execute.before": async (input, output) => {
471
+ try {
472
+ if (input.tool !== QUESTION_TOOL_ID) return
473
+ const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
474
+ const activeState = resolved.state
475
+ if (!activeState) return
476
+ if (activeState.translate_display_lang === LLM_LANGUAGE) return
477
+
478
+ const args = output.args as unknown
479
+ if (!isQuestionArgs(args)) return
480
+
481
+ const original = snapshotQuestions(args)
482
+ try {
483
+ await translateQuestionArgs(args, (text) =>
484
+ translator.translateText({
485
+ text,
486
+ sourceLanguage: LLM_LANGUAGE,
487
+ targetLanguage: activeState.translate_display_lang,
488
+ direction: "outbound",
489
+ }),
490
+ )
491
+ } catch (error) {
492
+ // Translation failed: restore the originals so the dialog at least
493
+ // renders in English instead of a half-translated mess.
494
+ args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
495
+ await logError(client, error)
496
+ return
497
+ }
498
+
499
+ const translated = snapshotQuestions(args)
500
+ questionSnapshots.set(input.callID, { original, translated })
501
+ } catch (error) {
502
+ await logError(client, error)
503
+ }
504
+ },
505
+ "tool.execute.after": async (input, output) => {
506
+ try {
507
+ if (input.tool !== QUESTION_TOOL_ID) return
508
+ const snapshot = questionSnapshots.get(input.callID)
509
+ if (!snapshot) return
510
+ questionSnapshots.delete(input.callID)
511
+ restoreQuestionOutput(output as QuestionToolOutput, snapshot)
512
+ } catch (error) {
513
+ await logError(client, error)
514
+ }
515
+ },
455
516
  }
456
517
  }
@@ -0,0 +1,142 @@
1
+ // Translation layer for OpenCode's built-in `question` tool.
2
+ //
3
+ // Flow:
4
+ // 1. Agent (main LLM, English-only) invokes the `question` tool with an
5
+ // `args.questions[]` payload in English.
6
+ // 2. `tool.execute.before` hook translates each question's text, header,
7
+ // and every option's label + description into `displayLanguage` so the
8
+ // question prompt renders in the user's language.
9
+ // 3. OpenCode publishes `question.asked`; the TUI shows the translated
10
+ // dialog and the user picks an option (or types a custom answer).
11
+ // 4. `tool.execute.after` hook reverses the substitution using the
12
+ // snapshot we captured in step 2, so the tool output string delivered
13
+ // back to the LLM stays in English.
14
+ //
15
+ // A per-callID snapshot is kept so mapping a user-selected translated label
16
+ // back to its original English label is deterministic.
17
+
18
+ type TextRecord = { question: string; header: string; options: OptionRecord[]; multiple?: boolean; custom?: boolean }
19
+ type OptionRecord = { label: string; description: string }
20
+
21
+ export interface QuestionArgs {
22
+ questions: TextRecord[]
23
+ }
24
+
25
+ export interface QuestionSnapshot {
26
+ original: TextRecord[]
27
+ translated: TextRecord[]
28
+ }
29
+
30
+ export interface QuestionToolOutput {
31
+ title?: string
32
+ output?: string
33
+ metadata?: { answers?: readonly (readonly string[])[] } | Record<string, unknown>
34
+ }
35
+
36
+ function cloneQuestion(q: TextRecord): TextRecord {
37
+ return {
38
+ question: q.question,
39
+ header: q.header,
40
+ options: q.options.map((option) => ({ label: option.label, description: option.description })),
41
+ ...(q.multiple !== undefined ? { multiple: q.multiple } : {}),
42
+ ...(q.custom !== undefined ? { custom: q.custom } : {}),
43
+ }
44
+ }
45
+
46
+ export function snapshotQuestions(args: QuestionArgs): TextRecord[] {
47
+ return args.questions.map(cloneQuestion)
48
+ }
49
+
50
+ export function isQuestionArgs(value: unknown): value is QuestionArgs {
51
+ if (!value || typeof value !== "object") return false
52
+ const questions = (value as Record<string, unknown>).questions
53
+ if (!Array.isArray(questions)) return false
54
+ for (const q of questions) {
55
+ if (!q || typeof q !== "object") return false
56
+ const record = q as Record<string, unknown>
57
+ if (typeof record.question !== "string") return false
58
+ if (typeof record.header !== "string") return false
59
+ if (!Array.isArray(record.options)) return false
60
+ for (const opt of record.options) {
61
+ if (!opt || typeof opt !== "object") return false
62
+ const optRecord = opt as Record<string, unknown>
63
+ if (typeof optRecord.label !== "string") return false
64
+ if (typeof optRecord.description !== "string") return false
65
+ }
66
+ }
67
+ return true
68
+ }
69
+
70
+ async function assignTranslation(
71
+ container: Record<string, string>,
72
+ key: string,
73
+ translate: (text: string) => Promise<string>,
74
+ ): Promise<void> {
75
+ const original = container[key]
76
+ if (!original || original.length === 0) return
77
+ const translated = await translate(original)
78
+ container[key] = translated
79
+ }
80
+
81
+ // Translate every display-facing string in `args` in parallel. Returns the
82
+ // snapshot of the translated form so the caller can pair it with the
83
+ // pre-translation snapshot taken with `snapshotQuestions`.
84
+ export async function translateQuestionArgs(
85
+ args: QuestionArgs,
86
+ translate: (text: string) => Promise<string>,
87
+ ): Promise<void> {
88
+ const jobs: Promise<void>[] = []
89
+
90
+ for (const q of args.questions) {
91
+ jobs.push(assignTranslation(q as unknown as Record<string, string>, "question", translate))
92
+ jobs.push(assignTranslation(q as unknown as Record<string, string>, "header", translate))
93
+ for (const option of q.options) {
94
+ jobs.push(assignTranslation(option as unknown as Record<string, string>, "label", translate))
95
+ jobs.push(assignTranslation(option as unknown as Record<string, string>, "description", translate))
96
+ }
97
+ }
98
+
99
+ await Promise.all(jobs)
100
+ }
101
+
102
+ // Given the user-selected labels (`answers`), find the matching translated
103
+ // option and return its original English label. If no match (e.g. a custom
104
+ // free-text answer), return the label verbatim so the LLM still sees what
105
+ // the user actually typed.
106
+ function restoreLabel(
107
+ selectedLabel: string,
108
+ translatedOptions: readonly OptionRecord[],
109
+ originalOptions: readonly OptionRecord[],
110
+ ): string {
111
+ const idx = translatedOptions.findIndex((option) => option.label === selectedLabel)
112
+ if (idx < 0) return selectedLabel
113
+ return originalOptions[idx]?.label ?? selectedLabel
114
+ }
115
+
116
+ // Reconstruct the exact output string the question tool would have produced
117
+ // if it had been called with the original English args. Mirrors the format
118
+ // in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
119
+ export function buildRestoredOutput(
120
+ original: readonly TextRecord[],
121
+ translated: readonly TextRecord[],
122
+ answers: readonly (readonly string[])[],
123
+ ): string {
124
+ const formatted = original
125
+ .map((q, i) => {
126
+ const selected = answers[i] ?? []
127
+ const translatedOptions = translated[i]?.options ?? []
128
+ const originalOptions = q.options
129
+ const restored = selected.map((label) => restoreLabel(label, translatedOptions, originalOptions))
130
+ const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
131
+ return `"${q.question}"="${rendered}"`
132
+ })
133
+ .join(", ")
134
+ return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
135
+ }
136
+
137
+ export function restoreQuestionOutput(output: QuestionToolOutput, snapshot: QuestionSnapshot): void {
138
+ if (typeof output.output !== "string") return
139
+ const answersRaw = (output.metadata as { answers?: readonly (readonly string[])[] } | undefined)?.answers
140
+ const answers = Array.isArray(answersRaw) ? answersRaw : []
141
+ output.output = buildRestoredOutput(snapshot.original, snapshot.translated, answers)
142
+ }