@@ -450,12 +450,16 @@ export function extractChoices(
450450 }
451451
452452 // Check for menu end (indentation decreases)
453- // Pop from stack until we find the appropriate menu level
453+ // Pop from stack until we find the appropriate menu level.
454+ // A line at the same indent as `menu:` is outside the menu (a sibling
455+ // of the menu block, e.g. the next label or a keyword), so pop with `<=`.
456+ // Use a while loop so a single dedent unwinds all nested menu levels
457+ // the line has escaped.
454458 const lineIndent = line . search ( / \S / ) ;
455- if (
459+ while (
456460 menuStack . length > 0 &&
457461 trimmed &&
458- lineIndent < menuStack [ menuStack . length - 1 ]
462+ lineIndent <= menuStack [ menuStack . length - 1 ]
459463 ) {
460464 menuStack . pop ( ) ;
461465 }
@@ -1122,8 +1126,19 @@ export function reconstructRPYFile(options: ReconstructedFileOptions): string {
11221126 const labelIndentation = new Map < string , string > ( ) ;
11231127 let lastDialogueIndent = " " ; // Default RPY indentation
11241128
1125- // Keywords that signal the end of a label's dialogue block
1126- const labelEndKeywords = new Set ( [ "return" , "menu" , "jump" , "call" ] ) ;
1129+ // Track menu block nesting to prevent premature dialogue insertion.
1130+ // Menu titles are editable dialogue entries, so they must be matched and
1131+ // replaced inside menu blocks. However, jump/call/return statements inside
1132+ // menu choice bodies should NOT trigger dialogue insertion — they're part of
1133+ // the menu structure, not the label's main dialogue flow.
1134+ const menuStack : number [ ] = [ ] ;
1135+
1136+ // Keywords that signal the end of a label's dialogue block.
1137+ // The `menuStack.length === 0` guard below prevents premature insertion at
1138+ // `menu:` (and any other line inside a menu block). This is the mechanism
1139+ // that stops the menu title from being inserted before `menu:` and again
1140+ // matched in place, which would produce duplicate dialogue entries.
1141+ const labelEndKeywords = new Set ( [ "return" , "jump" , "call" ] ) ;
11271142 const isLabelEndKeyword = ( trimmed : string ) : boolean => {
11281143 const firstWord = trimmed . split ( / \s + / ) [ 0 ] ;
11291144 // Normalize by stripping trailing colon and other punctuation
@@ -1171,16 +1186,35 @@ export function reconstructRPYFile(options: ReconstructedFileOptions): string {
11711186
11721187 currentLabel = labelMatch [ 1 ] ;
11731188 labelDialogueIndices . set ( currentLabel , 0 ) ;
1189+ // Reset menu tracking on label boundary
1190+ menuStack . length = 0 ;
11741191 result . push ( line ) ;
11751192 continue ;
11761193 }
11771194
1195+ // Track menu block nesting: push on menu:, pop on dedent
1196+ if ( trimmed === "menu:" ) {
1197+ menuStack . push ( line . search ( / \S / ) ) ;
1198+ } else if ( menuStack . length > 0 && trimmed ) {
1199+ const lineIndent = line . search ( / \S / ) ;
1200+ while (
1201+ menuStack . length > 0 &&
1202+ lineIndent <= menuStack [ menuStack . length - 1 ]
1203+ ) {
1204+ menuStack . pop ( ) ;
1205+ }
1206+ }
1207+
11781208 // Check if this is a dialogue line
11791209 const dialogueMatch = trimmed . match (
11801210 / ^ ( [ a - z A - Z _ ] [ a - z A - Z 0 - 9 _ ] * ) \s + " ( (?: [ ^ " \\ ] | \\ .) * ) " $ /
11811211 ) ;
11821212 const narrationMatch = trimmed . match ( / ^ " ( .* ) " $ / ) ;
11831213
1214+ // Match and replace dialogue/narration both outside AND inside menu blocks.
1215+ // Menu titles are editable entries that should be updated like any other
1216+ // dialogue. The label-end insertion below handles the case where there are
1217+ // more entries than original lines.
11841218 if (
11851219 ( dialogueMatch || narrationMatch ) &&
11861220 currentLabel &&
@@ -1216,8 +1250,14 @@ export function reconstructRPYFile(options: ReconstructedFileOptions): string {
12161250 // The line will be added by the fall-through below.
12171251 }
12181252
1219- // Before adding a line that ends the label block, insert any remaining dialogue
1220- if ( currentLabel && updatedDialogue . has ( currentLabel ) ) {
1253+ // Before adding a line that ends the label block, insert any remaining dialogue.
1254+ // Only do this OUTSIDE menu blocks — jump/call/return inside menu choice
1255+ // bodies are part of the menu structure, not the label's dialogue flow.
1256+ if (
1257+ currentLabel &&
1258+ updatedDialogue . has ( currentLabel ) &&
1259+ menuStack . length === 0
1260+ ) {
12211261 const labelDialogue = updatedDialogue . get ( currentLabel ) ! ;
12221262 const currentIndex = labelDialogueIndices . get ( currentLabel ) ?? 0 ;
12231263
0 commit comments