Merge remote-tracking branch 'origin/master' into mergebot/pr883

# Conflicts:
#	packages/client/tsdown.client.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/PendingCard.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
#	packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
#	packages/client/ui-conversation/tests/chat-apply.spec.tsx
#	packages/client/ui-conversation/tests/coverage-tails.spec.tsx
#	packages/client/ui-conversation/tests/todo-panel.spec.tsx
#	packages/client/ui-primitives/src/icons/index.tsx
#	packages/client/ui-primitives/tests/icons.spec.tsx
#	packages/client/ui-question/README.i18n.yaml
#	packages/client/ui-question/src/client/QuestionComposer.module.css
#	packages/client/ui-question/src/client/QuestionComposer.tsx
This commit is contained in:
imccyu
2026-07-30 01:01:14 +08:00
1663 changed files with 59630 additions and 13353 deletions

View File

@@ -0,0 +1,222 @@
.root {
--json-tree-property: #881391;
--json-tree-string: #c41a16;
--json-tree-number: #1c00cf;
--json-tree-keyword: #1c00cf;
--json-tree-punctuation: #202124;
--json-tree-icon: #5f6368;
--json-tree-hover: rgb(60 64 67 / 4%);
min-width: 0;
overflow: auto;
position: relative;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
font: 12px/16px var(--ds-font-family-code);
overscroll-behavior-x: contain;
overscroll-behavior-y: auto;
}
:global(body[data-ds-dark-theme]) .root {
--json-tree-property: #5db0d7;
--json-tree-string: #f28b82;
--json-tree-number: #99c8ff;
--json-tree-keyword: #99c8ff;
--json-tree-punctuation: #e8eaed;
--json-tree-icon: #9aa0a6;
--json-tree-hover: rgb(232 234 237 / 5%);
}
.container {
box-sizing: border-box;
width: max-content;
min-width: 100%;
margin: 0;
padding: 6px 8px 8px;
white-space: pre;
}
.expandedTopLevel {
box-sizing: border-box;
width: max-content;
min-width: 100%;
padding: 6px 8px 8px 14px;
}
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
background: var(--json-tree-hover);
}
.expandedTopLevelContainer {
padding: 0;
}
.row.topLevelBracket {
margin-left: 0;
padding-left: 0;
}
.children {
margin: 0;
padding: 0;
list-style: none;
}
.row {
position: relative;
box-sizing: border-box;
min-width: 100%;
min-height: 16px;
margin: 0;
padding: 0 0 0 10px;
list-style: none;
}
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
.row:not(.topLevelBracket)[data-json-copy-active]::after,
.row:has(> .expander:focus-visible)::after {
position: absolute;
z-index: 0;
top: 0;
right: 0;
left: 0;
height: 16px;
background: var(--json-tree-hover);
content: '';
pointer-events: none;
}
.row > span:not(.expander) {
position: relative;
z-index: 1;
}
.label {
margin-right: 3px;
color: var(--json-tree-property);
font-weight: 400;
}
.clickableLabel {
cursor: pointer;
}
.stringValue {
color: var(--json-tree-string);
}
.numberValue {
color: var(--json-tree-number);
}
.keywordValue {
color: var(--json-tree-keyword);
}
.otherValue {
color: var(--dsw-alias-label-secondary);
}
.punctuation {
color: var(--json-tree-punctuation);
}
.preview {
color: var(--json-tree-punctuation);
}
.previewProperty {
color: var(--json-tree-punctuation);
}
.previewEllipsis {
color: var(--dsw-alias-label-tertiary);
}
.copyAnchor {
position: fixed;
z-index: 3;
display: inline-flex;
}
.copyButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: 3px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
cursor: pointer;
}
.copyButton:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.copyButton:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.copyButton[data-state='failed'] {
color: var(--dsw-alias-state-error-primary);
}
.expander {
position: absolute;
z-index: 2;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
width: 8px;
height: 16px;
margin: 0;
color: var(--json-tree-icon);
cursor: pointer;
user-select: none;
}
.expander::before {
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid currentColor;
content: '';
transform: scale(0.75);
transform-origin: 33.333% center;
}
.collapseIcon::before {
transform: rotate(90deg) scale(0.75);
}
.expander:hover {
color: var(--dsw-alias-label-primary);
}
.expander:focus-visible {
outline: none;
}
.collapsedContent {
margin: 0 1px;
color: var(--json-tree-punctuation);
cursor: pointer;
}
.collapsedContent::after {
content: '…';
}

View File

