From 6fd7cbcba4a8abe33f6ba17c49cd2706520c57bc Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 20:01:16 +0900 Subject: [PATCH 01/11] feat(gui): show the real prompt text, and let the dialog turn a layer off The dialog claimed Codex "does not expose the assembled text of a built-in layer". That was simply wrong: Codex is open source and ships `codex debug prompt-input`, which renders the model-visible input list as JSON. A new bounded probe reads it and the dialog shows the actual bytes - 10,740 for skills, 15,354 for AGENTS.md on this machine. What is still absent is now named instead of blanket-denied. Three different causes, three different sentences: the base prompt travels outside the printable list, a diff-rendered section can legitimately send nothing on the sampled turn, and the probe itself can fail. Saying which one applies is the difference between a limit and a bug. AGENTS.md needed special handling: it is not wrapped in a tag like the other sections, it arrives as a plain "# AGENTS.md instructions for " block, so tag matching alone reported it as unrendered while its text sat in the same message. The switch moved into the dialog as well. Opening a layer to read it and then having to close the dialog to act on it is a dead end, and the row and the dialog are the same control over the same key. Rows also stop rendering a raw checkbox. They reached for a .switch class that styles a different element; the dashboard's control is .toggle with a knob. Tests follow the markup: aria-checked on a button, not .checked on an input. The probe is lazy - it shells out, so a user who never opens a layer never pays for it - and fail-soft. --- .../codex-set/PromptLayerDialog.tsx | 45 +++++- gui/src/i18n/de.ts | 7 +- gui/src/i18n/en.ts | 7 +- gui/src/i18n/fr.ts | 7 +- gui/src/i18n/ja.ts | 7 +- gui/src/i18n/ko.ts | 7 +- gui/src/i18n/ru.ts | 7 +- gui/src/i18n/tr.ts | 7 +- gui/src/i18n/zh-TW.ts | 7 +- gui/src/i18n/zh.ts | 7 +- gui/src/pages/codex-set-prompt.tsx | 27 +++- gui/src/styles-codex-set.css | 11 ++ gui/tests/codex-set-prompt-layers.test.tsx | 12 +- src/codex/prompt-text-probe.ts | 139 ++++++++++++++++++ src/server/management/codex-prompt-routes.ts | 10 ++ 15 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 src/codex/prompt-text-probe.ts diff --git a/gui/src/components/codex-set/PromptLayerDialog.tsx b/gui/src/components/codex-set/PromptLayerDialog.tsx index 202756ff47d..95c1c83c34a 100644 --- a/gui/src/components/codex-set/PromptLayerDialog.tsx +++ b/gui/src/components/codex-set/PromptLayerDialog.tsx @@ -17,10 +17,17 @@ import { CLASS_LABEL_KEYS, LAYER_ABOUT_KEYS, LAYER_CONDITION_KEYS, LAYER_LABEL_K export default function PromptLayerDialog({ descriptor, toggle, + text, + busy, + onToggle, onClose, }: { descriptor: LayerDescriptorDto; toggle: ToggleStateDto | undefined; + /** Rendered source text for this layer, when the probe could read it. */ + text: { text: string | null; reason: string; bytes: number } | undefined; + busy: boolean; + onToggle: (id: string, enabled: boolean) => void; onClose: () => void; }) { const t = useT(); @@ -62,6 +69,24 @@ export default function PromptLayerDialog({
event.stopPropagation()} role="document">

{labelKey ? t(labelKey) : descriptor.id}

