π File detail
utils/promptEditor.ts
π§© .tsπ 189 linesπΎ 5,664 bytesπ text
β Back to All Filesπ― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes EditorResult, editFileInEditor, and editPromptInEditor β mainly functions, hooks, or classes. It composes internal code from history, ink, config, editor, and execSyncWrapper (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { expandPastedTextRefs, formatPastedTextRef, getPastedTextRefNumLines, } from '../history.js'
π€ Exports (heuristic)
EditorResulteditFileInEditoreditPromptInEditor
π₯οΈ Source preview
import {
expandPastedTextRefs,
formatPastedTextRef,
getPastedTextRefNumLines,
} from '../history.js'
import instances from '../ink/instances.js'
import type { PastedContent } from './config.js'
import { classifyGuiEditor, getExternalEditor } from './editor.js'
import { execSync_DEPRECATED } from './execSyncWrapper.js'
import { getFsImplementation } from './fsOperations.js'
import { toIDEDisplayName } from './ide.js'
import { writeFileSync_DEPRECATED } from './slowOperations.js'
import { generateTempFilePath } from './tempfile.js'
// Map of editor command overrides (e.g., to add wait flags)
const EDITOR_OVERRIDES: Record<string, string> = {
code: 'code -w', // VS Code: wait for file to be closed
subl: 'subl --wait', // Sublime Text: wait for file to be closed
}
function isGuiEditor(editor: string): boolean {
return classifyGuiEditor(editor) !== undefined
}
export type EditorResult = {
content: string | null
error?: string
}
// sync IO: called from sync context (React components, sync command handlers)
export function editFileInEditor(filePath: string): EditorResult {
const fs = getFsImplementation()
const inkInstance = instances.get(process.stdout)
if (!inkInstance) {
throw new Error('Ink instance not found - cannot pause rendering')
}
const editor = getExternalEditor()
if (!editor) {
return { content: null }
}
try {
fs.statSync(filePath)
} catch {
return { content: null }
}
const useAlternateScreen = !isGuiEditor(editor)
if (useAlternateScreen) {
// Terminal editors (vi, nano, etc.) take over the terminal. Delegate to
// Ink's alt-screen-aware handoff so fullscreen mode (where <AlternateScreen>
// already entered alt screen) doesn't get knocked back to the main buffer
// by a hardcoded ?1049l. enterAlternateScreen() internally calls pause()
// and suspendStdin(); exitAlternateScreen() undoes both and resets frame
// state so the next render writes from scratch.
inkInstance.enterAlternateScreen()
} else {
// GUI editors (code, subl, etc.) open in a separate window β just pause
// Ink and release stdin while they're open.
inkInstance.pause()
inkInstance.suspendStdin()
}
try {
// Use override command if available, otherwise use the editor as-is
const editorCommand = EDITOR_OVERRIDES[editor] ?? editor
execSync_DEPRECATED(`${editorCommand} "${filePath}"`, {
stdio: 'inherit',
})
// Read the edited content
const editedContent = fs.readFileSync(filePath, { encoding: 'utf-8' })
return { content: editedContent }
} catch (err) {
if (
typeof err === 'object' &&
err !== null &&
'status' in err &&
typeof (err as { status: unknown }).status === 'number'
) {
const status = (err as { status: number }).status
if (status !== 0) {
const editorName = toIDEDisplayName(editor)
return {
content: null,
error: `${editorName} exited with code ${status}`,
}
}
}
return { content: null }
} finally {
if (useAlternateScreen) {
inkInstance.exitAlternateScreen()
} else {
inkInstance.resumeStdin()
inkInstance.resume()
}
}
}
/**
* Re-collapse expanded pasted text by finding content that matches
* pastedContents and replacing it with references.
*/
function recollapsePastedContent(
editedPrompt: string,
originalPrompt: string,
pastedContents: Record<number, PastedContent>,
): string {
let collapsed = editedPrompt
// Find pasted content in the edited text and re-collapse it
for (const [id, content] of Object.entries(pastedContents)) {
if (content.type === 'text') {
const pasteId = parseInt(id)
const contentStr = content.content
// Check if this exact content exists in the edited prompt
const contentIndex = collapsed.indexOf(contentStr)
if (contentIndex !== -1) {
// Replace with reference
const numLines = getPastedTextRefNumLines(contentStr)
const ref = formatPastedTextRef(pasteId, numLines)
collapsed =
collapsed.slice(0, contentIndex) +
ref +
collapsed.slice(contentIndex + contentStr.length)
}
}
}
return collapsed
}
// sync IO: called from sync context (React components, sync command handlers)
export function editPromptInEditor(
currentPrompt: string,
pastedContents?: Record<number, PastedContent>,
): EditorResult {
const fs = getFsImplementation()
const tempFile = generateTempFilePath()
try {
// Expand any pasted text references before editing
const expandedPrompt = pastedContents
? expandPastedTextRefs(currentPrompt, pastedContents)
: currentPrompt
// Write expanded prompt to temp file
writeFileSync_DEPRECATED(tempFile, expandedPrompt, {
encoding: 'utf-8',
flush: true,
})
// Delegate to editFileInEditor
const result = editFileInEditor(tempFile)
if (result.content === null) {
return result
}
// Trim a single trailing newline if present (common editor behavior)
let finalContent = result.content
if (finalContent.endsWith('\n') && !finalContent.endsWith('\n\n')) {
finalContent = finalContent.slice(0, -1)
}
// Re-collapse pasted content if it wasn't edited
if (pastedContents) {
finalContent = recollapsePastedContent(
finalContent,
currentPrompt,
pastedContents,
)
}
return { content: finalContent }
} finally {
// Clean up temp file
try {
fs.unlinkSync(tempFile)
} catch {
// Ignore cleanup errors
}
}
}