Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00
..

@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-level richText/ directory so we can refactor from the current code instead of redesigning from memory.
  • src/core/richTextDocument.ts is 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, and types remain 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:

  1. Promote host-agnostic document helpers from ported-source into core.
  2. Define a stable plugin contract for @, #, and future inline token triggers.
  3. Rebuild editor wrappers around injected host adapters instead of app-local imports.
  4. 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:

  • providerId identifies which host capability owns the token
  • entityId is the durable identity and must not depend on visible copy
  • label is the last rendered fallback text so readonly and indexing can still work without a roundtrip
  • scope holds short identity fields needed to locate the entity
  • presentation is 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>;
  getItemDirectory?: (
    item: TItem
  ) => RichTextTriggerDirectoryDescriptor | null | undefined;
  queryDirectory?: (
    input: RichTextTriggerDirectoryQueryInput
  ) => Promise<readonly TItem[]> | readonly 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:

  • query decides what a trigger can mention
  • queryGroups optionally returns provider-owned groups with stable ids, labels, query-scoped totals, and independent cursors; queryGroupPage appends one cursor page without re-querying sibling groups
  • getItemDirectory and queryDirectory are an optional hierarchy contract: the descriptor exposes a provider-owned canonical path and optional direct child count, while the query lists only that directory's direct children
  • getItemLabel and getItemSubtitle decide the suggestion copy
  • toInsertResult maps a chosen item into a mention, markdown-link, or text insertion
  • resolveMention restores editor-only label or presentation data from the stored mention identity
  • readonly conversation surfaces pass the same providers to RichTextReadonlyContent.triggerProviders; it calls resolveMention to 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

Directory navigation is opt-in at the editor boundary. Omitting palette.directoryNavigation preserves the ordinary ranked mention palette, even when a registered provider supports hierarchy. When enabled, an empty trigger browses the provider root, the row body still inserts a folder reference, and the row's hierarchy action or ArrowRight enters it. ArrowLeft and the header back action return to the parent. Non-empty queries continue to use ranked query() results.

The editor only renders hierarchy hints and actions when the configured provider actually implements queryDirectory; older hosts therefore keep a clean flat-search fallback. Starting a keyword search, changing the directory provider, closing the query, or pressing Escape clears the browse stack. Directory failures remain an explicit localized error state rather than being presented as an authoritative empty directory.

<RichTextTriggerEditor
  value={draft}
  onChange={setDraft}
  palette={{
    categories: [{ id: "file", label: labels.file, providerIds: ["file"] }],
    defaultCategoryId: "file",
    labels: {
      tabHint: labels.tabHint,
      cycleFilter: labels.cycleFilter,
      moveSelection: labels.moveSelection
    },
    directoryNavigation: {
      providerId: "file",
      labels: {
        back: labels.back,
        enter: labels.enter,
        navigateHierarchy: labels.navigateHierarchy
      }
    }
  }}
/>

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:

  • createRichTextMentionPlugin
  • createRichTextMentionAttrs
  • createRichTextMentionRegistry
  • createRichTextLinkMarkdown
  • getRichTextMentionDisplayText
  • isRichTextMentionAttrs
  • normalizeRichTextContent
  • resolveRichTextMentionView

Runtime surfaces now exported:

  • RichTextTriggerEditor
  • RichTextTriggerTextarea
  • RichTextMentionReadonly

Current runtime behavior:

  • the registry aggregates multiple trigger providers in declaration order
  • query results are flattened into a shared result shape
  • mention hydration uses resolveMention when the owning trigger provider is available and keeps the label-only fallback when it is not
  • RichTextTriggerEditor anchors trigger menus below the cursor by default; hosts can set menuPlacement to bottom-start, top-start, or auto-start and adjust the gap with menuOffset; hosts that want AgentGUI-style panels aligned to the editor surface can set menuAnchor to editor
  • RichTextTriggerEditor renders a flat result menu by default; hosts that need grouped AgentGUI-style tabs can pass the optional palette prop with MentionPaletteCategoryConfig[], 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:

  1. Install the package and load the panel CSS once from the app entry point:

    import "@tutti-os/ui-rich-text/at-panel/index.css";
    
  2. Provide one or more RichTextTriggerProviders for the app's mentionable domains. Host-provided Tutti workspace mentions can come from createTuttiExternalAtRichTextTriggerProviders; 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 }
          }
        })
      }
    ];
    
  3. 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.

  4. Define palette categories in the app. A category is the top-level tab/filter; optional sections become 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"]
      }
    ];
    
  5. 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)
          })
        )
      }
    />;
    
  6. 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.

  7. 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.