[devtools] Force ANSI colors on overlay code frames regardless of TTY (#93550)

### What?

The dev overlay's code frame loses syntax highlighting whenever `next
dev` runs under a non-TTY parent (VS Code / Cursor's integrated
terminal, Docker without `-t`, `concurrently`, the JavaScript Debug
Terminal, CI logs, etc.). Tokens render as plain monochrome text instead
of the usual colored output.

### Why?

The "syntax highlighting" in the overlay isn't real highlighting. The
server embeds ANSI escape codes into the code frame string, and the
browser overlay parses them via Anser to render colored `<span>`s.
Whether the dev server's stdout is a TTY is irrelevant on this path —
the consumer is the browser.

Since #71860, `getOriginalCodeFrame` defaults `colors` to
`process.stdout.isTTY`, and the overlay middlewares relied on that
default. The moment stdout was a pipe, the overlay had nothing to color.

### How?

`createOriginalStackFrame` and `getOriginalStackFrames` are shared by
five consumers (overlay HTTP endpoint, webpack `Module not found`
plugin, browser-log forwarding, MCP error formatter, hot-reloader
resolver). Hardcoding `colors: true` would leak escape sequences into
the four non-overlay paths.

Thread a `codeFrameOptions: { colors?, maxWidth? }` parameter through
both functions. The overlay HTTP middlewares opt in to `colors: true`
and `maxWidth: DEVTOOLS_CODE_FRAME_MAX_WIDTH`; everyone else keeps the
existing TTY-gated colors and terminal-width wrapping. (The 1000-column
override from #92621 was suffering from the same leak — also scoped to
the overlay now.)

Tested from Cursor's integrated terminal with/without fix:

Before:
<img width="1174" height="1070" alt="CleanShot 2026-05-06 at 22 34
45@2x"
src="https://github.com/user-attachments/assets/16ef1ac2-749b-443d-9e76-42f1318e9cd1"
/>


After:
<img width="1254" height="1066" alt="CleanShot 2026-05-06 at 22 34
30@2x"
src="https://github.com/user-attachments/assets/577ef3c2-3f02-4bb5-9825-63aa5564c593"
/>
This commit is contained in:
Aurora Scharff
2026-05-07 12:11:37 +02:00
committed by GitHub
parent 401ce3803c
commit d457e63ed6
2 changed files with 54 additions and 8 deletions
@@ -283,10 +283,22 @@ async function nativeTraceSource(
return undefined
}
/**
* Code frame rendering options. The defaults match terminal consumers; only
* the overlay HTTP path opts in to always-on colors and the wide max width.
*/
type CodeFrameOptions = {
/** Defaults to `process.stdout.isTTY`. */
colors?: boolean
/** Defaults to the dev server's terminal width. */
maxWidth?: number
}
async function createOriginalStackFrame(
project: Project,
projectPath: string,
frame: TurbopackStackFrame
frame: TurbopackStackFrame,
codeFrameOptions?: CodeFrameOptions
): Promise<OriginalStackFrameResponse | null> {
const traced =
(await nativeTraceSource(frame)) ??
@@ -324,9 +336,8 @@ async function createOriginalStackFrame(
get originalCodeFrame() {
if (originalCodeFrame === undefined) {
originalCodeFrame = getOriginalCodeFrame(tracedFrame, traced.source, {
// The overlay renders in a browser with horizontal scrolling,
// so don't truncate lines to the server's terminal width.
maxWidth: DEVTOOLS_CODE_FRAME_MAX_WIDTH,
colors: codeFrameOptions?.colors,
maxWidth: codeFrameOptions?.maxWidth,
})
}
return originalCodeFrame
@@ -372,6 +383,13 @@ export function getOverlayMiddleware({
isServer: request.isServer,
isEdgeServer: request.isEdgeServer,
isAppDirectory: request.isAppDirectory,
codeFrameOptions: {
// Overlay parses ANSI in JS and renders in a scrollable
// `<pre>`, so colors are always wanted and terminal width is
// irrelevant.
colors: true,
maxWidth: DEVTOOLS_CODE_FRAME_MAX_WIDTH,
},
})
ignoreListAnonymousStackFramesIfSandwiched(result)
@@ -492,6 +510,7 @@ export async function getOriginalStackFrames({
isServer,
isEdgeServer,
isAppDirectory,
codeFrameOptions,
}: {
project: Project
projectPath: string
@@ -499,6 +518,7 @@ export async function getOriginalStackFrames({
isServer: boolean
isEdgeServer: boolean
isAppDirectory: boolean
codeFrameOptions?: CodeFrameOptions
}): Promise<OriginalStackFramesResponse> {
const stackFrames = createStackFrames({
frames,
@@ -513,7 +533,8 @@ export async function getOriginalStackFrames({
const stackFrame = await createOriginalStackFrame(
project,
projectPath,
frame
frame,
codeFrameOptions
)
if (stackFrame === null) {
return {
@@ -182,12 +182,24 @@ function findOriginalSourcePositionAndContentFromCompilation(
return module?.buildInfo?.importLocByPath?.get(importedModule) ?? null
}
/**
* Code frame rendering options. The defaults match terminal consumers; only
* the overlay HTTP path opts in to always-on colors and the wide max width.
*/
type CodeFrameOptions = {
/** Defaults to `process.stdout.isTTY`. */
colors?: boolean
/** Defaults to the dev server's terminal width. */
maxWidth?: number
}
export async function createOriginalStackFrame({
ignoredByDefault,
source,
rootDirectory,
frame,
errorMessage,
codeFrameOptions,
}: {
/** setting this to true will not consult ignoreList */
ignoredByDefault: boolean
@@ -195,6 +207,7 @@ export async function createOriginalStackFrame({
rootDirectory: string
frame: StackFrame
errorMessage?: string
codeFrameOptions?: CodeFrameOptions
}): Promise<OriginalStackFrameResponse | null> {
const moduleNotFound = findModuleNotFoundFromError(errorMessage)
const result = await (() => {
@@ -263,9 +276,8 @@ export async function createOriginalStackFrame({
get originalCodeFrame() {
if (originalCodeFrame === undefined) {
originalCodeFrame = getOriginalCodeFrame(traced, sourceContent, {
// The overlay renders in a browser with horizontal scrolling,
// so don't truncate lines to the server's terminal width.
maxWidth: DEVTOOLS_CODE_FRAME_MAX_WIDTH,
colors: codeFrameOptions?.colors,
maxWidth: codeFrameOptions?.maxWidth,
})
}
return originalCodeFrame
@@ -393,6 +405,7 @@ export async function getOriginalStackFrames({
serverStats,
edgeServerStats,
rootDirectory,
codeFrameOptions,
}: {
isServer: boolean
isEdgeServer: boolean
@@ -402,6 +415,7 @@ export async function getOriginalStackFrames({
serverStats: () => webpack.Stats | null
edgeServerStats: () => webpack.Stats | null
rootDirectory: string
codeFrameOptions?: CodeFrameOptions
}): Promise<OriginalStackFramesResponse> {
const frameResponses = await Promise.all(
frames.map(
@@ -415,6 +429,7 @@ export async function getOriginalStackFrames({
serverStats,
edgeServerStats,
rootDirectory,
codeFrameOptions,
}).then(
(value) => {
return {
@@ -446,6 +461,7 @@ async function getOriginalStackFrame({
serverStats,
edgeServerStats,
rootDirectory,
codeFrameOptions,
}: {
isServer: boolean
isEdgeServer: boolean
@@ -455,6 +471,7 @@ async function getOriginalStackFrame({
serverStats: () => webpack.Stats | null
edgeServerStats: () => webpack.Stats | null
rootDirectory: string
codeFrameOptions?: CodeFrameOptions
}): Promise<OriginalStackFrameResponse> {
const filename = frame.file ?? ''
const source = await getSource(frame, {
@@ -534,6 +551,7 @@ async function getOriginalStackFrame({
frame,
source,
rootDirectory,
codeFrameOptions,
})
if (!originalStackFrameResponse) {
@@ -601,6 +619,13 @@ export function getOverlayMiddleware(options: {
serverStats,
edgeServerStats,
rootDirectory,
codeFrameOptions: {
// Overlay parses ANSI in JS and renders in a scrollable
// `<pre>`, so colors are always wanted and terminal width
// is irrelevant.
colors: true,
maxWidth: DEVTOOLS_CODE_FRAME_MAX_WIDTH,
},
})
)
} catch (err) {