日曜日, 7月 27, 2025
日曜日, 7月 27, 2025
- Advertisment -
ホームニューステックニュースWeb 上で Cursor や Copilot のようなタブ入力補完を実装する

Web 上で Cursor や Copilot のようなタブ入力補完を実装する


Cursor や Copilot、便利ですよね! これらのツールが提供する大きな機能として入力補完が挙げられ、人間が雑に書いたコードを修正してくれたり、ある箇所を修正すると他の箇所もまとめて修正してくれたりします。また、複雑なショートカットを必要とせず、それらの提案を Tab キーの一押しで適用/棄却できる点も優れた UX であると感じています。

さて、このような機能が他の様々なエディタ、特に Web サイトの入力フォームに実装されれば大変有用だと感じたため、React を用いて入力補完を再現してみることにしました。やや複雑 GUI みがあったので、その際の実装の知見をご紹介します。

https://x.com/kyoto_inaniwa/status/1931508213083680857

ユーザがフォームに入力を進めていくと、適宜 Cursor 風に補完内容が提案されます。ユーザは、Tab キーを押すことでその提案を適用するか、あるいは提案を棄却して入力を続行することができます。以下の GIF は、今回実装したフォームを利用して、React + styled-components によるコーディングを行うサンプルです。

また、以下のリンクから直接試すこともできます(OpenAI の API キーが必要です)。

https://inaniwaudon.github.io/tab-autocomplete-form/

以下のリポジトリに実装を公開しています。技術スタックは Vite + React + TS といつもの構成です。本記事では、特に重要そうな部分を抜粋して紹介します。

https://github.com/inaniwaudon/tab-autocomplete-form

実装方針として、過去の一点におけるテキストボックスの内容(prevValue)と、現在の内容(value)を API に投げ、その内容に基づいて補完内容を LLM に出力してもらうことにします。

一方、現行の Web ではユーザからの入力と、補完内容の提案(複数のスタイリングが要求される)を同一の要素で表現することは困難です。そこで、ユーザ入力時には単なる Textarea を使用し、補完内容の提案時には Textarea の上にオーバーレイ(Overlay)を表示して、その中に補完内容を表示するようにします。補完提案後は、ユーザのキー入力に応じて補完内容を適用/棄却した後、再度 Textarea を表示します。


人間が書いた温かみのあるチャート

コンポーネント定義

手始めに、以下のような React コンポーネントを定義します。

InputWrapper>
  Textarea value={value} ref={textareaRef} onChange={onChange} onKeyDown={onKeyDown} />
  Overlay complements={complements} selectionStart={selectionStart} />
InputWrapper>

入力検知

Textarea の内容が変更された際(onChange)には、通常のフォーム実装と同様に入力内容を value に記録します。また、最後の入力から一定時間(今回は 500 ms)経過後に、後述する complement 関数を呼ぶようにします。complement 関数の実行前に再度 onChange が呼ばれた場合は clearTimeout を通じて重複実行を防ぎます。

const onChange = (e: React.ChangeEventHTMLTextAreaElement>) => {
  const newValue = e.target.value;
  setValue(newValue);
  if (complementId) clearTimeout(complementId);
  if (!disabledChange) setDisabledChange(true);
  if (newValue.trim().length === 0) {
    setPrevValue("");
    return;
  }
  const id = setTimeout(() => complement(newValue), 500);
  setComplementId(id);
};

キー操作の検知

キー操作(onKeyDown)も同様に捕捉します。この際、Command/Ctrl + S が押された場合は現在の入力内容を prevValue に保存します。また、補完提案中に Tab キーが押された場合は、その内容を value に適用した後、以降 300 ms は次の補完を実行しないようにします。カーソル移動以外の操作が行われた場合は補完を中断するとともに、現在の入力内容を prevValue に保存します。

const onKeyDown = (e: React.KeyboardEventHTMLTextAreaElement>) => {
  if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
    setPrevValue(value);
    e.preventDefault();
  }
  
  if (complements) {
    if (e.key === "Tab") {
      setValue(complements.flatMap((c) => (!c.removed ? c.value : [])).join(""));
      setDisabledChange(true);
      setTimeout(() => setDisabledChange(false), 300);
      
      setTimeout(() => {
        if (textareaRef.current) {
          textareaRef.current.selectionStart = selectionStart;
          textareaRef.current.selectionEnd = selectionStart;
        }
      }, 10);
      e.preventDefault();
    }
    const moves = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key);
    if (!moves) {
      setPrevValue(value);
      setComplements(null);
    }
  }
};

カーソル操作の検知

カーソルの操作も併せて記録します。モダンブラウザでは textarea 要素に対して selectionchange イベントを捕捉できるようになっていますが、React 19 ではまだ対応していなかったため、addEventListener 経由で登録します。

const onSelectionChange: EventListenerOrEventListenerObject = (e) => {
  if (e.target instanceof HTMLTextAreaElement)
    setSelectionStart(e.target.selectionStart);
}

useEffect(() => {
  textareaRef.current?.addEventListener("selectionchange", onSelectionChange);
  return () => {
    textareaRef.current?.removeEventListener("selectionchange", onSelectionChange);
  };
}, []);

変更予測

previousValue, value の内容を基にプロンプトを作成します。これを OpenAI API(GPT4.1-mini)に投げて、レスポンスと現在の入力内容が異なれば補完を提案します。その後、jsdiff を用いて取得した単語毎の入力内容との差分を complements に保存します。

const complement = (newValue: string) => {
  (async () => {
    
    const response = await fetchGPT(prevValue, newValue, openAiApiKey);
    const matched = response.match(/```[a-zA-Z0-9]*\n(.*)\n```/s)?.[1];
    
    if (
      matched &&
      matched.trim().length > 0 &&
      matched.trim() !== newValue.trim()
    ) {
      setComplements(diffWordsWithSpace(newValue, matched));
    }
  })();
};

const getPropmt = (previous: string, value: string) => {
  const systemPrompt = `I will provide you with some previous content and the current one, so predict future changes based on them.
Mainly correct any typos, format content, and refactor the code.
If there is a comment, follow the instruction in the comment.
Output only all the content after changed.
`;
  const userPrompt = `## Previous
\`\`\`
${previous}
\`\`\`\

## Current
\`\`\`
${current}
\`\`\`
`;
  return [
    { role: "system", content: systemPrompt },
    { role: "user", content: userPrompt },
  ];
};

オーバーレイの表示

jsdiff の出力に基づいて 追加/削除/差分なし の 3 つに分けて出力を行います ――とこれ自体は簡単なのですが、カーソルの実装が問題です。Cusor では、補完提案中もカーソルを操作することができますが、この際、カーソルは補完箇所をスキップするようになっています。これを実装します。

補完箇所をスキップするカーソル操作のスクリーンショット
カーソルの動きに注目

ここで、元の入力内容と、補完提案後の内容における位置関係の対応を考えます。diff において、削除されたトークンと、追加されたトークンは 1 対 1 の関係になります。ゆえに jsdiff の出力内容について、削除されたトークンに対して 1、追加されたトークンに対して 0、その他トークンに対して元のトークン長を加算していったときの textarea 上のカーソル位置に対する位置を求めます。この結果を convertedSelectionStart とします。

 const convertedSelectionStart = useMemo(() => {
  
  if (!complements) {
    return selectionStart;
  }

  
  let [orgX, newX] = [0, 0];
  
  for (let i = 0; i  complements.length; i++) {
    const c = complements[i];
    
    if (orgX  selectionStart && selectionStart  orgX + c.value.length) {
      return x + selectionStart - orgX;
    }
    
    if (!c.added) {
      orgX += c.value.length;
    }
    
    if (c.removed) {
      x += 1;
    } else if (!c.added) {
      x += c.value.length;
    }
  }
  return x;
}, [selectionStart, complements]);

あとは、convertedSelectionStart の位置に基づいて、オーバーレイ上に擬似的なカーソルを表示すれば大丈夫です。今度は追加/削除されたトークンの長さを 0、その他の長さについては元のトークン長とみなします。

const Overlay = ({ complements, selectionStart }: OverlayProps) => {
  const convertedSelectionStart = useMemo(() => ...);

  return (
    Wrapper>
      {(() => {
        const elements: React.ReactNode[] = [];
        let x = 0;
        for (let i = 0; i  complements.length; i++) {
          const c = complements[i];
          const length = c.added || c.removed ? 0 : c.value.length;

          let content = >{c.value}>;
          if (x  convertedSelectionStart && convertedSelectionStart  x + length) {
            const index = convertedSelectionStart - x;
            content = (
              >
                {c.value.slice(0, index)}
                Cursor>Cursor>
                {c.value.slice(index)}
              >
            );
          }
          if (c.added) {
            elements.push(Added key={i}>{content}Added>);
          } else if (c.removed) {
            elements.push(Removed key={i}>{content}Removed>);
          } else {
            elements.push(React.Fragment key={i}>{content}React.Fragment>);
          }
          if (!c.removed) {
            x += length;
          }
        }
        return elements;
      })()}
    Wrapper>
  );
};

以上により、一通りの機能が実装できました。他にも undo/redo やタブ機能等が欲しいところですが、本記事の範疇から外れるので今回は省略します。

いかがでしたか? 実用に供するにはもう少し作り込みが必要ですが、雑な実装でも一通り動くことが示せたので、実装コストは比較的低いと思われます。ぜひ様々なサイトで普及することを願います。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -