/ utils / promptEditor.ts
promptEditor.ts
  1  import {
  2    expandPastedTextRefs,
  3    formatPastedTextRef,
  4    getPastedTextRefNumLines,
  5  } from '../history.js'
  6  import instances from '../ink/instances.js'
  7  import type { PastedContent } from './config.js'
  8  import { classifyGuiEditor, getExternalEditor } from './editor.js'
  9  import { execSync_DEPRECATED } from './execSyncWrapper.js'
 10  import { getFsImplementation } from './fsOperations.js'
 11  import { toIDEDisplayName } from './ide.js'
 12  import { writeFileSync_DEPRECATED } from './slowOperations.js'
 13  import { generateTempFilePath } from './tempfile.js'
 14  
 15  // Map of editor command overrides (e.g., to add wait flags)
 16  const EDITOR_OVERRIDES: Record<string, string> = {
 17    code: 'code -w', // VS Code: wait for file to be closed
 18    subl: 'subl --wait', // Sublime Text: wait for file to be closed
 19  }
 20  
 21  function isGuiEditor(editor: string): boolean {
 22    return classifyGuiEditor(editor) !== undefined
 23  }
 24  
 25  export type EditorResult = {
 26    content: string | null
 27    error?: string
 28  }
 29  
 30  // sync IO: called from sync context (React components, sync command handlers)
 31  export function editFileInEditor(filePath: string): EditorResult {
 32    const fs = getFsImplementation()
 33    const inkInstance = instances.get(process.stdout)
 34    if (!inkInstance) {
 35      throw new Error('Ink instance not found - cannot pause rendering')
 36    }
 37  
 38    const editor = getExternalEditor()
 39    if (!editor) {
 40      return { content: null }
 41    }
 42  
 43    try {
 44      fs.statSync(filePath)
 45    } catch {
 46      return { content: null }
 47    }
 48  
 49    const useAlternateScreen = !isGuiEditor(editor)
 50  
 51    if (useAlternateScreen) {
 52      // Terminal editors (vi, nano, etc.) take over the terminal. Delegate to
 53      // Ink's alt-screen-aware handoff so fullscreen mode (where <AlternateScreen>
 54      // already entered alt screen) doesn't get knocked back to the main buffer
 55      // by a hardcoded ?1049l. enterAlternateScreen() internally calls pause()
 56      // and suspendStdin(); exitAlternateScreen() undoes both and resets frame
 57      // state so the next render writes from scratch.
 58      inkInstance.enterAlternateScreen()
 59    } else {
 60      // GUI editors (code, subl, etc.) open in a separate window — just pause
 61      // Ink and release stdin while they're open.
 62      inkInstance.pause()
 63      inkInstance.suspendStdin()
 64    }
 65  
 66    try {
 67      // Use override command if available, otherwise use the editor as-is
 68      const editorCommand = EDITOR_OVERRIDES[editor] ?? editor
 69      execSync_DEPRECATED(`${editorCommand} "${filePath}"`, {
 70        stdio: 'inherit',
 71      })
 72  
 73      // Read the edited content
 74      const editedContent = fs.readFileSync(filePath, { encoding: 'utf-8' })
 75      return { content: editedContent }
 76    } catch (err) {
 77      if (
 78        typeof err === 'object' &&
 79        err !== null &&
 80        'status' in err &&
 81        typeof (err as { status: unknown }).status === 'number'
 82      ) {
 83        const status = (err as { status: number }).status
 84        if (status !== 0) {
 85          const editorName = toIDEDisplayName(editor)
 86          return {
 87            content: null,
 88            error: `${editorName} exited with code ${status}`,
 89          }
 90        }
 91      }
 92      return { content: null }
 93    } finally {
 94      if (useAlternateScreen) {
 95        inkInstance.exitAlternateScreen()
 96      } else {
 97        inkInstance.resumeStdin()
 98        inkInstance.resume()
 99      }
100    }
101  }
102  
103  /**
104   * Re-collapse expanded pasted text by finding content that matches
105   * pastedContents and replacing it with references.
106   */
107  function recollapsePastedContent(
108    editedPrompt: string,
109    originalPrompt: string,
110    pastedContents: Record<number, PastedContent>,
111  ): string {
112    let collapsed = editedPrompt
113  
114    // Find pasted content in the edited text and re-collapse it
115    for (const [id, content] of Object.entries(pastedContents)) {
116      if (content.type === 'text') {
117        const pasteId = parseInt(id)
118        const contentStr = content.content
119  
120        // Check if this exact content exists in the edited prompt
121        const contentIndex = collapsed.indexOf(contentStr)
122        if (contentIndex !== -1) {
123          // Replace with reference
124          const numLines = getPastedTextRefNumLines(contentStr)
125          const ref = formatPastedTextRef(pasteId, numLines)
126          collapsed =
127            collapsed.slice(0, contentIndex) +
128            ref +
129            collapsed.slice(contentIndex + contentStr.length)
130        }
131      }
132    }
133  
134    return collapsed
135  }
136  
137  // sync IO: called from sync context (React components, sync command handlers)
138  export function editPromptInEditor(
139    currentPrompt: string,
140    pastedContents?: Record<number, PastedContent>,
141  ): EditorResult {
142    const fs = getFsImplementation()
143    const tempFile = generateTempFilePath()
144  
145    try {
146      // Expand any pasted text references before editing
147      const expandedPrompt = pastedContents
148        ? expandPastedTextRefs(currentPrompt, pastedContents)
149        : currentPrompt
150  
151      // Write expanded prompt to temp file
152      writeFileSync_DEPRECATED(tempFile, expandedPrompt, {
153        encoding: 'utf-8',
154        flush: true,
155      })
156  
157      // Delegate to editFileInEditor
158      const result = editFileInEditor(tempFile)
159  
160      if (result.content === null) {
161        return result
162      }
163  
164      // Trim a single trailing newline if present (common editor behavior)
165      let finalContent = result.content
166      if (finalContent.endsWith('\n') && !finalContent.endsWith('\n\n')) {
167        finalContent = finalContent.slice(0, -1)
168      }
169  
170      // Re-collapse pasted content if it wasn't edited
171      if (pastedContents) {
172        finalContent = recollapsePastedContent(
173          finalContent,
174          currentPrompt,
175          pastedContents,
176        )
177      }
178  
179      return { content: finalContent }
180    } finally {
181      // Clean up temp file
182      try {
183        fs.unlinkSync(tempFile)
184      } catch {
185        // Ignore cleanup errors
186      }
187    }
188  }