diff --git a/packages/mayfly/src/conversation/projection.ts b/packages/mayfly/src/conversation/projection.ts index 8b589a29..631d9d62 100644 --- a/packages/mayfly/src/conversation/projection.ts +++ b/packages/mayfly/src/conversation/projection.ts @@ -155,6 +155,16 @@ function visibleText(content: readonly ContentBlock[]): string { return parts.filter(Boolean).join('\n') } +/** + * The user-message body without image placeholders: the attachment rows own + * the `[image]` marker, so inlining it here would render it twice. + */ +function bodyText(content: readonly ContentBlock[]): string { + const parts: string[] = [] + for (const block of content) if (block.type === 'text' && block.text !== '') parts.push(block.text) + return parts.join('\n') +} + function reasoningText(content: readonly ContentBlock[]): string { return content.filter(block => block.type === 'reasoning').map(block => block.text).join('\n\n') } @@ -463,8 +473,9 @@ export function foldConversationProjection( case 'user/message': { if (state.retractedTurns.includes(state.currentTurn)) return state if (!isAppendSurfaceEvent(event) || event.data.source.kind !== 'user') return state - const text = visibleText(event.data.content) - if (text.trim() === '') return state + const text = bodyText(event.data.content) + const images = imagesOf(event.data.content) + if (text.trim() === '' && images.length === 0) return state return appendEntry(state, { kind: 'user', id: `user:${String(event.seq)}`, @@ -472,7 +483,7 @@ export function foldConversationProjection( updatedSeq: event.seq, turn: state.currentTurn, text, - images: imagesOf(event.data.content), + images, }) } case 'assistant/attempt': { diff --git a/packages/mayfly/src/core/chrome.ts b/packages/mayfly/src/core/chrome.ts index 418d53b5..bcf8d518 100644 --- a/packages/mayfly/src/core/chrome.ts +++ b/packages/mayfly/src/core/chrome.ts @@ -12,6 +12,7 @@ */ import { sliceByColumn, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui' +import { OVERFLOW_ELLIPSIS } from './width.ts' // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences const ANSI_SGR = /\x1b\[[0-9;]*m/g @@ -147,7 +148,7 @@ export function injectGhostHint( const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2) const available = contentWidth - visibleWidth(text) - (cursorPresent ? 1 : 0) const trimmed = truncateHint(hint, available) - if (trimmed.length === 0) return line + if (trimmed === '') return line const insertAt = cursorPresent ? cursorIdx + CURSOR_BLOCK.length : visibleIndexToRaw(line, EDITOR_LEFT_PADDING + [...text].length) @@ -338,12 +339,12 @@ export function framePanel( const titlePaint = options.titlePaint ?? identity const hint = options.titleHint if (hint === undefined) { - lines.push(truncateToWidth(titlePaint(` ${title}`), ruleWidth)) + lines.push(truncateToWidth(titlePaint(` ${title}`), ruleWidth, OVERFLOW_ELLIPSIS)) } else { const hintPaint = options.hintPaint ?? identity // The kimi title line: ` help · Esc / Enter / q to cancel · ↑↓ scroll` // — callers lead the hint with `· ` so the join is a single space. - lines.push(truncateToWidth(`${titlePaint(` ${title}`)} ${hintPaint(hint)}`, ruleWidth)) + lines.push(truncateToWidth(`${titlePaint(` ${title}`)} ${hintPaint(hint)}`, ruleWidth, OVERFLOW_ELLIPSIS)) } } lines.push(...body) @@ -352,7 +353,7 @@ export function framePanel( // Key rows grow with every new panel action; an over-wide footer would // crash the renderer's width invariant, so it clips ANSI-safe like the // title line (the S23 model-family footer is the first to need it). - lines.push(truncateToWidth(hintRow(footer, options.footerPaint ?? identity), ruleWidth)) + lines.push(truncateToWidth(hintRow(footer, options.footerPaint ?? identity), ruleWidth, OVERFLOW_ELLIPSIS)) } lines.push(rulePaint('─'.repeat(ruleWidth))) // Body rows arrive pre-budgeted by their callers; only a degenerate @@ -361,7 +362,7 @@ export function framePanel( // over-wide, and only then does the framer cut (D48). Wider frames // emit the body untouched. if (width >= FRAME_DEGENERATE_WIDTH) return lines - return lines.map(line => truncateToWidth(line, Math.max(1, width))) + return lines.map(line => truncateToWidth(line, Math.max(1, width), OVERFLOW_ELLIPSIS)) } /** @@ -411,7 +412,7 @@ export function topRule(width: number, options: TopRuleOptions = {}): string { composite === '' ? '' : visibleWidth(composite) > innerWidth - ? truncateToWidth(composite, innerWidth, '') + ? truncateToWidth(composite, innerWidth, OVERFLOW_ELLIPSIS) : composite return paint('╭') + clipped + paint('─'.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))) + paint('╮') } diff --git a/packages/mayfly/src/core/context-hint-locale.ts b/packages/mayfly/src/core/context-hint-locale.ts index 50533d4e..3aff559c 100644 --- a/packages/mayfly/src/core/context-hint-locale.ts +++ b/packages/mayfly/src/core/context-hint-locale.ts @@ -58,6 +58,7 @@ const zh = Object.freeze({ 'The action completed, but newer data must be reviewed': '操作已完成,但需要查看更新的数据', 'The action completed, but its result could not be displayed': '操作已完成,但无法显示结果', 'The action could not be completed': '操作未能完成', + 'This surface could not be displayed': '无法显示此界面', }) const en = Object.freeze(Object.fromEntries(Object.keys(zh).map(key => [key, key]))) diff --git a/packages/mayfly/src/core/screen.ts b/packages/mayfly/src/core/screen.ts index 4542f7ab..75780b2d 100644 --- a/packages/mayfly/src/core/screen.ts +++ b/packages/mayfly/src/core/screen.ts @@ -199,11 +199,16 @@ export class MayflyScreenService extends Service implements MayflyScreen { return this.runtime.hasCapturingOverlay() || (this.editorLayers.overlay !== null && this.fixed.get('editor.prompt')!.editorReplaced) } - /** The editor shares the terminal with the fixed footer and at least one content row. */ + /** + * The editor slot the dock actually grants: the terminal minus the real + * status footer, the one-row transcript reserve, and the header/bottom lane + * rows the width-derived layout currently occupies (never a frame stale). + * Single source for panel bodies and editor overlays. + */ get editorViewport(): { readonly columns: number, readonly rows: number } { const columns = this.runtime.columns const footerRows = this.fixed.get('status.footer')!.render(columns).length - return { columns, rows: Math.max(1, this.runtime.rows - footerRows - 1) } + return { columns, rows: Math.max(1, this.runtime.rows - footerRows - 1 - this.runtime.surfaceLaneRows()) } } /** Registered overlays take precedence over a retained cold conversation and the prompt. */ diff --git a/packages/mayfly/src/core/scrollable-panel.ts b/packages/mayfly/src/core/scrollable-panel.ts index 68dc73cb..28c87555 100644 --- a/packages/mayfly/src/core/scrollable-panel.ts +++ b/packages/mayfly/src/core/scrollable-panel.ts @@ -8,6 +8,7 @@ import { ACTION_CANCEL, ACTION_END, ACTION_HOME, ACTION_MOVE_DOWN, ACTION_MOVE_UP, ACTION_PAGE_DOWN, ACTION_PAGE_UP, matchesKeyAction } from './key-actions.ts' import type { MayflyComponent, MayflyComponents, MayflyFocusable, MayflyKeymap, MayflyScreen, MayflySemanticColors } from './types.ts' import { sanitizePluginText } from './plugin-view.ts' +import { OVERFLOW_ELLIPSIS } from './width.ts' /** Renderer dependencies and product callbacks for one read-only panel. */ export interface ScrollablePanelOptions { @@ -21,6 +22,12 @@ export interface ScrollablePanelOptions { readonly hint?: () => string readonly footer?: () => readonly string[] readonly onClose: () => void + /** + * Rows the host grants this panel, including its own frame and footer. The + * subagent panel passes `screen.editorViewport.rows` so the panel and its + * inner renderer share one budget; isolated fixtures may omit it. + */ + readonly viewportRows?: () => number } interface WindowedComponent extends MayflyComponent { @@ -37,6 +44,11 @@ export class ScrollablePanel implements MayflyFocusable { constructor(private readonly options: ScrollablePanelOptions) {} + /** Body rows granted by the last render; the inner renderer reads this so both agree. */ + get bodyHeight(): number { + return this.bodyRows + } + handleInput(data: string): void { if (this.disposed) return const keymap = this.options.keymap @@ -59,11 +71,17 @@ export class ScrollablePanel implements MayflyFocusable { } render(width: number): string[] { - if (this.disposed || width < 5) return [] + if (this.disposed) return [] const { colors, components } = this.options - const contentWidth = Math.max(1, width - 4) + const safeWidth = Math.max(1, Math.floor(width)) + const contentWidth = Math.max(1, safeWidth - 4) const footer = [...(this.options.footer?.() ?? [])] - this.bodyRows = this.bodyBudget(footer.length) + const grant = this.grantedRows() + if (grant !== undefined && grant <= 0) return [] + // A host that cannot fit the frame gets the body alone rather than rows + // spilling past its grant: the screen clamp is only a diagnostic backstop. + const framed = grant === undefined || grant >= footer.length + 3 + this.bodyRows = framed ? this.bodyBudget(footer.length, grant) : Math.max(1, grant!) const windowed = this.options.body as WindowedComponent const requestedOffset = this.scrollOffset let rendered = windowed.renderWindow?.(contentWidth, requestedOffset, this.bodyRows) @@ -79,17 +97,25 @@ export class ScrollablePanel implements MayflyFocusable { ? (() => { const end = total - this.scrollOffset; return all!.slice(Math.max(0, end - this.bodyRows), end) })() : rendered.rows while (body.length < this.bodyRows) body.push('') + if (!framed) return body.slice(0, this.bodyRows).map(line => components.truncateToWidth(line, safeWidth, OVERFLOW_ELLIPSIS)) const title = sanitizePluginText(this.options.title()).replace(/[\r\n]+/gu, ' ') const hint = sanitizePluginText(this.options.hint?.() ?? '').replace(/[\r\n]+/gu, ' ') - const lines = [components.topRule(width, { + // A long body shows its position so a read-only view cannot look complete. + const shownEnd = this.bodyTotal - this.scrollOffset + const shownStart = Math.max(1, shownEnd - this.bodyRows + 1) + const scrollInfo = this.bodyTotal > this.bodyRows ? `(${String(shownStart)}-${String(shownEnd)}/${String(this.bodyTotal)})` : '' + const topHint = [hint, scrollInfo].filter(part => part !== '').join(' · ') + const lines = [components.topRule(safeWidth, { title: colors.primary(` ${title} `), - ...(hint === '' ? {} : { hint: colors.textMuted(`${hint} `) }), + ...(topHint === '' ? {} : { hint: colors.textMuted(`${topHint} `) }), paint: colors.border, })] lines.push(...body.map(line => this.frame(line, contentWidth))) lines.push(...footer.map(line => this.frame(colors.textMuted(sanitizePluginText(line).replace(/[\r\n]+/gu, ' ')), contentWidth))) - lines.push(colors.border(`╰${'─'.repeat(Math.max(1, width - 2))}╯`)) - return lines + lines.push(colors.border(`╰${'─'.repeat(Math.max(1, safeWidth - 2))}╯`)) + // Below the frame's minimum the rules and furniture cannot fit; degrade + // by clamping each row instead of making the whole panel vanish. + return width >= 5 ? lines : lines.map(line => components.truncateToWidth(line, Math.max(0, Math.floor(width)), OVERFLOW_ELLIPSIS)) } dispose(): void { @@ -98,10 +124,17 @@ export class ScrollablePanel implements MayflyFocusable { ;(this.options.body as MayflyComponent & { dispose?: () => void }).dispose?.() } - private bodyBudget(footerRows: number): number { - const rows = this.options.screen.rows + /** Host-granted rows, or `undefined` when the panel owns the whole screen (isolated fixtures). */ + private grantedRows(): number | undefined { + const rows = this.options.viewportRows?.() + return rows === undefined || !Number.isFinite(rows) ? undefined : Math.max(0, Math.floor(rows)) + } + + private bodyBudget(footerRows: number, grant: number | undefined): number { + const rows = grant ?? this.options.screen.rows if (!Number.isFinite(rows) || rows <= 0) return 12 - return Math.max(1, Math.floor(rows) - footerRows - 4) + // The frame owns one top and one bottom rule beside the caller footer. + return Math.max(1, Math.floor(rows) - footerRows - 2) } private scrollBy(delta: number): void { @@ -126,7 +159,7 @@ export class ScrollablePanel implements MayflyFocusable { private frame(line: string, width: number): string { const { colors, components } = this.options - const clipped = components.truncateToWidth(line, width, '…') + const clipped = components.truncateToWidth(line, width, OVERFLOW_ELLIPSIS) const padding = Math.max(0, width - components.visibleWidth(clipped)) return colors.border('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + colors.border('│') } diff --git a/packages/mayfly/src/core/surface-manager.ts b/packages/mayfly/src/core/surface-manager.ts index 519bef91..b248697c 100644 --- a/packages/mayfly/src/core/surface-manager.ts +++ b/packages/mayfly/src/core/surface-manager.ts @@ -234,16 +234,21 @@ export function allocateBottomRows(demands: readonly BottomRowDemand[], budget: /** * Fit one pane's rows to its allotment, keeping the head: an over-tall pane - * shows its first rows and ends in a row naming how many were cut. + * shows its first rows and ends in a row naming how many were cut. When + * `keepTail` is set the pane's own final row (a fold affordance) survives and + * the overflow row moves in front of it. * @param rows - the pane's rows. * @param size - the allotted row count. * @param overflow - renders the replacement row for `hidden` cut rows. + * @param keepTail - keep the final affordance row when at least 3 rows fit. * @returns at most `size` rows. */ -export function fitSurfaceRows(rows: readonly string[], size: number, overflow: (hidden: number) => string): string[] { +export function fitSurfaceRows(rows: readonly string[], size: number, overflow: (hidden: number) => string, keepTail = false): string[] { if (rows.length <= size) return [...rows] if (size <= 1) return rows.slice(0, Math.max(0, size)) - return [...rows.slice(0, size - 1), overflow(rows.length - size + 1)] + const hidden = rows.length - size + 1 + if (keepTail && size >= 3) return [...rows.slice(0, size - 2), overflow(hidden), rows[rows.length - 1]!] + return [...rows.slice(0, size - 1), overflow(hidden)] } /** Lane hooks a bottom pane component may offer beyond `render`. */ @@ -252,6 +257,8 @@ export interface SurfaceLaneRows { readonly leadingRule?: boolean /** Paint the muted row that replaces `hidden` cut rows. */ renderOverflow?(hidden: number, width: number): string + /** Whether the final row is a fold affordance the lane must keep visible. */ + readonly overflowKeepsTail?: boolean } /** One painted entry of a planned bottom lane. */ @@ -319,7 +326,8 @@ export function planBottomLane(lane: SurfaceLaneLayout, width: number, maxRows: const overflow = (hidden: number): string => fit(paint === undefined ? renderOverflowRow(hidden, interpolateLocaleMessage) : paint.call(item.entry.component, hidden, available), available) - return { entry: item.entry, passive: true, rows: fitSurfaceRows(item.rows, size, overflow), size } + const keepTail = laneRows(item.entry).overflowKeepsTail === true + return { entry: item.entry, passive: true, rows: fitSurfaceRows(item.rows, size, overflow, keepTail), size } }) const rows = (rule === undefined ? 0 : 1) + (tabs === undefined ? 0 : 1) + sizes.reduce((sum, size) => sum + size, 0) return { rule, tabs, slots, rows } @@ -343,7 +351,13 @@ export function renderSurfaceLane(lane: SurfaceLaneLayout | undefined, width: nu const body = renderedSurfaceEntries(lane) .flatMap(entry => entry.component.render(available)) .map(row => fit(row, available)) - return [...tabs, ...body].slice(0, Math.max(0, finiteInteger(maxRows, 0))) + const rows = [...tabs, ...body] + const budget = Math.max(0, finiteInteger(maxRows, 0)) + if (rows.length <= budget) return rows + // A header/body lane that runs out of rows names the loss instead of + // silently dropping the tail (the bottom lane already does). + if (budget <= 1) return rows.slice(0, budget) + return [...rows.slice(0, budget - 1), fit(renderOverflowRow(rows.length - budget + 1, interpolateLocaleMessage), available)] } /** diff --git a/packages/mayfly/src/core/surface-renderer.ts b/packages/mayfly/src/core/surface-renderer.ts index f822ad79..aebc41b4 100644 --- a/packages/mayfly/src/core/surface-renderer.ts +++ b/packages/mayfly/src/core/surface-renderer.ts @@ -5,6 +5,7 @@ import type { Context } from '@deepseek-ai/cordis' import { type MayflyOverlayEntry, type MayflyPaneEntry, + type MayflyUiChild, type MayflyUiEvent, type MayflyUiNode, } from '@ephemeral-ai/mayfly-ui' @@ -132,12 +133,34 @@ function leadsWithRule(node: MayflyUiNode): boolean { return first?.node.kind === 'divider' && first.node.label === undefined && (first.when ?? first.tab) === undefined } +/** + * Whether a pane's content ends with an unconditional muted text row — the + * fold/expand affordance a bottom lane must keep visible when it clamps. + * + * The contract this enforces: a column stack with `gap: 0` whose final child + * is a `text` node with `tone: 'muted'` and no `when`/`tab`, and whose + * second-to-last child is not another such row. A run of two or more muted + * rows is pane content (queued messages), not a single trailing affordance, so + * it declines; so do row stacks, gapped stacks, and conditional/tab-scoped + * tails. Panes that end in a fold footer (see `transcript/pane-todo.ts`) rely + * on this predicate staying true, so their specs pin it against the real node. + */ +export function endsWithAffordance(node: MayflyUiNode): boolean { + if (node.kind !== 'stack' || node.direction !== 'column' || (node.gap ?? 0) !== 0) return false + const unconditionalMutedText = (child: MayflyUiChild | undefined): boolean => + child?.node.kind === 'text' && child.node.tone === 'muted' && (child.when ?? child.tab) === undefined + if (!unconditionalMutedText(node.children.at(-1))) return false + return !unconditionalMutedText(node.children.at(-2)) +} + class PaneComponent implements MayflyFocusable { private targetValue: MayflyCompiledUi | null = null private focusedValue = false private live = true /** The first rendered row is a plain rule the bottom lane may share. */ leadingRule = false + /** The final rendered row is a fold affordance the lane must keep visible. */ + overflowKeepsTail = false constructor(private readonly colors: MayflySemanticColors, private readonly translate: MayflyTranslate) {} @@ -151,12 +174,13 @@ class PaneComponent implements MayflyFocusable { ? { type: 'vstack', entries: [], gap: 0, align: 'stretch' } : getLayoutNode(this.targetValue!.component)! } - replace(compiled: MayflyCompiledUi | null, leadingRule = false): void { + replace(compiled: MayflyCompiledUi | null, leadingRule = false, overflowKeepsTail = false): void { /* v8 ignore next -- record/map identity fences prevent replacement after one disposal. */ if (!this.live) return setCompiledFocus(this.targetValue, false) this.targetValue = compiled this.leadingRule = compiled !== null && leadingRule + this.overflowKeepsTail = compiled !== null && overflowKeepsTail setCompiledFocus(compiled, this.focusedValue) } dispose(): void { @@ -314,7 +338,7 @@ export function mountMayflySurfaceRenderer(ctx: OwnerContext, runtime: MayflyTer return } // A compiled pane always had a node; null nodes returned above. - record.component.replace(compiled, entry.definition.title === undefined && leadsWithRule(node as MayflyUiNode)) + record.component.replace(compiled, entry.definition.title === undefined && leadsWithRule(node as MayflyUiNode), endsWithAffordance(node as MayflyUiNode)) if (record.registration === undefined) { record.registration = runtime.surfaces.register({ id: entry.id, diff --git a/packages/mayfly/src/core/terminal.ts b/packages/mayfly/src/core/terminal.ts index 05be9163..d096b828 100644 --- a/packages/mayfly/src/core/terminal.ts +++ b/packages/mayfly/src/core/terminal.ts @@ -326,6 +326,24 @@ class DockLayoutContainer extends Container { } } +/** + * Header plus bottom lane rows the dock may apply to the shared viewport. + * Lanes are width-derived, so measuring them at read time keeps the editor + * slot current instead of a frame behind the dock's own allocation. The + * fixed-content clamp is deliberately omitted: over-counting lanes can only + * shrink the editor slot, never spill past the grant. + * @param rows - the terminal's row count. + * @param headerRows - the header lane's measured rows. + * @param bottomRows - the bottom lane's measured rows. + * @returns the lane rows the editor slot must subtract. + */ +export function measuredLaneRows(rows: number, headerRows: number, bottomRows: number): number { + const budget = Math.max(0, Math.floor(rows) - 1) + const header = Math.min(budget, Math.max(0, Math.floor(headerRows))) + const bottom = Math.min(budget - header, Math.max(0, Math.floor(bottomRows))) + return header + bottom +} + /** * Normalize terminal wheel reports to the direction-key sequences consumed by * Mayfly's focused components. Main-screen mode leaves mouse reporting disabled @@ -403,6 +421,13 @@ export interface MayflyTerminalRuntime { readonly surfaces: SurfaceManager /** Current best-effort viewport budget for one managed surface. */ surfaceViewport(id: string): { readonly columns: number, readonly rows: number } + /** + * Header plus bottom lane rows the width-derived layout currently + * occupies. The editor slot subtracts this one authoritative value so it + * never lags the dock's own allocation by a frame; content panes read + * `surfaceViewport` instead. + */ + surfaceLaneRows(): number /** Release one pane's focus back to the component active before it. */ releaseSurfaceFocus(id: string): void /** Whether any visible modal overlay currently owns the input plane. */ @@ -899,6 +924,9 @@ export async function startMayflyTerminal( : Math.max(1, terminal.rows - dockRows - lastSurfaceHeaderRows - lastSurfaceBottomRows - tabs) return { columns: Math.max(1, Math.min(terminal.columns, columns)), rows: Math.min(terminal.rows, rows) } }, + surfaceLaneRows() { + return measuredLaneRows(terminal.rows, surfaceHeaderRows(), surfaceBottomRows()) + }, releaseSurfaceFocus(id) { if (surfaces.focusedId !== id) return surfaces.setFocused(undefined) diff --git a/packages/mayfly/src/core/ui-interaction-surface.ts b/packages/mayfly/src/core/ui-interaction-surface.ts index 78886474..2d41194f 100644 --- a/packages/mayfly/src/core/ui-interaction-surface.ts +++ b/packages/mayfly/src/core/ui-interaction-surface.ts @@ -240,7 +240,9 @@ export class UiSurfaceModel { const admitted = this.publication?.source === snapshot.node ? { ok: true as const, value: this.publication.admitted } : snapshot.node === this.rawNode && this.admittedNode !== null ? { ok: true as const, value: this.admittedNode } : validateMayflyUiNode(snapshot.node) if (!admitted.ok) { - this.admissionError = freezeWire({ kind: 'text', tone: 'danger', content: admitted.message }) + // The technical JSON-pointer path stays in the report/log; the surface + // shows a user-facing sentence instead. + this.admissionError = freezeWire({ kind: 'text', tone: 'danger', content: this.t('This surface could not be displayed') }) this.report('snapshot', { severity: 'error', message: admitted.message }) return } diff --git a/packages/mayfly/src/core/ui-patterns.ts b/packages/mayfly/src/core/ui-patterns.ts index 66b775aa..e5fa40f9 100644 --- a/packages/mayfly/src/core/ui-patterns.ts +++ b/packages/mayfly/src/core/ui-patterns.ts @@ -403,6 +403,8 @@ export function renderActions(node: ActionsNode, width: number, focus: PatternFo * @returns the localized overflow row. */ export function renderOverflowRow(hidden: number, translate: MayflyTranslate): string { + // The full English string is the catalog key, so it stays a literal for + // `locale-catalog.spec.ts`'s dead-key scan instead of a template. return translate(' … +{count} more rows', { count: hidden }) } diff --git a/packages/mayfly/src/core/width.ts b/packages/mayfly/src/core/width.ts index 4620186f..bc9b4f58 100644 --- a/packages/mayfly/src/core/width.ts +++ b/packages/mayfly/src/core/width.ts @@ -11,3 +11,11 @@ */ export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui' + +/** + * The marker every signalling truncation uses when text must be elided. It is + * exactly one display column wide, so a caller can reserve it with one column + * and never split a grapheme. Silent clips keep their explicit `''` at the + * call site. + */ +export const OVERFLOW_ELLIPSIS = '…' diff --git a/packages/mayfly/src/interaction/commands-plugin.ts b/packages/mayfly/src/interaction/commands-plugin.ts index 05aa0ea6..4ac0f0df 100644 --- a/packages/mayfly/src/interaction/commands-plugin.ts +++ b/packages/mayfly/src/interaction/commands-plugin.ts @@ -135,7 +135,7 @@ export function apply(ctx: Context): void { ] const view = () => helpNode(sections(), t) let offLocale: (() => void) | undefined - const handle = openUiOverlay(ctx, { id: 'mayfly.help', presentation: 'editor', capturing: true, dismissal: 'discard', title: t('help'), scope: { kind: 'app', targetId: 'help' } }, view(), { reopen: 'focus', onClosed: () => offLocale?.() }) + const handle = openUiOverlay(ctx, { id: 'mayfly.help', presentation: 'editor', capturing: true, dismissal: 'discard', title: t('Help'), scope: { kind: 'app', targetId: 'help' } }, view(), { reopen: 'focus', onClosed: () => offLocale?.() }) if (handle === undefined) return { kind: 'success' } offLocale = observeInteractionLocale(ctx, () => { handle.set(view()) }) return { kind: 'success' } diff --git a/packages/mayfly/src/interaction/help.ts b/packages/mayfly/src/interaction/help.ts index c0fafeb4..616e4c58 100644 --- a/packages/mayfly/src/interaction/help.ts +++ b/packages/mayfly/src/interaction/help.ts @@ -31,7 +31,7 @@ export function helpNode( } } return ui.surface({ - title: t('help'), + title: t('Help'), chrome: 'overlay', padding: 1, child: ui.stack.column([ diff --git a/packages/mayfly/src/interaction/locale.ts b/packages/mayfly/src/interaction/locale.ts index 3a77c3bd..a8d4c3b6 100644 --- a/packages/mayfly/src/interaction/locale.ts +++ b/packages/mayfly/src/interaction/locale.ts @@ -17,6 +17,8 @@ import { } from '../frontend/index.ts' const zh: Readonly> = { + 'The external credential cannot be cleared here': '无法在此清除外部凭据', + 'Help': '帮助', 'Reply to {name}': '回复 {name}', 'Message': '消息', 'Delivery': '发送方式', @@ -242,7 +244,6 @@ const zh: Readonly> = { 'Analyze the codebase and write AGENTS.md': '分析代码库并编写 AGENTS.md', 'Commands': '命令', 'Keys': '按键', - 'help': '帮助', 'Select a theme': '选择主题', 'Toggle plan mode (same as Shift+Tab)': '切换计划模式(同 Shift+Tab)', 'unknown theme "{key}"': '未知主题「{key}」', @@ -604,7 +605,7 @@ const zh: Readonly> = { 'permission command is unavailable': '权限命令不可用', 'permission dispatch failed: {error}': '权限分发失败:{error}', 'permission picker is unavailable: the Mayfly UI registry is not mounted': '权限选择器不可用:Mayfly UI 注册表尚未挂载', - 'up {version}': '可升级到 {version}', + 'update {version}': '可升级到 {version}', '{engine} {range}': '引擎 {engine} {range}', 'reminders': '提醒', 'failed to apply theme "{key}": {error}': '应用主题 "{key}" 失败:{error}', diff --git a/packages/mayfly/src/interaction/plugin-commands.ts b/packages/mayfly/src/interaction/plugin-commands.ts index dbf7cfc2..5045b6b2 100644 --- a/packages/mayfly/src/interaction/plugin-commands.ts +++ b/packages/mayfly/src/interaction/plugin-commands.ts @@ -127,7 +127,7 @@ export function registerPluginCommand(ctx: Context): () => void { const pieces = [entry.source, surfaceBadge(entry)] /* v8 ignore next -- states() carries every indexed entry id */ if (state?.installed === true) { - pieces.push(state.updateAvailable === true ? t('up {version}', { version: state.updateVersion! }) : 'installed') + pieces.push(state.updateAvailable === true ? t('update {version}', { version: state.updateVersion! }) : 'installed') } else if (entry.install.rows.some(row => installed.some(plugin => plugin.name === row.name))) { pieces.push('partial') } diff --git a/packages/mayfly/src/interaction/provider-edit.ts b/packages/mayfly/src/interaction/provider-edit.ts index 6876c3a1..b6638639 100644 --- a/packages/mayfly/src/interaction/provider-edit.ts +++ b/packages/mayfly/src/interaction/provider-edit.ts @@ -258,7 +258,7 @@ export async function openProviderEditor(ctx: Context, route: string, signal?: A } /* v8 ignore next -- a cancellation racing the awaited native read is a lifetime path */ if (context.signal.aborted || cancellation.aborted) return { kind: 'cancelled' } - if (!view.credential.writable) return { kind: 'accepted', ...reply(await read()), feedback: { severity: 'warning', message: t('Provider removed; its external credential remains unchanged') } } + if (!view.credential.writable) return { kind: 'accepted', ...reply(await read()), feedback: { severity: 'warning', message: t(event.actionId === 'delete' ? 'Provider removed; its external credential remains unchanged' : 'The external credential cannot be cleared here') } } await credentials.unset(credentialRef(openedRef!)) /* v8 ignore next -- a cancellation racing the awaited native read is a lifetime path */ if (context.signal.aborted || cancellation.aborted) return { kind: 'cancelled' } diff --git a/packages/mayfly/src/interaction/session-transcript-panel.ts b/packages/mayfly/src/interaction/session-transcript-panel.ts index 332d34c8..006caa60 100644 --- a/packages/mayfly/src/interaction/session-transcript-panel.ts +++ b/packages/mayfly/src/interaction/session-transcript-panel.ts @@ -66,10 +66,14 @@ export class SessionTranscriptPanel implements MayflyFocusable { // the ticking running-turn header as its live signal. const presentation = new TranscriptPresentationPolicy() presentation.apply({ transcriptView: 'verbose' }) + // The shell is built after the body, so the renderer reads its granted body + // height lazily (the shell assigns `bodyRows` before it renders the body); + // both then share the one editor-slot budget. + let shell!: ScrollablePanel const renderer: TranscriptModelRenderer = { colors: ctx.mayflyTheme.colors, components: ctx.mayflyComponents, - viewportRows: () => screen.editorViewport.rows, + viewportRows: () => shell.bodyHeight, images: () => { const attachments = ctx.get('attachments') as { readImage(ref: unknown): Promise<{ data: Uint8Array }> } | undefined return attachments === undefined ? {} : { @@ -86,7 +90,7 @@ export class SessionTranscriptPanel implements MayflyFocusable { live === undefined ? () => this.model : () => this.source.snapshot(), renderer, ) - this.shell = new ScrollablePanel({ + shell = new ScrollablePanel({ screen, components: ctx.mayflyComponents, colors: ctx.mayflyTheme.colors, @@ -97,8 +101,10 @@ export class SessionTranscriptPanel implements MayflyFocusable { footer: () => [ `${interactionKeyHint(ctx.mayflyKeymap, ACTION_TOGGLE_AGENT_VIEW, 'F7')} ${t('toggle')} · ${interactionKeyHint(ctx.mayflyKeymap, ACTION_CLOSE_AGENT_VIEW, 'F8')} ${t('close')} · ${interactionKeyHint(ctx.mayflyKeymap, ACTION_CANCEL, 'Esc')} ${t('close')}`, ], + viewportRows: () => screen.editorViewport.rows, onClose, }) + this.shell = shell this.source = new OfficialConversationModelSource( ctx.sessionProjections, tools, diff --git a/packages/mayfly/src/interaction/settings-model.ts b/packages/mayfly/src/interaction/settings-model.ts index 24daaa2c..f6e8b23d 100644 --- a/packages/mayfly/src/interaction/settings-model.ts +++ b/packages/mayfly/src/interaction/settings-model.ts @@ -41,6 +41,24 @@ function candidates(schema: Schema): readonly (string | number | boolean | null) return values.every(item => item !== undefined) ? values.map(item => item!.value) : undefined } +/** + * Candidate options plus any stored off-list scalar, so a drifted value stays + * readable and selectable instead of surfacing its raw `JSON.stringify` id and + * failing save validation for a selection the user cannot see. + */ +function optionEntries(options: readonly (string | number | boolean | null)[], stored: readonly unknown[]): readonly { readonly id: string, readonly label: string }[] { + const entries: { id: string, label: string }[] = options.map(value => ({ id: JSON.stringify(value), label: String(value) })) + const seen = new Set(entries.map(entry => entry.id)) + for (const value of stored) { + if (!scalar(value)) continue + const id = JSON.stringify(value) + if (seen.has(id)) continue + seen.add(id) + entries.push({ id, label: String(value) }) + } + return entries +} + /** Project only declared scalar fields; unsupported containers never expose raw values or secrets. */ export function settingsProjection(descriptor: SettingsDescriptor, writable: boolean, choices: SettingsChoices, t: MayflyTranslate): SettingsProjection { const root = new Schema(descriptor.schema as Partial) @@ -73,10 +91,10 @@ export function settingsProjection(descriptor: SettingsDescriptor, writable: boo if (schema.type === 'string') field = { kind: 'secret', id, label, disabled, value: '', resetValue: '', placeholder: t(descriptor.secrets?.some(secret => secret.set && JSON.stringify(secret.path) === id) ? 'Configured; leave unchanged' : 'Not configured') } } else if (options !== undefined) { encoded = true - field = { ...base, disabled: disabled || dynamicKind !== undefined && choices[dynamicKind] === undefined, kind: 'select', value: value === undefined ? null : JSON.stringify(value), options: options.map(value => ({ id: JSON.stringify(value), label: String(value) })), ...resettable ? { resetValue: fallback === undefined ? null : JSON.stringify(fallback) } : {} } + field = { ...base, disabled: disabled || dynamicKind !== undefined && choices[dynamicKind] === undefined, kind: 'select', value: value === undefined || !scalar(value) ? null : JSON.stringify(value), options: optionEntries(options, value === undefined ? [] : [value]), ...resettable ? { resetValue: fallback === undefined ? null : JSON.stringify(fallback) } : {} } } else if (schema.type === 'array' && schema.inner !== undefined && candidates(schema.inner) !== undefined) { encoded = true - field = { ...base, kind: 'multiselect', value: Array.isArray(value) ? value.map(value => JSON.stringify(value)) : [], options: candidates(schema.inner)!.map(value => ({ id: JSON.stringify(value), label: String(value) })), ...resettable ? { resetValue: (fallback as unknown[]).map(value => JSON.stringify(value)) } : {}, ...schema.meta.min === undefined ? {} : { minSelected: schema.meta.min }, ...schema.meta.max === undefined ? {} : { maxSelected: schema.meta.max } } + field = { ...base, kind: 'multiselect', value: Array.isArray(value) ? value.filter(scalar).map(value => JSON.stringify(value)) : [], options: optionEntries(candidates(schema.inner)!, Array.isArray(value) ? value : []), ...resettable ? { resetValue: (fallback as unknown[]).filter(scalar).map(value => JSON.stringify(value)) } : {}, ...schema.meta.min === undefined ? {} : { minSelected: schema.meta.min }, ...schema.meta.max === undefined ? {} : { maxSelected: schema.meta.max } } } else if (schema.type === 'string') { field = { ...base, kind: schema.meta.role === 'textarea' ? 'textarea' : 'input', value: typeof value === 'string' ? value : '', ...resettable ? { resetValue: typeof fallback === 'string' ? fallback : '' } : {}, ...schema.meta.min === undefined ? {} : { minLength: schema.meta.min }, ...schema.meta.max === undefined ? {} : { maxLength: schema.meta.max } } } else if (schema.type === 'number') { @@ -84,7 +102,7 @@ export function settingsProjection(descriptor: SettingsDescriptor, writable: boo } else if (schema.type === 'boolean') { if (schema.meta.required !== true && fallback === undefined) { encoded = true - field = { ...base, kind: 'select', value: value === undefined ? null : JSON.stringify(value), options: [{ id: 'true', label: t('On') }, { id: 'false', label: t('Off') }], resetValue: null } + field = { ...base, kind: 'select', value: typeof value === 'boolean' ? JSON.stringify(value) : null, options: [{ id: 'true', label: t('On') }, { id: 'false', label: t('Off') }], resetValue: null } } else field = { ...base, kind: 'toggle', value: value === true, ...resettable ? { resetValue: fallback === true } : {} } } if (field === undefined) { diff --git a/packages/mayfly/src/transcript/locale.ts b/packages/mayfly/src/transcript/locale.ts index 2d918a45..299764ef 100644 --- a/packages/mayfly/src/transcript/locale.ts +++ b/packages/mayfly/src/transcript/locale.ts @@ -152,7 +152,7 @@ export const ACTIVITY_LOCALE = identityCatalog({ 'Deep diving': '深度求索中', 'Thinking': '思考中', 'Writing': '输出中', - ' interrupting...': ' 正在中断...', + ' interrupting…': ' 正在中断…', ...PROCESS_ACTIVE_ZH, }) diff --git a/packages/mayfly/src/transcript/pane-activity.ts b/packages/mayfly/src/transcript/pane-activity.ts index 995208ae..c3bb665c 100644 --- a/packages/mayfly/src/transcript/pane-activity.ts +++ b/packages/mayfly/src/transcript/pane-activity.ts @@ -82,7 +82,7 @@ const WRITING_LABEL = 'Writing' const DETAIL_BUDGETS = [12, 24, 40, 64, 96] /** The stopping row's label; the `■` marker rides in error red beside it. */ -const STOPPING_LABEL = ' interrupting...' +const STOPPING_LABEL = ' interrupting…' /** * Minimum time the stopping acknowledgment stays up. A plain-text abort diff --git a/packages/mayfly/src/transcript/pane-todo.ts b/packages/mayfly/src/transcript/pane-todo.ts index 50132805..fd256c03 100644 --- a/packages/mayfly/src/transcript/pane-todo.ts +++ b/packages/mayfly/src/transcript/pane-todo.ts @@ -172,7 +172,7 @@ function todoSpans(todo: TodoItem): readonly MayflyInlineSpan[] { } /** The pane's render state, mutated by the subscriptions in `apply`. */ -interface TodoState { +export interface TodoState { /** The latest whole-list snapshot; empty until the first `todo/write`. */ todos: readonly TodoItem[] /** The current official goal projection, when one is active. */ @@ -234,8 +234,13 @@ function titleSpans(goal: GoalProjection | null, interrupted: boolean, t: Mayfly ] } -/** Build the canonical todo tree; the core compiler owns paint and width. */ -function todoNode(state: TodoState, t: MayflyTranslate, keyHint: string): MayflyUiNode { +/** + * Build the canonical todo tree; the core compiler owns paint and width. The + * final child is the muted fold/expand footer whenever the list is folded or + * expanded past {@link MAX_VISIBLE}; `core/surface-renderer.ts`'s + * `endsWithAffordance` contract keeps that footer visible when a lane clamps. + */ +export function todoNode(state: TodoState, t: MayflyTranslate, keyHint: string): MayflyUiNode { const children: { readonly node: MayflyUiNode }[] = [ { node: { kind: 'divider' } }, { node: { kind: 'rich-text', spans: titleSpans(state.goal, state.interrupted, t), overflow: 'truncate' } }, diff --git a/packages/mayfly/tests/conversation/projection.spec.ts b/packages/mayfly/tests/conversation/projection.spec.ts index 37fdbac7..f5d1f7bf 100644 --- a/packages/mayfly/tests/conversation/projection.spec.ts +++ b/packages/mayfly/tests/conversation/projection.spec.ts @@ -190,7 +190,8 @@ describe('mayflyConversation projection', () => { expect(state.entries).toHaveLength(2) expect(state.entries[0]).toEqual(expect.objectContaining({ kind: 'user', - text: '[image]\n[image]', + // The attachment rows own the `[image]` marker; the body stays empty. + text: '', images: [{ attachmentId: 'image-1', mediaType: 'image/png', diff --git a/packages/mayfly/tests/core/chrome.spec.ts b/packages/mayfly/tests/core/chrome.spec.ts index b0684cea..d5af0c76 100644 --- a/packages/mayfly/tests/core/chrome.spec.ts +++ b/packages/mayfly/tests/core/chrome.spec.ts @@ -206,7 +206,7 @@ describe('framePanel', () => { footer: ['esc cancel', 'enter resume', 'f6 next surface'], footerPaint: text => text, }) - expect(lines[2]).toBe(' esc can\u001b[0m...\u001b[0m') + expect(lines[2]).toBe(' esc cance\u001b[0m…\u001b[0m') expect(lines).toHaveLength(4) }) @@ -216,7 +216,7 @@ describe('framePanel', () => { footerPaint: text => `_${text}_`, }) // The joined row (22 wide) clips to the 20-column rule, paint and all. - expect(framed).toEqual(['─'.repeat(20), '', '_ esc cancel · ↵\u001b[0m...\u001b[0m', '─'.repeat(20)]) + expect(framed).toEqual(['─'.repeat(20), '', '_ esc cancel · ↵ r\u001b[0m…\u001b[0m', '─'.repeat(20)]) }) it('skips the footer when the parts list is empty', () => { @@ -225,7 +225,7 @@ describe('framePanel', () => { it('defaults the footer and rule paints to identity', () => { // No paints injected: the footer row and rules render unstyled. - expect(framePanel([''], 4, { footer: ['a', 'b'] })).toEqual(['────', '', ' \u001b[0m...\u001b[0m', '────']) + expect(framePanel([''], 4, { footer: ['a', 'b'] })).toEqual(['────', '', ' a\u001b[0m…\u001b[0m', '────']) }) it('repaints the rules through the injected paint, clamped to the width', () => { @@ -233,7 +233,7 @@ describe('framePanel', () => { // literals add columns reads past the frame — the D48 backstop cuts it // (the paint still lands: the marker survives the cut). const framed = framePanel([], 6, { rulePaint: text => `%${text}%` }) - expect(framed).toEqual(['%──\x1b[0m...\x1b[0m', '%──\x1b[0m...\x1b[0m']) + expect(framed).toEqual(['%────\u001b[0m…\u001b[0m', '%────\u001b[0m…\u001b[0m']) }) it('truncates an over-long title with styled hint to the width', () => { @@ -300,11 +300,11 @@ describe('topRule', () => { expect(row).not.toContain('\x1b[22mcd') }) - it('truncates an overflowing composite with no dash fill left', () => { - // ' BTW ─ Esc close · ↑↓ scroll ' is 29 wide at inner width 28: the - // trailing space is dropped and the reset appended. + it('truncates an overflowing composite with one ellipsis and no dash fill left', () => { + // ' BTW ─ Esc close · ↑↓ scroll ' is 29 wide at inner width 28: the tail + // makes room for the single `…` and the reset is appended. expect(topRule(30, { title: ' BTW ', hint: 'Esc close · ↑↓ scroll ' })).toBe( - '╭ BTW ─ Esc close · ↑↓ scroll\x1b[0m╮', + '╭ BTW ─ Esc close · ↑↓ scrol\u001b[0m…\u001b[0m╮', ) }) @@ -420,7 +420,8 @@ describe('injectGhostHint', () => { it('falls back to the padding position when no cursor block renders', () => { // An unfocused editor renders no cursor; the ghost lands at the - // content-end column instead. + // content-end column instead. The bracket paint adds 2 columns and the + // display-width clamp trims them from the trailing padding. const row = ` /btw${' '.repeat(12)}` const ghosted = injectGhostHint(row, ' ', '/btw', 20, text => `[${text}]`) expect(ghosted).toBe(` /btw[ ]${' '.repeat(8)}`) diff --git a/packages/mayfly/tests/core/plugin-surface-bridge-pane.spec.ts b/packages/mayfly/tests/core/plugin-surface-bridge-pane.spec.ts index f67950d3..e63e2021 100644 --- a/packages/mayfly/tests/core/plugin-surface-bridge-pane.spec.ts +++ b/packages/mayfly/tests/core/plugin-surface-bridge-pane.spec.ts @@ -323,6 +323,34 @@ describe('direct pane surface renderer', () => { } }) + it('classifies pane tails when deciding the fold-affordance clamp', async () => { + const f = await fixture() + try { + f.register({ id: 'row-stack', render: () => ui.stack.row([ui.text('a')]) }) + f.register({ id: 'gapped-stack', render: () => ui.stack.column([ui.text('a')], { gap: 1 }) }) + f.register({ id: 'empty-stack', render: () => ui.stack.column([]) }) + f.register({ id: 'plain-tail', render: () => ui.stack.column([ui.text('a')]) }) + f.register({ id: 'muted-tail', render: () => ui.stack.column([ui.text('a', { tone: 'muted' })]) }) + f.register({ id: 'conditional-tail', render: () => ui.stack.column([ui.child(ui.text('a', { tone: 'muted' }), { when: { minWidth: 1 } })]) }) + f.register({ id: 'tabbed-tail', render: () => ui.stack.column([ui.child(ui.text('a', { tone: 'muted' }), { tab: { controlId: 'tabs', itemId: 'one' } })]) }) + await flush() + const keepsTail = (id: string): boolean => + (entry(f.runtime.surfaces, id).component as MayflyComponent & { overflowKeepsTail: boolean }).overflowKeepsTail + expect(entries(f.runtime.surfaces).map(item => item.id)).toEqual(expect.arrayContaining([ + 'row-stack', 'gapped-stack', 'empty-stack', 'plain-tail', 'muted-tail', 'conditional-tail', 'tabbed-tail', + ])) + // A single trailing unconditional muted text row is the fold affordance. + expect(keepsTail('muted-tail')).toBe(true) + // Row stacks, gapped stacks, empty stacks, plain tails, and conditional + // or tab-scoped tails are pane content, not an unconditional affordance. + for (const id of ['row-stack', 'gapped-stack', 'empty-stack', 'plain-tail', 'conditional-tail', 'tabbed-tail']) { + expect(keepsTail(id)).toBe(false) + } + } finally { + await f.dispose() + } + }) + it('contains null, hostile, and over-wide snapshot output', async () => { const f = await fixture() try { @@ -331,7 +359,7 @@ describe('direct pane surface renderer', () => { f.register({ id: 'invalid', render: () => ({ kind: 'unknown' }) as never }) await flush() const invalid = entry(f.runtime.surfaces, 'invalid').component.render(12) - expect(invalid.join(' ')).toContain('Mayfly UI') + expect(invalid.join(' ')).toContain('This surface') expect(invalid.every(row => visibleWidth(row) <= 12)).toBe(true) const nullableComponent = entry(f.runtime.surfaces, 'nullable').component expect(getLayoutNode(nullableComponent).entries.length).toBeGreaterThan(0) diff --git a/packages/mayfly/tests/core/screen.spec.ts b/packages/mayfly/tests/core/screen.spec.ts index 70c3b5be..a0950b80 100644 --- a/packages/mayfly/tests/core/screen.spec.ts +++ b/packages/mayfly/tests/core/screen.spec.ts @@ -23,7 +23,7 @@ interface Recorded { contentChanges: number } -function recordingRuntime(): MayflyTerminalRuntime & Recorded { +function recordingRuntime(laneRows = 0): MayflyTerminalRuntime & Recorded { const handle: MayflyOverlayHandle = { hide: () => {}, setHidden: () => {}, @@ -38,6 +38,7 @@ function recordingRuntime(): MayflyTerminalRuntime & Recorded { get contentChanges() { return recorded.contentChanges }, columns: 120, rows: 24, + surfaceLaneRows: () => laneRows, hasCapturingOverlay: () => false, addChild(component) { recorded.added.push(component) @@ -121,6 +122,16 @@ describe('MayflyScreenService', () => { await ctx.fiber.dispose() }) + it('subtracts the applied lane rows from the editor slot budget', async () => { + const runtime = recordingRuntime(3) + const ctx = new Context() + await ctx.plugin(MayflyScreenService, runtime) + const screen = ctx.mayflyScreen + screen.mountDockSlot('status.footer', { render: () => ['footer'], invalidate() {} }, 'bottom') + expect(screen.editorViewport).toEqual({ columns: 120, rows: 19 }) + await ctx.fiber.dispose() + }) + it('registers as ctx.mayflyScreen and unregisters when the fiber disposes', async () => { const ctx = new Context() const fiber = ctx.plugin(MayflyScreenService, recordingRuntime()) diff --git a/packages/mayfly/tests/core/scrollable-panel.spec.ts b/packages/mayfly/tests/core/scrollable-panel.spec.ts index 8eed9886..0c4d5a0f 100644 --- a/packages/mayfly/tests/core/scrollable-panel.spec.ts +++ b/packages/mayfly/tests/core/scrollable-panel.spec.ts @@ -27,11 +27,13 @@ describe('ScrollablePanel', () => { title: () => 'Transcript', hint: () => 'read-only', footer: () => ['keys'], + viewportRows: () => 8, onClose: close, }) panel.focused = true expect(panel.focused).toBe(true) expect(plain(panel.render(20)).join('\n')).toContain('row 9') + expect(panel.bodyHeight).toBe(5) panel.handleInput('\x1b[A') expect(plain(panel.render(20)).join('\n')).toContain('row 4') @@ -58,7 +60,12 @@ describe('ScrollablePanel', () => { panel.invalidate() expect(body.invalidate).toHaveBeenCalledOnce() - expect(panel.render(4)).toEqual([]) + // Degenerate widths clamp instead of vanishing, and a long body shows its + // scroll position in the top rule. + const tiny = panel.render(4) + expect(tiny.length).toBeGreaterThan(0) + expect(tiny.every(row => new FakeMayflyComponents().visibleWidth(row) <= 4)).toBe(true) + expect(plain(panel.render(80)).join('\n')).toContain('(7-11/11)') }) it('uses fallback row budgets, sanitizes chrome, clips body rows, and disposes once', () => { @@ -103,6 +110,7 @@ describe('ScrollablePanel', () => { colors: new FakeTheme().colors, body: { render: () => ['fallback'], renderWindow, invalidate: vi.fn() }, title: () => 'Windowed', + viewportRows: () => 6, onClose: vi.fn(), }) expect(plain(panel.render(20)).join('\n')).toContain('windowed') @@ -126,6 +134,7 @@ describe('ScrollablePanel', () => { invalidate: () => {}, }, title: () => 'Windowed', + viewportRows: () => 6, onClose: () => {}, }) const visible = () => plain(panel.render(20)).slice(1, 5).map(row => row.slice(2, -2).trim()) @@ -139,4 +148,39 @@ describe('ScrollablePanel', () => { rows.push('row 3', 'row 4') expect(visible()).toEqual(['row 1', 'row 2', 'row 3', 'row 4']) }) + + it('degrades height below the frame minimum and marks degenerate-width clips', () => { + const screen = new FakeScreen() + screen.rows = 20 + const components = new FakeMayflyComponents() + const colors = new FakeTheme().colors + const options = (grant: number, viewportRows: () => number = () => grant): ConstructorParameters[0] => ({ + screen, + components, + colors, + body: { render: () => ['alpha', 'beta', 'gamma'], invalidate: vi.fn() }, + title: () => 'Compact', + footer: () => ['keys'], + viewportRows, + onClose: vi.fn(), + }) + // footer(1) + 3 is the smallest framed height: 2 rows cannot fit the frame, + // so the panel returns body rows alone instead of spilling past its grant. + const compact = new ScrollablePanel(options(2)) + const compactRows = compact.render(20) + expect(compactRows).toHaveLength(2) + expect(compact.bodyHeight).toBe(2) + expect(plain(compactRows).join(' ')).not.toContain('Compact') + expect(plain(compactRows).join(' ')).not.toContain('keys') + // A zero grant has no room at all. + expect(new ScrollablePanel(options(0)).render(20)).toEqual([]) + // A non-finite grant is isolated-fixture behavior: fall back to the screen. + expect(new ScrollablePanel(options(0, () => Number.NaN)).render(20).length).toBeGreaterThan(0) + // Degenerate widths clamp and mark the clip with the shared one-column marker. + const narrowRows = new ScrollablePanel(options(6)).render(3) + expect(narrowRows.length).toBeGreaterThan(0) + expect(narrowRows.join('')).toContain('…') + expect(narrowRows.join('')).not.toContain('...') + expect(narrowRows.every(row => components.visibleWidth(row) <= 3)).toBe(true) + }) }) diff --git a/packages/mayfly/tests/core/surface-manager.spec.ts b/packages/mayfly/tests/core/surface-manager.spec.ts index f6a3977a..59b47474 100644 --- a/packages/mayfly/tests/core/surface-manager.spec.ts +++ b/packages/mayfly/tests/core/surface-manager.spec.ts @@ -44,6 +44,28 @@ describe('SurfaceManager', () => { expect(renderSurfaceLane(manager.linearLayout(10, 2).header, 10)).toEqual(['one']) }) + it('signals header-lane overflow instead of silently dropping the tail', () => { + const manager = new SurfaceManager() + manager.register(contribution('head', 'header', {}, component('a', 'b', 'c', 'd'))) + expect(renderSurfaceLane(manager.linearLayout(120, 24).header, 80, 3)).toEqual(['a', 'b', ' … +2 more rows']) + }) + + it('keeps a marked affordance tail while clamping pane rows', () => { + const overflow = (hidden: number): string => `+${String(hidden)}` + expect(fitSurfaceRows(['a', 'b', 'c', 'd'], 3, overflow, true)).toEqual(['a', '+2', 'd']) + expect(fitSurfaceRows(['a', 'b', 'c', 'd'], 2, overflow, true)).toEqual(['a', '+3']) + expect(fitSurfaceRows(['a', 'b'], 4, overflow, true)).toEqual(['a', 'b']) + expect(fitSurfaceRows(['a', 'b', 'c'], 0, overflow, true)).toEqual([]) + }) + + it('keeps the tail when a bottom pane marks the fold affordance', () => { + const manager = new SurfaceManager() + const pane = component('a', 'b', 'c', 'd') as MayflyComponent & { overflowKeepsTail: boolean } + pane.overflowKeepsTail = true + manager.register(contribution('fold', 'bottom', {}, pane)) + expect(renderSurfaceLane(manager.linearLayout(120, 24).bottom, 80, 3)).toEqual(['a', ' … +2 more rows', 'd']) + }) + it('orders deterministically and applies immutable user layout overrides', () => { const changes = vi.fn() const saves = vi.fn() diff --git a/packages/mayfly/tests/core/terminal.spec.ts b/packages/mayfly/tests/core/terminal.spec.ts index 81492b7e..c7ee4e1a 100644 --- a/packages/mayfly/tests/core/terminal.spec.ts +++ b/packages/mayfly/tests/core/terminal.spec.ts @@ -16,6 +16,7 @@ import type { MayflyComponent, MayflyComponents, MayflyFocusable, MayflyRgbColor import { createStableTuiReference, createTerminalRelease, + measuredLaneRows, normalizeNavigationInput, normalizeWheelInput, startMayflyTerminal, @@ -2064,3 +2065,17 @@ describe('createStableTuiReference', () => { expect(Object.getPrototypeOf(stable)).toBe(Object.getPrototypeOf(current)) }) }) + +describe('measuredLaneRows', () => { + it('fits the header then the bottom lane into the terminal budget', () => { + expect(measuredLaneRows(24, 2, 3)).toBe(5) + expect(measuredLaneRows(10, 4, 4)).toBe(8) + // The header wins the budget; the bottom lane takes only what remains. + expect(measuredLaneRows(24, 2, 100)).toBe(23) + expect(measuredLaneRows(24, 30, 3)).toBe(23) + // Flooring and a zero/negative budget stay safe. + expect(measuredLaneRows(10.9, 1.2, 2.7)).toBe(3) + expect(measuredLaneRows(0, 2, 3)).toBe(0) + expect(measuredLaneRows(24, -1, -1)).toBe(0) + }) +}) diff --git a/packages/mayfly/tests/interaction/commands-plugin.spec.ts b/packages/mayfly/tests/interaction/commands-plugin.spec.ts index 9f025f10..c80c7a39 100644 --- a/packages/mayfly/tests/interaction/commands-plugin.spec.ts +++ b/packages/mayfly/tests/interaction/commands-plugin.spec.ts @@ -391,7 +391,7 @@ describe('mayfly-commands plugin', () => { // overflow the window, so a `showing` line replaces the tail. const panel = overlay(ctx, 'mayfly.help') const rows = panel.render(80) - expect(rows.join('\n')).toContain('help') + expect(rows.join('\n')).toContain('Help') expect(rows.join('\n')).toContain('Commands') expect(rows.join('\n')).toContain('/plugin') expect(rows.join('\n')).toContain('/update') diff --git a/packages/mayfly/tests/interaction/fakes.ts b/packages/mayfly/tests/interaction/fakes.ts index d25e8929..48b87222 100644 --- a/packages/mayfly/tests/interaction/fakes.ts +++ b/packages/mayfly/tests/interaction/fakes.ts @@ -737,6 +737,9 @@ export interface FakeOverlay { export class FakeScreen implements MayflyScreen { columns = 80 rows = 24 + get editorViewport(): { readonly columns: number, readonly rows: number } { + return { columns: this.columns, rows: this.rows } + } readonly children: MayflyComponent[] = [] readonly overlays: FakeOverlay[] = [] focused: MayflyComponent | null = null diff --git a/packages/mayfly/tests/interaction/help.spec.ts b/packages/mayfly/tests/interaction/help.spec.ts index a63f0a0a..128aaf3f 100644 --- a/packages/mayfly/tests/interaction/help.spec.ts +++ b/packages/mayfly/tests/interaction/help.spec.ts @@ -13,7 +13,7 @@ describe('helpNode', () => { it('builds one frozen scroll document with semantic labels and a close action', () => { const node = helpNode(sections) expect(node).toMatchObject({ - kind: 'surface', title: 'help', chrome: 'overlay', + kind: 'surface', title: 'Help', chrome: 'overlay', child: { kind: 'stack', children: [ { node: { kind: 'scroll', id: 'help-document', scrollbar: true, child: { kind: 'rich-text' } } }, { node: { kind: 'actions', id: 'help-actions', items: [{ id: 'close', dismiss: true }] } }, @@ -25,7 +25,7 @@ describe('helpNode', () => { }) it('translates headings, descriptions, title, and close label while preserving live key labels', () => { - const node = helpNode(sections, (message) => ({ help: '帮助', Commands: '命令', Keys: '按键', 'Show help': '显示帮助', 'Submit input': '提交输入', Close: '关闭' })[message] ?? message) + const node = helpNode(sections, (message) => ({ Help: '帮助', Commands: '命令', Keys: '按键', 'Show help': '显示帮助', 'Submit input': '提交输入', Close: '关闭' })[message] ?? message) const text = JSON.stringify(node) for (const expected of ['帮助', '命令', '按键', '显示帮助', '提交输入', '关闭', 'enter']) expect(text).toContain(expected) }) diff --git a/packages/mayfly/tests/interaction/plugin-commands.spec.ts b/packages/mayfly/tests/interaction/plugin-commands.spec.ts index d1364788..2c1a6d74 100644 --- a/packages/mayfly/tests/interaction/plugin-commands.spec.ts +++ b/packages/mayfly/tests/interaction/plugin-commands.spec.ts @@ -903,7 +903,7 @@ describe('/plugin argument paths', () => { await world.run('/plugin list') const json = JSON.stringify((world.overlay() as { currentNode(): unknown }).currentNode()) expect(json).toContain('yanked') - expect(json).toContain('up 0.1.5') + expect(json).toContain('update 0.1.5') expect(json).toContain('Fresh') world.dispose() }) @@ -1081,7 +1081,7 @@ describe('/plugin surface actions', () => { }) await world.run('/plugin list') const model = world.surface()! - expect(JSON.stringify(model.node)).toContain('up 0.1.4') + expect(JSON.stringify(model.node)).toContain('update 0.1.4') expect(model.availableActions()).toEqual(expect.arrayContaining([ expect.objectContaining({ actionId: 'install', pagePath: marketPage(model), enabled: true }), ])) @@ -1089,7 +1089,7 @@ describe('/plugin surface actions', () => { await vi.waitFor(() => expect(marketFeedback(model)).toContain('updated; restart Mayfly and start a new session to apply')) expect(world.spawns).toContainEqual({ cmd: '/usr/bin/dsh', args: ['plugin', '--profile', 'mayfly', 'add', 'dsh-loop'] }) // The refreshed list no longer advertises an update. - expect(JSON.stringify(model.node)).not.toContain('up 0.1.4') + expect(JSON.stringify(model.node)).not.toContain('update 0.1.4') world.dispose() }) diff --git a/packages/mayfly/tests/interaction/provider-edit.spec.ts b/packages/mayfly/tests/interaction/provider-edit.spec.ts index 70b7efff..093bbf4e 100644 --- a/packages/mayfly/tests/interaction/provider-edit.spec.ts +++ b/packages/mayfly/tests/interaction/provider-edit.spec.ts @@ -339,6 +339,23 @@ describe('provider editor', () => { expect(model.form(address('credentials'))).toBeUndefined() }) + it('reports a clear-key request on a non-writable credential without claiming removal', async () => { + const ctx = new Context() + contexts.push(ctx) + const bench = await providerFixture(ctx, { custom: profile }) + vi.spyOn(bench.credentials, 'describe').mockResolvedValue({ configured: true, writable: false, source: 'environment' }) + expect(await openProviderEditor(ctx, 'custom')).toBe(true) + const entry = ctx.mayflyOverlays.list()[0]! + const reply = await entry.definition.onEvent!.action!( + { kind: 'activate', actionId: 'clear-key' } as never, + requestContext(entry), + ) + expect(reply).toEqual(expect.objectContaining({ + kind: 'accepted', + feedback: { severity: 'warning', message: 'The external credential cannot be cleared here' }, + })) + }) + it('returns false for incomplete services and opens without a caller signal', async () => { const empty = new Context() contexts.push(empty) diff --git a/packages/mayfly/tests/interaction/settings-model.spec.ts b/packages/mayfly/tests/interaction/settings-model.spec.ts index c4969acc..ae621cad 100644 --- a/packages/mayfly/tests/interaction/settings-model.spec.ts +++ b/packages/mayfly/tests/interaction/settings-model.spec.ts @@ -27,6 +27,30 @@ describe('native settings projections', () => { expect(JSON.stringify(projection.node)).not.toMatch(/opaque-value|hidden/) }) + it('appends off-list stored values as readable options instead of raw encoded ids', () => { + const schema = Schema.object({ choice: Schema.union(['a', 'b']), many: Schema.array(Schema.union(['a', 'b'])) }) + const projection = project(schema, { choice: 'custom', many: ['a', 'deleted'] }) + const choice = projection.bindings.get(settingsField('choice').fieldId)!.field + expect(choice).toMatchObject({ kind: 'select', value: '"custom"' }) + expect('options' in choice ? choice.options : []).toEqual([ + { id: '"a"', label: 'a' }, { id: '"b"', label: 'b' }, { id: '"custom"', label: 'custom' }, + ]) + const many = projection.bindings.get(settingsField('many').fieldId)!.field + expect('options' in many ? many.options : []).toEqual([ + { id: '"a"', label: 'a' }, { id: '"b"', label: 'b' }, { id: '"deleted"', label: 'deleted' }, + ]) + // A hand-edited non-scalar stored value is ignored rather than rendered, + // and the projected value no longer masquerades as a raw encoded id. + const malformed = project(schema, { choice: { nested: true }, many: [{ nested: true }] }) + const malformedChoice = malformed.bindings.get(settingsField('choice').fieldId)!.field + expect('options' in malformedChoice ? malformedChoice.options : []).toEqual([{ id: '"a"', label: 'a' }, { id: '"b"', label: 'b' }]) + expect(malformedChoice).toMatchObject({ kind: 'select', value: null }) + expect(malformed.bindings.get(settingsField('many').fieldId)!.field).toMatchObject({ kind: 'multiselect', value: [] }) + // A non-boolean stored value does not masquerade as an On/Off option id. + const booleanSchema = Schema.object({ flag: Schema.boolean() }) + expect(project(booleanSchema, { flag: 'yes' }).bindings.get(settingsField('flag').fieldId)!.field).toMatchObject({ kind: 'select', value: null }) + }) + it('never projects secrets hidden inside unsupported schema containers or secret defaults', () => { const schema = Schema.object({ secret: Schema.string().role('secret').default('sensitive-default'), diff --git a/packages/mayfly/tests/transcript/pane-activity.spec.ts b/packages/mayfly/tests/transcript/pane-activity.spec.ts index a1601d0f..9d0fa3a6 100644 --- a/packages/mayfly/tests/transcript/pane-activity.spec.ts +++ b/packages/mayfly/tests/transcript/pane-activity.spec.ts @@ -211,13 +211,13 @@ describe('mayfly-pane-activity', () => { vi.setSystemTime(10_000) pending = true ctx.emit('mayfly/request-stop-changed', true) - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) // The static row never arms the spinner: the animation timer stops and a // late tick cannot make the accepted interrupt look ignored again. expect(timers.cleared).toBe(1) expect(timers.intervals).toEqual([120]) timers.ticks[0]!() - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) for (const width of [18, 12, 4]) { expect(screen.paneLines(width).every(line => visibleWidth(line) <= width)).toBe(true) } @@ -227,7 +227,7 @@ describe('mayfly-pane-activity', () => { vi.setSystemTime(10_050) pending = false ctx.emit('mayfly/request-stop-changed', false) - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) expect(timers.timeouts).toHaveLength(1) expect(timers.timeouts[0]!.ms).toBe(550) @@ -240,7 +240,7 @@ describe('mayfly-pane-activity', () => { pending = true ctx.emit('mayfly/request-stop-changed', true) expect(timers.cleared).toBe(2) - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) vi.setSystemTime(10_450) pending = false ctx.emit('mayfly/request-stop-changed', false) @@ -266,17 +266,17 @@ describe('mayfly-pane-activity', () => { vi.useFakeTimers() try { ctx.emit('mayfly/request-stop-changed', true) - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) pending = false ctx.emit('mayfly/request-stop-changed', false) // The turn settled at once; the acknowledgment holds out its minimum. - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) // A fresh stop cancels the pending hold through the default clearTimeout. pending = true ctx.emit('mayfly/request-stop-changed', true) - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) vi.advanceTimersByTime(600) - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) pending = false ctx.emit('mayfly/request-stop-changed', false) vi.advanceTimersByTime(600) @@ -319,7 +319,7 @@ describe('mayfly-pane-activity', () => { mayflyRequests: { stopPending: () => true }, }) const { screen, dispose } = harness - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) await dispose() }) @@ -336,7 +336,7 @@ describe('mayfly-pane-activity', () => { vi.setSystemTime(20_000) pending = true ctx.emit('mayfly/request-stop-changed', true) - expect(screen.paneLines()).toEqual(['■ interrupting...']) + expect(screen.paneLines()).toEqual(['■ interrupting…']) // A drain that outlasts the hold releases on the event itself. vi.setSystemTime(20_900) diff --git a/packages/mayfly/tests/transcript/pane-todo.spec.ts b/packages/mayfly/tests/transcript/pane-todo.spec.ts index e5f9308e..d41e0add 100644 --- a/packages/mayfly/tests/transcript/pane-todo.spec.ts +++ b/packages/mayfly/tests/transcript/pane-todo.spec.ts @@ -12,6 +12,8 @@ import { describe, expect, it } from 'vitest' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { GoalPhase, GoalProjection } from '@deepseek-ai/dsh-goal' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo' +import { endsWithAffordance } from '../../src/core/surface-renderer.ts' +import type { MayflyTranslate } from '../../src/frontend/index.ts' import * as todo from '../../src/transcript/pane-todo.ts' import { event, resetSeq, turnEnd, turnStart, userEvent } from './helpers.ts' import { bootPanePlugin } from './pane-fakes.ts' @@ -497,4 +499,15 @@ describe('mayfly-pane-todo', () => { expect(pane.render(10)).toEqual(narrow) await dispose() }) + + it('pins the fold-affordance contract to the real todo node', () => { + const t: MayflyTranslate = key => key + const many: readonly TodoItem[] = Array.from({ length: 6 }, (_, index): TodoItem => ({ content: `t${String(index)}`, status: 'pending' })) + const state = (expanded: boolean, todos: readonly TodoItem[]): todo.TodoState => ({ todos, goal: null, expanded, dialog: false, interrupted: false }) + // Folded and expanded past the cap both end in the muted fold/expand footer. + expect(endsWithAffordance(todo.todoNode(state(false, many), t, 'Ctrl+T'))).toBe(true) + expect(endsWithAffordance(todo.todoNode(state(true, many), t, 'Ctrl+T'))).toBe(true) + // A short list has no footer, so the tail is ordinary content. + expect(endsWithAffordance(todo.todoNode(state(false, many.slice(0, 2)), t, 'Ctrl+T'))).toBe(false) + }) })