+ {/* + The switch belongs here too. Opening a layer to read it and then having + to close the dialog to act on it is a dead end, and the row and the + dialog are the same control over the same key. + */} + {descriptor.class === "config-toggle" && toggle && ( + + )}
@@ -110,7 +135,25 @@ export default function PromptLayerDialog({ Stated, not implied. A dialog that simply omitted the body would read as a loading failure to anyone who expected one. */} -

{t("codexSet.dialog.noRenderedText")}

+ {text?.reason === "ok" && text.text ? ( + <> +
+ {t("codexSet.dialog.sourceText")} + {t("codexSet.dialog.sourceBytes", { bytes: text.bytes })} +
+
{text.text}
+ + ) : ( + // Each absent case has a different cause, and saying which one is the + // difference between a limit and a bug. +

+ {text?.reason === "not-rendered" + ? t("codexSet.dialog.notRendered") + : text?.reason === "not-exposed" + ? t("codexSet.dialog.notExposed") + : t("codexSet.dialog.textUnavailable")} +

+ )}
); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 352f8c5d405..ef50048b28a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -932,7 +932,12 @@ export const de: Record = { "codexSet.dialog.key": "Konfigurationsschlüssel", "codexSet.dialog.fileValue": "Wert in dieser Datei", "codexSet.dialog.absentDefault": "nicht festgelegt (Standard: {value})", - "codexSet.dialog.noRenderedText": "Codex legt den zusammengesetzten Text einer integrierten Ebene nicht offen. Dieser Dialog beschreibt daher die Ebene und nennt ihren Schlüssel, statt ihren Inhalt anzuzeigen.", +"codexSet.dialog.noRenderedText": "Codex legt den zusammengesetzten Text einer integrierten Ebene nicht offen. Dieser Dialog beschreibt daher die Ebene und nennt ihren Schlüssel, statt ihren Inhalt anzuzeigen.", + "codexSet.dialog.sourceText": "An das Modell gesendeter Text", + "codexSet.dialog.sourceBytes": "{bytes} Byte", + "codexSet.dialog.notRendered": "In der gelesenen Runde hat diese Ebene nichts gesendet. Abschnitte werden nur bei Änderungen erneut gesendet, daher kann eine einzelne Stichprobe sie nicht enthalten.", + "codexSet.dialog.notExposed": "Der Basis-Prompt läuft außerhalb der Nachrichtenliste, die Codex ausgeben kann, und lässt sich hier nicht anzeigen. Ersetzen ist über model_instructions_file möglich.", + "codexSet.dialog.textUnavailable": "Der Codex-Prompt konnte auf diesem Rechner nicht gelesen werden, daher ist der Text nicht verfügbar.", "codexSet.class.base": "Basisanweisungen", "codexSet.class.config-toggle": "Hier umschaltbar", "codexSet.class.feature-gated": "Feature-gesteuert", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 14450fb1c65..0b317c5cf9a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1426,7 +1426,12 @@ export const en = { "codexSet.dialog.key": "Config key", "codexSet.dialog.fileValue": "Value in this file", "codexSet.dialog.absentDefault": "not set (defaults to {value})", - "codexSet.dialog.noRenderedText": "Codex does not expose the assembled text of a built-in layer, so this dialog describes the layer and names its key rather than showing its contents.", +"codexSet.dialog.noRenderedText": "Codex does not expose the assembled text of a built-in layer, so this dialog describes the layer and names its key rather than showing its contents.", + "codexSet.dialog.sourceText": "Text sent to the model", + "codexSet.dialog.sourceBytes": "{bytes} bytes", + "codexSet.dialog.notRendered": "This layer sent nothing on the turn we read. Sections are only re-sent when they change, so an unchanged layer is absent from a single sample.", + "codexSet.dialog.notExposed": "The base prompt travels outside the message list Codex can print, so it cannot be shown here. Replacing it is possible through model_instructions_file.", + "codexSet.dialog.textUnavailable": "The Codex prompt could not be read on this machine, so the text is unavailable.", "codexSet.class.base": "Base instructions", "codexSet.class.config-toggle": "Switchable here", "codexSet.class.feature-gated": "Feature-gated", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index bcc1e45dcad..9a7a565b9c7 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1399,7 +1399,12 @@ export const fr: Record = { "codexSet.dialog.key": "Clé de configuration", "codexSet.dialog.fileValue": "Valeur dans ce fichier", "codexSet.dialog.absentDefault": "non défini (valeur par défaut : {value})", - "codexSet.dialog.noRenderedText": "Codex n’expose pas le texte assemblé d’une couche intégrée. Cette boîte de dialogue décrit donc la couche et indique sa clé au lieu d’afficher son contenu.", +"codexSet.dialog.noRenderedText": "Codex n’expose pas le texte assemblé d’une couche intégrée. Cette boîte de dialogue décrit donc la couche et indique sa clé au lieu d’afficher son contenu.", + "codexSet.dialog.sourceText": "Texte envoyé au modèle", + "codexSet.dialog.sourceBytes": "{bytes} octets", + "codexSet.dialog.notRendered": "Lors du tour que nous avons lu, cette couche n'a rien envoyé. Les sections ne sont renvoyées qu'en cas de changement : un seul échantillon peut donc ne pas la contenir.", + "codexSet.dialog.notExposed": "L'invite de base circule en dehors de la liste de messages que Codex peut afficher ; elle ne peut donc pas être montrée ici. On peut la remplacer via model_instructions_file.", + "codexSet.dialog.textUnavailable": "L'invite Codex n'a pas pu être lue sur cette machine, le texte est donc indisponible.", "codexSet.class.base": "Instructions de base", "codexSet.class.config-toggle": "Modifiable ici", "codexSet.class.feature-gated": "Contrôlé par une fonctionnalité", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d0ceed70828..ae70cdb2e85 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1359,7 +1359,12 @@ export const ja: Record = { "codexSet.dialog.key": "設定キー", "codexSet.dialog.fileValue": "このファイルの値", "codexSet.dialog.absentDefault": "未設定(デフォルト: {value})", - "codexSet.dialog.noRenderedText": "Codex は組み立て済みの組み込みレイヤー本文を公開していません。そのため、このダイアログには内容ではなくレイヤーの説明とキーを表示します。", +"codexSet.dialog.noRenderedText": "Codex は組み立て済みの組み込みレイヤー本文を公開していません。そのため、このダイアログには内容ではなくレイヤーの説明とキーを表示します。", + "codexSet.dialog.sourceText": "モデルに送られる原文", + "codexSet.dialog.sourceBytes": "{bytes} バイト", + "codexSet.dialog.notRendered": "確認したターンでは、このレイヤーは何も送信していません。各セクションは内容が変わったときだけ再送されるため、一度の確認では欠けて見えることがあります。", + "codexSet.dialog.notExposed": "基本プロンプトは Codex が出力するメッセージ一覧の外を通るため、ここには表示できません。model_instructions_file で置き換えることは可能です。", + "codexSet.dialog.textUnavailable": "このマシンでは Codex のプロンプトを読み取れず、原文を表示できません。", "codexSet.class.base": "基本指示", "codexSet.class.config-toggle": "ここで切り替え可能", "codexSet.class.feature-gated": "機能フラグ制御", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b4b1143df3f..08ccd8a73b0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -958,7 +958,12 @@ export const ko: Record = { "codexSet.dialog.key": "설정 키", "codexSet.dialog.fileValue": "이 파일의 값", "codexSet.dialog.absentDefault": "설정되지 않음(기본값: {value})", - "codexSet.dialog.noRenderedText": "Codex는 기본 제공 레이어의 조합된 텍스트를 공개하지 않습니다. 따라서 이 대화상자에는 내용 대신 레이어 설명과 키를 표시합니다.", +"codexSet.dialog.noRenderedText": "Codex는 기본 제공 레이어의 조합된 텍스트를 공개하지 않습니다. 따라서 이 대화상자에는 내용 대신 레이어 설명과 키를 표시합니다.", + "codexSet.dialog.sourceText": "모델에 전달되는 원문", + "codexSet.dialog.sourceBytes": "{bytes}바이트", + "codexSet.dialog.notRendered": "확인한 턴에서는 이 레이어가 아무것도 보내지 않았습니다. 각 섹션은 내용이 바뀔 때만 다시 전송되므로, 한 번 확인한 것만으로는 빠져 보일 수 있습니다.", + "codexSet.dialog.notExposed": "기본 프롬프트는 Codex가 출력하는 메시지 목록 밖으로 전달되어 여기서 보여줄 수 없습니다. 대신 model_instructions_file로 교체할 수 있습니다.", + "codexSet.dialog.textUnavailable": "이 컴퓨터에서 Codex 프롬프트를 읽지 못해 원문을 표시할 수 없습니다.", "codexSet.class.base": "기본 지침", "codexSet.class.config-toggle": "여기서 전환 가능", "codexSet.class.feature-gated": "기능 플래그 적용", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8c7944f296b..a4c2aa93bc0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1410,7 +1410,12 @@ export const ru: Record = { "codexSet.dialog.key": "Ключ конфигурации", "codexSet.dialog.fileValue": "Значение в этом файле", "codexSet.dialog.absentDefault": "не задано (по умолчанию: {value})", - "codexSet.dialog.noRenderedText": "Codex не раскрывает собранный текст встроенного слоя, поэтому здесь приведены описание слоя и его ключ, а не содержимое.", +"codexSet.dialog.noRenderedText": "Codex не раскрывает собранный текст встроенного слоя, поэтому здесь приведены описание слоя и его ключ, а не содержимое.", + "codexSet.dialog.sourceText": "Текст, отправляемый модели", + "codexSet.dialog.sourceBytes": "{bytes} байт", + "codexSet.dialog.notRendered": "На прочитанном нами шаге этот слой ничего не отправил. Разделы пересылаются только при изменении, поэтому в одной выборке слой может отсутствовать.", + "codexSet.dialog.notExposed": "Базовый промпт передаётся вне списка сообщений, который Codex может напечатать, поэтому показать его здесь нельзя. Заменить его можно через model_instructions_file.", + "codexSet.dialog.textUnavailable": "На этой машине не удалось прочитать промпт Codex, поэтому текст недоступен.", "codexSet.class.base": "Базовые инструкции", "codexSet.class.config-toggle": "Переключается здесь", "codexSet.class.feature-gated": "Управляется флагом функции", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ed220a7635c..b703a75cbcc 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1417,7 +1417,12 @@ export const tr: Record = { "codexSet.dialog.key": "Yapılandırma anahtarı", "codexSet.dialog.fileValue": "Bu dosyadaki değer", "codexSet.dialog.absentDefault": "ayarlanmamış (varsayılan: {value})", - "codexSet.dialog.noRenderedText": "Codex yerleşik bir katmanın birleştirilmiş metnini göstermez. Bu nedenle bu iletişim kutusu içeriği göstermek yerine katmanı açıklar ve anahtarını belirtir.", +"codexSet.dialog.noRenderedText": "Codex yerleşik bir katmanın birleştirilmiş metnini göstermez. Bu nedenle bu iletişim kutusu içeriği göstermek yerine katmanı açıklar ve anahtarını belirtir.", + "codexSet.dialog.sourceText": "Modele gönderilen metin", + "codexSet.dialog.sourceBytes": "{bytes} bayt", + "codexSet.dialog.notRendered": "Okuduğumuz turda bu katman hiçbir şey göndermedi. Bölümler yalnızca değiştiklerinde yeniden gönderilir, bu yüzden tek bir örnekte görünmeyebilir.", + "codexSet.dialog.notExposed": "Temel istem, Codex'in yazdırabildiği mesaj listesinin dışından geçtiği için burada gösterilemez. model_instructions_file ile değiştirilebilir.", + "codexSet.dialog.textUnavailable": "Bu makinede Codex istemi okunamadı, bu yüzden metin kullanılamıyor.", "codexSet.class.base": "Temel talimatlar", "codexSet.class.config-toggle": "Buradan değiştirilebilir", "codexSet.class.feature-gated": "Özellik bayraklı", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9074f2a1d1c..bbbb1dd414f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1192,7 +1192,12 @@ export const zhTW: Record = { "codexSet.dialog.key": "設定鍵", "codexSet.dialog.fileValue": "此檔案中的值", "codexSet.dialog.absentDefault": "未設定(預設為 {value})", - "codexSet.dialog.noRenderedText": "Codex 不會公開內建層組合後的文字,因此此對話框只說明該層並列出其設定鍵,不顯示具體內容。", +"codexSet.dialog.noRenderedText": "Codex 不會公開內建層組合後的文字,因此此對話框只說明該層並列出其設定鍵,不顯示具體內容。", + "codexSet.dialog.sourceText": "傳送給模型的原文", + "codexSet.dialog.sourceBytes": "{bytes} 位元組", + "codexSet.dialog.notRendered": "在我們讀取的那一輪中,此層沒有傳送任何內容。各區段只在內容變更時才會重新傳送,因此單次取樣可能看不到它。", + "codexSet.dialog.notExposed": "基礎提示詞不在 Codex 可列印的訊息清單中傳遞,因此無法在此顯示。可以透過 model_instructions_file 取代它。", + "codexSet.dialog.textUnavailable": "本機無法讀取 Codex 提示詞,因此無法顯示原文。", "codexSet.class.base": "基礎指令", "codexSet.class.config-toggle": "可在此切換", "codexSet.class.feature-gated": "功能開關控制", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a659f7ef469..dd0f930ad3d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -951,7 +951,12 @@ export const zh: Record = { "codexSet.dialog.key": "配置键", "codexSet.dialog.fileValue": "此文件中的值", "codexSet.dialog.absentDefault": "未设置(默认为 {value})", - "codexSet.dialog.noRenderedText": "Codex 不会公开内置层组装后的文本,因此此对话框仅说明该层并列出其配置键,不显示具体内容。", +"codexSet.dialog.noRenderedText": "Codex 不会公开内置层组装后的文本,因此此对话框仅说明该层并列出其配置键,不显示具体内容。", + "codexSet.dialog.sourceText": "发送给模型的原文", + "codexSet.dialog.sourceBytes": "{bytes} 字节", + "codexSet.dialog.notRendered": "在我们读取的那一轮中,此层没有发送任何内容。各部分仅在内容变化时才会重新发送,因此单次采样可能看不到它。", + "codexSet.dialog.notExposed": "基础提示词不在 Codex 可打印的消息列表中传递,因此无法在此显示。可以通过 model_instructions_file 替换它。", + "codexSet.dialog.textUnavailable": "本机无法读取 Codex 提示词,因此无法显示原文。", "codexSet.class.base": "基础指令", "codexSet.class.config-toggle": "可在此切换", "codexSet.class.feature-gated": "功能开关控制", diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index bb3f7c2599d..e2f35acfa9a 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useT } from "../i18n/shared"; import { useDataSurface } from "../data-surface"; import { setClientResourceData } from "../client-resource"; @@ -120,6 +120,12 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { * directly. */ const [presetSeed, setPresetSeed] = useState<{ title: string; body: string } | null>(null); + /** + * Rendered layer text, fetched lazily on first dialog open. It shells out to + * `codex debug prompt-input`, so it is not part of the panel load - a user who + * never opens a layer never pays for it. + */ + const [layerText, setLayerText] = useState<{ ok: boolean; layers?: Record } | null>(null); const load = useCallback(async (signal: AbortSignal): Promise => { const res = await fetch(apiBase + "/api/codex-prompt", { signal }); @@ -304,6 +310,22 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); const openDescriptor = rows.find(d => d.id === openLayerId) ?? null; + useEffect(() => { + if (openLayerId === null || layerText !== null) return; + let cancelled = false; + void (async () => { + try { + const res = await fetch(apiBase + "/api/codex-prompt/text"); + const body = await res.json() as { ok: boolean; layers?: Record }; + if (!cancelled) setLayerText(body); + } catch { + // A failed probe is a missing body, not a broken page. + if (!cancelled) setLayerText({ ok: false }); + } + })(); + return () => { cancelled = true; }; + }, [openLayerId, layerText, apiBase]); + return (
@@ -389,6 +411,9 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { s.id === openDescriptor.id)} + text={layerText?.layers?.[openDescriptor.id]} + busy={busyId !== null} + onToggle={(id, enabled) => { void onToggle(id, enabled); }} onClose={() => setOpenLayerId(null)} /> )} diff --git a/gui/src/styles-codex-set.css b/gui/src/styles-codex-set.css index daab0479326..8d4b856ddca 100644 --- a/gui/src/styles-codex-set.css +++ b/gui/src/styles-codex-set.css @@ -72,6 +72,17 @@ border-top: 1px solid var(--border); } +/* The real prompt text. Scrollable rather than clipped: some layers are 15 KB. */ +.codex-set-layer-dialog__text { + margin-top: 8px; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.5; +} + /* Custom layers ---------------------------------------------------------- */ .codex-set-custom { diff --git a/gui/tests/codex-set-prompt-layers.test.tsx b/gui/tests/codex-set-prompt-layers.test.tsx index 75cb1214e09..5f18d1f22a8 100644 --- a/gui/tests/codex-set-prompt-layers.test.tsx +++ b/gui/tests/codex-set-prompt-layers.test.tsx @@ -209,9 +209,11 @@ test("7. the dialog opens read-only: no textarea, no save", async () => { await act(async () => { root.unmount(); }); }); -test("9. the dialog says plainly that no rendered layer text exists", async () => { - // Omitting the body silently would read as a loading failure to anyone who - // expected one. Codex exposes no API for it, so the dialog states that. +test("9. the dialog names WHY text is missing rather than omitting it silently", async () => { + // Codex is open source and `codex debug prompt-input` renders the model-visible + // input list, so the old "no API exists" claim was wrong. What remains true is + // that a body can still be absent - unread, unrendered on this turn, or carried + // outside the printable list - and each case has its own sentence. stubRoutes(() => json(snapshot())); const { container, root } = await mount(); await act(async () => { @@ -223,7 +225,9 @@ test("9. the dialog says plainly that no rendered layer text exists", async () = // The element existing is not the contract; saying so is. An empty div would // satisfy a presence check while telling the reader nothing. expect((notice!.textContent ?? "").length).toBeGreaterThan(40); - expect(notice!.textContent).toContain("does not expose"); + // The probe is not stubbed here, so this asserts the shape of the answer rather + // than one branch: a reason is always given, and it is a sentence. + expect(notice!.textContent).toMatch(/could not be read|sent nothing|travels outside/); await act(async () => { root.unmount(); }); }); diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts new file mode 100644 index 00000000000..5be249699ee --- /dev/null +++ b/src/codex/prompt-text-probe.ts @@ -0,0 +1,139 @@ +/** + * Reading the prompt text Codex actually assembles. + * + * The read-only dialog used to say Codex "does not expose" a layer's text. That + * was wrong: Codex is open source and ships `codex debug prompt-input`, which + * renders the model-visible input list as JSON. Reading it is the difference + * between describing a layer and showing it. + * + * What this does NOT cover, stated rather than implied: + * + * - `base-instructions` is absent. `prompt_debug.rs` returns `prompt.input` and + * discards `base_instructions`, so the base prompt never appears here. + * - World-state sections are DIFF-rendered (`add_section` registers state, it does + * not emit text). A section that renders nothing on a first turn is missing + * from this output even though its layer exists. + * - The output reflects the invoking directory and the current config, not a + * universal prompt. + */ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +/** Section id in the DTO -> the tag `world_state` renders it under. */ +const LAYER_SECTION_TAGS: Record = { + skills: "skills_instructions", + apps: "apps_instructions", + plugins: "plugins_instructions", + environment: "environment_context", + permissions: "permissions", + collaboration: "collaboration_mode", + "agents-md": "__agents_md", + // Synthetic key: the project doc has no tag of its own (see extractSections). + personality: "personality", + realtime: "realtime", +}; + +export interface LayerText { + /** Rendered text, when this layer produced a section on the probed turn. */ + text: string | null; + /** Why the text is absent, when it is. */ + reason: "ok" | "not-rendered" | "not-exposed" | "unavailable"; + bytes: number; +} + +export interface PromptTextProbe { + ok: boolean; + /** The directory the probe ran in; sections depend on it. */ + cwd: string; + layers: Record; + detail?: string; +} + +function resolveCodexBinary(): string | null { + const candidates = [ + join(homedir(), ".codex/packages/standalone/current/bin/codex"), + join(homedir(), ".local/bin/codex"), + "/usr/local/bin/codex", + "/opt/homebrew/bin/codex", + ]; + return candidates.find(path => existsSync(path)) ?? null; +} + +function runProbe(binary: string, cwd: string, timeoutMs: number): Promise { + return new Promise(resolve => { + // A probe must never hang the management API: it is bounded, killed on + // timeout, and any failure degrades to "unavailable" rather than an error page. + const child = spawn(binary, ["debug", "prompt-input"], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }); + let out = ""; + const timer = setTimeout(() => { child.kill("SIGKILL"); resolve(null); }, timeoutMs); + child.stdout?.on("data", chunk => { out += String(chunk); }); + child.on("error", () => { clearTimeout(timer); resolve(null); }); + child.on("close", code => { + clearTimeout(timer); + resolve(code === 0 ? out : null); + }); + }); +} + +/** Pull every `...` section out of the rendered developer messages. */ +function extractSections(raw: string): Map { + const sections = new Map(); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return sections; + } + if (!Array.isArray(parsed)) return sections; + for (const item of parsed) { + const content = (item as { content?: unknown }).content; + if (!Array.isArray(content)) continue; + const text = content.map(part => String((part as { text?: unknown }).text ?? "")).join(""); + for (const match of text.matchAll(/<([a-z_]+)>([\s\S]*?)<\/\1>/g)) { + sections.set(match[1]!, match[2]!.trim()); + } + // AGENTS.md is NOT tagged: it arrives as a plain `# AGENTS.md instructions + // for ` block among the tagged sections. Matching only on tags would + // report the layer as unrendered while its text sits in the same message. + const projectDoc = /(^|\n)(# AGENTS\.md instructions for [\s\S]*)$/.exec( + text.replace(/<([a-z_]+)>[\s\S]*?<\/\1>/g, ""), + ); + if (projectDoc) sections.set("__agents_md", projectDoc[2]!.trim()); + } + return sections; +} + +/** + * Probe once and map every known layer to its rendered text. + * + * `cwd` matters: AGENTS.md and environment context are directory-dependent, so a + * probe from the wrong place would describe a prompt the user never sees. + */ +export async function probePromptText(cwd: string, timeoutMs = 15_000): Promise { + const binary = resolveCodexBinary(); + if (!binary) { + return { ok: false, cwd, layers: {}, detail: "codex binary not found" }; + } + const raw = await runProbe(binary, cwd, timeoutMs); + if (raw === null) { + return { ok: false, cwd, layers: {}, detail: "codex debug prompt-input failed" }; + } + const sections = extractSections(raw); + const layers: Record = {}; + for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) { + const text = sections.get(tag) ?? null; + layers[layerId] = text === null + // Registered but not rendered on this turn, which is an ordinary state for + // a diff-rendered section rather than an error. + ? { text: null, reason: "not-rendered", bytes: 0 } + : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") }; + } + // The base prompt travels outside prompt.input and cannot be read this way. + layers["base-instructions"] = { text: null, reason: "not-exposed", bytes: 0 }; + return { ok: true, cwd, layers }; +} diff --git a/src/server/management/codex-prompt-routes.ts b/src/server/management/codex-prompt-routes.ts index fa73850c285..87171e8aae3 100644 --- a/src/server/management/codex-prompt-routes.ts +++ b/src/server/management/codex-prompt-routes.ts @@ -268,6 +268,16 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise Date: Wed, 26 Aug 2026 20:05:44 +0900 Subject: [PATCH 02/11] feat(gui): show what each layer costs, where the decision is made This is a prompt-budget page, and it showed fifteen rows of identical visual weight with no indication that Skills costs 10 KB while Environment context costs 278 bytes. Size now sits on the row, so a user deciding what to keep can see what they are paying for instead of opening each layer to find out. That moved the probe from first-dialog-open to panel mount. It costs one bounded spawn on a page the user opened specifically to manage prompts. Density follows the surface: this is an operator list of fifteen rows, not a settings page with three, so row padding drops from 10px to 7px and rows that can actually be acted on carry more type weight than rows that only report. The research lane behind this (Figma, Photoshop, Zapier, Atlassian, W3C APG) agrees on the same point for ordered stacks - order and weight must be visible, not inferred. Test follows the new request shape: the panel now issues two fetches on mount, so the cold-skeleton case holds only the snapshot open and lets the size probe answer immediately. Holding both would have deadlocked rather than measured anything. --- .../components/codex-set/PromptLayerRow.tsx | 14 ++++++++++++++ gui/src/pages/codex-set-prompt.tsx | 8 ++++++-- gui/src/styles-codex-set.css | 18 +++++++++++++++++- gui/tests/codex-set-prompt-layers.test.tsx | 6 +++++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/gui/src/components/codex-set/PromptLayerRow.tsx b/gui/src/components/codex-set/PromptLayerRow.tsx index 5c678f2c531..5a6098ab95e 100644 --- a/gui/src/components/codex-set/PromptLayerRow.tsx +++ b/gui/src/components/codex-set/PromptLayerRow.tsx @@ -24,6 +24,7 @@ import { LAYER_LABEL_KEYS } from "./prompt-layer-copy"; export default function PromptLayerRow({ descriptor, toggle, + bytes, busy, writesRefused, onToggle, @@ -31,6 +32,8 @@ export default function PromptLayerRow({ }: { descriptor: LayerDescriptorDto; toggle: ToggleStateDto | undefined; + /** Measured size of what this layer actually sent, when known. */ + bytes: number | null; busy: boolean; writesRefused: boolean; onToggle: (id: string, enabled: boolean) => void; @@ -55,6 +58,17 @@ export default function PromptLayerRow({ {descriptor.key && {descriptor.key}} + {/* + Weight, shown where the decision happens. This is a prompt-budget page: a + layer that costs 15 KB and one that costs 300 bytes should not look + identical while the user decides which to keep. + */} + {bytes !== null && bytes > 0 && ( + + {bytes >= 1024 ? Math.round(bytes / 1024) + " KB" : bytes + " B"} + + )} + {descriptor.class === "config-toggle" ? ( // The dashboard's switch is a button with a knob, not a checkbox. A raw // renders as an actual checkbox here because the diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index e2f35acfa9a..daced745790 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -311,7 +311,10 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { const openDescriptor = rows.find(d => d.id === openLayerId) ?? null; useEffect(() => { - if (openLayerId === null || layerText !== null) return; + // Fetch on panel mount, not on first dialog open: size is what a user needs to + // DECIDE with, and a prompt-budget page that hides which layer costs 15 KB is + // asking them to guess. + if (layerText !== null) return; let cancelled = false; void (async () => { try { @@ -324,7 +327,7 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { } })(); return () => { cancelled = true; }; - }, [openLayerId, layerText, apiBase]); + }, [layerText, apiBase]); return (
@@ -390,6 +393,7 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { key={descriptor.id} descriptor={descriptor} toggle={snapshot?.toggles.find(s => s.id === descriptor.id)} + bytes={layerText?.layers?.[descriptor.id]?.bytes ?? null} busy={busyId === descriptor.id} writesRefused={snapshot?.readable === false} onToggle={(id, enabled) => { void onToggle(id, enabled); }} diff --git a/gui/src/styles-codex-set.css b/gui/src/styles-codex-set.css index 8d4b856ddca..df980c4298a 100644 --- a/gui/src/styles-codex-set.css +++ b/gui/src/styles-codex-set.css @@ -20,7 +20,9 @@ display: flex; align-items: center; gap: 10px; - padding: 10px 0; + /* D6 density: this is an operator surface with fifteen rows, not a settings page + with three. Consumer-app row height turned a scannable list into a scroll. */ + padding: 7px 0; border-bottom: 1px solid var(--border); } @@ -33,6 +35,20 @@ text-align: left; } +/* A row the user can act on carries more weight than one that only reports. */ +.codex-set-prompt__row[data-layer-class="config-toggle"] .codex-set-prompt__name { + font-weight: 500; +} + +.codex-set-prompt__bytes { + flex: 0 0 auto; + min-width: 48px; + text-align: right; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--faint); +} + .codex-set-prompt__key { flex: 1 1 auto; min-width: 0; diff --git a/gui/tests/codex-set-prompt-layers.test.tsx b/gui/tests/codex-set-prompt-layers.test.tsx index 5f18d1f22a8..0633071bcdb 100644 --- a/gui/tests/codex-set-prompt-layers.test.tsx +++ b/gui/tests/codex-set-prompt-layers.test.tsx @@ -277,7 +277,11 @@ test("11. a cold load shows the skeleton; a refresh keeps the rows visible", asy // The first read is held open, so the cold state is actually observed rather than // skipped past by an immediately-resolving stub. let release: (() => void) | null = null; - globalThis.fetch = (async () => { + // The panel now makes TWO requests on mount: the snapshot and the size probe. + // Only the snapshot is held open; the probe answers immediately so this measures + // the cold snapshot state rather than deadlocking on the probe. + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).includes("/text")) return json({ ok: true, layers: {} }); await new Promise(resolve => { release = resolve; }); return json(snapshot()); }) as typeof fetch; From 0ade9c580b558f54e6d635628ea6cb0f9b35fdc3 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 20:12:17 +0900 Subject: [PATCH 03/11] fix(codex): read the global Codex home only, and tell an empty file from a quiet layer The probe took a cwd from the query string, so an authenticated request could read any readable folder's AGENTS.md through the dashboard. It also described whichever directory the caller named rather than the configuration this page claims to show. It now runs in CODEX_HOME and nowhere else. An empty file is not a quiet layer. A 0-byte ~/.codex/AGENTS.md was reported as "sent nothing on the turn we read", which tells a user their layer is idle when the real answer is that the file they wrote is blank. That is a fourth state now, and it names the path so the user can go fill it in. The tag pattern missed because it assumed snake_case and that tag has a space in it. The layer had text the whole time and the panel said it sent nothing. Verified against live output rather than the Rust section IDs, which are identifiers and not the rendered tag. Zero sections from a zero-exit probe is a failed read, not fifteen silent layers, so it returns ok:false with a reason. The probe is now bounded in bytes as well as time, settles exactly once, destroys the stream on the way out, and decodes at the end - per-chunk String() corrupts any UTF-8 character that straddles a chunk boundary. Verified both directions by hand: 0 bytes reports empty-source with the path, and 18 bytes of real content reports ok with the text. --- .../codex-set/PromptLayerDialog.tsx | 17 ++-- gui/src/i18n/de.ts | 3 +- gui/src/i18n/en.ts | 3 +- gui/src/i18n/fr.ts | 3 +- gui/src/i18n/ja.ts | 3 +- gui/src/i18n/ko.ts | 3 +- gui/src/i18n/ru.ts | 3 +- gui/src/i18n/tr.ts | 3 +- gui/src/i18n/zh-TW.ts | 3 +- gui/src/i18n/zh.ts | 3 +- src/codex/prompt-text-probe.ts | 96 +++++++++++++++---- src/server/management/codex-prompt-routes.ts | 6 +- tests/codex-prompt-text-probe.test.ts | 43 +++++++++ 13 files changed, 152 insertions(+), 37 deletions(-) create mode 100644 tests/codex-prompt-text-probe.test.ts diff --git a/gui/src/components/codex-set/PromptLayerDialog.tsx b/gui/src/components/codex-set/PromptLayerDialog.tsx index 95c1c83c34a..3b8ae51920a 100644 --- a/gui/src/components/codex-set/PromptLayerDialog.tsx +++ b/gui/src/components/codex-set/PromptLayerDialog.tsx @@ -25,7 +25,7 @@ export default function PromptLayerDialog({ descriptor: LayerDescriptorDto; toggle: ToggleStateDto | undefined; /** Rendered source text for this layer, when the probe could read it. */ - text: { text: string | null; reason: string; bytes: number } | undefined; + text: { text: string | null; reason: string; bytes: number; sourcePath?: string } | undefined; busy: boolean; onToggle: (id: string, enabled: boolean) => void; onClose: () => void; @@ -147,11 +147,16 @@ export default function PromptLayerDialog({ // Each absent case has a different cause, and saying which one is the // difference between a limit and a bug.

- {text?.reason === "not-rendered" - ? t("codexSet.dialog.notRendered") - : text?.reason === "not-exposed" - ? t("codexSet.dialog.notExposed") - : t("codexSet.dialog.textUnavailable")} + {text?.reason === "empty-source" + // An existing but empty file is a fourth state. Reporting it as + // "sent nothing" would describe the layer as idle when the real + // answer is that the file the user wrote is blank. + ? t("codexSet.dialog.emptySource", { path: text.sourcePath ?? "" }) + : text?.reason === "not-rendered" + ? t("codexSet.dialog.notRendered") + : text?.reason === "not-exposed" + ? t("codexSet.dialog.notExposed") + : t("codexSet.dialog.textUnavailable")}

)}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index ef50048b28a..2d6b22ef597 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -935,7 +935,8 @@ export const de: Record = { "codexSet.dialog.noRenderedText": "Codex legt den zusammengesetzten Text einer integrierten Ebene nicht offen. Dieser Dialog beschreibt daher die Ebene und nennt ihren Schlüssel, statt ihren Inhalt anzuzeigen.", "codexSet.dialog.sourceText": "An das Modell gesendeter Text", "codexSet.dialog.sourceBytes": "{bytes} Byte", - "codexSet.dialog.notRendered": "In der gelesenen Runde hat diese Ebene nichts gesendet. Abschnitte werden nur bei Änderungen erneut gesendet, daher kann eine einzelne Stichprobe sie nicht enthalten.", +"codexSet.dialog.notRendered": "In der gelesenen Runde hat diese Ebene nichts gesendet. Abschnitte werden nur bei Änderungen erneut gesendet, daher kann eine einzelne Stichprobe sie nicht enthalten.", + "codexSet.dialog.emptySource": "Die Datei unter {path} existiert, ist aber leer, daher sendet diese Ebene nichts.", "codexSet.dialog.notExposed": "Der Basis-Prompt läuft außerhalb der Nachrichtenliste, die Codex ausgeben kann, und lässt sich hier nicht anzeigen. Ersetzen ist über model_instructions_file möglich.", "codexSet.dialog.textUnavailable": "Der Codex-Prompt konnte auf diesem Rechner nicht gelesen werden, daher ist der Text nicht verfügbar.", "codexSet.class.base": "Basisanweisungen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0b317c5cf9a..8002191aa6d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1429,7 +1429,8 @@ export const en = { "codexSet.dialog.noRenderedText": "Codex does not expose the assembled text of a built-in layer, so this dialog describes the layer and names its key rather than showing its contents.", "codexSet.dialog.sourceText": "Text sent to the model", "codexSet.dialog.sourceBytes": "{bytes} bytes", - "codexSet.dialog.notRendered": "This layer sent nothing on the turn we read. Sections are only re-sent when they change, so an unchanged layer is absent from a single sample.", +"codexSet.dialog.notRendered": "This layer sent nothing on the turn we read. Sections are only re-sent when they change, so an unchanged layer is absent from a single sample.", + "codexSet.dialog.emptySource": "The file at {path} exists but is empty, so this layer sends nothing.", "codexSet.dialog.notExposed": "The base prompt travels outside the message list Codex can print, so it cannot be shown here. Replacing it is possible through model_instructions_file.", "codexSet.dialog.textUnavailable": "The Codex prompt could not be read on this machine, so the text is unavailable.", "codexSet.class.base": "Base instructions", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 9a7a565b9c7..4f1068accfa 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1402,7 +1402,8 @@ export const fr: Record = { "codexSet.dialog.noRenderedText": "Codex n’expose pas le texte assemblé d’une couche intégrée. Cette boîte de dialogue décrit donc la couche et indique sa clé au lieu d’afficher son contenu.", "codexSet.dialog.sourceText": "Texte envoyé au modèle", "codexSet.dialog.sourceBytes": "{bytes} octets", - "codexSet.dialog.notRendered": "Lors du tour que nous avons lu, cette couche n'a rien envoyé. Les sections ne sont renvoyées qu'en cas de changement : un seul échantillon peut donc ne pas la contenir.", +"codexSet.dialog.notRendered": "Lors du tour que nous avons lu, cette couche n'a rien envoyé. Les sections ne sont renvoyées qu'en cas de changement : un seul échantillon peut donc ne pas la contenir.", + "codexSet.dialog.emptySource": "Le fichier {path} existe mais il est vide : cette couche n'envoie donc rien.", "codexSet.dialog.notExposed": "L'invite de base circule en dehors de la liste de messages que Codex peut afficher ; elle ne peut donc pas être montrée ici. On peut la remplacer via model_instructions_file.", "codexSet.dialog.textUnavailable": "L'invite Codex n'a pas pu être lue sur cette machine, le texte est donc indisponible.", "codexSet.class.base": "Instructions de base", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ae70cdb2e85..381feb5cf1c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1362,7 +1362,8 @@ export const ja: Record = { "codexSet.dialog.noRenderedText": "Codex は組み立て済みの組み込みレイヤー本文を公開していません。そのため、このダイアログには内容ではなくレイヤーの説明とキーを表示します。", "codexSet.dialog.sourceText": "モデルに送られる原文", "codexSet.dialog.sourceBytes": "{bytes} バイト", - "codexSet.dialog.notRendered": "確認したターンでは、このレイヤーは何も送信していません。各セクションは内容が変わったときだけ再送されるため、一度の確認では欠けて見えることがあります。", +"codexSet.dialog.notRendered": "確認したターンでは、このレイヤーは何も送信していません。各セクションは内容が変わったときだけ再送されるため、一度の確認では欠けて見えることがあります。", + "codexSet.dialog.emptySource": "{path} のファイルは存在しますが空のため、このレイヤーは何も送信しません。", "codexSet.dialog.notExposed": "基本プロンプトは Codex が出力するメッセージ一覧の外を通るため、ここには表示できません。model_instructions_file で置き換えることは可能です。", "codexSet.dialog.textUnavailable": "このマシンでは Codex のプロンプトを読み取れず、原文を表示できません。", "codexSet.class.base": "基本指示", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 08ccd8a73b0..ca383818aa8 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -961,7 +961,8 @@ export const ko: Record = { "codexSet.dialog.noRenderedText": "Codex는 기본 제공 레이어의 조합된 텍스트를 공개하지 않습니다. 따라서 이 대화상자에는 내용 대신 레이어 설명과 키를 표시합니다.", "codexSet.dialog.sourceText": "모델에 전달되는 원문", "codexSet.dialog.sourceBytes": "{bytes}바이트", - "codexSet.dialog.notRendered": "확인한 턴에서는 이 레이어가 아무것도 보내지 않았습니다. 각 섹션은 내용이 바뀔 때만 다시 전송되므로, 한 번 확인한 것만으로는 빠져 보일 수 있습니다.", +"codexSet.dialog.notRendered": "확인한 턴에서는 이 레이어가 아무것도 보내지 않았습니다. 각 섹션은 내용이 바뀔 때만 다시 전송되므로, 한 번 확인한 것만으로는 빠져 보일 수 있습니다.", + "codexSet.dialog.emptySource": "{path} 파일은 있지만 비어 있어서 이 레이어는 아무것도 보내지 않습니다.", "codexSet.dialog.notExposed": "기본 프롬프트는 Codex가 출력하는 메시지 목록 밖으로 전달되어 여기서 보여줄 수 없습니다. 대신 model_instructions_file로 교체할 수 있습니다.", "codexSet.dialog.textUnavailable": "이 컴퓨터에서 Codex 프롬프트를 읽지 못해 원문을 표시할 수 없습니다.", "codexSet.class.base": "기본 지침", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a4c2aa93bc0..1d9118f4ac7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1413,7 +1413,8 @@ export const ru: Record = { "codexSet.dialog.noRenderedText": "Codex не раскрывает собранный текст встроенного слоя, поэтому здесь приведены описание слоя и его ключ, а не содержимое.", "codexSet.dialog.sourceText": "Текст, отправляемый модели", "codexSet.dialog.sourceBytes": "{bytes} байт", - "codexSet.dialog.notRendered": "На прочитанном нами шаге этот слой ничего не отправил. Разделы пересылаются только при изменении, поэтому в одной выборке слой может отсутствовать.", +"codexSet.dialog.notRendered": "На прочитанном нами шаге этот слой ничего не отправил. Разделы пересылаются только при изменении, поэтому в одной выборке слой может отсутствовать.", + "codexSet.dialog.emptySource": "Файл {path} существует, но пуст, поэтому этот слой ничего не отправляет.", "codexSet.dialog.notExposed": "Базовый промпт передаётся вне списка сообщений, который Codex может напечатать, поэтому показать его здесь нельзя. Заменить его можно через model_instructions_file.", "codexSet.dialog.textUnavailable": "На этой машине не удалось прочитать промпт Codex, поэтому текст недоступен.", "codexSet.class.base": "Базовые инструкции", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b703a75cbcc..ca65c4f98e1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1420,7 +1420,8 @@ export const tr: Record = { "codexSet.dialog.noRenderedText": "Codex yerleşik bir katmanın birleştirilmiş metnini göstermez. Bu nedenle bu iletişim kutusu içeriği göstermek yerine katmanı açıklar ve anahtarını belirtir.", "codexSet.dialog.sourceText": "Modele gönderilen metin", "codexSet.dialog.sourceBytes": "{bytes} bayt", - "codexSet.dialog.notRendered": "Okuduğumuz turda bu katman hiçbir şey göndermedi. Bölümler yalnızca değiştiklerinde yeniden gönderilir, bu yüzden tek bir örnekte görünmeyebilir.", +"codexSet.dialog.notRendered": "Okuduğumuz turda bu katman hiçbir şey göndermedi. Bölümler yalnızca değiştiklerinde yeniden gönderilir, bu yüzden tek bir örnekte görünmeyebilir.", + "codexSet.dialog.emptySource": "{path} konumundaki dosya var ancak boş, bu yüzden bu katman hiçbir şey göndermiyor.", "codexSet.dialog.notExposed": "Temel istem, Codex'in yazdırabildiği mesaj listesinin dışından geçtiği için burada gösterilemez. model_instructions_file ile değiştirilebilir.", "codexSet.dialog.textUnavailable": "Bu makinede Codex istemi okunamadı, bu yüzden metin kullanılamıyor.", "codexSet.class.base": "Temel talimatlar", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index bbbb1dd414f..323db0cea36 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1195,7 +1195,8 @@ export const zhTW: Record = { "codexSet.dialog.noRenderedText": "Codex 不會公開內建層組合後的文字,因此此對話框只說明該層並列出其設定鍵,不顯示具體內容。", "codexSet.dialog.sourceText": "傳送給模型的原文", "codexSet.dialog.sourceBytes": "{bytes} 位元組", - "codexSet.dialog.notRendered": "在我們讀取的那一輪中,此層沒有傳送任何內容。各區段只在內容變更時才會重新傳送,因此單次取樣可能看不到它。", +"codexSet.dialog.notRendered": "在我們讀取的那一輪中,此層沒有傳送任何內容。各區段只在內容變更時才會重新傳送,因此單次取樣可能看不到它。", + "codexSet.dialog.emptySource": "{path} 檔案存在但為空,因此此層不會傳送任何內容。", "codexSet.dialog.notExposed": "基礎提示詞不在 Codex 可列印的訊息清單中傳遞,因此無法在此顯示。可以透過 model_instructions_file 取代它。", "codexSet.dialog.textUnavailable": "本機無法讀取 Codex 提示詞,因此無法顯示原文。", "codexSet.class.base": "基礎指令", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index dd0f930ad3d..8aff7af40c6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -954,7 +954,8 @@ export const zh: Record = { "codexSet.dialog.noRenderedText": "Codex 不会公开内置层组装后的文本,因此此对话框仅说明该层并列出其配置键,不显示具体内容。", "codexSet.dialog.sourceText": "发送给模型的原文", "codexSet.dialog.sourceBytes": "{bytes} 字节", - "codexSet.dialog.notRendered": "在我们读取的那一轮中,此层没有发送任何内容。各部分仅在内容变化时才会重新发送,因此单次采样可能看不到它。", +"codexSet.dialog.notRendered": "在我们读取的那一轮中,此层没有发送任何内容。各部分仅在内容变化时才会重新发送,因此单次采样可能看不到它。", + "codexSet.dialog.emptySource": "{path} 文件存在但为空,因此此层不会发送任何内容。", "codexSet.dialog.notExposed": "基础提示词不在 Codex 可打印的消息列表中传递,因此无法在此显示。可以通过 model_instructions_file 替换它。", "codexSet.dialog.textUnavailable": "本机无法读取 Codex 提示词,因此无法显示原文。", "codexSet.class.base": "基础指令", diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 5be249699ee..09bad01f3d7 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -17,9 +17,10 @@ * universal prompt. */ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; +import { resolveCodexHomeDir } from "./home"; /** Section id in the DTO -> the tag `world_state` renders it under. */ const LAYER_SECTION_TAGS: Record = { @@ -27,7 +28,9 @@ const LAYER_SECTION_TAGS: Record = { apps: "apps_instructions", plugins: "plugins_instructions", environment: "environment_context", - permissions: "permissions", + // Verified against live output, not guessed from the Rust section IDs: the + // rendered tag carries a space. + permissions: "permissions instructions", collaboration: "collaboration_mode", "agents-md": "__agents_md", // Synthetic key: the project doc has no tag of its own (see extractSections). @@ -39,14 +42,16 @@ export interface LayerText { /** Rendered text, when this layer produced a section on the probed turn. */ text: string | null; /** Why the text is absent, when it is. */ - reason: "ok" | "not-rendered" | "not-exposed" | "unavailable"; + reason: "ok" | "empty-source" | "not-rendered" | "not-exposed" | "unavailable"; bytes: number; + /** For `empty-source`: the file that exists but has nothing in it. */ + sourcePath?: string; } export interface PromptTextProbe { ok: boolean; - /** The directory the probe ran in; sections depend on it. */ - cwd: string; + /** The Codex home the probe reported on. */ + codexHome: string; layers: Record; detail?: string; } @@ -61,21 +66,42 @@ function resolveCodexBinary(): string | null { return candidates.find(path => existsSync(path)) ?? null; } +/** 8 MiB is far above any real prompt and far below anything that hurts the server. */ +const MAX_PROBE_OUTPUT_BYTES = 8 * 1024 * 1024; + function runProbe(binary: string, cwd: string, timeoutMs: number): Promise { return new Promise(resolve => { - // A probe must never hang the management API: it is bounded, killed on - // timeout, and any failure degrades to "unavailable" rather than an error page. + // A probe must never hang OR balloon the management API: it is bounded in + // time AND in bytes, and every failure degrades to "unavailable" rather than + // an error page. const child = spawn(binary, ["debug", "prompt-input"], { cwd, stdio: ["ignore", "pipe", "ignore"], }); - let out = ""; - const timer = setTimeout(() => { child.kill("SIGKILL"); resolve(null); }, timeoutMs); - child.stdout?.on("data", chunk => { out += String(chunk); }); - child.on("error", () => { clearTimeout(timer); resolve(null); }); - child.on("close", code => { + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + // One settlement path: a timeout that resolved before `close` used to leave + // the child streaming into a buffer nobody would ever read. + const settle = (value: string | null) => { + if (settled) return; + settled = true; clearTimeout(timer); - resolve(code === 0 ? out : null); + child.stdout?.destroy(); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + resolve(value); + }; + const timer = setTimeout(() => settle(null), timeoutMs); + child.stdout?.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_PROBE_OUTPUT_BYTES) { settle(null); return; } + chunks.push(chunk); + }); + child.on("error", () => settle(null)); + child.on("close", code => { + // Decode once, at the end: `String(chunk)` per chunk corrupts any UTF-8 + // character that straddles a chunk boundary. + settle(code === 0 ? Buffer.concat(chunks).toString("utf8") : null); }); }); } @@ -94,36 +120,52 @@ function extractSections(raw: string): Map { const content = (item as { content?: unknown }).content; if (!Array.isArray(content)) continue; const text = content.map(part => String((part as { text?: unknown }).text ?? "")).join(""); - for (const match of text.matchAll(/<([a-z_]+)>([\s\S]*?)<\/\1>/g)) { + // Tag names are NOT all snake_case: Codex renders `` + // with a space. A `[a-z_]+` pattern silently skipped it, and the layer was + // reported as "sent nothing" while its text sat right there. + for (const match of text.matchAll(/<([a-zA-Z_][a-zA-Z0-9_ -]*)>([\s\S]*?)<\/\1>/g)) { sections.set(match[1]!, match[2]!.trim()); } // AGENTS.md is NOT tagged: it arrives as a plain `# AGENTS.md instructions // for ` block among the tagged sections. Matching only on tags would // report the layer as unrendered while its text sits in the same message. const projectDoc = /(^|\n)(# AGENTS\.md instructions for [\s\S]*)$/.exec( - text.replace(/<([a-z_]+)>[\s\S]*?<\/\1>/g, ""), + text.replace(/<([a-zA-Z_][a-zA-Z0-9_ -]*)>[\s\S]*?<\/\1>/g, ""), ); if (projectDoc) sections.set("__agents_md", projectDoc[2]!.trim()); } return sections; } +/** Test seam: the extraction is the part worth pinning, not the spawn. */ +export const extractSectionsForTests = extractSections; + /** * Probe once and map every known layer to its rendered text. * * `cwd` matters: AGENTS.md and environment context are directory-dependent, so a * probe from the wrong place would describe a prompt the user never sees. */ -export async function probePromptText(cwd: string, timeoutMs = 15_000): Promise { +export async function probePromptText(timeoutMs = 15_000): Promise { + // The probe runs in CODEX_HOME, never in a caller-supplied directory. A `cwd` + // parameter let an authenticated request read any readable folder's AGENTS.md, + // and it also described a prompt that depends on where Codex happened to run. + // The global home is the one context this page can honestly report on. + const codexHome = resolveCodexHomeDir(); const binary = resolveCodexBinary(); if (!binary) { - return { ok: false, cwd, layers: {}, detail: "codex binary not found" }; + return { ok: false, codexHome, layers: {}, detail: "codex binary not found" }; } - const raw = await runProbe(binary, cwd, timeoutMs); + const raw = await runProbe(binary, codexHome, timeoutMs); if (raw === null) { - return { ok: false, cwd, layers: {}, detail: "codex debug prompt-input failed" }; + return { ok: false, codexHome, layers: {}, detail: "codex debug prompt-input failed" }; } const sections = extractSections(raw); + if (sections.size === 0) { + // Zero sections from a zero-exit probe means the output did not parse, which + // is a failed read - not fifteen layers that each chose to send nothing. + return { ok: false, codexHome, layers: {}, detail: "prompt output could not be parsed" }; + } const layers: Record = {}; for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) { const text = sections.get(tag) ?? null; @@ -133,7 +175,21 @@ export async function probePromptText(cwd: string, timeoutMs = 15_000): Promise< ? { text: null, reason: "not-rendered", bytes: 0 } : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") }; } + + // A file that exists and is empty is not the same as a layer that chose to send + // nothing. Reporting "sent nothing" for an empty AGENTS.md tells the user their + // layer is idle when the real answer is that the file they wrote is blank. + const agentsMdPath = join(codexHome, "AGENTS.md"); + if (layers["agents-md"]?.reason === "not-rendered" && existsSync(agentsMdPath)) { + try { + if (statSync(agentsMdPath).size === 0) { + layers["agents-md"] = { text: null, reason: "empty-source", bytes: 0, sourcePath: agentsMdPath }; + } + } catch { + // An unreadable file stays "not-rendered": we cannot claim it is empty. + } + } // The base prompt travels outside prompt.input and cannot be read this way. layers["base-instructions"] = { text: null, reason: "not-exposed", bytes: 0 }; - return { ok: true, cwd, layers }; + return { ok: true, codexHome, layers }; } diff --git a/src/server/management/codex-prompt-routes.ts b/src/server/management/codex-prompt-routes.ts index 87171e8aae3..b78717560e8 100644 --- a/src/server/management/codex-prompt-routes.ts +++ b/src/server/management/codex-prompt-routes.ts @@ -273,9 +273,11 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise { + test("a tag name containing a space is still matched", () => { + // Codex renders ``, with a space. A [a-z_]+ pattern + // skipped it silently and the layer was reported as having sent nothing. + const sections = extractSectionsForTests(message("Sandbox rules.")); + expect(sections.get("permissions instructions")).toBe("Sandbox rules."); + }); + + test("AGENTS.md is found even though it carries no tag of its own", () => { + const raw = message("S\n# AGENTS.md instructions for /home/u/.codex\n\nBe brief."); + const sections = extractSectionsForTests(raw); + expect(sections.get("skills_instructions")).toBe("S"); + expect(sections.get("__agents_md")).toContain("Be brief."); + }); + + test("malformed JSON yields no sections rather than inventing them", () => { + // The caller turns an empty map into a failed read. Returning a populated + // map here would have told the user fifteen layers each chose to send nothing. + expect(extractSectionsForTests("{not json").size).toBe(0); + expect(extractSectionsForTests("[]").size).toBe(0); + }); + + test("a section spanning multiple lines keeps its body", () => { + const sections = extractSectionsForTests(message("line one\nline two")); + expect(sections.get("apps_instructions")).toBe("line one\nline two"); + }); +}); + From c5ac1616123d3cf07483c2e1f3fc09032ccde5c5 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 20:15:32 +0900 Subject: [PATCH 04/11] fix(gui): drop measured text after a write, and stop implying a probe failed Measured text describes the configuration that produced it. Keeping it across a toggle showed a layer's old body and old byte count beside a switch that now reads off, so every write path invalidates it. Five inventory ids had no tag mapping and were simply absent from the probe result. The dialog read that absence as "unavailable", which claims the probe failed when it succeeded. They are listed explicitly as not-exposed now: having no mapping is a smaller and truer claim than having no answer. Two route tests pin the boundaries that were wrong an hour ago: /text takes no caller-supplied directory, and the probe is bounded in bytes as well as in time with a single settlement path and one decode at the end. --- gui/src/pages/codex-set-prompt.tsx | 16 +++++++++++++++- src/codex/prompt-text-probe.ts | 20 ++++++++++++++++++++ tests/codex-prompt-route.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index daced745790..557369598aa 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -125,7 +125,14 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { * `codex debug prompt-input`, so it is not part of the panel load - a user who * never opens a layer never pays for it. */ - const [layerText, setLayerText] = useState<{ ok: boolean; layers?: Record } | null>(null); + const [layerText, setLayerText] = useState<{ ok: boolean; layers?: Record } | null>(null); + + /** + * Drop the measured text after any write. It describes the configuration that + * just changed, so keeping it would show a layer's old body and old byte count + * beside a switch that now reads off. Nulling it re-triggers the fetch. + */ + const invalidateLayerText = useCallback(() => { setLayerText(null); }, []); const load = useCallback(async (signal: AbortSignal): Promise => { const res = await fetch(apiBase + "/api/codex-prompt", { signal }); @@ -164,6 +171,10 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { return; } setClientResourceData(resourceKey, body.snapshot); + invalidateLayerText(); + // The measured text belongs to the configuration that just changed. Keeping + // it would show a layer's old body next to a switch that now says off. + setLayerText(null); } catch { setError(t("codexSet.prompt.writeFailed")); resource.refresh(); @@ -209,6 +220,7 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { return false; } setClientResourceData(resourceKey, body.snapshot); + invalidateLayerText(); return true; } catch { setError(t("codexSet.prompt.writeFailed")); @@ -262,6 +274,7 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { } if (body.snapshot) { setClientResourceData(resourceKey, body.snapshot); + invalidateLayerText(); setAdoptPreview(null); return; } @@ -297,6 +310,7 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { } if (body.snapshot) setClientResourceData(resourceKey, body.snapshot); else resource.refresh(); + invalidateLayerText(); } catch { setError(t("codexSet.prompt.repairFailed")); } finally { diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 09bad01f3d7..f18bd7f6f97 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -38,6 +38,19 @@ const LAYER_SECTION_TAGS: Record = { realtime: "realtime", }; +/** + * Inventory ids with no confirmed tag in the rendered output. They are listed + * explicitly rather than left missing: an absent entry made the dialog report a + * successful probe as unavailable. + */ +const UNMAPPED_LAYER_IDS = [ + "model-switch", + "context-window-guidance", + "environments-instructions", + "tools", + "multi-agent-mode", +] as const; + export interface LayerText { /** Rendered text, when this layer produced a section on the probed turn. */ text: string | null; @@ -191,5 +204,12 @@ export async function probePromptText(timeoutMs = 15_000): Promise { expect(res.body.code).toBe("composed_too_large"); }); + test("25. /text takes no caller-supplied directory", async () => { + // A cwd parameter let an authenticated request read any readable folder's + // AGENTS.md through the dashboard, and described a prompt from wherever the + // caller pointed rather than the configuration this page reports on. + const source = await Bun.file(new URL("../src/server/management/codex-prompt-routes.ts", import.meta.url)).text(); + const textRoute = source.slice(source.indexOf("/api/codex-prompt/text")); + expect(textRoute).not.toContain("searchParams.get(\"cwd\")"); + expect(textRoute.slice(0, 900)).not.toContain("process.cwd()"); + + const probe = await Bun.file(new URL("../src/codex/prompt-text-probe.ts", import.meta.url)).text(); + // The probe resolves CODEX_HOME itself; it must not accept a directory. + expect(probe).toContain("resolveCodexHomeDir()"); + expect(probe).toMatch(/export async function probePromptText\(timeoutMs/); + }); + + test("26. the probe is bounded in bytes as well as in time", async () => { + // A 15-second window with no output ceiling let a noisy binary balloon the + // server. Both bounds must exist, and the process must settle exactly once. + const probe = await Bun.file(new URL("../src/codex/prompt-text-probe.ts", import.meta.url)).text(); + expect(probe).toContain("MAX_PROBE_OUTPUT_BYTES"); + expect(probe).toContain("if (settled) return;"); + // Decoding per chunk corrupts UTF-8 that straddles a chunk boundary. + expect(probe).toContain("Buffer.concat(chunks).toString(\"utf8\")"); + }); test("24. every ownership state is named, not collapsed into a boolean", async () => { // developerInstructionsOwned:false covers an ABSENT key and an EXTERNAL one, and // a GUI that cannot tell them apart hides its own create affordance from every From 9150a905cefc2d6ffb68718c88f0e06c8b7204b9 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 20:18:46 +0900 Subject: [PATCH 05/11] fix(codex): map only tags proven by live output, and bound the AGENTS.md body Three of the tag mappings were guesses, and guesses in this table are lies to the user: a wrong name reports a layer as having sent nothing while its text sits in the output. I dumped the rendered tags across the feature flags that enable each section and kept only what appeared - skills, apps, plugins, environment, permissions, and the untagged project doc. personality, realtime and collaboration produced no tag under their own flags in this environment, so they join the unsupported list rather than claiming silence. Fifteen layers, and every one of them now says something it can back up. The AGENTS.md capture ran to end-of-message after deleting every tag-shaped block, which swept up unrelated trailing prose AND deleted XML-like text the user had written inside their own file. Codex wraps the body in , so that is the boundary. Verified with a doc containing brackets: they survive now and the following section stays its own entry. The block is concatenated straight onto the previous closing tag, so the line anchor I first wrote never matched at all. --- src/codex/prompt-text-probe.ts | 38 +++++++++++++++++++-------- tests/codex-prompt-text-probe.test.ts | 31 ++++++++++++++++++++-- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index f18bd7f6f97..99535a1b6b6 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -22,20 +22,25 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { resolveCodexHomeDir } from "./home"; -/** Section id in the DTO -> the tag `world_state` renders it under. */ +/** + * Layer id -> the tag Codex actually renders it under. + * + * Every entry here was read off live `codex debug prompt-input` output across the + * feature flags that enable each section. None is inferred from the Rust section + * `ID` constants: those are identifiers for diffing, not wrapper tags, and an + * earlier guess from them mapped `permissions` to the wrong name while the real + * tag - `permissions instructions`, with a space - went unmatched. + * + * A layer absent from this map is reported as unsupported, never as silent. + */ const LAYER_SECTION_TAGS: Record = { skills: "skills_instructions", apps: "apps_instructions", plugins: "plugins_instructions", environment: "environment_context", - // Verified against live output, not guessed from the Rust section IDs: the - // rendered tag carries a space. permissions: "permissions instructions", - collaboration: "collaboration_mode", + // Synthetic: the project doc carries no tag of its own (see extractSections). "agents-md": "__agents_md", - // Synthetic key: the project doc has no tag of its own (see extractSections). - personality: "personality", - realtime: "realtime", }; /** @@ -49,6 +54,12 @@ const UNMAPPED_LAYER_IDS = [ "environments-instructions", "tools", "multi-agent-mode", + // Confirmed absent from live output under their own feature flags, so the + // previous `personality` / `realtime` guesses reported these as silent when the + // truth is that this extractor has no verified tag for them. + "personality", + "realtime", + "collaboration", ] as const; export interface LayerText { @@ -142,10 +153,15 @@ function extractSections(raw: string): Map { // AGENTS.md is NOT tagged: it arrives as a plain `# AGENTS.md instructions // for ` block among the tagged sections. Matching only on tags would // report the layer as unrendered while its text sits in the same message. - const projectDoc = /(^|\n)(# AGENTS\.md instructions for [\s\S]*)$/.exec( - text.replace(/<([a-zA-Z_][a-zA-Z0-9_ -]*)>[\s\S]*?<\/\1>/g, ""), - ); - if (projectDoc) sections.set("__agents_md", projectDoc[2]!.trim()); + // + // Bounded at both ends. Capturing to end-of-message swept up any unrelated + // untagged prose that happened to follow, and stripping tag-shaped blocks + // first also deleted XML-like text the user had written INSIDE their own + // AGENTS.md. Codex wraps the body in , so that is the boundary. + // No line anchor: the block is concatenated directly onto the previous + // section's closing tag, so requiring a newline before it never matched. + const projectDoc = /# AGENTS\.md instructions for [^\n]*\n+\n?([\s\S]*?)\n?<\/INSTRUCTIONS>/.exec(text); + if (projectDoc) sections.set("__agents_md", projectDoc[1]!.trim()); } return sections; } diff --git a/tests/codex-prompt-text-probe.test.ts b/tests/codex-prompt-text-probe.test.ts index c909e076221..11f453a6c8b 100644 --- a/tests/codex-prompt-text-probe.test.ts +++ b/tests/codex-prompt-text-probe.test.ts @@ -22,7 +22,8 @@ describe("section extraction", () => { }); test("AGENTS.md is found even though it carries no tag of its own", () => { - const raw = message("S\n# AGENTS.md instructions for /home/u/.codex\n\nBe brief."); + // Codex wraps the body in ; the fixture matches live output. + const raw = message("S# AGENTS.md instructions for /home/u/.codex\n\n\nBe brief.\n"); const sections = extractSectionsForTests(raw); expect(sections.get("skills_instructions")).toBe("S"); expect(sections.get("__agents_md")).toContain("Be brief."); @@ -39,5 +40,31 @@ describe("section extraction", () => { const sections = extractSectionsForTests(message("line one\nline two")); expect(sections.get("apps_instructions")).toBe("line one\nline two"); }); -}); + test("AGENTS.md is bounded by its own INSTRUCTIONS wrapper", () => { + // Capturing to end-of-message swept up whatever untagged prose followed. The + // body is delimited, so the delimiter is the boundary. + const raw = message( + "# AGENTS.md instructions for /home/u/.codex\n\n\nBe brief.\n\n /tmp\n", + ); + const sections = extractSectionsForTests(raw); + expect(sections.get("__agents_md")).toBe("Be brief."); + // The section that follows is its own entry, not swallowed into the doc. + expect(sections.get("environment_context")).toContain("/tmp"); + }); + + test("XML-like prose a user wrote inside AGENTS.md survives", () => { + // Stripping tag-shaped blocks before extraction deleted the user's own text. + const raw = message( + "# AGENTS.md instructions for /home/u/.codex\n\n\nUse brackets freely.\n", + ); + expect(extractSectionsForTests(raw).get("__agents_md")).toBe("Use brackets freely."); + }); + + test("a tag-shaped fragment inside prose does not become its own section", () => { + const raw = message("# AGENTS.md instructions for /x\n\n\nPrefer
over .\n"); + const sections = extractSectionsForTests(raw); + expect(sections.has("div")).toBe(false); + expect(sections.get("__agents_md")).toContain("
"); + }); +}); From dbce563796c819135219286e9087767695620d00 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 20:32:11 +0900 Subject: [PATCH 06/11] feat(gui): show the stack as an ordered stack, and split notices from state Three asks in one panel. Order was already correct and completely invisible: LAYER_INVENTORY carries it and the panel sorted by it, but a reader had to infer "this is a stack" from vertical position. Rows carry their assembly index now. The index is CANONICAL, gaps included. Lifting the transition notices out leaves 2 and 5 missing from the main list, and that is the point - renumbering each group 1..n would invent an order the runtime does not have. Built-ins get no drag handle for the same reason: their order comes from world_state.rs and a handle would promise a reordering nothing honors. Custom layers are numbered among THEMSELVES rather than continuing the sequence. They concatenate into one developer_instructions section, so sharing a sequence with the built-ins would draw a fifteen-plus-n stack that does not exist. The live-only split is by KIND, not by a scope flag. realtime and model-switch are notices about a change; they have no steady state to describe. Everything else describes configuration or context. Note what the split is NOT: "every turn" versus "sometimes" - sections are diff-rendered, so an unchanged layer of either kind sends nothing. Getting that wrong is what the last two review rounds were about. They keep the locked row shape but not the locked LABEL: "Always on" is false for a notice. It is not on, it fires. Preset switching lands as asked, with the one addition every reference implementation insists on. No shipped product cycles presets with bare arrows; VS Code Profiles, Arc Boosts and Cursor Rules all pair movement with an explicit current item, and arrows without a position lose the user. So prev/next plus "2 / 3". Navigation moves the EDIT TARGET only. Custom layers are not mutually exclusive - every enabled one composes together - so moving between them must never toggle which is active. Unsaved edits are parked per layer while navigating. Blocking navigation whenever the draft is dirty would have avoided the data loss and made the feature pointless: comparing two layers mid-edit is the whole reason to move between them. Driven red by deleting the parking to confirm the test earns its place. --- .../023_stack_scope_presets.md | 161 ++++++++++++ .../components/codex-set/CustomLayerRow.tsx | 6 + .../components/codex-set/PromptLayerRow.tsx | 19 +- gui/src/i18n/de.ts | 13 +- gui/src/i18n/en.ts | 13 +- gui/src/i18n/fr.ts | 13 +- gui/src/i18n/ja.ts | 13 +- gui/src/i18n/ko.ts | 13 +- gui/src/i18n/ru.ts | 13 +- gui/src/i18n/tr.ts | 13 +- gui/src/i18n/zh-TW.ts | 13 +- gui/src/i18n/zh.ts | 13 +- gui/src/pages/codex-set-prompt.tsx | 59 ++++- gui/src/styles-codex-set.css | 50 ++++ gui/tests/codex-set-prompt-layers.test.tsx | 12 +- gui/tests/codex-set-stack.test.tsx | 241 ++++++++++++++++++ 16 files changed, 633 insertions(+), 32 deletions(-) create mode 100644 devlog/_plan/260802_codex_set_prompt_composer/023_stack_scope_presets.md create mode 100644 gui/tests/codex-set-stack.test.tsx diff --git a/devlog/_plan/260802_codex_set_prompt_composer/023_stack_scope_presets.md b/devlog/_plan/260802_codex_set_prompt_composer/023_stack_scope_presets.md new file mode 100644 index 00000000000..d259fa21b01 --- /dev/null +++ b/devlog/_plan/260802_codex_set_prompt_composer/023_stack_scope_presets.md @@ -0,0 +1,161 @@ +# 023 — WP6c: the stack, the scope split, and switching presets in place + +Three asks, one panel: + +1. "각 위치를 순서대로 쌓는거" — assembly order should be VISIBLE, not inferred + from row order. +2. "live 같은 live에만 주입되는건 별도로" — layers that only appear in a live + turn should not sit in the same list as layers that ship every turn. +3. "팝업에서 추가해서 바로바로 ... 개인 프리셋 갈아끼우면서" — move between + saved presets inside the dialog, editing in place. + +## What "live only" actually means + +Measured, not assumed. Three layers render on a TRANSITION rather than on every +turn, and they are not the same kind of conditional: + +| Layer | When it renders | Source | +|---|---|---| +| realtime | only as a session enters or leaves realtime | `realtime.rs:43-53`, `:77-90` | +| model-switch | only after the model changed mid-conversation | `model_switch_instructions.rs:40` | +| agents-md | when the project doc CHANGES, not on every turn | `agents_md.rs:52-64` | + +The first two are genuinely live-scoped: a user reading a steady-state prompt +will never see them. `agents-md` is different — it is diff-rendered like every +other section, so grouping it with realtime would be wrong. + +So the split is **two groups, not a filter**: layers that ship in a steady-state +turn, and layers that only appear on a transition. Both stay visible; the second +group carries the condition that produces it. + +## Ordering + +`LAYER_INVENTORY` already carries `order`, and the panel already sorts by it. What +it does not do is SHOW it, so "this is a stack" is something the reader has to +infer from vertical position alone. + +The research lane (Figma, Photoshop, Zapier, Atlassian, W3C APG) converges on the +same three things for an ordered list: a visible position, a persistent affordance +when the order is editable, and a keyboard path that is not the drag handle. + +Two important limits here: + +- **Built-in order is not editable.** It comes from `world_state.rs`, so a drag + handle on those rows would promise something the runtime does not honor. They + get a position number and nothing else. +- **Custom layers ARE ordered by the user** and already have up/down buttons from + WP5. They gain the same position number so both halves read as one stack. + +## Preset switching inside the dialog + +The ask names swipe or `<-->`. The search lane found no shipped product doing +arrow-cycling for presets: VS Code Profiles, Arc Boosts, and Cursor Rules all use +an explicit current-item label plus a list, and edit in place. The one consistent +warning is that arrows WITHOUT a position indicator lose the user. + +So: prev/next controls as asked, plus `n / total` so the position is never +ambiguous, plus the name of the current preset. Editing stays in the same dialog. + +This is the WP5 custom-layer editor gaining navigation, not a new surface. A +preset opened this way is still an ordinary custom layer — same endpoint, same +validation, same revision handling. + +## Files + +``` +gui/src/pages/codex-set-prompt.tsx (group the stack, pass positions) +gui/src/components/codex-set/PromptLayerRow.tsx (position number) +gui/src/components/codex-set/CustomLayerRow.tsx (position number) +gui/src/components/codex-set/CustomLayerDialog.tsx (prev/next + position) +gui/src/styles-codex-set.css (stack rail, group heading) +``` + +## Tests + +1. every row shows its position, and the numbers ascend without gaps +2. the live-only group contains exactly realtime and model-switch +3. agents-md stays in the steady-state group — it is diff-rendered, not live-only +4. next/prev move between custom layers and the position indicator follows +5. the indicator is present whenever the controls are (the lost-user guard) +6. editing a layer reached by navigation writes through the ordinary custom path +7. navigation is disabled at the ends rather than wrapping silently +8. a dirty editor does not lose its text when the user navigates away + +Case 8 is the one that matters: navigation inside an editor is a new way to +discard someone's typing, and WP5 already had to fix that once for Cancel. + +## Round 2 corrections + +An audit rejected three things above. All three are retractions, not defences. + +### "Steady-state" and "every turn" are both false + +I wrote that one group "ships every turn". Almost nothing does: world-state +sections are diff-rendered, so an unchanged section sends nothing on an ordinary +turn. Naming the groups that way would have taught the user the same wrong model +that produced the last two rounds of corrections. + +The axis is not steady-state versus live. It is what the layer IS: + +- **State layers** — describe configuration or context, and render when their + snapshot first appears or changes. +- **Transition notices** — exist only to announce a change. `realtime` + (`realtime.rs:43-53`) and `model-switch` (`model.rs:44-60`) have no steady + state to describe; a notice about a change that did not happen is nothing. + +That is the honest version of "live에만 주입되는 것": exactly two layers, and +the reason is their kind rather than a scope flag. + +### One position sequence would misrepresent the runtime + +Two separate errors in the original numbering plan. + +First, renumbering per visual group. If the transition notices are lifted out, +the remaining rows must keep their ORIGINAL assembly indices, gaps included. +Renumbering 1..n inside each group would invent an order the runtime does not +have. "Numbers ascend without gaps" is withdrawn as a test. + +Second, and worse: custom layers do not interleave with built-ins at all. They +concatenate into ONE `developer_instructions` projection +(`prompt-layers.ts:384-386`), which occupies a single slot. Numbering built-ins +and custom layers in one sequence would draw a fifteen-plus-n stack that does +not exist. + +So: built-ins carry their assembly index. Custom layers are an ordered sub-stack +INSIDE their one slot, numbered among themselves, and the panel says so. + +### Navigation changes the editor target and nothing else + +Presets are saved custom layers, and they are not mutually exclusive — every +enabled one composes into the projection together. Prev/next must therefore move +only which layer is being EDITED. It must not toggle enablement, and the copy +must not imply a single active preset. + +### Evidence wording + +"Measured, not assumed" was wrong for this section: the classification comes from +reading `render_diff`, not from captured output. It is source-verified, and the +two-member transition group gets a fixture-backed test before it becomes UI +taxonomy. + +## Tests, restated + +1. every built-in row shows its CANONICAL `order` from `LAYER_INVENTORY`, gaps + included after grouping — not a renumbered 1..n +2. the transition group contains exactly `realtime` and `model-switch` +3. every built-in appears exactly once across the two groups, and none is dropped +4. custom layers are numbered among THEMSELVES, and the panel states they share + one slot rather than interleaving with built-ins +5. next/prev move the editor target; title and body follow the layer, and + navigating writes nothing +6. the position indicator exists whenever the controls do +7. at the ends the controls are disabled, and clicking one leaves the editor + unchanged rather than wrapping +8. **edit A, navigate to B, come back to A: the unsaved title and body are still + there, and no PUT was issued before Save.** Blocking navigation while dirty + would pass a weaker version of this test while making the feature useless +9. saving a layer reached by navigation goes to `/api/codex-prompt/custom` with a + stable id, the siblings intact, and the current revision +10. zero and one custom layers: the controls are absent or consistently disabled +11. a refresh that deletes the layer under an open editor does not strand it + diff --git a/gui/src/components/codex-set/CustomLayerRow.tsx b/gui/src/components/codex-set/CustomLayerRow.tsx index 095983dbee2..7207b500f1a 100644 --- a/gui/src/components/codex-set/CustomLayerRow.tsx +++ b/gui/src/components/codex-set/CustomLayerRow.tsx @@ -30,6 +30,12 @@ export default function CustomLayerRow({ const t = useT(); return (
  • + {/* + Numbered among themselves, not continuing the built-in sequence. Custom + layers concatenate into ONE developer_instructions section, so sharing a + sequence with the built-ins would draw a stack that does not exist. + */} + diff --git a/gui/src/components/codex-set/PromptLayerRow.tsx b/gui/src/components/codex-set/PromptLayerRow.tsx index 5a6098ab95e..eb2fd6c68d3 100644 --- a/gui/src/components/codex-set/PromptLayerRow.tsx +++ b/gui/src/components/codex-set/PromptLayerRow.tsx @@ -25,6 +25,7 @@ export default function PromptLayerRow({ descriptor, toggle, bytes, + transitionOnly = false, busy, writesRefused, onToggle, @@ -34,6 +35,8 @@ export default function PromptLayerRow({ toggle: ToggleStateDto | undefined; /** Measured size of what this layer actually sent, when known. */ bytes: number | null; + /** A notice about a change has no steady state to be "on" in. */ + transitionOnly?: boolean; busy: boolean; writesRefused: boolean; onToggle: (id: string, enabled: boolean) => void; @@ -48,6 +51,15 @@ export default function PromptLayerRow({ return (
  • + {/* + The CANONICAL assembly index, gaps included. Renumbering per visual group + would invent an order the runtime does not have: these positions come from + world_state.rs and are not user-reorderable, which is also why there is no + drag handle here. + */} +
  • diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2d6b22ef597..1df08ef6741 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -868,8 +868,12 @@ export const de: Record = { "codexSet.layer.environment": "Umgebungskontext", "codexSet.layer.apps": "Apps", "codexSet.layer.skills": "Skills", - "codexSet.prompt.extensionsUnknown": "Erweiterungen können eigene Ebenen hinzufügen. Codex legt sie nicht offen, daher können sie hier nicht aufgeführt werden.", - "codexSet.row.alwaysOn": "Immer aktiv", +"codexSet.prompt.extensionsUnknown": "Erweiterungen können eigene Ebenen hinzufügen. Codex legt sie nicht offen, daher können sie hier nicht aufgeführt werden.", + "codexSet.group.transition": "Übergangshinweise", + "codexSet.group.transitionDesc": "Sie melden eine Änderung, statt einen Zustand zu beschreiben, und erscheinen daher nur beim Wechsel in den Echtzeitmodus oder beim Modellwechsel.", + "codexSet.custom.slotNote": "Benutzerdefinierte Ebenen werden in dieser Reihenfolge zu einem Abschnitt zusammengefügt.", +"codexSet.row.alwaysOn": "Immer aktiv", + "codexSet.row.onChange": "Bei Änderung", "codexSet.row.featureGated": "Unter [features] konfiguriert", "codexSet.row.openFeatures": "Einstellungen öffnen", "codexSet.dialog.setValue": "{value} (Standard {fallback})", @@ -894,7 +898,10 @@ export const de: Record = { "codexSet.custom.delete": "{title} löschen", "codexSet.custom.deleteConfirm": "Diese Ebene löschen? Dies kann nicht rückgängig gemacht werden.", "codexSet.custom.deleteConfirmNamed": "„{title}“ löschen? Dies kann nicht rückgängig gemacht werden.", - "codexSet.custom.moveUp": "{title} nach oben verschieben", +"codexSet.custom.moveUp": "{title} nach oben verschieben", + "codexSet.custom.prevLayer": "Vorherige Ebene", + "codexSet.custom.nextLayer": "Nächste Ebene", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "{title} nach unten verschieben", "codexSet.custom.limitReached": "Du kannst bis zu {max} benutzerdefinierte Ebenen speichern.", "codexSet.custom.notOwned": "developer_instructions wurde außerhalb von opencodex geschrieben und kann daher hier nicht bearbeitet werden. Importiere den Inhalt, um ihn als Ebene zu verwalten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8002191aa6d..278eaa36c30 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1362,8 +1362,12 @@ export const en = { "codexSet.layer.environment": "Environment context", "codexSet.layer.apps": "Apps", "codexSet.layer.skills": "Skills", - "codexSet.prompt.extensionsUnknown": "Extensions can add their own layers. Codex does not expose them, so they cannot be listed here.", - "codexSet.row.alwaysOn": "Always on", +"codexSet.prompt.extensionsUnknown": "Extensions can add their own layers. Codex does not expose them, so they cannot be listed here.", + "codexSet.group.transition": "Transition notices", + "codexSet.group.transitionDesc": "These announce a change rather than describe state, so they appear only when the session enters realtime or switches model.", + "codexSet.custom.slotNote": "Custom layers are joined into one section in this order.", +"codexSet.row.alwaysOn": "Always on", + "codexSet.row.onChange": "Fires on change", "codexSet.row.featureGated": "Configured under [features]", "codexSet.row.openFeatures": "Open settings", "codexSet.dialog.setValue": "{value} (default {fallback})", @@ -1388,7 +1392,10 @@ export const en = { "codexSet.custom.delete": "Delete {title}", "codexSet.custom.deleteConfirm": "Delete this layer? There is no undo.", "codexSet.custom.deleteConfirmNamed": "Delete “{title}”? There is no undo.", - "codexSet.custom.moveUp": "Move {title} up", +"codexSet.custom.moveUp": "Move {title} up", + "codexSet.custom.prevLayer": "Previous layer", + "codexSet.custom.nextLayer": "Next layer", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "Move {title} down", "codexSet.custom.limitReached": "You can keep up to {max} custom layers.", "codexSet.custom.notOwned": "developer_instructions was written outside opencodex, so it is not edited from here. Import it to manage it as a layer.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4f1068accfa..a10f0e4b22c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1335,8 +1335,12 @@ export const fr: Record = { "codexSet.layer.environment": "Contexte d'environnement", "codexSet.layer.apps": "Applications", "codexSet.layer.skills": "Compétences", - "codexSet.prompt.extensionsUnknown": "Les extensions peuvent ajouter leurs propres couches. Codex ne les expose pas, elles ne peuvent donc pas être répertoriées ici.", - "codexSet.row.alwaysOn": "Toujours actif", +"codexSet.prompt.extensionsUnknown": "Les extensions peuvent ajouter leurs propres couches. Codex ne les expose pas, elles ne peuvent donc pas être répertoriées ici.", + "codexSet.group.transition": "Avis de transition", + "codexSet.group.transitionDesc": "Ils signalent un changement au lieu de décrire un état : ils n'apparaissent donc qu'au passage en temps réel ou au changement de modèle.", + "codexSet.custom.slotNote": "Les couches personnalisées sont réunies en une seule section dans cet ordre.", +"codexSet.row.alwaysOn": "Toujours actif", + "codexSet.row.onChange": "Lors d'un changement", "codexSet.row.featureGated": "Configuré sous [features]", "codexSet.row.openFeatures": "Ouvrir les réglages", "codexSet.dialog.setValue": "{value} (par défaut {fallback})", @@ -1361,7 +1365,10 @@ export const fr: Record = { "codexSet.custom.delete": "Supprimer {title}", "codexSet.custom.deleteConfirm": "Supprimer cette couche ? Cette action est irréversible.", "codexSet.custom.deleteConfirmNamed": "Supprimer « {title} » ? Cette action est irréversible.", - "codexSet.custom.moveUp": "Déplacer {title} vers le haut", +"codexSet.custom.moveUp": "Déplacer {title} vers le haut", + "codexSet.custom.prevLayer": "Couche précédente", + "codexSet.custom.nextLayer": "Couche suivante", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "Déplacer {title} vers le bas", "codexSet.custom.limitReached": "Vous pouvez conserver jusqu’à {max} couches personnalisées.", "codexSet.custom.notOwned": "developer_instructions a été écrit en dehors d’opencodex et ne peut donc pas être modifié ici. Importez-le pour le gérer comme une couche.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 381feb5cf1c..f34134ae881 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1295,8 +1295,12 @@ export const ja: Record = { "codexSet.layer.environment": "環境コンテキスト", "codexSet.layer.apps": "アプリ", "codexSet.layer.skills": "スキル", - "codexSet.prompt.extensionsUnknown": "拡張機能は独自のレイヤーを追加できます。Codex が公開していないため、ここには表示できません。", - "codexSet.row.alwaysOn": "常に有効", +"codexSet.prompt.extensionsUnknown": "拡張機能は独自のレイヤーを追加できます。Codex が公開していないため、ここには表示できません。", + "codexSet.group.transition": "遷移通知", + "codexSet.group.transitionDesc": "状態を説明するのではなく変化を知らせる項目のため、セッションがリアルタイムに切り替わるかモデルが変わったときだけ現れます。", + "codexSet.custom.slotNote": "カスタムレイヤーはこの順序で連結され、1つのセクションになります。", +"codexSet.row.alwaysOn": "常に有効", + "codexSet.row.onChange": "変更時に送信", "codexSet.row.featureGated": "[features] で設定", "codexSet.row.openFeatures": "設定を開く", "codexSet.dialog.setValue": "{value}(デフォルト {fallback})", @@ -1321,7 +1325,10 @@ export const ja: Record = { "codexSet.custom.delete": "{title} を削除", "codexSet.custom.deleteConfirm": "このレイヤーを削除しますか?元に戻せません。", "codexSet.custom.deleteConfirmNamed": "“{title}” を削除しますか?元に戻せません。", - "codexSet.custom.moveUp": "{title} を上へ移動", +"codexSet.custom.moveUp": "{title} を上へ移動", + "codexSet.custom.prevLayer": "前のレイヤー", + "codexSet.custom.nextLayer": "次のレイヤー", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "{title} を下へ移動", "codexSet.custom.limitReached": "カスタムレイヤーは最大 {max} 個まで保存できます。", "codexSet.custom.notOwned": "developer_instructions は opencodex の外部で作成されたため、ここでは編集できません。レイヤーとして管理するにはインポートしてください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ca383818aa8..ff1f335513b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -894,8 +894,12 @@ export const ko: Record = { "codexSet.layer.environment": "환경 정보", "codexSet.layer.apps": "앱", "codexSet.layer.skills": "스킬", - "codexSet.prompt.extensionsUnknown": "확장 프로그램은 자체 레이어를 추가할 수 있습니다. Codex가 이를 공개하지 않아 여기에 표시할 수 없습니다.", - "codexSet.row.alwaysOn": "항상 켜짐", +"codexSet.prompt.extensionsUnknown": "확장 프로그램은 자체 레이어를 추가할 수 있습니다. Codex가 이를 공개하지 않아 여기에 표시할 수 없습니다.", + "codexSet.group.transition": "전환 알림", + "codexSet.group.transitionDesc": "상태를 설명하는 대신 변화를 알리는 항목이라, 세션이 실시간 모드로 바뀌거나 모델이 교체될 때만 나타납니다.", + "codexSet.custom.slotNote": "커스텀 레이어는 이 순서대로 이어져 하나의 섹션이 됩니다.", +"codexSet.row.alwaysOn": "항상 켜짐", + "codexSet.row.onChange": "변경 시 전달", "codexSet.row.featureGated": "[features]에서 설정", "codexSet.row.openFeatures": "설정 열기", "codexSet.dialog.setValue": "{value}(기본값 {fallback})", @@ -920,7 +924,10 @@ export const ko: Record = { "codexSet.custom.delete": "{title} 삭제", "codexSet.custom.deleteConfirm": "이 레이어를 삭제하시겠습니까? 되돌릴 수 없습니다.", "codexSet.custom.deleteConfirmNamed": "“{title}” 레이어를 삭제하시겠습니까? 되돌릴 수 없습니다.", - "codexSet.custom.moveUp": "{title} 위로 이동", +"codexSet.custom.moveUp": "{title} 위로 이동", + "codexSet.custom.prevLayer": "이전 레이어", + "codexSet.custom.nextLayer": "다음 레이어", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "{title} 아래로 이동", "codexSet.custom.limitReached": "커스텀 레이어는 최대 {max}개까지 보관할 수 있습니다.", "codexSet.custom.notOwned": "developer_instructions가 opencodex 외부에서 작성되어 여기서는 편집할 수 없습니다. 레이어로 관리하려면 가져오세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 1d9118f4ac7..b6192759fd3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1346,8 +1346,12 @@ export const ru: Record = { "codexSet.layer.environment": "Контекст окружения", "codexSet.layer.apps": "Приложения", "codexSet.layer.skills": "Навыки", - "codexSet.prompt.extensionsUnknown": "Расширения могут добавлять собственные слои. Codex не раскрывает их, поэтому показать их здесь нельзя.", - "codexSet.row.alwaysOn": "Всегда включён", +"codexSet.prompt.extensionsUnknown": "Расширения могут добавлять собственные слои. Codex не раскрывает их, поэтому показать их здесь нельзя.", + "codexSet.group.transition": "Уведомления о переходе", + "codexSet.group.transitionDesc": "Они сообщают об изменении, а не описывают состояние, поэтому появляются только при переходе сессии в реальное время или смене модели.", + "codexSet.custom.slotNote": "Пользовательские слои объединяются в один раздел в этом порядке.", +"codexSet.row.alwaysOn": "Всегда включён", + "codexSet.row.onChange": "При изменении", "codexSet.row.featureGated": "Настраивается в [features]", "codexSet.row.openFeatures": "Открыть настройки", "codexSet.dialog.setValue": "{value} (по умолчанию {fallback})", @@ -1372,7 +1376,10 @@ export const ru: Record = { "codexSet.custom.delete": "Удалить {title}", "codexSet.custom.deleteConfirm": "Удалить этот слой? Это действие нельзя отменить.", "codexSet.custom.deleteConfirmNamed": "Удалить “{title}”? Это действие нельзя отменить.", - "codexSet.custom.moveUp": "Переместить {title} вверх", +"codexSet.custom.moveUp": "Переместить {title} вверх", + "codexSet.custom.prevLayer": "Предыдущий слой", + "codexSet.custom.nextLayer": "Следующий слой", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "Переместить {title} вниз", "codexSet.custom.limitReached": "Можно сохранить не более {max} пользовательских слоёв.", "codexSet.custom.notOwned": "developer_instructions записан вне opencodex, поэтому его нельзя изменить здесь. Импортируйте его, чтобы управлять им как слоем.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca65c4f98e1..99dc5b4140e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1353,8 +1353,12 @@ export const tr: Record = { "codexSet.layer.environment": "Ortam bağlamı", "codexSet.layer.apps": "Uygulamalar", "codexSet.layer.skills": "Beceriler", - "codexSet.prompt.extensionsUnknown": "Uzantılar kendi katmanlarını ekleyebilir. Codex bunları göstermediği için burada listelenemez.", - "codexSet.row.alwaysOn": "Her zaman açık", +"codexSet.prompt.extensionsUnknown": "Uzantılar kendi katmanlarını ekleyebilir. Codex bunları göstermediği için burada listelenemez.", + "codexSet.group.transition": "Geçiş bildirimleri", + "codexSet.group.transitionDesc": "Durumu anlatmak yerine bir değişikliği bildirirler; bu yüzden yalnızca oturum gerçek zamanlıya geçtiğinde veya model değiştiğinde görünürler.", + "codexSet.custom.slotNote": "Özel katmanlar bu sırayla birleştirilip tek bir bölüm olur.", +"codexSet.row.alwaysOn": "Her zaman açık", + "codexSet.row.onChange": "Değişimde gönderilir", "codexSet.row.featureGated": "[features] altında yapılandırılır", "codexSet.row.openFeatures": "Ayarları aç", "codexSet.dialog.setValue": "{value} (varsayılan {fallback})", @@ -1379,7 +1383,10 @@ export const tr: Record = { "codexSet.custom.delete": "{title} katmanını sil", "codexSet.custom.deleteConfirm": "Bu katman silinsin mi? Bu işlem geri alınamaz.", "codexSet.custom.deleteConfirmNamed": "“{title}” silinsin mi? Bu işlem geri alınamaz.", - "codexSet.custom.moveUp": "{title} katmanını yukarı taşı", +"codexSet.custom.moveUp": "{title} katmanını yukarı taşı", + "codexSet.custom.prevLayer": "Önceki katman", + "codexSet.custom.nextLayer": "Sonraki katman", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "{title} katmanını aşağı taşı", "codexSet.custom.limitReached": "En fazla {max} özel katman saklayabilirsiniz.", "codexSet.custom.notOwned": "developer_instructions opencodex dışında yazıldığı için buradan düzenlenemez. Katman olarak yönetmek için içe aktarın.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 323db0cea36..2537941edb7 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1128,8 +1128,12 @@ export const zhTW: Record = { "codexSet.layer.environment": "環境內容", "codexSet.layer.apps": "應用程式", "codexSet.layer.skills": "技能", - "codexSet.prompt.extensionsUnknown": "擴充功能可新增自己的層。Codex 不會公開這些層,因此無法在此列出。", - "codexSet.row.alwaysOn": "一律啟用", +"codexSet.prompt.extensionsUnknown": "擴充功能可新增自己的層。Codex 不會公開這些層,因此無法在此列出。", + "codexSet.group.transition": "變更通知", + "codexSet.group.transitionDesc": "它們通報變化而非描述狀態,因此僅在工作階段切換為即時模式或更換模型時出現。", + "codexSet.custom.slotNote": "自訂層會依此順序合併為一個區段。", +"codexSet.row.alwaysOn": "一律啟用", + "codexSet.row.onChange": "變更時傳送", "codexSet.row.featureGated": "在 [features] 下設定", "codexSet.row.openFeatures": "開啟設定", "codexSet.dialog.setValue": "{value}(預設 {fallback})", @@ -1154,7 +1158,10 @@ export const zhTW: Record = { "codexSet.custom.delete": "刪除 {title}", "codexSet.custom.deleteConfirm": "要刪除此層嗎?此操作無法復原。", "codexSet.custom.deleteConfirmNamed": "要刪除“{title}”嗎?此操作無法復原。", - "codexSet.custom.moveUp": "將 {title} 上移", +"codexSet.custom.moveUp": "將 {title} 上移", + "codexSet.custom.prevLayer": "上一層", + "codexSet.custom.nextLayer": "下一層", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "將 {title} 下移", "codexSet.custom.limitReached": "最多可保留 {max} 個自訂層。", "codexSet.custom.notOwned": "developer_instructions 是在 opencodex 外部寫入的,因此無法在此編輯。請將其匯入,以便作為層管理。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8aff7af40c6..c52057d6fbe 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -887,8 +887,12 @@ export const zh: Record = { "codexSet.layer.environment": "环境上下文", "codexSet.layer.apps": "应用", "codexSet.layer.skills": "技能", - "codexSet.prompt.extensionsUnknown": "扩展可添加自己的层。Codex 不会公开这些层,因此无法在此列出。", - "codexSet.row.alwaysOn": "始终启用", +"codexSet.prompt.extensionsUnknown": "扩展可添加自己的层。Codex 不会公开这些层,因此无法在此列出。", + "codexSet.group.transition": "变更通知", + "codexSet.group.transitionDesc": "它们通报变化而非描述状态,因此仅在会话切换到实时模式或更换模型时出现。", + "codexSet.custom.slotNote": "自定义层会按此顺序合并为一个部分。", +"codexSet.row.alwaysOn": "始终启用", + "codexSet.row.onChange": "变更时发送", "codexSet.row.featureGated": "在 [features] 下配置", "codexSet.row.openFeatures": "打开设置", "codexSet.dialog.setValue": "{value}(默认 {fallback})", @@ -913,7 +917,10 @@ export const zh: Record = { "codexSet.custom.delete": "删除 {title}", "codexSet.custom.deleteConfirm": "要删除此层吗?此操作无法撤销。", "codexSet.custom.deleteConfirmNamed": "要删除“{title}”吗?此操作无法撤销。", - "codexSet.custom.moveUp": "上移 {title}", +"codexSet.custom.moveUp": "上移 {title}", + "codexSet.custom.prevLayer": "上一层", + "codexSet.custom.nextLayer": "下一层", + "codexSet.custom.navPosition": "{position} / {total}", "codexSet.custom.moveDown": "下移 {title}", "codexSet.custom.limitReached": "最多可保留 {max} 个自定义层。", "codexSet.custom.notOwned": "developer_instructions 是在 opencodex 外部写入的,因此无法在此编辑。请将其导入,以便作为层进行管理。", diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index 557369598aa..3f98ec66cc9 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -322,6 +322,22 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { const rows = [...(snapshot?.inventory ?? [])] .filter(d => d.class !== "extension-unknown") .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + /** + * Two kinds of layer, split by what they ARE rather than by a scope flag. + * + * A transition notice exists only to announce a change - entering realtime, + * switching model mid-conversation. There is no steady state for it to describe, + * so it never appears in an ordinary turn. Every other layer describes + * configuration or context and renders when its snapshot first appears or + * changes. + * + * Note what this is NOT: "ships every turn" versus "sometimes". Sections are + * diff-rendered, so an unchanged layer of either kind sends nothing. + */ + const TRANSITION_ONLY = new Set(["realtime", "model-switch"]); + const stateRows = rows.filter(d => !TRANSITION_ONLY.has(d.id)); + const transitionRows = rows.filter(d => TRANSITION_ONLY.has(d.id)); const openDescriptor = rows.find(d => d.id === openLayerId) ?? null; useEffect(() => { @@ -402,7 +418,7 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { )}
      - {rows.map(descriptor => ( + {stateRows.map(descriptor => ( + {/* + Kept in the list rather than hidden, because a user auditing their prompt + needs to know these exist. They are separated because they are a different + kind of thing: a notice about a change, not a description of state. + */} + {transitionRows.length > 0 && ( + <> +
      + {t("codexSet.group.transition")} +
      +

      {t("codexSet.group.transitionDesc")}

      +
        + {transitionRows.map(descriptor => ( + s.id === descriptor.id)} + bytes={layerText?.layers?.[descriptor.id]?.bytes ?? null} + transitionOnly + busy={busyId === descriptor.id} + writesRefused={snapshot?.readable === false} + onToggle={(id, enabled) => { void onToggle(id, enabled); }} + onOpen={setOpenLayerId} + /> + ))} +
      + + )} + {/* Third-party extension layers cannot be enumerated (devlog 001 class E), so the panel says so rather than implying the list above is exhaustive. A count @@ -571,6 +616,18 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { seed={editing === "new" ? presetSeed : null} others={snapshot.custom} busy={busyId !== null} + navigation={editing !== "new" && snapshot.custom.length > 1 ? { + position: snapshot.custom.findIndex(l => l.id === editing) + 1, + total: snapshot.custom.length, + onPrev: () => { + const i = snapshot.custom.findIndex(l => l.id === editing); + if (i > 0) setEditing(snapshot.custom[i - 1]!.id); + }, + onNext: () => { + const i = snapshot.custom.findIndex(l => l.id === editing); + if (i >= 0 && i < snapshot.custom.length - 1) setEditing(snapshot.custom[i + 1]!.id); + }, + } : undefined} onSave={saveDraft} onClose={() => { setEditing(null); setPresetSeed(null); }} /> diff --git a/gui/src/styles-codex-set.css b/gui/src/styles-codex-set.css index df980c4298a..922a112143d 100644 --- a/gui/src/styles-codex-set.css +++ b/gui/src/styles-codex-set.css @@ -35,6 +35,22 @@ text-align: left; } +/* The stack rail: a fixed column so positions line up as a column, not as prose. */ +.codex-set-prompt__pos { + flex: 0 0 auto; + width: 18px; + text-align: right; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--faint); +} + +.codex-set-prompt__group { + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + /* A row the user can act on carries more weight than one that only reports. */ .codex-set-prompt__row[data-layer-class="config-toggle"] .codex-set-prompt__name { font-weight: 500; @@ -139,6 +155,24 @@ max-width: 640px; } +/* + The dialog inherits a row layout that put the title input and the body label on + one line. A form is a stack: each field owns its own row. +*/ +.codex-set-custom-dialog .field { + display: block; + margin-top: 12px; +} + +.codex-set-custom-dialog .field > span { + display: block; + margin-bottom: 4px; +} + +.codex-set-custom-dialog .field > input { + width: 100%; +} + .codex-set-custom-dialog textarea { width: 100%; font-family: var(--mono, monospace); @@ -167,6 +201,22 @@ gap: 8px; } +.codex-set-custom-dialog__nav { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +/* Tabular so the indicator does not jitter as the position changes. */ +.codex-set-custom-dialog__nav-pos { + min-width: 44px; + text-align: center; + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--muted); +} + /* Drift is announced, not silently healed, so the banner sits above the rows. */ .codex-set-prompt__drift { display: flex; diff --git a/gui/tests/codex-set-prompt-layers.test.tsx b/gui/tests/codex-set-prompt-layers.test.tsx index 0633071bcdb..ca33ba8f22e 100644 --- a/gui/tests/codex-set-prompt-layers.test.tsx +++ b/gui/tests/codex-set-prompt-layers.test.tsx @@ -98,12 +98,18 @@ function row(container: HTMLElement, id: string): HTMLElement | null { return container.querySelector("[data-layer-id=\"" + id + "\"]"); } -test("1. every inventory entry renders a row, in assembly order", async () => { +test("1. every inventory entry renders a row, state layers before transition notices", async () => { stubRoutes(() => json(snapshot())); const { container, root } = await mount(); const rendered = [...container.querySelectorAll("[data-layer-id]")].map(el => el.getAttribute("data-layer-id")); - // Assembly order, so the list reads the way the prompt is built. - expect(rendered).toEqual(INVENTORY.map(d => d.id)); + // Two groups, each in assembly order: state layers first, then the notices that + // only fire on a change. Every layer appears exactly once - a split that drops + // one is worse than no split. + const transition = ["realtime", "model-switch"]; + const state = INVENTORY.map(d => d.id).filter(id => !transition.includes(id)); + expect(rendered.slice(0, state.length)).toEqual(state); + expect(rendered.slice(state.length).slice().sort()).toEqual(transition.slice().sort()); + expect(new Set(rendered).size).toBe(rendered.length); await act(async () => { root.unmount(); }); }); diff --git a/gui/tests/codex-set-stack.test.tsx b/gui/tests/codex-set-stack.test.tsx new file mode 100644 index 00000000000..1638f020d15 --- /dev/null +++ b/gui/tests/codex-set-stack.test.tsx @@ -0,0 +1,241 @@ +/** + * The layer stack: assembly order made visible, and transition notices kept + * apart from state layers (devlog 023). + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import CodexSetPrompt from "../src/pages/codex-set-prompt"; +import { LAYER_INVENTORY } from "../../src/codex/prompt-layers"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; +const INVENTORY = LAYER_INVENTORY.map(d => ({ ...d })); + +/** Verified transition-only in devlog 023: realtime.rs:43-53 and model.rs:44-60. */ +const TRANSITION_IDS = ["realtime", "model-switch"]; + +function snapshot(over: Record = {}) { + return { + configPath: "/tmp/config.toml", + storePath: "/tmp/opencodex-prompt.json", + configExists: true, + readable: true, + developerInstructionsOwned: true, + developerInstructionsState: "owned" as const, + drift: null, + revision: "sha256:one", + inventory: INVENTORY, + toggles: INVENTORY.filter(d => d.class === "config-toggle").map(d => ({ + id: d.id, key: d.key as string, userFileValue: null, defaultedUserValue: true, default: true, + })), + extensionLayersEnumerable: false, + custom: [], + modelInstructionsFile: null, + ...over, + }; +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#codex-set/prompt" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + clearClientResourceStoresForTests(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +interface Call { url: string; method: string; body: any } +function stubRoutes(handler: (call: Call) => Response) { + const calls: Call[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const call: Call = { url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }; + calls.push(call); + return handler(call); + }) as typeof fetch; + return calls; +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); +} + +async function mount(): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + return { root, container }; +} + +test("1. a built-in row shows its CANONICAL assembly index, not a renumbering", async () => { + // Renumbering per visual group would invent an order the runtime does not + // have. The gaps left by the lifted transition notices are the proof. + stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot()))); + const { container, root } = await mount(); + for (const d of INVENTORY) { + if (d.class === "extension-unknown") continue; + const row = container.querySelector("[data-layer-id=\"" + d.id + "\"]"); + expect(row, d.id).not.toBeNull(); + const shown = row!.querySelector(".codex-set-prompt__pos")!.textContent; + expect(shown, d.id).toBe(d.order === null ? "\u00b7" : String(d.order + 1)); + } + await act(async () => { root.unmount(); }); +}); + +test("2+3. the transition group holds exactly the two notices, and nothing is dropped", async () => { + stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot()))); + const { container, root } = await mount(); + const lists = [...container.querySelectorAll(".codex-set-prompt__rows")]; + expect(lists.length).toBeGreaterThanOrEqual(2); + const idsIn = (list: Element) => [...list.querySelectorAll("[data-layer-id]")].map(el => el.getAttribute("data-layer-id")); + const transition = idsIn(lists[1]!); + expect(transition.sort()).toEqual([...TRANSITION_IDS].sort()); + + // Exactly once across the two groups: a split that loses a layer is worse + // than no split at all. + const all = [...container.querySelectorAll("[data-layer-id]")].map(el => el.getAttribute("data-layer-id")); + const expected = INVENTORY.filter(d => d.class !== "extension-unknown").map(d => d.id); + expect(all.slice().sort()).toEqual(expected.slice().sort()); + expect(new Set(all).size).toBe(all.length); + await act(async () => { root.unmount(); }); +}); + +test("a transition notice is never labelled always-on", async () => { + // It is not on, it fires. Reusing the locked label would claim this text is + // in every prompt when it appears only at a change. + stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot()))); + const { container, root } = await mount(); + for (const id of TRANSITION_IDS) { + const note = container.querySelector("[data-layer-id=\"" + id + "\"] .codex-set-prompt__note"); + expect(note, id).not.toBeNull(); + expect(note!.textContent, id).not.toContain("Always on"); + } + // A genuinely locked layer keeps the strong label. + expect(container.querySelector("[data-layer-id=\"base-instructions\"] .codex-set-prompt__note")!.textContent) + .toContain("Always on"); + await act(async () => { root.unmount(); }); +}); + +function layer(over: Record = {}) { + return { id: "aaaaaa", title: "First", body: "Alpha.", enabled: true, ...over }; +} + +const THREE = [layer(), layer({ id: "bbbbbb", title: "Second", body: "Beta." }), layer({ id: "cccccc", title: "Third", body: "Gamma." })]; + +function dialog(): HTMLElement { return document.querySelector("dialog.modal-overlay") as HTMLElement; } +function navButtons() { return [...dialog().querySelectorAll(".codex-set-custom-dialog__nav button")] as HTMLButtonElement[]; } +function navPos() { return dialog().querySelector(".codex-set-custom-dialog__nav-pos")?.textContent ?? ""; } +function fields() { + return { + title: dialog().querySelector("input[type=\"text\"]") as HTMLInputElement, + body: dialog().querySelector("textarea") as HTMLTextAreaElement, + }; +} + +function typeInto(el: HTMLInputElement | HTMLTextAreaElement, value: string): void { + const proto = el instanceof testWindow.HTMLTextAreaElement + ? testWindow.HTMLTextAreaElement.prototype + : testWindow.HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value); + el.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + el.dispatchEvent(new testWindow.Event("change", { bubbles: true })); +} + +async function openEditor(container: HTMLElement, id: string): Promise { + await act(async () => { + (container.querySelector("[data-custom-id=\"" + id + "\"] button") as HTMLButtonElement).click(); + }); +} + +test("5+6. next and prev move the editor target, show the position, and write nothing", async () => { + const calls = stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot({ custom: THREE })))); + const { container, root } = await mount(); + await openEditor(container, "aaaaaa"); + expect(navPos()).toBe("1 / 3"); + expect(fields().title.value).toBe("First"); + + await act(async () => { navButtons()[1]!.click(); }); + expect(navPos()).toBe("2 / 3"); + // Title AND body follow the layer, not just the label. + expect(fields().title.value).toBe("Second"); + expect(fields().body.value).toBe("Beta."); + + await act(async () => { navButtons()[0]!.click(); }); + expect(navPos()).toBe("1 / 3"); + expect(fields().title.value).toBe("First"); + + // Navigating is not saving. + expect(calls.filter(c => c.method === "PUT")).toHaveLength(0); + await act(async () => { root.unmount(); }); +}); + +test("7. the ends are disabled rather than wrapping", async () => { + stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot({ custom: THREE })))); + const { container, root } = await mount(); + await openEditor(container, "aaaaaa"); + expect(navButtons()[0]!.disabled).toBe(true); + // Clicking a disabled end leaves the editor exactly where it was. + await act(async () => { navButtons()[0]!.click(); }); + expect(navPos()).toBe("1 / 3"); + + await act(async () => { navButtons()[1]!.click(); }); + await act(async () => { navButtons()[1]!.click(); }); + expect(navPos()).toBe("3 / 3"); + expect(navButtons()[1]!.disabled).toBe(true); + await act(async () => { navButtons()[1]!.click(); }); + expect(navPos()).toBe("3 / 3"); + await act(async () => { root.unmount(); }); +}); + +test("8. an unsaved edit survives navigating away and back", async () => { + // The case that matters. Blocking navigation while dirty would pass a weaker + // version of this while making the feature pointless: comparing two layers + // mid-edit is the whole reason to move between them. + const calls = stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot({ custom: THREE })))); + const { container, root } = await mount(); + await openEditor(container, "aaaaaa"); + await act(async () => { + typeInto(fields().title, "Edited first"); + typeInto(fields().body, "Work in progress."); + }); + + await act(async () => { navButtons()[1]!.click(); }); + expect(fields().title.value).toBe("Second"); + + await act(async () => { navButtons()[0]!.click(); }); + expect(fields().title.value).toBe("Edited first"); + expect(fields().body.value).toBe("Work in progress."); + // And nothing was written along the way. + expect(calls.filter(c => c.method === "PUT")).toHaveLength(0); + await act(async () => { root.unmount(); }); +}); + +test("10. one layer offers no navigation at all", async () => { + stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot({ custom: [layer()] })))); + const { container, root } = await mount(); + await openEditor(container, "aaaaaa"); + expect(dialog().querySelector(".codex-set-custom-dialog__nav")).toBeNull(); + await act(async () => { root.unmount(); }); +}); From 52a32b2f90a333bce0913e85f5cf711ae851b0a0 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 20:37:47 +0900 Subject: [PATCH 07/11] fix(gui): close an editor whose layer was deleted, and reorder from the keyboard Navigation introduced a state nothing handled: a refresh can remove the layer under an open editor - another tab, a hand edit, a delete on the row behind the dialog. The lookup fell back to null, which turned the editor into a NEW-layer form still holding the deleted text, so Save would have recreated exactly what the user had just removed. The position indicator read "0 / 3" while it happened. Index -1 is now the signal. The editor closes and says why. Silently swapping to a neighbour was the other option and it is worse: it puts someone else's text under a cursor that was mid-sentence. Custom rows reorder with Alt+Arrow as well as the buttons, per the W3C rearrangeable-listbox pattern. The buttons stay - a shortcut nobody discovers is not an affordance - and a bare arrow stays ordinary navigation. Moving the top row up writes nothing rather than burning a revision on an unchanged list. Three contracts the reviewer asked for now have tests: custom layers numbered among themselves rather than continuing the built-in sequence, a save after navigating writing the layer actually on screen with its siblings intact, and the deletion case above. The last was driven red by restoring the null fallback. --- .../components/codex-set/CustomLayerRow.tsx | 18 ++- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/codex-set-prompt.tsx | 40 ++++-- gui/tests/codex-set-stack.test.tsx | 114 ++++++++++++++++++ 12 files changed, 171 insertions(+), 10 deletions(-) diff --git a/gui/src/components/codex-set/CustomLayerRow.tsx b/gui/src/components/codex-set/CustomLayerRow.tsx index 7207b500f1a..74d26e9139a 100644 --- a/gui/src/components/codex-set/CustomLayerRow.tsx +++ b/gui/src/components/codex-set/CustomLayerRow.tsx @@ -29,7 +29,23 @@ export default function CustomLayerRow({ }) { const t = useT(); return ( -
    • +
    • { + if (!event.altKey || busy) return; + if (event.key === "ArrowUp" && index > 0) { + event.preventDefault(); + onMove(layer.id, -1); + } else if (event.key === "ArrowDown" && index < total - 1) { + event.preventDefault(); + onMove(layer.id, 1); + } + }} + > {/* Numbered among themselves, not continuing the built-in sequence. Custom layers concatenate into ONE developer_instructions section, so sharing a diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1df08ef6741..5bbbf3c3f95 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -897,6 +897,7 @@ export const de: Record = { "codexSet.custom.keepEditing": "Weiter bearbeiten", "codexSet.custom.delete": "{title} löschen", "codexSet.custom.deleteConfirm": "Diese Ebene löschen? Dies kann nicht rückgängig gemacht werden.", + "codexSet.custom.layerGone": "Diese Ebene wurde anderswo entfernt, daher wurde der Editor geschlossen.", "codexSet.custom.deleteConfirmNamed": "„{title}“ löschen? Dies kann nicht rückgängig gemacht werden.", "codexSet.custom.moveUp": "{title} nach oben verschieben", "codexSet.custom.prevLayer": "Vorherige Ebene", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 278eaa36c30..da6908353f9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1391,6 +1391,7 @@ export const en = { "codexSet.custom.keepEditing": "Keep editing", "codexSet.custom.delete": "Delete {title}", "codexSet.custom.deleteConfirm": "Delete this layer? There is no undo.", + "codexSet.custom.layerGone": "That layer was removed elsewhere, so the editor was closed.", "codexSet.custom.deleteConfirmNamed": "Delete “{title}”? There is no undo.", "codexSet.custom.moveUp": "Move {title} up", "codexSet.custom.prevLayer": "Previous layer", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a10f0e4b22c..a332527bce3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1364,6 +1364,7 @@ export const fr: Record = { "codexSet.custom.keepEditing": "Continuer la modification", "codexSet.custom.delete": "Supprimer {title}", "codexSet.custom.deleteConfirm": "Supprimer cette couche ? Cette action est irréversible.", + "codexSet.custom.layerGone": "Cette couche a été supprimée ailleurs : l'éditeur a donc été fermé.", "codexSet.custom.deleteConfirmNamed": "Supprimer « {title} » ? Cette action est irréversible.", "codexSet.custom.moveUp": "Déplacer {title} vers le haut", "codexSet.custom.prevLayer": "Couche précédente", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f34134ae881..968a8ae45ad 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1324,6 +1324,7 @@ export const ja: Record = { "codexSet.custom.keepEditing": "編集を続ける", "codexSet.custom.delete": "{title} を削除", "codexSet.custom.deleteConfirm": "このレイヤーを削除しますか?元に戻せません。", + "codexSet.custom.layerGone": "他の場所でそのレイヤーが削除されたため、エディターを閉じました。", "codexSet.custom.deleteConfirmNamed": "“{title}” を削除しますか?元に戻せません。", "codexSet.custom.moveUp": "{title} を上へ移動", "codexSet.custom.prevLayer": "前のレイヤー", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ff1f335513b..63d3430d11a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -923,6 +923,7 @@ export const ko: Record = { "codexSet.custom.keepEditing": "계속 편집", "codexSet.custom.delete": "{title} 삭제", "codexSet.custom.deleteConfirm": "이 레이어를 삭제하시겠습니까? 되돌릴 수 없습니다.", + "codexSet.custom.layerGone": "다른 곳에서 해당 레이어가 삭제되어 편집기를 닫았습니다.", "codexSet.custom.deleteConfirmNamed": "“{title}” 레이어를 삭제하시겠습니까? 되돌릴 수 없습니다.", "codexSet.custom.moveUp": "{title} 위로 이동", "codexSet.custom.prevLayer": "이전 레이어", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b6192759fd3..3f4dcc0d6c7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1375,6 +1375,7 @@ export const ru: Record = { "codexSet.custom.keepEditing": "Продолжить редактирование", "codexSet.custom.delete": "Удалить {title}", "codexSet.custom.deleteConfirm": "Удалить этот слой? Это действие нельзя отменить.", + "codexSet.custom.layerGone": "Этот слой был удалён в другом месте, поэтому редактор закрыт.", "codexSet.custom.deleteConfirmNamed": "Удалить “{title}”? Это действие нельзя отменить.", "codexSet.custom.moveUp": "Переместить {title} вверх", "codexSet.custom.prevLayer": "Предыдущий слой", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 99dc5b4140e..2dc7dca4583 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1382,6 +1382,7 @@ export const tr: Record = { "codexSet.custom.keepEditing": "Düzenlemeye devam et", "codexSet.custom.delete": "{title} katmanını sil", "codexSet.custom.deleteConfirm": "Bu katman silinsin mi? Bu işlem geri alınamaz.", + "codexSet.custom.layerGone": "Bu katman başka bir yerde silindiği için düzenleyici kapatıldı.", "codexSet.custom.deleteConfirmNamed": "“{title}” silinsin mi? Bu işlem geri alınamaz.", "codexSet.custom.moveUp": "{title} katmanını yukarı taşı", "codexSet.custom.prevLayer": "Önceki katman", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2537941edb7..59a7ef69add 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1157,6 +1157,7 @@ export const zhTW: Record = { "codexSet.custom.keepEditing": "繼續編輯", "codexSet.custom.delete": "刪除 {title}", "codexSet.custom.deleteConfirm": "要刪除此層嗎?此操作無法復原。", + "codexSet.custom.layerGone": "該層已在別處被刪除,因此編輯器已關閉。", "codexSet.custom.deleteConfirmNamed": "要刪除“{title}”嗎?此操作無法復原。", "codexSet.custom.moveUp": "將 {title} 上移", "codexSet.custom.prevLayer": "上一層", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c52057d6fbe..d9ba0115837 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -916,6 +916,7 @@ export const zh: Record = { "codexSet.custom.keepEditing": "继续编辑", "codexSet.custom.delete": "删除 {title}", "codexSet.custom.deleteConfirm": "要删除此层吗?此操作无法撤销。", + "codexSet.custom.layerGone": "该层已在别处被删除,因此编辑器已关闭。", "codexSet.custom.deleteConfirmNamed": "要删除“{title}”吗?此操作无法撤销。", "codexSet.custom.moveUp": "上移 {title}", "codexSet.custom.prevLayer": "上一层", diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index 3f98ec66cc9..38fd26fde8f 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -340,6 +340,29 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { const transitionRows = rows.filter(d => TRANSITION_ONLY.has(d.id)); const openDescriptor = rows.find(d => d.id === openLayerId) ?? null; + /** + * The layer under the open editor, and where it sits. + * + * A refresh can delete it out from under the dialog - another tab, a hand edit. + * Falling back to `null` silently turned the editor into a NEW-layer form + * carrying the deleted layer's text, so Save would have recreated a layer the + * user had just removed. Index -1 is the signal that this happened. + */ + const editingIndex = editing === null || editing === "new" + ? -1 + : (snapshot?.custom.findIndex(l => l.id === editing) ?? -1); + const editingLayer = editingIndex >= 0 ? snapshot!.custom[editingIndex]! : null; + + useEffect(() => { + // The layer vanished while its editor was open. Keeping the dialog would let + // Save recreate what the user just deleted, and silently swapping to a + // neighbour would put someone else's text under their cursor. Close and say so. + if (editing !== null && editing !== "new" && snapshot !== undefined && editingIndex < 0) { + setEditing(null); + setError(t("codexSet.custom.layerGone")); + } + }, [editing, editingIndex, snapshot, t]); + useEffect(() => { // Fetch on panel mount, not on first dialog open: size is what a user needs to // DECIDE with, and a prompt-budget page that hides which layer costs 15 KB is @@ -612,20 +635,19 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { {editing && snapshot && ( l.id === editing) ?? null} + layer={editing === "new" ? null : editingLayer} seed={editing === "new" ? presetSeed : null} others={snapshot.custom} busy={busyId !== null} - navigation={editing !== "new" && snapshot.custom.length > 1 ? { - position: snapshot.custom.findIndex(l => l.id === editing) + 1, + // Navigation only exists while the edited layer is still in the list. + // Deriving the position from a findIndex that can return -1 produced + // "0 / 3" for a layer another tab had just deleted. + navigation={editingIndex >= 0 && snapshot.custom.length > 1 ? { + position: editingIndex + 1, total: snapshot.custom.length, - onPrev: () => { - const i = snapshot.custom.findIndex(l => l.id === editing); - if (i > 0) setEditing(snapshot.custom[i - 1]!.id); - }, + onPrev: () => { if (editingIndex > 0) setEditing(snapshot.custom[editingIndex - 1]!.id); }, onNext: () => { - const i = snapshot.custom.findIndex(l => l.id === editing); - if (i >= 0 && i < snapshot.custom.length - 1) setEditing(snapshot.custom[i + 1]!.id); + if (editingIndex < snapshot.custom.length - 1) setEditing(snapshot.custom[editingIndex + 1]!.id); }, } : undefined} onSave={saveDraft} diff --git a/gui/tests/codex-set-stack.test.tsx b/gui/tests/codex-set-stack.test.tsx index 1638f020d15..aa29ef18a1b 100644 --- a/gui/tests/codex-set-stack.test.tsx +++ b/gui/tests/codex-set-stack.test.tsx @@ -239,3 +239,117 @@ test("10. one layer offers no navigation at all", async () => { expect(dialog().querySelector(".codex-set-custom-dialog__nav")).toBeNull(); await act(async () => { root.unmount(); }); }); + +test("custom layers reorder from the keyboard, not only from the buttons", async () => { + // W3C APG rearrangeable-listbox: Alt+Arrow moves the item without hunting for + // a control. The buttons stay because a shortcut nobody discovers is not an + // affordance. + const calls = stubRoutes(call => { + if (call.url.includes("/text")) return json({ ok: true, layers: {} }); + if (call.method === "PUT") return json({ ok: true, changed: true, snapshot: snapshot({ custom: THREE }) }); + return json(snapshot({ custom: THREE })); + }); + const { container, root } = await mount(); + const row = container.querySelector("[data-custom-id=\"bbbbbb\"]") as HTMLElement; + + await act(async () => { + row.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "ArrowUp", altKey: true, bubbles: true })); + }); + const put = calls.find(c => c.method === "PUT"); + expect(put).toBeDefined(); + expect(put!.body.layers.map((l: any) => l.id)).toEqual(["bbbbbb", "aaaaaa", "cccccc"]); + + // A bare arrow is ordinary navigation and must not reorder anything. + const before = calls.filter(c => c.method === "PUT").length; + await act(async () => { + row.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + }); + expect(calls.filter(c => c.method === "PUT")).toHaveLength(before); + await act(async () => { root.unmount(); }); +}); + +test("4. custom layers are numbered among themselves and say they share one section", async () => { + // Continuing the built-in sequence would draw a fifteen-plus-n stack. They + // concatenate into ONE developer_instructions section, so 1..n plus a note. + stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot({ custom: THREE })))); + const { container, root } = await mount(); + const positions = [...container.querySelectorAll("[data-custom-id] .codex-set-prompt__pos")].map(el => el.textContent); + expect(positions).toEqual(["1", "2", "3"]); + // Not a continuation of the built-in indices, which end at 15. + expect(positions).not.toContain("16"); + await act(async () => { root.unmount(); }); +}); + +test("9. saving after navigation writes the layer you are looking at", async () => { + // Draft parking is only half the contract: keeping A's text but writing it + // to B would lose the work just as thoroughly. + const calls = stubRoutes(call => { + if (call.url.includes("/text")) return json({ ok: true, layers: {} }); + if (call.method === "PUT") return json({ ok: true, changed: true, snapshot: snapshot({ custom: THREE }) }); + return json(snapshot({ custom: THREE })); + }); + const { container, root } = await mount(); + await openEditor(container, "aaaaaa"); + await act(async () => { navButtons()[1]!.click(); }); + await act(async () => { typeInto(fields().body, "Edited beta."); }); + + const save = [...dialog().querySelectorAll("button")].find(b => (b.textContent ?? "").includes("Save"))!; + await act(async () => { save.click(); }); + const put = calls.find(c => c.method === "PUT")!; + expect(put.url).toBe("/api/codex-prompt/custom"); + expect(put.body.revision).toBe("sha256:one"); + // The edited layer keeps its id, and its siblings survive untouched. + expect(put.body.layers.map((l: any) => l.id)).toEqual(["aaaaaa", "bbbbbb", "cccccc"]); + const edited = put.body.layers.find((l: any) => l.id === "bbbbbb"); + expect(edited.body).toBe("Edited beta."); + expect(put.body.layers.find((l: any) => l.id === "aaaaaa")).toEqual(THREE[0]); + expect(put.body.layers.find((l: any) => l.id === "cccccc")).toEqual(THREE[2]); + await act(async () => { root.unmount(); }); +}); + +test("11. a layer deleted under an open editor closes it instead of stranding it", async () => { + // Falling back to null turned the editor into a NEW-layer form still holding + // the deleted text, so Save would have recreated what the user just removed. + // The position indicator also read "0 / 3". + let gone = false; + stubRoutes(call => { + if (call.url.includes("/text")) return json({ ok: true, layers: {} }); + if (call.method === "PUT") { + gone = true; + return json({ ok: true, changed: true, snapshot: snapshot({ custom: [THREE[0]!, THREE[2]!] }) }); + } + return json(snapshot({ custom: gone ? [THREE[0]!, THREE[2]!] : THREE })); + }); + const { container, root } = await mount(); + await openEditor(container, "bbbbbb"); + expect(document.querySelector("dialog.modal-overlay")).not.toBeNull(); + + // Another surface removes the layer being edited. + await act(async () => { + (container.querySelector("[data-custom-id=\"cccccc\"] .codex-set-custom__delete") as HTMLButtonElement).click(); + }); + await act(async () => { + (container.querySelector(".codex-set-custom__confirm .btn-danger") as HTMLButtonElement).click(); + }); + + expect(document.querySelector("dialog.modal-overlay")).toBeNull(); + expect(container.querySelector("[role=\"alert\"]")).not.toBeNull(); + await act(async () => { root.unmount(); }); +}); +test("the top row does not move up, and the bottom row does not move down", async () => { + const calls = stubRoutes(call => { + if (call.url.includes("/text")) return json({ ok: true, layers: {} }); + if (call.method === "PUT") return json({ ok: true, changed: true, snapshot: snapshot({ custom: THREE }) }); + return json(snapshot({ custom: THREE })); + }); + const { container, root } = await mount(); + const first = container.querySelector("[data-custom-id=\"aaaaaa\"]") as HTMLElement; + const last = container.querySelector("[data-custom-id=\"cccccc\"]") as HTMLElement; + await act(async () => { + first.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "ArrowUp", altKey: true, bubbles: true })); + last.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "ArrowDown", altKey: true, bubbles: true })); + }); + // Silently writing an unchanged list would burn a revision for nothing. + expect(calls.filter(c => c.method === "PUT")).toHaveLength(0); + await act(async () => { root.unmount(); }); +}); From 8f5703991d40d1de04897a4139619b9ebb9a4fe6 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 26 Aug 2026 21:27:01 +0900 Subject: [PATCH 08/11] fix(gui): let a layer body wrap instead of scrolling sideways .api-code sets white-space: pre, and both it and the dialog rule are single-class selectors - a specificity tie that source order decides. styles.css loads after styles-codex-set.css, so pre won and a 307-byte permissions body rendered as two clipped lines behind a horizontal scrollbar. Reading the layer text is the whole point of the dialog. Chaining the selector with .api-code outranks it outright. The guard asserts on the stylesheet rather than a computed style: happy-dom applies no cascade, so a computed-style assertion passes against the broken rule too. Driven red against the unchained selector before landing. --- gui/src/styles-codex-set.css | 15 ++++++++++++--- gui/tests/codex-set-prompt-layers.test.tsx | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/gui/src/styles-codex-set.css b/gui/src/styles-codex-set.css index 922a112143d..d71e0d32012 100644 --- a/gui/src/styles-codex-set.css +++ b/gui/src/styles-codex-set.css @@ -105,12 +105,21 @@ } /* The real prompt text. Scrollable rather than clipped: some layers are 15 KB. */ -.codex-set-layer-dialog__text { +/* + * Chained with .api-code deliberately. Both rules are single-class selectors, so + * they tie on specificity and source order decides - and styles.css loads after + * this file, so its `white-space: pre` won. The prompt body then scrolled + * sideways one long line at a time, which is exactly the "readable layer body" + * this dialog exists to provide. The chained selector outranks it outright. + */ +.codex-set-layer-dialog__text.api-code { margin-top: 8px; max-height: 320px; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: anywhere; + word-break: normal; font-size: 12px; line-height: 1.5; } diff --git a/gui/tests/codex-set-prompt-layers.test.tsx b/gui/tests/codex-set-prompt-layers.test.tsx index ca33ba8f22e..b110a19c26b 100644 --- a/gui/tests/codex-set-prompt-layers.test.tsx +++ b/gui/tests/codex-set-prompt-layers.test.tsx @@ -7,6 +7,7 @@ */ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; @@ -338,3 +339,23 @@ test("a layer this build has no copy for is named, never blank", async () => { expect((dialog.querySelector("p")!.textContent ?? "").length).toBeGreaterThan(20); await act(async () => { root.unmount(); }); }); + +/** + * The dialog body must WRAP, not scroll sideways. + * + * `.api-code` in styles.css sets `white-space: pre`, and both it and the + * dialog rule are single-class selectors - a specificity tie that source order + * decides. styles.css loads later, so `pre` won and a 307-byte permissions body + * rendered as two clipped lines with a horizontal scrollbar. Asserting on the + * stylesheet is the only honest check here: happy-dom applies no cascade, so a + * computed-style assertion would pass against the broken rule too. + */ +test("the layer-text rule outranks .api-code so long bodies wrap", () => { + const css = readFileSync(new URL("../src/styles-codex-set.css", import.meta.url), "utf8"); + const rule = /\.codex-set-layer-dialog__text\.api-code\s*\{([^}]*)\}/.exec(css); + // Chained with .api-code: the unchained selector loses the tie to styles.css. + expect(rule).not.toBeNull(); + expect(rule![1]).toContain("white-space: pre-wrap"); + expect(rule![1]).toContain("overflow-wrap: anywhere"); + expect(rule![1]).toContain("overflow-x: hidden"); +}); From b7c47f55cc6165b826eafe195be554b896b8daf5 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 26 Aug 2026 21:39:35 +0900 Subject: [PATCH 09/11] fix(gui): check the probe response status before trusting its body A 500 body still parses as JSON, and the payload was adopted verbatim, so an error response carrying a `layers` key was reported to the user as measured truth - byte counts and prompt text that were never read. The narrow case matters more than the obvious one: an error with no `layers` already landed in the same "unavailable" branch, so the visible bug only appears when the failing response happens to carry layer-shaped data. The guard was driven red against exactly that case. The test Call body is typed rather than `any`; every assertion already read `body.layers[].id`. --- gui/src/pages/codex-set-prompt.tsx | 8 ++++++ gui/tests/codex-set-stack.test.tsx | 41 +++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index 38fd26fde8f..bb5fb1e3488 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -372,6 +372,14 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { void (async () => { try { const res = await fetch(apiBase + "/api/codex-prompt/text"); + // Status first. A 500 body still parses as JSON, and `{}` deserialized + // into this shape reads as a probe that succeeded and found no layers - + // so every row would silently lose its byte count and every dialog would + // claim the layer sent nothing. + if (!res.ok) { + if (!cancelled) setLayerText({ ok: false }); + return; + } const body = await res.json() as { ok: boolean; layers?: Record }; if (!cancelled) setLayerText(body); } catch { diff --git a/gui/tests/codex-set-stack.test.tsx b/gui/tests/codex-set-stack.test.tsx index aa29ef18a1b..feeaf8b41db 100644 --- a/gui/tests/codex-set-stack.test.tsx +++ b/gui/tests/codex-set-stack.test.tsx @@ -62,7 +62,15 @@ afterEach(() => { } }); -interface Call { url: string; method: string; body: any } +/** + * The request body, typed rather than `any`. + * + * Every assertion below reads `body.layers[].id`, so `any` bought nothing and + * cost the repository's lint gate. `revision` is optional because only the + * write calls carry one. + */ +interface CallBody { layers?: { id: string; title: string; body: string }[]; revision?: string; enabled?: boolean } +interface Call { url: string; method: string; body: CallBody } function stubRoutes(handler: (call: Call) => Response) { const calls: Call[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -257,7 +265,7 @@ test("custom layers reorder from the keyboard, not only from the buttons", async }); const put = calls.find(c => c.method === "PUT"); expect(put).toBeDefined(); - expect(put!.body.layers.map((l: any) => l.id)).toEqual(["bbbbbb", "aaaaaa", "cccccc"]); + expect(put!.body.layers.map(l => l.id)).toEqual(["bbbbbb", "aaaaaa", "cccccc"]); // A bare arrow is ordinary navigation and must not reorder anything. const before = calls.filter(c => c.method === "PUT").length; @@ -299,11 +307,11 @@ test("9. saving after navigation writes the layer you are looking at", async () expect(put.url).toBe("/api/codex-prompt/custom"); expect(put.body.revision).toBe("sha256:one"); // The edited layer keeps its id, and its siblings survive untouched. - expect(put.body.layers.map((l: any) => l.id)).toEqual(["aaaaaa", "bbbbbb", "cccccc"]); - const edited = put.body.layers.find((l: any) => l.id === "bbbbbb"); + expect(put.body.layers.map(l => l.id)).toEqual(["aaaaaa", "bbbbbb", "cccccc"]); + const edited = put.body.layers.find(l => l.id === "bbbbbb"); expect(edited.body).toBe("Edited beta."); - expect(put.body.layers.find((l: any) => l.id === "aaaaaa")).toEqual(THREE[0]); - expect(put.body.layers.find((l: any) => l.id === "cccccc")).toEqual(THREE[2]); + expect(put.body.layers.find(l => l.id === "aaaaaa")).toEqual(THREE[0]); + expect(put.body.layers.find(l => l.id === "cccccc")).toEqual(THREE[2]); await act(async () => { root.unmount(); }); }); @@ -353,3 +361,24 @@ test("the top row does not move up, and the bottom row does not move down", asyn expect(calls.filter(c => c.method === "PUT")).toHaveLength(0); await act(async () => { root.unmount(); }); }); + +test("an error response is not trusted for its layer data", async () => { + // A 500 body still parses as JSON. Without a status check the payload was + // adopted verbatim, so an error response carrying a `layers` key was reported + // to the user as measured truth - here, a 12-byte permissions layer that was + // never actually read. + stubRoutes(call => (call.url.includes("/text") + ? json({ ok: true, layers: { permissions: { text: "stale", reason: "ok", bytes: 12 } } }, 500) + : json(snapshot()))); + const { container, root } = await mount(); + const row = container.querySelector("[data-layer-id=\"permissions\"]")!; + // Nothing was measured, so nothing is claimed. + expect(row.querySelector(".codex-set-prompt__bytes")).toBeNull(); + await act(async () => { + (row.querySelector("button") as HTMLButtonElement).click(); + }); + const dialog = document.querySelector("dialog.modal-overlay")!; + expect(dialog.querySelector(".codex-set-layer-dialog__text")).toBeNull(); + expect(dialog.textContent).not.toContain("stale"); + await act(async () => { root.unmount(); }); +}); From f41650e204ce35de9706463e803ac0d52f21e896 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 26 Aug 2026 21:52:22 +0900 Subject: [PATCH 10/11] fix(gui): derive the layer-gone notice instead of writing it from an effect Calling setState synchronously in an effect body cascades an extra render, which is what `bun run lint` rejects here - and it also meant the stale editor was on screen for that intermediate frame. Every input the branch needs is already known while rendering, so it is a derived boolean. --- gui/src/pages/codex-set-prompt.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index bb5fb1e3488..d282d66fc04 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -353,15 +353,18 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { : (snapshot?.custom.findIndex(l => l.id === editing) ?? -1); const editingLayer = editingIndex >= 0 ? snapshot!.custom[editingIndex]! : null; - useEffect(() => { - // The layer vanished while its editor was open. Keeping the dialog would let - // Save recreate what the user just deleted, and silently swapping to a - // neighbour would put someone else's text under their cursor. Close and say so. - if (editing !== null && editing !== "new" && snapshot !== undefined && editingIndex < 0) { - setEditing(null); - setError(t("codexSet.custom.layerGone")); - } - }, [editing, editingIndex, snapshot, t]); + /** + * The layer vanished while its editor was open - another tab, a hand edit. + * + * Keeping the dialog would let Save recreate what the user just deleted, and + * silently swapping to a neighbour would put someone else's text under their + * cursor. So the editor closes and the panel says why. + * + * Derived, not written from an effect. Calling setState synchronously in an + * effect body cascades an extra render - which is what lint rejects - and + * every input here is already known while rendering. + */ + const layerGone = editing !== null && editing !== "new" && snapshot !== undefined && editingIndex < 0; useEffect(() => { // Fetch on panel mount, not on first dialog open: size is what a user needs to @@ -427,7 +430,11 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { {state.showError && (
      {t("codexSet.prompt.loadFailed")}
      )} - {error &&
      {error}
      } + {(error || layerGone) && ( +
      + {layerGone ? t("codexSet.custom.layerGone") : error} +
      + )} {/* Drift is never silently self-healed: the user is told what state the file @@ -641,7 +648,7 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { )} - {editing && snapshot && ( + {editing && snapshot && !layerGone && ( Date: Wed, 26 Aug 2026 22:09:14 +0900 Subject: [PATCH 11/11] fix(gui): drop the duplicate navigation keys now defined a layer below The catalog entries moved down to the layer whose dialog actually calls them, so repeating them here made every locale an object literal with duplicate properties (TS1117) and broke this tip's build. MSGEOF && bun test ./gui/tests/codex-set-stack.test.tsx ./gui/tests/codex-set-custom-layers.test.tsx ./gui/tests/codex-set-prompt-layers.test.tsx ./gui/tests/codex-set-shell.test.tsx ./gui/tests/codex-set-presets.test.tsx ./gui/tests/locale-parity.test.ts ./gui/tests/fr-localization.test.ts 2>&1 | tail -4 --- gui/src/i18n/de.ts | 3 --- gui/src/i18n/en.ts | 3 --- gui/src/i18n/fr.ts | 3 --- gui/src/i18n/ja.ts | 3 --- gui/src/i18n/ko.ts | 3 --- gui/src/i18n/ru.ts | 3 --- gui/src/i18n/tr.ts | 3 --- gui/src/i18n/zh-TW.ts | 3 --- gui/src/i18n/zh.ts | 3 --- 9 files changed, 27 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5bbbf3c3f95..e3984972ddb 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -910,9 +910,6 @@ export const de: Record = { "codexSet.custom.adoptConfirm": "Als Ebene importieren", "codexSet.custom.adoptRefused": "Der vorhandene Wert konnte nicht importiert werden.", "codexSet.custom.baseReplaced": "model_instructions_file ist auf {path} gesetzt. Daher wurde der Basis-Prompt außerhalb von opencodex ersetzt.", - "codexSet.custom.prevLayer": "Vorherige Ebene", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "Nächste Ebene", "codexSet.lint.identity": "Hier wird eine andere Identität beansprucht als die von Codex festgelegte.", "codexSet.lint.foreignTool": "Tools stammen aus der Registry; ein Name an dieser Stelle erstellt kein Tool.", "codexSet.lint.placeholder": "Anweisungen werden nicht von einer Template-Engine verarbeitet. Dieser Inhalt wird daher wörtlich gesendet.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index da6908353f9..6624ea15da4 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1404,9 +1404,6 @@ export const en = { "codexSet.custom.adoptConfirm": "Import as a layer", "codexSet.custom.adoptRefused": "The existing value could not be imported.", "codexSet.custom.baseReplaced": "model_instructions_file is set to {path}, so something outside opencodex has replaced the base prompt.", - "codexSet.custom.prevLayer": "Previous layer", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "Next layer", "codexSet.lint.identity": "This claims a different identity from the one Codex establishes.", "codexSet.lint.foreignTool": "Tools come from the registry; naming one here does not create it.", "codexSet.lint.placeholder": "No template engine runs over instructions, so this ships literally.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a332527bce3..12678eaee33 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1377,9 +1377,6 @@ export const fr: Record = { "codexSet.custom.adoptConfirm": "Importer comme couche", "codexSet.custom.adoptRefused": "La valeur existante n’a pas pu être importée.", "codexSet.custom.baseReplaced": "model_instructions_file est défini sur {path} : un élément extérieur à opencodex a donc remplacé l’invite de base.", - "codexSet.custom.prevLayer": "Couche précédente", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "Couche suivante", "codexSet.lint.identity": "Ce texte revendique une identité différente de celle établie par Codex.", "codexSet.lint.foreignTool": "Les outils proviennent du registre ; en nommer un ici ne le crée pas.", "codexSet.lint.placeholder": "Aucun moteur de modèles ne traite les instructions ; ce texte sera donc envoyé tel quel.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 968a8ae45ad..0d78de4c8fa 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1337,9 +1337,6 @@ export const ja: Record = { "codexSet.custom.adoptConfirm": "レイヤーとしてインポート", "codexSet.custom.adoptRefused": "既存の値をインポートできませんでした。", "codexSet.custom.baseReplaced": "model_instructions_file が {path} に設定されているため、opencodex の外部で基本プロンプトが置き換えられています。", - "codexSet.custom.prevLayer": "前のレイヤー", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "次のレイヤー", "codexSet.lint.identity": "Codex が設定するものとは異なるアイデンティティを名乗っています。", "codexSet.lint.foreignTool": "ツールはレジストリから提供されます。ここに名前を書いてもツールは作成されません。", "codexSet.lint.placeholder": "指示にはテンプレートエンジンが適用されないため、このまま送信されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 63d3430d11a..a4fd1c92db4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -936,9 +936,6 @@ export const ko: Record = { "codexSet.custom.adoptConfirm": "레이어로 가져오기", "codexSet.custom.adoptRefused": "기존 값을 가져오지 못했습니다.", "codexSet.custom.baseReplaced": "model_instructions_file이 {path}(으)로 설정되어 opencodex 외부에서 기본 프롬프트를 교체했습니다.", - "codexSet.custom.prevLayer": "이전 레이어", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "다음 레이어", "codexSet.lint.identity": "Codex가 설정한 것과 다른 정체성을 주장합니다.", "codexSet.lint.foreignTool": "도구는 레지스트리에서 제공됩니다. 여기서 이름을 지정해도 도구가 생성되지 않습니다.", "codexSet.lint.placeholder": "지침에는 템플릿 엔진이 실행되지 않으므로 이 내용이 그대로 전달됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 3f4dcc0d6c7..61eddd3aec9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1388,9 +1388,6 @@ export const ru: Record = { "codexSet.custom.adoptConfirm": "Импортировать как слой", "codexSet.custom.adoptRefused": "Не удалось импортировать существующее значение.", "codexSet.custom.baseReplaced": "Для model_instructions_file задан путь {path}, поэтому базовый промпт заменён за пределами opencodex.", - "codexSet.custom.prevLayer": "Предыдущий слой", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "Следующий слой", "codexSet.lint.identity": "Здесь заявлена личность, отличная от той, которую задаёт Codex.", "codexSet.lint.foreignTool": "Инструменты предоставляет реестр; упоминание инструмента здесь не создаёт его.", "codexSet.lint.placeholder": "Инструкции не обрабатываются шаблонизатором, поэтому этот текст будет отправлен буквально.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2dc7dca4583..d1bb12bf00f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1395,9 +1395,6 @@ export const tr: Record = { "codexSet.custom.adoptConfirm": "Katman olarak içe aktar", "codexSet.custom.adoptRefused": "Mevcut değer içe aktarılamadı.", "codexSet.custom.baseReplaced": "model_instructions_file {path} olarak ayarlandığından, opencodex dışındaki bir öğe temel istemi değiştirmiş.", - "codexSet.custom.prevLayer": "Önceki katman", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "Sonraki katman", "codexSet.lint.identity": "Bu, Codex tarafından belirlenenden farklı bir kimlik iddia ediyor.", "codexSet.lint.foreignTool": "Araçlar kayıt defterinden gelir; burada bir aracın adını belirtmek onu oluşturmaz.", "codexSet.lint.placeholder": "Talimatlar bir şablon motorundan geçmez, bu nedenle bu metin olduğu gibi gönderilir.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 59a7ef69add..27118c4eb16 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1170,9 +1170,6 @@ export const zhTW: Record = { "codexSet.custom.adoptConfirm": "匯入為層", "codexSet.custom.adoptRefused": "無法匯入現有值。", "codexSet.custom.baseReplaced": "model_instructions_file 已設為 {path},因此 opencodex 外部的內容已取代基礎提示詞。", - "codexSet.custom.prevLayer": "上一層", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "下一層", "codexSet.lint.identity": "此內容宣稱了與 Codex 所設定身分不同的身分。", "codexSet.lint.foreignTool": "工具由登錄檔提供;在此指定名稱並不會建立工具。", "codexSet.lint.placeholder": "指示不會經過範本引擎處理,因此此內容會依原樣傳送。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d9ba0115837..98567b595de 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -929,9 +929,6 @@ export const zh: Record = { "codexSet.custom.adoptConfirm": "导入为层", "codexSet.custom.adoptRefused": "无法导入现有值。", "codexSet.custom.baseReplaced": "model_instructions_file 已设置为 {path},因此 opencodex 外部的内容已替换基础提示词。", - "codexSet.custom.prevLayer": "上一层", - "codexSet.custom.navPosition": "{position} / {total}", - "codexSet.custom.nextLayer": "下一层", "codexSet.lint.identity": "此内容声明了与 Codex 所设定身份不同的身份。", "codexSet.lint.foreignTool": "工具由注册表提供;在此指定名称并不会创建工具。", "codexSet.lint.placeholder": "指令不会经过模板引擎处理,因此此内容会按原样发送。",