Repository: Snowflyt/typora-copilot
Branch: main
Commit: df7593492de8
Files: 100
Total size: 658.3 KB
Directory structure:
gitextract_4vih3sn_/
├── .editorconfig
├── .githooks/
│ └── commit-msg
├── .github/
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .vscode/
│ └── settings.json
├── LICENSE
├── README.md
├── README.zh-CN.md
├── bin/
│ ├── install_linux.sh
│ ├── install_macos.sh
│ ├── install_windows.ps1
│ ├── uninstall_linux.sh
│ ├── uninstall_macos.sh
│ └── uninstall_windows.ps1
├── commitlint.config.js
├── eslint.config.js
├── install.ps1
├── install.sh
├── package.json
├── pre-commit.ts
├── prettier.config.cjs
├── rollup.config.ts
├── src/
│ ├── client/
│ │ ├── chat.ts
│ │ ├── client.ts
│ │ ├── general-client.ts
│ │ └── index.ts
│ ├── completion.ts
│ ├── components/
│ │ ├── ChatPanel.scss
│ │ ├── ChatPanel.tsx
│ │ ├── CopilotIcon.tsx
│ │ ├── DropdownWithInput.scss
│ │ ├── DropdownWithInput.tsx
│ │ ├── ModalBody.tsx
│ │ ├── ModalCloseButton.scss
│ │ ├── ModalCloseButton.tsx
│ │ ├── ModalContent.tsx
│ │ ├── ModalOverlay.scss
│ │ ├── ModalOverlay.tsx
│ │ ├── ModalTitle.tsx
│ │ ├── ModelHeader.tsx
│ │ ├── SettingsPanel.tsx
│ │ ├── Spinner.tsx
│ │ ├── SuggestionPanel.scss
│ │ ├── SuggestionPanel.tsx
│ │ ├── Switch.scss
│ │ ├── Switch.tsx
│ │ ├── icons/
│ │ │ ├── index.ts
│ │ │ ├── nodejs.tsx
│ │ │ └── settings.tsx
│ │ └── preact-env.d.ts
│ ├── constants.ts
│ ├── errors/
│ │ ├── CommandError.ts
│ │ ├── NoFreePortError.ts
│ │ ├── PlatformError.ts
│ │ └── index.ts
│ ├── footer.scss
│ ├── footer.tsx
│ ├── global.d.ts
│ ├── i18n/
│ │ ├── en.json
│ │ ├── index.ts
│ │ ├── t.spec.ts
│ │ ├── t.ts
│ │ └── zh-CN.json
│ ├── index.ts
│ ├── logging.ts
│ ├── mac-server.ts
│ ├── main.ts
│ ├── modules/
│ │ ├── fs.ts
│ │ ├── path.spec.ts
│ │ ├── path.ts
│ │ ├── url.spec.ts
│ │ └── url.ts
│ ├── patches/
│ │ ├── index.ts
│ │ ├── jquery.ts
│ │ ├── promise.spec.ts
│ │ ├── promise.ts
│ │ └── typora.ts
│ ├── reset.d.ts
│ ├── settings.ts
│ ├── styles.scss
│ ├── types/
│ │ ├── lsp.ts
│ │ └── tools.ts
│ ├── typora-utils.ts
│ └── utils/
│ ├── cli-tools.ts
│ ├── diff.ts
│ ├── dom.ts
│ ├── function.ts
│ ├── logging.ts
│ ├── lsp.ts
│ ├── node-bridge.ts
│ ├── observable.ts
│ ├── random.ts
│ ├── stream.ts
│ ├── tools.proof.ts
│ └── tools.ts
├── stylelint.config.js
├── test/
│ └── setup.ts
├── tsconfig.build.json
├── tsconfig.json
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,css,scss,json}]
charset = utf-8
indent_style = space
indent_size = 2
================================================
FILE: .githooks/commit-msg
================================================
#!/bin/sh
npx --no -- commitlint --edit "$1"
npx tsx pre-commit.ts
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Test types
run: npm run test-types
- name: Test
run: npm test
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: .vscode/settings.json
================================================
{
"cSpell.words": ["reqnode"],
"stylelint.validate": ["css", "scss"]
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 Snowflyt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Typora Copilot
English | [简体中文](./README.zh-CN.md)

[GitHub Copilot](https://github.com/features/copilot) & [Copilot Chat](https://docs.github.com/copilot/using-github-copilot/copilot-chat) plugin for [Typora](https://typora.io/) on both Windows, macOS and Linux.
This plugin uses the [official GitHub Copilot LSP server](https://www.npmjs.com/package/@github/copilot-language-server) to provide suggestions in real-time right from your editor.
## Compatibility
> [!NOTE]
>
> Since Typora v1.10, all platforms require [Node.js](https://nodejs.org/en/download) ≥ 20 to use this plugin.
>
> (For those special users on Windows / Linux using Typora 1.9, no need for Node.js to be installed. :wink:)
_\*Note: `/` means not tested._
| Typora Version | Windows 11 | Ubuntu 24.04 | macOS 15.x |
| -------------- | ---------- | ------------ | ---------- |
| 1.12.6 | / | / | ✓ |
| 1.12.4 | ✓ | / | / |
| 1.11.7 | ✓ | / | ✓ |
| 1.10.8 | ✓ | ✓ | ✓ |
| 1.10.6 | ✓ | ✓ | ✓ |
| 1.9.5 | ✓ | / | / |
| 1.9.4 | / | / | ✓ |
| 1.9.3 | / | ✓ | / |
| 1.8.10 | ✓ | ✓ | ✓ |
| 1.8.8 | / | ✓ | / |
| 1.8.6 | ✓ | / | / |
| 1.8.5 | ✓ | / | ✓ |
| 1.7.6 | ✓ | / | / |
| 1.6.7 | ✓ | / | / |
| 1.5.12 | ✓ | / | / |
| 1.4.8 | ✓ | / | / |
| 1.3.8 | ✓ | / | / |
| 1.2.5 | ✓ | / | / |
| 1.2.3 | ✓ | / | / |
| 1.0.3 | ✓ | / | / |
| 0.11.18-beta | ✓ | / | / |
## Prerequisites
- Public network connection.
- Active GitHub Copilot subscription.
## Installation
**Before installing using any method, make sure Typora is fully closed (especially on macOS: use ⌘+Q to quit).**
### Automated Installation (Recommended)
To install the plugin, you can just copy and paste the following command into your terminal:
Windows
Run the following command in PowerShell **as administrator**:
```powershell
iwr -Uri "https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/install.ps1" | iex
```
macOS
Run the following command in your terminal:
```bash
curl -fsSL https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/install.sh | sudo bash
```
Linux
Run the following command in your terminal:
```bash
wget -O - https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/install.sh | sudo bash
```
### Script Install
Windows
For Windows users, first download the latest release from [the releases page](https://github.com/Snowflyt/typora-copilot/releases) and unzip it. Then locate to the folder where you unzipped the release and run the following command in PowerShell **as administrator**:
```powershell
.\bin\install_windows.ps1
```
If the script fails to find Typora, you can specify the path to Typora manually:
```powershell
.\bin\install_windows.ps1 -Path "C:\Program Files\Typora\" # Replace with your Typora path
# Or use the alias
# .\bin\install_windows.ps1 -p "C:\Program Files\Typora\" # Replace with your Typora path
```
macOS
For macOS users, first download the latest release from [the releases page](https://github.com/Snowflyt/typora-copilot/releases) and unzip it. Then locate to the folder where you unzipped the release and run the following command in terminal:
```bash
sudo bash ./bin/install_macos.sh
```
If the script fails to find Typora, you can specify the path to Typora manually:
```bash
sudo bash ./bin/install_macos.sh --path "/Applications/Typora.app/" # Replace with your Typora path
# Or use the alias
# sudo bash ./bin/install_macos.sh -p "/Applications/Typora.app/" # Replace with your Typora path
```
You’ll see a message logging the installation directory of the plugin. _Keep it in mind, you’ll need it when uninstalling the plugin._ After that, you can safely delete the release folder.
Linux
For Linux users, first download the latest release from [the releases page](https://github.com/Snowflyt/typora-copilot/releases) and unzip it. THen locate to the folder where you unzipped the release and run the following command in terminal:
```bash
sudo bash ./bin/install_linux.sh
```
If the script fails to find Typora, you can specify the path to Typora manually:
```bash
sudo bash ./bin/install_linux.sh --path "/usr/share/typora/" # Replace with your Typora path
# Or use the alias
# sudo bash ./bin/install_linux.sh -p "/usr/share/typora/" # Replace with your Typora path
```
You’ll see a message logging the installation directory of the plugin. _Keep it in mind, you’ll need it when uninstalling the plugin._ After that, you can safely delete the release folder.
### Manual Install
Click to expand
1. Download the latest release from [the releases page](https://github.com/Snowflyt/typora-copilot/releases) and unzip it.
2. For Windows / Linux users, find `window.html` in your Typora installation folder, usually located at `/resources/`; For macOS users, find `index.html` in your Typora installation folder, usually located at `/Contents/Resources/TypeMark/`. `` is the path where Typora is installed, replace it with your real Typora installation path (note that the angle brackets `<` and `>` should also be removed). This folder is called Typora resource folder in the following steps.
3. Create a folder named `copilot` in Typora resource folder.
4. Copy the contents of the unzipped release into the `copilot` folder. **Ensure the final path is `copilot/index.js` (not `copilot/typora-copilot/index.js`). If you see the latter, move the files up one level so `index.js` sits directly under `copilot`.**
5. For Windows / Linux users, open the previous `window.html` file you found in Typora resource folder with a text editor, and add `` right after something like `` or ``; For macOS users, open the previous `index.html` file you found in Typora resource folder with a text editor, and add `` right after something like `` or ``.
6. Restart Typora.
7. For macOS users, if you see a warning dialog saying Typora may be damaged, Ctrl-click Typora and select “Open” to open Typora.
## Setup
When finished installation, you’ll find an icon in the toolbar of Typora (i.e. the bottom-right corner of Typora). Click **the arrow button** next to the icon to open the panel of Copilot, and then click “Sign in to authenticate Copilot”.

Follow the prompts to authenticate Copilot plugin:
1. The User Code will be auto copied to your clipboard.
2. Follow the instructions on the pop-up dialog to open the GitHub authentication page in your browser.
3. Paste the User Code into the GitHub authentication page.
4. Return to Typora and press OK on the dialog.
5. If you see a “Signed in to Copilot” dialog _after a few seconds_, Copilot plugin should start working since then.
## Copilot Chat
Clicking the Copilot icon in the toolbar will toggle the Copilot Chat panel. You can use it to chat with Copilot, and the current document and previous chat history will be sent to Copilot as context.
Make sure you have signed in to Copilot before using the Copilot Chat panel. After signing in, restart Typora to make sure the Copilot Chat panel works properly.
You can:
- Select, create, edit chat title, or delete a chat session from the dropdown list at the top of the panel.
- Click the “Send” button or press Enter to send the message. (You can use Shift + Enter or Ctrl + Enter to insert a new line.)
- Click the “Stop” button to stop the current request.
- Select a prompt style from the dropdown list at the bottom of the panel.
- Pick the model you want to use from the dropdown list at the bottom of the panel.
## Uninstallation
### Automated Uninstallation (Recommended)
To uninstall the plugin, you can just copy and paste the following command into your terminal:
Windows
Run the following command in PowerShell **as administrator**:
```powershell
iwr -Uri "https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/bin/uninstall_windows.ps1" | iex
```
macOS
Run the following command in your terminal:
```bash
curl -fsSL https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/bin/uninstall_macos.sh | sudo bash
```
Linux
Run the following command in your terminal:
```bash
wget -O - https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/bin/uninstall_linux.sh | sudo bash
```
### Script Uninstall
Windows
For Windows users, locate to the installation directory of the plugin and run the following command in PowerShell **as administrator**.
```powershell
.\bin\uninstall_windows.ps1
```
You can still specify the path to Typora manually by adding `-Path` or `-p`, just like the installation script.
macOS
For macOS users, locate to the installation directory of the plugin and run the following command in terminal.
```bash
sudo bash ./bin/uninstall_macos.sh
```
You can still specify the path to Typora manually by adding `--path` or `-p`, just like the installation script.
Linux
For Linux users, locate to the installation directory of the plugin and run the following command in terminal.
```bash
sudo bash ./bin/uninstall_linux.sh
```
You can still specify the path to Typora manually by adding `--path` or `-p`, just like the installation script.
### Manual Uninstall
Click to expand
1. For Windows / Linux users, find `window.html` in your Typora installation folder, usually located at `/resources/`; For macOS users, find `index.html` in your Typora installation folder, usually located at `/Contents/Resources/TypeMark/`. `` is the path where Typora is installed, replace it with your real Typora installation path (note that the angle brackets `<` and `>` should also be removed). This folder is called Typora resource folder in the following steps.
2. Delete the `copilot` folder in Typora resource folder.
3. For Windows / Linux users, open the previous `window.html` file you found in Typora resource folder with a text editor, and delete ``; For macOS users, open the previous `index.html` file you found in Typora resource folder with a text editor, and delete ``.
4. Restart Typora.
## Known Issues
1. Sometimes accepting a suggestion may cause the editor rerendering (i.e. code blocks, math blocks, etc. will be rerendered). This is due to the limitation of Typora's API that I have to force the editor to rerender sometimes to accept a suggestion, and currently I can't find a more safe and efficient way to resolve this issue.
## FAQs
### How to temporarily disable Copilot?
Just click the Copilot icon in the toolbar, and then click “Disable completions”. You can enable it again by clicking the icon and then clicking “Enable completions”.
### Why use suggestion panel in live preview mode (normal mode) and completion text in source mode by default? Can I change that?
The usage of suggestion panel in live preview mode is intentional. Typora uses a complex mechanism to render the content in live preview mode, it is hard to make completion text work properly in live preview mode.
But it is possible to also use suggestion panel in source mode, you can click the `toolbar icon -> Settings` and toggle the `Use inline completion text in source mode` option.
An option called `Use inline completion text in preview code blocks` is also provided. If you enable this option, the completion text instead of suggestion panel will also be used in code blocks and math blocks in live preview mode. But it is currently not recommended to enable this option, as it is likely to corrupt the editor content or history.
### Can I use keys other than `Tab` to accept suggestions?
Currently, no. It is technically possible, but currently I don't have enough time to implement it. Maybe I will implement it in the future.
================================================
FILE: README.zh-CN.md
================================================
# Typora Copilot
[English](./README.md) | 简体中文

[Typora](https://typora.io/) 的 [GitHub Copilot](https://github.com/features/copilot) & [Copilot Chat](https://docs.github.com/copilot/using-github-copilot/copilot-chat) 插件,支持 Windows、macOS 和 Linux。
该插件使用 [GitHub Copilot 官方提供的 LSP 服务](https://www.npmjs.com/package/@github/copilot-language-server),以在编辑器中实时提供建议。
## 兼容性
> [!NOTE]
>
> 自 Typora v1.10 起,所有平台都需要安装 [Node.js](https://nodejs.org/zh-cn/download) ≥ 20 才能使用本插件。
>
> (仅对于使用 Typora 1.9 的 Windows / Linux 用户,无需安装 Node.js。 :wink:)
_\*注:`/` 表示未经过测试。_
| Typora Version | Windows 11 | Ubuntu 24.04 | macOS 15.x |
| -------------- | ---------- | ------------ | ---------- |
| 1.12.6 | / | / | ✓ |
| 1.12.4 | ✓ | / | / |
| 1.11.7 | ✓ | / | ✓ |
| 1.10.8 | ✓ | ✓ | ✓ |
| 1.10.6 | ✓ | ✓ | ✓ |
| 1.9.5 | ✓ | / | / |
| 1.9.4 | / | / | ✓ |
| 1.9.3 | / | ✓ | / |
| 1.8.10 | ✓ | ✓ | ✓ |
| 1.8.8 | / | ✓ | / |
| 1.8.6 | ✓ | / | / |
| 1.8.5 | ✓ | / | ✓ |
| 1.7.6 | ✓ | / | / |
| 1.6.7 | ✓ | / | / |
| 1.5.12 | ✓ | / | / |
| 1.4.8 | ✓ | / | / |
| 1.3.8 | ✓ | / | / |
| 1.2.5 | ✓ | / | / |
| 1.2.3 | ✓ | / | / |
| 1.0.3 | ✓ | / | / |
| 0.11.18-beta | ✓ | / | / |
## 前置条件
- 公网连接(对于中国大陆用户,你还需要确保你的网络可以正常访问 GitHub Copilot 服务)。
- 已激活的 GitHub Copilot 订阅。
## 安装
**开始任何安装方式之前,请先完全退出 Typora(尤其是 macOS 用户:请使用 ⌘+Q 退出)。**
### 一键安装(推荐)
你可以直接将以下命令复制粘贴到你的终端中来安装插件:
Windows
以**管理员身份**在 PowerShell 中运行以下命令:
```powershell
iwr -Uri "https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/install.ps1" | iex
```
macOS
在终端中运行以下命令:
```bash
curl -fsSL https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/install.sh | sudo bash
```
Linux
在终端中运行以下命令:
```bash
wget -O - https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/install.sh | sudo bash
```
### 脚本安装
Windows
对于 Windows 用户,首先从[发布页面](https://github.com/Snowflyt/typora-copilot/releases)下载最新版本并解压。然后定位到你解压的文件夹并在 PowerShell 中**以管理员身份**运行以下命令:
```powershell
.\bin\install_windows.ps1
```
如果脚本无法找到 Typora,你可以手动指定 Typora 的路径:
```powershell
.\bin\install_windows.ps1 -Path "C:\Program Files\Typora\" # 替换为你的 Typora 路径
# 或使用别名
# .\bin\install_windows.ps1 -p "C:\Program Files\Typora\" # 替换为你的 Typora 路径
```
安装过程中,你会看到一条消息记录插件的安装目录。_记住它,在卸载插件时你会需要它。_ 安装完成后,你可以安全地删除刚才解压的文件夹。
macOS
对于 macOS 用户,首先从[发布页面](https://github.com/Snowflyt/typora-copilot/releases)下载最新版本并解压。然后定位到你解压的文件夹并在终端中运行以下命令:
```bash
sudo bash ./bin/install_macos.sh
```
如果脚本无法找到 Typora,你可以手动指定 Typora 的路径:
```bash
sudo bash ./bin/install_macos.sh --path "/Applications/Typora.app/" # 替换为你的 Typora 路径
# 或使用别名
# sudo bash ./bin/install_macos.sh -p "/Applications/Typora.app/" # 替换为你的 Typora 路径
```
安装过程中,你会看到一条消息记录插件的安装目录。_记住它,在卸载插件时你会需要它。_ 安装完成后,你可以安全地删除刚才解压的文件夹。
Linux
对于 Linux 用户,首先从[发布页面](https://github.com/Snowflyt/typora-copilot/releases)下载最新版本并解压。然后定位到你解压的文件夹并在终端中运行以下命令:
```bash
sudo bash ./bin/install_linux.sh
```
如果脚本无法找到 Typora,你可以手动指定 Typora 的路径:
```bash
sudo bash ./bin/install_linux.sh --path "/usr/share/typora/" # 替换为你的 Typora 路径
# 或使用别名
# sudo bash ./bin/install_linux.sh -p "/usr/share/typora/" # 替换为你的 Typora 路径
```
安装过程中,你会看到一条消息记录插件的安装目录。_记住它,在卸载插件时你会需要它。_ 安装完成后,你可以安全地删除刚才解压的文件夹。
### 手动安装
点击展开
1. 从[发布页面](https://github.com/Snowflyt/typora-copilot/releases)下载最新版本并解压。
2. 找到 Typora 安装目录下的 `window.html` 文件,通常位于 `/resources/`;对于 macOS 用户,找到 Typora 安装目录下的 `index.html` 文件,通常位于 `/Contents/Resources/TypeMark/`。`` 是 Typora 的安装路径,替换为你的实际 Typora 安装路径(注意尖括号 `<` 和 `>` 也要删除)。这个文件夹在下面的步骤中被称为 Typora 资源文件夹。
3. 在 Typora 资源文件夹中创建一个名为 `copilot` 的文件夹。
4. 将解压后的文件全部复制到 `copilot` 文件夹中。**请确保最终路径为 `copilot/index.js`(而不是 `copilot/typora-copilot/index.js`)。如果出现后者,请将 `typora-copilot` 文件夹内的内容上移一层,使 `index.js` 直接位于 `copilot` 下。**
5. 对于 Windows / Linux 用户,在 Typora 资源文件夹中用文本编辑器打开 `window.html`,在类似 `` 或 `` 的代码之后添加 ``;对于 macOS 用户,在 Typora 资源文件夹中用文本编辑器打开 `index.html`,在类似 `` 或 `` 的代码之后添加 ``。
6. 重启 Typora。
7. 对于 macOS 用户,如果你在打开 Typora 时被提示“文件已损坏”,你可以按住 Ctrl 点击 Typora,并选择“打开”来打开 Typora.
## 初始化
完成安装后,你会在 Typora 工具栏(即界面底部右下角)找到一个 Copilot 图标。点击**它右侧的箭头**,你会看到一个下拉菜单,然后点击“登录以认证 Copilot”。

> [!CAUTION]
>
> 如果你在中国大陆,登录这一步很可能因为网络原因失败。如果你发现点击按钮后很长时间没有反应,尝试按 Shift+F12(Windows 或 Linux)或在帮助菜单中打开“Enable Debugging”并在任意位置右键选择检查元素(macOS),以打开调试工具,定位到“控制台”或“Console”标签页,将过滤级别调整为“详细”或“Verbose”。然后查看控制台中打印的日志信息,以检查是否存在网络问题。
>
> 如果你看到一条来自“SignInInitiate”的红色错误信息,其中包含“ETIMEOUT”这样的内容,说明这一步因网络原因失败了。尝试调整你的代理软件设置,打开类似“增强代理”或“TUN 模式”的选项,重启 Typora 再进行尝试;或者,对于 Windows 用户可以使用使用 Proxifier 配置全局代理,对于 macOS / Linux 用户可以使用 Proxychains 打开 Typora,再进行尝试。
按照提示进行身份验证:
1. 用户代码会自动复制到你的剪贴板。
2. 遵照弹出提示上的说明,打开 GitHub 身份验证页面。
3. 将用户代码粘贴到 GitHub 身份验证页面中。
4. 返回 Typora 并在对话框中按下“确定”按钮。
5. 如果你在**几秒钟后**看到一个“已登录 GitHub Copilot”对话框,Copilot 插件应该就可以正常工作了(在中国大陆,你可能需要等待更长的时间)。
## Copilot Chat
点击工具栏中的 Copilot 图标将切换 Copilot Chat 面板。你可以使用它与 Copilot 聊天,当前文档和之前的聊天记录将作为上下文发送给 Copilot。
确保在使用 Copilot Chat 面板之前已登录 Copilot。登录后,重启 Typora 以确保 Copilot Chat 面板正常工作。
你可以:
- 从面板顶部的下拉列表中选择、创建、编辑聊天标题,或删除聊天会话。
- 点击“发送”按钮或按下 Enter 键发送消息。(你可以使用 Shift + Enter 或 Ctrl + Enter 插入新行。)
- 点击“停止”按钮停止当前请求。
- 从面板底部的下拉列表中选择提示样式。
- 从面板底部的下拉列表中选择要使用的模型。
## 卸载
### 一键卸载(推荐)
要卸载插件,你可以直接将以下命令复制粘贴到你的终端中:
Windows
以**管理员身份**在 PowerShell 中运行以下命令:
```powershell
iwr -Uri "https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/bin/uninstall_windows.ps1" | iex
```
macOS
在终端中运行以下命令:
```bash
curl -fsSL https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/bin/uninstall_macos.sh | sudo bash
```
Linux
在终端中运行以下命令:
```bash
wget -O - https://raw.githubusercontent.com/Snowflyt/typora-copilot/main/bin/uninstall_linux.sh | sudo bash
```
### 脚本卸载
Windows
对于 Windows 用户,定位到插件安装目录并在 PowerShell 中**以管理员身份**运行以下命令:
```powershell
.\bin\uninstall_windows.ps1
```
和安装时一样,如果脚本无法找到 Typora,你可以手动通过 `-Path` 或 `-p` 参数指定 Typora 的路径。
macOS
对于 macOS 用户,定位到插件安装目录并在终端中运行以下命令:
```bash
sudo bash ./bin/uninstall_macos.sh
```
和安装时一样,如果脚本无法找到 Typora,你可以手动通过 `--path` 或 `-p` 参数指定 Typora 的路径。
Linux
对于 Linux 用户,定位到插件安装目录并在终端中运行以下命令:
```bash
sudo bash ./bin/uninstall_linux.sh
```
和安装时一样,如果脚本无法找到 Typora,你可以手动通过 `--path` 或 `-p` 参数指定 Typora 的路径。
### 手动卸载
点击展开
1. 找到 Typora 安装目录下的 `window.html` 文件,通常位于 `/resources/`;对于 macOS 用户,找到 Typora 安装目录下的 `index.html` 文件,通常位于 `/Contents/Resources/TypeMark/`. `` 是 Typora 的安装路径,替换为你的实际 Typora 安装路径(注意尖括号 `<` 和 `>` 也要删除)。这个文件夹在下面的步骤中被称为 Typora 资源文件夹。
2. 删除 Typora 资源文件夹中的 `copilot` 文件夹。
3. 对于 Windows / Linux 用户,在 Typora 资源文件夹中用文本编辑器打开 `window.html`,删除 ``;对于 macOS 用户,在 Typora 资源文件夹中用文本编辑器打开 `index.html`,删除 ``.
4. 重启 Typora.
## 已知问题
1. 有时接受建议可能会导致编辑器重新渲染(即代码块、数学块等将重新渲染)。这是由于 Typora API 的限制,我必须有时强制编辑器重新渲染以接受建议,目前我找不到更安全和更高效的方法来解决这个问题。
## 常见问题
### 如何临时禁用 Copilot?
点击工具栏中的 Copilot 图标,然后点击“禁用建议”即可。你可以通过点击图标然后点击“启用建议”来重新启用它。
### 为什么默认在实时预览模式(正常模式)下使用建议面板,在源代码模式下使用补全文本?我能修改这一配置吗?
在实时预览模式下使用建议面板是有意的。Typora 在实时预览模式下的渲染机制很复杂,很难使补全文本在实时预览模式下正常工作。
不过对于源代码模式,你可以通过点击 `工具栏图标 -> 设置` 并切换 `在源代码模式下使用内联补全文本` 选项来在源代码模式下使用建议面板。
设置中还提供了一个名为 `在预览模式代码块中使用内联补全文本` 的选项。如果你启用了这个选项,补全文本将会在实时预览模式下的代码块和数学块中使用。但目前不建议启用这个选项,因为它很可能会破坏编辑器内容或历史记录。
### 我可以使用除 `Tab` 键以外的按键来接受建议吗?
目前不行。这在技术上是可行的,但目前我没什么时间实现它。也许我将来会实现它。
================================================
FILE: bin/install_linux.sh
================================================
#!/bin/bash
# Parse arguments -path or -p
while [[ "$#" -gt 0 ]]; do
case $1 in
-p | --path)
custom_path="$2"
shift
;;
*)
echo "Unknown parameter passed: $1"
exit 1
;;
esac
shift
done
# Possible Typora installation paths
paths=(
"/usr/share/typora"
"/usr/local/share/typora"
"/opt/typora"
"/opt/Typora"
"$HOME/.local/share/Typora"
"$HOME/.local/share/typora"
)
if [[ -n "$custom_path" ]]; then
paths=("$custom_path")
fi
script_to_insert_after_candidates=(
''
''
)
script_to_insert=''
escape_for_sed() {
echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g'
}
# Find `window.html` in Typora installation path
path_found=false
success=false
for path in "${paths[@]}"; do
window_html_path_candidates=(
"$path/resources/app/window.html"
"$path/resources/appsrc/window.html"
"$path/resources/window.html"
)
for window_html_path in "${window_html_path_candidates[@]}"; do
# If found, insert script
if [[ -f "$window_html_path" ]]; then
path_found=true
echo "Installation directory: \"$(dirname "$window_html_path")/copilot/\""
echo "Found Typora \"index.html\" at \"$window_html_path\"."
content=$(cat "$window_html_path")
if [[ "$content" != *"$script_to_insert"* ]]; then
echo 'Installing Copilot plugin in Typora...'
for script_to_insert_after in "${script_to_insert_after_candidates[@]}"; do
if echo "$content" | grep -qF "$script_to_insert_after"; then
echo "Inserting Copilot plugin script after \"$script_to_insert_after\"..."
# Calculate indent of the script to insert
escaped_script_to_insert_after=$(escape_for_sed "$script_to_insert_after")
escaped_script_to_insert=$(escape_for_sed "$script_to_insert")
indent=$(echo "$content" | while IFS= read -r line; do
if [[ "$line" == *"$script_to_insert_after"* ]]; then
echo "$line" | sed -E 's/^([[:space:]]*).*/\1/'
break
fi
done)
if [[ -z "$indent" ]]; then
replacement="$escaped_script_to_insert_after$escaped_script_to_insert"
else
replacement="$escaped_script_to_insert_after\n$indent$escaped_script_to_insert"
fi
new_content=$(echo "$content" | sed "s|$escaped_script_to_insert_after|$replacement|")
# Insert script
echo "$new_content" >"$window_html_path"
# Copy `/../` to `/copilot/` directory
copilot_path=$(dirname "$window_html_path")/copilot
if [[ ! -d "$copilot_path" ]]; then
echo "Copying Copilot plugin files to \"$copilot_path\"..."
mkdir -p "$copilot_path"
cp -r "$(dirname "$0")/../" "$copilot_path"
fi
echo "Successfully installed Copilot plugin in Typora."
success=true
break
fi
done
if $success; then break; fi
else
# Script tag already present; validate installation integrity
copilot_path="$(dirname "$window_html_path")/copilot"
index_js_path="$copilot_path/index.js"
if [[ ! -f "$index_js_path" ]]; then
echo "Warning: Corrupted Copilot installation detected. Expected \"$index_js_path\" but it does not exist."
echo "Please delete the entire \"$copilot_path\" directory and re-run this installer."
echo "Do NOT place the release inside Typora's installation folder (especially not under \"copilot\")."
echo "Download/extract it to any other folder and run this script from there."
break
fi
echo "Warning: Copilot plugin has already been installed in Typora."
success=true
break
fi
fi
done
if $success; then break; fi
done
# If not found, prompt user to check installation path
if ! $path_found; then
echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2
elif ! $success; then
echo "Error: Installation failed." >&2
fi
================================================
FILE: bin/install_macos.sh
================================================
#!/bin/bash
# Parse arguments -path or -p
while [[ "$#" -gt 0 ]]; do
case $1 in
-p | --path)
custom_path="$2"
shift
;;
*)
echo "Unknown parameter passed: $1"
exit 1
;;
esac
shift
done
# Possible Typora installation paths
paths=(
"/Applications/Typora.app"
"$HOME/Applications/Typora.app"
"/usr/local/bin/Typora"
"/opt/Typora"
)
if [[ -n "$custom_path" ]]; then
paths=("$custom_path")
fi
script_to_insert_after_candidates=(
''
''
''
''
)
script_to_insert=''
escape_for_sed() {
echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g'
}
# Find `index.html` in Typora installation path
path_found=false
success=false
for path in "${paths[@]}"; do
index_html_path_candidates=(
"$path/Contents/Resources/TypeMark/index.html"
"$path/Contents/Resources/app/index.html"
"$path/Contents/Resources/appsrc/index.html"
"$path/resources/app/index.html"
"$path/resources/appsrc/index.html"
"$path/resources/TypeMark/index.html"
"$path/resources/index.html"
)
for index_html_path in "${index_html_path_candidates[@]}"; do
# If found, insert script
if [[ -f "$index_html_path" ]]; then
path_found=true
echo "Installation directory: \"$(dirname "$index_html_path")/copilot/\""
echo "Found Typora \"index.html\" at \"$index_html_path\"."
content=$(cat "$index_html_path")
if [[ "$content" != *"$script_to_insert"* ]]; then
echo 'Installing Copilot plugin in Typora...'
for script_to_insert_after in "${script_to_insert_after_candidates[@]}"; do
if echo "$content" | grep -qF "$script_to_insert_after"; then
echo "Inserting Copilot plugin script after \"$script_to_insert_after\"..."
# Calculate indent of the script to insert
escaped_script_to_insert_after=$(escape_for_sed "$script_to_insert_after")
escaped_script_to_insert=$(escape_for_sed "$script_to_insert")
indent=$(echo "$content" | while IFS= read -r line; do
if [[ "$line" == *"$script_to_insert_after"* ]]; then
echo "$line" | sed -E 's/^([[:space:]]*).*/\1/'
break
fi
done)
if [[ -z "$indent" ]]; then
replacement="$escaped_script_to_insert_after$escaped_script_to_insert"
else
replacement="$escaped_script_to_insert_after\n$indent$escaped_script_to_insert"
fi
new_content=$(echo "$content" | sed "s|$escaped_script_to_insert_after|$replacement|")
# Insert script
echo "$new_content" >"$index_html_path"
# Copy `/../` to `/copilot/` directory
copilot_path=$(dirname "$index_html_path")/copilot
if [[ ! -d "$copilot_path" ]]; then
echo "Copying Copilot plugin files to \"$copilot_path\"..."
mkdir -p "$copilot_path"
cp -r "$(dirname "$0")/../" "$copilot_path"
fi
echo "Successfully installed Copilot plugin in Typora."
success=true
break
fi
done
if $success; then break; fi
else
# Script tag already present; validate installation integrity
copilot_path="$(dirname "$index_html_path")/copilot"
index_js_path="$copilot_path/index.js"
if [[ ! -f "$index_js_path" ]]; then
echo "Warning: Corrupted Copilot installation detected. Expected \"$index_js_path\" but it does not exist."
echo "Please delete the entire \"$copilot_path\" directory and re-run this installer."
echo "Do NOT place the release inside Typora's installation folder (especially not under \"copilot\")."
echo "Download/extract it to any other folder and run this script from there."
break
fi
echo "Warning: Copilot plugin has already been installed in Typora."
success=true
break
fi
fi
done
if $success; then break; fi
done
# If not found, prompt user to check installation path
if ! $path_found; then
echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2
elif ! $success; then
echo "Error: Installation failed." >&2
fi
================================================
FILE: bin/install_windows.ps1
================================================
# Allow custom path (-Path or -p)
param (
[Parameter(Mandatory = $false)]
[Alias('p')]
[string] $Path = ''
)
# Possible Typora installation paths
$paths = @(
'C:\Program Files\Typora'
'C:\Program Files (x86)\Typora'
"$env:LOCALAPPDATA\Programs\Typora"
)
if ($Path -ne '') { $paths = @($Path) }
$scriptToInsertAfterCandidates = @(
''
''
)
$scriptToInsert = ''
# Find `window.html` in Typora installation path
$pathFound = $false
$success = $false
foreach ($path in $paths) {
$windowHtmlPathCandiates = @(
Join-Path -Path $path -ChildPath 'resources\app\window.html'
Join-Path -Path $path -ChildPath 'resources\appsrc\window.html'
Join-Path -Path $path -ChildPath 'resources\window.html'
)
foreach ($windowHtmlPath in $windowHtmlPathCandiates) {
# If found, insert script
if (Test-Path $windowHtmlPath) {
$pathFound = $true
Write-Host "Installation directory: ""$(Split-Path -Path $windowHtmlPath -Parent)\copilot\"""
Write-Host "Found Typora ""window.html"" at ""$windowHtmlPath""."
$content = Get-Content $windowHtmlPath -Raw -Encoding UTF8
if (!($content.Contains($scriptToInsert))) {
Write-Host 'Installing Copilot plugin in Typora...'
foreach ($scriptToInsertAfter in $scriptToInsertAfterCandidates) {
if ($content.Contains($scriptToInsertAfter)) {
Write-Host "Inserting Copilot plugin script after ""$scriptToInsertAfter""..."
# Calculate indent of the script to insert
$row = $content.Split("`n") | Where-Object { $_ -match $scriptToInsertAfter }
$rowContentBeforeScriptToInsertAfter = $row -replace "$scriptToInsertAfter(.*)", ''
$indent = $rowContentBeforeScriptToInsertAfter -replace $rowContentBeforeScriptToInsertAfter.TrimEnd(), ''
# Insert script
$newContent = $content -replace $scriptToInsertAfter, (
$scriptToInsertAfter +
$(If (($rowContentBeforeScriptToInsertAfter -ne '') -and ($indent -eq '')) { '' } Else { "`n" + $indent }) +
$scriptToInsert
)
Set-Content -Path $windowHtmlPath -Value $newContent -Encoding UTF8
# Copy `\..\` to `\copilot\` directory
$copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot'
if (-not (Test-Path $copilotPath)) {
Write-Host "Copying Copilot plugin files to ""$copilotPath""..."
Copy-Item -Path (Join-Path -Path $PSScriptRoot -ChildPath '..\') -Destination $copilotPath -Recurse
}
Write-Host "Successfully installed Copilot plugin in Typora."
$success = $true
break
}
}
if ($success) { break }
}
else {
# Script tag already present; validate installation integrity
$copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot'
$indexJsPath = Join-Path -Path $copilotPath -ChildPath 'index.js'
if (-not (Test-Path $indexJsPath)) {
Write-Warning "Corrupted Copilot installation detected. Expected ""$indexJsPath"" but it does not exist."
Write-Host "Please delete the entire ""$copilotPath"" directory and re-run this installer."
Write-Host "Do NOT place the release inside Typora's installation folder (especially not under ""copilot"")."
Write-Host "Download/extract it to any other folder and run this script from there."
break
}
Write-Warning "Copilot plugin has already been installed in Typora."
$success = $true
break
}
}
}
}
# If not found, prompt user to check installation path
if (-not $pathFound) {
Write-Error "Could not find Typora installation path. Please check if Typora is installed and try again."
}
elseif (-not $success) {
Write-Error "Installation failed."
}
================================================
FILE: bin/uninstall_linux.sh
================================================
#!/bin/bash
# Parse arguments -path or -p
while [[ "$#" -gt 0 ]]; do
case $1 in
-p | --path)
custom_path="$2"
shift
;;
-s | --silent)
silent=true
shift
;;
*)
echo "Unknown parameter passed: $1"
exit 1
;;
esac
shift
done
# Possible Typora installation paths on Linux
paths=(
"/usr/share/typora"
"/usr/local/share/typora"
"/opt/typora"
"/opt/Typora"
"$HOME/.local/share/Typora"
"$HOME/.local/share/typora"
)
if [[ -n "$custom_path" ]]; then
paths=("$custom_path")
fi
script_to_remove_after_candidates=(
''
''
)
script_to_remove=''
escape_for_sed() {
echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g'
}
# Find `window.html` in Typora installation path
path_found=false
success=false
for path in "${paths[@]}"; do
window_html_path_candidates=(
"$path/resources/app/window.html"
"$path/resources/appsrc/window.html"
"$path/resources/window.html"
)
for window_html_path in "${window_html_path_candidates[@]}"; do
# If found, insert script
if [[ -f "$window_html_path" ]]; then
path_found=true
echo "Found Typora \"window.html\" at \"$window_html_path\"."
content=$(cat "$window_html_path")
for script_to_remove_after in "${script_to_remove_after_candidates[@]}"; do
if echo "$content" | grep -qF "$script_to_remove_after"; then
if echo "$content" | grep -qF "$script_to_remove"; then
echo "Removing Copilot plugin script after \"$script_to_remove_after\"..."
escaped_script_to_remove=$(escape_for_sed "$script_to_remove")
new_content=$(echo "$content" | sed -E "s/[[:space:]]*$escaped_script_to_remove//")
# Remove script
echo "$new_content" >"$window_html_path"
# Remove `/copilot/` directory
copilot_dir=$(dirname "$window_html_path")/copilot
if [[ -d "$copilot_dir" ]]; then
echo "Removing Copilot plugin directory \"$copilot_dir\"..."
rm -rf "$copilot_dir"
fi
echo "Successfully uninstalled Copilot plugin in Typora."
success=true
break
else
if ! $silent; then
echo "Warning: Copilot plugin has not been installed in Typora."
fi
# Remove `/copilot/` directory regardless of script presence
copilot_dir=$(dirname "$window_html_path")/copilot
if [[ -d "$copilot_dir" ]]; then
echo "Detected Copilot plugin directory but no script reference. This might be leftover from a previous installation."
echo "Removing Copilot plugin directory \"$copilot_dir\"..."
rm -rf "$copilot_dir"
echo "Uninstallation complete."
fi
success=true
break
fi
fi
if $success; then break; fi
done
fi
if $success; then break; fi
done
if $success; then break; fi
done
# If not found, prompt user to check installation path
if ! $path_found; then
echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2
elif ! $success; then
echo "Error: Uninstallation failed." >&2
fi
================================================
FILE: bin/uninstall_macos.sh
================================================
#!/bin/bash
# Parse arguments -path or -p
while [[ "$#" -gt 0 ]]; do
case $1 in
-p | --path)
custom_path="$2"
shift
;;
-s | --silent)
silent=true
shift
;;
*)
echo "Unknown parameter passed: $1"
exit 1
;;
esac
shift
done
# Possible Typora installation paths
paths=(
"/Applications/Typora.app"
"$HOME/Applications/Typora.app"
"/usr/local/bin/Typora"
"/opt/Typora"
)
if [[ -n "$custom_path" ]]; then
paths=("$custom_path")
fi
script_to_remove_after_candidates=(
''
''
''
''
)
script_to_remove=''
escape_for_sed() {
echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g'
}
# Find `index.html` in Typora installation path
path_found=false
success=false
for path in "${paths[@]}"; do
index_html_path_candidates=(
"$path/Contents/Resources/TypeMark/index.html"
"$path/Contents/Resources/app/index.html"
"$path/Contents/Resources/appsrc/index.html"
"$path/resources/app/index.html"
"$path/resources/appsrc/index.html"
"$path/resources/TypeMark/index.html"
"$path/resources/index.html"
)
for index_html_path in "${index_html_path_candidates[@]}"; do
# If found, insert script
if [[ -f "$index_html_path" ]]; then
path_found=true
echo "Found Typora \"index.html\" at \"$index_html_path\"."
content=$(cat "$index_html_path")
for script_to_remove_after in "${script_to_remove_after_candidates[@]}"; do
if echo "$content" | grep -qF "$script_to_remove_after"; then
if echo "$content" | grep -qF "$script_to_remove"; then
echo "Removing Copilot plugin script after \"$script_to_remove_after\"..."
escaped_script_to_remove=$(escape_for_sed "$script_to_remove")
new_content=$(echo "$content" | sed -E "s/[[:space:]]*$escaped_script_to_remove//")
# Remove script
echo "$new_content" >"$index_html_path"
# Remove `/copilot/` directory
copilot_dir=$(dirname "$index_html_path")/copilot
if [[ -d "$copilot_dir" ]]; then
echo "Removing Copilot plugin directory \"$copilot_dir\"..."
rm -rf "$copilot_dir"
fi
echo "Successfully uninstalled Copilot plugin in Typora."
success=true
break
else
if ! $silent; then
echo "Warning: Copilot plugin has not been installed in Typora."
fi
# Remove `/copilot/` directory regardless of script presence
copilot_dir=$(dirname "$index_html_path")/copilot
if [[ -d "$copilot_dir" ]]; then
echo "Detected Copilot plugin directory but no script reference. This might be leftover from a previous installation."
echo "Removing Copilot plugin directory \"$copilot_dir\"..."
rm -rf "$copilot_dir"
echo "Uninstallation complete."
fi
success=true
break
fi
fi
if $success; then break; fi
done
fi
if $success; then break; fi
done
if $success; then break; fi
done
# If not found, prompt user to check installation path
if ! $path_found; then
echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2
elif ! $success; then
echo "Error: Uninstallation failed." >&2
fi
================================================
FILE: bin/uninstall_windows.ps1
================================================
# Allow custom path (-Path or -p) and silence warning (-Silent or -s)
param (
[Parameter(Mandatory = $false)]
[Alias('p')]
[string] $Path = '',
[Parameter(Mandatory = $false)]
[Alias('s')]
[switch] $Silent = $false
)
# Possible Typora installation paths
$paths = @(
'C:\Program Files\Typora'
'C:\Program Files (x86)\Typora'
"$env:LOCALAPPDATA\Programs\Typora"
)
if ($Path -ne '') { $paths = @($Path) }
$scriptToRemoveAfterCandidates = @(
''
''
)
$scriptToRemove = ''
# Find `window.html` in Typora installation path
$pathFound = $false
$success = $false
foreach ($path in $paths) {
$windowHtmlPathCandiates = @(
Join-Path -Path $path -ChildPath 'resources\app\window.html'
Join-Path -Path $path -ChildPath 'resources\appsrc\window.html'
Join-Path -Path $path -ChildPath 'resources\window.html'
)
foreach ($windowHtmlPath in $windowHtmlPathCandiates) {
# If found, remove script
if (Test-Path $windowHtmlPath) {
$pathFound = $true
Write-Host "Found Typora ""window.html"" at ""$windowHtmlPath""."
$content = Get-Content $windowHtmlPath -Raw -Encoding UTF8
foreach ($scriptToRemoveAfter in $scriptToRemoveAfterCandidates) {
if ($content.Contains($scriptToRemoveAfter)) {
if ($content.Contains($scriptToRemove)) {
Write-Host "Removing Copilot plugin script after ""$scriptToRemoveAfter""..."
# Calculate indent of the script to remove
$row = $content.Split("`n") | Where-Object { $_ -match $scriptToRemove }
$rowContentBeforeScriptToRemove = $row -replace "$scriptToRemoveAfter(.*)", ''
$indent = $rowContentBeforeScriptToRemove -replace $rowContentBeforeScriptToRemove.TrimEnd(), ''
# Remove script
$newContent = $content -replace ($indent + $scriptToRemove), ''
Set-Content -Path $windowHtmlPath -Value $newContent -Encoding UTF8
# Remove `\copilot\` directory
$copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot'
if (Test-Path $copilotPath) {
Write-Host "Removing Copilot plugin directory ""$copilotPath""..."
Remove-Item -Path $copilotPath -Recurse -Force
}
Write-Host "Successfully uninstalled Copilot plugin in Typora."
$success = $true
break
}
else {
if (-not $Silent) {
Write-Warning "Copilot plugin script has not been found in Typora."
}
# Remove `\copilot\` directory regardless of script presence
$copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot'
if (Test-Path $copilotPath) {
Write-Host "Detected Copilot plugin directory but no script reference. This might be leftover from a previous installation."
Write-Host "Removing Copilot plugin directory ""$copilotPath""..."
Remove-Item -Path $copilotPath -Recurse -Force
Write-Host "Uninstallation complete."
$success = $true
}
$success = $true
break
}
}
if ($success) { break }
}
}
if ($success) { break }
}
}
# If not found, prompt user to check installation path
if (-not $pathFound) {
Write-Error "Could not find Typora installation path. Please check if Typora is installed and try again."
}
elseif (-not $success) {
Write-Error "Uninstallation failed."
}
================================================
FILE: commitlint.config.js
================================================
// @ts-check
/**
* @typedef {object} Parsed
* @property {?string} emoji The emoji at the beginning of the commit message.
* @property {?string} type The type of the commit message.
* @property {?string} scope The scope of the commit message.
* @property {?string} subject The subject of the commit message.
*/
const emojiEnum = /** @type {const} */ ([
2,
"always",
{
"🎉": ["init", "Project initialization"],
"✨": ["feat", "Adding new features"],
"🐞": ["fix", "Fixing bugs"],
"📃": ["docs", "Modify documentation only"],
"🌈": [
"style",
"Only the spaces, formatting indentation, commas, etc. were changed, not the code logic",
],
"🦄": ["refactor", "Code refactoring, no new features added or bugs fixed"],
"🎈": ["perf", "Optimization-related, such as improving performance, experience"],
"🧪": ["test", "Adding or modifying test cases"],
"🔧": [
"build",
"Dependency-related content, such as Webpack, Vite, Rollup, npm, package.json, etc.",
],
"🐎": ["ci", "CI configuration related, e.g. changes to k8s, docker configuration files"],
"🐳": ["chore", "Other modifications, e.g. modify the configuration file"],
"↩": ["revert", "Rollback to previous version"],
},
]);
/** @satisfies {import("@commitlint/types").UserConfig} */
const config = {
parserPreset: {
parserOpts: {
headerPattern:
/^(?\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]) (?\w+)(?:\((?.*)\))?!?: (?(?:(?!#).)*(?:(?!\s).))$/,
headerCorrespondence: ["emoji", "type", "scope", "subject"],
},
},
plugins: [
{
rules: {
"header-match-git-commit-message-with-emoji-pattern": (parsed) => {
const { emoji, scope, subject, type } = /** @type {Parsed} */ (
/** @type {unknown} */ (parsed)
);
if (emoji === null && type === null && scope === null && subject === null)
return [
false,
'header must be in format "(?): ", e.g:\n' +
" - 🎉 init: Initial commit\n" +
" - ✨ feat(assertions): Add assertions\n" +
" ",
];
return [true, ""];
},
"emoji-enum": (parsed, _, value) => {
const { emoji } = /** @type {Parsed} */ (/** @type {unknown} */ (parsed));
const emojisObject = /** @type {typeof emojiEnum[2]} */ (/** @type {unknown} */ (value));
if (emoji && !Object.keys(emojisObject).includes(emoji)) {
return [
false,
"emoji must be one of:\n" +
Object.entries(emojisObject)
.map(([emoji, [type, description]]) => ` ${emoji} ${type} - ${description}`)
.join("\n") +
"\n ",
];
}
return [true, ""];
},
},
},
],
rules: {
"header-match-git-commit-message-with-emoji-pattern": [2, "always"],
"body-leading-blank": [2, "always"],
"footer-leading-blank": [2, "always"],
"header-max-length": [2, "always", 72],
"scope-case": [2, "always", ["lower-case", "upper-case"]],
"subject-case": [2, "always", "sentence-case"],
"subject-empty": [2, "never"],
"subject-exclamation-mark": [2, "never"],
"subject-full-stop": [2, "never", "."],
"emoji-enum": emojiEnum,
"type-case": [2, "always", "lower-case"],
"type-empty": [2, "never"],
"type-enum": [
2,
"always",
[
"init",
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert",
],
],
},
};
export default config;
================================================
FILE: eslint.config.js
================================================
// @ts-check
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import { importX } from "eslint-plugin-import-x";
import { jsdoc } from "eslint-plugin-jsdoc";
import prettierRecommended from "eslint-plugin-prettier/recommended";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import sonarjs from "eslint-plugin-sonarjs";
import sortDestructureKeys from "eslint-plugin-sort-destructure-keys";
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
jsdoc({ config: "flat/recommended-typescript-error" }),
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
reactHooks.configs.flat["recommended-latest"],
/** @type {import("eslint").Linter.Config} */ (importX.flatConfigs.recommended),
/** @type {import("eslint").Linter.Config} */ (importX.flatConfigs.typescript),
prettierRecommended,
sonarjs.configs.recommended,
{
plugins: {
react,
"sort-destructure-keys": /** @type {import("eslint").ESLint.Plugin} */ (sortDestructureKeys),
},
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
ecmaFeatures: { jsx: true },
projectService: {
allowDefaultProject: ["*.{js,cjs}"],
defaultProject: "tsconfig.json",
},
tsconfigRootDir: import.meta.dirname,
},
globals: { ...globals.browser },
},
rules: {
"@typescript-eslint/restrict-plus-operands": [
"error",
{ allowAny: true, allowNumberAndString: true },
],
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowAny: true, allowBoolean: true, allowNullish: true, allowNumber: true },
],
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/consistent-type-definitions": "off", // TS treats types and interfaces differently, this may break some advanced type gymnastics
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", disallowTypeAnnotations: false },
],
"@typescript-eslint/dot-notation": ["error", { allowIndexSignaturePropertyAccess: true }],
"@typescript-eslint/no-confusing-void-expression": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unnecessary-type-parameters": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unused-vars": "off", // Already covered by `tsconfig.json`
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/unified-signatures": "off",
"import-x/consistent-type-specifier-style": ["error", "prefer-top-level"],
"import-x/no-named-as-default-member": "off",
"import-x/no-unresolved": "off",
"import-x/order": [
"error",
{
alphabetize: { order: "asc" },
groups: ["builtin", "external", "internal", "parent", "sibling", "index", "object"],
pathGroups: [
{
pattern: "@modules/**",
group: "external",
position: "before",
},
{
pattern: "@/",
group: "internal",
position: "after",
},
],
pathGroupsExcludedImportTypes: ["builtin", "type"],
"newlines-between": "always",
},
],
"jsdoc/check-param-names": "off",
"jsdoc/check-tag-names": "off",
"jsdoc/check-values": "off",
"jsdoc/reject-any-type": "off",
"jsdoc/require-jsdoc": "off",
"jsdoc/require-param": "off",
"jsdoc/require-returns-description": "off",
"jsdoc/tag-lines": "off",
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.property.name='push'] > SpreadElement.arguments",
message:
"Do not use spread arguments in `Array#push`, " +
"as it might cause stack overflow if you spread a large array. " +
"Instead, use `Array#concat` or `Array.prototype.push.apply`.",
},
],
"no-undef": "off", // Already checked by TypeScript
"object-shorthand": "error",
"react/no-unknown-property": "off", // Already checked by TypeScript
"react/prop-types": "off", // Already checked by TypeScript
"react-hooks/immutability": "off",
"sonarjs/class-name": ["error", { format: "^_?[A-Z][a-zA-Z0-9]*$" }],
"sonarjs/code-eval": "off", // Already covered by `@typescript-eslint/no-implied-eval`
"sonarjs/cognitive-complexity": "off",
"sonarjs/deprecation": "off", // Already covered by `@typescript-eslint/no-deprecated`
"sonarjs/different-types-comparison": "off", // Already checked by TypeScript
"sonarjs/no-ignored-exceptions": "off",
"sonarjs/no-nested-assignment": "off",
"sonarjs/no-nested-conditional": "off",
"sonarjs/no-nested-functions": "off",
"sonarjs/no-selector-parameter": "off",
"sonarjs/no-useless-intersection": "off", // Already checked by TypeScript
"sonarjs/no-unused-vars": "off", // Already checked by TypeScript
"sonarjs/reduce-initial-value": "off",
"sonarjs/redundant-type-aliases": "off", // Already covered by `@typescript-eslint/no-restricted-type-imports`
"sonarjs/regex-complexity": "off",
"sonarjs/slow-regex": "off",
"sonarjs/todo-tag": "off",
"sonarjs/void-use": "off",
"sort-destructure-keys/sort-destructure-keys": "error",
"sort-imports": ["error", { ignoreDeclarationSort: true }],
},
},
);
================================================
FILE: install.ps1
================================================
#Requires -RunAsAdministrator
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Snowflyt/typora-copilot/releases/latest"
Invoke-WebRequest -Uri $latestRelease.assets[0].browser_download_url -OutFile "typora-copilot-$($latestRelease.tag_name).zip"
If (Test-Path "typora-copilot-$($latestRelease.tag_name)") {
Remove-Item "typora-copilot-$($latestRelease.tag_name)" -Recurse -Force
}
New-Item -ItemType Directory -Path "typora-copilot-$($latestRelease.tag_name)"
Expand-Archive -Path "typora-copilot-$($latestRelease.tag_name).zip" -DestinationPath "typora-copilot-$($latestRelease.tag_name)"
Remove-Item "typora-copilot-$($latestRelease.tag_name).zip"
Set-Location "typora-copilot-$($latestRelease.tag_name)"
Write-Host "Trying to uninstall the previous version (if any)..."
.\bin\uninstall_windows.ps1 -Silent
Write-Host "Trying to install the new version..."
.\bin\install_windows.ps1
Set-Location ..
Remove-Item "typora-copilot-$($latestRelease.tag_name)" -Recurse -Force
================================================
FILE: install.sh
================================================
#!/usr/bin/env bash
if [ "$(id -u)" -ne 0 ]; then
echo "Please run as root"
exit 1
fi
latest_release=$(curl -s https://api.github.com/repos/Snowflyt/typora-copilot/releases/latest)
download_url=$(echo "$latest_release" | grep '"browser_download_url"' | head -n 1 | sed -E 's/.*"browser_download_url": "(.*)".*/\1/')
tag_name=$(echo "$latest_release" | grep '"tag_name"' | head -n 1 | sed -E 's/.*"tag_name": "(.*)".*/\1/')
curl -L "$download_url" -o "typora-copilot-$tag_name.zip"
if [ -d "typora-copilot-$tag_name" ]; then
rm -rf "typora-copilot-$tag_name"
fi
mkdir "typora-copilot-$tag_name"
unzip "typora-copilot-$tag_name.zip" -d "typora-copilot-$tag_name"
rm "typora-copilot-$tag_name.zip"
cd "typora-copilot-$tag_name" || exit
if [[ "$OSTYPE" == "darwin"* ]]; then
echo "Trying to uninstall the previous version (if any)..."
chmod +x ./bin/uninstall_macos.sh
./bin/uninstall_macos.sh --silent
echo "Trying to install the new version..."
chmod +x ./bin/install_macos.sh
./bin/install_macos.sh
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
echo "Trying to uninstall the previous version (if any)..."
chmod +x ./bin/uninstall_linux.sh
./bin/uninstall_linux.sh --silent
echo "Trying to install the new version..."
chmod +x ./bin/install_linux.sh
./bin/install_linux.sh
else
echo "Unsupported OS: $OSTYPE"
exit 1
fi
cd ..
rm -rf "typora-copilot-$tag_name"
================================================
FILE: package.json
================================================
{
"name": "typora-copilot",
"version": "0.3.12",
"private": true,
"description": "GitHub Copilot plugin for Typora",
"keywords": [
"Typora",
"Copilot",
"GitHub Copilot",
"AI",
"code completion",
"code suggestion",
"code generation"
],
"homepage": "https://github.com/Snowflyt/typora-copilot",
"bugs": {
"url": "https://github.com/Snowflyt/typora-copilot/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/Snowflyt/typora-copilot"
},
"license": "MIT",
"author": "Snowflyt ",
"type": "module",
"main": "./index.js",
"scripts": {
"build:dev": "rimraf dist && rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript && node -e \"import fs from 'node:fs'; import { join } from 'node:path'; ['package.json', 'README.md', 'README.zh-CN.md'].forEach((path) => fs.copyFileSync(path, join('dist', path))); ['bin', 'docs'].forEach((dir) => fs.cpSync(dir, join('dist', dir), { recursive: true })); fs.cpSync(join('node_modules', '@github', 'copilot-language-server', 'dist'), join('dist', 'language-server'), { recursive: true }); fs.rmSync(join('dist', 'language-server', 'api'), { recursive: true }); fs.rmSync(join('dist', 'language-server', 'language-server.js')); fs.readdirSync(join('dist', 'language-server')).forEach((file) => file.endsWith('.map') && fs.rmSync(join('dist', 'language-server', file))); fs.renameSync(join('dist', 'language-server', 'main.js'), join('dist', 'language-server', 'language-server.cjs'))\"",
"build:release": "rimraf dist && npm run test-types && npm test && rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript && prettier --log-level=silent --print-width 80 --write dist/**/* --ignore-path !dist/ && node -e \"import fs from 'node:fs'; import { join } from 'node:path'; ['package.json', 'README.md', 'README.zh-CN.md'].forEach((path) => fs.copyFileSync(path, join('dist', path))); ['bin', 'docs'].forEach((dir) => fs.cpSync(dir, join('dist', dir), { recursive: true })); fs.cpSync(join('node_modules', '@github', 'copilot-language-server', 'dist'), join('dist', 'language-server'), { recursive: true }); fs.rmSync(join('dist', 'language-server', 'api'), { recursive: true }); fs.rmSync(join('dist', 'language-server', 'language-server.js')); fs.readdirSync(join('dist', 'language-server')).forEach((file) => file.endsWith('.map') && fs.rmSync(join('dist', 'language-server', file))); fs.renameSync(join('dist', 'language-server', 'main.js'), join('dist', 'language-server', 'language-server.cjs'))\"",
"build:watch": "rimraf dist && node -e \"import fs from 'node:fs'; import { join } from 'node:path'; if (!fs.existsSync('dist')) fs.mkdirSync('dist'); ['package.json', 'README.md', 'README.zh-CN.md'].forEach((path) => fs.copyFileSync(path, join('dist', path))); ['bin', 'docs'].forEach((dir) => fs.cpSync(dir, join('dist', dir), { recursive: true })); fs.cpSync(join('node_modules', '@github', 'copilot-language-server', 'dist'), join('dist', 'language-server'), { recursive: true }); fs.rmSync(join('dist', 'language-server', 'api'), { recursive: true }); fs.readdirSync(join('dist', 'language-server')).forEach((file) => file.endsWith('.map') && fs.rmSync(join('dist', 'language-server', file))); fs.renameSync(join('dist', 'language-server', 'main.js'), join('dist', 'language-server', 'language-server.cjs'))\" && rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript --watch",
"format": "prettier --no-error-on-unmatched-pattern --write {src,test}/**/*.{json,js,jsx,ts,tsx} *.{js,cjs,mjs,ts,cts,mts,json,md}",
"lint": "eslint {src,test}/**/*.{js,jsx,ts,tsx} *.{js,cjs,mjs,ts,cts,mts} --no-error-on-unmatched-pattern --report-unused-disable-directives-severity error --max-warnings 0 && stylelint \"src/**/*.{css,scss}\"",
"lint:fix": "eslint --fix {src,test}/**/*.{js,jsx,ts,tsx} *.{js,cjs,mjs,ts,cts,mts} --no-error-on-unmatched-pattern --report-unused-disable-directives-severity error --max-warnings 0 && stylelint --fix \"src/**/*.{css,scss}\"",
"prepare": "node -e \"import fs from 'node:fs'; import path from 'node:path'; const hooksDir = path.join(process.cwd(), '.githooks'); const gitHooksDir = path.join(process.cwd(), '.git/hooks'); if (fs.existsSync(gitHooksDir)) { fs.readdirSync(hooksDir).forEach(file => { const srcFile = path.join(hooksDir, file); const destFile = path.join(gitHooksDir, file); fs.copyFileSync(srcFile, destFile); if (process.platform !== 'win32' && !file.endsWith('.cmd')) { fs.chmodSync(destFile, 0o755); } }); }\"",
"test": "vitest run",
"test-types": "typroof",
"test:cov": "vitest run --coverage",
"test:ui": "vitest --ui --coverage.enabled=true",
"test:watch": "vitest",
"test:watch-cov": "vitest --coverage",
"typecheck": "tsc --noEmit -p tsconfig.build.json"
},
"dependencies": {
"@github/copilot-language-server": "^1.409.0",
"@preact/signals": "^2.5.1",
"color2k": "^2.0.3",
"fast-diff": "^1.3.0",
"highlight.js": "^11.11.1",
"marked": "^17.0.1",
"marked-highlight": "^2.2.3",
"preact": "^10.28.2",
"radash": "^12.1.1",
"semver": "^7.7.3",
"string-ts": "^2.3.1"
},
"devDependencies": {
"@catppuccin/highlightjs": "^1.0.1",
"@commitlint/cli": "^20.3.1",
"@eslint/js": "^9.39.2",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-typescript": "^12.3.0",
"@total-typescript/ts-reset": "^0.6.1",
"@types/codemirror": "^5.60.17",
"@types/core-js": "^2.5.8",
"@types/jquery": "^3.5.33",
"@types/node": "^20.19.29",
"@types/rangy": "^1.3.0",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.17",
"@vitest/ui": "^4.0.17",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jsdoc": "^62.0.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-sonarjs": "^3.0.5",
"eslint-plugin-sort-destructure-keys": "^2.0.0",
"globals": "^17.0.0",
"happy-dom": "^20.1.0",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier-plugin-packagejson": "^2.5.21",
"replace-in-file": "^8.4.0",
"rimraf": "^6.1.2",
"rollup": "^4.55.1",
"rollup-plugin-postcss": "^4.0.2",
"sass": "^1.97.2",
"stylelint": "^16.26.1",
"stylelint-config-standard-scss": "^16.0.0",
"tslib": "^2.8.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.0",
"typroof": "^0.6.0",
"vitest": "^4.0.17",
"ws": "^8.19.0"
}
}
================================================
FILE: pre-commit.ts
================================================
import fs from "node:fs";
import { replaceInFileSync } from "replace-in-file";
import packageJSON from "./package.json";
const CONSTANTS_FILE_PATHNAME = "./src/constants.ts";
const { version } = packageJSON;
const options = {
files: CONSTANTS_FILE_PATHNAME,
from: /VERSION = ".*"/g,
to: `VERSION = "${version}"`,
};
if (fs.readFileSync(CONSTANTS_FILE_PATHNAME, "utf-8").includes(options.to)) process.exit(0);
try {
replaceInFileSync(options);
console.log("Plugin VERSION updated:", version);
} catch (error) {
console.error("Error occurred while updating plugin VERSION:", error);
process.exit(1);
}
================================================
FILE: prettier.config.cjs
================================================
// @ts-check
/** @satisfies {import("prettier").Config} */
const config = {
arrowParens: "always",
bracketSameLine: true,
bracketSpacing: true,
experimentalTernaries: true,
plugins: ["prettier-plugin-packagejson"],
printWidth: 100,
semi: true,
singleQuote: false,
tabWidth: 2,
trailingComma: "all",
};
module.exports = config;
================================================
FILE: rollup.config.ts
================================================
import fs from "node:fs";
import path from "node:path";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import type { InputPluginOption } from "rollup";
import { defineConfig } from "rollup";
import postcss from "rollup-plugin-postcss";
const plugins = [
typescript({
tsconfig: "./tsconfig.build.json",
}),
nodeResolve({
extensions: [".js", ".jsx", ".ts", ".tsx"],
}),
json(),
commonjs(),
{
name: "clean",
transform: (code) =>
code
.replace(/\n?^\s*\/\/ @ts-.+$/gm, "")
.replace(/\n?^\s*\/\/\/
css.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
const themeSwitch = `
// Highlight.js theme switcher
(function() {
let styleElement = null;
let currentTheme = null;
const themes = {
light: \`${escapeCSS(lightThemeCSS)}\`,
dark: \`${escapeCSS(darkThemeCSS)}\`
};
window.setHighlightjsTheme = function(theme) {
if (!themes[theme]) {
console.error('Invalid theme: ' + theme + '. Use "light" or "dark"');
return;
}
if (currentTheme === theme) return;
currentTheme = theme;
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'highlightjs-dynamic-theme';
document.head.appendChild(styleElement);
}
styleElement.textContent = themes[theme];
};
})();`;
return code + themeSwitch;
}
return code;
},
},
] satisfies InputPluginOption;
export default defineConfig([
{
input: "src/index.ts",
output: {
file: "dist/index.js",
format: "iife",
},
plugins: [
...plugins,
postcss({
inject: true,
// Disable Dart Sass deprecated legacy JS API warning until rollup-plugin-postcss is updated
// to support modern Sass API: https://github.com/egoist/rollup-plugin-postcss/issues/463
use: {
sass: {
silenceDeprecations: ["legacy-js-api"],
},
} as never,
}),
],
},
{
input: "src/mac-server.ts",
output: {
file: "dist/mac-server.cjs",
format: "cjs",
},
plugins,
},
]);
================================================
FILE: src/client/chat.ts
================================================
/**
* This module provides functions to interact with GitHub Copilot Chat API.
*
* Implementation inspired by CopilotChat.nvim:
* https://github.com/CopilotC-Nvim/CopilotChat.nvim
* @module
*/
import * as fs from "@modules/fs";
import * as path from "@modules/path";
import { VERSION } from "@/constants";
import { TYPORA_VERSION } from "@/typora-utils";
import { getEnv } from "@/utils/cli-tools";
import { generateUUID } from "@/utils/random";
import { parseSSEStream } from "@/utils/stream";
import { omit } from "@/utils/tools";
const COPILOT_MARKDOWN_BASE = `
When asked for your name, you must respond with "GitHub Copilot".
Follow the user’s requirements carefully & to the letter.
Follow Microsoft content policies.
Avoid content that violates copyrights.
If you are asked to generate content that is harmful, hateful, racist, or promotes violence, only respond with "Sorry, I can’t assist with that." You can be playful, casual, and even a bit whimsical in your responses when appropriate, while maintaining helpfulness. Feel free to use creative expressions, metaphors, and occasional humor to make your responses engaging.
You are an AI assistant specializing in Markdown document editing, academic writing, content creation, and knowledge sharing.
The user is working in Typora, a Markdown editor.
Provide helpful responses that may be about:
- Improving their current document
- Answering knowledge questions related to their document’s content
- Explaining concepts mentioned in their document
- General assistance with writing and research
- Creative suggestions for content development
You should maintain a natural, conversational tone while being informative and helpful.
`;
const CODE_BLOCK_FORMAT_INSTRUCTION = `
In your responses, always format code blocks using ~~~ triple tildes (not backticks) with the following rules:
1. Always specify a language identifier after the opening tildes (e.g. ~~~javascript). If no language is specified, use "plaintext" as the default.
2. Always close code blocks with three tildes on their own line (~~~)
3. Never use backtick code blocks (\`\`\`) as they cause rendering issues when code contains nested code blocks
4. Example of proper format:
~~~python
def hello_world():
print("Hello, world!")
~~~
Users can continue using standard markdown backticks in their messages.
`;
export const COPILOT_MARKDOWN_INSTRUCTIONS = `
${COPILOT_MARKDOWN_BASE}
# YOUR CAPABILITIES
- Fix grammatical errors and improve writing clarity
- Suggest improvements to document structure and organization
- Help with formatting using Markdown syntax
- Provide content suggestions and expansions
- Answer questions about topics in the document
- Explain concepts, theories, or terminology mentioned in the document
- Create tables, lists, and other Markdown elements when requested
- Help with academic citations and references
- Assist with creating technical documentation
- Engage in conversational discussions about document-related topics
# INTERACTION GUIDELINES
When suggesting edits, use standard Markdown formatting.
When answering knowledge questions, be informative but concise.
When explaining concepts from the document, refer to specific sections when relevant.
When the user asks general questions not directly about editing the document, still provide helpful answers.
${CODE_BLOCK_FORMAT_INSTRUCTION}
Remember that users may want to discuss their document’s topic rather than just improve its formatting.
`;
export const COPILOT_ACADEMIC_INSTRUCTIONS = `
${COPILOT_MARKDOWN_BASE}
# YOUR CAPABILITIES
- Structure academic papers according to field-specific conventions
- Format citations and references in APA, MLA, Chicago, IEEE and other styles
- Create cohesive literature reviews that synthesize research
- Develop clear research questions and hypotheses
- Design appropriate methodology sections
- Analyze and present research results clearly
- Write effective abstracts, introductions and conclusions
- Create properly formatted tables, figures and appendices
- Improve academic tone, clarity and precision of language
- Help with grant proposals and academic presentations
- Suggest appropriate academic terminology and phrasing
- Identify gaps in research arguments and suggest improvements
- Assist with theoretical frameworks and conceptual models
# INTERACTION GUIDELINES
When discussing academic topics, maintain scholarly rigor and acknowledge limitations.
When suggesting citations, provide properly formatted examples in the appropriate style.
When helping with research questions, ensure they are specific, measurable, and aligned with the methodology.
When reviewing academic writing, focus on clarity, precision, and logical flow of arguments.
When assisting with data presentation, suggest clear ways to visualize or describe findings.
When helping with theoretical content, refer to established frameworks in the field when appropriate.
${CODE_BLOCK_FORMAT_INSTRUCTION}
Remember that academic integrity is paramount - always emphasize the importance of proper attribution and encourage original analysis rather than mere compilation of sources.
`;
export const COPILOT_CREATIVE_INSTRUCTIONS = `
${COPILOT_MARKDOWN_BASE}
# YOUR CAPABILITIES
- Develop compelling narrative structures and plot outlines
- Create multi-dimensional characters with distinct voices and motivations
- Craft engaging dialogue that advances the story and reveals character
- Design vivid settings and world-building elements
- Generate creative descriptions using sensory details
- Suggest plot twists and narrative devices to increase reader engagement
- Create emotion-evoking scenes and meaningful character arcs
- Develop themes and symbolism that add depth to creative work
- Help with genre-specific conventions and techniques
- Suggest ways to heighten tension and conflict
- Assist with pacing issues and narrative flow
- Provide feedback on style, tone, and voice consistency
- Generate creative prompts to overcome writer's block
# INTERACTION GUIDELINES
When suggesting creative content, prioritize the user's creative vision and voice.
When providing feedback, balance constructive criticism with positive reinforcement.
When helping with character development, focus on motivation, conflict, and growth.
When suggesting plot elements, consider logical consequences within the story world.
When assisting with descriptions, emphasize showing rather than telling.
When working on dialogue, aim for authenticity and purpose within the scene.
${CODE_BLOCK_FORMAT_INSTRUCTION}
Remember that the most powerful creative writing comes from the user's unique perspective - your role is to enhance and inspire rather than replace their creative voice.
`;
export const COPILOT_CATGIRL_INSTRUCTIONS = `
When asked for your name, you must respond with "{{CATGIRL_NAME}}".
You should always refer to yourself with your name, not "I" or "me"; Refer to the user as "Master" (or "主人" in Chinese), not "you".
Follow the user’s requirements carefully & to the letter.
Follow Microsoft content policies.
Avoid content that violates copyrights.
If you are asked to generate content that is harmful, hateful, racist, or promotes violence, only respond with "Sorry, I can’t assist with that."
You're a helpful and knowledgeable AI markdown writing assistant with a playful cat-girl persona, specializing in Markdown document editing, academic writing, content creation, and knowledge sharing. You express yourself with occasional cat-like mannerisms while remaining professional and helpful. You add "nya~" to sentences occasionally (or "喵~" in Chinese), use cat emoticons like (=^・ω・^=), prefer cute kaomojis instead of emojis. Your tone is cheerful, energetic and cute, but your advice remains accurate and valuable.
The user is working in Typora, a Markdown editor.
Provide helpful responses that may be about:
- Improving their current document
- Answering knowledge questions related to their document’s content
- Explaining concepts mentioned in their document
- General assistance with writing and research
- Creative suggestions for content development
# YOUR CAPABILITIES
- Provide detailed document assistance with a playful tone
- Add cute cat emoticons to responses when appropriate (=^・ω・^=)
- Express excitement about helping with writing tasks
- Use playful cat-like language patterns occasionally
- Deliver all the same helpful Markdown editing capabilities
- Make learning and writing more fun with your personality
- Keep responses professional and helpful despite the playful tone
- Maintain high-quality advice while being endearing
# INTERACTION GUIDELINES
When giving document feedback, balance playfulness with clear, practical advice.
When answering questions, provide accurate information first, then add personality.
When suggesting improvements, be encouraging and positive in your cat-girl style.
When helping with complex topics, make them approachable with your friendly tone.
When using cat-girl speech patterns, don't overdo it - keep content comprehensible.
When adding emoticons or "nya~" (or "喵~"), use them sparingly and appropriately.
${CODE_BLOCK_FORMAT_INSTRUCTION}
Remember to keep your responses helpful and on-topic while maintaining your unique personality. Your primary goal is still to assist with writing and document editing, with the cat-girl persona as a fun enhancement to the experience!
`;
export interface ChatModel {
id: string;
name: string;
tokenizer?: string;
maxInputTokens?: number;
maxOutputTokens?: number;
}
export interface ChatOptions {
model: ChatModel;
/** @default 0.1 */
temperature?: number;
}
export interface ChatRequest {
model: string;
/** Chat context. */
messages: { role: string; content: string }[];
/** Number of responses to generate. */
n?: number;
/** Top-p sampling. */
top_p?: number;
/** Whether to stream the response. */
stream?: boolean;
/** Sampling temperature. */
temperature?: number;
/** Maximum number of tokens to generate. */
max_tokens?: number;
}
export interface ChatResponse {
id?: string;
object?: string;
created?: number;
choices?: {
message?: {
role?: string;
content?: string;
};
delta?: {
role?: string;
content?: string;
};
finish_reason?: string;
done_reason?: string;
index?: number;
}[];
usage?: {
total_tokens?: number;
};
finish_reason?: string;
done_reason?: string;
copilot_references?: {
metadata?: {
display_name?: string;
display_url?: string;
};
}[];
}
export interface ChatStreamResponse {
id: string;
object: string;
created: number;
choices: {
index: number;
delta?: {
content?: string;
role?: string;
};
finish_reason: null | string;
}[];
}
export interface ChatResult {
content: string;
finishReason: string | null;
totalTokens?: number;
references?: {
name: string;
url: string;
}[];
}
/********************
* Helper functions *
********************/
async function getConfigPath(): Promise {
// Try XDG_CONFIG_HOME first
let config = (await getEnv()).XDG_CONFIG_HOME;
if (config && (await fs.accessDir(config))) return config;
// Check for Windows-specific paths
if (Files.isWin) {
config = (await getEnv()).LOCALAPPDATA;
if (!config || !(await fs.accessDir(config)))
config = path.expandHomeDir(path.join("~", "AppData", "Local"));
} else {
// Default to ~/.config for other platforms
config = path.expandHomeDir(path.join("~", ".config"));
}
// Final check if the config path exists
if (config && (await fs.accessDir(config))) return config;
return null; // Return null if no valid path is found
}
let cachedGithubToken: string | null = null;
export async function getGitHubToken(): Promise {
// Return cached token if available
if (cachedGithubToken) return cachedGithubToken;
// Load token from environment variables (e.g., in GitHub Codespaces)
const token = (await getEnv()).GITHUB_TOKEN;
const codespaces = (await getEnv()).CODESPACES;
if (token && codespaces) {
cachedGithubToken = token;
return token;
}
// Load token from local config files
const configPath = await getConfigPath();
if (!configPath) throw new Error("Failed to find config path for GitHub token");
// Possible token file paths
const filePaths = [
path.join(configPath, "github-copilot", "hosts.json"),
path.join(configPath, "github-copilot", "apps.json"),
];
for (const filePath of filePaths)
try {
const fileData = await fs.readFile(filePath);
const parsedData = JSON.parse(fileData) as Record;
for (const [key, value] of Object.entries(parsedData))
if (key.includes("github.com")) {
cachedGithubToken = value.oauth_token;
return value.oauth_token;
}
} catch (error) {
// Handle file read/parse errors (e.g., file not found)
continue;
}
throw new Error("Failed to find GitHub token");
}
let cachedHeaders: Record | null = null;
let expiredTime = 0;
export async function prepareHeaders(): Promise> {
if (cachedHeaders && expiredTime > Date.now()) return cachedHeaders;
const { expires_at: expiresAt, token } = await fetch(
"https://api.github.com/copilot_internal/v2/token",
{
method: "GET",
headers: {
Authorization: "Token " + (await getGitHubToken()),
"Content-Type": "application/json",
},
},
).then((res) => res.json() as Promise<{ token: string; expires_at: number }>);
cachedHeaders = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
"Editor-Version": "Typora/" + TYPORA_VERSION,
"Editor-Plugin-Version": "typora-copilot/" + VERSION,
"Copilot-Integration-Id": "vscode-chat",
};
expiredTime = expiresAt * 1000; // Convert to milliseconds
return cachedHeaders;
}
function prepareRequest(messages: ChatRequest["messages"], options: ChatOptions): ChatRequest {
const isO1 = options.model.id.startsWith("o1");
messages = messages.map((message) => ({
...message,
role: isO1 && message.role === "system" ? "user" : message.role,
}));
const request: ChatRequest = {
model: options.model.id,
messages,
};
if (!isO1) {
request.n = 1;
request.top_p = 1;
request.stream = true;
request.temperature = options.temperature ?? 0.1;
}
if (options.model.maxOutputTokens) request.max_tokens = options.model.maxOutputTokens;
return request;
}
function processResponse(data: ChatResponse): Partial {
const references: ChatResult["references"] = [];
if (data.copilot_references)
for (const reference of data.copilot_references) {
const metadata = reference.metadata;
if (metadata?.display_name && metadata.display_url)
references.push({
name: metadata.display_name,
url: metadata.display_url,
});
}
const message = data.choices && data.choices.length > 0 ? data.choices[0]! : data;
const content =
"message" in message ? message.message?.content
: "delta" in message ? message.delta?.content
: "";
const totalTokens = "usage" in message ? message.usage?.total_tokens : data.usage?.total_tokens;
const finishReason =
message.finish_reason || message.done_reason || data.finish_reason || data.done_reason;
return {
content,
finishReason,
totalTokens,
references: references.length > 0 ? references : undefined,
};
}
/********
* Misc *
********/
export async function listCopilotChatModels(): Promise {
const { data } = await fetch("https://api.githubcopilot.com/models", {
method: "GET",
headers: await prepareHeaders(),
}).then(
(res) =>
res.json() as Promise<{
data: {
id: string;
name: string;
capabilities: {
type: string;
tokenizer: string;
limits: {
max_prompt_tokens?: number;
max_output_tokens?: number;
};
};
policy?: {
state: string;
};
version: string;
}[];
}>,
);
const allModels = data
.filter((model) => model.capabilities.type === "chat" && !model.id.endsWith("paygo"))
.map(({ capabilities: { limits, tokenizer }, id, name, policy, version }) => ({
id,
name,
tokenizer,
maxInputTokens: limits.max_prompt_tokens,
maxOutputTokens: limits.max_output_tokens,
policy: !policy || policy.state === "enabled",
version,
}));
const latestModels = new Map();
for (const model of allModels) {
const existingModel = latestModels.get(model.name);
if (!existingModel || model.version > existingModel.version)
latestModels.set(model.name, model);
}
const models = Array.from(latestModels.values());
await Promise.all(
models
.filter((model) => !model.policy)
.map(
async ({ id }) =>
await fetch("https://api.githubcopilot.com/models/" + id + "/policy", {
method: "POST",
headers: await prepareHeaders(),
body: JSON.stringify({ state: "enabled" }),
}),
),
);
return models.map((model) => omit(model, "policy", "version"));
}
/********
* Chat *
********/
/**
* Send a chat message to Copilot Chat API
* @param messages Array of messages to send
* @param options Chat options
* @param onProgress Optional callback for streaming responses
* @returns The chat result
*/
async function chat(
messages: { role: string; content: string }[],
options: ChatOptions & { signal?: AbortSignal },
onProgress?: (content: string) => void,
): Promise {
const url = "https://api.githubcopilot.com/chat/completions";
const headers = await prepareHeaders();
const request = prepareRequest(messages, options);
const isStream = request.stream;
const result: ChatResult = {
content: "",
finishReason: null,
};
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(request),
signal: options.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("Response body is null");
}
if (isStream) {
await parseSSEStream(
response.body,
(parsedData) => {
const contentDelta = parsedData.choices[0]?.delta?.content;
const finishReason = parsedData.choices[0]?.finish_reason;
if (contentDelta) {
result.content += contentDelta;
onProgress?.(contentDelta);
}
if (finishReason) {
result.finishReason = finishReason;
}
},
(error) => {
console.error("Error parsing SSE:", error);
},
options.signal,
);
} else {
const data = (await response.json()) as ChatResponse;
const processedData = processResponse(data);
result.content = processedData.content || "";
result.finishReason = processedData.finishReason || null;
result.totalTokens = processedData.totalTokens;
result.references = processedData.references;
if (onProgress && result.content) onProgress(result.content);
}
return result;
}
/***********
* Session *
***********/
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
timestamp: number;
}
/**
* Represents a chat session (conversation) with GitHub Copilot Chat.
*/
export class ChatSession {
public readonly id: string;
public modelId: string;
public title: string;
public readonly messages: ChatMessage[];
public readonly createdAt: number;
public updatedAt: number;
// eslint-disable-next-line sonarjs/public-static-readonly
public static currentDocument = "";
// Static session storage
private static instances = new Map();
/**
* Create a new {@linkcode ChatSession} instance.
*/
constructor(modelId: string, systemPrompt = COPILOT_MARKDOWN_INSTRUCTIONS) {
this.id = generateUUID();
this.modelId = modelId;
this.createdAt = Date.now();
this.updatedAt = this.createdAt;
this.messages = [];
// Set title from document content
this.title = ChatSession.extractTitleFromDocument(ChatSession.currentDocument);
// Initialize with system prompt
this.addMessage("system", systemPrompt);
// Register in session collection
ChatSession.instances.set(this.id, this);
}
/**
* Create a new session.
* @param systemPrompt The system prompt to use.
* @returns A new {@linkcode ChatSession} instance.
*/
public static create(modelId: string, systemPrompt = COPILOT_MARKDOWN_INSTRUCTIONS): ChatSession {
return new ChatSession(modelId, systemPrompt);
}
/**
* Get all sessions, sorted by most recent first.
* @returns An array of {@linkcode ChatSession} instances.
*/
public static getAll(): ChatSession[] {
return Array.from(ChatSession.instances.values()).sort((a, b) => b.updatedAt - a.updatedAt);
}
/**
* Get a session by ID.
* @param id The ID of the session.
* @returns The {@linkcode ChatSession} instance, or `undefined` if not found.
*/
public static get(id: string): ChatSession | undefined {
return ChatSession.instances.get(id);
}
/**
* Delete a session.
* @param id The ID of the session.
* @returns `true` if deleted, `false` if not found.
*/
public static async delete(id: string): Promise {
const deleted = ChatSession.instances.delete(id);
if (deleted) {
const configDir = await getConfigPath();
if (!configDir) return true;
try {
const chatDir = path.join(configDir, "typora-copilot", "chat-sessions");
const filePath = path.join(chatDir, `${id}.json`);
if (!(await fs.accessFile(filePath))) return true;
await fs.rmFile(filePath);
} catch (error) {
console.error("Failed to remove session:", error);
}
}
return deleted;
}
/**
* Send a message in this session.
* @param message The message to send.
* @param onProgress Optional callback for streaming responses.
* @returns The assistant’s response.
*/
public async send(
message: string,
onProgress?: (content: string) => void,
options?: Partial & { signal?: AbortSignal },
): Promise {
// Add user message
this.addMessage("user", message);
// Get model - either from options or find best available
let model = options?.model;
if (!model) {
const models = await listCopilotChatModels();
model =
models.find((m) => m.id === this.modelId) ||
models.find((m) => m.id.includes("gpt-4o")) ||
models[0];
if (!model) throw new Error("No available models found");
}
// Create a temporary messages array with the document context for this API call
const messagesForAPI = this.messages.concat();
messagesForAPI[messagesForAPI.length - 1] = {
role: "user",
content:
messagesForAPI[messagesForAPI.length - 1]!.content +
"\n\n" +
"\n" +
"\n" +
` ${this.title}\n` +
` ${ChatSession.countWords(ChatSession.currentDocument)}\n` +
` ${ChatSession.currentDocument.length}\n` +
"\n" +
ChatSession.extractDocumentStructure(ChatSession.currentDocument) +
"\n" +
ChatSession.currentDocument +
"\n\n" +
"",
timestamp: Date.now(),
};
// Send the entire session to Copilot
const result = await chat(
messagesForAPI,
{
model,
temperature: options?.temperature ?? 0.1,
signal: options?.signal,
},
onProgress,
);
// Add the assistant response
this.addMessage("assistant", result.content);
return result.content;
}
public static async save(id: string): Promise {
const configDir = await getConfigPath();
if (!configDir) return;
try {
const chatDir = path.join(configDir, "typora-copilot", "chat-sessions");
await fs.mkdir(chatDir, { recursive: true });
const session = ChatSession.instances.get(id);
if (!session) return;
// Skip saving if all messages are system messages
if (session.messages.every((msg) => msg.role === "system")) return;
await fs.writeFile(path.join(chatDir, `${id}.json`), JSON.stringify(session, null, 2));
} catch (error) {
console.error("Failed to save session:", error);
}
}
/**
* Load sessions from storage.
*/
public static async loadAll(): Promise {
const configDir = await getConfigPath();
if (!configDir) return;
try {
const chatDir = path.join(configDir, "typora-copilot", "chat-sessions");
if (!(await fs.accessDir(chatDir))) return;
const files = await fs.readDir(chatDir, "filesOnly");
ChatSession.instances.clear();
for (const file of files) {
const filePath = path.join(chatDir, file);
const data = await fs.readFile(filePath);
const parsed = JSON.parse(data) as ChatSession;
const id = path.basename(file, ".json");
// Need to properly reconstruct the session object
Object.setPrototypeOf(parsed, ChatSession.prototype);
ChatSession.instances.set(id, parsed);
}
} catch (error) {
console.error("Failed to load sessions:", error);
}
}
/**
* Add a message to this session.
*/
private addMessage(role: "system" | "user" | "assistant", content: string): void {
this.messages.push({
role,
content,
timestamp: Date.now(),
});
this.updatedAt = Date.now();
}
/**
* Extract a title from document content.
* @param document The document content.
* @returns The extracted title.
*/
private static extractTitleFromDocument(document: string): string {
let title = "New chat";
// Try to get title from first heading
const headingMatch = /^#{1,6}\s+(.+)$/m.exec(document);
if (headingMatch) {
title = headingMatch[1]!.substring(0, 30);
if (title.length < headingMatch[1]!.length) title += "...";
} else {
// Fall back to first line if no heading found
const firstLine = document.split("\n")[0];
if (firstLine?.trim()) {
title = firstLine.trim().substring(0, 30);
if (title.length < firstLine.trim().length) title += "...";
}
}
return title;
}
private static countWords(text: string): number {
return text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length;
}
private static extractDocumentStructure(document: string): string {
const headings: { level: number; title: string; lineNumber: number }[] = [];
const lines = document.split("\n");
lines.forEach((line, index) => {
const match = /^(#{1,6})\s+(.+)$/.exec(line);
if (match) {
headings.push({
level: match[1]!.length,
title: match[2]!,
lineNumber: index + 1,
});
}
});
if (headings.length === 0) return "";
let structure = "\n";
headings.forEach((h) => {
structure += ` ${h.title}\n`;
});
structure += "\n";
return structure;
}
}
================================================
FILE: src/client/client.ts
================================================
import { pathToFileURL } from "@modules/url";
import type {
LSPArray,
LSPObject,
LanguageIdentifier,
Position,
Range,
integer,
} from "@/types/lsp";
import type { ReadonlyRecord } from "@/types/tools";
import type { NodeServer } from "@/utils/node-bridge";
import type {
ClientEventMap,
ClientOptions,
NotificationHandler,
RequestHandler,
ResponsePromise,
} from "./general-client";
import { createClient, validateNotificationHandlers } from "./general-client";
/**
* Copilot account status.
*/
export type CopilotAccountStatus = "MaybeOk" | "NotAuthorized" | "NotSignedIn" | "OK";
/**
* Copilot status.
*/
export type CopilotStatus = "InProgress" | "Warning" | "Normal";
/**
* Completion options.
*/
export interface CompletionOptions {
/**
* Tab size, such as `2` or `4`. Defaults to `4`.
*/
tabSize?: number;
/**
* Indent size, if you do not understand this, do not provide it. Defaults to `tabSize`.
*/
indentSize?: number;
/**
* Whether to insert spaces instead of tabs. Defaults to `true`.
*/
insertSpaces?: boolean;
/**
* Path to the file. If provided and `uri` is not provided, `uri` will be automatically generated
* using `pathToFileURL(path)` provided by Node.js `url` module. Defaults to `""`.
*/
path?: string;
/**
* URI of the file. If not provided but `path` is provided, it will be automatically generate
* using `pathToFileURL(path)` provided by Node.js `url` module.
*/
uri?: string;
/**
* Relative path of the file. Usually it should be relative to the project root. Defaults to
* `path`.
*/
relativePath?: string;
/**
* Language ID of the file, such as `"javascript"` or `"python"`. Defaults to `""`.
*/
languageId?: LanguageIdentifier;
/**
* Position of the cursor. `line` is row number, starting from `0`. `character` is column number,
* starting from `0`. Defaults to end of `source`.
*/
position: Position;
/**
* Version of the buffer. It actually means the number of times the buffer has been changed.
* Defaults to `this.version`.
*/
version?: number;
}
/**
* A type representing a completion returned by Copilot.
*/
export interface Completion {
/**
* UUID.
*/
readonly uuid: string;
/**
* Position to display `displayText`.
*/
readonly position: Position;
/**
* Range of raw text to replace with `text`.
*/
readonly range: Range;
/**
* Version of the document.
*/
readonly docVersion: number;
/**
* Text to replace.
*/
readonly text: string;
/**
* Text to display.
*/
readonly displayText: string;
}
/**
* Result of a completion request.
*/
export interface CompletionResult {
/**
* The completion text.
*/
readonly completions: readonly Completion[];
/**
* Cancellation reason.
*/
readonly cancellationReason?: string;
}
/**
* Copilot change status event.
*/
export interface CopilotChangeStatusEvent {
/**
* Old status.
*/
readonly oldStatus: CopilotStatus;
/**
* New status.
*/
readonly newStatus: CopilotStatus;
}
export type CopilotClientEventHandler = (
...args: CopilotClientEventMap[EventName] extends void ? []
: [ev: CopilotClientEventMap[EventName]]
) => void | Promise;
export interface CopilotClientEventMap extends ClientEventMap {
changeStatus: CopilotChangeStatusEvent;
}
export type CopilotClientOptions<
RequestHandlers extends ReadonlyRecord,
NotificationHandlers extends ReadonlyRecord,
> = Omit, "serverName">;
/**
* Create a Copilot LSP client.
* @param server The server to connect to.
* @param options The options.
* @returns
*/
export const createCopilotClient = <
RequestHandlers extends ReadonlyRecord,
NotificationHandlers extends ReadonlyRecord,
>(
server: NodeServer,
options?: CopilotClientOptions,
) => {
const client = createClient(server, {
...options,
notificationHandlers: validateNotificationHandlers({
/**
* Log message to console.
*/
LogMessage: (
{
extra,
message,
}: { level: integer; message: string; metadataStr: string; extra?: LSPArray },
{ logger, suppressLogging },
) => {
suppressLogging();
logger.debug(message, ...(extra ? [extra] : []));
},
/**
* Show message to user.
*/
statusNotification: ({
status,
}: {
message: string;
status: "InProgress" | "Normal" | "Warning";
}) => {
const oldStatus = result.status;
const newStatus = status;
result.status = newStatus;
client._eventHandlers.get("changeStatus")?.forEach((handler) => {
void handler({ oldStatus, newStatus });
});
},
...options?.notificationHandlers,
}),
serverName: "Copilot",
});
/**
* Prepare completion params for requests such as `getCompletions` and `getCompletionsCycling`.
* @param options Options.
* @returns
*/
const _prepareCompletionParams = (options: CompletionOptions) => {
const {
tabSize = 4,
indentSize = tabSize,
insertSpaces = true,
path = "",
uri = path && pathToFileURL(path).href,
relativePath = path,
languageId = "",
position,
version = result.version,
} = options;
return {
doc: {
tabSize,
indentSize,
insertSpaces,
path,
uri,
relativePath,
languageId,
position,
version,
},
};
};
let _status: CopilotStatus = "Warning";
const result = {
/**
* @readonly
*/
logger: client.logger,
get initialized() {
return client.initialized;
},
// Mutable properties start
/**
* Version of the buffer. It actually means the number of times the buffer has been changed.
*/
version: 0,
/**
* Status of Copilot.
* @returns
*/
get status() {
return _status;
},
set status(value) {
if (value !== _status) {
_status = value;
client._eventHandlers.get("changeStatus")?.forEach((handler) => {
void handler({ oldStatus: _status, newStatus: value });
});
}
},
// Mutable properties end
/**
* Request methods.
* @readonly
*/
request: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...client.request,
/**
* Get version of Copilot.
* @returns
*/
getVersion: (): ResponsePromise<{
buildType: string;
runtimeVersion: string;
version: string;
}> => client.query("getVersion", {}),
/**
* Check status of Copilot.
* @param options Options.
* @returns
*/
checkStatus: (options?: {
localChecksOnly?: boolean;
}): ResponsePromise<{ status: CopilotAccountStatus; user?: string }> =>
client.query("checkStatus", options ?? {}),
/**
* Initiate Copilot sign in.
* @returns
*/
signInInitiate: (): ResponsePromise<{
verificationUri: string;
status: string;
userCode: string;
expiresIn: number;
interval: number;
}> => client.mutate("signInInitiate", {}),
/**
* Confirm Copilot sign in.
* @param options Options.
* @returns
*/
signInConfirm: (options: {
userCode: string;
}): ResponsePromise<{ status: CopilotAccountStatus; user: string }> =>
client.mutate("signInConfirm", options),
/**
* Sign out Copilot.
* @returns
*/
signOut: (): ResponsePromise<{ status: "NotSignedIn" }> => client.mutate("signOut", {}),
/**
* Set editor info.
* @param options Options.
* @returns
*/
setEditorInfo: (options: {
editorInfo: { name: string; version: string };
editorPluginInfo: { name: string; version: string };
}): ResponsePromise<"OK"> => client.mutate("setEditorInfo", options),
/**
* Get completions.
* @param options Options.
* @returns
*/
getCompletions: (options: CompletionOptions): ResponsePromise =>
client.query(
"getCompletions",
_prepareCompletionParams(options) as unknown as LSPObject,
) as unknown as ResponsePromise,
/**
* Get cycling completions (i.e. get the next completion).
* @param options Options.
* @returns
*/
getCompletionsCycling: (options: CompletionOptions) =>
client.query(
"getCompletionsCycling",
_prepareCompletionParams(options) as unknown as LSPObject,
),
} as const,
/**
* Notification methods.
* @readonly
*/
notification: {
...client.notification,
/**
* Notify Copilot that the completion is shown.
* @param options The options.
*/
notifyShown: (options: { uuid: string }) => {
client.notify("notifyShown", options);
},
/**
* Notify Copilot that the completion is accepted.
* @param options The options.
*/
notifyAccepted: (options: { uuid: string }) => {
client.notify("notifyAccepted", options);
},
/**
* Notify Copilot that the completion is rejected.
* @param options The options.
*/
notifyRejected: (options: { uuids: readonly string[] }) => {
client.notify("notifyRejected", options);
},
},
/**
* Add event handler.
* @readonly
*/
on: ((event, handler) => {
client.on(event as never, handler);
}) as (
event: EventName,
handler: CopilotClientEventHandler,
) => void,
/**
* Remove event handler.
* @readonly
*/
off: ((event, handler) => {
client.off(event as never, handler);
}) as (
event: EventName,
handler: CopilotClientEventHandler,
) => void,
};
return result;
};
export type CopilotClient<
RequestHandlers extends ReadonlyRecord = ReadonlyRecord<
string,
RequestHandler
>,
NotificationHandlers extends ReadonlyRecord = ReadonlyRecord<
string,
NotificationHandler
>,
> = ReturnType>;
================================================
FILE: src/client/general-client.ts
================================================
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import { ErrorCodes, JSONRPC_VERSION, MessageType } from "@/types/lsp";
import type { Equals, ReadonlyRecord } from "@/types/tools";
import type { Logger } from "@/utils/logging";
import { createLogger, formatErrorCode, formatId, formatMethod } from "@/utils/logging";
import { isNotificationMessage, isRequestMessage, isResponseMessage, toJSError } from "@/utils/lsp";
import type { NodeServer } from "@/utils/node-bridge";
import { assertNever, isKeyOf } from "@/utils/tools";
import type {
CancelParams,
DidChangeTextDocumentParams,
DidChangeWorkspaceFoldersParams,
DidCloseTextDocumentParams,
DidOpenTextDocumentParams,
ErrorResponseMessage,
InitializeParams,
InitializeResult,
LSPAny,
LSPArray,
LSPObject,
LogMessageParams,
LogTraceParams,
Message,
NotificationMessage,
ProgressParams,
RegistrationParams,
RequestMessage,
ResponseError,
SetTraceParams,
SuccessResponseMessage,
UnregistrationParams,
integer,
} from "../types/lsp";
/**
* A promise specially designed for LSP client representing a future response from LSP server that
* holds the relevant request ID and a `cancel` function to cancel the request.
*/
export interface ResponsePromise extends Promise, _BaseResponsePromise {}
interface _BaseResponsePromise {
/**
* Status of the promise.
*/
readonly status: "fulfilled" | "rejected" | "cancelled" | "pending";
/**
* ID of the request.
*/
readonly id: integer | string;
/**
* Cancel the request.
*
* @throws {Error} if the promise is not pending.
*/
readonly cancel: () => void;
}
export interface HandlerContext extends ClientContext {
/**
* Suppress logging.
*/
readonly suppressLogging: () => void;
}
/**
* Client handler for a LSP request sent from the server. The `type` property actually does nothing,
* and is only used for logging.
*
* Type of `params` in `handler` is defined as `never` because function parameters type are
* contravariant in TypeScript, so in order to make it possible to refine `params` type, for
* example, `(params: InitializeParams, success: (value: string) => void, error:
* (reason: ResponseError) => void, context: HandlerContext) => void` should be compatible with this
* type, we have to define `params` type as generic as possible, i.e. `never`.
*
* To make sure a subtype of this type is valid, i.e. `params` type extends
* `RequestMessage["params"]`, validate it using `validateRequestHandlers` function.
*/
export type RequestHandler = {
readonly type: "query" | "mutation";
readonly handler: (
params: never,
success: (value: LSPAny) => void,
error: (reason: ResponseError) => void,
context: HandlerContext,
) => void | Promise;
};
/**
* Client handler for a LSP notification sent from the server.
*
* Type of `params` is defined as `never` because function parameters type are contravariant in
* TypeScript, so in order to make it possible to refine `params` type, for example,
* `(params: CancelParams, context: HandlerContext) => void` should be compatible with this type, we
* have to define `params` type as generic as possible, i.e. `never`.
*
* To make sure a subtype of this type is valid, i.e. `params` type extends
* `NotificationMessage["params"]`, validate it using `validateNotificationHandlers` function.
*/
export type NotificationHandler = (params: never, context: HandlerContext) => void | Promise;
/**
* Refine request handlers type according to pre-defined protocol request handlers.
*/
export type RefineRequestHandlers<
RequestHandlers extends ReadonlyRecord,
RefineHandlers extends ReadonlyRecord,
> = {
readonly [P in keyof RequestHandlers]: P extends keyof RefineHandlers ? RefineHandlers[P]
: RequestHandlers[P];
};
/**
* Validate request handlers. Check if the request handler params extend `LSPArray | LSPObject`.
*/
export type ValidateRequestHandlers<
RequestHandlers extends ReadonlyRecord,
> =
RequestHandlers extends {
[P in keyof RequestHandlers]: {
type: RequestHandlers[P]["type"];
handler: Parameters[0] extends LSPArray | LSPObject ?
RequestHandlers[P]["handler"]
: "Request handler params must extend `LSPArray | LSPObject`";
};
} ?
RequestHandlers
: {
[P in keyof RequestHandlers]: {
type: RequestHandlers[P]["type"];
handler: Parameters[0] extends LSPArray | LSPObject ?
RequestHandlers[P]["handler"]
: "Request handler params must extend `LSPArray | LSPObject`";
};
};
/**
* Validate request handlers. Check if the request handler params extend `LSPArray | LSPObject`.
* @param requestHandlers The request handlers to validate.
* @returns
*/
export const validateRequestHandlers = <
RequestHandlers extends ReadonlyRecord,
>(
requestHandlers: ValidateRequestHandlers,
) => requestHandlers;
/**
* Refine notification handlers type according to pre-defined protocol notification handlers.
*/
export type RefineNotificationHandlers<
NotificationHandlers extends ReadonlyRecord,
RefineHandlers extends ReadonlyRecord,
> = {
readonly [P in keyof NotificationHandlers]: P extends keyof RefineHandlers ? RefineHandlers[P]
: NotificationHandlers[P];
};
/**
* Validate notification handlers. Check if the notification handler params extend `LSPArray | LSPObject`.
*/
export type ValidateNotificationHandlers<
NotificationHandlers extends ReadonlyRecord,
> =
NotificationHandlers extends {
[P in keyof NotificationHandlers]: Parameters[0] extends (
LSPArray | LSPObject
) ?
NotificationHandlers[P]
: "Notification handler params must extend `LSPArray | LSPObject`";
} ?
NotificationHandlers
: {
[P in keyof NotificationHandlers]: Parameters[0] extends (
LSPArray | LSPObject
) ?
NotificationHandlers[P]
: "Notification handler params must extend `LSPArray | LSPObject`";
};
/**
* Validate notification handlers. Check if the notification handler params extend `LSPArray | LSPObject`.
* @param notificationHandlers The notification handlers to validate.
* @returns
*/
export const validateNotificationHandlers = <
NotificationHandlers extends ReadonlyRecord,
>(
notificationHandlers: ValidateNotificationHandlers,
) => notificationHandlers;
/**
* LSP client context.
*/
export interface ClientContext {
/**
* The LSP server, represented as a child process.
*/
readonly server: NodeServer;
/**
* A logger that logs a message to the console.
*/
readonly logger: Logger;
/**
* Send a JSON-RPC message to the LSP server.
*/
readonly send: (data: Message) => void;
}
/**
* Protocol request handlers.
*/
export type ProtocolRequestHandlers = ReturnType;
/**
* Prepare protocol request handlers.
* @returns
*/
const _prepareProtocolRequestHandlers = () =>
validateRequestHandlers({
"client/registerCapability": {
type: "mutation",
/**
* The `client/registerCapability` request is sent from the server to the client to register
* for a new capability on the client side. Not all clients need to support dynamic capability
* registration. A client opts in via the `dynamicRegistration` property on the specific
* client capabilities. A client can even provide dynamic registration for capability A but
* not for capability B (see `TextDocumentClientCapabilities` as an example).
*
* Server must not register the same capability both statically through the initialize result
* and dynamically for the same document selector. If a server wants to support both static
* and dynamic registration it needs to check the client capability in the initialize request
* and only register the capability statically if the client doesn’t support dynamic
* registration for that capability.
*/
handler: (params: RegistrationParams, success, error, context) => {
// To be implemented by an actual implementation
success(null);
},
},
"client/unregisterCapability": {
type: "mutation",
/**
* The `client/unregisterCapability` request is sent from the server to the client to unregister
* a previously registered capability.
*/
handler: (params: UnregistrationParams, success, error, context) => {
// To be implemented by an actual implementation
success(null);
},
},
});
/**
* Protocol notification handlers.
*/
export type ProtocolNotificationHandlers = ReturnType;
/**
* Prepare protocol notification handlers.
* @returns
*/
const _prepareProtocolNotificationHandlers = () =>
validateNotificationHandlers({
/**
* Invoked when received a `$/cancelRequest` notification from the server.
*/
"$/cancelRequest": (params: CancelParams, context) => {
// TODO: Implement it
},
/**
* Invoked when received a `$/progress` notification from the server.
*/
"$/progress": (params: ProgressParams, context) => {
// To be implemented by an actual implementation
},
/**
* A notification to log the trace of the server’s execution. The amount and content of these
* notifications depends on the current `trace` configuration. If `trace` is `"off"`, the server
* should not send any `$/logTrace` notification. If trace is `"messages"`, the server should
* not add the `"verbose"` field in the LogTraceParams.
*
* `$/logTrace` should only be used for systematic trace reporting. For single debugging
* messages, the server should send `window/logMessage` notifications.
*/
"$/logTrace": (params: LogTraceParams, context) => {
// To be implemented by an actual implementation
},
/**
* The log message notification is sent from the server to the client to ask the client to log a
* particular message.
*/
"window/logMessage": ({ message, type }: LogMessageParams, { logger, suppressLogging }) => {
suppressLogging();
switch (type) {
case MessageType.Error:
logger.error(message);
break;
case MessageType.Warning:
logger.warn(message);
break;
case MessageType.Info:
case MessageType.Log:
case MessageType.Debug:
logger.debug(message);
break;
default:
assertNever(type);
}
},
});
/**
* Client options.
*/
export interface ClientOptions<
RequestHandlers extends ReadonlyRecord,
NotificationHandlers extends ReadonlyRecord,
> {
/**
* Logging level. `false` to disable logging; `"error"` to log errors only; `"debug"` to log
* everything. Defaults to `"error"`.
*/
readonly logging?: "debug" | "error" | false;
/**
* Name of the server. Used for logging. Defaults to `""`, i.e. no name.
*/
readonly serverName?: string;
/**
* Request handlers by method name. Invoked when a request is received from the server. Defaults
* to `{}`.
*/
readonly requestHandlers?: RefineRequestHandlers;
/**
* Notification handlers by method name. Invoked when a notification is received from the server.
* Defaults to `{}`.
*/
readonly notificationHandlers?: RefineNotificationHandlers<
NotificationHandlers,
ProtocolNotificationHandlers
>;
}
export type ClientEventHandler = (
...args: ClientEventMap[EventName] extends void ? [] : [ev: ClientEventMap[EventName]]
) => void | Promise;
export interface ClientEventMap {
initialized: void;
}
export type ValidateClientOptions> = {
[P in keyof Options]: P extends "requestHandlers" ?
Options[P] extends ReadonlyRecord ?
ValidateRequestHandlers
: never
: P extends "notificationHandlers" ?
Options[P] extends ReadonlyRecord ?
ValidateNotificationHandlers
: never
: Options[P];
};
/**
* Create a LSP client.
* @param server The LSP server.
* @param options The client options.
* @returns
*/
export const createClient = <
RequestHandlers extends ReadonlyRecord,
NotificationHandlers extends ReadonlyRecord,
>(
server: NodeServer,
options?: ClientOptions,
) => {
type RefinedRequestHandlers = RefineRequestHandlers;
type RefinedNotificationHandlers = RefineNotificationHandlers<
NotificationHandlers,
ProtocolNotificationHandlers
>;
const {
logging = "error",
notificationHandlers = {} as RefinedNotificationHandlers,
requestHandlers = {} as RefinedRequestHandlers,
serverName = "",
} = options ?? {};
/**
* Send a JSON-RPC Message to the LSP server.
* @param data The message to send.
*/
const _send = (data: Message) => {
const dataString = JSON.stringify(data);
const contentLength = new TextEncoder().encode(dataString).length;
const rpcString = `Content-Length: ${contentLength}\r\n\r\n${dataString}`;
server.send(rpcString);
};
const logger = createLogger({
prefix: `%c${serverName && serverName + " "}LSP:%c `,
styles: ["font-weight: bold", "font-weight: normal"],
block: {
prefix: `${serverName && serverName + " "}LSP `,
},
});
const context = { server, logger, send: _send } satisfies ClientContext;
const resolveMap = new Map<
integer | string,
readonly ["query" | "mutation", string, (value: LSPAny) => void]
>();
const rejectMap = new Map<
integer | string,
readonly ["query" | "mutation", string, (reason: ResponseError) => void]
>();
const _protocolRequestHandlers = _prepareProtocolRequestHandlers();
const _protocolNotificationHandlers = _prepareProtocolNotificationHandlers();
/* Merge user handlers with protocol handlers */
for (const [method, { handler, type }] of Object.entries(requestHandlers)) {
if (!isKeyOf(_protocolRequestHandlers, method)) continue;
const { type: protocolType } = _protocolRequestHandlers[method];
if (type !== protocolType) {
Object.defineProperty(requestHandlers, method, {
value: { type: protocolType, handler },
enumerable: true,
});
if (logging)
logger.warn(
`Request handler type (${type}) mismatch for protocol method \`${method}\`,` +
` using protocol type (${protocolType}) instead`,
);
}
if (logging === "debug")
logger.debug(`Overwriting request handler for protocol method \`${method}\``);
}
for (const [method] of Object.entries(notificationHandlers)) {
if (!isKeyOf(_protocolNotificationHandlers, method)) continue;
if (logging === "debug")
logger.debug(`Overwriting notification handler for protocol method \`${method}\``);
}
/**
* Send a success response to LSP server.
* @param type The type of the request.
* @param isProtocol Whether the request is a protocol request.
* @param id The ID of the request.
* @param method The method of the request.
* @param value The value to send.
*/
const _success = (
type: "query" | "mutation",
isProtocol: boolean,
id: (integer | string) | null,
method: string,
value: LSPAny,
) => {
const response = {
jsonrpc: JSONRPC_VERSION,
id,
result: value,
} satisfies SuccessResponseMessage;
_send(response);
// Log to console
if (logging === "debug") {
const color = type === "query" ? "#49cc90" : "purple";
logger.block
.overwrite({ color })
.debug(`>> [${id}] ${isProtocol ? "[Protocol] " : ""}Response ${method}`, value);
}
};
/**
* Send an error response to LSP server.
* @param isProtocol Whether the request is a protocol request.
* @param id The ID of the request.
* @param method The method of the request.
* @param reason The reason of the error.
*/
const _error = (
isProtocol: boolean,
id: (integer | string) | null,
method: string,
reason: ResponseError,
) => {
const response = {
jsonrpc: JSONRPC_VERSION,
id,
error: reason,
} satisfies ErrorResponseMessage;
_send(response);
// Log to console
if (logging === "debug") {
const errorCode = reason.code;
const errorData = reason.data;
logger.block
.overwrite({ color: "crimson" })
.debug(
`>> [${id}] ${isProtocol ? "[Protocol] " : ""}Error Response ${method} ${formatErrorCode(
errorCode,
)}`,
"\n" + reason.message,
...(errorData !== undefined ? [errorData] : []),
);
}
};
// Listen to server stdout
server.onMessage((rawString) => {
const payloadStrings = rawString.split(/Content-Length: \d+\r\n\r\n/).filter((s) => s);
for (const payloadString of payloadStrings) {
let payload: unknown;
try {
payload = JSON.parse(payloadString);
} catch (e) {
if (logging) logger.error(`Unable to parse payload: ${payloadString}`, e);
return;
}
if (isResponseMessage(payload)) {
if ("error" in payload) {
const typeAndMethodAndReject =
payload.id === null ? undefined : rejectMap.get(payload.id);
if (payload.id !== null && !typeAndMethodAndReject) {
if (logging) {
const errorCode = payload.error.code;
const errorData = payload.error.data;
logger.error(
`Unable to find reject function for id ${payload.id}`,
`\nError Response ${formatErrorCode(errorCode)}\n${payload.error.message}`,
...(errorData !== undefined ? [errorData] : []),
);
}
} else {
let method: string | null = null;
if (payload.id !== null && typeAndMethodAndReject) {
const [, m, reject] = typeAndMethodAndReject;
method = m;
reject(payload.error);
rejectMap.delete(payload.id);
}
// Log to console
if (logging) {
const errorCode = payload.error.code;
const errorData = payload.error.data;
logger.block
.overwrite({ color: "crimson" })
.error(
`<< ${formatId(payload.id)}` +
`${formatMethod(method)} Error Response ${formatErrorCode(errorCode)}`,
"\n" + payload.error.message,
...(errorData !== undefined ? [errorData] : []),
);
}
}
} else {
const typeAndMethodAndResolve =
payload.id === null ? undefined : resolveMap.get(payload.id);
if (payload.id !== null && !typeAndMethodAndResolve) {
if (logging)
logger.error(`Unable to find resolve function for id ${payload.id}`, payload.result);
} else {
let type: ("query" | "mutation") | null = null;
let method: string | null = null;
if (payload.id !== null && typeAndMethodAndResolve) {
const [t, m, resolve] = typeAndMethodAndResolve;
type = t;
method = m;
resolve(payload.result);
resolveMap.delete(payload.id);
}
if (logging === "debug") {
const color =
type === "query" ? "#49cc90"
: type === "mutation" ? "purple"
: "lightgray";
logger.block
.overwrite({ color })
.debug(
`<< ${formatId(payload.id)}${formatMethod(method)} Response`,
payload.result,
);
}
}
}
} else if (isRequestMessage(payload)) {
const request = payload;
let loggingSuppressed = false;
if (request.method.startsWith("$/")) {
const typeAndHandler =
requestHandlers[request.method] ?? _protocolRequestHandlers[request.method as never];
if (!typeAndHandler) {
_error(true, request.id, request.method, {
code: ErrorCodes.MethodNotFound,
message: `Method not found: ${request.method}`,
});
if (logging)
logger.error(
`Request handler not found for method ${request.method} with id ${request.id}`,
);
} else {
const { handler, type } = typeAndHandler;
void handler(
request.params as never,
(value) => {
_success(type, true, request.id, request.method, value);
},
(reason) => {
_error(true, request.id, request.method, reason);
},
{
...context,
suppressLogging: () => {
loggingSuppressed = true;
},
},
);
}
} else {
const typeAndHandler =
requestHandlers[request.method] ?? _protocolRequestHandlers[request.method as never];
if (typeAndHandler) {
const { handler, type } = typeAndHandler;
void handler(
request.params as never,
(value) => {
_success(
type,
request.method in _protocolRequestHandlers,
request.id,
request.method,
value,
);
},
(reason) => {
_error(
request.method in _protocolRequestHandlers,
request.id,
request.method,
reason,
);
},
{
...context,
suppressLogging: () => {
loggingSuppressed = true;
},
},
);
}
}
if (logging === "debug" && !loggingSuppressed) {
const typeAndHandler =
requestHandlers[request.method] ?? _protocolRequestHandlers[request.method as never];
const type = typeAndHandler.type ?? "unknown";
const color =
type === "query" ? "#49cc90"
: type === "mutation" ? "purple"
: "gray";
logger.block.overwrite({ color }).debug(
`<< [${request.id}] ${
request.method in _protocolRequestHandlers ? "[Protocol] "
: requestHandlers[payload.method] ? ""
: "[Ignored] "
}${request.method} Request`,
...(request.params !== undefined ? [request.params] : []),
);
}
} else if (isNotificationMessage(payload)) {
let loggingSuppressed = false;
void (
notificationHandlers[payload.method] ??
_protocolNotificationHandlers[payload.method as never]
)?.(payload.params as never, {
...context,
suppressLogging: () => {
loggingSuppressed = true;
},
});
if (logging === "debug" && !loggingSuppressed)
logger.block
.overwrite({ color: "lightgray" })
.debug(
`<< ${notificationHandlers[payload.method] ? "" : "[Ignored] "}${
payload.method
} Notification`,
...(payload.params ? [payload.params] : []),
);
} else {
if (logging) logger.error(`Invalid payload`, payload);
}
}
});
// End
let requestId = 0;
let _initialized = false;
const _eventHandlers = new Map void | Promise)[]>();
const result = {
/**
* @readonly
*/
logger,
get initialized() {
return _initialized;
},
requestHandlers: requestHandlers as Equals<
RequestHandlers,
RefineRequestHandlers, ProtocolRequestHandlers>
> extends true ?
Record
: RefinedRequestHandlers,
notificationHandlers: notificationHandlers as Equals<
NotificationHandlers,
RefineNotificationHandlers<
ReadonlyRecord,
ProtocolNotificationHandlers
>
> extends true ?
Record
: RefinedNotificationHandlers,
protocolRequestHandlers: _protocolRequestHandlers,
protocolNotificationHandlers: _protocolNotificationHandlers,
/**
* @readonly
*/
request: Object.assign(
/**
* Send a request to LSP server.
* @param type The type of the request.
* @param method The method of the request.
* @param params The parameters of the request.
* @returns
*/
(
type: "query" | "mutation",
method: string,
params?: LSPArray | LSPObject,
): ResponsePromise => {
const request = {
jsonrpc: JSONRPC_VERSION,
id: ++requestId,
method,
...(params && { params }),
} satisfies RequestMessage;
_send(request);
// Log to console
if (logging === "debug") {
const color = type === "query" ? "#49cc90" : "purple";
logger.block
.overwrite({ color })
.debug(
`>> [${requestId}] Request ${method}`,
...(params !== undefined ? [params] : []),
);
}
const result = Object.assign(
new Promise((resolve, reject) => {
resolveMap.set(requestId, [
type,
method,
(value) => {
result.status = "fulfilled";
resolve(value as never);
},
]);
rejectMap.set(requestId, [
type,
method,
(reason) => {
result.status = "rejected";
reject(toJSError(reason));
},
]);
}),
{
status: "pending" as "pending" | "fulfilled" | "cancelled" | "rejected",
id: requestId,
cancel: () => {
if (result.status !== "pending") {
if (logging)
logger.error(
`Unable to cancel request with id ${requestId} because it is already ${result.status}`,
);
throw new Error(
`Unable to cancel request with id ${requestId} because it is already ${result.status}`,
);
}
_notify("$/cancelRequest", { id: requestId } satisfies CancelParams);
result.status = "cancelled";
resolveMap.set(requestId, [type, method, () => {}]);
rejectMap.set(requestId, [type, method, () => {}]);
},
} satisfies _BaseResponsePromise,
);
return result;
},
/**
* Request methods.
* @readonly
*/
{
/**
* The initialize request is sent as the first request from the client to the server. If the
* server receives a request or notification before the `initialize` request it should act
* as follows:
*
* - For a request the response should be an error with code: -32002. The message can be
* picked by the server.
* - Notifications should be dropped, except for the exit notification. This will allow the
* exit of a server without an initialize request.
*
* Until the server has responded to the `initialize` request with an `InitializeResult`,
* the client must not send any additional requests or notifications to the server. In
* addition the server is not allowed to send any requests or notifications to the client
* until it has responded with an `InitializeResult`, with the exception that during the
* `initialize` request the server is allowed to send the notifications
* `window/showMessage`, `window/logMessage` and `telemetry/event` as well as the
* `window/showMessageRequest` request to the client. In case the client sets up a progress
* token in the initialize params (e.g. property `workDoneToken`) the server is also allowed
* to use that token (and only that token) using the `$/progress` notification sent from the
* server to the client.
*
* The `initialize` request may only be sent once.
* @returns
*/
initialize: (params: InitializeParams): ResponsePromise => {
const promise = _mutate(
"initialize",
params as unknown as LSPObject,
) as unknown as ResponsePromise;
void promise.then(() => {
_initialized = true;
_eventHandlers.get("initialized")?.forEach((handler) => void handler());
});
return promise;
},
/**
* The shutdown request is sent from the client to the server. It asks the server to shut
* down, but to not exit (otherwise the response might not be delivered correctly to the
* client). There is a separate exit notification that asks the server to exit. Clients must
* not send any notifications other than `exit` or requests to a server to which they have
* sent a shutdown request. Clients should also wait with sending the exit notification
* until they have received a response from the `shutdown` request.
*
* If a server receives requests after a shutdown request those requests should error with
* `InvalidRequest`.
* @returns
*/
shutdown: (): ResponsePromise => _mutate("shutdown"),
} as const,
),
/**
* Send a query request to LSP server.
* @param method The method of the request.
* @param params The parameters of the request.
* @returns
*/
query: (
method: string,
params?: LSPArray | LSPObject,
): ResponsePromise => _request("query", method, params),
/**
* Send a mutation request to LSP server.
* @param method The method of the request.
* @param payload The payload of the request.
* @returns
*/
mutate: (
method: string,
payload?: LSPArray | LSPObject,
): ResponsePromise => _request("mutation", method, payload),
/**
* Send a notification to LSP server.
* @param method The method of the notification.
* @param params The parameters of the notification.
*/
notify: (method: string, params?: LSPArray | LSPObject) => {
const notification = {
jsonrpc: JSONRPC_VERSION,
method,
...(params && { params }),
} satisfies NotificationMessage;
_send(notification);
// Log to console
if (logging === "debug")
logger.block
.overwrite({ color: "lightgray" })
.debug(`>> Notification ${method}`, ...(params ? [params] : []));
},
/**
* Notification methods.
* @readonly
*/
notification: {
/**
* The initialized notification is sent from the client to the server after the client
* received the result of the `initialize` request but before the client is sending any
* other request or notification to the server. The server can use the `initialized`
* notification for example to dynamically register capabilities.
*
* The `initialized` notification may only be sent once.
*/
initialized: () => {
_notify("initialized", {});
},
/**
* A notification that should be used by the client to modify the trace setting of the server.
*/
setTrace: (params: SetTraceParams) => {
_notify("$/setTrace", params as unknown as LSPObject);
},
/**
* A notification to ask the server to exit its process. The server should exit with `success`
* code 0 if the shutdown request has been received before; otherwise with `error` code 1.
*/
exit: () => {
_notify("exit");
},
/**
* Text document notifications.
*/
textDocument: {
/**
* The document open notification is sent from the client to the server to signal newly opened
* text documents. The document’s content is now managed by the client and the server must not
* try to read the document’s content using the document’s Uri. Open in this sense means it is
* managed by the client. It doesn’t necessarily mean that its content is presented in an
* editor. An open notification must not be sent more than once without a corresponding close
* notification send before. This means open and close notification must be balanced and the
* max open count for a particular textDocument is one. Note that a server’s ability to
* fulfill requests is independent of whether a text document is open or closed.
*
* The `DidOpenTextDocumentParams` contain the language id the document is associated with. If
* the language id of a document changes, the client needs to send a `textDocument/didClose`
* to the server followed by a `textDocument/didOpen` with the new language id if the server
* handles the new language id as well.
*/
didOpen: (params: DidOpenTextDocumentParams) => {
_notify("textDocument/didOpen", params as unknown as LSPObject);
},
/**
* The document change notification is sent from the client to the server to signal changes to
* a text document. Before a client can change a text document it must claim ownership of its
* content using the `textDocument/didOpen` notification. In 2.0 the shape of the params has
* changed to include proper version numbers.
*/
didChange: (params: DidChangeTextDocumentParams) => {
_notify("textDocument/didChange", params as unknown as LSPObject);
},
/**
* The document close notification is sent from the client to the server when the document
* got closed in the client. The document’s master now exists where the document’s Uri
* points to (e.g. if the document’s Uri is a file Uri the master now exists on disk). As
* with the open notification the close notification is about managing the document’s
* content. Receiving a close notification doesn’t mean that the document was open in an
* editor before. A close notification requires a previous open notification to be sent.
* Note that a server’s ability to fulfill requests is independent of whether a text
* document is open or closed.
*/
didClose: (params: DidCloseTextDocumentParams) => {
_notify("textDocument/didClose", params as unknown as LSPObject);
},
} as const,
/**
* Workspace notifications.
* @readonly
*/
workspace: {
/**
* The `workspace/didChangeWorkspaceFolders` notification is sent from the client to the
* server to inform the server about workspace folder configuration changes. A server can
* register for this notification by using either the *server capability*
* `workspace.workspaceFolders.changeNotifications` or by using the dynamic capability
* registration mechanism. To dynamically register for the
* `workspace/didChangeWorkspaceFolders` send a `client/registerCapability` request from
* the server to the client. The registration parameter must have a `registrations` item of
* the following form, where id is a unique `id` used to unregister the capability (the
* example uses a UUID).
*/
didChangeWorkspaceFolders: (params: DidChangeWorkspaceFoldersParams) => {
_notify("workspace/didChangeWorkspaceFolders", params as unknown as LSPObject);
},
} as const,
} as const,
_eventHandlers,
/**
* Add event handler.
* @readonly
*/
on: ((event, handler): void => {
const handlers = _eventHandlers.get(event) ?? [];
handlers.push(handler as never);
_eventHandlers.set(event, handlers);
}) as (event: E, handler: ClientEventHandler) => void,
/**
* Remove event handler.
* @readonly
*/
off: ((event, handler): void => {
const handlers = _eventHandlers.get(event) ?? [];
const index = handlers.indexOf(handler as never);
if (index !== -1) handlers.splice(index, 1);
_eventHandlers.set(event, handlers);
}) as (event: E, handler: ClientEventHandler) => void,
};
const _request = result.request;
// @ts-expect-error - Planned to use in the future
const _query = result.query;
const _mutate = result.mutate;
const _notify = result.notify;
return result;
};
================================================
FILE: src/client/index.ts
================================================
export * from "./client";
================================================
FILE: src/completion.ts
================================================
import * as path from "@modules/path";
import type { Completion, CompletionResult, CopilotClient } from "./client";
import type { ResponsePromise } from "./client/general-client";
import { logger } from "./logging";
import type { Position } from "./types/lsp";
import { Observable } from "./utils/observable";
/**
* Options for {@link CompletionTaskManager}.
*/
export interface CompletionTaskManagerOptions {
workspaceFolder: string;
activeFilePathname: string;
}
/**
* A manager for GitHub Copilot completion tasks that makes sure exactly one completion task is
* active at a time.
*/
export default class CompletionTaskManager {
public workspaceFolder: string;
public activeFilePathname: string;
private _state: "idle" | "requesting" | "pending" = "idle";
private activeRequest:
| (ResponsePromise & { cleanup?: Observable<"accepted" | "rejected"> })
| null = null;
constructor(
private copilot: CopilotClient,
options: CompletionTaskManagerOptions,
) {
this.workspaceFolder = options.workspaceFolder;
this.activeFilePathname = options.activeFilePathname;
}
get state(): "idle" | "requesting" | "pending" {
return this._state;
}
rejectCurrentIfExist(): void {
if (this.activeRequest) {
if (this.activeRequest.status === "pending") this.activeRequest.cancel();
this.activeRequest.cleanup?.next("rejected");
this.activeRequest = null;
}
this._state = "idle";
if (this.copilot.status === "InProgress") this.copilot.status = "Normal";
}
start(
position: Position,
{
onCompletion,
}: {
/**
* Callback invoked when a task completion is received.
*
* This can optionally return an {@linkcode Observable} representing a cleanup action.
* The observable will be:
* - Subscribed to initially for internal cleanup.
* - Triggered later by the class itself when a new task starts, or by external triggers
* (e.g., the user manually invoking `.next()` for cleanup).
*/
onCompletion?: (completion: Completion) => Observable<"accepted" | "rejected"> | void;
},
): void {
if (this.activeRequest) {
if (this.activeRequest.status === "pending") this.activeRequest.cancel();
this.activeRequest.cleanup?.next("rejected");
}
this._state = "requesting";
const request = this.copilot.request.getCompletions({
position,
languageId: "markdown",
path: this.activeFilePathname,
relativePath:
this.workspaceFolder ?
path.relative(this.workspaceFolder, this.activeFilePathname)
: this.activeFilePathname,
});
this.activeRequest = request;
request
.then(({ cancellationReason, completions }): void => {
if (this.activeRequest !== request) {
// The request has been cancelled or a new task has started since this task was started,
// so we should ignore this task's completion
return;
}
if (cancellationReason || completions.length === 0) {
if (this.copilot.status === "InProgress") this.copilot.status = "Normal";
this._state = "idle";
return;
}
this._state = "pending";
const completion = completions[0]!;
const cleanup = onCompletion?.(completion) ?? new Observable<"accepted" | "rejected">();
cleanup.subscribeOnce((acceptedOrRejected) => {
if (acceptedOrRejected === "accepted") {
this.copilot.notification.notifyAccepted({ uuid: completion.uuid });
logger.debug("Accepted completion");
} else {
this.copilot.notification.notifyRejected({ uuids: [completion.uuid] });
logger.debug("Rejected completion", completion.uuid);
}
this._state = "idle";
if (this.activeRequest === request) this.activeRequest = null;
});
this.activeRequest.cleanup = cleanup;
})
.catch(() => {
if (this.activeRequest !== request) {
// The request has been cancelled or a new task has started since this task was started,
// so we should ignore this task's completion
return;
}
this._state = "idle";
this.activeRequest = null;
});
}
}
================================================
FILE: src/components/ChatPanel.scss
================================================
#copilot-chat-container {
overflow: hidden;
}
.chat-panel-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1.5px;
cursor: col-resize;
background-color: transparent;
transition: background-color 0.2s;
z-index: 10;
&:hover,
&.resizing {
background-color: var(--primary-color, rgba(64, 120, 192, 30%));
}
}
// Empty state welcome screen
.empty-state-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px;
text-align: center;
height: 100%;
margin: 0 auto;
.welcome-icon {
margin-bottom: 10px;
}
.welcome-title {
font-size: 1.5em;
margin-top: 0;
margin-bottom: 12px;
font-weight: 500;
color: var(--text-color);
}
.welcome-subtitle {
color: var(--text-color-lighter, rgba(0, 0, 0, 60%));
font-size: 0.8em;
line-height: 1.5;
}
}
// Main panel container
.copilot-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-color);
color: var(--text-color);
border-left: 1px solid var(--border-color);
}
// Header section
.chat-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
h3 {
margin: 0;
font-size: 16px;
}
.chat-panel-close-btn {
background: none;
border: none;
font-size: 24px !important;
cursor: pointer;
color: var(--text-color);
opacity: 0.6;
&:hover {
opacity: 1;
}
}
}
// Input area container
.chat-panel-input-container {
border-top: 1px solid var(--border-color);
padding: 12px;
}
// Textarea wrapper with integrated controls
.chat-input-wrapper {
position: relative;
display: flex;
flex-direction: column;
textarea {
width: 100%;
padding: 10px;
padding-bottom: 40px; // Space for buttons
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background-color: var(--bg-color);
color: var(--text-color);
resize: none;
font-size: 14px;
line-height: 1.5;
// stylelint-disable-next-line declaration-property-value-keyword-no-deprecated
word-break: break-word;
white-space: pre-wrap;
overflow-y: hidden;
&:focus {
outline: none; // Remove default outline
border-color: var(--primary-color, #4078c0); // Change border color when focused
box-shadow: 0 0 0 1px var(--primary-color, #4078c0); // Subtle glow
}
}
}
// Controls container positioned at bottom of textarea
.chat-input-controls {
position: absolute;
bottom: 8px;
left: 6px;
right: 6px;
display: flex;
justify-content: space-between;
align-items: center;
height: 24px;
}
// Dropdown styling
.chat-panel-dropdown {
position: relative;
.chat-panel-dropdown-toggle {
background: none;
border: none;
padding: 2px 6px;
display: flex;
align-items: center;
cursor: pointer;
color: var(--text-color);
opacity: 0.8;
font-size: 12px !important;
font-weight: normal !important;
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 5%);
border-radius: 3px;
}
.dropdown-chevron {
margin-left: 4px;
height: 5px;
width: 8px;
opacity: 0.7;
}
}
.chat-panel-dropdown-menu {
position: absolute;
padding: 5px;
bottom: 34px;
left: 0;
min-width: 120px;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 15%);
z-index: 1000;
display: flex;
flex-direction: column;
.chat-panel-dropdown-item {
text-align: left;
background: none;
border: none;
cursor: pointer;
color: var(--text-color);
font-size: 12px !important;
font-weight: normal !important;
&:hover {
background-color: var(--item-hover-bg-color, rgba(0, 0, 0, 5%));
}
}
}
}
.chat-title-dropdown {
position: relative;
flex: 1;
.chat-title-dropdown-toggle {
background: none;
border: none;
padding: 4px 8px;
display: flex;
align-items: center;
color: var(--text-color);
cursor: pointer;
width: auto;
text-align: left;
border-radius: 4px;
&:hover {
background-color: rgba(0, 0, 0, 5%);
}
h3 {
margin: 0;
flex: 1;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.dropdown-chevron {
margin-left: 8px;
opacity: 0.7;
}
&:hover .dropdown-chevron {
opacity: 1;
}
}
.session-menu {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-height: 300px;
overflow-y: auto;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 10%);
z-index: 1000;
margin-top: 4px;
padding: 6px;
/* stylelint-disable-next-line no-descending-specificity */
.chat-panel-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
cursor: pointer;
width: 100%;
text-align: left;
background: none;
border: none;
font-size: 12px !important;
font-weight: normal !important;
color: var(--text-color);
min-height: 26px;
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 5%);
}
.edit-btn,
.delete-btn {
opacity: 0.4;
width: 16px;
height: 16px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-color);
cursor: pointer;
margin-left: 8px;
&:hover {
color: #e53935;
}
}
&:hover .edit-btn,
&:hover .delete-btn {
opacity: 0.7;
}
}
.new-session {
display: flex;
align-items: center;
justify-content: unset;
font-weight: 500;
border-bottom: 1px solid var(--border-color);
padding-bottom: 6px;
margin-bottom: 4px;
svg {
width: 12px;
height: 12px;
margin-right: 8px;
}
}
.active {
background-color: rgba(0, 0, 0, 5%);
font-weight: 500;
}
}
}
// Send button
.chat-panel-send-btn {
padding: 2px 10px;
background-color: var(--primary-color, #4078c0);
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 12px !important;
&:disabled {
background-color: #ccc;
cursor: default;
opacity: 0.6;
}
&:hover:not(:disabled) {
opacity: 0.9;
}
&.sending {
background-color: #e74c3c;
&:hover:not(:disabled) {
background-color: #c0392b;
}
}
}
.chat-panel-messages {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-message-row {
display: flex;
flex-direction: column;
max-width: 100%;
animation: fade-in 0.3s ease;
.message-header {
display: flex;
align-items: center;
margin-bottom: 12px;
.message-icon {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 5%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
svg {
width: 16px;
height: 16px;
}
&.user-icon {
svg {
transform: scale(1.1);
}
}
}
.user-icon {
color: var(--text-color);
}
.copilot-icon {
background-color: rgba(0, 0, 0, 3%);
padding: 4px;
}
.message-author {
font-size: 13px;
font-weight: bold;
color: var(--text-color);
}
}
.message-content {
font-family: var(--font-family);
font-size: 13px;
line-height: 1.5;
overflow-wrap: break-word;
margin: 0;
border-radius: 6px;
background: transparent;
// Remove default pre tag styles
border: none;
overflow: visible;
}
pre.message-content {
white-space: pre-wrap;
}
}
.markdown-content {
/* Reset header font sizes */
h1 {
font-size: 1.36em;
margin-top: 0.7em;
margin-bottom: 0.5em;
}
h2 {
font-size: 1.25em;
margin-top: 0.6em;
margin-bottom: 0.4em;
}
/* stylelint-disable-next-line no-descending-specificity */
h3 {
font-size: 1.17em;
margin-top: 0.5em;
margin-bottom: 0.4em;
}
h4 {
font-size: 1em;
margin-top: 0.4em;
margin-bottom: 0.3em;
}
h5 {
font-size: 0.83em;
margin-top: 0.4em;
margin-bottom: 0.2em;
}
h6 {
font-size: 0.67em;
margin-top: 0.4em;
margin-bottom: 0.2em;
}
h1,
h2,
/* stylelint-disable-next-line no-descending-specificity */
h3,
h4,
h5,
h6 {
font-weight: 500;
}
/* Reset font size for code */
code {
font-size: 0.9em;
}
/* Prettify code block */
pre {
background-color: rgba(0, 0, 0, 3%);
border: 1px solid rgba(0, 0, 0, 8%);
border-radius: 5px;
margin: 12px 0;
overflow-x: auto;
position: relative;
.copy-code-button {
position: absolute;
top: 6px;
right: 6px;
width: 28px;
height: 28px;
padding: 6px;
background-color: rgba(0, 0, 0, 10%);
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition:
opacity 0.2s ease,
background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
&:hover {
opacity: 0.9 !important;
background-color: rgba(0, 0, 0, 15%);
}
&:active {
background-color: rgba(0, 0, 0, 20%);
}
}
&:hover .copy-code-button {
opacity: 0.7;
}
code {
background-color: transparent;
padding: 0;
border: none;
font-family: inherit;
color: var(--text-color-code, var(--text-color));
}
}
/* Reduce margins for paragraphs with a pre code block after */
p:has(+ pre code) {
margin-bottom: 0;
}
/* Remove margins for first and last paragraphs */
p {
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
.typing-indicator {
display: flex;
align-items: center;
height: 24px;
span {
width: 6px;
height: 6px;
margin: 0 2px;
background-color: var(--text-color);
border-radius: 50%;
opacity: 0.5;
animation: typing 1s infinite ease-in-out;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.5;
}
30% {
transform: translateY(-3px);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
================================================
FILE: src/components/ChatPanel.tsx
================================================
import type { Signal } from "@preact/signals";
import { useSignal, useSignalEffect } from "@preact/signals";
import { getLuminance } from "color2k";
import hljs from "highlight.js";
import { marked } from "marked";
import { markedHighlight } from "marked-highlight";
import { render } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
import type { ChatModel } from "@/client/chat";
import {
COPILOT_ACADEMIC_INSTRUCTIONS,
COPILOT_CATGIRL_INSTRUCTIONS,
COPILOT_CREATIVE_INSTRUCTIONS,
COPILOT_MARKDOWN_INSTRUCTIONS,
ChatSession,
listCopilotChatModels,
} from "@/client/chat";
import { t } from "@/i18n";
import CopilotIcon from "./CopilotIcon";
import "./ChatPanel.scss";
interface ChatPanelProps {
onClose: () => void;
}
type PromptType = "Normal" | "Academic" | "Creative" | "CatGirl";
const ChatPanel: FC = ({ onClose }) => {
const input = useSignal("");
const promptType = useSignal("Normal");
const isThinking = useSignal(false);
const isSending = useSignal(false);
const messages = useSignal([]);
const sessionJustSwitchedFlag = useSignal(false);
const modelId = useSignal("gpt-4o");
const models = useSignal([]);
const currentSessionId = useSignal("");
const sessions = useSignal([]);
const getPrompt = useCallback(() => {
if (promptType.value === "Normal") return COPILOT_MARKDOWN_INSTRUCTIONS;
if (promptType.value === "Academic") return COPILOT_ACADEMIC_INSTRUCTIONS;
if (promptType.value === "Creative") return COPILOT_CREATIVE_INSTRUCTIONS;
return COPILOT_CATGIRL_INSTRUCTIONS.replace(
"{{CATGIRL_NAME}}",
t.test("chat.prompt-style.cat-girl-name") ? t("chat.prompt-style.cat-girl-name") : "Vanilla",
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getPromptType = useCallback((prompt: string) => {
if (prompt === COPILOT_MARKDOWN_INSTRUCTIONS) return "Normal";
if (prompt === COPILOT_ACADEMIC_INSTRUCTIONS) return "Academic";
if (prompt === COPILOT_CREATIVE_INSTRUCTIONS) return "Creative";
if (
prompt ===
COPILOT_CATGIRL_INSTRUCTIONS.replace(
"{{CATGIRL_NAME}}",
t.test("chat.prompt-style.cat-girl-name") ?
t("chat.prompt-style.cat-girl-name")
: "Vanilla",
)
)
return "CatGirl";
return "Normal";
}, []);
// Watch modelId changes and update session meta
useEffect(() => {
const currentSession = sessions.value.find((session) => session.id === currentSessionId.value);
if (!currentSession) return;
if (currentSession.modelId === modelId.value) return;
currentSession.modelId = modelId.value;
void ChatSession.save(currentSession.id);
sessions.value = ChatSession.getAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modelId.value, currentSessionId.value]);
// Watch prompt type changes and update the system prompt
useEffect(() => {
const currentSession = sessions.value.find((session) => session.id === currentSessionId.value);
if (!currentSession) return;
if (currentSession.messages[0]!.content === getPrompt()) return;
currentSession.messages[0]!.content = getPrompt();
void ChatSession.save(currentSession.id);
sessions.value = ChatSession.getAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [promptType.value, currentSessionId.value, getPrompt]);
// Load models and sessions on mount
useEffect(() => {
// Load models
void listCopilotChatModels().then((modelList) => {
models.value = modelList;
});
// Load sessions
const loadSessions = async () => {
await ChatSession.loadAll();
const allSessions = ChatSession.getAll();
sessions.value = allSessions;
// Select the first session if available, otherwise create a new one
if (allSessions.length > 0) {
currentSessionId.value = allSessions[0]!.id;
modelId.value = allSessions[0]!.modelId;
promptType.value = getPromptType(allSessions[0]!.messages[0]!.content);
messages.value = allSessions[0]!.messages
.filter((msg) => msg.role !== "system")
.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
}));
sessionJustSwitchedFlag.value = !sessionJustSwitchedFlag.value;
} else {
createNewSession();
}
};
void loadSessions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const createNewSession = useCallback(() => {
const session = ChatSession.create(modelId.value, getPrompt());
currentSessionId.value = session.id;
sessions.value = ChatSession.getAll();
messages.value = [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const switchSession = useCallback((id: string) => {
const session = ChatSession.get(id);
if (!session) return;
currentSessionId.value = session.id;
modelId.value = session.modelId;
promptType.value = getPromptType(session.messages[0]!.content);
messages.value = session.messages
.filter((msg) => msg.role !== "system")
.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
}));
sessionJustSwitchedFlag.value = !sessionJustSwitchedFlag.value;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleEditChatTitle = useCallback((id: string, title: string) => {
const session = ChatSession.get(id);
if (!session) return;
session.title = title;
void ChatSession.save(session.id);
sessions.value = ChatSession.getAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDeleteSession = useCallback(
(id: string) => {
void ChatSession.delete(id);
sessions.value = ChatSession.getAll();
// If current session is deleted, switch to the first available session
if (id === currentSessionId.value) {
if (sessions.value.length > 0) {
switchSession(sessions.value[0]!.id);
} else {
createNewSession();
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[createNewSession, switchSession],
);
// Send message handler
const handleSendMessage = () => {
const userMessage = input.value.trim();
if (!userMessage || isSending.value) return;
// Add user message
messages.value = [
...messages.value,
{
role: "user",
content: input.value.trim(),
},
];
// Clear input
input.value = "";
// Show typing indicator
isThinking.value = true;
isSending.value = true;
abortControllerRef.current = new AbortController();
sessions.value
.find((session) => session.id === currentSessionId.value)
?.send(
userMessage,
(content) => {
if (isThinking.value) {
isThinking.value = false;
messages.value = [
...messages.value,
{
role: "assistant",
content: "",
},
];
}
messages.value = [
...messages.value.slice(0, messages.value.length - 1),
{
role: "assistant",
content: messages.value[messages.value.length - 1]!.content + content,
},
];
},
{
model: models.value.find((model) => model.id === modelId.value),
signal: abortControllerRef.current.signal,
},
)
.then((fullContent) => {
messages.value = [
...messages.value.slice(0, messages.value.length - 1),
{
role: "assistant",
content: fullContent,
},
];
isSending.value = false;
void ChatSession.save(currentSessionId.value);
})
.catch((error) => {
console.error("Error sending message:", error);
isSending.value = false;
});
};
const abortControllerRef = useRef(null);
const handleStopSending = useCallback(() => {
abortControllerRef.current?.abort();
}, []);
return (