@@ -0,0 +1,602 @@
import clsx from 'clsx'
import { useEffect, useId, useRef, useState } from 'react'
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
UIEvent as ReactUIEvent,
} from 'react'
import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
import { Menu } from './Menu.tsx'
import type { MenuEntry } from './Menu.tsx'
import css from './JsonTree.module.css'
const OBJECT_PREVIEW_LIMIT = 4
const ARRAY_PREVIEW_LIMIT = 5
const PREVIEW_DEPTH_LIMIT = 2
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'value', label: 'Copy value' },
{ id: 'json', label: 'Copy JSON' },
{ id: 'path', label: 'Copy property path' },
]
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'prettyJson', label: 'Copy pretty JSON' },
{ id: 'json', label: 'Copy compact JSON' },
{ id: 'path', label: 'Copy property path' },
]
type JsonPath = readonly (number | string)[]
interface RowTarget {
path: JsonPath
value: unknown
}
interface CopyTarget extends RowTarget {
left: number
side: 'bottom' | 'top'
top: number
}
function isExpandableValue(value: unknown): value is object | unknown[] {
return typeof value === 'object' && value !== null && !(value instanceof Date)
}
function entriesOf(value: object | unknown[]): readonly (readonly [string, unknown])[] {
if (Array.isArray(value)) {
return value.map((item, index) => [String(index), item] as const)
}
return Object.keys(value).map(key => [
key,
(value as Record<string, unknown>)[key],
] as const)
}
function bracketOf(value: object | unknown[]): readonly [string, string] {
return Array.isArray(value) ? ['[', ']'] : ['{', '}']
}
function previewPrimitive(value: unknown): ReactNode {
if (value === null) return <span className={css.keywordValue}>null</span>
if (typeof value === 'string') {
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
}
if (typeof value === 'number') {
return <span className={css.numberValue}>{String(value)}</span>
}
if (typeof value === 'boolean') {
return <span className={css.keywordValue}>{String(value)}</span>
}
if (typeof value === 'bigint') {
return <span className={css.otherValue}>{value.toString()}</span>
}
if (typeof value === 'undefined') {
return <span className={css.otherValue}>undefined</span>
}
if (typeof value === 'symbol') {
return <span className={css.otherValue}>{value.description ?? 'Symbol'}</span>
}
if (typeof value === 'function') {
return <span className={css.otherValue}>{value.name || 'Function'}</span>
}
return null
}
function previewValue(value: unknown, depth: number): ReactNode {
if (!isExpandableValue(value)) return previewPrimitive(value)
const array = Array.isArray(value)
const entries = entriesOf(value)
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
const visible = entries.slice(0, limit)
const [open, close] = bracketOf(value)
return (
<>
<span className={css.punctuation}>{open}</span>
{depth >= PREVIEW_DEPTH_LIMIT
? <span className={css.previewEllipsis}>…</span>
: visible.map(([key, item], index) => (
<span key={key}>
{index > 0 && <span className={css.punctuation}>, </span>}
{!array && (
<>
<span className={css.previewProperty}>{key}</span>
<span className={css.punctuation}>: </span>
</>
)}
{previewValue(item, depth + 1)}
</span>
))}
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
<span className={css.previewEllipsis}>, …</span>
)}
<span className={css.punctuation}>{close}</span>
</>
)
}
function primitiveValue(value: unknown): ReactNode {
if (value === null) return <span className={css.keywordValue}>null</span>
if (typeof value === 'string') {
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
}
if (typeof value === 'boolean') {
return <span className={css.keywordValue}>{String(value)}</span>
}
if (typeof value === 'number') {
return <span className={css.numberValue}>{String(value)}</span>
}
if (typeof value === 'bigint') {
return <span className={css.numberValue}>{`${value.toString()}n`}</span>
}
if (value instanceof Date) {
return <span className={css.otherValue}>{value.toISOString()}</span>
}
if (typeof value === 'function') {
return <span className={css.otherValue}>function() {'{ }'}</span>
}
if (typeof value === 'undefined') {
return <span className={css.otherValue}>undefined</span>
}
return <span className={css.otherValue}>{(value as symbol).toString()}</span>
}
function fieldText(field: string): string {
return field === '' ? '""' : field
}
function pathId(path: JsonPath): string {
return path.map(part => (
typeof part === 'number' ? `n${String(part)}` : `s${String(part.length)}:${part}`
)).join('/')
}
function claimFocus(button: HTMLElement): void {
button.focus()
}
function moveFocus(button: HTMLElement, direction: -1 | 1): void {
const tree = button.closest<HTMLElement>('[role="tree"]')
/* v8 ignore next -- JsonTree attaches expander handlers only beneath its owning role=tree. */
if (tree === null) return
const expanders = Array.from(tree.querySelectorAll<HTMLElement>('[data-json-expander]'))
const current = expanders.indexOf(button)
/* v8 ignore next -- the current expander is a member of the queried non-empty set. */
if (current < 0 || expanders.length === 0) return
const next = (current + direction + expanders.length) % expanders.length
const nextExpander = expanders[next]
/* v8 ignore next -- modulo over the non-empty expander set always resolves a member. */
if (nextExpander !== undefined) claimFocus(nextExpander)
}
function NodeField({
field,
expandable,
onToggle,
}: {
field: string | undefined
expandable: boolean
onToggle: () => void
}) {
if (field === undefined) return null
return (
<span
className={clsx(css.label, expandable && css.clickableLabel)}
onClick={expandable ? onToggle : undefined}
>
{fieldText(field)}:
</span>
)
}
interface JsonTreeNodeProps {
field?: string
initialExpanded: boolean
lastElement: boolean
onClaimTabStop: (id: string) => void
onRowHover: (row: HTMLElement, target: RowTarget) => void
path: JsonPath
tabStopId: string | null
value: unknown
}
function JsonTreeNode({
field,
initialExpanded,
lastElement,
onClaimTabStop,
onRowHover,
path,
tabStopId,
value,
}: JsonTreeNodeProps) {
const contentsId = useId()
const expanderRef = useRef<HTMLSpanElement>(null)
const [expanded, setExpanded] = useState(initialExpanded)
const nodeId = pathId(path)
const container = isExpandableValue(value)
const entries = container ? entriesOf(value) : []
const expandable = entries.length > 0
const toggle = () => {
setExpanded(current => !current)
claimFocus(expanderRef.current as HTMLSpanElement)
}
const onExpanderKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault()
setExpanded(event.key === 'ArrowRight')
return
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
moveFocus(event.currentTarget, event.key === 'ArrowUp' ? -1 : 1)
}
}
const row = (children: ReactNode, ariaExpanded?: boolean) => (
<div
className={css.row}
role="treeitem"
aria-expanded={ariaExpanded}
onMouseOver={(event) => {
event.stopPropagation()
onRowHover(event.currentTarget, { path, value })
}}
>
{children}
</div>
)
if (!container) {
return row((
<>
<NodeField field={field} expandable={false} onToggle={toggle} />
{primitiveValue(value)}
{!lastElement && <span className={css.punctuation}>,</span>}
</>
))
}
const [open, close] = bracketOf(value)
if (!expandable) {
return row((
<>
<NodeField field={field} expandable={false} onToggle={toggle} />
<span className={css.punctuation}>{open}</span>
<span className={css.punctuation}>{close}</span>
{!lastElement && <span className={css.punctuation}>,</span>}
</>
))
}
return row((
<>
<span
ref={expanderRef}
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
data-json-expander
role="button"
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
aria-expanded={expanded}
aria-controls={expanded ? contentsId : undefined}
tabIndex={tabStopId === nodeId ? 0 : -1}
onFocus={() => { onClaimTabStop(nodeId) }}
onClick={toggle}
onKeyDown={onExpanderKeyDown}
/>
<NodeField field={field} expandable onToggle={toggle} />
<span className={css.preview}>{previewValue(value, 0)}</span>
{!lastElement && <span className={css.punctuation}>,</span>}
{expanded && (
<ul id={contentsId} role="group" className={css.children}>
{entries.map(([key, item], index) => (
<JsonTreeNode
key={key}
field={key}
value={item}
path={[...path, Array.isArray(value) ? index : key]}
lastElement={index === entries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
onClaimTabStop={onClaimTabStop}
onRowHover={onRowHover}
/>
))}
</ul>
)}
</>
), expanded)
}
function formattedPath(path: JsonPath): string {
return path.reduce<string>((result, part) => {
if (typeof part === 'number') return `${result}[${String(part)}]`
return /^[A-Za-z_$][\w$]*$/.test(part)
? `${result}.${part}`
: `${result}[${JSON.stringify(part)}]`
}, '$')
}
function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
if (mode === 'path') return formattedPath(target.path)
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
if (mode === 'json') return JSON.stringify(target.value)
if (typeof target.value === 'string') return target.value
if (typeof target.value === 'undefined') return 'undefined'
if (typeof target.value === 'bigint') return target.value.toString()
if (typeof target.value === 'symbol') return target.value.description ?? 'Symbol'
if (typeof target.value === 'function') return target.value.name || 'Function'
return JSON.stringify(target.value)
}
/** Props for the read-only, token-themed JSON tree. */
export interface JsonTreeProps {
/** Parsed JSON object or array. */
data: object | unknown[]
/** Accessible label for the tree. */
label?: string
/** Optional positioning class owned by the caller. */
className?: string | undefined
/** Whether JSON rows expose copy actions. */
copyable?: boolean
/** Whether the top-level object or array is always expanded. */
expandTopLevel?: boolean
}
/**
* Render parsed JSON as a compact, keyboard-accessible inspector tree.
* @param props - Parsed data, accessible label, and display options.
* @returns A read-only JSON tree with an optionally fixed-open top level.
*/
export function JsonTree({
data,
label = 'JSON',
className,
copyable = true,
expandTopLevel = true,
}: JsonTreeProps) {
const rootEntries = entriesOf(data)
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
isExpandableValue(value) && entriesOf(value).length > 0
))
const firstExpandableEntry = rootEntries[firstExpandableIndex]
const initialTabStopId = expandTopLevel
? firstExpandableEntry === undefined
? null
: pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
: isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
const rootRef = useRef<HTMLDivElement>(null)
const activeRowRef = useRef<HTMLElement>()
const copyButtonRef = useRef<HTMLButtonElement>(null)
const copyMenuOpenRef = useRef(false)
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
const setActiveRow = (row: HTMLElement | undefined) => {
activeRowRef.current?.removeAttribute('data-json-copy-active')
activeRowRef.current = row
row?.setAttribute('data-json-copy-active', '')
}
const clearCopyTarget = () => {
setActiveRow(undefined)
setCopyTarget(undefined)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}
const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
const root = rootRef.current
/* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
if (root === null) throw new Error('JsonTree root is not mounted')
const rootRect = root.getBoundingClientRect()
const rowRect = row.getBoundingClientRect()
return {
left: rootRect.left + root.clientWidth - 26,
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
top: rowRect.top,
}
}
const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
const position = copyPosition(row)
setCopyTarget({ ...target, ...position })
}
const repositionCopyButton = (row: HTMLElement) => {
const position = copyPosition(row)
setCopyTarget((current) => {
/* v8 ignore next -- an active row and its copy target are installed together. */
if (current === undefined) return current
return { ...current, ...position }
})
}
useEffect(() => () => {
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
activeRowRef.current?.removeAttribute('data-json-copy-active')
}, [])
useEffect(() => {
activeRowRef.current?.removeAttribute('data-json-copy-active')
activeRowRef.current = undefined
copyMenuOpenRef.current = false
setCopyTarget(undefined)
setCopyState('idle')
setCopyMenuOpen(false)
setTabStopId(initialTabStopId)
}, [data, expandTopLevel, initialTabStopId])
useEffect(() => {
const reposition = () => {
const row = activeRowRef.current
if (row !== undefined) repositionCopyButton(row)
}
window.addEventListener('scroll', reposition, true)
window.addEventListener('resize', reposition)
return () => {
window.removeEventListener('scroll', reposition, true)
window.removeEventListener('resize', reposition)
}
}, [])
const handleRowHover = (row: HTMLElement, target: RowTarget) => {
if (!copyable || copyMenuOpenRef.current) return
if (activeRowRef.current === row) return
setActiveRow(row)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
positionCopyButton(row, target)
}
const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!copyable || copyMenuOpenRef.current) return
/* v8 ignore next -- browser mouse events delivered through React target an Element. */
if (!(event.target instanceof Element)) return
if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
}
const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
const row = activeRowRef.current
if (row !== undefined) repositionCopyButton(row)
}
const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
/* v8 ignore next -- copy controls only render while their target exists. */
if (copyTarget === undefined) return
try {
await navigator.clipboard.writeText(copyText(copyTarget, mode))
setCopyState('copied')
} catch {
setCopyState('failed')
}
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
}
const [rootOpen, rootClose] = bracketOf(data)
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
const copyTitle = copyState === 'copied'
? 'Copied'
: copyState === 'failed'
? 'Copy failed'
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
return (
<div
ref={rootRef}
className={clsx(css.root, className)}
onMouseOver={handleRootMouseOver}
onMouseLeave={() => {
if (!copyMenuOpenRef.current) clearCopyTarget()
}}
onScroll={handleScroll}
>
{expandTopLevel
? (
<div className={css.expandedTopLevel}>
<div
className={clsx(css.row, css.topLevelBracket)}
data-json-root-row
onMouseOver={(event) => {
event.stopPropagation()
handleRowHover(event.currentTarget, { path: [], value: data })
}}
>
<span className={css.punctuation}>{rootOpen}</span>
</div>
<div
aria-label={label}
className={clsx(css.container, css.expandedTopLevelContainer)}
role="tree"
>
{rootEntries.map(([key, value], index) => (
<JsonTreeNode
key={key}
field={key}
value={value}
path={[Array.isArray(data) ? index : key]}
lastElement={index === rootEntries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
onClaimTabStop={setTabStopId}
onRowHover={handleRowHover}
/>
))}
</div>
<div className={clsx(css.row, css.topLevelBracket)}>
<span className={css.punctuation}>{rootClose}</span>
</div>
</div>
)
: (
<div aria-label={label} className={css.container} role="tree">
<JsonTreeNode
value={data}
path={[]}
lastElement
initialExpanded
tabStopId={tabStopId}
onClaimTabStop={setTabStopId}
onRowHover={handleRowHover}
/>
</div>
)}
{copyTarget !== undefined && (
<span
className={css.copyAnchor}
style={{ left: copyTarget.left, top: copyTarget.top }}
>
<Menu
open={copyMenuOpen}
compact
portal
align="end"
side={copyTarget.side}
anchor={(
<button
ref={copyButtonRef}
type="button"
className={css.copyButton}
data-json-copy-button
data-state={copyState}
aria-label={copyTitle}
title={`${copyTitle}; right-click for copy options`}
onClick={() => void copy(defaultCopyMode)}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
copyMenuOpenRef.current = true
setCopyMenuOpen(true)
}}
>
{copyState === 'copied'
? <IconCheckOutline16 size={12} />
: <IconCopyOutline16 size={12} />}
</button>
)}
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
onSelect={(id) => {
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}}
onClose={clearCopyTarget}
getAnchorRect={() => (
copyButtonRef.current as HTMLButtonElement
).getBoundingClientRect()}
/>
</span>
)}
</div>
)
}

