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
17 changes: 14 additions & 3 deletions packages/mayfly/src/conversation/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down Expand Up @@ -463,16 +473,17 @@ 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)}`,
seq: event.seq,
updatedSeq: event.seq,
turn: state.currentTurn,
text,
images: imagesOf(event.data.content),
images,
})
}
case 'assistant/attempt': {
Expand Down
13 changes: 7 additions & 6 deletions packages/mayfly/src/core/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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))
}

/**
Expand Down Expand Up @@ -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('╮')
}
Expand Down
1 change: 1 addition & 0 deletions packages/mayfly/src/core/context-hint-locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])))
Expand Down
9 changes: 7 additions & 2 deletions packages/mayfly/src/core/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
55 changes: 44 additions & 11 deletions packages/mayfly/src/core/scrollable-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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('│')
}
Expand Down
24 changes: 19 additions & 5 deletions packages/mayfly/src/core/surface-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand All @@ -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. */
Expand Down Expand Up @@ -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 }
Expand All @@ -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)]
}

/**
Expand Down
28 changes: 26 additions & 2 deletions packages/mayfly/src/core/surface-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {}

Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading