diff --git a/extensions/README.md b/extensions/README.md index 7d22350..dacb7f9 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -1,6 +1,6 @@ -# Расширение для Pi +# Расширение Pi -Расширение [`pohuy.ts`](./pohuy.ts) добавляет команду `/pohuy` и интерактивные настройки источников стиля. +[`pohuy.ts`](./pohuy.ts) добавляет команду `/pohuy` и меню настройки стиля. ## Установка @@ -10,9 +10,9 @@ pi install git:github.com/smixs/pohuy После установки или обновления выполните `/reload`. -## Команды и переключение режима +## Команды -`/pohuy` открывает настройки в TUI. Быстрые команды переключают калибр без открытия интерфейса: +`/pohuy` открывает меню. Режим можно сменить без меню: ```text /pohuy lite @@ -21,37 +21,79 @@ pi install git:github.com/smixs/pohuy /pohuy normal ``` -`normal` выключает стиль ответов и удаляет управляемый блок Pohuy из системного промпта. +`normal` выключает стиль и удаляет управляемый блок Pohuy из системного промпта. -Те же переключатели работают отдельными фразами: +Те же действия доступны через фразы: -- «та мне похуй» и «заебал» включают `full`; -- «нормальный режим» и «хватит материться» выключают стиль. +- "та мне похуй" и "заебал" включают `full`; +- "нормальный режим" и "хватит материться" включают `normal`. -Распознанная фраза работает как управляющая команда и не отправляется модели как пользовательский запрос. +Расширение обрабатывает такую фразу как команду и не отправляет её модели. ## Настройки -### Mode +### Режим -Выбирает `normal`, `lite`, `full` или `ultra`. +Доступны `normal`, `lite`, `full` и `ultra`. -### Source preset +### Источники -- `tier-defaults` использует стандартный набор секций выбранного уровня; -- `custom` сохраняет пользовательский набор секций. +`tier-defaults` выбирает стандартные секции текущего режима. `custom` хранит ручной выбор. -Разделы `Skill`, `Dictionary` и `Scenes` позволяют независимо включать найденные секции из [`SKILL.md`](../skills/pohuy/SKILL.md), [`slovar.md`](../skills/pohuy/references/slovar.md) и [`sceny.md`](../skills/pohuy/references/sceny.md). Невыбранные дополнительные секции в системный промпт не попадают. +Вкладки `Skill`, `Словарь` и `Сцены` строятся из следующих файлов: -Выбранные источники компилируются без потери авторского материала: +- [`SKILL.md`](docs://page/project/skills/pohuy/SKILL.md); +- [`slovar.md`](docs://page/project/skills/pohuy/references/slovar.md); +- [`sceny.md`](docs://page/project/skills/pohuy/references/sceny.md); +- [`huenitiv.md`](docs://page/project/skills/pohuy/references/huenitiv.md). -- если вместе с рабочим минимумом выбрана полная словарная категория, краткий вариант этой категории из минимума не дублируется; -- в сценах с вариантами `lite`, `full` и `ultra` остаётся только пример активного уровня; -- остальные примеры и выбранные сцены сохраняются полностью, без символьной обрезки, token cap или скрытого лимита количества. +`Space` переключает запись, группу, сцену или ресурс. Родитель показывает состояние всех дочерних элементов: включено, выключено или частично. + +Отключение главного компонента прекращает добавление стилевых инструкций в контекст. Выбор словаря, сцен и хуенитивов при этом сохраняется. + +### Интерфейс + +Меню занимает доступную ширину и до 60% высоты терминала. Размер рамки не меняется при навигации. Списки и документы прокручиваются внутри неё. + +В широком терминале дерево и карточка видны одновременно. `Tab` и `Shift+Tab` меняют активную панель. `f` разворачивает её и возвращает разделённый вид. + +В узком терминале сначала видно дерево. `Tab` или `Enter` открывает карточку выбранного элемента. Повторный `Tab`, `Backspace` или `Esc` возвращает к дереву. + +Карточка показывает состояние, источник, описание и текст выбранного раздела. Для сцены выводится полный пример. Для группы сверху указан счётчик включённых дочерних элементов. Список потомков второй раз не печатается. + +Остальные действия: + +- `/` ищет по названиям, тексту, примерам, алиасам и путям; +- `Enter` в результатах поиска показывает элемент в дереве; +- `c` копирует путь и строку источника; +- `←` или `h` возвращает к родителю либо сворачивает группу; +- `→` или `l` раскрывает группу либо выбирает первый пункт; +- `1`, `2`, `3` и `4` открывают вкладку напрямую; +- `[` и `]` переключают вкладки; +- `?` открывает полную справку по клавишам. + +Нижняя строка показывает действия для текущего экрана. Если места мало, второстепенные подсказки остаются в `?`. + +### Вкладка Skill + +Вкладка содержит главный компонент, рабочий словарь, сцены и хуенитивы. Карточка ресурса показывает его состояние, источник и текст файла. YAML frontmatter выводится отдельно. Многострочное поле `description` рендерится как Markdown-цитата. + +Хуенитивы по умолчанию включены в `ultra`. Их можно вручную включить в `lite` или `full` и выключить в любом режиме. + +`Enter` на рабочем словаре или сценах открывает соответствующую вкладку. Для переключения отдельной записи вернитесь в дерево, раскройте группу и нажмите `Space`. + +### Сборка инструкции + +Расширение сохраняет выбранный авторский текст без скрытой обрезки: + +- полная словарная категория заменяет её краткий вариант из рабочего минимума; +- из вариантов сцены `lite`, `full` и `ultra` остаётся пример текущего режима; +- хуенитивы добавляются текстом из `references/huenitiv.md`, а не ссылкой; +- число примеров и сцен не ограничивается. ## Хранение настроек -Выбор сохраняется глобально в `~/.pi/agent/settings.json` и действует после перезапуска Pi и в других проектах. Расширение изменяет только ключ `pohuy`: +Настройки хранятся в `~/.pi/agent/settings.json` и действуют во всех проектах. Расширение изменяет только ключ `pohuy`: ```json { @@ -61,9 +103,9 @@ pi install git:github.com/smixs/pohuy } ``` -Если `settings.json` содержит повреждённый JSON, расширение запускается с безопасными значениями по умолчанию. При следующем изменении настроек исходный файл сохраняется рядом как `settings.json.corrupt-*`, после чего записывается новый валидный файл. +Если файл содержит некорректный JSON, расширение запускается со значениями по умолчанию. Перед следующей записью повреждённый файл переименовывается в `settings.json.corrupt-*`. -Отсутствующий `selectedSections` использует defaults активного tier. Явный пустой массив отключает все дополнительные секции: +Если `selectedSections` отсутствует, используются стандартные секции режима. Пустой массив отключает все дополнительные секции: ```json { @@ -74,11 +116,27 @@ pi install git:github.com/smixs/pohuy } ``` -## Композиция системного промпта +Изменения отдельных записей хранятся в `itemOverrides`. Ключ состоит из источника, раздела и нормализованного заголовка. Строка источника вычисляется при загрузке. Явно выключенный термин удаляется из всего управляемого блока, включая повторы в других секциях: + +```json +{ + "pohuy": { + "tier": "full", + "skillEnabled": true, + "itemOverrides": { + "item:slovar:Образность: восклицания, звукопись, присказки:опа! пиздрик": false + } + } +} +``` + +`skillEnabled: false` выключает стиль, но не сбрасывает `selectedSections` и `itemOverrides`. + +## Системный промпт -Расширение сохраняет уже собранный системный промпт Pi и помещает управляемый блок стиля непосредственно перед пользовательским `appendSystemPrompt`, когда такой сегмент присутствует. Оно не пересобирает базовый промпт, поэтому внутренние и сторонние context lanes остаются в исходном порядке. +Расширение не пересобирает системный промпт Pi. Оно вставляет блок стиля перед `appendSystemPrompt`, если этот сегмент есть, и сохраняет порядок остального контекста. -Управляемый блок ограничен маркерами: +Блок ограничен маркерами: ```text @@ -86,13 +144,21 @@ pi install git:github.com/smixs/pohuy ``` -Перед вставкой расширение удаляет все завершённые блоки между собственными маркерами и добавляет один текущий экземпляр. Это делает обработчик идемпотентным при повторных вызовах и не затрагивает текст без полного парного диапазона маркеров. +Перед вставкой расширение удаляет завершённые блоки между этими маркерами и добавляет один актуальный блок. Непарные маркеры и чужой текст оно не трогает. + +Расширение удаляет только известные повторы между собственными источниками. Повторы во внешнем контексте должен устранять его владелец. + +## Код + +[`pohuy.ts`](./pohuy.ts) регистрирует события и команды Pi. Остальной код разделён по назначению: -Расширение не дедуплицирует произвольные project context или session lanes. Оно устраняет только известные семантические пересечения собственных источников. Если одинаковый внешний контекст приходит из нескольких владельцев, его нормализация и provenance относятся к сборщику промпта Pi, а не к этому пакету. +- [`pohuy/style-source.ts`](./pohuy/style-source.ts) читает Markdown, строит дерево и собирает инструкцию; +- [`pohuy/settings-store.ts`](./pohuy/settings-store.ts) читает, нормализует и атомарно записывает настройки; +- [`pohuy/settings-ui.ts`](./pohuy/settings-ui.ts) рендерит дерево, карточки, поиск и справку. -## Ограничения стиля +## Границы стиля -Стиль применяется только к естественному тексту ответов. Tool calls, structured output, код, команды, идентификаторы, строки ошибок, коммиты, PR и документация сохраняют требуемый формат. Для security-предупреждений и подтверждения необратимых операций действует режим без юмора. +Стиль меняет только обычный текст ответа. Он не меняет вызовы инструментов, структурированные данные, код, команды, идентификаторы, ошибки, коммиты, PR и документацию. Предупреждения безопасности и подтверждения необратимых операций остаются без юмора. ## Быстрая проверка @@ -101,10 +167,10 @@ pi install git:github.com/smixs/pohuy Pohuy enabled: ultra. > объясни, почему тест флакает -# ответ остаётся технически точным, но использует выбранный стиль +# ответ остаётся технически точным и использует выбранный стиль > /pohuy normal Pohuy disabled. Normal mode restored. ``` -В RPC и других режимах без TUI команда `/pohuy` без аргумента выводит предупреждение; быстрые команды с аргументом продолжают работать. +В RPC и других режимах без TUI команда `/pohuy` без аргумента выводит предупреждение. Команды с режимом продолжают работать. diff --git a/extensions/pohuy.test.ts b/extensions/pohuy.test.ts index a38fcd8..d5e9c9c 100644 --- a/extensions/pohuy.test.ts +++ b/extensions/pohuy.test.ts @@ -1,15 +1,23 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; +import { initTheme } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import { buildStylePolicy, + buildStylePrompt, + buildStylePromptReport, + createStyleSettingsComponent, + diffStoredSettings, fitSettingsRender, loadStyleSource, mergeStoredSettings, + normalizeStoredSettings, placeStylePromptAtAppendBoundary, selectTierVariant, stripManagedStyleBlocks, } from "./pohuy.js"; +import { SETTINGS_PATH } from "./pohuy/settings-store.js"; const START = ""; const END = ""; @@ -40,24 +48,37 @@ test("removes the managed block when normal mode is selected", () => { assert.equal(placeStylePromptAtAppendBoundary(base, "Append", undefined), "Base\n\nAppend"); }); -test("leaves unmatched project text intact", () => { - const base = `Base\n\n${START}\nunterminated project text`; +test("removes an unterminated managed block", () => { + const base = `Base\n\n${START}\nunterminated policy`; - assert.equal(stripManagedStyleBlocks(base), base); + assert.equal(stripManagedStyleBlocks(base), "Base"); }); -test("preserves malformed marker text before a later completed block", () => { +test("removes nested malformed and completed managed blocks", () => { const base = [ "Base", - `${START}\nunterminated project text`, + `${START}\nunterminated policy`, `${START}\nmanaged policy\n${END}`, "Append", ].join("\n\n"); - assert.equal( - stripManagedStyleBlocks(base), - ["Base", `${START}\nunterminated project text`, "Append"].join("\n\n"), - ); + assert.equal(stripManagedStyleBlocks(base), "Base"); +}); + +test("preserves the append boundary while replacing an unterminated managed block", () => { + const base = ["Base", `${START}\nunterminated policy`, "User append"].join("\n\n"); + + const result = placeStylePromptAtAppendBoundary(base, "User append", "current policy"); + + assert.equal(result, ["Base", `${START}\ncurrent policy\n${END}`, "User append"].join("\n\n")); +}); + +test("ignores append text that appears only inside the base prompt", () => { + const base = "Base mentions User append inside its main instruction.\n\nUnmanaged tail"; + + const result = placeStylePromptAtAppendBoundary(base, "User append", "current policy"); + + assert.equal(result, [base, `${START}\ncurrent policy\n${END}`].join("\n\n")); }); test("does not reformat prompts without completed managed blocks", () => { @@ -121,10 +142,147 @@ test("reported full configuration removes only known overlaps and inactive tier assert.doesNotMatch(policy, /^- lite:/gm); assert.doesNotMatch(policy, /^- ultra:/gm); assert.match(policy, /^- full: «Опизденеть можно:/m); + assert.match(policy, /Кластер ебашит, латенси в норме\.»\n- \*\*пиздато\*\*/); + assert.match(policy, /^## Уровень: full$/m); + assert.match(policy, /Примеры:\n- «Хуйня вопрос\./); + assert.doesNotMatch(policy, /Default:|\/pohuy lite|нормальный режим|Уровень держится до смены|Полные примеры по каждой|Полная версия с маппингами|Полный арсенал образности|references\/(?:sceny|slovar)\.md/); + assert.doesNotMatch(policy, /Scene examples are tone references|\| \*\*full\*\* \|/); + assert.match(policy, /Сцены задают тон, а не готовый текст/); assert.match(policy, /### Триумф после долгого дебага/); assert.match(policy, /таймзона в CI/); }); +test("a dictionary entry keeps its exact source path and can be disabled alone", async () => { + const source = await loadStyleSource(); + const imagery = source.roots.dictionary.find((node) => node.label.startsWith("Образность")); + assert.ok(imagery); + const entries = (nodes: typeof source.roots.dictionary): typeof source.roots.dictionary => + nodes.flatMap((node) => [node, ...entries(node.children)]); + const item = entries([imagery]).find((node) => node.label === "опа! пиздрик"); + assert.ok(item); + assert.equal(item.path, "references/slovar.md"); + const slovar = await readFile(new URL("../skills/pohuy/references/slovar.md", import.meta.url), "utf8"); + assert.match(slovar.split("\n")[item.line - 1] ?? "", /опа! пиздрик/); + assert.ok(entries([imagery]).some((node) => node.label === "ебать-копать")); + + const policy = buildStylePolicy({ + tier: "full", + selectedSections: [imagery.optionId!], + itemOverrides: { [item.id]: false }, + }, source); + assert.ok(policy); + assert.doesNotMatch(policy, /опа! пиздрик/); + assert.match(policy, /хуйня-муйня/); +}); + +test("an explicitly disabled term removes matching entries without altering unrelated prose", async () => { + const source = await loadStyleSource(); + const entries = (nodes: typeof source.roots.dictionary): typeof source.roots.dictionary => + nodes.flatMap((node) => [node, ...entries(node.children)]); + const item = entries(source.roots.dictionary).find((node) => node.label === "ебашит"); + assert.ok(item); + + const policy = buildStylePolicy({ + tier: "full", + selectedSections: [ + "skill:Словарь (рабочий минимум)", + "slovar:Состояния и статусы", + "sceny:1. Триумф — взлетело лучше, чем ждали", + ], + itemOverrides: { [item.id]: false }, + }, source); + + assert.ok(policy); + assert.doesNotMatch(policy, /^- (?:\*\*)?ебашит(?:\*\*)?\s+—/imu); + assert.match(policy, /Ебашит так, что алерты от скуки уснули\./u); + assert.match(policy, /Триумф — лучше, чем ждали \| охуенно, опизденеть можно, ебашит,/u); +}); + +test("disabling the style component preserves source selections", async () => { + const source = await loadStyleSource(); + const settings = { + tier: "ultra" as const, + skillEnabled: false, + selectedSections: ["sceny:Ревью говнокода"], + }; + assert.equal(buildStylePolicy(settings, source), undefined); + assert.deepEqual(settings.selectedSections, ["sceny:Ревью говнокода"]); +}); + +test("normalizes item overrides to meaningful deviations from inherited section state", async () => { + const source = await loadStyleSource(); + const settings = normalizeStoredSettings({ + tier: "full", + selectedSections: [ + "skill:Словарь (рабочий минимум)", + "slovar:Состояния и статусы", + ], + itemOverrides: { + "item:slovar:Состояния и статусы:ебашит": true, + "item:slovar:Действия:проёб": true, + "item:skill:Словарь (рабочий минимум):пиздрик": false, + "item:slovar:Состояния и статусы:unknown": false, + }, + }, source); + + assert.deepEqual(settings.itemOverrides, { + "item:slovar:Действия:проёб": true, + "item:skill:Словарь (рабочий минимум):пиздрик": false, + }); +}); + +test("huenitiv rules default to ultra and remain manually configurable in every active tier", async () => { + const source = await loadStyleSource(); + const resource = source.roots.skill.find((node) => node.id === "component:huenitiv-resource"); + assert.ok(resource); + assert.equal(resource.path, "references/huenitiv.md"); + assert.equal(resource.line, 1); + + const fullDefault = buildStylePolicy({ tier: "full" }, source); + assert.ok(fullDefault); + assert.doesNotMatch(fullDefault, /^# ХУЕНИТИВЫ/m); + assert.equal(buildStylePromptReport({ tier: "full" }, source).huenitivEnabled, false); + + const ultraDefault = buildStylePolicy({ tier: "ultra" }, source); + assert.ok(ultraDefault); + assert.equal(ultraDefault.match(/^# ХУЕНИТИВЫ/m)?.length, 1); + assert.match(ultraDefault, /зарядка-хуерядка/); + assert.equal(buildStylePromptReport({ tier: "ultra" }, source).huenitivEnabled, true); + + for (const tier of ["lite", "full"] as const) { + const manuallyEnabled = buildStylePolicy({ tier, selectedSections: ["huenitiv:rules"] }, source); + assert.ok(manuallyEnabled); + assert.match(manuallyEnabled, /^# ХУЕНИТИВЫ/m); + assert.match(manuallyEnabled, /зарядка-хуерядка/); + assert.doesNotMatch(manuallyEnabled, /Только в режиме \*\*ultra\*\*\./); + } + + const migrated = normalizeStoredSettings({ + tier: "full", + selectedSections: ["skill:Хуенитивы"], + }, source); + assert.deepEqual(migrated.selectedSections, ["huenitiv:rules"]); + assert.match(buildStylePolicy(migrated, source) ?? "", /^# ХУЕНИТИВЫ/m); +}); + +test("prompt report measures the exact managed Pohuy block", async () => { + const source = await loadStyleSource(); + const settings = { tier: "full" as const }; + const stylePrompt = buildStylePrompt(settings, source); + const report = buildStylePromptReport(settings, source); + + assert.ok(stylePrompt); + assert.equal(report.stylePrompt, stylePrompt); + assert.equal(report.managedBlock, `${START}\n${stylePrompt}\n${END}`); + assert.ok(report.managedBlock); + assert.equal(report.managedCharacters, report.managedBlock.length); + assert.equal(report.policyCharacters, buildStylePolicy(settings, source)?.length); + assert.ok(report.managedCharacters > report.policyCharacters); + assert.match(stylePrompt, /^Стиль ответов: Pohuy\./); + assert.match(stylePrompt, /Не меняй вызовы и результаты инструментов/); + assert.doesNotMatch(stylePrompt, /Active session style|Selected tier|Do not alter tool calls/); +}); + test("keeps skipping an inactive tier variant across blank continuation lines", () => { const content = [ "### Scene", @@ -176,6 +334,23 @@ test("preserves unrelated root and legacy Pohuy settings fields", () => { }); }); +test("persists only fields changed from the latest stored settings", () => { + assert.deepEqual( + diffStoredSettings( + { tier: "full", selectedSections: ["old"], itemOverrides: { keep: true } }, + { tier: "ultra", selectedSections: ["old"], itemOverrides: { keep: true } }, + ), + { tier: "ultra" }, + ); + assert.deepEqual( + diffStoredSettings( + { tier: "full", selectedSections: ["old"], itemOverrides: { stale: false } }, + { tier: "full" }, + ), + { selectedSections: null, itemOverrides: null }, + ); +}); + test("bounds settings output by terminal width and height", () => { const lines = ["123456", "abcdef", "third"]; @@ -188,6 +363,372 @@ test("bounds settings output by terminal width and height", () => { assert.deepEqual(fitSettingsRender(lines, 4, 0), []); }); +test("settings navigation keeps Tab inside the current section and uses available geometry", async () => { + initTheme("dark"); + const source = await loadStyleSource(); + const identityTheme = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + let requested = 0; + const terminal = { rows: 50 }; + const component = (createStyleSettingsComponent as (options: unknown) => { + render(width: number): string[]; + handleInput(data: string): void; + })({ + source, + getSettings: () => ({ tier: "full" }), + mutate: async () => undefined, + tui: { terminal, requestRender: () => { requested += 1; } }, + theme: identityTheme, + border: (text: string) => text, + done: () => undefined, + notify: () => undefined, + }); + + const initial = component.render(170); + assert.ok(initial.every((line) => visibleWidth(line) === 170)); + assert.equal(initial.length, Math.floor(terminal.rows * 0.6)); + assert.match(initial[1], /\[Общие\] Skill Словарь Сцены/); + assert.match(initial.at(-2) ?? "", /Tab подробности/); + assert.equal(initial.join("\n").match(/поиск/g)?.length, 1); + assert.match(initial[2], /┬/); + assert.match(initial[4], /┼/); + assert.match(initial.at(-3) ?? "", /┴/); + assert.match(initial.join("\n"), /Режим: full \(normal, lite, ultra\)/); + assert.match(initial.join("\n"), /Итоговая инструкция: [\d\s ]+ симв\./); + assert.doesNotMatch(initial.join("\n"), /Current mode:|Available values:|settings\.json:1/); + assert.doesNotMatch(initial.join("\n"), /ПАКЕТ|ОБЛАСТЬ|ИТОГ/); + const initialDetail = initial.slice(5, -3).map((line) => line.split("│")[2] ?? ""); + const metadataValueColumns = [ + ["СОСТОЯНИЕ", "включено"], + ["ИСТОЧНИК", "settings.json"], + ["ЧТО ДЕЛАЕТ", "normal"], + ["ЗАГРУЖЕНО ИЗ", SETTINGS_PATH], + ].map(([label, value]) => initialDetail.find((line) => line.includes(label))?.indexOf(value)); + assert.deepEqual([...new Set(metadataValueColumns)], [14]); + + component.handleInput("j"); + const promptPreview = component.render(170).join("\n"); + assert.match(promptPreview, /Итоговая инструкция Pohuy/); + assert.match(promptPreview, /символов в блоке POHUY/); + assert.match(promptPreview, /РАЗДЕЛЫ\s+1 из 29/); + assert.match(promptPreview, /ХУЕНИТИВЫ\s+выключены/); + assert.match(promptPreview, /НАБОР\s+стандартный для режима full/); + assert.match(promptPreview, /ПРАВКИ\s+нет/); + assert.doesNotMatch(promptPreview, /ВНЕ POHUY|ИСКЛЮЧЕНИЯ|1\/28;/); + component.handleInput("k"); + + component.handleInput("\t"); + const detailFocused = component.render(170); + assert.equal(detailFocused.length, initial.length); + assert.match(detailFocused[1], /\[Общие\] Skill Словарь Сцены/); + assert.match(detailFocused.at(-2) ?? "", /Tab к дереву/); + + component.handleInput("\x1b[Z"); + assert.match(component.render(170).at(-2) ?? "", /Tab подробности/); + + component.handleInput("]"); + const skillSection = component.render(170); + assert.match(skillSection[1], /Общие \[Skill\] Словарь Сцены/); + assert.match(skillSection[1], /включено: \d\/4/); + assert.equal(skillSection.length, initial.length); + const skillList = skillSection.slice(5, -3).map((line) => line.split("│")[1] ?? "").join("\n"); + assert.match(skillList, /Стилевой skill/); + assert.match(skillList, /Рабочий словарь/); + assert.match(skillList, /Сцены/); + assert.match(skillList, /Хуенитивы/); + assert.doesNotMatch(skillList, /Словарь \(рабочий минимум\)/); + const skillResource = skillSection.join("\n"); + assert.match(skillResource, /ИСТОЧНИК\s+SKILL\.md/); + assert.doesNotMatch(skillResource, /SKILL\.md:1/); + assert.match(skillResource, /ПО УМОЛЧАНИЮ\s+включён в lite, full и ultra/); + assert.match(skillResource, /РУЧНОЙ ВЫБОР\s+общий выключатель доступен в любом активном режиме/); + assert.match(skillResource, /МЕТАДАННЫЕ ФАЙЛА/); + assert.match(skillResource, /name:.*pohuy/); + assert.match(skillResource, /description:/); + assert.doesNotMatch(skillResource, /description: >/); + assert.match(skillResource, /СОДЕРЖИМОЕ ФАЙЛА/); + + component.handleInput("j"); + const dictionaryResource = component.render(170); + assert.doesNotMatch(dictionaryResource.join("\n"), /ACTION/); + const dictionaryCard = dictionaryResource.join("\n"); + assert.match(dictionaryCard, /ИСТОЧНИК\s+references\/slovar\.md/); + assert.doesNotMatch(dictionaryCard, /references\/slovar\.md:1/); + assert.match(dictionaryCard, /ПО УМОЛЧАНИЮ\s+набор записей зависит от режима/); + assert.match(dictionaryCard, /РУЧНОЙ ВЫБОР\s+записи и группы доступны в lite, full и ultra/); + assert.match(dictionaryCard, /СОДЕРЖИМОЕ ФАЙЛА/); + assert.match(dictionaryCard, /Словарь: расширение/); + assert.match(dictionaryResource.at(-2) ?? "", /Enter открыть/); + assert.match(dictionaryResource.at(-2) ?? "", /c копировать путь/); + component.handleInput("j"); + const scenesResource = component.render(170).join("\n"); + assert.match(scenesResource, /Примеры ответов для состояний проекта и типичных ситуаций\./); + assert.match(scenesResource, /ИСТОЧНИК\s+references\/sceny\.md/); + assert.doesNotMatch(scenesResource, /references\/sceny\.md:1/); + assert.match(scenesResource, /ПО УМОЛЧАНИЮ\s+набор сцен зависит от режима/); + assert.match(scenesResource, /РУЧНОЙ ВЫБОР\s+сцены доступны в lite, full и ultra/); + assert.match(scenesResource, /СОДЕРЖИМОЕ ФАЙЛА/); + assert.match(scenesResource, /Сцены: шкала состояний и эталонные примеры/); + component.handleInput("j"); + const huenitivResource = component.render(170).join("\n"); + assert.match(huenitivResource, /ИСТОЧНИК\s+references\/huenitiv\.md/); + assert.doesNotMatch(huenitivResource, /references\/huenitiv\.md:1/); + assert.match(huenitivResource, /ПО УМОЛЧАНИЮ\s+включены в ultra/); + assert.match(huenitivResource, /РУЧНОЙ ВЫБОР\s+доступны в lite, full и ultra/); + assert.match(huenitivResource, /СОДЕРЖИМОЕ ФАЙЛА/); + assert.match(huenitivResource, /зарядка.*хуерядка/); + component.handleInput("k"); + component.handleInput("k"); + component.handleInput("\r"); + const expandedViewport = component.render(170); + assert.match(expandedViewport[1], /Общие Skill \[Словарь\] Сцены/); + assert.equal(expandedViewport.length, Math.floor(terminal.rows * 0.6)); + assert.ok(expandedViewport.every((line) => visibleWidth(line) === 170)); + for (const width of [118, 140, 170, 240]) { + assert.ok(component.render(width).every((line) => visibleWidth(line) === width)); + } + + component.handleInput("/"); + const emptySearch = component.render(170); + assert.match(emptySearch[1], /\[ \] найдено: 0 │$/); + component.handleInput("d"); + const oneCharacterSearch = component.render(170); + assert.match(oneCharacterSearch[1], /\[ d \] найдено:/); + assert.match(oneCharacterSearch[1], /найдено: \d+ │$/); + component.handleInput("\x7f"); + component.handleInput("/"); + component.handleInput("?"); + assert.match(component.render(170)[1], /\[ \/\? \] найдено: 0 │$/); + component.handleInput("\x7f"); + component.handleInput("\x7f"); + for (const character of "пиздрик") component.handleInput(character); + const populatedSearch = component.render(170); + assert.match(populatedSearch[1], /\[ пиздрик \] найдено:/); + assert.match(populatedSearch[1], /найдено: \d+ │$/); + assert.equal(populatedSearch.length, emptySearch.length); + component.handleInput("\x1b"); + component.render(170); + + component.handleInput("\t"); + for (let index = 0; index < 100; index += 1) component.handleInput("\x1b[6~"); + const scrollEnd = component.render(170); + component.handleInput("\x1b[6~"); + assert.deepEqual(component.render(170), scrollEnd); + + terminal.rows = 4; + const compact = component.render(60); + assert.equal(compact.length, Math.floor(terminal.rows * 0.6)); + assert.ok(component.render(2).every((line) => visibleWidth(line) <= 2)); + assert.ok(requested >= 3); +}); + +test("settings UX supports opposite-pane scrolling, focus mode, help, and numeric tabs", async () => { + initTheme("dark"); + const source = await loadStyleSource(); + const identityTheme = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const component = (createStyleSettingsComponent as (options: unknown) => { + render(width: number): string[]; + handleInput(data: string): void; + })({ + source, + getSettings: () => ({ tier: "ultra" }), + mutate: async () => undefined, + tui: { terminal: { rows: 50 }, requestRender: () => undefined }, + theme: identityTheme, + border: (text: string) => text, + done: () => undefined, + notify: () => undefined, + }); + + component.render(170); + component.handleInput("j"); + const detailAtTop = component.render(170).join("\n"); + assert.match(detailAtTop, /Итоговая инструкция Pohuy/); + component.handleInput("J"); + const oppositePaneScrolled = component.render(170).join("\n"); + assert.doesNotMatch(oppositePaneScrolled, /Итоговая инструкция Pohuy/); + component.handleInput("K"); + assert.match(component.render(170).join("\n"), /Итоговая инструкция Pohuy/); + + component.handleInput("f"); + const listFocus = component.render(170).join("\n"); + assert.doesNotMatch(listFocus, /РЕЗУЛЬТАТЫ ПОИСКА\s+│\s+ЭЛЕМЕНТ/); + assert.doesNotMatch(listFocus, /Итоговая инструкция Pohuy/); + component.handleInput("f"); + component.handleInput("\t"); + component.handleInput("f"); + let detailFocusLines = component.render(170); + let detailFocus = detailFocusLines.join("\n"); + assert.match(detailFocus, /ПОДРОБНОСТИ/); + assert.match(detailFocus, /Итоговая инструкция Pohuy/); + assert.match(detailFocusLines.at(-2) ?? "", /↑↓ прокрутка/); + assert.doesNotMatch(detailFocus, /(?:j k|jk|PgUp|PgDn)/); + assert.equal(detailFocus.match(/\? помощь/g)?.length, 1); + for (let index = 0; index < 14; index += 1) component.handleInput("j"); + detailFocusLines = component.render(170); + detailFocus = detailFocusLines.join("\n"); + assert.match(detailFocus, /Мат идиоматический.*сказал бы живой человек/); + component.handleInput("f"); + + component.handleInput("4"); + assert.match(component.render(170)[1], /Общие Skill Словарь \[Сцены\]/); + component.handleInput("2"); + assert.match(component.render(170)[1], /Общие \[Skill\] Словарь Сцены/); + + component.handleInput("?"); + const help = component.render(170).join("\n"); + assert.match(help, /Pohuy: клавиши/); + assert.match(help, /Shift\+J\/K/); + assert.match(help, /развернуть активную панель/); + assert.match(help, /1 2 3 4/); + component.handleInput("\x03"); + assert.doesNotMatch(component.render(170).join("\n"), /Pohuy: клавиши/); + component.handleInput("?"); + component.handleInput("?"); + assert.doesNotMatch(component.render(170).join("\n"), /Pohuy: клавиши/); + + const compactFooter = component.render(69).slice(-3).join("\n"); + assert.match(compactFooter, /\? помощь/); +}); + +test("Space toggles a nested dictionary group while the detail panel is focused", async () => { + initTheme("dark"); + const source = await loadStyleSource(); + const identityTheme = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + let settings = { tier: "full" as const }; + let appliedPatch: { itemOverrides?: Record } | undefined; + const component = (createStyleSettingsComponent as (options: unknown) => { + render(width: number): string[]; + handleInput(data: string): void; + })({ + source, + getSettings: () => settings, + mutate: async (patch: { itemOverrides?: Record }) => { + appliedPatch = patch; + settings = { ...settings, ...patch } as typeof settings; + }, + tui: { terminal: { rows: 50 }, requestRender: () => undefined }, + theme: identityTheme, + border: (text: string) => text, + done: () => undefined, + notify: () => undefined, + }); + + component.handleInput("/"); + for (const character of "Лексика отношения к агентам") component.handleInput(character); + component.handleInput("\r"); + component.handleInput("\t"); + assert.match(component.render(170).at(-2) ?? "", /Space переключить группу/); + component.handleInput(" "); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(appliedPatch?.itemOverrides); + assert.ok(Object.keys(appliedPatch.itemOverrides).length > 0); + assert.ok(Object.values(appliedPatch.itemOverrides).every(Boolean)); + + component.handleInput("\t"); + component.handleInput("/"); + for (const character of "пиздарики") component.handleInput(character); + component.handleInput("\r"); + const entryCard = component.render(170).join("\n"); + assert.doesNotMatch(entryCard, /СОСЕДНИЕ ЗАПИСИ/); + assert.doesNotMatch(entryCard, /\*\*/); + assert.equal(entryCard.match(/лёгкая констатация наступившего провала/g)?.length, 1); + assert.match(component.render(170).at(-2) ?? "", /←\/h к родителю/); + + const narrowList = component.render(69); + assert.ok(narrowList.every((line) => visibleWidth(line) === 69)); + assert.match(narrowList.at(-2) ?? "", /↑↓ выбор.*Tab\/Enter карточка.*\? помощь/); + component.handleInput("\t"); + let narrowCard = component.render(69); + assert.match(narrowCard.join("\n"), /СОСЕДНИЕ ЗАПИСИ/); + assert.match(narrowCard.at(-2) ?? "", /Tab\/Backspace\/Esc к дереву/); + component.handleInput("\t"); + assert.match(component.render(69).join("\n"), /ВЫБРАНО\s+пиздарики/); + component.handleInput("\r"); + narrowCard = component.render(69); + assert.match(narrowCard.join("\n"), /СОСЕДНИЕ ЗАПИСИ/); + component.handleInput("\x1b"); + assert.match(component.render(69).join("\n"), /ВЫБРАНО\s+пиздарики/); + + component.handleInput("h"); + const parentSelected = component.render(170); + const parentList = parentSelected.slice(5, -3).map((line) => line.split("│")[1] ?? "").join("\n"); + assert.match(parentList, /→ .*Состояния и статусы/); + assert.match(parentSelected.at(-2) ?? "", /←\/h свернуть/); + component.handleInput("h"); + const collapsed = component.render(170); + const collapsedList = collapsed.slice(5, -3).map((line) => line.split("│")[1] ?? "").join("\n"); + assert.doesNotMatch(collapsedList, /пиздарики/); + const collapsedText = collapsed.join("\n"); + assert.match(collapsedText, /ВКЛЮЧЕНО\s+\d+ из \d+/); + assert.match(collapsedText, /ТЕКСТ РАЗДЕЛА/); + assert.match(collapsedText, /Кластер ебашит/); + assert.doesNotMatch(collapsedText, /ВНУТРИ \(только просмотр\)/); + assert.ok(collapsedText.indexOf("ВКЛЮЧЕНО") < collapsedText.indexOf("ТЕКСТ РАЗДЕЛА")); + assert.match(collapsed.at(-2) ?? "", /→\/l раскрыть/); + + const stateGroup = source.roots.dictionary.find((node) => node.label === "Состояния и статусы"); + assert.ok(stateGroup?.children[0]); + component.handleInput("l"); + component.handleInput("l"); + const firstChildSelected = component.render(170); + const firstChildLines = firstChildSelected.slice(5, -3).map((line) => line.split("│")[1] ?? ""); + const firstChildLine = firstChildLines.find((line) => line.includes(stateGroup.children[0].label)); + assert.ok(firstChildLine?.includes("→")); + + component.handleInput("]"); + component.handleInput("/"); + for (const character of "10. Катастрофа") component.handleInput(character); + component.handleInput("\r"); + const sceneFrame = component.render(170); + const sceneDetail = sceneFrame.slice(5, -3).map((line) => line.split("│")[2] ?? "").join("\n"); + assert.match(sceneDetail, /УРОВЕНЬ\s+10 из 10/); + assert.match(sceneDetail, /ЛЕКСИКА\s+полный пиздец/); + assert.match(sceneDetail, /СОВПАДЕНИЯ СО СЛОВАРЁМ/); + assert.match(sceneDetail, /Состояния и статусы\s+1 запись/); + assert.match(sceneDetail, /Образность: восклицания, звукопись, присказки\s+3 записи/); + assert.match(sceneDetail, /ПРИМЕРЫ ОТВЕТОВ/); + assert.doesNotMatch(sceneDetail, /### 10\.|ЧТО ДЕЛАЕТ|ШКАЛА|УСЛОВИЕ|1 совпадений/); + assert.equal(sceneDetail.match(/полный пиздец, высший пиздец, накрылось пиздой/g)?.length, 1); + const narrowSceneFrame = component.render(130).join("\n"); + assert.match(narrowSceneFrame, /Идея пользователя так себе.*…/); +}); + +test("active tab and search control use the same frame color", async () => { + initTheme("dark"); + const source = await loadStyleSource(); + const accent = (text: string) => `\x1b[35m${text}\x1b[39m`; + const component = (createStyleSettingsComponent as (options: unknown) => { render(width: number): string[] })({ + source, + getSettings: () => ({ tier: "full" }), + mutate: async () => undefined, + tui: { terminal: { rows: 50 }, requestRender: () => undefined }, + theme: { + fg: (color: string, text: string) => color === "accent" ? accent(text) : text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + }, + border: (text: string) => text, + done: () => undefined, + notify: () => undefined, + }); + const header = component.render(170)[1]; + assert.match(header, /\x1b\[35m\[Общие\]\x1b\[39m/); + assert.match(header, /\x1b\[35m\[\x1b\[39m.*\x1b\[35m\]\x1b\[39m/); +}); + test("repeated prompt injection is idempotent", () => { const first = placeStylePromptAtAppendBoundary("Base\n\nAppend", "Append", "policy"); const second = placeStylePromptAtAppendBoundary(first, "Append", "policy"); diff --git a/extensions/pohuy.ts b/extensions/pohuy.ts index 526da8e..bb95c63 100644 --- a/extensions/pohuy.ts +++ b/extensions/pohuy.ts @@ -1,831 +1,42 @@ +import { type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { AutocompleteItem } from "@earendil-works/pi-tui"; +import { createStyleSettingsComponent } from "./pohuy/settings-ui.js"; import { - getAgentDir, - withFileMutationQueue, - type ExtensionAPI, - type ExtensionContext, -} from "@earendil-works/pi-coding-agent"; + applySettingsPatch, + diffStoredSettings, + readStoredSettings, + saveStoredSettings, +} from "./pohuy/settings-store.js"; import { - type AutocompleteItem, - Key, - matchesKey, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, -} from "@earendil-works/pi-tui"; -import { randomUUID } from "node:crypto"; -import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; -import { dirname, join } from "node:path"; - -type Tier = "lite" | "full" | "ultra"; -type StoredTier = Tier | "normal"; -type JsonObject = Record; - -const SETTINGS_KEY = "pohuy"; -const SETTINGS_PATH = join(getAgentDir(), "settings.json"); -const TIERS = ["lite", "full", "ultra"] as const; -const USAGE = "Use /pohuy, /pohuy lite, /pohuy full, /pohuy ultra, or /pohuy normal."; -const PROMPT_START = ""; -const PROMPT_END = ""; + buildStylePrompt, + isTier, + loadStyleSource, + normalizeStoredSettings, + placeStylePromptAtAppendBoundary, + sameStoredSettings, + TIERS, + type SettingsPatch, + type StoredSettings, + type StoredTier, + type StyleSource, +} from "./pohuy/style-source.js"; + +export { + buildStylePolicy, + buildStylePrompt, + buildStylePromptReport, + loadStyleSource, + normalizeStoredSettings, + placeStylePromptAtAppendBoundary, + selectTierVariant, + stripManagedStyleBlocks, +} from "./pohuy/style-source.js"; +export { diffStoredSettings, fitSettingsRender, mergeStoredSettings } from "./pohuy/settings-store.js"; +export { createStyleSettingsComponent } from "./pohuy/settings-ui.js"; + +const USAGE = "Используйте /pohuy, /pohuy lite, /pohuy full, /pohuy ultra или /pohuy normal."; const ENABLE_PHRASES = new Set(["та мне похуй", "заебал"]); const DISABLE_PHRASES = new Set(["нормальный режим", "хватит материться"]); -const SKILL_PATH = new URL("../skills/pohuy/SKILL.md", import.meta.url); -const SLOVAR_PATH = new URL("../skills/pohuy/references/slovar.md", import.meta.url); -const SCENES_PATH = new URL("../skills/pohuy/references/sceny.md", import.meta.url); -const CORE_SKILL_SECTIONS = [ - "Persistence", - "Правила", - "Шкала состояний проекта", - "Auto-Clarity (мат выключается)", - "Boundaries", -] as const; -const OPTIONAL_SKILL_SECTIONS = [ - "Словарь (рабочий минимум)", - "Хуенитивы", -] as const; -const SCENE_FRAMING = - "Scene examples are tone references, not scripts. Never quote them verbatim; adapt them to the current situation."; -const DEFAULT_ULTRA_SCENES = [ - "Легаси-археология", - "Каскадный отказ", - "Триумф после долгого дебага", -] as const; -const FEATURE_STATE_VALUES = ["enabled", "disabled", "selected"] as const; -const SOURCE_PRESET_VALUES = ["tier-defaults", "custom"] as const; -const SETTINGS_SECTIONS = ["general", "skill", "dictionary", "scenes"] as const; -const SETTINGS_SECTION_LABELS: Record = { - general: "General", - skill: "Skill", - dictionary: "Dictionary", - scenes: "Scenes", -}; -const NON_OPTIONAL_DICTIONARY_PREFIXES = ["Чего в словаре нет"] as const; -const DICTIONARY_DESCRIPTIONS: Record = { - "Состояния и статусы": "Лексика для оценки состояния проекта: от нормальной работы до полного отказа.", - "Действия": "Глаголы и выражения для работы, ошибок, исправлений, ожидания и бесполезной возни.", - "Оценки и количества": "Оценки масштаба, количества, уверенности и значимости.", - "Сущности": "Названия для кода, артефактов, процессов и прочих технических сущностей.", -}; - -type FeatureState = (typeof FEATURE_STATE_VALUES)[number]; -type SourcePreset = (typeof SOURCE_PRESET_VALUES)[number]; -type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; -type StoredSettings = { - tier: StoredTier; - selectedSections?: string[]; -}; -type SettingsItem = { - id: string; - label: string; - description?: string; - currentValue: string; - values: string[]; -}; -type SourceOption = { - id: string; - section: Exclude; - label: string; - description: string; - content: string; -}; -type StyleSource = { - common: string[]; - tiers: Record; - options: SourceOption[]; -}; - -const WORKING_MINIMUM_OVERLAPS: Record = { - [sourceId("slovar", "Состояния и статусы")]: "Состояние:", - [sourceId("slovar", "Действия")]: "Действия:", - [sourceId("slovar", "Оценки и количества")]: "Связки и оценки:", - [sourceId("slovar", "Сущности")]: "Сущности:", -}; -const WORKING_MINIMUM_LABELS = [...Object.values(WORKING_MINIMUM_OVERLAPS), "Присказки"]; - -function isJsonObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isTier(value: unknown): value is Tier { - return typeof value === "string" && (TIERS as readonly string[]).includes(value); -} - -function extractHeadingSection(markdown: string, depth: number, heading: string, source: string): string { - const lines = markdown.split("\n"); - const marker = `${"#".repeat(depth)} ${heading}`; - const start = lines.findIndex((line) => line.trimEnd() === marker); - if (start === -1) throw new Error(`Missing ${source} section: ${heading}`); - - const next = lines.findIndex((line, index) => { - if (index <= start) return false; - const match = line.match(/^(#{1,6})\s/); - return match !== null && match[1].length <= depth; - }); - const body = lines.slice(start + 1, next === -1 ? undefined : next).join("\n").trim(); - if (!body) throw new Error(`Empty ${source} section: ${heading}`); - return `${marker}\n\n${body}`; -} - -function extractSkillSection(markdown: string, heading: string): string { - return extractHeadingSection(markdown, 2, heading, "SKILL.md"); -} - -function headingsAtDepth(markdown: string, depth: number): string[] { - const prefix = `${"#".repeat(depth)} `; - return markdown - .split("\n") - .filter((line) => line.startsWith(prefix) && !line.startsWith(`${prefix}#`)) - .map((line) => line.slice(prefix.length).trim()) - .filter(Boolean); -} - -function requiredHeadingWithPrefix(markdown: string, depth: number, prefix: string): string { - const heading = headingsAtDepth(markdown, depth).find((candidate) => candidate.startsWith(prefix)); - if (!heading) throw new Error(`Missing heading beginning with: ${prefix}`); - return heading; -} - -function sourceId(source: "skill" | "slovar" | "sceny", heading: string): string { - return `${source}:${heading}`; -} - -function plainMarkdownText(text: string): string { - return text - .replace(/^>\s?/gm, "") - .replace(/^[-*]\s+/gm, "") - .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/`([^`]+)`/g, "$1") - .replace(/\s+/g, " ") - .trim(); -} - -function firstContentBlock(content: string): string { - const lines = content.split("\n").slice(1); - const start = lines.findIndex((line) => line.trim() !== ""); - if (start === -1) return ""; - const endOffset = lines.slice(start).findIndex((line) => line.trim() === ""); - const end = endOffset === -1 ? lines.length : start + endOffset; - return plainMarkdownText(lines.slice(start, end).join("\n")); -} - -function sourceOption( - source: "skill" | "slovar" | "sceny", - heading: string, - content: string, -): SourceOption { - return { - id: sourceId(source, heading), - section: source === "skill" ? "skill" : source === "slovar" ? "dictionary" : "scenes", - label: heading, - description: source === "slovar" - ? DICTIONARY_DESCRIPTIONS[heading] ?? firstContentBlock(content) - : firstContentBlock(content), - content, - }; -} - -function extractTierPolicy(levels: string, tier: Tier): string { - const lines = levels.split("\n"); - const tableRow = lines.find((line) => line.startsWith(`| **${tier}** |`)); - if (!tableRow) throw new Error(`Missing SKILL.md tier table row: ${tier}`); - - const examples: string[] = []; - for (let index = 0; index < lines.length; index += 1) { - if (!lines[index].startsWith(`- ${tier}:`)) continue; - const block = [lines[index]]; - while (index + 1 < lines.length && /^\s{2,}\S/.test(lines[index + 1])) { - block.push(lines[index + 1]); - index += 1; - } - examples.push(block.join("\n")); - } - if (examples.length === 0) throw new Error(`Missing SKILL.md tier examples: ${tier}`); - - return [`## Уровень ${tier}`, tableRow, ...examples].join("\n\n"); -} - -export async function loadStyleSource(): Promise { - const [skill, slovar, scenes] = await Promise.all([ - readFile(SKILL_PATH, "utf8"), - readFile(SLOVAR_PATH, "utf8"), - readFile(SCENES_PATH, "utf8"), - ]); - const levels = extractSkillSection(skill, "Уровни"); - const nonOptionalDictionaryHeading = requiredHeadingWithPrefix( - slovar, - 2, - NON_OPTIONAL_DICTIONARY_PREFIXES[0], - ); - const common = [ - ...CORE_SKILL_SECTIONS.map((heading) => extractSkillSection(skill, heading)), - extractHeadingSection(slovar, 2, nonOptionalDictionaryHeading, "slovar.md"), - SCENE_FRAMING, - ]; - const options = [ - ...OPTIONAL_SKILL_SECTIONS.map((heading) => - sourceOption("skill", heading, extractSkillSection(skill, heading)) - ), - ...headingsAtDepth(slovar, 2) - .filter((heading) => - !NON_OPTIONAL_DICTIONARY_PREFIXES.some((prefix) => heading.startsWith(prefix)) - ) - .map((heading) => - sourceOption("slovar", heading, extractHeadingSection(slovar, 2, heading, "slovar.md")) - ), - ...headingsAtDepth(scenes, 3).map((heading) => - sourceOption("sceny", heading, extractHeadingSection(scenes, 3, heading, "sceny.md")) - ), - ]; - - const source: StyleSource = { - common, - options, - tiers: Object.fromEntries( - TIERS.map((tier) => [tier, extractTierPolicy(levels, tier)]), - ) as Record, - }; - assertDefaultSections(source); - return source; -} - -function defaultSectionIds(tier: StoredTier): string[] { - if (tier === "lite" || tier === "normal") return []; - const full = OPTIONAL_SKILL_SECTIONS.map((heading) => sourceId("skill", heading)); - if (tier === "full") return full; - return [ - ...full, - sourceId("slovar", "Образность: восклицания, звукопись, присказки"), - ...DEFAULT_ULTRA_SCENES.map((heading) => sourceId("sceny", heading)), - ]; -} - -function assertDefaultSections(source: StyleSource): void { - const known = new Set(source.options.map((option) => option.id)); - const required = new Set([ - ...defaultSectionIds("full"), - ...defaultSectionIds("ultra"), - ]); - const missing = [...required].filter((id) => !known.has(id)); - if (missing.length > 0) throw new Error(`Missing default policy sections: ${missing.join(", ")}`); -} - -function selectedSectionIds(settings: StoredSettings, source: StyleSource): string[] { - const selected = new Set(settings.selectedSections ?? defaultSectionIds(settings.tier)); - return source.options.filter((option) => selected.has(option.id)).map((option) => option.id); -} - -function effectiveSectionIds(settings: StoredSettings, source: StyleSource): string[] { - return settings.tier === "normal" ? [] : selectedSectionIds(settings, source); -} - -function normalizeStoredSettings(settings: StoredSettings, source: StyleSource): StoredSettings { - if (settings.selectedSections === undefined) return settings; - return { ...settings, selectedSections: selectedSectionIds(settings, source) }; -} - -function sameStoredSettings(left: StoredSettings, right: StoredSettings): boolean { - if (left.tier !== right.tier) return false; - if (left.selectedSections === undefined || right.selectedSections === undefined) { - return left.selectedSections === right.selectedSections; - } - return left.selectedSections.length === right.selectedSections.length && - left.selectedSections.every((id, index) => id === right.selectedSections?.[index]); -} - -function fullSettingsPatch(settings: StoredSettings): SettingsPatch { - return { - tier: settings.tier, - selectedSections: settings.selectedSections === undefined ? null : settings.selectedSections, - }; -} - -function removeWorkingMinimumOverlaps(content: string, selected: Set): string { - const removedLabels = new Set( - Object.entries(WORKING_MINIMUM_OVERLAPS) - .filter(([id]) => selected.has(id)) - .map(([, label]) => label), - ); - if (removedLabels.size === 0) return content; - - const lines = content.split("\n"); - const result: string[] = []; - let skipping = false; - for (const line of lines) { - const trimmed = line.trim(); - const label = WORKING_MINIMUM_LABELS.find((candidate) => - candidate === "Присказки" ? trimmed.startsWith("Присказки") : trimmed === candidate - ); - if (label) skipping = removedLabels.has(label); - if (!skipping) result.push(line); - } - return result.join("\n").replace(/\n{3,}/g, "\n\n").trim(); -} - -export function selectTierVariant(content: string, tier: Tier): string { - const lines = content.split("\n"); - const result: string[] = []; - let skippingVariant = false; - let pendingBlankLines: string[] = []; - - for (const line of lines) { - const variant = line.match(/^- (lite|full|ultra):/); - if (variant) { - pendingBlankLines = []; - skippingVariant = variant[1] !== tier; - if (!skippingVariant) result.push(line); - continue; - } - if (skippingVariant) { - if (line.trim() === "") { - pendingBlankLines.push(line); - continue; - } - if (/^\s{2,}\S/.test(line)) { - pendingBlankLines = []; - continue; - } - result.push(...pendingBlankLines); - pendingBlankLines = []; - skippingVariant = false; - } - result.push(line); - } - return result.join("\n").replace(/\n{3,}/g, "\n\n").trim(); -} - -function compileSelectedOption(option: SourceOption, selected: Set, tier: Tier): string { - let content = option.content; - if (option.id === sourceId("skill", "Словарь (рабочий минимум)")) { - content = removeWorkingMinimumOverlaps(content, selected); - } - return selectTierVariant(content, tier); -} - -export function buildStylePolicy(settings: StoredSettings, source: StyleSource): string | undefined { - const tier = settings.tier; - if (tier === "normal") return undefined; - const selected = new Set(effectiveSectionIds(settings, source)); - return [ - ...source.common, - ...source.options - .filter((option) => selected.has(option.id)) - .map((option) => compileSelectedOption(option, selected, tier)), - source.tiers[tier], - ].filter(Boolean).join("\n\n"); -} - -function managedStyleBlock(stylePrompt: string | undefined): string | undefined { - return stylePrompt ? `${PROMPT_START}\n${stylePrompt}\n${PROMPT_END}` : undefined; -} - -export function stripManagedStyleBlocks(prompt: string): string { - const openStarts: number[] = []; - const ranges: Array<{ start: number; end: number }> = []; - let cursor = 0; - - while (cursor < prompt.length) { - const start = prompt.indexOf(PROMPT_START, cursor); - const end = prompt.indexOf(PROMPT_END, cursor); - if (start === -1 && end === -1) break; - - if (start !== -1 && (end === -1 || start < end)) { - openStarts.push(start); - cursor = start + PROMPT_START.length; - continue; - } - - if (openStarts.length > 0) { - ranges.push({ - start: openStarts.pop()!, - end: end + PROMPT_END.length, - }); - } - cursor = end + PROMPT_END.length; - } - - if (ranges.length === 0) return prompt; - - const merged = ranges - .sort((left, right) => left.start - right.start) - .reduce>((result, range) => { - const previous = result.at(-1); - if (previous && range.start <= previous.end) { - previous.end = Math.max(previous.end, range.end); - } else { - result.push({ ...range }); - } - return result; - }, []); - const expanded = merged - .map((range) => { - let { start, end } = range; - while (start > 0 && prompt[start - 1] === "\n") start -= 1; - while (end < prompt.length && prompt[end] === "\n") end += 1; - return { start, end }; - }) - .reduce>((result, range) => { - const previous = result.at(-1); - if (previous && range.start <= previous.end) { - previous.end = Math.max(previous.end, range.end); - } else { - result.push({ ...range }); - } - return result; - }, []); - - let result = ""; - cursor = 0; - for (const range of expanded) { - result += prompt.slice(cursor, range.start); - if (result.length > 0 && range.end < prompt.length) result += "\n\n"; - cursor = range.end; - } - result += prompt.slice(cursor); - return result; -} - -export function placeStylePromptAtAppendBoundary( - basePrompt: string, - appendSystemPrompt: string | undefined, - stylePrompt: string | undefined, -): string { - const cleanBase = stripManagedStyleBlocks(basePrompt); - const block = managedStyleBlock(stylePrompt); - if (!block) return cleanBase; - - const append = appendSystemPrompt?.trim(); - const appendIndex = append ? cleanBase.lastIndexOf(append) : -1; - if (append && appendIndex >= 0) { - const before = cleanBase.slice(0, appendIndex).replace(/\n+$/, ""); - const after = cleanBase.slice(appendIndex).replace(/^\n+/, ""); - return [before, block, after].filter(Boolean).join("\n\n"); - } - return cleanBase ? `${cleanBase}\n\n${block}` : block; -} - -async function readStoredSettings(): Promise { - try { - const root: unknown = JSON.parse(await readFile(SETTINGS_PATH, "utf8")); - if (!isJsonObject(root) || !isJsonObject(root[SETTINGS_KEY])) { - return { tier: "normal" }; - } - - const record = root[SETTINGS_KEY]; - const rawTier = record.tier; - const tier = rawTier === "normal" || isTier(rawTier) ? rawTier : "normal"; - const selectedSections = Array.isArray(record.selectedSections) - ? [...new Set(record.selectedSections.filter((value): value is string => typeof value === "string"))] - : undefined; - return selectedSections === undefined ? { tier } : { tier, selectedSections }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) { - return { tier: "normal" }; - } - throw error; - } -} - -type SettingsPatch = { - tier?: StoredTier; - selectedSections?: string[] | null; -}; - -function applySettingsPatch(settings: StoredSettings, patch: SettingsPatch): StoredSettings { - const next: StoredSettings = { - ...settings, - tier: patch.tier ?? settings.tier, - }; - if (patch.selectedSections === null) { - delete next.selectedSections; - } else if (patch.selectedSections !== undefined) { - next.selectedSections = [...new Set(patch.selectedSections)]; - } - return next; -} - -function sourceSettingId(id: string): string { - return `source:${id}`; -} - -function sourceIdFromSetting(id: string): string | undefined { - return id.startsWith("source:") ? id.slice("source:".length) : undefined; -} - -function buildSettingsItems( - section: SettingsSection, - settings: StoredSettings, - source: StyleSource, -): SettingsItem[] { - if (section === "general") { - const preset: SourcePreset = settings.selectedSections === undefined ? "tier-defaults" : "custom"; - return [ - { - id: "tier", - label: "Mode", - description: "Response style and default source selection.", - currentValue: settings.tier, - values: ["normal", ...TIERS], - }, - { - id: "sourcePreset", - label: "Source preset", - description: "Follow the selected tier defaults or keep a custom section set.", - currentValue: preset, - values: [...SOURCE_PRESET_VALUES], - }, - ]; - } - - const active = new Set(effectiveSectionIds(settings, source)); - const selected = new Set(selectedSectionIds(settings, source)); - return source.options - .filter((option) => option.section === section) - .map((option) => { - const currentValue = active.has(option.id) - ? "enabled" - : selected.has(option.id) - ? "selected" - : "disabled"; - const values = settings.tier === "normal" - ? currentValue === "selected" - ? ["selected", "disabled"] - : ["disabled", "selected"] - : ["enabled", "disabled"]; - return { - id: sourceSettingId(option.id), - label: option.label, - description: option.description, - currentValue, - values, - }; - }); -} - -function adjacentSettingsSection( - section: SettingsSection, - direction: "forward" | "backward", -): SettingsSection { - const index = SETTINGS_SECTIONS.indexOf(section); - const offset = direction === "forward" ? 1 : -1; - return SETTINGS_SECTIONS[(index + offset + SETTINGS_SECTIONS.length) % SETTINGS_SECTIONS.length]; -} - -function renderSettingsTabs( - section: SettingsSection, - theme: ExtensionContext["ui"]["theme"], - width: number, -): string { - const tabs = SETTINGS_SECTIONS.map((candidate) => { - const label = SETTINGS_SECTION_LABELS[candidate]; - return candidate === section - ? theme.fg("accent", theme.bold(label)) - : theme.fg("muted", label); - }); - const full = ` ${tabs.join(theme.fg("muted", " / "))}`; - if (visibleWidth(full) <= width) return full; - const index = SETTINGS_SECTIONS.indexOf(section); - return ` ${theme.fg("accent", theme.bold(SETTINGS_SECTION_LABELS[section]))} ${theme.fg("dim", `(${index + 1}/${SETTINGS_SECTIONS.length})`)}`; -} - -function wrapToWidth(text: string, width: number): string[] { - return wrapTextWithAnsi(text, Math.max(1, width)); -} - -function padToWidth(text: string, width: number): string { - return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`; -} - -function settingsLayout( - items: SettingsItem[], - width: number, -): { - inline: boolean; - labelWidth: number; - maxLabelWidth: number; - rowHeights: number[]; - valueWidth: number; -} { - const valueWidth = Math.max(...items.map((item) => visibleWidth(item.currentValue))); - const maxLabelWidth = Math.max(...items.map((item) => visibleWidth(item.label))); - const inlineLabelWidth = width - 2 - 2 - valueWidth; - const inline = inlineLabelWidth >= 8; - const labelWidth = inline - ? Math.min(maxLabelWidth, inlineLabelWidth) - : Math.max(1, width - 2); - const rowHeights = items.map((item) => { - const labelRows = wrapToWidth(item.label, labelWidth).length; - return inline ? labelRows : labelRows + 1; - }); - return { inline, labelWidth, maxLabelWidth, rowHeights, valueWidth }; -} - -function visibleSettingsRange( - rowHeights: number[], - selectedIndex: number, - rowBudget: number, -): { start: number; end: number } { - if (rowHeights.length === 0) return { start: 0, end: 0 }; - const selected = Math.min(Math.max(0, selectedIndex), rowHeights.length - 1); - let start = selected; - let end = selected + 1; - let used = rowHeights[selected]; - - while (true) { - let expanded = false; - if (start > 0 && used + rowHeights[start - 1] <= rowBudget) { - start -= 1; - used += rowHeights[start]; - expanded = true; - } - if (end < rowHeights.length && used + rowHeights[end] <= rowBudget) { - used += rowHeights[end]; - end += 1; - expanded = true; - } - if (!expanded) break; - } - return { start, end }; -} - -function renderSettingValue( - value: string, - theme: ExtensionContext["ui"]["theme"], -): string { - if (value === "enabled") return theme.fg("success", theme.bold(value)); - if (value === "selected") return theme.fg("accent", theme.bold(value)); - if (value === "disabled" || value === "normal") return theme.fg("dim", value); - if (value === "custom") return theme.fg("warning", theme.bold(value)); - return theme.fg("accent", theme.bold(value)); -} - -function paintSettingsRow( - line: string, - active: boolean, - width: number, - theme: ExtensionContext["ui"]["theme"], -): string { - return active ? theme.bg("selectedBg", padToWidth(line, width)) : line; -} - -function renderHelpLine( - line: string, - theme: ExtensionContext["ui"]["theme"], -): string { - return line - .split(/(Enter\/Space|Tab or reverse Tab|Esc)/) - .map((part) => - /^(Enter\/Space|Tab or reverse Tab|Esc)$/.test(part) - ? theme.fg("accent", theme.bold(part)) - : theme.fg("dim", part) - ) - .join(""); -} - -function renderResponsiveSettings( - items: SettingsItem[], - selectedIndex: number, - width: number, - height: number, - theme: ExtensionContext["ui"]["theme"], -): string[] { - if (items.length === 0) return [theme.fg("muted", " No settings in this section.")]; - - const selected = Math.min(Math.max(0, selectedIndex), items.length - 1); - const { inline, labelWidth, rowHeights } = settingsLayout(items, width); - const description = items[selected]?.description; - const descriptionLines = description - ? wrapToWidth(description, Math.max(1, width - 4)) - : []; - const helpLines = wrapToWidth( - "Enter/Space to change / Tab or reverse Tab to switch sections / Esc to close", - Math.max(1, width - 2), - ); - const contentHeight = Math.max(1, height - 4); - const selectedRows = rowHeights[selected]; - const helpBlockRows = 1 + helpLines.length; - const showHelp = contentHeight >= selectedRows + helpBlockRows; - const descriptionBlockRows = descriptionLines.length > 0 ? 1 + descriptionLines.length : 0; - const showDescription = descriptionBlockRows > 0 && - contentHeight >= selectedRows + (showHelp ? helpBlockRows : 0) + descriptionBlockRows; - const fixedRows = (showHelp ? helpBlockRows : 0) + (showDescription ? descriptionBlockRows : 0); - const allItemRows = rowHeights.reduce((sum, rows) => sum + rows, 0); - const showIndicator = allItemRows > contentHeight - fixedRows; - const rowBudget = Math.max( - selectedRows, - contentHeight - fixedRows - (showIndicator ? 1 : 0), - ); - const { start, end } = visibleSettingsRange(rowHeights, selected, rowBudget); - const window = items.slice(start, end); - const lines: string[] = []; - - for (const [windowIndex, item] of window.entries()) { - const itemIndex = start + windowIndex; - const active = itemIndex === selected; - const labelLines = wrapToWidth(item.label, labelWidth); - const cursor = active ? theme.fg("accent", "→ ") : " "; - const renderLabel = (label: string) => active - ? theme.fg("accent", theme.bold(label)) - : theme.fg("text", label); - const pushRow = (line: string) => lines.push(paintSettingsRow(line, active, width, theme)); - - if (inline) { - pushRow( - `${cursor}${renderLabel(padToWidth(labelLines[0] ?? "", labelWidth))} ${renderSettingValue(item.currentValue, theme)}`, - ); - for (const continuation of labelLines.slice(1)) { - pushRow(` ${renderLabel(continuation)}`); - } - } else { - pushRow(`${cursor}${renderLabel(labelLines[0] ?? "")}`); - for (const continuation of labelLines.slice(1)) pushRow(` ${renderLabel(continuation)}`); - pushRow(` ${renderSettingValue(item.currentValue, theme)}`); - } - } - - if (start > 0 || end < items.length) { - lines.push( - ` ${theme.fg("dim", "(")}${theme.fg("accent", theme.bold(String(selected + 1)))}${theme.fg("dim", `/${items.length})`)}`, - ); - } - if (showDescription) { - lines.push(""); - lines.push( - ...descriptionLines.map((line, index) => - `${index === 0 ? theme.fg("accent", " › ") : " "}${theme.fg("text", line)}` - ), - ); - } - if (showHelp) { - lines.push(""); - lines.push(...helpLines.map((line) => renderHelpLine(` ${line}`, theme))); - } - return lines; -} - -export function fitSettingsRender(lines: string[], width: number, height: number): string[] { - const safeWidth = Math.max(0, width); - const safeHeight = Math.max(0, height); - return lines - .slice(0, safeHeight) - .map((line) => truncateToWidth(line, safeWidth, "")); -} - -export function mergeStoredSettings(root: JsonObject, patch: SettingsPatch): JsonObject { - const current = isJsonObject(root[SETTINGS_KEY]) ? { ...root[SETTINGS_KEY] } : {}; - if (patch.tier !== undefined) current.tier = patch.tier; - if (patch.selectedSections === null) { - delete current.selectedSections; - } else if (patch.selectedSections !== undefined) { - current.selectedSections = [...new Set(patch.selectedSections)]; - } - return { ...root, [SETTINGS_KEY]: current }; -} - -async function saveStoredSettings(patch: SettingsPatch): Promise { - await withFileMutationQueue(SETTINGS_PATH, async () => { - await mkdir(dirname(SETTINGS_PATH), { recursive: true }); - - let root: JsonObject = {}; - let mode = 0o600; - try { - mode = (await stat(SETTINGS_PATH)).mode & 0o777; - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(SETTINGS_PATH, "utf8")); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - const backup = `${SETTINGS_PATH}.corrupt-${Date.now()}-${randomUUID()}`; - await rename(SETTINGS_PATH, backup); - } - if (parsed !== undefined) { - if (isJsonObject(parsed)) { - root = parsed; - } else { - const backup = `${SETTINGS_PATH}.corrupt-${Date.now()}-${randomUUID()}`; - await rename(SETTINGS_PATH, backup); - } - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - - const nextRoot = mergeStoredSettings(root, patch); - const temporary = `${SETTINGS_PATH}.${process.pid}.${randomUUID()}.tmp`; - try { - const handle = await open(temporary, "wx", mode); - try { - await handle.writeFile(`${JSON.stringify(nextRoot, null, 2)}\n`, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - await rename(temporary, SETTINGS_PATH); - } catch (error) { - await rm(temporary, { force: true }).catch(() => undefined); - throw error; - } - }); -} export default async function pohuyExtension(pi: ExtensionAPI) { let styleSource: StyleSource | undefined; @@ -839,10 +50,12 @@ export default async function pohuyExtension(pi: ExtensionAPI) { }; const enqueueSettingsMutation = (patch: SettingsPatch): Promise => enqueueSettingsOperation(async () => { + const loaded = await readStoredSettings(); + const current = styleSource ? normalizeStoredSettings(loaded, styleSource) : loaded; const next = styleSource - ? normalizeStoredSettings(applySettingsPatch(settings, patch), styleSource) - : applySettingsPatch(settings, patch); - await saveStoredSettings(fullSettingsPatch(next)); + ? normalizeStoredSettings(applySettingsPatch(current, patch), styleSource) + : applySettingsPatch(current, patch); + await saveStoredSettings(diffStoredSettings(loaded, next)); settings = next; return settings; }); @@ -850,7 +63,7 @@ export default async function pohuyExtension(pi: ExtensionAPI) { enqueueSettingsOperation(async () => { const loaded = await readStoredSettings(); const next = styleSource ? normalizeStoredSettings(loaded, styleSource) : loaded; - if (!sameStoredSettings(loaded, next)) await saveStoredSettings(fullSettingsPatch(next)); + if (!sameStoredSettings(loaded, next)) await saveStoredSettings(diffStoredSettings(loaded, next)); settings = next; return settings; }); @@ -872,21 +85,21 @@ export default async function pohuyExtension(pi: ExtensionAPI) { try { styleSource = await loadStyleSource(); } catch (error) { - failures.push(`sources: ${String(error)}`); + failures.push(`источники: ${String(error)}`); } } try { await enqueueSettingsReload(); } catch (error) { - failures.push(`settings: ${String(error)}`); + failures.push(`настройки: ${String(error)}`); } if (failures.length > 0 && ctx.hasUI) { - ctx.ui.notify(`Could not initialize Pohuy (${failures.join("; ")})`, "error"); + ctx.ui.notify(`Не удалось запустить Pohuy. ${failures.join(". ")}`, "error"); } }); pi.registerCommand("pohuy", { - description: "Configure Pohuy response style and prompt sources", + description: "Настроить стиль ответов, словарь и сцены Pohuy", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const options = [...TIERS, "normal"]; const value = prefix.trim().toLowerCase(); @@ -906,144 +119,27 @@ export default async function pohuyExtension(pi: ExtensionAPI) { try { styleSource ??= await loadStyleSource(); } catch (error) { - if (ctx.hasUI) ctx.ui.notify(`Could not load Pohuy sources: ${String(error)}`, "error"); + if (ctx.hasUI) ctx.ui.notify(`Не удалось загрузить источники Pohuy: ${String(error)}`, "error"); return; } const source = styleSource; if (!ctx.hasUI || ctx.mode !== "tui") { - if (ctx.hasUI) ctx.ui.notify("Pohuy settings UI is available only in TUI mode.", "warning"); + if (ctx.hasUI) ctx.ui.notify("Меню настроек Pohuy доступно только в режиме TUI.", "warning"); return; } - await ctx.ui.custom((tui, theme, _keybindings, done) => { - let applying = false; - let closeRequested = false; - let activeSection: SettingsSection = "general"; - const selectedIndexes: Record = { - general: 0, - skill: 0, - dictionary: 0, - scenes: 0, - }; - const currentItems = () => buildSettingsItems(activeSection, settings, source); - const clampSelection = () => { - const items = currentItems(); - selectedIndexes[activeSection] = Math.min( - Math.max(0, selectedIndexes[activeSection]), - Math.max(0, items.length - 1), - ); - }; - const switchSection = (direction: "forward" | "backward") => { - activeSection = adjacentSettingsSection(activeSection, direction); - clampSelection(); - tui.requestRender(); - }; - const applyCurrentSetting = () => { - if (applying) return; - const items = currentItems(); - const item = items[selectedIndexes[activeSection]]; - if (!item || item.values.length < 2) return; - const valueIndex = item.values.indexOf(item.currentValue); - const newValue = item.values[(Math.max(0, valueIndex) + 1) % item.values.length]; - let patch: SettingsPatch | undefined; - let notification: string | undefined; - - if (item.id === "tier" && (newValue === "normal" || isTier(newValue))) { - patch = { tier: newValue }; - notification = `Pohuy mode: ${newValue}`; - } else if ( - item.id === "sourcePreset" && - SOURCE_PRESET_VALUES.includes(newValue as SourcePreset) - ) { - patch = newValue === "tier-defaults" - ? { selectedSections: null } - : { selectedSections: selectedSectionIds(settings, source) }; - notification = `Pohuy source preset: ${newValue}`; - } else { - const optionId = sourceIdFromSetting(item.id); - const option = source.options.find((candidate) => candidate.id === optionId); - if (option && FEATURE_STATE_VALUES.includes(newValue as FeatureState)) { - const selected = new Set(selectedSectionIds(settings, source)); - if (newValue === "enabled" || newValue === "selected") selected.add(option.id); - else selected.delete(option.id); - patch = { selectedSections: [...selected] }; - notification = `${option.label}: ${newValue}`; - } - } - if (!patch) return; - - applying = true; - void enqueueSettingsMutation(patch) - .then(() => { - clampSelection(); - ctx.ui.notify(notification ?? "Pohuy settings updated.", "info"); - }) - .catch((error) => { - ctx.ui.notify(`Could not update Pohuy settings: ${String(error)}`, "error"); - }) - .finally(() => { - applying = false; - if (closeRequested) done(undefined); - else tui.requestRender(); - }); - }; - - return { - render(width: number) { - const height = tui.terminal.rows; - const border = theme.fg("border", "─".repeat(Math.max(0, width))); - return fitSettingsRender([ - border, - renderSettingsTabs(activeSection, theme, width), - border, - ...renderResponsiveSettings( - currentItems(), - selectedIndexes[activeSection], - width, - height, - theme, - ), - border, - ], width, height); - }, - invalidate() {}, - handleInput(data: string) { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { - if (applying) closeRequested = true; - else done(undefined); - return; - } - if (applying || closeRequested) return; - if (matchesKey(data, Key.tab)) { - switchSection("forward"); - return; - } - if (matchesKey(data, Key.shift("tab"))) { - switchSection("backward"); - return; - } - const items = currentItems(); - if (matchesKey(data, Key.up)) { - selectedIndexes[activeSection] = Math.max(0, selectedIndexes[activeSection] - 1); - } else if (matchesKey(data, Key.down)) { - selectedIndexes[activeSection] = Math.min( - Math.max(0, items.length - 1), - selectedIndexes[activeSection] + 1, - ); - } else if (matchesKey(data, Key.home)) { - selectedIndexes[activeSection] = 0; - } else if (matchesKey(data, Key.end)) { - selectedIndexes[activeSection] = Math.max(0, items.length - 1); - } else if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { - applyCurrentSetting(); - return; - } else { - return; - } - tui.requestRender(); - }, - }; - }); + await ctx.ui.custom((tui, theme, _keybindings, done) => + createStyleSettingsComponent({ + source, + getSettings: () => settings, + mutate: async (patch) => { await enqueueSettingsMutation(patch); }, + tui, + theme, + border: theme.getThinkingBorderColor(ctx.thinkingLevel ?? "off"), + done: () => done(undefined), + notify: (message, level) => ctx.ui.notify(message, level), + }) + ); return; } @@ -1058,13 +154,13 @@ export default async function pohuyExtension(pi: ExtensionAPI) { if (tier !== "normal") styleSource ??= await loadStyleSource(); await enqueueSettingsMutation({ tier }); } catch (error) { - if (ctx.hasUI) ctx.ui.notify(`Could not save Pohuy settings: ${String(error)}`, "error"); + if (ctx.hasUI) ctx.ui.notify(`Не удалось сохранить настройки Pohuy: ${String(error)}`, "error"); return; } if (ctx.hasUI) { ctx.ui.notify( - tier === "normal" ? "Pohuy disabled. Normal mode restored." : `Pohuy enabled: ${tier}.`, + tier === "normal" ? "Pohuy отключён. Включён обычный режим." : `Pohuy включён, режим: ${tier}.`, "info", ); } @@ -1083,27 +179,21 @@ export default async function pohuyExtension(pi: ExtensionAPI) { await enqueueSettingsMutation({ tier }); if (ctx.hasUI) { ctx.ui.notify( - tier === "normal" ? "Pohuy disabled. Normal mode restored." : "Pohuy enabled: full.", + tier === "normal" ? "Pohuy отключён. Включён обычный режим." : "Pohuy включён, режим: full.", "info", ); } } catch (error) { - if (ctx.hasUI) ctx.ui.notify(`Could not save Pohuy settings: ${String(error)}`, "error"); + if (ctx.hasUI) ctx.ui.notify(`Не удалось сохранить настройки Pohuy: ${String(error)}`, "error"); + return { action: "continue" }; } return { action: "handled" }; }); pi.on("before_agent_start", (event) => { - const policy = styleSource ? buildStylePolicy(settings, styleSource) : undefined; - const stylePrompt = policy && settings.tier !== "normal" - ? [ - "Active session style: pohuy. Apply this policy only to natural-language assistant responses.", - policy, - `Selected tier: ${settings.tier}. Keep it until /pohuy or a documented natural-language switch changes it.`, - "Do not alter tool calls, tool results, structured output, or higher-priority instructions.", - ].join("\n\n") - : undefined; + if (!styleSource) return; + const stylePrompt = buildStylePrompt(settings, styleSource); const systemPrompt = placeStylePromptAtAppendBoundary( event.systemPrompt, diff --git a/extensions/pohuy/settings-store.ts b/extensions/pohuy/settings-store.ts new file mode 100644 index 0000000..ac5acfd --- /dev/null +++ b/extensions/pohuy/settings-store.ts @@ -0,0 +1,153 @@ +import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth } from "@earendil-works/pi-tui"; +import { randomUUID } from "node:crypto"; +import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + isJsonObject, + isTier, + type JsonObject, + type SettingsPatch, + type StoredSettings, +} from "./style-source.js"; + +const SETTINGS_KEY = "pohuy"; +export const SETTINGS_PATH = join(getAgentDir(), "settings.json"); + +export async function readStoredSettings(): Promise { + try { + const root: unknown = JSON.parse(await readFile(SETTINGS_PATH, "utf8")); + if (!isJsonObject(root) || !isJsonObject(root[SETTINGS_KEY])) { + return { tier: "normal" }; + } + + const record = root[SETTINGS_KEY]; + const rawTier = record.tier; + const tier = rawTier === "normal" || isTier(rawTier) ? rawTier : "normal"; + const skillEnabled = typeof record.skillEnabled === "boolean" ? record.skillEnabled : undefined; + const selectedSections = Array.isArray(record.selectedSections) + ? [...new Set(record.selectedSections.filter((value): value is string => typeof value === "string"))] + : undefined; + const itemOverrides = isJsonObject(record.itemOverrides) + ? Object.fromEntries(Object.entries(record.itemOverrides).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean")) + : undefined; + return { + tier, + ...(skillEnabled === undefined ? {} : { skillEnabled }), + ...(selectedSections === undefined ? {} : { selectedSections }), + ...(itemOverrides === undefined ? {} : { itemOverrides }), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) { + return { tier: "normal" }; + } + throw error; + } +} + +export function applySettingsPatch(settings: StoredSettings, patch: SettingsPatch): StoredSettings { + const next: StoredSettings = { + ...settings, + tier: patch.tier ?? settings.tier, + skillEnabled: patch.skillEnabled ?? settings.skillEnabled, + }; + if (patch.selectedSections === null) { + delete next.selectedSections; + } else if (patch.selectedSections !== undefined) { + next.selectedSections = [...new Set(patch.selectedSections)]; + } + if (patch.itemOverrides === null) { + delete next.itemOverrides; + } else if (patch.itemOverrides !== undefined) { + next.itemOverrides = { ...patch.itemOverrides }; + } + return next; +} + +export function fitSettingsRender(lines: string[], width: number, height: number): string[] { + const safeWidth = Math.max(0, width); + const safeHeight = Math.max(0, height); + return lines + .slice(0, safeHeight) + .map((line) => truncateToWidth(line, safeWidth, "")); +} + +export function mergeStoredSettings(root: JsonObject, patch: SettingsPatch): JsonObject { + const current = isJsonObject(root[SETTINGS_KEY]) ? { ...root[SETTINGS_KEY] } : {}; + if (patch.tier !== undefined) current.tier = patch.tier; + if (patch.skillEnabled !== undefined) current.skillEnabled = patch.skillEnabled; + if (patch.selectedSections === null) { + delete current.selectedSections; + } else if (patch.selectedSections !== undefined) { + current.selectedSections = [...new Set(patch.selectedSections)]; + } + if (patch.itemOverrides === null) { + delete current.itemOverrides; + } else if (patch.itemOverrides !== undefined) { + current.itemOverrides = { ...patch.itemOverrides }; + } + return { ...root, [SETTINGS_KEY]: current }; +} + +export function diffStoredSettings(current: StoredSettings, next: StoredSettings): SettingsPatch { + return { + ...(current.tier === next.tier ? {} : { tier: next.tier }), + ...((current.skillEnabled ?? true) === (next.skillEnabled ?? true) + ? {} + : { skillEnabled: next.skillEnabled ?? true }), + ...(JSON.stringify(current.selectedSections) === JSON.stringify(next.selectedSections) + ? {} + : { selectedSections: next.selectedSections ?? null }), + ...(JSON.stringify(current.itemOverrides ?? {}) === JSON.stringify(next.itemOverrides ?? {}) + ? {} + : { itemOverrides: next.itemOverrides ?? null }), + }; +} + +export async function saveStoredSettings(patch: SettingsPatch): Promise { + await withFileMutationQueue(SETTINGS_PATH, async () => { + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + + let root: JsonObject = {}; + let mode = 0o600; + try { + mode = (await stat(SETTINGS_PATH)).mode & 0o777; + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(SETTINGS_PATH, "utf8")); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + const backup = `${SETTINGS_PATH}.corrupt-${Date.now()}-${randomUUID()}`; + await rename(SETTINGS_PATH, backup); + } + if (parsed !== undefined) { + if (isJsonObject(parsed)) { + root = parsed; + } else { + const backup = `${SETTINGS_PATH}.corrupt-${Date.now()}-${randomUUID()}`; + await rename(SETTINGS_PATH, backup); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + const nextRoot = mergeStoredSettings(root, patch); + const temporary = `${SETTINGS_PATH}.${process.pid}.${randomUUID()}.tmp`; + try { + const handle = await open(temporary, "wx", mode); + try { + await handle.writeFile(`${JSON.stringify(nextRoot, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, SETTINGS_PATH); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } + }); +} + + diff --git a/extensions/pohuy/settings-ui.ts b/extensions/pohuy/settings-ui.ts new file mode 100644 index 0000000..00b9e75 --- /dev/null +++ b/extensions/pohuy/settings-ui.ts @@ -0,0 +1,1106 @@ +import { + copyToClipboard, + getMarkdownTheme, + type ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { + Input, + Key, + Markdown, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; +import { fitSettingsRender, SETTINGS_PATH } from "./settings-store.js"; +import { + buildStylePrompt, + buildStylePromptReport, + entryEnabled, + flattenNodes, + inheritedEntryEnabled, + selectedSectionIds, + SETTINGS_SECTIONS, + SETTINGS_SECTION_LABELS, + type ConfigNode, + type SettingsPatch, + type SettingsSection, + type StoredSettings, + type StoredTier, + type StyleSource, +} from "./style-source.js"; + +type TriState = "enabled" | "disabled" | "partial"; +const STATE_LABELS: Record = { + enabled: "включено", + disabled: "выключено", + partial: "частично", +}; +function stateLabel(state: TriState): string { + return STATE_LABELS[state]; +} +type TreeRow = { node: ConfigNode; depth: number }; +type SettingsTheme = ExtensionContext["ui"]["theme"]; +const METADATA_LABEL_WIDTH = 12; + +function formatCharacterCount(value: number): string { + return value.toLocaleString("ru-RU"); +} + +function generalRoots(settings: StoredSettings, source: StyleSource): ConfigNode[] { + const customSources = settings.selectedSections !== undefined || Object.keys(settings.itemOverrides ?? {}).length > 0; + const report = buildStylePromptReport(settings, source); + return [ + { + id: "setting:tier", + kind: "setting", + section: "general", + label: `Режим: ${settings.tier}`, + description: "normal отключает стиль. lite, full и ultra постепенно усиливают его. Остальные настройки сохраняются.", + markdown: "", + path: "settings.json", + line: 1, + aliases: ["mode", "tier"], + children: [], + }, + { + id: "setting:preview", + kind: "setting", + section: "general", + label: report.managedBlock + ? `Итоговая инструкция: ${formatCharacterCount(report.managedCharacters)} симв.` + : "Итоговая инструкция: не добавляется", + description: "Точный блок POHUY для системного промпта. Размер остального промпта Pi не входит в счётчик.", + markdown: "", + path: "generated://pohuy", + line: 1, + aliases: ["prompt", "preview", "итог"], + children: [], + }, + { + id: "setting:preset", + kind: "setting", + section: "general", + label: `Набор источников: ${customSources ? "custom" : "tier-defaults"}`, + description: "tier-defaults использует набор текущего режима. custom хранит ручной выбор.", + markdown: "", + path: "settings.json", + line: 1, + aliases: ["source preset"], + children: [], + }, + ]; +} + +function stateFrom(states: TriState[]): TriState { + if (states.length === 0 || states.every((state) => state === "disabled")) return "disabled"; + if (states.every((state) => state === "enabled")) return "enabled"; + return "partial"; +} + +function nodeState(node: ConfigNode, settings: StoredSettings, source: StyleSource): TriState { + if (node.id === "setting:preview") return buildStylePrompt(settings, source) ? "enabled" : "disabled"; + if (node.kind === "setting") return "enabled"; + if (node.id === "component:style-skill") return settings.skillEnabled === false ? "disabled" : "enabled"; + if (node.id === "component:dictionary-resource") { + return stateFrom(flattenNodes(source.roots.dictionary).filter((item) => item.kind === "entry").map((item) => nodeState(item, settings, source))); + } + if (node.id === "component:scenes-resource") { + return stateFrom(flattenNodes(source.roots.scenes).filter((item) => item.kind === "scene").map((item) => nodeState(item, settings, source))); + } + if (node.children.length > 0) return stateFrom(node.children.map((child) => nodeState(child, settings, source))); + if (node.kind === "entry") return entryEnabled(node, settings, source) ? "enabled" : "disabled"; + if (node.optionId) return selectedSectionIds(settings, source).includes(node.optionId) ? "enabled" : "disabled"; + return "disabled"; +} + +function sectionRoots(section: SettingsSection, settings: StoredSettings, source: StyleSource): ConfigNode[] { + return section === "general" ? generalRoots(settings, source) : source.roots[section]; +} + +function parentOf(target: ConfigNode, source: StyleSource, settings: StoredSettings): ConfigNode | undefined { + const roots = sectionRoots(target.section, settings, source); + return flattenNodes(roots).find((node) => node.children.some((child) => child.id === target.id)); +} + +function ancestorChain(target: ConfigNode, source: StyleSource, settings: StoredSettings): ConfigNode[] { + const result: ConfigNode[] = []; + let current = parentOf(target, source, settings); + while (current) { + result.unshift(current); + current = parentOf(current, source, settings); + } + return result; +} + +function visibleTreeRows(roots: ConfigNode[], expanded: Set): TreeRow[] { + const rows: TreeRow[] = []; + const visit = (node: ConfigNode, depth: number) => { + rows.push({ node, depth }); + if (node.children.length > 0 && expanded.has(node.id)) { + for (const child of node.children) visit(child, depth + 1); + } + }; + for (const root of roots) visit(root, 0); + return rows; +} + +function countState(nodes: ConfigNode[], settings: StoredSettings, source: StyleSource): { enabled: number; total: number } { + const leaves = flattenNodes(nodes).filter((node) => node.children.length === 0 && node.kind !== "setting"); + return { enabled: leaves.filter((node) => nodeState(node, settings, source) === "enabled").length, total: leaves.length }; +} + +function checkbox(state: TriState, theme: SettingsTheme): string { + if (state === "enabled") return theme.fg("success", "[✓]"); + if (state === "partial") return theme.fg("warning", "[-]"); + return theme.fg("muted", "[ ]"); +} + +function fitCell(value: string, width: number, align: "left" | "right" = "left"): string { + const fitted = truncateToWidth(value, Math.max(0, width), ""); + const padding = " ".repeat(Math.max(0, width - visibleWidth(fitted))); + return align === "right" ? padding + fitted : fitted + padding; +} + +function alignHeader(left: string, right: string, width: number): string { + if (visibleWidth(left) + visibleWidth(right) + 1 <= width) { + return left + " ".repeat(width - visibleWidth(left) - visibleWidth(right)) + right; + } + if (right && visibleWidth(right) + 2 < width) { + const fittedLeft = truncateToWidth(left, width - visibleWidth(right) - 1, "…"); + return fittedLeft + " ".repeat(Math.max(1, width - visibleWidth(fittedLeft) - visibleWidth(right))) + right; + } + return truncateToWidth(left, width, "…"); +} + +function tabsLine(active: SettingsSection, theme: SettingsTheme): string { + return SETTINGS_SECTIONS.map((section) => { + const label = SETTINGS_SECTION_LABELS[section]; + return section === active + ? theme.fg("accent", theme.bold(`[${label}]`)) + : theme.fg("text", label); + }).join(" "); +} + +function searchControl(input: Input, active: boolean, maxWidth: number, theme: SettingsTheme, focused = active): string { + const bracketColor = "accent"; + if (!active) { + return theme.fg(bracketColor, "[") + ` ${theme.fg("accent", "/")} ${theme.fg("muted", "поиск")} ` + theme.fg(bracketColor, "]"); + } + input.focused = focused; + const contentWidth = Math.max(1, maxWidth - 4); + const value = focused + ? (input.render(contentWidth + 2)[0] ?? "").slice(2) + : truncateToWidth(input.getValue(), contentWidth, ""); + return theme.fg(bracketColor, "[") + ` ${value} ` + theme.fg(bracketColor, "]"); +} + +function sourceLocation(node: ConfigNode): string { + return node.kind === "setting" || node.kind === "component" ? node.path : `${node.path}:${node.line}`; +} + +function splitSourceDocument(markdown: string): { frontmatter?: string; body: string } { + const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)*/u); + if (!match) return { body: markdown }; + return { + frontmatter: match[1], + body: markdown.slice(match[0].length), + }; +} + +function sceneBodyMarkdown(markdown: string): string { + const lines = markdown.split("\n"); + if (/^###\s+/.test(lines[0] ?? "")) lines.shift(); + while (lines[0]?.trim() === "") lines.shift(); + if (/^Лексика:\s*/.test(lines[0] ?? "")) lines.shift(); + while (lines[0]?.trim() === "") lines.shift(); + return lines.join("\n").trim(); +} + +function dictionaryMatchCountLabel(count: number): string { + const lastTwo = count % 100; + const last = count % 10; + const noun = last === 1 && lastTwo !== 11 + ? "запись" + : last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14) + ? "записи" + : "записей"; + return `${count} ${noun}`; +} + +function openSectionFor(node: ConfigNode): SettingsSection | undefined { + if (node.id === "component:dictionary-resource") return "dictionary"; + if (node.id === "component:scenes-resource") return "scenes"; + return undefined; +} + +function canToggleNode(node: ConfigNode): boolean { + if (node.kind === "setting") return node.id === "setting:tier" || node.id === "setting:preset"; + if (["component:style-skill", "component:dictionary-resource", "component:scenes-resource"].includes(node.id)) return true; + return node.optionId !== undefined || node.children.some(canToggleNode); +} + +function toggleFooterDescription(node: ConfigNode): string { + if (node.kind === "setting") return "изменить настройку"; + if (node.kind === "group") return "переключить группу"; + if (node.kind === "entry") return "переключить запись"; + if (node.kind === "scene") return "переключить сцену"; + return "переключить ресурс"; +} + +function treeFooterActions(node: ConfigNode | undefined, expanded: Set, hasParent: boolean): Array<[string, string]> { + if (!node) return []; + if (node.children.length > 0 && expanded.has(node.id)) { + return [["←/h", "свернуть"], ["→/l", "к первому пункту"]]; + } + return [ + ...(hasParent ? [["←/h", "к родителю"] as [string, string]] : []), + ...(node.children.length > 0 ? [["→/l", "раскрыть"] as [string, string]] : []), + ]; +} + + +function metadataLine(label: string, value: string, width: number, theme: SettingsTheme): string { + return truncateToWidth(`${theme.fg("muted", fitCell(label, METADATA_LABEL_WIDTH))} ${theme.fg("text", value)}`, width, ""); +} + +function metadataLines(label: string, value: string, width: number, theme: SettingsTheme): string[] { + const valueWidth = Math.max(1, width - METADATA_LABEL_WIDTH - 1); + const wrapped = wrapTextWithAnsi(value, valueWidth); + return wrapped.map((line, index) => + `${index === 0 ? theme.fg("muted", fitCell(label, METADATA_LABEL_WIDTH)) : " ".repeat(METADATA_LABEL_WIDTH)} ${theme.fg("text", line)}` + ); +} + +function relatedDictionaryGroups(node: ConfigNode, source: StyleSource): Array<{ label: string; matches: number }> { + const haystack = `${node.label}\n${node.markdown}`.toLocaleLowerCase("ru-RU"); + return source.roots.dictionary.flatMap((group) => { + const matches = flattenNodes([group]).filter((candidate) => { + if (candidate.kind !== "entry") return false; + const terms = [candidate.label, ...candidate.aliases] + .flatMap((term) => term.split(/\s*\/\s*/)) + .map((term) => term.toLocaleLowerCase("ru-RU").trim()) + .filter((term) => visibleWidth(term) >= 4); + return terms.some((term) => haystack.includes(term)); + }).length; + return matches > 0 ? [{ label: group.label, matches }] : []; + }); +} + +function reflowMarkdownPreview(markdown: string): string { + const output: string[] = []; + let paragraph: string[] = []; + let paragraphKind: "text" | "quote" | undefined; + let fenced = false; + const flush = () => { + if (paragraph.length > 0) output.push(paragraph.join(" ")); + paragraph = []; + paragraphKind = undefined; + }; + + for (const line of markdown.split("\n")) { + if (/^\s*```/u.test(line)) { + flush(); + output.push(line); + fenced = !fenced; + continue; + } + if (fenced) { + output.push(line); + continue; + } + if (line.trim() === "") { + flush(); + output.push(""); + continue; + } + if (/^(?:\s{0,3}#{1,6}\s|\s*\| |\s*[-*_]{3,}\s*$|\s{4}\S|\s*<[^>]+>)/u.test(line)) { + flush(); + output.push(line); + continue; + } + const quote = line.match(/^\s*>\s?(.*)$/u); + if (quote) { + if (paragraphKind !== "quote") flush(); + paragraphKind = "quote"; + paragraph.push(paragraph.length === 0 ? `> ${quote[1].trim()}` : quote[1].trim()); + continue; + } + if (paragraphKind === "quote") flush(); + if (/^\s*(?:[-*+] |\d+[.)] )/u.test(line)) flush(); + paragraphKind = "text"; + paragraph.push(line.trim()); + if (/\s{2}$/u.test(line)) flush(); + } + flush(); + return output.join("\n"); +} + +function promptReportLines(width: number, settings: StoredSettings, source: StyleSource, theme: SettingsTheme): string[] { + const report = buildStylePromptReport(settings, source); + const active = report.managedBlock !== undefined; + const customSections = settings.selectedSections !== undefined; + const lines = [theme.fg("text", theme.bold("Итоговая инструкция Pohuy"))]; + const meta = (label: string, value: string) => lines.push(...metadataLines(label, value, width, theme)); + meta("СОСТОЯНИЕ", theme.fg(active ? "success" : "muted", active ? "добавляется в системный промпт" : "не добавляется")); + meta("РЕЖИМ", settings.tier); + meta("РАЗМЕР", `${formatCharacterCount(report.managedCharacters)} символов в блоке POHUY`); + meta("ПОЛИТИКА", `${formatCharacterCount(report.policyCharacters)} символов без маркеров POHUY`); + meta("РАЗДЕЛЫ", `${report.selectedSections} из ${report.totalSections}`); + meta("НАБОР", customSections ? "ручной" : `стандартный для режима ${settings.tier}`); + meta("SLOVAR.MD", `${report.dictionaryEnabled}/${report.dictionaryTotal} записей включено`); + meta("СЦЕНЫ", `${report.scenesEnabled}/${report.scenesTotal} сцен включено`); + meta("ХУЕНИТИВЫ", report.huenitivEnabled ? "включены" : "выключены"); + if (report.explicitOverrides === 0) { + meta("ПРАВКИ", "нет"); + } else { + meta("ДОБАВЛЕНО", dictionaryMatchCountLabel(report.explicitlyEnabled)); + meta("УБРАНО", dictionaryMatchCountLabel(report.explicitlyDisabled)); + } + + if (report.stylePrompt) { + lines.push("", theme.fg("muted", "ТЕКСТ БЛОКА")); + lines.push(...new Markdown(reflowMarkdownPreview(report.stylePrompt), 0, 0, getMarkdownTheme()).render(Math.max(1, width))); + } else { + lines.push("", theme.fg("muted", settings.skillEnabled === false + ? "Стилевой skill выключен. Выбор словаря и сцен сохранён." + : "Режим normal: блок POHUY отсутствует. Остальные настройки сохранены.")); + } + return lines.flatMap((line) => line === "" ? [""] : wrapTextWithAnsi(line, Math.max(1, width))); +} + +function detailLines(node: ConfigNode, width: number, settings: StoredSettings, source: StyleSource, theme: SettingsTheme, showNeighbors = false): string[] { + if (node.id === "setting:preview") return promptReportLines(width, settings, source, theme); + const state = nodeState(node, settings, source); + const stateColor = state === "enabled" ? "success" : state === "partial" ? "warning" : "muted"; + const status = node.kind === "entry" && settings.itemOverrides?.[node.id] !== undefined + ? `${stateLabel(state)} (переопределено)` + : stateLabel(state); + const title = node.id === "setting:tier" + ? theme.fg("text", theme.bold(`Режим: ${settings.tier}`)) + theme.fg("muted", ` (${(["normal", "lite", "full", "ultra"] as StoredTier[]).filter((tier) => tier !== settings.tier).join(", ")})`) + : theme.fg("text", theme.bold(node.label)); + const lines = [title]; + const meta = (label: string, value: string) => lines.push(...metadataLines(label, value, width, theme)); + meta("СОСТОЯНИЕ", theme.fg(stateColor, status)); + meta("ИСТОЧНИК", sourceLocation(node)); + if (node.kind === "entry" && settings.itemOverrides?.[node.id] === false) { + meta("ПРИМЕНЕНИЕ", "термин исключён из системного промпта"); + } + if (node.kind !== "entry" && node.kind !== "scene" && node.description) meta("ЧТО ДЕЛАЕТ", node.description); + if (node.kind === "setting") meta("ЗАГРУЖЕНО ИЗ", SETTINGS_PATH); + const ancestors = ancestorChain(node, source, settings); + if (ancestors.length > 0) { + meta("РОДИТЕЛЬ", ancestors.map((ancestor) => `${ancestor.label} (${stateLabel(nodeState(ancestor, settings, source))})`).join(" / ")); + } + if (node.kind === "entry") { + if (node.description) meta("ЗНАЧЕНИЕ", node.description); + const parent = parentOf(node, source, settings); + if (parent && showNeighbors) { + const index = parent.children.findIndex((child) => child.id === node.id); + const context = parent.children.slice(Math.max(0, index - 1), index + 2); + if (context.length > 1) { + lines.push(theme.fg("muted", "СОСЕДНИЕ ЗАПИСИ")); + for (const sibling of context) { + const marker = sibling.id === node.id ? theme.fg("accent", "→ ") : " "; + const color = sibling.id === node.id ? "text" : "muted"; + lines.push(`${marker}${theme.fg(color, sibling.label)}`); + } + } + } + } + if (node.kind === "scene") { + const severity = node.label.match(/^(\d+)\.\s+(.+?)\s+[—–-]\s+(.+)$/); + if (severity) { + meta("УРОВЕНЬ", `${severity[1]} из 10`); + if (node.description) meta("ЛЕКСИКА", node.description.replace(/^Лексика:\s*/u, "")); + } + const related = relatedDictionaryGroups(node, source); + if (related.length > 0) { + lines.push(theme.fg("muted", "СОВПАДЕНИЯ СО СЛОВАРЁМ")); + for (const group of related) { + lines.push(alignHeader(theme.fg("text", group.label), theme.fg("muted", dictionaryMatchCountLabel(group.matches)), width)); + } + } + } + if (settings.skillEnabled === false && node.section !== "general") { + meta("ПРИМЕНЕНИЕ", "выключено вместе со стилевым skill"); + } + if (node.kind === "group" && node.children.length > 0) { + const count = countState([node], settings, source); + meta("ВКЛЮЧЕНО", `${count.enabled} из ${count.total}`); + } + const resourceMetadata: Partial> = { + "component:style-skill": { + defaults: "включён в lite, full и ultra", + manual: "общий выключатель доступен в любом активном режиме", + }, + "component:dictionary-resource": { + defaults: "набор записей зависит от режима", + manual: "записи и группы доступны в lite, full и ultra", + }, + "component:scenes-resource": { + defaults: "набор сцен зависит от режима", + manual: "сцены доступны в lite, full и ultra", + }, + "component:huenitiv-resource": { + defaults: "включены в ultra", + manual: "доступны в lite, full и ultra", + }, + }; + const selection = resourceMetadata[node.id]; + if (selection) { + meta("ПО УМОЛЧАНИЮ", selection.defaults); + meta("РУЧНОЙ ВЫБОР", selection.manual); + } + const sourceDocument = node.kind === "component" ? splitSourceDocument(node.markdown) : undefined; + const displayMarkdown = node.kind === "scene" + ? sceneBodyMarkdown(node.markdown) + : sourceDocument?.body ?? node.markdown; + const showMarkdown = displayMarkdown && node.kind !== "entry" && + (node.kind === "component" || node.kind === "group" || node.children.length === 0); + if (showMarkdown) { + if (sourceDocument?.frontmatter) { + lines.push(""); + lines.push(theme.fg("muted", "МЕТАДАННЫЕ ФАЙЛА")); + const metadata = sourceDocument.frontmatter.split("\n"); + for (let index = 0; index < metadata.length; index += 1) { + if (metadata[index] !== "description: >") { + lines.push(theme.fg("text", metadata[index])); + continue; + } + lines.push(theme.fg("text", "description:")); + const description: string[] = []; + while (index + 1 < metadata.length && /^\s/u.test(metadata[index + 1])) { + description.push(metadata[index + 1].replace(/^ {2}/u, "")); + index += 1; + } + const quote = description.map((line) => `> ${line}`).join("\n"); + lines.push(...new Markdown(quote, 0, 0, getMarkdownTheme()).render(Math.max(1, width))); + } + } + if (node.kind === "scene") { + lines.push(theme.fg("muted", /^\s*-\s+(?:lite|full|ultra):/mu.test(displayMarkdown) ? "ПРИМЕРЫ ОТВЕТОВ" : "ПРИМЕР ОТВЕТА")); + } else if (node.kind === "component") { + lines.push(""); + lines.push(theme.fg("muted", "СОДЕРЖИМОЕ ФАЙЛА")); + } else if (node.kind === "group") { + lines.push(""); + lines.push(theme.fg("muted", "ТЕКСТ РАЗДЕЛА")); + } else { + lines.push(""); + } + lines.push(...new Markdown(reflowMarkdownPreview(displayMarkdown), 0, 0, getMarkdownTheme()).render(Math.max(1, width))); + } + return lines.flatMap((line) => line === "" ? [""] : wrapTextWithAnsi(line, Math.max(1, width))); +} + +function searchNodes(query: string, source: StyleSource, settings: StoredSettings): ConfigNode[] { + const needle = query.trim().toLocaleLowerCase("ru-RU"); + if (!needle) return []; + const nodes = SETTINGS_SECTIONS.flatMap((section) => flattenNodes(sectionRoots(section, settings, source))); + const unique = new Map(); + for (const node of nodes) { + const label = node.label.toLocaleLowerCase("ru-RU"); + const aliases = node.aliases.join("\n").toLocaleLowerCase("ru-RU"); + const body = [node.description, node.children.length === 0 ? node.markdown : "", node.path] + .join("\n").toLocaleLowerCase("ru-RU"); + if (!label.includes(needle) && !aliases.includes(needle) && !body.includes(needle)) continue; + const score = label === needle ? 4 : label.includes(needle) ? 3 : aliases.includes(needle) ? 2 : 1; + if (!unique.has(node.id)) unique.set(node.id, { node, score }); + } + return [...unique.values()] + .sort((left, right) => right.score - left.score || left.node.path.localeCompare(right.node.path) || left.node.line - right.node.line) + .map(({ node }) => node); +} + +function footerLine(actions: Array<[string, string]>, width: number, theme: SettingsTheme, includeHelp = true): string { + const help: [string, string] = ["?", "помощь"]; + const complete = !includeHelp || actions.some(([key]) => key.includes("?")) ? actions : [...actions, help]; + const rendered = complete.map(([key, description]) => `${theme.fg("accent", theme.bold(key))} ${theme.fg("muted", description)}`); + const separator = theme.fg("muted", " / "); + let value = rendered.join(separator); + if (visibleWidth(value) > width) { + const required = rendered.at(-1); + const fitted: string[] = []; + for (const action of rendered.slice(0, -1)) { + const candidate = [...fitted, action, ...(required ? [required] : [])].join(separator); + if (visibleWidth(candidate) <= width) fitted.push(action); + } + if (required) fitted.push(required); + value = truncateToWidth(fitted.join(separator), width, ""); + } + return " ".repeat(Math.max(0, Math.floor((width - visibleWidth(value)) / 2))) + value; +} + +function togglePatch(node: ConfigNode, settings: StoredSettings, source: StyleSource): SettingsPatch | undefined { + if (node.id === "setting:tier") { + const values: StoredTier[] = ["normal", "lite", "full", "ultra"]; + return { tier: values[(values.indexOf(settings.tier) + 1) % values.length] }; + } + if (node.id === "setting:preset") { + const customSources = settings.selectedSections !== undefined || Object.keys(settings.itemOverrides ?? {}).length > 0; + return !customSources + ? { selectedSections: selectedSectionIds(settings, source) } + : { selectedSections: null, itemOverrides: null }; + } + if (node.id === "component:style-skill") return { skillEnabled: settings.skillEnabled === false }; + + const targetEnabled = nodeState(node, settings, source) !== "enabled"; + const dictionaryEntries = node.id === "component:dictionary-resource" + ? flattenNodes(source.roots.dictionary).filter((item) => item.kind === "entry") + : flattenNodes([node]).filter((item) => item.kind === "entry"); + if (dictionaryEntries.length > 0) { + const overrides = { ...(settings.itemOverrides ?? {}) }; + for (const entry of dictionaryEntries) { + if (targetEnabled === inheritedEntryEnabled(entry, settings, source)) delete overrides[entry.id]; + else overrides[entry.id] = targetEnabled; + } + return { itemOverrides: Object.keys(overrides).length > 0 ? overrides : null }; + } + + const sceneNodes = node.id === "component:scenes-resource" + ? flattenNodes(source.roots.scenes).filter((item) => item.kind === "scene") + : flattenNodes([node]).filter((item) => item.kind === "scene"); + const optionIds = sceneNodes.map((item) => item.optionId).filter((id): id is string => id !== undefined); + if (optionIds.length > 0) { + const selected = new Set(selectedSectionIds(settings, source)); + for (const id of optionIds) targetEnabled ? selected.add(id) : selected.delete(id); + return { selectedSections: [...selected] }; + } + if (node.optionId) { + const selected = new Set(selectedSectionIds(settings, source)); + targetEnabled ? selected.add(node.optionId) : selected.delete(node.optionId); + return { selectedSections: [...selected] }; + } + return undefined; +} + +type SettingsComponentOptions = { + source: StyleSource; + getSettings: () => StoredSettings; + mutate: (patch: SettingsPatch) => Promise; + tui: { terminal: { rows: number }; requestRender(): void }; + theme: SettingsTheme; + border: (text: string) => string; + done: () => void; + notify: (message: string, level: "info" | "warning" | "error") => void; +}; + +export function createStyleSettingsComponent(options: SettingsComponentOptions) { + const { source, tui, theme, border } = options; + let section: SettingsSection = "general"; + let focus: "search" | "list" | "detail" = "list"; + let narrowDetail = false; + let focusMode: "list" | "detail" | undefined; + let showHelp = false; + let helpScroll = 0; + let lastHelpMaxScroll = 0; + let searchActive = false; + let searchInputFocused = false; + const searchInput = new Input(); + let componentFocused = false; + let selected = 0; + let listScroll = 0; + let detailScroll = 0; + let applying = false; + let lastSplit = false; + let lastDetailPage = 1; + let lastDetailMaxScroll = 0; + const expanded = new Set(); + for (const root of [...source.roots.dictionary, ...source.roots.scenes]) expanded.add(root.id); + + const settings = () => options.getSettings(); + const searchQuery = () => searchInput.getValue(); + const results = () => searchNodes(searchQuery(), source, settings()); + const rows = (): TreeRow[] => searchActive && searchQuery() + ? results().map((node) => ({ node, depth: 0 })) + : visibleTreeRows(sectionRoots(section, settings(), source), expanded); + const selectedNode = () => rows()[Math.min(selected, Math.max(0, rows().length - 1))]?.node; + const clamp = () => { + selected = Math.min(Math.max(0, selected), Math.max(0, rows().length - 1)); + detailScroll = Math.min(Math.max(0, detailScroll), lastDetailMaxScroll); + }; + const request = () => { clamp(); tui.requestRender(); }; + const apply = (node: ConfigNode) => { + if (applying) return; + const patch = togglePatch(node, settings(), source); + if (!patch) return; + applying = true; + void options.mutate(patch).catch((error) => { + options.notify(`Не удалось изменить настройки Pohuy: ${String(error)}`, "error"); + }).finally(() => { + applying = false; + request(); + }); + }; + const selectSection = (next: SettingsSection) => { + if (section === next) return; + section = next; + selected = 0; + listScroll = 0; + detailScroll = 0; + focus = "list"; + narrowDetail = false; + focusMode = undefined; + request(); + }; + const cycleSection = (delta: -1 | 1) => { + const index = SETTINGS_SECTIONS.indexOf(section); + selectSection(SETTINGS_SECTIONS[(index + delta + SETTINGS_SECTIONS.length) % SETTINGS_SECTIONS.length]); + }; + const reveal = (node: ConfigNode) => { + section = node.section; + searchActive = false; + searchInputFocused = false; + searchInput.setValue(""); + focus = "list"; + for (const ancestor of ancestorChain(node, source, settings())) expanded.add(ancestor.id); + const revealed = visibleTreeRows(sectionRoots(section, settings(), source), expanded); + selected = Math.max(0, revealed.findIndex((row) => row.node.id === node.id)); + listScroll = Math.max(0, selected - 2); + detailScroll = 0; + request(); + }; + + const helpPanel = (width: number, height: number): string[] => { + const inner = Math.max(0, width - 2); + const contentWidth = Math.max(1, inner - 2); + const sections: Array<{ title: string; entries: Array<[string, string]> }> = [ + { title: "Навигация", entries: [["↑↓ / j k", "выбор или прокрутка активной панели"], ["Shift+J/K", "прокрутка противоположной панели"], ["PgUp/PgDn", "прокрутка страницей"], ["←→ / h l", "дерево: родитель, свернуть или раскрыть"], ["Tab / Shift+Tab", "переключить панели; в узком виде открыть или закрыть карточку"]] }, + { title: "Вид", entries: [["f", "развернуть активную панель или вернуть две панели"], ["?", "открыть или закрыть эту панель"], ["1 2 3 4", "Общие, Skill, Словарь, Сцены"]] }, + { title: "Действия", entries: [["Space", "переключить настройку, запись, группу или сцену"], ["Enter", "открыть ресурс, карточку или результат поиска"], ["c", "скопировать путь источника"], ["/", "поиск по всем вкладкам"]] }, + { title: "Поиск и выход", entries: [["Enter", "показать найденный элемент в дереве"], ["Backspace", "удалить символ поиска"], ["Esc", "очистить поиск, свернуть панель или закрыть меню"], ["Ctrl+C", "закрыть меню"]] }, + ]; + const logical: string[] = []; + for (const section of sections) { + if (logical.length > 0) logical.push(""); + logical.push(theme.fg("accent", theme.bold(section.title))); + for (const [key, description] of section.entries) { + const prefix = `${theme.fg("accent", theme.bold(fitCell(key, 16)))} `; + const wrapped = wrapTextWithAnsi(theme.fg("muted", description), Math.max(1, contentWidth - 17)); + wrapped.forEach((line, index) => { + logical.push(`${index === 0 ? prefix : " ".repeat(17)}${line}`); + }); + } + } + const bodyHeight = Math.max(1, height - 6); + lastHelpMaxScroll = Math.max(0, logical.length - bodyHeight); + helpScroll = Math.min(helpScroll, lastHelpMaxScroll); + const visible = logical.slice(helpScroll, helpScroll + bodyHeight); + const top = border(`┌${"─".repeat(inner)}┐`); + const horizontal = border(`├${"─".repeat(inner)}┤`); + const bottom = border(`└${"─".repeat(inner)}┘`); + const outer = (content: string) => `${border("│")} ${fitCell(content, contentWidth)} ${border("│")}`; + return fitSettingsRender([ + top, + outer(alignHeader(theme.fg("accent", theme.bold("Pohuy: клавиши")), theme.fg("muted", `${helpScroll + 1}-${Math.min(logical.length, helpScroll + bodyHeight)}/${logical.length}`), contentWidth)), + horizontal, + ...Array.from({ length: bodyHeight }, (_, index) => outer(visible[index] ?? "")), + horizontal, + outer(footerLine([["? Esc", "закрыть"]], contentWidth, theme)), + bottom, + ], width, height); + }; + + const panelListLines = (width: number, height: number): string[] => { + const currentRows = rows(); + if (currentRows.length === 0) return [theme.fg("muted", searchActive ? "Совпадений нет." : "Элементов нет.")]; + if (selected < listScroll) listScroll = selected; + if (selected >= listScroll + height) listScroll = selected - height + 1; + const visible = currentRows.slice(listScroll, listScroll + height); + return visible.map((row, offset) => { + const index = listScroll + offset; + const active = index === selected; + const node = row.node; + const state = nodeState(node, settings(), source); + const cursor = active ? theme.fg("accent", "→ ") : " "; + const indent = " ".repeat(row.depth); + const fold = node.children.length > 0 ? (expanded.has(node.id) ? "▾ " : "▸ ") : ""; + const labelColor = state === "disabled" ? "muted" : "text"; + const prefix = node.kind === "setting" ? theme.fg("accent", "•") : checkbox(state, theme); + const left = `${cursor}${indent}${prefix} ${fold}${theme.fg(labelColor, active ? theme.bold(node.label) : node.label)}`; + const counts = node.children.length > 0 && node.kind !== "component" ? countState([node], settings(), source) : undefined; + const right = searchActive + ? sourceLocation(node) + : counts && counts.total > 0 ? `${counts.enabled}/${counts.total}` : ""; + const line = alignHeader(left, theme.fg("muted", right), width); + return active ? theme.bg("selectedBg", fitCell(line, width)) : truncateToWidth(line, width, ""); + }); + }; + + const render = (width: number): string[] => { + const targetShellHeight = Math.max(1, Math.floor(options.tui.terminal.rows * 0.6)); + if (width < 4 || targetShellHeight < 4) { + return fitSettingsRender( + [tabsLine(section, theme), ...Array.from({ length: targetShellHeight - 1 }, () => "")], + width, + targetShellHeight, + ); + } + const shellWidth = width; + if (showHelp) return helpPanel(shellWidth, targetShellHeight); + const inner = Math.max(0, shellWidth - 2); + lastSplit = inner >= 116; + if (lastSplit) narrowDetail = false; + const split = lastSplit && focusMode === undefined; + if (!split && focus === "detail" && focusMode !== "detail" && !narrowDetail) focus = "list"; + const leftWidth = split ? Math.max(50, Math.floor((inner - 1) * 0.42)) : inner; + const rightWidth = split ? inner - leftWidth - 1 : 0; + const leftContentWidth = Math.max(1, leftWidth - 2); + const rightContentWidth = Math.max(1, rightWidth - 2); + const singleContentWidth = Math.max(1, inner - 2); + const searchMatches = searchActive ? results().length : 0; + const sectionNodes = sectionRoots(section, settings(), source); + const sectionCount = section === "skill" + ? (() => { + const components = flattenNodes(sectionNodes).filter((candidate) => candidate.kind === "component"); + return { + enabled: components.filter((candidate) => nodeState(candidate, settings(), source) !== "disabled").length, + total: components.length, + }; + })() + : countState(sectionNodes, settings(), source); + const status = searchActive + ? `найдено: ${searchMatches}` + : section === "general" ? `режим: ${settings().tier}` : `включено: ${sectionCount.enabled}/${sectionCount.total}`; + const searchMaxWidth = Math.max(4, Math.min(28, Math.floor(singleContentWidth * 0.24))); + const search = searchControl(searchInput, searchActive, searchMaxWidth, theme, componentFocused && searchInputFocused); const rightHeader = `${search} ${theme.fg("muted", status)}`; + const header = alignHeader(tabsLine(section, theme), rightHeader, singleContentWidth); + const top = border(`┌${"─".repeat(inner)}┐`); + const bottom = border(`└${"─".repeat(inner)}┘`); + const outer = (content: string) => `${border("│")} ${fitCell(content, singleContentWidth)} ${border("│")}`; + const fullHorizontal = border(`├${"─".repeat(inner)}┤`); + const splitStart = split + ? border(`├${"─".repeat(leftWidth)}┬${"─".repeat(rightWidth)}┤`) + : fullHorizontal; + const splitMiddle = split + ? border(`├${"─".repeat(leftWidth)}┼${"─".repeat(rightWidth)}┤`) + : fullHorizontal; + const splitEnd = split + ? border(`├${"─".repeat(leftWidth)}┴${"─".repeat(rightWidth)}┤`) + : fullHorizontal; + const joined = (left: string, right: string) => `${border("│")} ${fitCell(left, leftContentWidth)} ${border("│")} ${fitCell(right, rightContentWidth)} ${border("│")}`; + const title = searchActive && searchQuery() ? "РЕЗУЛЬТАТЫ ПОИСКА" : SETTINGS_SECTION_LABELS[section].toUpperCase(); + const node = selectedNode(); + const listTitle = theme.fg(focus === "list" ? "accent" : "muted", theme.bold(title)); + const detailTitle = theme.fg(focus === "detail" ? "accent" : "muted", theme.bold(searchActive ? "СОВПАДЕНИЕ" : section === "skill" ? "SKILL" : node?.kind === "scene" ? "СЦЕНА" : "ЭЛЕМЕНТ")); + if (targetShellHeight < 10) { + const summary = node ? `${node.label} / ${stateLabel(nodeState(node, settings(), source))}` : "Элементов нет."; + const compact = targetShellHeight >= 8 + ? [top, outer(tabsLine(section, theme)), fullHorizontal, outer(summary), outer(node ? sourceLocation(node) : ""), fullHorizontal, outer(footerLine([["Esc", "закрыть"]], singleContentWidth, theme)), bottom] + : targetShellHeight >= 7 + ? [top, outer(tabsLine(section, theme)), fullHorizontal, outer(summary), fullHorizontal, outer(footerLine([["Esc", "закрыть"]], singleContentWidth, theme)), bottom] + : targetShellHeight >= 6 + ? [top, outer(tabsLine(section, theme)), fullHorizontal, outer(summary), outer(footerLine([["Esc", "закрыть"]], singleContentWidth, theme)), bottom] + : targetShellHeight >= 5 + ? [top, outer(tabsLine(section, theme)), outer(summary), outer(footerLine([["Esc", "закрыть"]], singleContentWidth, theme)), bottom] + : [top, outer(tabsLine(section, theme)), outer(summary), bottom]; + return fitSettingsRender(compact, shellWidth, targetShellHeight); + } + const overhead = 8; + const availableBody = Math.max(1, targetShellHeight - overhead); + const bodyHeight = availableBody; + let contentLines: string[]; + + if (!split && (narrowDetail || focusMode === "detail") && node) { + const details = detailLines(node, singleContentWidth, settings(), source, theme, true); + lastDetailPage = bodyHeight; + lastDetailMaxScroll = Math.max(0, details.length - bodyHeight); + detailScroll = Math.min(detailScroll, lastDetailMaxScroll); + const visibleDetails = details.slice(detailScroll, detailScroll + bodyHeight); + const detailRange = details.length > bodyHeight + ? `${detailScroll + 1}-${Math.min(details.length, detailScroll + bodyHeight)}/${details.length}` + : ""; + contentLines = [ + top, + outer(theme.fg("accent", `${SETTINGS_SECTION_LABELS[section]} / ${node.label}`)), + fullHorizontal, + outer(alignHeader(theme.fg("accent", theme.bold("ПОДРОБНОСТИ")), theme.fg("muted", detailRange), singleContentWidth)), + fullHorizontal, + ...Array.from({ length: bodyHeight }, (_, index) => outer(visibleDetails[index] ?? "")), + fullHorizontal, + outer(footerLine([ + ["↑↓", "прокрутка"], + [focusMode === "detail" ? "f/Esc" : "Tab/Backspace/Esc", focusMode === "detail" ? "вернуть две панели" : "к дереву"], + ...(canToggleNode(node) ? [["Space", "вкл/выкл"] as [string, string]] : []), + ...(openSectionFor(node) ? [["Enter", "открыть"] as [string, string]] : []), + ["c", "путь"], + ], singleContentWidth, theme)), + bottom, + ]; + } else if (split) { + const list = panelListLines(leftContentWidth, bodyHeight); + const details = node ? detailLines(node, rightContentWidth, settings(), source, theme) : []; + lastDetailPage = bodyHeight; + lastDetailMaxScroll = Math.max(0, details.length - bodyHeight); + detailScroll = Math.min(detailScroll, lastDetailMaxScroll); + const visibleDetails = details.slice(detailScroll, detailScroll + bodyHeight); + const listRange = rows().length > bodyHeight + ? `${listScroll + 1}-${Math.min(rows().length, listScroll + bodyHeight)}/${rows().length}` + : ""; + const detailRange = details.length > bodyHeight + ? `${detailScroll + 1}-${Math.min(details.length, detailScroll + bodyHeight)}/${details.length}` + : ""; + const listHeading = alignHeader(listTitle, theme.fg("muted", listRange), leftContentWidth); + const detailHeading = alignHeader(detailTitle, theme.fg("muted", detailRange), rightContentWidth); + const footerActions: Array<[string, string]> = searchActive + ? focus === "search" + ? [["Tab", "результаты"], ["Enter", "результаты"], ["Esc", "очистить"]] + : focus === "detail" + ? [["Tab", "поиск"], ["Shift+Tab", "результаты"], ["↑↓", "прокрутка"], ["c", "копировать путь"], ["Esc", "очистить"]] + : [["Tab", "подробности"], ["Shift+Tab", "поиск"], ["↑↓", "результат"], ...(node && canToggleNode(node) ? [["Space", toggleFooterDescription(node)] as [string, string]] : []), ["Enter", "показать в дереве"], ["c", "копировать путь"], ["Esc", "очистить"]] + : focus === "detail" + ? [["Tab", "к дереву"], ["↑↓", "прокрутка"], ...(node && canToggleNode(node) ? [["Space", toggleFooterDescription(node)] as [string, string]] : []), ...(node && openSectionFor(node) ? [["Enter", "открыть"] as [string, string]] : []), ["c", "копировать путь"], ["Esc", "закрыть"]] + : [["Tab", "подробности"], ["↑↓", "выбор"], ...treeFooterActions(node, expanded, Boolean(node && ancestorChain(node, source, settings()).length)), ...(node && canToggleNode(node) ? [["Space", toggleFooterDescription(node)] as [string, string]] : []), ...(node && openSectionFor(node) ? [["Enter", "открыть"] as [string, string]] : []), ["c", "копировать путь"], ["[ ]", "раздел"], ["Esc", "закрыть"]]; + contentLines = [ + top, + outer(header), + splitStart, + joined(listHeading, detailHeading), + splitMiddle, + ...Array.from({ length: bodyHeight }, (_, index) => joined(list[index] ?? "", visibleDetails[index] ?? "")), + splitEnd, + outer(footerLine(footerActions, singleContentWidth, theme)), + bottom, + ]; + } else { + const listHeight = Math.max(1, bodyHeight - 1); + const list = panelListLines(singleContentWidth, listHeight); + lastDetailPage = bodyHeight; + lastDetailMaxScroll = 0; + const selectedSummary = node + ? metadataLine("ВЫБРАНО", `${node.label} / ${stateLabel(nodeState(node, settings(), source))} / ${sourceLocation(node)}`, singleContentWidth, theme) + : ""; + const titleRight = searchActive + ? `${searchControl(searchInput, true, searchMaxWidth, theme, componentFocused && searchInputFocused)} ${theme.fg("muted", `найдено: ${searchMatches}`)}` + : searchControl(searchInput, false, searchMaxWidth, theme); + const narrowTitle = searchActive && visibleWidth(listTitle) + visibleWidth(titleRight) + 1 > singleContentWidth + ? fitCell(titleRight, singleContentWidth, "right") + : alignHeader(listTitle, titleRight, singleContentWidth); + contentLines = [ + top, + outer(tabsLine(section, theme)), + fullHorizontal, + outer(narrowTitle), + fullHorizontal, + ...Array.from({ length: listHeight }, (_, index) => outer(list[index] ?? "")), + outer(selectedSummary), + fullHorizontal, + outer(footerLine(searchActive + ? [["↑↓", "результат"], ["Enter", "показать"], ...(node && canToggleNode(node) ? [["Space", "вкл/выкл"] as [string, string]] : []), ["c", "путь"], ["Esc", "очистить"]] + : [["↑↓", "выбор"], ["Tab/Enter", node && openSectionFor(node) ? "открыть" : "карточка"], ...treeFooterActions(node, expanded, Boolean(node && ancestorChain(node, source, settings()).length)), ...(node && canToggleNode(node) ? [["Space", "вкл/выкл"] as [string, string]] : []), ["c", "путь"], ["[ ]", "раздел"], ["Esc", "закрыть"]], singleContentWidth, theme)), + bottom, + ]; + } + return fitSettingsRender(contentLines, shellWidth, targetShellHeight); + }; + + return { + get focused() { + return componentFocused; + }, + set focused(value: boolean) { + componentFocused = value; + searchInput.focused = value && searchInputFocused; + }, + render, + invalidate() { + searchInput.invalidate(); + }, + handleInput(data: string) { + if (showHelp) { + if (data === "?" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + showHelp = false; + helpScroll = 0; + } else if (matchesKey(data, Key.up) || data === "k") { + helpScroll = Math.max(0, helpScroll - 1); + } else if (matchesKey(data, Key.down) || data === "j") { + helpScroll = Math.min(lastHelpMaxScroll, helpScroll + 1); + } else { + return; + } + request(); + return; + } + if (searchActive) { + if (matchesKey(data, Key.escape)) { + searchActive = false; + searchInputFocused = false; + searchInput.setValue(""); + focus = "list"; + selected = 0; + request(); + return; + } + const reserved = matchesKey(data, Key.enter) || matchesKey(data, Key.tab) || + matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.up) || + matchesKey(data, Key.down) || matchesKey(data, Key.pageUp) || + matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("c")); + if (searchInputFocused && !reserved) { + searchInput.handleInput(data); + selected = 0; + request(); + return; + } + } + if (data === "?") { + showHelp = !showHelp; + helpScroll = 0; + request(); + return; + } + if ((narrowDetail || focusMode) && (matchesKey(data, Key.backspace) || matchesKey(data, Key.escape))) { + narrowDetail = false; + focusMode = undefined; + detailScroll = 0; + request(); + return; + } + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + options.done(); + return; + } + if (applying) return; + if (data === "/") { + searchActive = true; + searchInputFocused = true; + focus = "search"; + selected = 0; + request(); + return; + } + if (matchesKey(data, Key.tab) && !lastSplit) { + if (narrowDetail) { + narrowDetail = false; + focus = "list"; + } else if (focus === "search") { + focus = "list"; + searchInputFocused = false; + } else if (selectedNode()) { + narrowDetail = true; + focus = "detail"; + detailScroll = 0; + } + request(); + return; + } + if (matchesKey(data, Key.shift("tab")) && !lastSplit) { + if (narrowDetail) { + narrowDetail = false; + focus = "list"; + } else if (searchActive) { + focus = "search"; + searchInputFocused = true; + } else if (selectedNode()) { + narrowDetail = true; + focus = "detail"; + detailScroll = 0; + } + request(); + return; + } + if (matchesKey(data, Key.tab)) { + const order: Array<"search" | "list" | "detail"> = searchActive + ? lastSplit ? ["search", "list", "detail"] : ["search", "list"] + : lastSplit ? ["list", "detail"] : ["list"]; + focus = order[(order.indexOf(focus) + 1 + order.length) % order.length]; + searchInputFocused = focus === "search"; + request(); + return; + } + if (matchesKey(data, Key.shift("tab"))) { + const order: Array<"search" | "list" | "detail"> = searchActive + ? lastSplit ? ["search", "list", "detail"] : ["search", "list"] + : lastSplit ? ["list", "detail"] : ["list"]; + focus = order[(order.indexOf(focus) - 1 + order.length) % order.length]; + searchInputFocused = focus === "search"; + request(); + return; + } + if (!searchActive && data === "[") { cycleSection(-1); return; } + if (!searchActive && data === "]") { cycleSection(1); return; } + if (!searchActive && /^[1-4]$/u.test(data)) { + selectSection(SETTINGS_SECTIONS[Number(data) - 1]); + return; + } + if (!searchActive && data === "f" && lastSplit) { + focusMode = focusMode ? undefined : focus === "detail" ? "detail" : "list"; + narrowDetail = false; + request(); + return; + } + const node = selectedNode(); + if (matchesKey(data, Key.enter) && node) { + if (searchActive && searchQuery()) reveal(node); + else if (openSectionFor(node)) selectSection(openSectionFor(node)!); + else if (!lastSplit) { narrowDetail = true; detailScroll = 0; request(); } + else if (focus === "list") { focus = "detail"; detailScroll = 0; request(); } + return; + } + if ((data === "c" || data === "C") && node) { + const location = sourceLocation(node); + void copyToClipboard(location) + .then(() => options.notify(`Скопировано: ${location}`, "info")) + .catch((error) => options.notify(`Не удалось скопировать путь: ${String(error)}`, "error")); + return; + } + if (matchesKey(data, Key.space) && node) { apply(node); return; } + const page = Math.max(1, lastDetailPage - 1); + const shiftedUp = data === "K" || matchesKey(data, Key.shift("k")); + const shiftedDown = data === "J" || matchesKey(data, Key.shift("j")); + if (lastSplit && (shiftedUp || shiftedDown)) { + if (focus === "detail" || focusMode === "detail") { + selected = shiftedUp ? Math.max(0, selected - 1) : Math.min(Math.max(0, rows().length - 1), selected + 1); + detailScroll = 0; + } else { + detailScroll = shiftedUp + ? Math.max(0, detailScroll - 1) + : Math.min(lastDetailMaxScroll, detailScroll + 1); + } + request(); + return; + } + if (focus === "detail" || narrowDetail || focusMode === "detail") { + if (matchesKey(data, Key.up) || data === "k") detailScroll = Math.max(0, detailScroll - 1); + else if (matchesKey(data, Key.down) || data === "j") detailScroll = Math.min(lastDetailMaxScroll, detailScroll + 1); + else if (matchesKey(data, Key.pageUp)) detailScroll = Math.max(0, detailScroll - page); + else if (matchesKey(data, Key.pageDown)) detailScroll = Math.min(lastDetailMaxScroll, detailScroll + page); + else return; + request(); + return; + } + if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1); + else if (matchesKey(data, Key.down) || data === "j") { + if (searchActive) { + searchInputFocused = false; + focus = "list"; + } + selected = Math.min(Math.max(0, rows().length - 1), selected + 1); + } + else if (matchesKey(data, Key.pageUp)) selected = Math.max(0, selected - page); + else if (matchesKey(data, Key.pageDown)) selected = Math.min(Math.max(0, rows().length - 1), selected + page); + else if (!searchActive && (matchesKey(data, Key.left) || data === "h") && node) { + if (node.children.length > 0 && expanded.has(node.id)) { + expanded.delete(node.id); + } else { + const parent = parentOf(node, source, settings()); + if (!parent) return; + selected = rows().findIndex((row) => row.node.id === parent.id); + } + } + else if (!searchActive && (matchesKey(data, Key.right) || data === "l") && node?.children.length) { + if (!expanded.has(node.id)) { + expanded.add(node.id); + } else { + const firstChild = node.children[0]; + selected = rows().findIndex((row) => row.node.id === firstChild.id); + } + } + else return; + detailScroll = 0; + request(); + }, + }; +} + + diff --git a/extensions/pohuy/style-source.ts b/extensions/pohuy/style-source.ts new file mode 100644 index 0000000..f446e7c --- /dev/null +++ b/extensions/pohuy/style-source.ts @@ -0,0 +1,916 @@ +import { readFile } from "node:fs/promises"; + +export type Tier = "lite" | "full" | "ultra"; +export type StoredTier = Tier | "normal"; +export type JsonObject = Record; + +export const TIERS = ["lite", "full", "ultra"] as const; +const PROMPT_START = ""; +const PROMPT_END = ""; +const SKILL_PATH = new URL("../../skills/pohuy/SKILL.md", import.meta.url); +const SLOVAR_PATH = new URL("../../skills/pohuy/references/slovar.md", import.meta.url); +const SCENES_PATH = new URL("../../skills/pohuy/references/sceny.md", import.meta.url); +const HUENITIV_PATH = new URL("../../skills/pohuy/references/huenitiv.md", import.meta.url); +const HUENITIV_OPTION_ID = "huenitiv:rules"; +const LEGACY_HUENITIV_OPTION_ID = "skill:Хуенитивы"; +const CORE_SKILL_SECTIONS = [ + "Persistence", + "Правила", + "Шкала состояний проекта", + "Auto-Clarity (мат выключается)", + "Boundaries", +] as const; +const OPTIONAL_SKILL_SECTIONS = [ + "Словарь (рабочий минимум)", +] as const; +const SCENE_FRAMING = + "Сцены задают тон, а не готовый текст. Не цитируй их дословно. Адаптируй к ситуации."; +const DEFAULT_ULTRA_SCENES = [ + "Легаси-археология", + "Каскадный отказ", + "Триумф после долгого дебага", +] as const; +export const SETTINGS_SECTIONS = ["general", "skill", "dictionary", "scenes"] as const; +export const SETTINGS_SECTION_LABELS: Record = { + general: "Общие", + skill: "Skill", + dictionary: "Словарь", + scenes: "Сцены", +}; +const NON_OPTIONAL_DICTIONARY_PREFIXES = ["Чего в словаре нет"] as const; +const DICTIONARY_DESCRIPTIONS: Record = { + "Состояния и статусы": "Слова для описания состояния проекта, от штатной работы до полного отказа.", + "Действия": "Глаголы для работы, ошибок, исправлений, ожидания и бесполезной возни.", + "Оценки и количества": "Слова для масштаба, количества, уверенности и значимости.", + "Сущности": "Названия для кода, артефактов, процессов и прочих технических сущностей.", +}; + +export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; +export type StoredSettings = { + tier: StoredTier; + skillEnabled?: boolean; + selectedSections?: string[]; + itemOverrides?: Record; +}; +export type SettingsPatch = { + tier?: StoredTier; + skillEnabled?: boolean; + selectedSections?: string[] | null; + itemOverrides?: Record | null; +}; +export type SourceOption = { + id: string; + section: Exclude; + label: string; + description: string; + content: string; + path: string; + line: number; + nodes: ConfigNode[]; +}; +export type NodeKind = "group" | "entry" | "scene" | "component" | "setting"; +export type ConfigNode = { + id: string; + kind: NodeKind; + section: SettingsSection; + label: string; + description: string; + markdown: string; + path: string; + line: number; + aliases: string[]; + children: ConfigNode[]; + optionId?: string; + category?: string; +}; +export type StyleSource = { + common: string[]; + tiers: Record; + options: SourceOption[]; + roots: Record, ConfigNode[]>; +}; +type SourceDocuments = { + skill: string; + dictionary: string; + scenes: string; + huenitiv: string; +}; + +const WORKING_MINIMUM_OVERLAPS: Record = { + [sourceId("slovar", "Состояния и статусы")]: "Состояние:", + [sourceId("slovar", "Действия")]: "Действия:", + [sourceId("slovar", "Оценки и количества")]: "Связки и оценки:", + [sourceId("slovar", "Сущности")]: "Сущности:", +}; +const WORKING_MINIMUM_LABELS = [...Object.values(WORKING_MINIMUM_OVERLAPS), "Присказки"]; + +export function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isTier(value: unknown): value is Tier { + return typeof value === "string" && (TIERS as readonly string[]).includes(value); +} + +function extractHeadingSection(markdown: string, depth: number, heading: string, source: string): string { + const lines = markdown.split("\n"); + const marker = `${"#".repeat(depth)} ${heading}`; + const start = lines.findIndex((line) => line.trimEnd() === marker); + if (start === -1) throw new Error(`В ${source} нет раздела: ${heading}`); + + const next = lines.findIndex((line, index) => { + if (index <= start) return false; + const match = line.match(/^(#{1,6})\s/); + return match !== null && match[1].length <= depth; + }); + const body = lines.slice(start + 1, next === -1 ? undefined : next).join("\n").trim(); + if (!body) throw new Error(`Empty ${source} section: ${heading}`); + return `${marker}\n\n${body}`; +} + +function extractSkillSection(markdown: string, heading: string): string { + return extractHeadingSection(markdown, 2, heading, "SKILL.md"); +} + +function headingsAtDepth(markdown: string, depth: number): string[] { + const prefix = `${"#".repeat(depth)} `; + return markdown + .split("\n") + .filter((line) => line.startsWith(prefix) && !line.startsWith(`${prefix}#`)) + .map((line) => line.slice(prefix.length).trim()) + .filter(Boolean); +} + +function requiredHeadingWithPrefix(markdown: string, depth: number, prefix: string): string { + const heading = headingsAtDepth(markdown, depth).find((candidate) => candidate.startsWith(prefix)); + if (!heading) throw new Error(`Нет заголовка, начинающегося с: ${prefix}`); + return heading; +} + +function sourceId(source: "skill" | "slovar" | "sceny", heading: string): string { + return `${source}:${heading}`; +} + +function plainMarkdownText(text: string): string { + return text + .replace(/^>\s?/gm, "") + .replace(/^[-*]\s+/gm, "") + .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +function firstContentBlock(content: string): string { + const lines = content.split("\n").slice(1); + const start = lines.findIndex((line) => line.trim() !== ""); + if (start === -1) return ""; + const endOffset = lines.slice(start).findIndex((line) => line.trim() === ""); + const end = endOffset === -1 ? lines.length : start + endOffset; + return plainMarkdownText(lines.slice(start, end).join("\n")); +} + +function headingLine(markdown: string, depth: number, heading: string): number { + const marker = `${"#".repeat(depth)} ${heading}`; + const index = markdown.split("\n").findIndex((line) => line.trimEnd() === marker); + return index < 0 ? 1 : index + 1; +} + +function stableNodeId(_source: string, optionId: string, _line: number, label: string): string { + return `item:${optionId}:${label.toLocaleLowerCase("ru-RU")}`; +} + +function entryLabel(markdown: string): string { + const bold = markdown.match(/^\s*-\s+\*\*([^*]+)\*\*/); + if (bold) return bold[1].trim(); + const quoted = markdown.match(/^\s*-\s+[«"]([^»"]+)[»"]/); + if (quoted) return quoted[1].trim(); + return plainMarkdownText(markdown).split(/\s+[—–-]\s+/)[0].trim(); +} + +function entryDescription(markdown: string): string { + const plain = plainMarkdownText(markdown); + const separator = plain.match(/\s+[—–-]\s+/); + return separator ? plain.slice((separator.index ?? 0) + separator[0].length).trim() : plain; +} + +function parseDictionaryNodes( + optionId: string, + heading: string, + sectionContent: string, + sectionLine: number, + section: "skill" | "dictionary" = "dictionary", + path = "references/slovar.md", +): ConfigNode[] { + const lines = sectionContent.split("\n"); + const root: ConfigNode = { + id: `group:${optionId}`, + kind: "group", + section, + label: heading, + description: DICTIONARY_DESCRIPTIONS[heading] ?? firstContentBlock(sectionContent), + markdown: sectionContent, + path, + line: sectionLine, + aliases: [], + children: [], + optionId, + }; + let parent = root; + for (let index = 1; index < lines.length; index += 1) { + const line = lines[index]; + const subgroup = line.match(/^([^#*`].+):\s*$/); + const nextContent = lines.slice(index + 1).find((candidate) => candidate.trim() !== ""); + if (subgroup && nextContent && /^\s*-\s+/.test(nextContent)) { + const label = subgroup[1].trim(); + parent = { + id: stableNodeId("slovar", optionId, sectionLine + index, label), + kind: "group", + section, + label, + description: `Подгруппа раздела "${heading}".`, + markdown: line, + path, + line: sectionLine + index, + aliases: [], + children: [], + optionId, + }; + root.children.push(parent); + continue; + } + if (!/^\s*-\s+/.test(line)) continue; + const block = [line]; + while (index + 1 < lines.length && !/^\s*-\s+/.test(lines[index + 1]) && !/^\S.+:\s*$/.test(lines[index + 1])) { + if (lines[index + 1].trim() === "" && (index + 2 >= lines.length || !/^\s{2,}\S/.test(lines[index + 2]))) break; + block.push(lines[index + 1]); + index += 1; + } + const markdown = block.join("\n").trimEnd(); + const label = entryLabel(markdown); + parent.children.push({ + id: stableNodeId("slovar", optionId, sectionLine + index - block.length + 1, label), + kind: "entry", + section, + label, + description: entryDescription(markdown), + markdown, + path, + line: sectionLine + index - block.length + 1, + aliases: label.split(/\s*\/\s*/).filter((alias) => alias !== label), + children: [], + optionId, + }); + } + return [root]; +} + +function sourceOption( + source: "skill" | "slovar" | "sceny", + heading: string, + content: string, + fullSource: string, +): SourceOption { + const depth = source === "sceny" ? 3 : 2; + const path = source === "skill" + ? "SKILL.md" + : source === "slovar" + ? "references/slovar.md" + : "references/sceny.md"; + const line = headingLine(fullSource, depth, heading); + const id = sourceId(source, heading); + return { + id, + section: source === "skill" ? "skill" : source === "slovar" ? "dictionary" : "scenes", + label: heading, + description: source === "slovar" + ? DICTIONARY_DESCRIPTIONS[heading] ?? firstContentBlock(content) + : firstContentBlock(content), + content, + path, + line, + nodes: source === "slovar" + ? parseDictionaryNodes(id, heading, content, line) + : source === "skill" + ? parseDictionaryNodes(id, heading, content, line, "skill", "SKILL.md") + : [], + }; +} + +function huenitivOption(content: string): SourceOption { + return { + id: HUENITIV_OPTION_ID, + section: "skill", + label: "Хуенитивы", + description: "Правила рифмованных замен через дефис. По умолчанию включены в ultra, но доступны в любом активном режиме.", + content: compileRuntimeMarkdown(content).replace(/ Только в режиме \*\*ultra\*\*\./u, ""), + path: "references/huenitiv.md", + line: 1, + nodes: [], + }; +} + +function extractTierPolicy(levels: string, examplesSource: string, tier: Tier): string { + const tableRow = levels.split("\n").find((line) => line.startsWith(`| **${tier}** |`)); + if (!tableRow) throw new Error(`В таблице SKILL.md нет режима: ${tier}`); + const description = tableRow.split("|")[2]?.trim().replace(/\s+Default\.$/u, ""); + if (!description) throw new Error(`В таблице SKILL.md нет описания режима: ${tier}`); + + const examples: string[] = []; + const exampleLines = examplesSource.split("\n"); + for (let index = 0; index < exampleLines.length; index += 1) { + if (!exampleLines[index].startsWith(`- ${tier}:`)) continue; + const block = [exampleLines[index]]; + while (index + 1 < exampleLines.length && /^\s{2,}\S/.test(exampleLines[index + 1])) { + block.push(exampleLines[index + 1]); + index += 1; + } + examples.push(block.join("\n").replace(new RegExp(`^- ${tier}:`), "-")); + } + if (examples.length === 0) throw new Error(`В SKILL.md нет примеров для режима: ${tier}`); + + return [`## Уровень: ${tier}`, description, `Примеры:\n${examples.join("\n")}`].join("\n\n"); +} + +function compileRuntimeMarkdown(markdown: string): string { + return markdown + .replace(/^## Persistence$/gmu, "## Постоянство стиля") + .replace(/^## Auto-Clarity \(мат выключается\)$/gmu, "## Ясность в критических ситуациях") + .replace(/^## Boundaries$/gmu, "## Границы") + .replace(/^- Security-предупреждения$/gmu, "- Предупреждения безопасности") + .replace(/^Default:.*$/gmu, "") + .replace(/ Выключение только: «нормальный режим» \/ «хватит материться»\.$/gmu, "") + .replace(/ «нормальный режим» — выключение\.$/gmu, "") + .replace(/^Уровень держится до смены или конца сессии\.\s*$/gmu, "") + .replace(/\s+Полные примеры по каждой — в `references\/sceny\.md`\./gu, "") + .replace(/^Полная версия с маппингами — в `references\/slovar\.md`\.\s*$/gmu, "") + .replace(/^Полный арсенал образности — в `references\/slovar\.md`\.\s*$/gmu, "") + .replace(/\s+Теория — в `ontologia\.md`\./gu, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function sceneCategory(markdown: string, sceneLine: number): string { + let category = "Сцены"; + for (const [index, line] of markdown.split("\n").entries()) { + if (index + 1 >= sceneLine) break; + const match = line.match(/^##\s+(.+)$/); + if (match) category = match[1].trim(); + } + return category; +} + +function buildSourceRoots(options: SourceOption[], documents: SourceDocuments): StyleSource["roots"] { + const skillOptions = options.filter((option) => option.section === "skill"); + const huenitiv = skillOptions.find((option) => option.id === HUENITIV_OPTION_ID); + const dictionaryOptions = options.filter((option) => option.section === "dictionary"); + const sceneOptions = options.filter((option) => option.section === "scenes"); + const skillRoot: ConfigNode = { + id: "component:style-skill", + kind: "component", + section: "skill", + label: "Стилевой skill", + description: "Подключает правила ответа из SKILL.md. Словарь, сцены и хуенитивы включаются отдельно.", + markdown: documents.skill, + path: "SKILL.md", + line: 1, + aliases: ["pohuy", "style"], + children: [], + }; + for (const option of skillOptions.filter((candidate) => candidate.id !== HUENITIV_OPTION_ID)) { + skillRoot.children.push({ + id: `component:${option.id}`, + kind: "group", + section: "skill", + label: option.label, + description: option.description, + markdown: option.content, + path: option.path, + line: option.line, + aliases: [], + children: option.nodes[0]?.children ?? [], + optionId: option.id, + }); + } + const dictionaryResource: ConfigNode = { + id: "component:dictionary-resource", + kind: "component", + section: "skill", + label: "Рабочий словарь", + description: "Слова и выражения из references/slovar.md. Их можно включать по одному или группами.", + markdown: documents.dictionary, + path: "references/slovar.md", + line: 1, + aliases: ["dictionary", "словарь"], + children: [], + }; + const scenesResource: ConfigNode = { + id: "component:scenes-resource", + kind: "component", + section: "skill", + label: "Сцены", + description: "Примеры ответов для состояний проекта и типичных ситуаций.", + markdown: documents.scenes, + path: "references/sceny.md", + line: 1, + aliases: ["scenes", "сцены"], + children: [], + }; + + const huenitivResource: ConfigNode | undefined = huenitiv && { + id: "component:huenitiv-resource", + kind: "component", + section: "skill", + label: huenitiv.label, + description: huenitiv.description, + markdown: documents.huenitiv, + path: huenitiv.path, + line: huenitiv.line, + aliases: ["huenitiv", "хуенитив", "рифма"], + children: [], + optionId: huenitiv.id, + }; + + const sceneGroups = new Map(); + for (const option of sceneOptions) { + const category = option.nodes[0]?.category ?? "Сцены"; + let group = sceneGroups.get(category); + if (!group) { + group = { + id: `scene-group:${category}`, + kind: "group", + section: "scenes", + label: category, + description: category === "Шкала состояний проекта" + ? "Десять состояний проекта: от триумфа до катастрофы." + : "Примеры ответов для типичных ситуаций.", + markdown: "", + path: "references/sceny.md", + line: option.line, + aliases: [], + children: [], + category, + }; + sceneGroups.set(category, group); + } + group.children.push(...option.nodes); + } + return { + skill: [skillRoot, dictionaryResource, scenesResource, ...(huenitivResource ? [huenitivResource] : [])], + dictionary: dictionaryOptions.flatMap((option) => option.nodes), + scenes: [...sceneGroups.values()], + }; +} + +export async function loadStyleSource(): Promise { + const [skill, slovar, scenes, huenitiv] = await Promise.all([ + readFile(SKILL_PATH, "utf8"), + readFile(SLOVAR_PATH, "utf8"), + readFile(SCENES_PATH, "utf8"), + readFile(HUENITIV_PATH, "utf8"), + ]); + const levels = extractSkillSection(skill, "Уровни"); + const nonOptionalDictionaryHeading = requiredHeadingWithPrefix( + slovar, + 2, + NON_OPTIONAL_DICTIONARY_PREFIXES[0], + ); + const common = [ + ...CORE_SKILL_SECTIONS.map((heading) => compileRuntimeMarkdown(extractSkillSection(skill, heading))), + compileRuntimeMarkdown(extractHeadingSection(slovar, 2, nonOptionalDictionaryHeading, "slovar.md")), + SCENE_FRAMING, + ]; + const options = [ + ...OPTIONAL_SKILL_SECTIONS.map((heading) => + sourceOption("skill", heading, extractSkillSection(skill, heading), skill) + ), + huenitivOption(huenitiv), + ...headingsAtDepth(slovar, 2) + .filter((heading) => + !NON_OPTIONAL_DICTIONARY_PREFIXES.some((prefix) => heading.startsWith(prefix)) + ) + .map((heading) => + sourceOption("slovar", heading, extractHeadingSection(slovar, 2, heading, "slovar.md"), slovar) + ), + ...headingsAtDepth(scenes, 3).map((heading) => { + const option = sourceOption("sceny", heading, extractHeadingSection(scenes, 3, heading, "sceny.md"), scenes); + const category = sceneCategory(scenes, option.line); + option.nodes = [{ + id: `scene:${option.id}`, + kind: "scene", + section: "scenes", + label: heading, + description: option.description, + markdown: option.content, + path: option.path, + line: option.line, + aliases: [], + children: [], + optionId: option.id, + category, + }]; + return option; + }), + ]; + + const source: StyleSource = { + common, + options, + roots: buildSourceRoots(options, { + skill, + dictionary: slovar, + scenes, + huenitiv, + }), + tiers: Object.fromEntries( + TIERS.map((tier) => [tier, extractTierPolicy(levels, skill, tier)]), + ) as Record, + }; + assertDefaultSections(source); + return source; +} + +function defaultSectionIds(tier: StoredTier): string[] { + if (tier === "lite" || tier === "normal") return []; + const full = OPTIONAL_SKILL_SECTIONS.map((heading) => sourceId("skill", heading)); + if (tier === "full") return full; + return [ + ...full, + HUENITIV_OPTION_ID, + sourceId("slovar", "Образность: восклицания, звукопись, присказки"), + ...DEFAULT_ULTRA_SCENES.map((heading) => sourceId("sceny", heading)), + ]; +} + +function assertDefaultSections(source: StyleSource): void { + const known = new Set(source.options.map((option) => option.id)); + const required = new Set([ + ...defaultSectionIds("full"), + ...defaultSectionIds("ultra"), + ]); + const missing = [...required].filter((id) => !known.has(id)); + if (missing.length > 0) throw new Error(`Не найдены стандартные разделы: ${missing.join(", ")}`); +} + +function canonicalSectionId(id: string): string { + return id === LEGACY_HUENITIV_OPTION_ID ? HUENITIV_OPTION_ID : id; +} + +export function selectedSectionIds(settings: StoredSettings, source: StyleSource): string[] { + const selected = new Set((settings.selectedSections ?? defaultSectionIds(settings.tier)).map(canonicalSectionId)); + return source.options.filter((option) => selected.has(option.id)).map((option) => option.id); +} + +function effectiveSectionIds(settings: StoredSettings, source: StyleSource): string[] { + return settings.tier === "normal" ? [] : selectedSectionIds(settings, source); +} + +export function normalizeStoredSettings(settings: StoredSettings, source: StyleSource): StoredSettings { + const knownItems = new Map( + [...source.roots.skill, ...source.roots.dictionary].flatMap((root) => flattenNodes([root])) + .filter((node) => node.kind === "entry") + .map((node) => [node.id, node] as const), + ); + const normalized: StoredSettings = { + ...settings, + ...(settings.selectedSections === undefined ? {} : { selectedSections: selectedSectionIds(settings, source) }), + }; + const itemOverrides = settings.itemOverrides + ? Object.fromEntries(Object.entries(settings.itemOverrides).filter(([id, value]) => { + const node = knownItems.get(id); + return node !== undefined && typeof value === "boolean" && value !== inheritedEntryEnabled(node, normalized, source); + })) + : undefined; + if (itemOverrides && Object.keys(itemOverrides).length > 0) normalized.itemOverrides = itemOverrides; + else delete normalized.itemOverrides; + return normalized; +} + +export function sameStoredSettings(left: StoredSettings, right: StoredSettings): boolean { + if (left.tier !== right.tier) return false; + if ((left.skillEnabled ?? true) !== (right.skillEnabled ?? true)) return false; + if (left.selectedSections === undefined || right.selectedSections === undefined) { + if (left.selectedSections !== right.selectedSections) return false; + return JSON.stringify(left.itemOverrides ?? {}) === JSON.stringify(right.itemOverrides ?? {}); + } + const sectionsEqual = left.selectedSections.length === right.selectedSections.length && + left.selectedSections.every((id, index) => id === right.selectedSections?.[index]); + if (!sectionsEqual) return false; + return JSON.stringify(left.itemOverrides ?? {}) === JSON.stringify(right.itemOverrides ?? {}); +} + +export function fullSettingsPatch(settings: StoredSettings): SettingsPatch { + return { + tier: settings.tier, + skillEnabled: settings.skillEnabled ?? true, + selectedSections: settings.selectedSections === undefined ? null : settings.selectedSections, + itemOverrides: settings.itemOverrides ?? null, + }; +} + +function removeWorkingMinimumOverlaps(content: string, selected: Set): string { + const removedLabels = new Set( + Object.entries(WORKING_MINIMUM_OVERLAPS) + .filter(([id]) => selected.has(id)) + .map(([, label]) => label), + ); + if (removedLabels.size === 0) return content; + + const lines = content.split("\n"); + const result: string[] = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + const label = WORKING_MINIMUM_LABELS.find((candidate) => + candidate === "Присказки" ? trimmed.startsWith("Присказки") : trimmed === candidate + ); + if (label) skipping = removedLabels.has(label); + if (!skipping) result.push(line); + } + return result.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +export function selectTierVariant(content: string, tier: Tier): string { + const lines = content.split("\n"); + const result: string[] = []; + let skippingVariant = false; + let pendingBlankLines: string[] = []; + + for (const line of lines) { + const variant = line.match(/^- (lite|full|ultra):/); + if (variant) { + pendingBlankLines = []; + skippingVariant = variant[1] !== tier; + if (!skippingVariant) result.push(line); + continue; + } + if (skippingVariant) { + if (line.trim() === "") { + pendingBlankLines.push(line); + continue; + } + if (/^\s{2,}\S/.test(line)) { + pendingBlankLines = []; + continue; + } + result.push(...pendingBlankLines); + pendingBlankLines = []; + skippingVariant = false; + } + result.push(line); + } + return result.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +function compileSelectedOption(option: SourceOption, selected: Set, tier: Tier): string { + let content = option.content; + if (option.id === sourceId("skill", "Словарь (рабочий минимум)")) { + content = removeWorkingMinimumOverlaps(content, selected); + } + return compileRuntimeMarkdown(selectTierVariant(content, tier)); +} + +export function flattenNodes(nodes: ConfigNode[]): ConfigNode[] { + return nodes.flatMap((node) => [node, ...flattenNodes(node.children)]); +} + +export function inheritedEntryEnabled(node: ConfigNode, settings: StoredSettings, source: StyleSource): boolean { + return node.optionId !== undefined && selectedSectionIds(settings, source).includes(node.optionId); +} + +export function entryEnabled(node: ConfigNode, settings: StoredSettings, source: StyleSource): boolean { + const override = settings.itemOverrides?.[node.id]; + if (override !== undefined) return override; + return inheritedEntryEnabled(node, settings, source); +} + +function explicitlyDisabledTerms(settings: StoredSettings, source: StyleSource): Set { + const entries = flattenNodes([ + ...source.roots.skill, + ...source.roots.dictionary, + ]).filter((node) => node.kind === "entry" && settings.itemOverrides?.[node.id] === false); + return new Set( + entries.flatMap((entry) => [entry.label, ...entry.aliases]) + .filter(Boolean) + .map((term) => term.toLocaleLowerCase("ru")), + ); +} + +function matchesExplicitlyDisabledTerm(node: ConfigNode, disabledTerms: Set): boolean { + return [node.label, ...node.aliases].some((term) => disabledTerms.has(term.toLocaleLowerCase("ru"))); +} + +function compileListOption( + option: SourceOption, + settings: StoredSettings, + source: StyleSource, + disabledTerms: Set, +): string | undefined { + const root = option.nodes[0]; + if (!root) return option.content; + const enabled = (node: ConfigNode) => + entryEnabled(node, settings, source) && !matchesExplicitlyDisabledTerm(node, disabledTerms); + const enabledEntries = flattenNodes([root]).filter((node) => node.kind === "entry" && enabled(node)); + if (enabledEntries.length === 0) return undefined; + + const originalLines = option.content.split("\n"); + const firstStructural = Math.min(...root.children.map((node) => node.line - option.line)); + const intro = originalLines.slice(0, firstStructural < 0 ? 1 : firstStructural).join("\n").trimEnd(); + const blocks: string[] = [intro]; + let entryRun: string[] = []; + const flushEntryRun = () => { + if (entryRun.length > 0) blocks.push(entryRun.join("\n")); + entryRun = []; + }; + for (const child of root.children) { + if (child.kind === "group") { + flushEntryRun(); + const entries = child.children.filter((entry) => entry.kind === "entry" && enabled(entry)); + if (entries.length > 0) blocks.push(`${child.markdown}\n\n${entries.map((entry) => entry.markdown).join("\n")}`); + } else if (child.kind === "entry" && enabled(child)) { + entryRun.push(child.markdown); + } + } + flushEntryRun(); + return compileRuntimeMarkdown(blocks.filter(Boolean).join("\n\n")); +} + +export function buildStylePolicy(settings: StoredSettings, source: StyleSource): string | undefined { + const tier = settings.tier; + if (tier === "normal" || settings.skillEnabled === false) return undefined; + const selected = new Set(effectiveSectionIds(settings, source)); + const disabledTerms = explicitlyDisabledTerms(settings, source); + const policy = [ + ...source.common, + ...source.options.map((option) => { + if (option.section === "dictionary") return compileListOption(option, settings, source, disabledTerms); + const hasEntries = flattenNodes(option.nodes).some((node) => node.kind === "entry"); + if (option.section === "skill" && hasEntries) { + const compiled = compileListOption(option, settings, source, disabledTerms); + return compiled ? removeWorkingMinimumOverlaps(compiled, selected) : undefined; + } + return selected.has(option.id) ? compileSelectedOption(option, selected, tier) : undefined; + }), + source.tiers[tier], + ].filter(Boolean).join("\n\n"); + return policy; +} + +const STYLE_PROMPT_LEAD = + "Стиль ответов: Pohuy. Применяй его только к обычному тексту ответа. Не меняй вызовы и результаты инструментов, структурированные данные и инструкции с более высоким приоритетом."; + +export function buildStylePrompt(settings: StoredSettings, source: StyleSource): string | undefined { + const policy = buildStylePolicy(settings, source); + if (!policy || settings.tier === "normal") return undefined; + return [STYLE_PROMPT_LEAD, policy].join("\n\n"); +} + +function managedStyleBlock(stylePrompt: string | undefined): string | undefined { + return stylePrompt ? `${PROMPT_START}\n${stylePrompt}\n${PROMPT_END}` : undefined; +} + +type StylePromptReport = { + stylePrompt?: string; + managedBlock?: string; + policyCharacters: number; + managedCharacters: number; + selectedSections: number; + totalSections: number; + dictionaryEnabled: number; + dictionaryTotal: number; + scenesEnabled: number; + scenesTotal: number; + explicitOverrides: number; + explicitlyEnabled: number; + explicitlyDisabled: number; + huenitivEnabled: boolean; +}; + +export function buildStylePromptReport(settings: StoredSettings, source: StyleSource): StylePromptReport { + const policy = buildStylePolicy(settings, source); + const stylePrompt = buildStylePrompt(settings, source); + const managedBlock = managedStyleBlock(stylePrompt); + const dictionaryEntries = flattenNodes(source.roots.dictionary).filter((node) => node.kind === "entry"); + const sceneEntries = flattenNodes(source.roots.scenes).filter((node) => node.kind === "scene"); + const dictionary = { + enabled: dictionaryEntries.filter((node) => entryEnabled(node, settings, source)).length, + total: dictionaryEntries.length, + }; + const scenes = { + enabled: sceneEntries.filter((node) => node.optionId && selectedSectionIds(settings, source).includes(node.optionId)).length, + total: sceneEntries.length, + }; + return { + ...(stylePrompt ? { stylePrompt } : {}), + ...(managedBlock ? { managedBlock } : {}), + policyCharacters: policy?.length ?? 0, + managedCharacters: managedBlock?.length ?? 0, + selectedSections: effectiveSectionIds(settings, source).length, + totalSections: source.options.length, + dictionaryEnabled: dictionary.enabled, + dictionaryTotal: dictionary.total, + scenesEnabled: scenes.enabled, + scenesTotal: scenes.total, + explicitOverrides: Object.keys(settings.itemOverrides ?? {}).length, + explicitlyEnabled: Object.values(settings.itemOverrides ?? {}).filter(Boolean).length, + explicitlyDisabled: Object.values(settings.itemOverrides ?? {}).filter((enabled) => !enabled).length, + huenitivEnabled: effectiveSectionIds(settings, source).includes(HUENITIV_OPTION_ID), + }; +} + +export function stripManagedStyleBlocks(prompt: string): string { + const openStarts: number[] = []; + const ranges: Array<{ start: number; end: number }> = []; + let cursor = 0; + + while (cursor < prompt.length) { + const start = prompt.indexOf(PROMPT_START, cursor); + const end = prompt.indexOf(PROMPT_END, cursor); + if (start === -1 && end === -1) break; + + if (start !== -1 && (end === -1 || start < end)) { + openStarts.push(start); + cursor = start + PROMPT_START.length; + continue; + } + + if (openStarts.length > 0) { + ranges.push({ + start: openStarts.pop()!, + end: end + PROMPT_END.length, + }); + } + cursor = end + PROMPT_END.length; + } + + for (const start of openStarts) ranges.push({ start, end: prompt.length }); + if (ranges.length === 0) return prompt; + + const merged = ranges + .sort((left, right) => left.start - right.start) + .reduce>((result, range) => { + const previous = result.at(-1); + if (previous && range.start <= previous.end) { + previous.end = Math.max(previous.end, range.end); + } else { + result.push({ ...range }); + } + return result; + }, []); + const expanded = merged + .map((range) => { + let { start, end } = range; + while (start > 0 && prompt[start - 1] === "\n") start -= 1; + while (end < prompt.length && prompt[end] === "\n") end += 1; + return { start, end }; + }) + .reduce>((result, range) => { + const previous = result.at(-1); + if (previous && range.start <= previous.end) { + previous.end = Math.max(previous.end, range.end); + } else { + result.push({ ...range }); + } + return result; + }, []); + + let result = ""; + cursor = 0; + for (const range of expanded) { + result += prompt.slice(cursor, range.start); + if (result.length > 0 && range.end < prompt.length) result += "\n\n"; + cursor = range.end; + } + result += prompt.slice(cursor); + return result; +} + +export function placeStylePromptAtAppendBoundary( + basePrompt: string, + appendSystemPrompt: string | undefined, + stylePrompt: string | undefined, +): string { + const block = managedStyleBlock(stylePrompt); + const append = appendSystemPrompt?.trim(); + const trimmedBase = basePrompt.trimEnd(); + const appendIndex = + append && (trimmedBase === append || trimmedBase.endsWith(`\n${append}`)) + ? trimmedBase.length - append.length + : -1; + if (append && appendIndex >= 0) { + const originalBefore = basePrompt.slice(0, appendIndex); + const cleanBefore = stripManagedStyleBlocks(originalBefore); + if (!block && cleanBefore === originalBefore) return basePrompt; + + const before = cleanBefore.replace(/\n+$/, ""); + const after = basePrompt.slice(appendIndex).replace(/^\n+/, ""); + return [before, block, after].filter(Boolean).join("\n\n"); + } + + const cleanBase = stripManagedStyleBlocks(basePrompt); + if (!block) return cleanBase; + return cleanBase ? `${cleanBase}\n\n${block}` : block; +} + + diff --git a/package.json b/package.json index 38d8cd3..038aeef 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "commands/", "docs/", "extensions/pohuy.ts", + "extensions/pohuy/", "extensions/README.md", "skills/", "ЧЕСТНЫЕ-ЦИФРЫ.md"