View File

@@ -17,6 +17,13 @@
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens. The
declaration sits on the card rather than on `.scrollable .viewport`
because the elevation is a property of this surface, and the custom
properties inherit down to whichever descendant actually scrolls (see
ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Primary card is 218 wide in the design across both hosts. */
@@ -108,6 +115,37 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.list.compactList,
.submenu.compactList {
min-width: 164px;
padding: 2px;
border-radius: 7px;
}
.compactList .item {
min-height: 26px;
gap: 6px;
padding: 3px 7px;
border-radius: 5px;
font-size: 12px;
line-height: 18px;
}
.compactList .itemIcon {
width: 14px;
height: 14px;
}
.compactList .separator {
margin: 2px;
}
.compactList .label {
padding: 4px 7px;
font-size: 11px;
line-height: 16px;
}
.item:disabled {
opacity: 0.4;
cursor: not-allowed;

View File

@@ -71,6 +71,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list when the pointer leaves
* it (default false keeps it open until outside click/Escape/selection).
* @param props.compact - use reduced menu typography and spacing.
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -81,7 +82,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* by a hairline; they stay visible while the items above scroll.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -93,6 +94,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
side?: 'bottom' | 'top' | 'right'
portal?: boolean
closeOnPointerLeave?: boolean
compact?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
}) {
@@ -219,7 +221,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
@@ -246,7 +248,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
const list = open && (
<div
ref={listRef}
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}

View File

@@ -12,13 +12,16 @@ import css from './Modal.module.css'
* Render a centered modal over a blurred page mask.
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading.
* @param props.title - dialog heading (aria-label in every mode).
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @param props.headless - render children directly in the card (no default
* header/close/body chrome) for dialogs whose figma frame owns its own
* header structure; mask, card, Escape, and aria-label remain.
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({ open, onClose, title, description, children, footer, className }: {
export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
open: boolean
onClose: () => void
title: string
@@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
children?: ReactNode
footer?: ReactNode
className?: string
headless?: boolean
}) {
useEffect(() => {
if (!open) return
@@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla
aria-modal="true"
aria-label={title}
>
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>
{description !== undefined && description !== '' && (
<p className={css.description}>{description}</p>
{headless
? children
: (
<>
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>
{description !== undefined && description !== '' && (
<p className={css.description}>{description}</p>
)}
{children !== undefined && <div className={css.body}>{children}</div>}
</div>
{footer !== undefined && <div className={css.footer}>{footer}</div>}
</>
)}
{children !== undefined && <div className={css.body}>{children}</div>}
</div>
{footer !== undefined && <div className={css.footer}>{footer}</div>}
</div>
</div>
)

View File

@@ -12,7 +12,9 @@ import css from './Pill.module.css'
*/
export function Pill({ active = false, className, children, onClick, ...rest }: {
active?: boolean
className?: string
// `| undefined` so a caller can forward an optional class straight through
// under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
className?: string | undefined
children?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>) {
if (!onClick) {

View File

@@ -23,8 +23,8 @@ const MATRIX_CELLS: readonly (readonly [number, number])[] = [
*/
export function StateDot({ state, size = 10, className }: {
state: StateDotState
size?: number
className?: string
size?: number | undefined
className?: string | undefined
}) {
if (state === 'ongoing') {
return (

View File

@@ -0,0 +1,152 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner rows,
markdown code-block font) so a terminal card and a fenced code block read as
one family. The one deliberate divergence: output keeps `white-space: pre`
and scrolls horizontally, because folding a column-aligned command's output
destroys its alignment. */
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
/* The card's own left inset, holding the run-state dot in a column of its own
so it never competes with the commands for horizontal space. */
--dsl-terminal-gutter: 30px;
position: relative;
margin: 16px 0;
/* The gutter is the card's OWN padding, not a margin: every consumer rewrites
`margin` wholesale (each render site sets its own indent), which silently
cancelled the reservation and let the dot fall outside the card into a
container that clips it. Owning the reservation here keeps the invariant
with the component that depends on it. */
padding-left: var(--dsl-terminal-gutter);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
}
/* Top-aligned: the status pill and copy control stay on the first prompt row
however many command lines the card carries. */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
/* Pulled back across the card's gutter padding so the banner background and
its top-left radius span the FULL surface, then re-inset by the same amount
so the prompt text and the dot keep their positions. A plain block child
only reaches the content box, which left the gutter column painted in the
body color and drew the card's top-left corner in it — invisible in the
light theme, where banner and body share a token, and visible in the dark
one, where they do not. */
margin-left: calc(-1 * var(--dsl-terminal-gutter));
padding: 9px 14px 9px var(--dsl-terminal-gutter);
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
}
/* One row per command line. The prompt column is the only element allowed to
shrink; the status pill and the copy control keep their intrinsic width. */
.prompt {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
}
.promptLine {
position: relative;
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
line-height: var(--dsl-terminal-line-height);
}
/* Out of flow inside the card's own gutter padding, so the reservation and the
dot move together and no consumer margin can pull them apart; the dot neither
indents its command nor depends on the command's text metrics to line up.
Centered against the row's line box, not the code font's baseline. */
.runState {
position: absolute;
left: calc(-1 * var(--dsl-terminal-gutter) + 8px);
top: 50%;
transform: translateY(-50%);
}
/* The dot is aria-hidden; this is its text label for assistive technology. */
.runStateLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cwd {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
`nowrap` collapses the repeated spaces, tabs, and alignment of an indented
continuation. Both hold the single row and the ellipsis. */
.command {
min-width: 0;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
.status {
flex: none;
color: var(--dsw-alias-state-error-primary);
}
.copyButton {
flex: none;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.output {
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: alignment is the payload of terminal output. */
.line {
min-height: var(--dsl-terminal-line-height);
white-space: pre;
}
.expand {
display: block;
width: 100%;
padding: 0;
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
.empty {
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,237 @@
// TerminalBlock: the terminal surface for a shell command and its output —
// prompt line (run-state dot + shortened cwd + command), ANSI-colored output,
// settled exit status, and a copy control for the raw output. Output never soft-wraps:
// column-aligned output (ls, tables, box drawing) keeps its alignment and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; ANSI parsing lives in ansi.ts.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
import { writeClipboard } from './clipboard.ts'
import { Pill } from './Pill.tsx'
import { StateDot, type StateDotState } from './StateDot.tsx'
import css from './TerminalBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches the
* TUI transcript's default tool-output budget so both front ends cut a long
* command's output at the same place.
*/
export const DEFAULT_TERMINAL_MAX_LINES = 16
export interface TerminalBlockProps {
/** The command line, rendered verbatim after the prompt label. */
command: string
/** Working directory for the prompt label; absent renders a plain `$`. */
cwd?: string | undefined
/** Absolute home directory, so a cwd equal to it collapses to `~`; absent disables that collapse. */
home?: string | undefined
/** The command's output text; may contain ANSI escape sequences. */
output?: string | undefined
/** Settled exit code; a non-zero value renders the status pill. */
exitCode?: number | undefined
/** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
signal?: string | undefined
/** The command is still running: the block shows the prompt line alone. */
running?: boolean | undefined
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/**
* Prompt label for a working directory: `~` for the home directory itself,
* otherwise the path's last segment (both separators accepted, trailing
* separators ignored), falling back to the path itself when it has no
* segment.
* @param cwd - the working directory path.
* @param home - absolute home directory, when the caller knows it.
* @returns the prompt label.
*/
function promptLabel(cwd: string, home: string | undefined): string {
const trimmed = cwd.replace(/[/\\]+$/, '')
if (home !== undefined && trimmed === home.replace(/[/\\]+$/, '')) return '~'
const segment = trimmed.split(/[/\\]/).pop()
return segment === undefined || segment === '' ? cwd : segment
}
/**
* Status pill text for a settled command, or undefined when the command
* settled cleanly (exit 0, no signal) and needs no pill — the same
* distinction the bash tool's own exit-status markers draw.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @returns the pill text, or undefined for a clean exit.
*/
function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
if (signal !== undefined) return `信号 ${signal}`
if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
return undefined
}
/**
* Run-state indicator for the command, shown at the head of the prompt line so
* the card states whether the command is still running without the reader
* having to infer it from the presence of output. Three of {@link StateDotState}'s
* four states are reachable: the running chase (the same
* indicator a running tool row's leading icon uses, so the row and its card
* never disagree), green for a clean settle, red for a signal or a non-zero
* exit — the same status distinction {@link statusText} draws for the pill. A
* settled command whose exit status never reached the view counts as a clean
* settle: the view says it finished and says nothing went wrong.
* @param running - the command has not settled.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @returns the dot's state and its text label, since the dot is aria-hidden.
*/
function runState(
running: boolean,
exitCode: number | undefined,
signal: string | undefined,
): { state: StateDotState; label: string } {
if (running) return { state: 'ongoing', label: '运行中' }
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
return { state: 'done', label: '已完成' }
}
/**
* Render one parsed output line. Runs without SGR state render as bare text,
* so uncolored output carries no span wrappers.
* @param line - the line's styled runs.
* @returns the line's children.
*/
function renderLine(line: AnsiLine) {
return line.map((span, index) => span.style === undefined
? span.text
: <span key={index} style={span.style}>{span.text}</span>)
}
/**
* Render a shell command as a terminal surface.
* @param props - see {@link TerminalBlockProps}.
* @returns the terminal block element.
*/
export function TerminalBlock({
command,
cwd,
home,
output,
exitCode,
signal,
running = false,
maxLines = DEFAULT_TERMINAL_MAX_LINES,
className,
}: TerminalBlockProps) {
const text = output ?? ''
// A command's output ends with a newline; that terminator is not an extra
// blank line to draw or to count against the height cap. The check runs on the
// PARSED lines rather than on the raw text, because a reset after the final
// newline (`line\n\x1b[0m`) leaves the string not ending in one while still
// producing a last line with nothing visible in it. A genuinely blank final
// line — the double newline — survives, since it has a real empty line before
// the terminator. The copy control still copies `text` untouched.
const lines = useMemo(() => {
const parsed = parseAnsiLines(text)
const last = parsed[parsed.length - 1]
const terminated = parsed.length > 1 && last !== undefined
&& last.every(span => span.text === '')
return terminated ? parsed.slice(0, -1) : parsed
}, [text])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The raw output, never the rendered tree: the prompt line and the status
// pill are chrome the user did not run.
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, text])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const status = statusText(exitCode, signal)
const state = runState(running, exitCode, signal)
// A multi-line command gets one prompt row per line, so a two-command shell
// snippet reads as the two commands it is instead of collapsing into one
// ellipsized row. A trailing newline is a terminator, not an empty command.
const commandLines = useMemo(() => {
const body = command.endsWith('\n') ? command.slice(0, -1) : command
return body.split('\n')
}, [command])
// Read from the parsed lines the card actually renders, not from the raw text:
// output that is only escapes or control bytes (a lone reset, an OSC title, an
// erase) survives `text.trim()` yet parses to nothing visible. Judging it on
// the raw text drew an output box of blank rows plus a copy control for
// invisible bytes, and hid the placeholder that belongs there.
const empty = lines.every(line => line.every(span => span.text.trim() === ''))
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as the TUI transcript's collapsed tool card, so a
// command's head and tail slices agree between the two front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
return (
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
<div className={css.header}>
<div className={css.prompt}>
<span className={css.runStateLabel}>{state.label}</span>
{commandLines.map((line, index) => (
<div key={index} className={css.promptLine}>
{/* One dot for the card, on the first row: the exit status the
view carries is the whole call's, and bash reports no
per-command status, so a dot per row would assert a
per-line outcome nothing here knows. */}
{index === 0 && <StateDot state={state.state} className={css.runState} />}
{/* The cwd labels the CALL, so only its first row carries it. The
view knows one working directory — where the call started —
and a later line may well run somewhere else (a `cd` in the
command is enough), so repeating the label down the rows would
assert a directory per line that nothing here knows. Later
rows keep a bare `$` to stay aligned as prompts. */}
<span className={css.cwd}>
{index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)}
</span>
<span className={css.command}>{line}</span>
</div>
))}
</div>
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{!running && !empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{!running && (empty
? <div className={css.empty}>无输出</div>
: (
<div className={css.output}>
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 行`}
</button>
)}
{capped && lines.slice(lines.length - tailLines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,447 @@
// ANSI model behind TerminalBlock: anser splits the SGR runs, this module
// resolves each run's colors and decorations into a plain style record and
// folds the runs into per-line span arrays so a height cap can slice whole
// lines. Sequences anser does not turn into color (OSC, cursor movement,
// other C0 controls) are removed before parsing so they never reach the DOM
// as literal characters.
import Anser from 'anser'
import type { CSSProperties } from 'react'
/**
* The subset of one anser JSON chunk this module reads. anser's own types
* declare `fg`/`bg` as `string`, but its parser leaves them `null` for a run
* that sets no color, so the null is spelled out here.
*/
interface AnsiChunk {
/** Run text with its SGR codes already removed. */
content: string
/** Foreground as an `r, g, b` triple, or null when the run sets none. */
fg: string | null
/** Background as an `r, g, b` triple, or null when the run sets none. */
bg: string | null
/** SGR attributes in effect for the run, in the order they were declared. */
decorations: readonly string[]
}
/** One run of terminal text; `style` is undefined for text that carries no SGR state. */
export interface AnsiSpan {
/** The run's plain text, free of escape sequences and newlines. */
text: string
/** Resolved inline style, or undefined when the run needs no wrapper. */
style: CSSProperties | undefined
}
/** The spans of one output line, in order. */
export type AnsiLine = readonly AnsiSpan[]
/**
* The 8/16 basic ANSI colors, keyed by the whitespace-free `r,g,b` triple
* anser emits for them, mapped onto the theme tokens that carry the same
* semantic. Black and white both resolve to the primary label color so text
* stays legible under either theme instead of matching the surface it sits
* on; bright black takes the tertiary label color (the muted-gray role).
* Magenta and cyan have no token equivalent in this design system and fall
* through to anser's literal rgb, as do all 256-palette and truecolor values.
*/
const TOKEN_BY_BASIC_RGB: Record<string, string> = {
'0,0,0': 'var(--dsw-alias-label-primary)',
'255,255,255': 'var(--dsw-alias-label-primary)',
'85,85,85': 'var(--dsw-alias-label-tertiary)',
'187,0,0': 'var(--dsw-alias-state-error-primary)',
'255,85,85': 'var(--dsw-alias-state-error-secondary)',
'0,187,0': 'var(--dsw-alias-state-success-primary)',
'0,255,0': 'var(--dsw-alias-state-success-secondary)',
'187,187,0': 'var(--dsw-alias-state-warn-primary)',
'255,255,85': 'var(--dsw-alias-state-warn-secondary)',
'0,0,187': 'var(--dsw-alias-state-business-primary)',
'85,85,255': 'var(--dsw-static-blue-400)',
}
/**
* CSS for each SGR attribute anser reports. `blink` is deliberately absent —
* animated text is not reproduced. `reverse` never arrives here: anser
* consumes it by swapping the run's foreground and background. Underline and
* strikethrough share `textDecoration`, so in a run declaring both, the
* later declaration wins.
*/
const STYLE_BY_DECORATION: Record<string, CSSProperties | undefined> = {
bold: { fontWeight: 700 },
dim: { opacity: 0.7 },
italic: { fontStyle: 'italic' },
underline: { textDecoration: 'underline' },
strikethrough: { textDecoration: 'line-through' },
hidden: { visibility: 'hidden' },
}
/** OSC strings (window title, hyperlinks), with or without their terminator. */
const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
/** Escape sequences other than CSI: charset selection, single-shift, reset. */
const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
/**
* C0 controls with no display meaning here. Tab, newline, backspace and ESC
* survive: the first two for layout, backspace for the cursor replay, ESC
* for anser's CSI split.
*/
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
/**
* Lines whose cursor movements have to be replayed: a carriage return, a
* backspace, or an erase-in-line. The erase pattern matches the SAME CSI shape
* `replayLine` parses (parameters may carry `;` and intermediate bytes), so a
* form like `\x1b[1;2K` cannot slip past this guard and skip its own erase.
*/
const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
/** SGR sequences alone, for folding state through a line that needs no replay. */
const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
/** Terminal tab stop width; a tab advances to the next multiple of this. */
const TAB_WIDTH = 8
/**
* Combining marks and other zero-width code points: a terminal advances no
* column for them, so `e` + U+0301 occupies one cell and a two-column redraw
* covers both code points.
*/
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
/**
* Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
* CJK punctuation, and characters with emoji presentation. Text-presentation
* symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
* must stay out of this set.
*/
const WIDE_CHAR = new RegExp(
'\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
// Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
// width — `\u2713` (the check every progress line writes, this fixture
// included) advances one column, verified against a real terminal, so taking
// the whole block as wide misaligned exactly the output this card exists for.
+ '|\\p{Emoji_Presentation}'
+ '|[\\uff01-\\uff60\\u3000-\\u303e]',
'u',
)
/**
* Whether a character occupies two terminal columns (CJK, fullwidth forms,
* emoji). Covers the ranges a command's output realistically carries; a
* narrower guess would misalign the columns this card exists to preserve.
* @param char - one character from the output.
* @returns true when the terminal advances two columns for it.
*/
function isWide(char: string): boolean {
const code = char.codePointAt(0)
if (code === undefined || code < 0x1100) return false
return WIDE_CHAR.test(char)
}
/**
* A cell's graphic state, normalized. Held as fields rather than as the raw
* sequence history because a terminal tracks CURRENT state, not a transcript:
* accumulating sequences made each state boundary re-emit the whole chain, so
* output that switches color without a full reset emitted O(n^2) characters
* (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
* the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
* `24`, `27`, `29` — actually close their attribute instead of appending to it.
*/
interface SgrState {
fg: string
bg: string
/** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
attrs: readonly string[]
}
/** The default state: no color, no attributes. */
const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
/** Attribute closers, mapped to the opener parameters each one turns off. */
const ATTR_CLOSERS: Record<string, readonly string[]> = {
22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
}
/**
* Fold one SGR sequence's parameters into the state it produces.
* @param state - state in force before the sequence.
* @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
* @returns the state the sequence leaves in force.
*/
function foldSgr(state: SgrState, params: string): SgrState {
const codes = params === '' ? ['0'] : params.split(';')
let next = state
for (let index = 0; index < codes.length; index++) {
const code = String(codes[index])
if (code === '' || code === '0') { next = SGR_NONE; continue }
// Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
// consume their own arguments, so they are taken whole.
if (code === '38' || code === '48') {
const kind = codes[index + 1] ?? ''
const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
const value = codes.slice(index, index + span + 1).join(';')
next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
index += span
continue
}
const closes = ATTR_CLOSERS[code]
if (closes !== undefined) {
next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
continue
}
const numeric = Number(code)
if (code === '39') { next = { ...next, fg: '' }; continue }
if (code === '49') { next = { ...next, bg: '' }; continue }
if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
}
return next
}
/**
* Render a state as the one canonical sequence that establishes it from the
* default, so a boundary emits a bounded string no matter how the state was
* reached.
* @param state - the state to open.
* @returns the SGR sequence, or the empty string for the default state.
*/
function openSgr(state: SgrState): string {
const codes = [...state.attrs]
if (state.fg !== '') codes.push(state.fg)
if (state.bg !== '') codes.push(state.bg)
return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
}
/** Whether two states are the same, so a boundary is only emitted on a change. */
function sameSgr(a: SgrState, b: SgrState): boolean {
return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
&& a.attrs.every((attr, index) => attr === b.attrs[index])
}
/**
* Replay one line's cursor movements the way a terminal paints it, into a
* column buffer. Carriage return and backspace only MOVE the cursor — neither
* erases anything — so what a reader sees is whatever each column last had
* written to it. That distinction is the whole point of doing this as a buffer
* rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
* shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
* because nothing ever overwrote the `c`.
*
* A CSI sequence occupies no column; it changes the state that the NEXT writes
* are stamped with, which is how a terminal stores color per cell. `red bad`
* then three backspaces then `ok` therefore shows `okd` with the `d` still red:
* `ok` overwrote two cells and the third kept the state it was written with.
* The columns are re-emitted as runs, so anser sees that same styling.
* @param line - one output line, still carrying its CSI sequences.
* @param entrySgr - SGR state in force when the line begins, since a newline
* does not reset it.
* @returns the line as the terminal would have it after every movement, plus the
* SGR state at its end for the next line to enter with.
*/
function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
// Same shape anser splits on, so a sequence is one unit here as well.
const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
/** Per column: the state in force when it was written, and its character. */
const columns: (Cell | undefined)[] = []
let cursor = 0
// State is tracked exactly as a terminal tracks it: each cell is stamped with
// whatever was in force at the moment of the write, so a later redraw cannot
// restyle the cells it does not reach. It enters carrying the previous line's
// state, since a newline does not reset it.
let sgr = entrySgr
let at = 0
/** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
const clear = (index: number, fill: string): void => {
const cell = columns[index]
if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
columns[index + 1] = { sgr, char: fill }
}
columns[index] = { sgr, char: fill }
}
const consume = (chunk: string): void => {
for (const char of chunk) {
if (char === '\r') { cursor = 0; continue }
if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
if (char === '\t') {
// A tab advances to the next 8-column stop, leaving the cells it skips
// as they were — which is how a redraw can leave a tabbed column
// standing. Column alignment is the whole point of this card.
const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH)
for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
continue
}
if (ZERO_WIDTH.test(char)) {
// No column of its own: it attaches to the cell already written, so a
// redraw that covers that cell covers the mark with it. With no cell to
// attach to (line start, or straight after a redraw to column 0) a
// terminal shows nothing rather than a lone accent.
const base = cursor > 0 ? columns[cursor - 1] : undefined
if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
continue
}
// Writing over either half of a wide pair blanks the other half, since a
// terminal cannot leave one cell of a two-cell glyph standing.
clear(cursor, ' ')
columns[cursor] = { sgr, char }
cursor++
// A wide character occupies two columns; the trailing one is a spacer,
// marked so that overwriting the lead cell leaves a blank behind instead
// of closing the gap and shifting everything after it left.
if (isWide(char)) { columns[cursor] = { sgr, char: '', spacer: true }; cursor++ }
}
}
for (const match of line.matchAll(csi)) {
consume(line.slice(at, match.index))
at = match.index + match[0].length
// Both groups are mandatory in the pattern, so destructuring types them as
// strings without a fallback that could never run.
const params = String(match[1])
const final = String(match[2])
if (final === 'K') {
// Erase in line: the fixed companion of `\r` in every spinner and progress
// bar. Without it a shorter redraw leaves the previous frame's tail
// standing, which is text the terminal never showed. `1` blanks from the
// line start THROUGH the cursor column (inclusive, per the CSI spec)
// rather than dropping those cells, since the cursor does not move and a
// later write can still land past them. Only the FIRST parameter selects
// the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
const mode = String(params.split(';')[0])
if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
else columns.length = mode === '2' ? 0 : cursor
continue
}
// Only SGR carries graphic state; every other final byte is a cursor or
// erase action that must not affect a cell's style.
if (final !== 'm') continue
sgr = foldSgr(sgr, params)
}
consume(line.slice(at))
// Re-emit the columns, opening a run only where its state changes, so anser
// sees the same styling a terminal shows. Each boundary emits ONE canonical
// sequence for the state it opens, which is what keeps the output linear in
// the number of cells however the state was reached.
let out = ''
let active = entrySgr
for (let index = 0; index < columns.length; index++) {
const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
if (!sameSgr(column.sgr, active)) {
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
out += openSgr(column.sgr)
active = column.sgr
}
// A spacer still holds its column. While its lead cell survives, the wide
// glyph spans both and the spacer emits nothing; once a later write replaced
// that lead, the terminal blanks the spacer instead of closing the gap, so
// emitting nothing would shift everything after it one column left.
const leadIntact = index > 0 && isWide(columns[index - 1]?.char ?? '')
out += column.spacer === true && !leadIntact ? ' ' : column.char
}
// Converge to the state the SCAN ended in, not the last written cell's: a
// sequence after the final write (the `\x1b[0m` closing a colored line) changes
// no cell yet still ends the run, and it has to reach both the DOM and the
// next line. Without this a line ending in a reset leaked its color onward.
if (!sameSgr(active, sgr)) {
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
out += openSgr(sgr)
}
return { text: out, sgr }
}
/** One replayed column: the state it was written with, and its character. */
interface Cell {
sgr: SgrState
char: string
/** The trailing half of a wide character's two-column pair. */
spacer?: boolean
}
/**
* Replay every line's cursor movements. A `\r` that only terminates a CRLF line
* is dropped first, so those lines keep their text instead of being redrawn onto
* themselves. SGR state threads across lines: a newline does not reset it, so a
* run opened before a redraw still colors the lines after it.
* @param text - output text, already free of OSC and non-CSI escapes.
* @returns the text with each line painted as the terminal would.
*/
function applyCursorMovements(text: string): string {
const replayed: string[] = []
let sgr = SGR_NONE
for (const raw of text.split('\n')) {
const line = raw.replace(/\r+$/, '')
if (NEEDS_REPLAY.test(line)) {
const result = replayLine(line, sgr)
replayed.push(result.text)
sgr = result.sgr
continue
}
// No cursor movement: the line needs no column buffer, and painting one
// would allocate a cell per character of output this card never redraws —
// an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
// line that DOES replay enters with the right state.
replayed.push(line)
for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
}
return replayed.join('\n')
}
/**
* Remove every escape sequence and control character that carries no color,
* leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
* (carriage return, backspace) replay first, since their effect on the visible
* text must land before the characters that expressed them are dropped.
* @param text - raw command output.
* @returns text whose only remaining escapes are CSI sequences.
*/
function sanitize(text: string): string {
const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
return applyCursorMovements(escaped).replace(INERT_CONTROL, '')
}
/**
* Resolve one run's colors and decorations.
* @param chunk - the anser chunk to style.
* @returns the run's inline style, or undefined when it carries no SGR state.
*/
function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined {
const style: CSSProperties = {}
const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})`
if (background !== undefined) style.backgroundColor = background
if (chunk.fg !== null) {
const literal = `rgb(${chunk.fg})`
// A run that paints its own background keeps anser's literal pair so the
// authored foreground/background contrast survives; a foreground-only run
// maps onto a theme token, which adapts to light and dark surfaces.
style.color = background === undefined
? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal
: literal
}
for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration])
return Object.keys(style).length === 0 ? undefined : style
}
/**
* Parse command output into styled spans grouped by line.
* @param text - raw output text, which may contain ANSI escape sequences.
* @returns one entry per output line (always at least one, possibly empty).
*/
export function parseAnsiLines(text: string): AnsiLine[] {
let current: AnsiSpan[] = []
const lines: AnsiSpan[][] = [current]
for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) {
const style = resolveStyle(chunk)
for (const [index, part] of chunk.content.split('\n').entries()) {
if (index > 0) {
current = []
lines.push(current)
}
if (part !== '') current.push({ text: part, style })
}
}
return lines
}

View File

@@ -0,0 +1,48 @@
// Package-internal clipboard write, shared by every copy control in this
// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
// public surface: consumers get the components, not the host detection.
/**
* Write text to the host clipboard, preferring the async Clipboard API and
* falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts)
* that omit it.
* @param text - the exact text to place on the clipboard.
* @returns true only when the host accepted the write.
*/
export async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}

View File

@@ -522,6 +522,18 @@ export const IconPlayOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_pause_outline_16 */
export const IconPauseOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.1448 8.00024C14.1448 4.60644 11.394 1.85563 8.00024 1.85563C4.60644 1.85563 1.85563 4.60644 1.85563 8.00024C1.85563 11.394 4.60644 14.1448 8.00024 14.1448C11.394 14.1448 14.1448 11.394 14.1448 8.00024ZM15.5112 8.00024C15.5112 12.1482 12.1482 15.5112 8.00024 15.5112C3.85226 15.5112 0.489258 12.1482 0.489258 8.00024C0.489258 3.85226 3.85226 0.489258 8.00024 0.489258C12.1482 0.489258 15.5112 3.85226 15.5112 8.00024Z"
fill="currentColor"
/>
<path d="M7.14244 5.14258V10.8569H5.71387V5.14258H7.14244Z" fill="currentColor" />
<path d="M10.286 5.14258V10.8569H8.85742V5.14258H10.286Z" fill="currentColor" />
</svg>
)
/** ic_ds_fullscreen_outline_16 */
export const IconFullscreenOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -691,6 +703,17 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** sparkle_16 (Others tool-row / goal strip leading glyph; hand-authored three-star
* approximation — the figma 43:31850 glyph is an SF Symbols "sparkles" text glyph,
* not extractable as vector data) */
export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
</svg>
)
/** ic_ds_question_outline_14 (figma extract): ring + question glyph. */
export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -10,6 +10,7 @@ export { Pill } from './Pill.tsx'
export { Input } from './Input.tsx'
export { Menu } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
@@ -17,8 +18,14 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
export * from './icons/index.tsx'

View File

@@ -74,6 +74,10 @@
white-space: pre-wrap;
word-break: break-all;
background: var(--dsw-alias-markdown-code-block);
/* Bottom radii live on <pre>: overflow:hidden on .block would kill the
sticky banner, and this opaque fill otherwise squares off the wrapper. */
border-bottom-left-radius: var(--dsl-code-block-border-radius);
border-bottom-right-radius: var(--dsl-code-block-border-radius);
}
/* Shiki inlines its theme background var; route it to the repo token. */

View File

@@ -6,6 +6,7 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
@@ -18,45 +19,6 @@ export interface CodeBlockProps {
className?: string | undefined
}
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])

View File

@@ -0,0 +1,124 @@
/**
* Markdown-to-plain-text projection for compact summaries and labels.
* Parsing shares the renderer's GFM grammar; raw HTML stays literal, links
* keep their labels, images keep alt text, and code keeps its source text.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
/** Amount of parsed Markdown content returned by the extractor. */
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
/** Options for {@link extractMarkdownPlainText}. */
export interface MarkdownPlainTextOptions {
/** Projection boundary; defaults to the complete document. */
mode?: MarkdownPlainTextMode
}
interface MarkdownNode {
type: string
value?: string
alt?: string
children?: MarkdownNode[]
}
function inlineText(node: MarkdownNode): string {
switch (node.type) {
case 'text':
case 'inlineCode':
case 'code':
return node.value ?? ''
case 'image':
case 'imageReference':
return node.alt ?? ''
case 'break':
return '\n'
case 'html':
return node.value ?? ''
default:
return node.children?.map(inlineText).join('') ?? ''
}
}
function compactInline(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
function blockText(node: MarkdownNode): string {
switch (node.type) {
case 'root':
case 'blockquote':
return node.children?.map(blockText).filter(Boolean).join('\n\n') ?? ''
case 'paragraph':
case 'heading':
return compactInline(inlineText(node))
case 'code':
return node.value?.trim() ?? ''
case 'list':
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
case 'listItem':
return node.children?.map(blockText).filter(Boolean).join(' ') ?? ''
case 'table':
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
case 'tableRow':
return node.children?.map(blockText).join('\t') ?? ''
case 'tableCell':
return compactInline(inlineText(node))
case 'html':
return node.value ?? ''
case 'thematicBreak':
case 'definition':
return ''
default:
return compactInline(inlineText(node))
}
}
function findFirstParagraph(node: MarkdownNode): string | undefined {
if (node.type === 'paragraph') {
const text = compactInline(inlineText(node))
if (text !== '') return text
}
for (const child of node.children ?? []) {
const text = findFirstParagraph(child)
if (text !== undefined) return text
}
return undefined
}
function fullText(root: MarkdownNode): string {
return blockText(root)
.split('\n')
.map(line => line.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/**
* Parse GFM Markdown, remove its presentation markup, and preserve raw HTML literally.
* @param markdown - Markdown source.
* @param options - Optional extraction boundary.
* @returns Plain text for the whole document, first visible line, or first semantic paragraph.
*/
export function extractMarkdownPlainText(
markdown: string,
options: MarkdownPlainTextOptions = {},
): string {
const { mode = 'all' } = options
const root = fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
}) as MarkdownNode
const all = fullText(root)
switch (mode) {
case 'all':
return all
case 'first-line':
return all.split('\n').find(line => line !== '') ?? ''
case 'first-paragraph':
return findFirstParagraph(root) ?? all.split('\n').find(line => line !== '') ?? ''
}
}

View File

@@ -0,0 +1,38 @@
/**
* Viewport-fit hook for bottom-anchored overlays (slash menu, popupSelect):
* the element's bottom edge is laid out independent of its height, so it
* grows upward and only the top edge can collide with the viewport — clamp
* the design cap to the space between that edge and the viewport top.
*/
import { useLayoutEffect, useState } from 'react'
import type { RefObject } from 'react'
/** Safe distance kept between the overlay and the viewport top edge (mirrors the Menu portal margin). */
const MARGIN = 12
/**
* Clamp a bottom-anchored overlay's max-height to the viewport.
* @param ref - the overlay element; a null current (overlay closed) skips measuring.
* @param cap - design max-height in px (the clamp never exceeds it).
* @param signal - re-measure trigger: pass the overlay's render state so anchor
* moves (composer growth) re-fit; resize/scroll re-fit while mounted.
* @returns the max-height to apply inline, in px.
*/
export function useAnchoredMaxHeight(ref: RefObject<HTMLElement>, cap: number, signal: unknown): number {
const [maxHeight, setMaxHeight] = useState(cap)
useLayoutEffect(() => {
const el = ref.current
if (el === null) return
const fit = () => {
setMaxHeight(Math.min(cap, Math.max(0, el.getBoundingClientRect().bottom - MARGIN)))
}
fit()
window.addEventListener('resize', fit)
window.addEventListener('scroll', fit, true)
return () => {
window.removeEventListener('resize', fit)
window.removeEventListener('scroll', fit, true)
}
}, [ref, cap, signal])
return maxHeight
}