From 220ec34a741ccc849c912d1cfa720c78049018aa Mon Sep 17 00:00:00 2001 From: vegu-ai-tools <152010387+vegu-ai-tools@users.noreply.github.com> Date: Sun, 17 May 2026 01:51:56 +0300 Subject: [PATCH] Implement autocomplete hints feature across various components and contexts --- CHANGELOG.yaml | 2 +- src/talemate/agents/creator/assistant.py | 79 +++++++++++++++++++ .../creator/contextual-generate.jinja2 | 2 + src/talemate/server/assistant.py | 3 +- .../src/components/CharacterMessage.vue | 5 +- .../ContextInvestigationMessage.vue | 5 +- .../src/components/NarratorMessage.vue | 5 +- .../src/components/TalemateApp.vue | 17 ++-- .../WorldStateManagerCharacterAttributes.vue | 13 ++- .../WorldStateManagerCharacterCreator.vue | 5 +- .../WorldStateManagerCharacterDescription.vue | 13 ++- .../WorldStateManagerCharacterDetails.vue | 13 ++- .../WorldStateManagerSceneOutline.vue | 13 ++- .../src/utils/autocompleteHint.js | 11 +++ tests/prompts/test_creator_templates.py | 79 +++++++++++++++++++ 15 files changed, 232 insertions(+), 33 deletions(-) create mode 100644 talemate_frontend/src/utils/autocompleteHint.js diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 4ef938fc..d4ff7759 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -15,7 +15,7 @@ - "Frontend Tooling: Migrated the frontend from npm to pnpm for supply-chain hardening. pnpm is provisioned automatically through Corepack (bundled with Node.js), so there is no extra install step. A 7-day minimum-release-age cooldown prevents installing dependency versions less than a week old — keeping the project out of the blast radius of fast-moving npm supply-chain attacks — and dependency install/build scripts are now blocked unless explicitly allowlisted. pnpm 11 requires Node.js 22, so the install, update, and Docker build scripts now provision Node 22 automatically; the Linux and Windows installers download a portable Node runtime." - "Scene Message List: Long scenes render more smoothly when the message history is modified. Operations that remove or rearrange messages (deleting a message, hiding from context, status updates that replace a previous status, etc.) now only re-render the affected slot instead of every message after the change point. As a side benefit, the collapsed state on context-investigation, director, and time-passage messages now survives when an earlier message in the scene is removed." - "OpenAI Compatible Client: Added a Parameters config tab with individual toggles for `temperature`, `top_p`, and `presence_penalty`. When a parameter is toggled off it is omitted from the request payload entirely, which is needed for OpenAI-compatible backends that hard-error if the parameter is sent for the selected model. All three default to on so existing clients are unaffected." - - "Autocomplete Hints: Dialogue and narrative autocomplete now accept a free-form `{...}` hint block at the end of the input. Anything inside the curly braces is passed to the LLM as directional guidance for the continuation (tone, beats, sensory detail, character reactions, etc.) and the brace block itself is stripped from the scene input when the suggestion is accepted. Example: typing `\"Kaira!?\" he yelled {dark corridor, no response, ship shakes}` cues the model on what to weave into the completion without those tokens ending up in the scene text. Trailing braces only; mid-text `{...}` is left alone, and the hint is opt-in — no behavior change when no brace block is present." + - "Autocomplete Hints: Dialogue, narrative, and contextual autocomplete now accept a free-form `{...}` hint block at the end of the input. Anything inside the curly braces is passed to the LLM as directional guidance for the continuation (tone, beats, sensory detail, character reactions, etc.) and the brace block itself is stripped from the field when the suggestion is accepted. Example: typing `\"Kaira!?\" he yelled {dark corridor, no response, ship shakes}` cues the model on what to weave into the completion without those tokens ending up in the scene text. Works in the scene input, character description / details / attributes, scene intro, and inline character / narrator / context-investigation message editing. Toggle via the new `Enable Hints` setting on the Creator agent's Autocomplete config (default on); trailing braces only, mid-text `{...}` is left alone." fixes: - "Game Loop Event: Fixed an internal scene-loop event being constructed with the wrong scene reference, surfaced by the pydantic migration." - "Conversation Agent: Stopped injecting `#` into the LLM stop-sequence list on every conversation turn. The conversation agent's prompt-parameter hook now only wipes character-name stop sequences when the `inject_character_names_into_stop` setting is disabled, and is a no-op otherwise." diff --git a/src/talemate/agents/creator/assistant.py b/src/talemate/agents/creator/assistant.py index 2c4d1e9a..58bcfb6f 100644 --- a/src/talemate/agents/creator/assistant.py +++ b/src/talemate/agents/creator/assistant.py @@ -57,6 +57,7 @@ class ContextualGenerateEmission(AgentTemplateEmission): content_generation_context: "ContentGenerationContext | None" = None character: "Character | None" = None + autocomplete_hint: str | None = None @property def context_type(self) -> str: @@ -75,6 +76,7 @@ class AutocompleteEmission(AgentTemplateEmission): input: str = "" type: str = "" character: "Character | None" = None + autocomplete_hint: str | None = None class ContentGenerationContext(pydantic.BaseModel): @@ -207,6 +209,42 @@ class AssistantMixin: max=256, step=16, ), + "character_attribute_suggestion_length": AgentActionConfig( + type="number", + label="Character Attribute Suggestion Length", + description="Length of the generated suggestion when using autocomplete on a character attribute field.", + value=32, + min=32, + max=256, + step=16, + ), + "character_detail_suggestion_length": AgentActionConfig( + type="number", + label="Character Detail Suggestion Length", + description="Length of the generated suggestion when using autocomplete on a character detail or description field.", + value=64, + min=32, + max=256, + step=16, + ), + "scene_intro_suggestion_length": AgentActionConfig( + type="number", + label="Scene Intro Suggestion Length", + description="Length of the generated suggestion when using autocomplete on the scene intro field.", + value=96, + min=32, + max=256, + step=16, + ), + "context_investigation_suggestion_length": AgentActionConfig( + type="number", + label="Context Investigation Suggestion Length", + description="Length of the generated suggestion when using autocomplete on a context-investigation message.", + value=64, + min=32, + max=256, + step=16, + ), "hints_enabled": AgentActionConfig( type="bool", label="Enable Hints", @@ -238,6 +276,36 @@ class AssistantMixin: def autocomplete_narrative_suggestion_length(self): return self.resolve_config("autocomplete", "narrative_suggestion_length") + @property + def autocomplete_character_attribute_suggestion_length(self): + return self.resolve_config("autocomplete", "character_attribute_suggestion_length") + + @property + def autocomplete_character_detail_suggestion_length(self): + return self.resolve_config("autocomplete", "character_detail_suggestion_length") + + @property + def autocomplete_scene_intro_suggestion_length(self): + return self.resolve_config("autocomplete", "scene_intro_suggestion_length") + + @property + def autocomplete_context_investigation_suggestion_length(self): + return self.resolve_config("autocomplete", "context_investigation_suggestion_length") + + def autocomplete_contextual_length_for(self, context_type: str) -> int: + """Resolve the configured suggestion length for a contextual autocomplete + type. Properties follow `autocomplete__suggestion_length` + (spaces → underscores). Unmapped types fall back to character-detail.""" + attr = f"autocomplete_{context_type.replace(' ', '_')}_suggestion_length" + length = getattr(self, attr, None) + if length is None: + log.debug( + "autocomplete length fallback (unmapped context_type)", + context_type=context_type, + ) + return self.autocomplete_character_detail_suggestion_length + return length + @property def autocomplete_hints_enabled(self): return self.resolve_config("autocomplete", "hints_enabled") @@ -309,9 +377,16 @@ class AssistantMixin: kind = f"create_{generation_context.length}" + hint = None + if self.autocomplete_hints_enabled and generation_context.partial: + cleaned, hint = util.extract_autocomplete_hint(generation_context.partial) + if hint is not None: + generation_context.partial = cleaned + log.debug( f"Contextual generate: {context_typ} - {context_name}", generation_context=generation_context, + hint=hint, ) character = ( @@ -332,6 +407,7 @@ class AssistantMixin: "history_aware": generation_context.history_aware, "character": character, "template": generation_context.template, + "hint": hint, } emission = ContextualGenerateEmission( @@ -339,6 +415,7 @@ class AssistantMixin: content_generation_context=generation_context, character=character, template_vars=template_vars, + autocomplete_hint=hint, ) await async_signals.get("agent.creator.contextual_generate.before").send( @@ -574,6 +651,7 @@ class AssistantMixin: type="dialogue", character=character, template_vars=template_vars, + autocomplete_hint=hint, ) await async_signals.get("agent.creator.autocomplete.before").send(emission) @@ -674,6 +752,7 @@ class AssistantMixin: input=input, type="narrative", template_vars=template_vars, + autocomplete_hint=hint, ) await async_signals.get("agent.creator.autocomplete.before").send(emission) diff --git a/src/talemate/prompts/templates/creator/contextual-generate.jinja2 b/src/talemate/prompts/templates/creator/contextual-generate.jinja2 index 848086f2..770ab0fe 100644 --- a/src/talemate/prompts/templates/creator/contextual-generate.jinja2 +++ b/src/talemate/prompts/templates/creator/contextual-generate.jinja2 @@ -82,6 +82,8 @@ Override example: {% with information=generation_context.information %}{% include "task-information.jinja2" %}{% endwith %} {# END INFORMATION #} +{% include "autocomplete-hint.jinja2" %} + {# TASK DISPATCH #} <|SECTION:TASK|> {% if context_typ == "list" %} diff --git a/src/talemate/server/assistant.py b/src/talemate/server/assistant.py index 89b840c6..1b19b150 100644 --- a/src/talemate/server/assistant.py +++ b/src/talemate/server/assistant.py @@ -145,8 +145,7 @@ class AssistantPlugin(Plugin): await creator.autocomplete_narrative(data.partial, emit_signal=True) return - # force length to 35 - data.length = 35 + data.length = creator.autocomplete_contextual_length_for(context_type) log.info("Running autocomplete for contextual generation", args=data) completion = await creator.contextual_generate(data) log.info( diff --git a/talemate_frontend/src/components/CharacterMessage.vue b/talemate_frontend/src/components/CharacterMessage.vue index 0d08178a..6be277fa 100644 --- a/talemate_frontend/src/components/CharacterMessage.vue +++ b/talemate_frontend/src/components/CharacterMessage.vue @@ -91,6 +91,7 @@ import { SceneTextParser } from '@/utils/sceneMessageRenderer'; import { insertNewlineAtCursor } from '@/utils/textAreaUtils'; import { isPrimaryModifier } from '@/utils/keyboardModifiers'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; import { spliceContinuation } from '@/utils/messageContinuation'; import MessageAssetImage from './MessageAssetImage.vue'; import MessageAssetMixin from './MessageAssetMixin.js'; @@ -324,8 +325,8 @@ export default { context: "dialogue:npc", character: this.character, }, - (completion) => { - this.editing_text += completion; + (completion, { hintsEnabled }) => { + this.editing_text = applyAutocompleteCompletion(this.editing_text, completion, hintsEnabled); this.autocompleting = false; }, this.$refs.textarea diff --git a/talemate_frontend/src/components/ContextInvestigationMessage.vue b/talemate_frontend/src/components/ContextInvestigationMessage.vue index 9c60b8a8..22ad1c1f 100644 --- a/talemate_frontend/src/components/ContextInvestigationMessage.vue +++ b/talemate_frontend/src/components/ContextInvestigationMessage.vue @@ -72,6 +72,7 @@ import { SceneTextParser } from '@/utils/sceneMessageRenderer'; import { insertNewlineAtCursor } from '@/utils/textAreaUtils'; import { isPrimaryModifier } from '@/utils/keyboardModifiers'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; import MessageAssetImage from './MessageAssetImage.vue'; import MessageAssetMixin from './MessageAssetMixin.js'; import RevisionNav from './RevisionNav.vue'; @@ -221,8 +222,8 @@ export default { partial: this.editing_text, context: "context_investigation:continue", }, - (completion) => { - this.editing_text += completion; + (completion, { hintsEnabled }) => { + this.editing_text = applyAutocompleteCompletion(this.editing_text, completion, hintsEnabled); this.autocompleting = false; }, this.$refs.textarea diff --git a/talemate_frontend/src/components/NarratorMessage.vue b/talemate_frontend/src/components/NarratorMessage.vue index caf3348d..276e0c0d 100644 --- a/talemate_frontend/src/components/NarratorMessage.vue +++ b/talemate_frontend/src/components/NarratorMessage.vue @@ -95,6 +95,7 @@ import { SceneTextParser } from '@/utils/sceneMessageRenderer'; import { insertNewlineAtCursor } from '@/utils/textAreaUtils'; import { isPrimaryModifier } from '@/utils/keyboardModifiers'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; import { spliceContinuation } from '@/utils/messageContinuation'; import MessageAssetImage from './MessageAssetImage.vue'; import MessageAssetMixin from './MessageAssetMixin.js'; @@ -250,8 +251,8 @@ export default { partial: this.editing_text, context: "narrative:continue", }, - (completion) => { - this.editing_text += completion; + (completion, { hintsEnabled }) => { + this.editing_text = applyAutocompleteCompletion(this.editing_text, completion, hintsEnabled); this.autocompleting = false; }, this.$refs.textarea diff --git a/talemate_frontend/src/components/TalemateApp.vue b/talemate_frontend/src/components/TalemateApp.vue index 4b52d732..42d113e1 100644 --- a/talemate_frontend/src/components/TalemateApp.vue +++ b/talemate_frontend/src/components/TalemateApp.vue @@ -412,10 +412,7 @@ import PromptsMenu from './prompts/PromptsMenu.vue'; import { debounce } from 'lodash'; import { isVisualAgentReady, isImageEditAvailable, isImageCreateAvailable } from '@/constants/visual'; import { createSceneAssetsRequester } from './VisualAssetsMixin.js'; - -// Mirror of AUTOCOMPLETE_HINT_RE in src/talemate/util/dialogue.py. -// Used only for cosmetic textbox cleanup; the backend is the semantic source of truth. -const AUTOCOMPLETE_HINT_RE = /\s*\{[^{}]+\}\s*$/; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; export default { components: { @@ -1295,10 +1292,9 @@ export default { this.autocompleting = false; // Use the toggle value captured at send time, so toggling mid-flight // doesn't desync from what the backend already decided. - if (this.autocompleteHintsEnabledAtSend) { - this.messageInput = this.messageInput.replace(AUTOCOMPLETE_HINT_RE, ''); - } - this.messageInput += completion; + this.messageInput = applyAutocompleteCompletion( + this.messageInput, completion, this.autocompleteHintsEnabledAtSend + ); }, autocompleteHintsEnabled() { @@ -1347,14 +1343,15 @@ export default { autocompleteRequest(param, callback, focus_element, delay=500) { + const hintsEnabled = this.autocompleteHintsEnabled(); this.autocompleteCallback = (completion) => { setTimeout(() => { - callback(completion); + callback(completion, { hintsEnabled }); }, delay); }; this.autocompleteFocusElement = focus_element; this.autocompletePartialInput = param.partial; - this.autocompleteHintsEnabledAtSend = this.autocompleteHintsEnabled(); + this.autocompleteHintsEnabledAtSend = hintsEnabled; const param_copy = JSON.parse(JSON.stringify(param)); param_copy.type = "assistant"; diff --git a/talemate_frontend/src/components/WorldStateManagerCharacterAttributes.vue b/talemate_frontend/src/components/WorldStateManagerCharacterAttributes.vue index c94600de..34814aee 100644 --- a/talemate_frontend/src/components/WorldStateManagerCharacterAttributes.vue +++ b/talemate_frontend/src/components/WorldStateManagerCharacterAttributes.vue @@ -83,7 +83,7 @@ @keyup.ctrl.enter.stop="sendAutocompleteRequest" @update:modelValue="dirty = true" - @blur="update(selected, true)" + @blur="onBlurSave" v-model="character.base_attributes[selected]"> @@ -115,6 +115,7 @@ import ConfirmActionInline from './ConfirmActionInline.vue'; import ContextualGenerate from './ContextualGenerate.vue'; import WorldStateManagerTemplateApplicator from './WorldStateManagerTemplateApplicator.vue'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; import SpiceAppliedNotification from './SpiceAppliedNotification.vue'; export default { @@ -272,6 +273,12 @@ export default { })); }, + onBlurSave() { + // Guard: blur during autocomplete would save the un-stripped {hint}. + if (this.busy) return; + this.update(this.selected, true); + }, + setShared(name, shared) { const payload = { type: 'world_state_manager', @@ -315,8 +322,8 @@ export default { partial: this.character.base_attributes[this.selected], context: `character attribute:${this.selected}`, character: this.character.name - }, (completion) => { - this.character.base_attributes[this.selected] += completion; + }, (completion, { hintsEnabled }) => { + this.character.base_attributes[this.selected] = applyAutocompleteCompletion(this.character.base_attributes[this.selected], completion, hintsEnabled); this.busy = false; }, this.$refs.attribute); diff --git a/talemate_frontend/src/components/WorldStateManagerCharacterCreator.vue b/talemate_frontend/src/components/WorldStateManagerCharacterCreator.vue index ebd19bc0..af5a4b93 100644 --- a/talemate_frontend/src/components/WorldStateManagerCharacterCreator.vue +++ b/talemate_frontend/src/components/WorldStateManagerCharacterCreator.vue @@ -83,6 +83,7 @@ import ConfirmActionInline from './ConfirmActionInline.vue'; import WorldStateManagerTemplateApplicator from './WorldStateManagerTemplateApplicator.vue'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; export default { name: "WorldStateManagerCharacterCreator", @@ -171,8 +172,8 @@ export default { context: `character detail:description`, instructions: this.character.generation_context.instructions, character: this.character.name - }, (completion) => { - this.character.description += completion; + }, (completion, { hintsEnabled }) => { + this.character.description = applyAutocompleteCompletion(this.character.description, completion, hintsEnabled); this.descriptionBusy = false; }, this.$refs.description); diff --git a/talemate_frontend/src/components/WorldStateManagerCharacterDescription.vue b/talemate_frontend/src/components/WorldStateManagerCharacterDescription.vue index 054f8213..35ebc255 100644 --- a/talemate_frontend/src/components/WorldStateManagerCharacterDescription.vue +++ b/talemate_frontend/src/components/WorldStateManagerCharacterDescription.vue @@ -20,7 +20,7 @@ @keyup.ctrl.enter.stop="sendAutocompleteRequest" @update:model-value="dirty = true" - @blur="update(true)" + @blur="onBlurSave" label="Description" :hint="'A short description of the character. '+autocompleteInfoMessage(busy)"> @@ -32,6 +32,7 @@ import ContextualGenerate from './ContextualGenerate.vue'; import SpiceAppliedNotification from './SpiceAppliedNotification.vue'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; export default { name: 'WorldStateManagerCharacterDescription', @@ -97,14 +98,20 @@ export default { this.update(); }, + onBlurSave() { + // Guard: blur during autocomplete would save the un-stripped {hint}. + if (this.busy) return; + this.update(true); + }, + sendAutocompleteRequest() { this.busy = true; this.autocompleteRequest({ partial: this.character.description, context: `character detail:description`, character: this.character.name - }, (completion) => { - this.character.description += completion; + }, (completion, { hintsEnabled }) => { + this.character.description = applyAutocompleteCompletion(this.character.description, completion, hintsEnabled); this.busy = false; }, this.$refs.description); }, diff --git a/talemate_frontend/src/components/WorldStateManagerCharacterDetails.vue b/talemate_frontend/src/components/WorldStateManagerCharacterDetails.vue index c96c8496..18c9f826 100644 --- a/talemate_frontend/src/components/WorldStateManagerCharacterDetails.vue +++ b/talemate_frontend/src/components/WorldStateManagerCharacterDetails.vue @@ -86,7 +86,7 @@ @keyup.ctrl.enter.stop="sendAutocompleteRequest" @update:modelValue="dirty = true" - @blur="update(selected, true)" + @blur="onBlurSave" v-model="character.details[selected]"> @@ -126,6 +126,7 @@ import ContextualGenerate from './ContextualGenerate.vue'; import WorldStateManagerTemplateApplicator from './WorldStateManagerTemplateApplicator.vue'; import SpiceAppliedNotification from './SpiceAppliedNotification.vue'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; import ConfirmActionInline from './ConfirmActionInline.vue'; export default { @@ -298,6 +299,12 @@ export default { })); }, + onBlurSave() { + // Guard: blur during autocomplete would save the un-stripped {hint}. + if (this.busy) return; + this.update(this.selected, true); + }, + setShared(name, shared) { this.getWebsocket().send(JSON.stringify({ type: 'world_state_manager', @@ -340,8 +347,8 @@ export default { partial: this.character.details[this.selected], context: `character detail:${this.selected}`, character: this.character.name - }, (completion) => { - this.character.details[this.selected] += completion; + }, (completion, { hintsEnabled }) => { + this.character.details[this.selected] = applyAutocompleteCompletion(this.character.details[this.selected], completion, hintsEnabled); this.busy = false; }, this.$refs.detail); diff --git a/talemate_frontend/src/components/WorldStateManagerSceneOutline.vue b/talemate_frontend/src/components/WorldStateManagerSceneOutline.vue index 569fb34e..8b829222 100644 --- a/talemate_frontend/src/components/WorldStateManagerSceneOutline.vue +++ b/talemate_frontend/src/components/WorldStateManagerSceneOutline.vue @@ -146,7 +146,7 @@ max-rows="32" @update:model-value="setFieldDirty('intro')" - @blur="update(true)" + @blur="onIntroBlurSave" :color="dirty['intro'] ? 'dirty' : ''" :disabled="busy['intro']" @@ -168,6 +168,7 @@ import ContextualGenerate from './ContextualGenerate.vue'; import { MAX_CONTENT_WIDTH } from '@/constants/layout'; +import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint'; const defaultPerspectives = () => ({ default: "", player: "", other: "", narrator: "" }); @@ -299,13 +300,19 @@ export default { })); }, + onIntroBlurSave() { + // Guard: blur during autocomplete would save the un-stripped {hint}. + if (this.busy['intro']) return; + this.update(true); + }, + sendAutocompleteRequestForIntro() { this.busy['intro'] = true; this.autocompleteRequest({ partial: this.scene.data.intro, context: "scene intro:scene intro", - }, (completion) => { - this.scene.data.intro += completion; + }, (completion, { hintsEnabled }) => { + this.scene.data.intro = applyAutocompleteCompletion(this.scene.data.intro, completion, hintsEnabled); this.busy['intro'] = false; }, this.$refs.intro); diff --git a/talemate_frontend/src/utils/autocompleteHint.js b/talemate_frontend/src/utils/autocompleteHint.js new file mode 100644 index 00000000..0f147d9e --- /dev/null +++ b/talemate_frontend/src/utils/autocompleteHint.js @@ -0,0 +1,11 @@ +// Mirror of AUTOCOMPLETE_HINT_RE in src/talemate/util/dialogue.py. +// Used only for cosmetic textbox cleanup; the backend is the semantic source of truth. +export const AUTOCOMPLETE_HINT_RE = /\s*\{[^{}]+\}\s*$/; + +// Append an autocomplete completion to a field, optionally stripping a trailing +// `{...}` hint block from the existing text first. +export function applyCompletion(current, completion, hintsEnabled) { + const safe = String(current ?? ''); + const base = hintsEnabled ? safe.replace(AUTOCOMPLETE_HINT_RE, '') : safe; + return base + completion; +} diff --git a/tests/prompts/test_creator_templates.py b/tests/prompts/test_creator_templates.py index 8bd638e8..0eb13944 100644 --- a/tests/prompts/test_creator_templates.py +++ b/tests/prompts/test_creator_templates.py @@ -594,6 +594,57 @@ class TestCreatorContextualGenerateMethod: assert response is not None creator.client.send_prompt.assert_called_once() + @pytest.mark.asyncio + async def test_contextual_generate_extracts_hint_from_partial( + self, active_context + ): + """Trailing `{...}` is stripped from partial and rendered as EDITOR HINTS.""" + creator = active_context + creator.client.send_prompt.return_value = "extended text" + + generation_context = ContentGenerationContext( + context="character detail:background", + character="Elena", + partial="She grew up in the forest {haunted, lost sister}", + length=100, + ) + + await creator.contextual_generate(generation_context) + + prompt_text = str(creator.client.send_prompt.call_args[0][0]) + # Partial mutated in place: clean version visible to renderer + assert generation_context.partial == "She grew up in the forest" + # Hint surfaced in the EDITOR HINTS section + assert "editor hints" in prompt_text.lower() + assert "haunted, lost sister" in prompt_text + # Brace block does not leak into the DRAFT context + assert "{haunted, lost sister}" not in prompt_text + + @pytest.mark.asyncio + async def test_contextual_generate_hint_disabled_leaves_partial_intact( + self, active_context + ): + """When hints_enabled toggle is off, trailing `{...}` stays in partial.""" + creator = active_context + creator.actions["autocomplete"].config["hints_enabled"].value = False + creator.client.send_prompt.return_value = "extended text" + + original_partial = "She grew up in the forest {haunted, lost sister}" + generation_context = ContentGenerationContext( + context="character detail:background", + character="Elena", + partial=original_partial, + length=100, + ) + + await creator.contextual_generate(generation_context) + + prompt_text = str(creator.client.send_prompt.call_args[0][0]) + # Partial untouched + assert generation_context.partial == original_partial + # No hints section rendered + assert "EDITOR HINTS" not in prompt_text + @pytest.mark.asyncio async def test_generate_character_attribute_wrapper( self, active_context, mock_scene @@ -805,3 +856,31 @@ class TestCreatorAgentProperties: length = creator_agent.autocomplete_narrative_suggestion_length assert isinstance(length, int) assert length > 0 + + @pytest.mark.parametrize( + "context_type, expected_property", + [ + ("character attribute", "autocomplete_character_attribute_suggestion_length"), + ("character detail", "autocomplete_character_detail_suggestion_length"), + ("scene intro", "autocomplete_scene_intro_suggestion_length"), + ( + "context_investigation", + "autocomplete_context_investigation_suggestion_length", + ), + ], + ) + def test_autocomplete_contextual_length_for_known_types( + self, creator_agent, context_type, expected_property + ): + """Each known context_type dispatches to its dedicated length config.""" + expected = getattr(creator_agent, expected_property) + assert creator_agent.autocomplete_contextual_length_for(context_type) == expected + + def test_autocomplete_contextual_length_for_unknown_falls_back_to_detail( + self, creator_agent + ): + """Unmapped context_types fall back to character_detail's length.""" + fallback = creator_agent.autocomplete_character_detail_suggestion_length + assert ( + creator_agent.autocomplete_contextual_length_for("scene title") == fallback + )