Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions packages/nuxt-cli/src/dev/tui/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,31 @@ export function normaliseMessage(text: string): string {
return stripAnsi(text).replaceAll('`', '').replace(/\s+/g, ' ').trim()
}

/**
* Box drawing and the ASCII fallback's `>` gutter, so a boxed log can be
* recognised as the printed form of the message it was built from. The borders
* sit between every line of the message, which plain containment cannot see
* past.
*/
const BOX_DECORATION_RE = /[\u2500-\u257F]|^[ \t]*>[ \t]?/gm

/** Text as it reads with any box the printer drew around it taken off. */
function undecorate(text: string): string {
return normaliseMessage(stripAnsi(text).replaceAll(BOX_DECORATION_RE, ' '))
}

/**
* Whether the event is asking the user for something rather than reporting.
*
* A tool only draws a box around output it needs read, and what is inside is
* usually a URL to open or a token to paste: useless unheeded, and useless
* truncated onto a status line. Tools that know the terminal host say so
* through `notify` instead; a box is how everything else says it.
*/
export function isBoxedNotice(event: DevLogEvent): boolean {
return event.type === 'box'
}

/** Either text may carry a badge the other does not, so neither has to be exact. */
function sameMessage(a: string, b: string): boolean {
return a.includes(b) || b.includes(a)
Expand All @@ -76,9 +101,13 @@ function errorSignature(message: string): string | undefined {
* Badges usually arrive wrapped in colour, so matching happens against plain
* text. Only an uncoloured badge is cut from the message: slicing through an
* escape sequence would drop the reset and leave the rest of the line styled.
*
* A boxed notice already knows what it is, and inferring a severity from its
* opening words would rewrite it into a warning that no longer reaches the
* panel as one.
*/
function classify(event: DevLogEvent): DevLogEvent {
if (event.level < 2) {
if (event.level < 2 || isBoxedNotice(event)) {
return event
}
const plain = stripAnsi(event.message)
Expand Down Expand Up @@ -158,15 +187,19 @@ export class DevEventLog {
if (!text) {
return false
}
const boxed = undecorate(plain)
const now = Date.now()
for (let index = this.#events.length - 1; index >= 0 && index > this.#events.length - RECENT_SCAN; index--) {
const event = this.#events[index]!
if (now - event.time > withinMs) {
return false
}
const message = normaliseMessage(event.message)
if (!event.rendered && message && text.includes(message)) {
event.rendered = chunk
// A boxed notice that has already been printed keeps the output it was
// paired with: a repeat of it was collapsed into that entry, so its
// second printing has no other home.
if (message && (text.includes(message) || boxed.includes(message)) && (!event.rendered || isBoxedNotice(event))) {
event.rendered ??= chunk
return true
}
}
Expand Down Expand Up @@ -196,12 +229,20 @@ export class DevEventLog {
* (the one with the file and the stack) and counts the rest.
*/
#dedupe(event: DevLogEvent): DevLogEvent | undefined {
if (event.level > 1) {
if (event.level > 1 && !isBoxedNotice(event)) {
return undefined
}
const signature = errorSignature(event.message)
const sameProblem = (candidate: DevLogEvent) => !!signature && signature === errorSignature(candidate.message)
return this.#merge(event, candidate => candidate.level <= 1, (candidate) => {
// A boxed notice only ever joins another one. Folded into a warning it
// would leave the entry a warning, reported as a merge, and the attention
// the box was drawn to ask for would never be raised. The other direction
// is welcome: a warning saying the same thing joins the box and the box
// keeps the notice it already raised.
const matches = isBoxedNotice(event)
? isBoxedNotice
: (candidate: DevLogEvent) => candidate.level <= 1 || isBoxedNotice(candidate)
return this.#merge(event, matches, (candidate) => {
candidate.repeats = (candidate.repeats ?? 1) + 1
if (normaliseMessage(candidate.message).length < normaliseMessage(event.message).length) {
candidate.message = event.message
Expand Down
64 changes: 50 additions & 14 deletions packages/nuxt-cli/src/dev/tui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { checkForUpdate, isUpdateCheckEnabled, releaseNotesUrl } from '../../uti
import { openBrowser } from '../listen'
import { setupShortcuts } from '../shortcuts'
import { NOOP_CONTROLLER } from './controller'
import { isBoxedNotice, normaliseMessage } from './events'
import { HelpOverlay } from './help-overlay'
import { InfoOverlay } from './info-overlay'
import { attachKeys } from './keys'
Expand Down Expand Up @@ -183,6 +184,8 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
interface HeldNotice {
text: string
tone: 'info' | 'warn'
/** Replaces the status badge, for something that is waiting on the user. */
label?: string
resolve: () => void
}

Expand All @@ -191,7 +194,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})

function clearNotice(): void {
const held = heldNotices.at(-1)
update({ notice: held ? { text: held.text, tone: held.tone } : undefined })
update({ notice: held ? { text: held.text, tone: held.tone, label: held.label } : undefined })
}

function dismissHeld(held: HeldNotice): void {
Expand All @@ -204,6 +207,26 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
clearNotice()
}

/**
* Hold a message on the status line until the user acknowledges it with a
* keypress or the caller lets it go.
*
* The single path for anything that must not scroll away unnoticed, whether
* it was reported through the terminal host or recovered from a box a tool
* printed without knowing about the host.
*/
function holdNotice(notice: { text: string, tone: 'info' | 'warn', label?: string }) {
let resolve!: () => void
const dismissed = new Promise<void>((settle) => {
resolve = settle
})
const held: HeldNotice = { ...notice, text: notice.text.split('\n')[0]!.trim(), resolve }
heldNotices.push(held)
clearTimeout(noticeTimer)
clearNotice()
return { dismiss: () => dismissHeld(held), dismissed }
}

/** Show `text` for a moment. Nothing a notice reports outlives the moment. */
function showNotice(text: string, tone: 'info' | 'warn' | 'success'): void {
clearTimeout(noticeTimer)
Expand All @@ -229,14 +252,28 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
if (merged) {
return
}
if (isBoxedNotice(event)) {
// The box itself is written above the panel, where its URL can be read and
// copied; the badge is what stops it scrolling away unnoticed.
holdNotice({ text: firstSentence(event.message), tone: 'warn', label: 'ACTION' })
return
}
if (event.level <= 0) {
update({ errors: (state.errors ?? 0) + 1, status: state.status === 'ready' ? 'error' : state.status })
}
else if (event.level === 1) {
// A warning about what the CLI could not do says nothing about the app, so
// it is shown once rather than counted against the build.
if (event.source === 'cli') {
showNotice(event.message, 'warn')
// One raised while starting up describes the session itself, not a
// moment in it: how the server is exposed, what could not be set up.
// Those are worth holding until someone has looked at the panel.
if (state.readyMs === undefined) {
holdNotice({ text: firstSentence(event.message), tone: 'warn', label: 'WARNING' })
}
else {
showNotice(event.message, 'warn')
}
}
else {
update({ warnings: (state.warnings ?? 0) + 1 })
Expand Down Expand Up @@ -428,15 +465,8 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
return result
},
notify: (notification) => {
const tone = notification.level === 'warn' ? 'warn' as const : 'info' as const
let resolve!: () => void
const dismissed = new Promise<void>((settle) => {
resolve = settle
})
const held: HeldNotice = { text: (notification.title ?? notification.message).split('\n')[0]!.trim(), tone, resolve }
heldNotices.push(held)
// The full text goes into scrollback where it can be read and copied,
// and into the history; the held badge is what stops it scrolling away
// and into the history; the held notice is what stops it scrolling away
// unnoticed.
surfaceText(renderNotification(notification))
events.push({
Expand All @@ -446,9 +476,10 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
message: [notification.title, notification.message].filter(Boolean).join('\n'),
source: 'cli',
})
clearTimeout(noticeTimer)
clearNotice()
return { dismiss: () => dismissHeld(held), dismissed }
return holdNotice({
text: notification.title ?? notification.message,
tone: notification.level === 'warn' ? 'warn' : 'info',
})
},
startTask: (label) => {
const task = { label, startedAt: Date.now() }
Expand Down Expand Up @@ -649,14 +680,19 @@ function describeSession(
]
}

/** A version, linked to its release notes where the terminal supports it. */
/** The gist of a message, for a status line that has one line to say it in. */
function firstSentence(message: string): string {
return normaliseMessage(message).split('. ')[0]!
}

/** A notification as it belongs in scrollback: legible, copyable, unboxed. */
function renderNotification({ title, message, level }: TerminalNotification): string {
const mark = level === 'warn' ? styleText(['yellow', 'bold'], '\u26A0') : styleText('cyan', '\u2139')
const head = title ? `${mark} ${styleText('bold', title)}\n` : ''
return `${head}${message}`
}

/** A version, linked to its release notes where the terminal supports it. */
function linkVersion(version: string): string {
const notes = releaseNotesUrl('nuxt', version)
return notes ? terminalLink(version, notes) : version
Expand Down
15 changes: 13 additions & 2 deletions packages/nuxt-cli/src/dev/tui/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,12 @@ export interface PanelState {
active?: boolean
/** Replaces the badge's standing description, for a restart reason. */
note?: string
/** Passing feedback, shown for a moment and then dropped. */
notice?: { text: string, tone: 'info' | 'warn' | 'success' }
/**
* Feedback in place of the badge's standing description. Passing unless it
* carries a `label`, which marks something waiting on the user: the label
* takes the badge's place until the notice is let go.
*/
notice?: { text: string, tone: 'info' | 'warn' | 'success', label?: string }
/** Long-running work reported through the terminal host, while it runs. */
task?: { label: string, startedAt: number }
confirmQuit?: boolean
Expand Down Expand Up @@ -297,6 +301,13 @@ function renderStatus(state: PanelState, columns: number): string {
)
}

if (state.notice?.label) {
return truncate(
` ${styleText(['bgYellow', 'black', 'bold'], ` ${state.notice.label} `)} ${styleText(MUTED, decapitalise(state.notice.text))}`,
columns,
)
}

const badge = BADGES[state.status]
const description = state.notice ? renderNotice(state) : styleText(MUTED, decapitalise(state.note || badge.note))
const head = ` ${styleText(badge.style, ` ${badge.label} `)} ${description}`
Expand Down
77 changes: 63 additions & 14 deletions packages/nuxt-cli/src/dev/tui/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { startupElapsedMs } from '../../utils/startup-clock'
import { resolveBackground } from '../../utils/terminal-theme'
import { currentRequest, isServingRequest } from '../serving-state'
import { queryBackground } from './background'
import { DevEventLog, normaliseMessage } from './events'
import { DevEventLog, isBoxedNotice, normaliseMessage } from './events'
import { LOGO_FRAME_MS } from './logo'
import { DEFAULT_HINTS, describeListenURLs, renderPanel } from './panel'
import { resolveDevUISupport, supportsUnicode } from './support'
Expand All @@ -33,6 +33,16 @@ function renderErrorLine(event: DevLogEvent): string {
return event.rendered ?? `${styleText(['red', 'bold'], 'ERROR')} ${event.message}`
}

/** A boxed notice as it belongs in scrollback: as printed, or as reported. */
function renderNoticeBlock(event: DevLogEvent): string {
return event.rendered ?? `${event.message}\n`
}

/** A warning as it belongs in scrollback: as printed, or as reported. */
function renderWarningLine(event: DevLogEvent): string {
return event.rendered ?? `${styleText(['yellow', 'bold'], 'WARN')} ${event.message}`
}

/** Cursor movement and erasure: output that repaints rather than appends. */
// eslint-disable-next-line no-control-regex
const REWRITE_RE = /\r(?!\n)|\u001B\[[0-9;]*[A-GJK]/
Expand Down Expand Up @@ -143,8 +153,8 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
let torn = false
let handlers: Array<[NodeJS.Signals | 'exit' | 'uncaughtException', (...args: any[]) => void]> = []
const teardownTasks: Array<() => void> = []
/** Errors whose surface delay has not fired yet, keyed by that timer. */
const pendingErrors = new Map<NodeJS.Timeout, DevLogEvent>()
/** Text whose surface delay has not fired yet, keyed by that timer. */
const pendingSurfaces = new Map<NodeJS.Timeout, () => string>()

// Nothing else repaints while Nuxt is loading, so the session drives the
// shimmer and the elapsed time itself until the controller takes over.
Expand Down Expand Up @@ -277,11 +287,21 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
}

/**
* Write an error into scrollback above the panel.
* Write text into scrollback above the panel, once the event it was rendered
* from has had time to be paired with its printed form.
*
* Delayed by a beat because a log forwarded from a fork arrives before the
* output that renders it, and the rendered form is what should be shown.
*/
function surfaceLater(render: () => string): void {
const timer: NodeJS.Timeout = setTimeout(() => {
pendingSurfaces.delete(timer)
surfaceText(render())
}, ERROR_SURFACE_DELAY_MS)
timer.unref?.()
pendingSurfaces.set(timer, render)
}

function surfaceError(event: DevLogEvent): void {
// Once the server has been ready, errors belong to the panel's badge and the
// log view. Before that, one may be the last thing the process ever says.
Expand All @@ -295,12 +315,35 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
}
event.surfaced = true
lastSurfacedError = text
const timer: NodeJS.Timeout = setTimeout(() => {
pendingErrors.delete(timer)
surfaceText(renderErrorLine(event))
}, ERROR_SURFACE_DELAY_MS)
timer.unref?.()
pendingErrors.set(timer, event)
surfaceLater(() => renderErrorLine(event))
}

/**
* Write a warning the CLI raised during startup into scrollback above the
* panel. The panel holds a badge for it, but a badge has one truncated line
* and these run to a sentence or two.
*/
function surfaceWarning(event: DevLogEvent): void {
if (event.surfaced || state.readyMs !== undefined || !normaliseMessage(event.message)) {
return
}
event.surfaced = true
surfaceLater(() => renderWarningLine(event))
}

/**
* Write a boxed notice into scrollback above the panel, at any point in the
* session: it carries something (a URL, a token) that has to be readable and
* selectable, which a status line cannot offer.
*/
function surfaceNotice(event: DevLogEvent): void {
// Repeats within the dedupe window are merged into the entry already shown;
// a later request is news again, and has to be answered again.
if (event.surfaced || !normaliseMessage(event.message)) {
return
}
event.surfaced = true
surfaceLater(() => renderNoticeBlock(event))
}

const reporter = {
Expand Down Expand Up @@ -332,7 +375,7 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
clearImmediate(flushTimer)
// A fatal startup error tears down and exits before the surface delay can
// fire, and a dev server that dies without a trace is undebuggable.
const unsurfaced = [...pendingErrors.entries()]
const unsurfaced = [...pendingSurfaces.entries()]
for (const [timer] of unsurfaced) {
clearTimeout(timer)
}
Expand All @@ -342,8 +385,8 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
}
surface.externalOutput = 'passthrough'
surface.writeRaw(SHOW_CURSOR)
for (const [, event] of unsurfaced) {
surface.writeRaw(`${renderErrorLine(event)}\n`)
for (const [, render] of unsurfaced) {
surface.writeRaw(`${render()}\n`)
}
surface.close({ keep: teardownOptions.keep })
consola.removeReporter(reporter)
Expand All @@ -368,9 +411,15 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
}

events.onEvent((event) => {
if (event.level <= 0) {
if (isBoxedNotice(event)) {
surfaceNotice(event)
}
else if (event.level <= 0) {
surfaceError(event)
}
else if (event.level === 1 && event.source === 'cli') {
surfaceWarning(event)
}
})

consola.addReporter(reporter)
Expand Down
Loading
Loading