@tutti-os/ui-rich-text
v0.0.254
Published
Host-agnostic rich text foundations for Tutti frontend packages.
Keywords
Readme
@tutti-os/ui-rich-text
Host-agnostic rich text foundations for Tutti frontend packages.
This package is the new home for the repository's rich text work. It is intended to own:
- document normalization and plain-text extraction
- generic markdown-link helpers and mention-link serialization
- editor and readonly surfaces
- plugin and mention runtime contracts
- rich text extension registration
This package should not own workspace-domain semantics such as /workspace/...
path policy, workspace-file markdown meaning, host file lookup, or product
workflow-specific reference rules. Those stay with the owning workspace-domain
package or host adapter.
Current migration status:
src/internal/ported-source/*is a direct snapshot of the old top-levelrichText/directory so we can refactor from the current code instead of redesigning from memory.src/core/richTextDocument.tsis the first promoted, host-agnostic surface extracted from that snapshot.- editor wrappers and current node extensions are intentionally not public yet because they still depend on app-specific imports and legacy host seams.
- the package root export is intentionally narrow;
core,editor,plugins, andtypesremain the explicit public subpaths
Known transitional seam:
- current editor and readonly surfaces still embed workspace-reference semantics
such as
/workspace/...link handling and workspace reference presentation - treat that behavior as transitional implementation, not as the intended
public contract of
@tutti-os/ui-rich-text - before adding another host-specific inline reference protocol here, stop and re-evaluate the generic rich-text reference seam across real consumers
Current refactor plan:
- Promote host-agnostic document helpers from
ported-sourceintocore. - Define a stable plugin contract for
@,#, and future inline token triggers. - Rebuild editor wrappers around injected host adapters instead of app-local imports.
- Keep domain-specific reference protocols in their owning packages and only promote the generic rich-text seam here when it is truly host-agnostic.
Mention Protocol
The stable @ mention storage protocol is provider-agnostic:
[@Label](mention://provider-id/entity-id?workspaceId=ws_1)Boundary split:
- the editor core owns trigger detection, selection state, keyboard handling, insertion lifecycle, and storage shape
- the host trigger provider owns query behavior, suggestion copy, insert mapping, and reverse resolution
Stable stored attrs:
interface RichTextMentionAttrs {
trigger: "@";
providerId: string;
entityId: string;
label: string;
scope?: Readonly<Record<string, string>>;
presentation?: RichTextMentionPresentation;
}Why this shape:
providerIdidentifies which host capability owns the tokenentityIdis the durable identity and must not depend on visible copylabelis the last rendered fallback text so readonly and indexing can still work without a roundtripscopeholds short identity fields needed to locate the entitypresentationis editor-only display data and is not serialized to Markdown
Trigger provider contract:
interface RichTextTriggerProvider<TItem = unknown> {
id: string;
trigger: RichTextTrigger;
boundary?: RichTextTriggerBoundary;
query: (
input: RichTextTriggerQueryInput
) => Promise<readonly TItem[]> | readonly TItem[];
queryGroups?: (
input: RichTextTriggerQueryInput
) =>
| Promise<RichTextTriggerGroupedQueryResult<TItem>>
| RichTextTriggerGroupedQueryResult<TItem>;
queryGroupPage?: (
input: RichTextTriggerGroupPageQueryInput
) =>
| Promise<RichTextTriggerQueryGroup<TItem>>
| RichTextTriggerQueryGroup<TItem>;
getItemKey: (item: TItem) => string;
getItemLabel: (item: TItem) => string;
getItemSubtitle?: (item: TItem) => string | null | undefined;
getItemIconUrl?: (
item: TItem
) => string | null | undefined | Promise<string | null | undefined>;
getItemKeywords?: (item: TItem) => readonly string[] | undefined;
toInsertResult: (item: TItem) => RichTextTriggerInsertResult;
resolveMention?: (
identity: RichTextMentionIdentity
) => Promise<RichTextMentionResolved | null> | RichTextMentionResolved | null;
}Interpretation:
querydecides what a trigger can mentionqueryGroupsoptionally returns provider-owned groups with stable ids, labels, query-scoped totals, and independent cursors;queryGroupPageappends one cursor page without re-querying sibling groupsgetItemLabelandgetItemSubtitledecide the suggestion copytoInsertResultmaps a chosen item into a mention, markdown-link, or text insertionresolveMentionrestores editor-only label or presentation data from the stored mention identity- readonly conversation surfaces pass the same providers to
RichTextReadonlyContent.triggerProviders; it callsresolveMentionto restore presentation such as app icons while keeping serialized Markdown limited to durable identity and scope - group ids, labels, totals, and cursors are candidate-panel metadata only; they must not be copied into mention identity, href, or persisted scope
Mention data:
interface RichTextMentionInsert {
entityId: string;
label: string;
scope?: Readonly<Record<string, string>>;
presentation?: RichTextMentionPresentation;
}
interface RichTextMentionResolved {
label?: string;
presentation?: RichTextMentionPresentation;
}Markdown serialization includes only providerId, entityId, label, and
short scope fields. It does not serialize presentation, href, kind,
version, or arbitrary metadata.
Mention Service
RichTextMentionService is the host-agnostic owner of provider lookup, query,
reverse resolution, presentation caching, invalidation, and subscriptions.
Create one service at the host or app root and reuse it across composer,
readonly conversation, preview, and AgentGUI surfaces:
import {
RichTextMentionServiceProvider,
RichTextReadonlyContent,
RichTextTriggerEditor
} from "@tutti-os/ui-rich-text/editor";
import { createRichTextMentionService } from "@tutti-os/ui-rich-text/service";
const mentionService = createRichTextMentionService({ providers });
<RichTextMentionServiceProvider service={mentionService}>
<RichTextTriggerEditor value={draft} onChange={setDraft} />
<RichTextReadonlyContent value={savedMarkdown} />
</RichTextMentionServiceProvider>;
// Dispose when the owning host/workspace root is destroyed.
mentionService.dispose();Non-React consumers use the same instance directly through query, resolve,
getSnapshot, invalidate, and subscribe. The React Provider never creates
a network client or a global singleton; it only injects an existing service.
Resolution precedence is fixed: an explicit mentionService prop wins, then
the nearest RichTextMentionServiceProvider, then the deprecated
triggerProviders compatibility path. If no resolver exists, persisted label
text and the semantic MentionPill icon remain available.
The service uses a normalized providerId + entityId + canonical scope key.
Labels and presentation URLs are deliberately excluded. Ready entries are
cached for 5 minutes, missing entries for 30 seconds, and errors retry after 5
seconds. The cache is capped at 1000 identities, concurrent resolution is
single-flight, and stale ready presentation remains visible during refresh.
Explicit invalidation may target all entries, a provider, a workspace, or an
entity.
Never persist a resolved snapshot or copy presentation into Markdown. Icon
URLs and runtime cache state stay in memory; durable mention content remains
limited to provider identity, entity identity, fallback label, and short scope.
Helpers now exported:
createRichTextMentionPlugincreateRichTextMentionAttrscreateRichTextMentionRegistrycreateRichTextLinkMarkdowngetRichTextMentionDisplayTextisRichTextMentionAttrsnormalizeRichTextContentresolveRichTextMentionView
Runtime surfaces now exported:
RichTextTriggerEditorRichTextTriggerTextareaRichTextMentionReadonly
Current runtime behavior:
- the registry aggregates multiple trigger providers in declaration order
- query results are flattened into a shared result shape
- mention hydration uses
resolveMentionwhen the owning trigger provider is available and keeps the label-only fallback when it is not RichTextTriggerEditoranchors trigger menus below the cursor by default; hosts can setmenuPlacementtobottom-start,top-start, orauto-startand adjust the gap withmenuOffset; hosts that want AgentGUI-style panels aligned to the editor surface can setmenuAnchortoeditorRichTextTriggerEditorrenders a flat result menu by default; hosts that need grouped AgentGUI-style tabs can pass the optionalpaletteprop withMentionPaletteCategoryConfig[], labels, and a default category while keeping query providers and insertion side effects host-owned
External At-Panel Integration
External apps should treat @tutti-os/ui-rich-text as the generic trigger and
palette shell, not as an app-domain data source. The app still owns what can be
mentioned, how those items are queried, and what gets inserted.
Tutti workspace apps that already receive mention candidates from
window.tuttiExternal.at.query() should adapt that bridge through
@tutti-os/workspace-external-core/rich-text:
import { createTuttiExternalRichTextMentionService } from "@tutti-os/workspace-external-core/rich-text";
const mentionService = createTuttiExternalRichTextMentionService({
getBridge: () => window.tuttiExternal,
providerIds: ["workspace-app", "agent-session"]
});The older trigger-provider factory remains available for compatibility, but a root-owned service is the preferred integration because editor and readonly surfaces share resolution, caching, and invalidation automatically.
Use a custom RichTextTriggerProvider only for app-local mention sources or for
apps that do not use the Tutti external bridge.
Minimum integration checklist:
Install the package and load the panel CSS once from the app entry point:
import "@tutti-os/ui-rich-text/at-panel/index.css";Provide one or more
RichTextTriggerProviders for the app's mentionable domains. Host-provided Tutti workspace mentions can come fromcreateTuttiExternalAtRichTextTriggerProviders; app-local domains can define providers directly. A provider owns querying, stable keys, visible labels, optional subtitles/icons/keywords, insertion mapping, and optional reverse resolution:import type { RichTextTriggerProvider } from "@tutti-os/ui-rich-text/types"; const providers: RichTextTriggerProvider[] = [ { id: "primary-record", trigger: "@", query: async ({ keyword, context }) => searchRecords(keyword, context), getItemKey: (record) => record.id, getItemLabel: (record) => record.title, getItemSubtitle: (record) => record.subtitle, getItemIconUrl: (record) => record.iconUrl, toInsertResult: (record) => ({ kind: "mention", mention: { entityId: record.id, label: record.title, scope: { ownerId: record.ownerId } } }) } ];Query those providers through the rich-text trigger registry or an equivalent host bridge and keep the results as
RichTextTriggerQueryMatch[]. Provider ordering remains host-owned. The palette only renders the matches it is given.Define palette categories in the app. A category is the top-level tab/filter; optional
sectionsbecome second-level groups inside the active category. When sections are present, each match is assigned to the first matching section in declaration order. A category without sections renders as a single group:import type { MentionPaletteCategoryConfig } from "@tutti-os/ui-rich-text/at-panel"; const categories: MentionPaletteCategoryConfig[] = [ { id: "primary", label: t("mentions.primary"), providerIds: ["primary-record"], sections: [ { id: "recent", label: t("mentions.recent"), matches: (match) => match.item.bucket === "recent" }, { id: "all", label: t("mentions.all"), matches: (match) => match.item.bucket !== "recent" } ] }, { id: "secondary", label: t("mentions.secondary"), providerIds: ["secondary-record"] } ];Convert matches into palette state and render the shared shell:
import { MentionPaletteFromState, buildMentionPaletteModelFromTriggerMatches, renderMentionRow, richTextTriggerQueryMatchToMentionRowItem } from "@tutti-os/ui-rich-text/at-panel"; const state = buildMentionPaletteModelFromTriggerMatches({ activeCategoryId, categories, matches, loading, query }); <MentionPaletteFromState state={state} highlightedKey={highlightedKey} getItemKey={(match, groupId) => `${match.providerId}:${match.key}`} callbacks={{ onActiveCategoryIdChange: setActiveCategoryId, onHighlightChange: setHighlightedKey, onSelectItem: commitMatch }} labels={{ empty: t("mentions.empty"), loading: t("mentions.loading") }} hintLabels={{ cycleFilter: t("mentions.switchCategory"), moveSelection: t("mentions.switchSelection") }} maxHeightPx={360} renderItem={(match) => renderMentionRow( richTextTriggerQueryMatchToMentionRowItem(match, { getDescription: (candidate) => candidate.subtitle, renderLeading: (ctx) => renderAppSpecificLeading(ctx) }) ) } />;Wire keyboard handling to the state adapter or to
makeAtPanelKeyDown. External apps should keep the exact shortcut policy local; the shared shell supports moving selection, cycling categories, expanding groups, and committing the highlighted item.Keep app-owned behavior outside the package. This includes i18n strings, item-specific icons or avatars, domain data fetches, cache refresh policy, app-local bridge calls, and the final insertion side effects. Use
renderLeading,getDescription, status helpers, and category/section config as customization slots instead of forking the panel shell.
