`<lexxy-prompt>`に`only-at`属性とコンテンツタイプゲートの迂回を追加

basecamp/lexxy

<lexxy-prompt>に2つの機能が追加された。トリガー文字を発火させる位置を正規表現で制御するonly-at属性と、insert-editable-textプロンプトがエディタのアタッチメント制限に関わらず常に登録されるよう#promptContentTypePermittedゲートを短絡させる修正だ。

背景

この変更はBC4(Basecamp 4)のボット選択プロンプトの実装要件から生まれた。ボット選択プロンプトでは!がチャット行の先頭にある場合にのみ発火し、かつAttachment plumbingを使わずにプレーンテキストとして挿入する必要がある。

既存の実装には2つの制約があった。第一に、トリガーの発火条件がハードコードされており、「行頭またはスペース・改行の直後」という固定ルールしか持てなかった。第二に、insert-editable-text属性を持つプロンプトであっても#promptContentTypePermittedのチェックを通過する必要があり、エディタがpermitted-attachment-typesを制限していたりattachments="false"を設定していたりすると、トリガーリスナーが登録されずサイレントに失敗していた。

技術的な変更

only-at属性の実装はsrc/elements/prompt.jssrc/helpers/lexical_helper.jsにまたがる。

変更前の実装では、トリガー検出ロジックが現在のテキストノード内の文字列のみを参照していた。トリガーの直前の1文字を調べ、スペース・改行であるかをチェックするシンプルな実装だった:

// 変更前
const isAtStart = offset === triggerLength
const charBeforeTrigger = offset > triggerLength ? fullText[offset - triggerLength - 1] : null
const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n"

if (isAtStart || isPrecededBySpaceOrNewline) {
  this.#popoverListeners.dispose()
  this.#showPopover()
}

変更後は、ドキュメント全体のトリガー位置までのテキストを取得し、only-at属性の正規表現に対してマッチを試みる:

// 変更後
const DEFAULT_ONLY_AT_PATTERN = "^|[ \\n]"

// ...

const textBeforeTrigger = $textBeforeOffset(node, offset - triggerLength)

if (this.#onlyAtRegExp.test(textBeforeTrigger)) {
  this.#popoverListeners.dispose()
  this.#showPopover()
}

このアプローチのキーとなるのが、新たに追加された $textBeforeOffset 関数だ。src/helpers/lexical_helper.jsに実装されており、Lexicalのノードツリーを深さ優先で走査してtargetNodeoffset位置までのテキストを収集する。非インライン要素の境界では`

を挿入することで、LexicalのElementNodeのgetTextContent()`の動作と一致させている:

export function $textBeforeOffset(targetNode, offset) {
  const parts = []
  let done = false

  function visit(node) {
    if (done) return
    if (node === targetNode) {
      parts.push(node.getTextContent().slice(0, offset))
      done = true
      return
    }
    if ($isElementNode(node)) {
      const children = node.getChildren()
      for (let i = 0; i < children.length; i++) {
        visit(children[i])
        if (done) return
        const child = children[i]
        if ($isElementNode(child) && !child.isInline() && i < children.length - 1) {
          parts.push("\n\n")
        }
      }
    } else {
      parts.push(node.getTextContent())
    }
  }

  visit($getRoot())
  return parts.join("")
}

デフォルト値の DEFAULT_ONLY_AT_PATTERN^|[ \n])はパターン末尾に自動的にアンカーされるため、only-atを設定しない既存の<lexxy-prompt>の動作は変わらない。

#promptContentTypePermittedの短絡については、insert-editable-text属性が存在する場合に早期リターンする分岐を追加したとPR説明に記載されている。アタッチメントが作成されないプロンプトに対して、エディタのアタッチメント許可チェックをバイパスする。

only-at属性が取り得るパターンの例を以下に示す:

  • only-at="^":ドキュメントの絶対先頭のみで発火(別段落の先頭には反応しない)
  • only-at=".*":任意の位置で発火(単語の途中でも#が使えるハッシュタグ用途)
  • 省略時:デフォルトの^|[ \n]が適用され、行頭または空白・改行後に発火

設計判断

ドキュメント全体を参照するアーキテクチャが採用された点が重要な設計判断だ。以前の実装は現在のテキストノード内だけを参照していたため、複数段落にまたがるコンテキストを判定できなかった。$textBeforeOffsetがLexicalのノードツリーを$getRoot()から走査し、段落境界を`

`として正規化することで、正規表現に渡されるテキストがエディタ全体の内容を正確に反映する。

正規表現の末尾アンカーも意識的な選択だ。ユーザーが「直前に何があるべきか」だけを記述すれば済むように設計されており、^という1文字のパターンで「ドキュメントの最初の文字」という条件が自然に表現できる。only-at="^"のとき、2段落目の先頭で!を打っても発火しないのは、渡されるテキストが`"hello

"であり^(末尾アンカー付き)にマッチしないためだ。これはPlaywrightテストのonly_at.test.js`でも明示的に検証されている。

insert-editable-textの短絡は、責務の分離という観点からも整合している。アタッチメントを作成しないプロンプトに対してアタッチメントタイプのゲートチェックを通過させるのは設計上の矛盾であり、今回の修正はその不整合を解消している。

まとめ

only-at属性は、トリガーの発火条件をノードレベルからドキュメント全体の文脈へと引き上げることで、@メンション・ボット選択・ハッシュタグといった多様なユースケースを単一の設定インターフェースで表現できるようにした。insert-editable-textの短絡修正と合わせて、アタッチメント非対応のエディタ環境でもテキスト挿入型プロンプトが正しく機能するようになった。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
afb3ac6d

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)」の構成が明確で、ガイドラインの要件を完全に満たしています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(言語:ファイルパス)とGitHubのPRリンク記法(#付き)が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Lexicalや正規表現に関する専門的な内容を前提としており、対象読者であるエンジニアに適した技術レベルと表現で書かれています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論・各論で構成され、各段落はトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が非常によく守られており、構成が明快です。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロック(prompt.js, lexical_helper.js)は、提供されたDiffの内容と正確に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「ノードツリー」「末尾アンカー」「責務の分離」など、技術用語が文脈に応じて正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「$textBeforeOffset」関数の動作や「#promptContentTypePermitted」の短絡ロジックなど、技術的な説明はPRの内容とコードに基づいており、正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(BC4の要件、既存実装の制約、Playwrightテストでの検証など)は、PRのDescriptionやDiff内容で裏付けられており、ハルシネーションは見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号「#1073」や、コード内の定数・属性名などの固有名詞はすべて正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルは、PRの主要な変更点である「only-at属性」と、もう一つの重要な変更点である「コンテンツタイプゲートの迂回」を両方含んでおり、内容を正確かつ包括的に表現しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報にないバージョンサポート状況やリリース日程などの外部知識の持ち込みはなく、記事の信頼性が保たれています。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「変更前」「既存の実装」といった時間表現は正確であり、PRの内容との矛盾はありません。