🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s

A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
This commit is contained in:
Evandro committed 2026-06-01 00:50:06 -03:00
commit b3489e1ede
983 files changed
+158475

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
node_modules
dist
dist-ssr
root
workspace
.opencode
.git
.gitignore
.vscode
.idea
*.log
*.local
.DS_Store
.ruff_cache
README.md
+9
View File
@@ -0,0 +1,9 @@
root = true
[\*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
+46
View File
@@ -0,0 +1,46 @@
# ============================================
# OpenCode WebUI - 环境变量
# ============================================
# cp .env.example .env
# ---- LLM Provider API Keys(至少填一个)----
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GEMINI_API_KEY=
DEEPSEEK_API_KEY=
GROQ_API_KEY=
MISTRAL_API_KEY=
XAI_API_KEY=
# ---- 端口 ----
GATEWAY_PORT=6658
PREVIEW_PORT=6659
# ---- 预览域名(绑定独立域名解决前端资源路径问题)----
# 例如 preview.example.com,Nginx 将此域名转发到 PREVIEW_PORT
PREVIEW_DOMAIN=
# ---- 工作目录 ----
WORKSPACE=./workspace
# ---- 安全(公网部署务必设置)----
OPENCODE_SERVER_PASSWORD=
OPENCODE_SERVER_USERNAME=opencode
# ---- 路由服务(由 gateway 内置)----
PUBLIC_BASE_URL=
ROUTER_SCAN_INTERVAL=5
ROUTER_TOKEN_LENGTH=12
ROUTER_PORT_RANGE=3000-9999
ROUTER_EXCLUDE_PORTS=4096
# ---- Backend 工具链镜像/运行时源(可选)----
USE_CHINA_MIRROR=1
APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian
APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security
NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
MISE_INSTALL_URL=https://mise.run
OPENCODE_INSTALL_URL=https://opencode.ai/install
+24
View File
@@ -0,0 +1,24 @@
# Auto-detect text files and normalize to LF
* text=auto eol=lf
# Shell scripts always LF
*.sh text eol=lf
# Caddy/Docker always LF
Caddyfile text eol=LF
Dockerfile text eol=lf
Dockerfile.* text eol=lf
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.icns binary
*.pdf binary
*.zip binary
*.tar.gz binary
*.ttf binary
*.woff binary
*.woff2 binary
+32
View File
@@ -0,0 +1,32 @@
name: Build Validation
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: build-validation-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Validate app
run: npm run validate
+46
View File
@@ -0,0 +1,46 @@
name: Deploy to GitHub Pages
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
env:
VITE_BASE_PATH: /OpenCodeUI/
- uses: actions/upload-pages-artifact@v5
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
+66
View File
@@ -0,0 +1,66 @@
name: Build & Push Backend Docker Image
on:
push:
branches: [main]
paths:
- 'docker/Dockerfile.backend'
- 'docker/backend-entrypoint.sh'
schedule:
# 每天自动重建,跟进 opencode 最新版本
- cron: '0 4 * * *'
workflow_dispatch:
concurrency:
group: docker-backend-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/opencodeui-backend
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.backend
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+71
View File
@@ -0,0 +1,71 @@
name: Build & Push Frontend Docker Image
on:
push:
branches: [main]
paths:
- 'src/**'
- 'public/**'
- 'index.html'
- 'package.json'
- 'package-lock.json'
- 'vite.config.ts'
- 'tsconfig*.json'
- 'docker/Dockerfile.frontend'
- 'docker/Caddyfile'
- 'docker/Caddyfile.standalone'
workflow_dispatch:
concurrency:
group: docker-frontend-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/opencodeui-frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.frontend
build-args: |
NPM_REGISTRY=https://registry.npmjs.org/
NPM_REGISTRY_FALLBACK=https://registry.npmmirror.com/
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+65
View File
@@ -0,0 +1,65 @@
name: Build & Push Gateway Docker Image
on:
push:
branches: [main]
paths:
- 'docker/Dockerfile.gateway'
- 'docker/Caddyfile.gateway'
- 'docker/entrypoint-gateway.sh'
- 'src-router/**'
workflow_dispatch:
concurrency:
group: docker-gateway-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/opencodeui-gateway
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.gateway
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+240
View File
@@ -0,0 +1,240 @@
name: Release
on:
push:
tags:
- 'v*'
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
# ============================================
# Desktop builds (Windows, macOS, Linux)
# ============================================
build-desktop:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
args: '--target aarch64-apple-darwin'
arch: aarch64
os: macos
- platform: macos-latest
args: '--target x86_64-apple-darwin'
arch: x86_64
os: macos
- platform: ubuntu-22.04
args: ''
arch: x86_64
os: linux
- platform: windows-latest
args: ''
arch: x86_64
os: windows
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'npm'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
key: ${{ matrix.os }}-${{ matrix.arch }}
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- run: npm ci
# tauri-action 自动执行 beforeBuildCommand (npm run build) + cargo build
- name: Build desktop app
uses: tauri-apps/tauri-action@v0
with:
args: ${{ matrix.args }}
- name: Prepare artifacts
shell: bash
run: |
mkdir -p release-artifacts
BUNDLE_DIR="src-tauri/target/release/bundle"
if [[ "${{ matrix.os }}" == "macos" ]]; then
BUNDLE_DIR="src-tauri/target/${{ matrix.arch }}-apple-darwin/release/bundle"
fi
if [[ "${{ matrix.os }}" == "windows" ]]; then
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
elif [[ "${{ matrix.os }}" == "macos" ]]; then
find "$BUNDLE_DIR/dmg" -name "*.dmg" -exec cp {} release-artifacts/ \; 2>/dev/null || true
elif [[ "${{ matrix.os }}" == "linux" ]]; then
find "$BUNDLE_DIR/deb" -name "*.deb" -exec cp {} release-artifacts/ \; 2>/dev/null || true
find "$BUNDLE_DIR/appimage" -name "*.AppImage" -exec cp {} release-artifacts/ \; 2>/dev/null || true
fi
echo "Artifacts:"
ls -R release-artifacts
- uses: actions/upload-artifact@v7
with:
name: artifacts-desktop-${{ matrix.os }}-${{ matrix.arch }}
path: release-artifacts
if-no-files-found: warn
retention-days: 1
# ============================================
# Android builds (arm64 + armv7 并行)
# ============================================
build-android:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: aarch64
rust_target: aarch64-linux-android
label: arm64
- target: armv7
rust_target: armv7-linux-androideabi
label: armv7
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'npm'
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- uses: android-actions/setup-android@v3
- run: sdkmanager "ndk;29.0.14206865"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
key: android-${{ matrix.target }}
- name: Gradle cache
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
src-tauri/gen/android/.gradle
key: gradle-${{ matrix.target }}-${{ hashFiles('src-tauri/gen/android/**/*.gradle*', 'src-tauri/gen/android/**/gradle-wrapper.properties') }}
restore-keys: gradle-
- run: npm ci
- name: Setup Android signing
run: |
cd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
- name: Build and sign APK
run: |
VERSION=${{ github.ref_name }}
BT="$ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools/ | tail -1)"
npx tauri android build --apk --target ${{ matrix.target }}
SIGNED_APK=$(find src-tauri/gen/android/app/build/outputs/apk -path '*/release/*-signed*' -name '*.apk' -type f 2>/dev/null | head -1)
RELEASE_APK=$(find src-tauri/gen/android/app/build/outputs/apk -path '*/release/*.apk' -name '*.apk' -type f 2>/dev/null | head -1)
APK_PATH="${SIGNED_APK:-$RELEASE_APK}"
DEST="OpenCodeUI-${VERSION}-android-${{ matrix.label }}.apk"
if $BT/apksigner verify "$APK_PATH" 2>/dev/null; then
cp "$APK_PATH" "$DEST"
else
$BT/zipalign -v 4 "$APK_PATH" "${APK_PATH}.aligned"
$BT/apksigner sign \
--ks $RUNNER_TEMP/keystore.jks \
--ks-key-alias ${{ secrets.ANDROID_KEY_ALIAS }} \
--ks-pass pass:${{ secrets.ANDROID_KEY_PASSWORD }} \
--key-pass pass:${{ secrets.ANDROID_KEY_PASSWORD }} \
--out "$DEST" "${APK_PATH}.aligned"
fi
mkdir -p release-artifacts
mv "$DEST" release-artifacts/
env:
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/29.0.14206865
- uses: actions/upload-artifact@v7
with:
name: artifacts-android-${{ matrix.label }}
path: release-artifacts/*
retention-days: 1
# ============================================
# Publish Release
# ============================================
publish-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: [build-desktop, build-android]
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: artifacts-*
merge-multiple: true
- name: Display artifacts
run: ls -R artifacts
- name: Extract changelog
id: changelog
run: |
TAG="${{ github.ref_name }}"
BODY=$(sed -n "/^## \[${TAG}\]/,/^## \[/{ /^## \[${TAG}\]/d; /^## \[/d; p; }" CHANGELOG.md)
BODY=$(echo "$BODY" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba;}')
if [ -z "$BODY" ]; then
BODY="No changelog entry found for ${TAG}."
fi
echo "$BODY" > /tmp/release-body.md
printf '\n---\n\n**Desktop:** Windows (.exe), macOS (.dmg), Linux (.deb, .AppImage)\n**Mobile:** Android (.apk) — arm64, armeabi-v7a\n' >> /tmp/release-body.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: 'OpenCodeUI ${{ github.ref_name }}'
body_path: /tmp/release-body.md
files: artifacts/*
draft: false
prerelease: ${{ contains(github.ref_name, 'canary') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
target/
.superpowers/
tmp/
*.log
.DS_Store
Thumbs.db
+1
View File
@@ -0,0 +1 @@
{"version":"v2","builtAt":"2026-06-01T00:40:39.785Z","signature":"D:/Documents/Github/PiAgentUI/PiAgentUi/eslint.config.js:1486:1780022589270.6587|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.test.ts:5516:1780258362691.8145|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.ts:16383:1780258362659.5957|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.test.ts:4040:1780266481529.727|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.ts:7293:1780266481600.4917|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.test.ts:6112:1780253705167.7166|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.ts:5140:1780253889743.0134|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.test.ts:39380:1780272600127.7744|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts:83661:1780272653246.032|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.test.ts:2999:1780248290341.1304|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.ts:6608:1780250094524.6309|D:/Documents/Github/PiAgentUI/PiAgentUi/public/notification-sw.js:1287:1780022589275.1758|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/bump-version.mjs:7766:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/copy-material-icons.mjs:2053:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/prepare-release.mjs:2730:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/caddy.rs:2091:1780022589277.6882|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/config.rs:3669:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/main.rs:796:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/router.rs:12128:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/scanner.rs:2323:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/state.rs:2417:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/build.rs:40:1780022589282.198|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/args.rs:1765:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/event.rs:558:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/mod.rs:197:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/state.rs:4232:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/bridge.rs:13983:1780022589314.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/mod.rs:124:1780027014784.0298|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/pi_agent.rs:4814:1780027011663.1926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/utils.rs:1496:1780029769772.7441|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/dir_state.rs:773:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/mod.rs:10869:1780029779431.7087|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/service.rs:500:1780027024480.5833|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/lib.rs:100:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/main.rs:193:1780022589316.2898|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/agent.ts:929:1780026517593.7356|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.test.ts:1876:1780180406861.2058|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.ts:3901:1780257142048.0273|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.test.ts:2375:1780194572795.3748|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.ts:3632:1780194752659.6665|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/config.ts:1414:1780026299997.4521|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.test.ts:8606:1780119380006.358|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.ts:27784:1780121498237.752|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.test.ts:1680:1780258114988.9187|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.ts:1197:1780258069136.5046|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/file.ts:4453:1780026718153.368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/global.ts:981:1780026539178.5913|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/http.test.ts:1443:1780022589319.4368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/http.ts:1509:1780029793129.7656|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/index.ts:135:1780022589319.4368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/lsp.ts:657:1780026505282.3206|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/mcp.test.ts:1017:1780188851123.1152|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/mcp.ts:1868:1780188942993.4717|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/message.ts:4368:1780026289798.4277|D:/Documents/Github/PiAgentUI/PiAgLine truncated
+5
View File
@@ -0,0 +1,5 @@
dist
node_modules
public/material-icons
src-tauri/target
coverage
+7
View File
@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "avoid",
"printWidth": 120
}
+2
View File
@@ -0,0 +1,2 @@
node 22.22.0
rust 1.85.0
+796
View File
@@ -0,0 +1,796 @@
# Changelog
## [v0.6.10] - 2026-05-17
- fix: refresh sidebar child session links (a49908a)
- ci: deploy pages from dev (bc50843)
- fix: route permission replies through session (75bd8ac)
- fix: avoid iOS toolbar bottom gap (d880fac)
- fix: keep provider enabled for visible models (2462adf)
- fix: show detailed completion time on hover (9ca1ab6)
## [v0.6.9] - 2026-05-16
- fix(terminal): correct clipboard paste behavior (bf6f902)
- ci(docker): add image workflow guardrails (da80d45)
- fix(docker): avoid qemu frontend builds (2d4d00b)
## [v0.6.8] - 2026-05-16
- fix(diff): keep split word diff text themed (53e1d9a)
- feat(docker): add optional host backend access (279ac15)
- fix(message): preserve aborted messages with content (86fd11f)
## [v0.6.7] - 2026-05-15
- fix(input): prevent IME confirmation from sending messages (5f4e0bb)
- feat(terminal): add scoped clipboard keybindings (2f8a933)
## [v0.6.6] - 2026-05-13
- fix(session): 修复 PR #90 的子目录加载竞态并理顺范围刷新语义 (5360b59)
- fix(session): 目录切换时合并活动会话刷新状态 (4aca25d)
- feat(file-explorer): 按目录恢复文件树展开状态 (e29498d)
## [v0.6.5] - 2026-05-10
- ui: show bash working directory (48d5b28)
- fix: skip shiki fallback for unsupported languages (d4ea3be)
## [v0.6.4] - 2026-05-04
- fix: keep delete change bars continuous (843d861)
## [v0.6.3] - 2026-05-04
- ui: simplify tool diff result headers (a5889a5)
- build: reduce bundled asset size (3e4aaa2)
- fix: align wrapped diff empty textures (d4b187b)
- fix: smooth split session loading (c4f3bb7)
## [v0.6.2] - 2026-05-04
- fix: smooth wrapped diff scrolling (e46d0e5)
- fix: center collapsed diff buttons on mobile (9c1c6ef)
- fix: keep empty diff buffer textures aligned (3b70696)
## [v0.6.1] - 2026-05-03
- fix: align readonly code gutters with diffs (b9a15d3)
- fix: polish diff gutter and separators (2568070)
- fix: keep chat bottom spacing comfortable (bc42873)
- fix: tighten chat surface bottom spacing (f47dedb)
- fix: PWA safe-area double padding on iOS (0b4fa33)
## [v0.6.0] - 2026-05-03
- test: update diff viewer mock for shared data (13d7c04)
- fix: stabilize preview tab hover width (f2d0564)
- feat: improve file and change previews (7ae977d)
- fix: restore scrolling for inline code previews (09d3e96)
- perf: share diff data with fullscreen viewer (e3ceef8)
- perf: hoist diff viewer data across view modes (4467730)
- fix: improve readonly code selection contrast (4955f56)
- fix: strengthen empty diff buffer texture (849445f)
- fix: add Pierre-style empty diff buffers (16eec59)
- fix: adapt line number gutter width (d37f112)
- fix: center diff separator icons (db857dc)
- fix: port Pierre diff separator styling (789dcf2)
- fix: add Pierre-style diff separator expansion (1ce0d3a)
- fix: refine handwritten diff collapsed separators (ab9c8a6)
- fix: mask readonly code gutters (0a41e2a)
- refactor: extract readonly codemirror view (0b81235)
- fix: refine code search responsive layout (fc12a33)
- fix: anchor code preview search panel (00244e3)
- fix: polish code preview search panel (cd4e8d1)
- fix: smooth syntax theme switching (1f079e1)
- fix: use complete github shiki themes (2fc89fb)
- fix: align shiki theme with github syntax colors (e4c171a)
- feat: add adaptive shiki theme (6cb720e)
- feat: use codemirror for code previews (487cea3)
- fix: optimize sidebar transition rendering (06fe9a2)
- fix: limit message offscreen unmounting to resize (3986e5f)
## [v0.5.20] - 2026-05-01
- fix: preserve git subdirectory projects (e9646b9)
## [v0.5.19] - 2026-04-29
- fix: tighten sidebar search spacing (8b76cb2)
- fix: align desktop titlebar and dialog layout (f5800ba)
- fix: align dialog overlay styling (fe3a3cf)
- fix: unify right panel tab styling (ca6d697)
- fix: align command palette styling (dc67f38)
- fix: polish sidebar popover styling (da633ea)
- fix: preserve route session during refresh (9d3f0a0)
- fix: prevent terminal reconnect loops on global switch (5d0aca2)
## [v0.5.18] - 2026-04-29
- fix: tighten pane focus and interaction regressions (c800e44)
- fix: prevent route sync from overwriting focused panes (fa44f13)
## [v0.5.17] - 2026-04-28
- fix: keep session actions tied to visible focus (0e16597)
- ci: update GitHub artifact actions (d9e49c6)
## [v0.5.16] - 2026-04-28
- fix: hide session actions after mouse selection (1d6b4be)
- ci: upgrade GitHub Actions Node runtimes (ff10d7b)
## [v0.5.15] - 2026-04-28
- fix: restore model selector typography scaling (121b762)
## [v0.5.14] - 2026-04-28
- fix: close the remaining review regressions (58a03dc)
- fix: clear lint regressions and selector focus artifacts (1b85707)
- fix: preserve injected desktop titlebar controls (7e60acf)
- fix: reveal session row actions on keyboard focus (a568535)
- fix: ignore hidden controls during menu dismissal (2db0cd0)
- fix: normalize menu focus target detection (c981584)
- fix: harden menu close paths against hidden targets (c1ac600)
- fix: make portal menus inert only after close (f82b285)
- fix: route tab exits through real control order (ce3ad2d)
- fix: keep selector focus stable during keyboard actions (6fbefae)
- fix: finish keyboard flows for selection popups (110cb06)
- fix: complete keyboard navigation for selection menus (ce247ee)
- fix: stabilize stacked dialog and toolbar menu focus (3d3c542)
- fix: align keyboard paths across dialogs and selectors (8f7b01f)
- fix: tighten follow-up focus and visibility behavior (7618593)
- fix: close the remaining interaction accessibility gaps (f634223)
- fix: polish remaining high-traffic control semantics (5c88ad0)
- fix: resolve remaining session and dialog correctness issues (7bde507)
- fix: tighten menu and diff accessibility (399471d)
- fix: finish button semantics in project controls (c05d5c9)
- fix: improve semantic buttons in settings and sidebar (3a1b5bf)
- fix: harden session updates and menu interactions (1d673df)
- fix: update context usage immediately after compaction (df01835)
## [v0.5.13] - 2026-04-27
- fix: restore drag and drop in secondary desktop windows (36d489e)
## [v0.5.12] - 2026-04-27
- fix: create the Android main window explicitly (1c20510)
## [v0.5.11] - 2026-04-26
- fix: defer desktop windows until the first frame is ready (1b1e186)
- fix: remove titlebar separator that misaligns with sidebar border (ed14752)
- fix: prevent overlay sidebars from being obscured by desktop titlebar (8b20d80)
- feat: add titlebar actions — back/forward, open project, settings, new window (65abc23)
- fix: keep fullscreen viewers below desktop chrome (40e1ef4)
## [v0.5.10] - 2026-04-26
- feat: add manual terminal labels and restore state (b42a90d)
- fix: align terminal PTY handling with upstream (093f2f2)
- fix: avoid macOS traffic light overlap in fullscreen headers (a5a3554)
## [v0.5.9] - 2026-04-23
- fix: harden live tool timing updates (d6c963a)
- feat: resolve issue #74 running tool durations (54490f3)
- feat: store calibrated server time locally (f35c4ab)
- feat: handle server.connected event timestamps (0e77075)
- refactor: extract shared ticking clock hook (1b0c626)
## [v0.5.8] - 2026-04-21
- fix: polish sidebar session and notification layout (51b22f2)
- refactor: modularize settings backup snapshots (a9d59c6)
- fix: reset project dialog state on reopen (b344278)
- feat: add settings backup import and export (93ad2ab)
- fix: rebalance obsidian surface contrast (0837734)
- fix: separate system notification settings from sound config (ccc80e4)
- feat: add separate system notification controls (3236d14)
- feat: add Dracula theme preset (7623377)
- fix: persist model selection across session restore (d8eb318)
- fix: shrink launcher icon mark further for masked shells (4f3e99f)
- fix: reduce app icon mark scale and drop custom Android splash (2552342)
- fix: remove unsupported Android splash attr from canary build (21ea11a)
- fix: shrink Android app icon foreground and add native splash theme (8325bfc)
- feat: redesign app icons for desktop and Android (527d423)
## [v0.5.7] - 2026-04-19
- fix: anchor toasts to the content area beneath desktop titlebar (fe15280)
## [v0.5.6] - 2026-04-19
- chore: sync Tauri app icons from web opencode.svg (f792218)
- polish: slim desktop titlebar down to minimal app chrome (375e8d1)
- feat: add platform-aware desktop titlebar foundation (43fa2da)
- fix: prevent model selector from jumping to wrong provider on split (5b33360)
## [v0.5.5] - 2026-04-18
- fix: remove redundant *Single i18n keys — let i18next handle count=1 natively (9385ef8)
- style: match pane drop highlight radius to pane shell (rounded-lg) (f8acac2)
- feat: enable drag-to-split on active session list items (ac20b7b)
## [v0.5.4] - 2026-04-18
- polish: pane drop overlay — drop text labels, harden edge cases (2be4c13)
- perf: keep pane drop overlay state out of ChatPane re-render path (17025ad)
- feat: drag sessions onto chat pane to split or replace (e413864)
- fix: add keyboard shortcuts to question dialogs (send keybinding to submit, Escape to skip) (dc83e0e)
- fix: narrow shell tool detection to exact 'sh' match across all matchers (f534454)
- Update zh-CN usage stats labels to English (074878e)
- fix: distinguish file writes in tool summary (2f25e93)
- fix: refine message tool summary wording (1d41542)
- fix: guard session error without sessionID (2166183)
## [v0.5.3] - 2026-04-17
- style: use distinct icons for settings tabs (9b2adb1)
- fix: sanitize exported CSS snippet filenames safely (139725c)
- fix: adapt token usage ring track to all themes (75f2ad4)
- refactor: derive theme previews from preset tokens (720c426)
- feat: add reusable CSS overrides for themes (eba9d1d)
- refactor: split settings into agent and workspace tabs (b38ac6b)
- feat: add configurable message completion timestamps (2f01c56)
- feat: add model visibility settings (731efdd)
- style: improve glass effect - thicker base, blur(22px), saturate(200%) (bd27ce1)
- feat(themes): 新增Sakura、Ocean、Obsidian三款预设主题 (bfa449c)
- feat(settings): 添加自定义CSS模板管理功能 (79db9f4)
## [v0.5.2] - 2026-04-15
- test: fix streamed event mock typing (92627ee)
- fix: preserve UTF-8 text in streamed markdown updates (8d89ce4)
- feat: add release update checks with about entry (closes #33) (e29a842)
- fix: preserve files and changes panel state across tab switches (closes #63) (3d57dc9)
- fix: allow pin toggle on selected model in desktop model selector (96e0ab2)
- fix: remove mobile 16px font-size override so input matches message stream (9e1eb6e)
## [v0.5.1] - 2026-04-13
- feat: show agent and model name in step finish info (closes #61) (7299319)
- fix: restore line-height lost during font system migration (3d83f3b)
- fix: split diff view losing syntax highlighting when word diff is active (9f281ec)
- feat: tune default font sizes to match opencode official UI proportions (29c6f76)
- feat: unified typography system with CSS variables and per-axis font scale sliders (4533b15)
## [v0.5.0] - 2026-04-13
- feat: add keep-screen-awake toggle in appearance settings (Wake Lock API) (3a6a13c)
- fix: update inline code test to match simplified style (no border/bg) (edc4beb)
- fix: simplify inline code style and add persistent underline to links (7f2cd73)
- fix: allow share URL to scroll horizontally on mobile (d049ce0)
- fix: ensure bottom padding in PWA standalone for devices without Home Indicator (eb8682c)
- fix: use replaceState on mobile to prevent session history stacking (ceed83d)
## [v0.4.9] - 2026-04-11
- fix: prevent model restoration from overriding user selection during streaming (0c799e3)
- fix: return 404 for /api on frontend-only image (b07a30e)
- fix: align UI hook dependencies with live state (63a2b87)
- refactor: remove dead UI cleanup leftovers (5b82694)
- fix: remove unused resolveAlias function in shiki module (81d5fdb)
- perf: lazy-load shiki languages and optimize Tauri release profile (1be34e0)
- fix: PTY WebSocket auth fails behind reverse proxy (4bbb3c5)
- fix: prevent session fetch storm on SSE reconnect in SessionContext (ae26e6c)
- refactor: unify SSE and PTY into a single transparent bridge (73e638f)
- fix: use tungstenite message variants for PTY bridge (6210294)
- fix: bridge Tauri mobile PTY through native client (e33bde5)
## [v0.4.8] - 2026-04-10
- feat: queue follow-up messages behind active turns (90756e3)
- fix: GPT apply_patch diff not rendering and error messages invisible in chat (6d6f81d)
- fix: align prompt history cursor navigation (4dd2bf4)
- fix: tighten session alerts and mobile code copy (8e02c0a)
## [v0.4.7] - 2026-04-09
- fix: update command test to mock sdk instead of removed http module (f7521bc)
- fix: patch sdk migration review findings (e45b2b5)
- refactor: collapse remaining sdk helper types (c9d2a74)
- refactor: align remaining api models with sdk (f9c9272)
- refactor: align user message model fields with sdk (e427ccd)
- refactor: align event types with sdk (826cd49)
- refactor: tighten message part guards (b83098f)
- refactor: align tool part types with sdk (cecab44)
- refactor: align event payload adapters with sdk (d9ffe50)
- refactor: tighten sdk adapters and message conversions (a5aa326)
- refactor: align config and skill types with sdk (4eed10c)
- refactor: trim remaining sdk type wrappers (bfa0bde)
- refactor: collapse API types onto sdk definitions (e5e7305)
- refactor: replace API type wrappers with sdk aliases (89d42ac)
- fix: finish sdk migration cleanup (a90d0aa)
- fix: align API layer with official opencode sdk (ae4308c)
- fix: eliminate UI flicker, merge duplicate effects, avoid object mutation (a6e4cc1)
- fix: stabilize git workspace recents and worktree actions (abd0ee5)
## [v0.4.6] - 2026-04-07
- fix: keep active child sessions visible across projects (9d8a725)
- fix: polish folder recents load more control (cbb1d59)
- fix: fade changes stats as one line (f05f8af)
- fix: compact changes panel header (231904f)
## [v0.4.5] - 2026-04-06
- fix: refine changes menu spacing (ad48921)
- feat: sync file explorer status with change modes (b655d50)
- fix: align undo state with visible messages (752cae6)
- fix: clear command drafts after dispatch (4851435)
- fix: show history compacted messages (cc7d980)
- fix: simplify changes panel mode switch (ceb721b)
- feat: add git and branch review modes (71a8c0d)
- feat: add git setup and current-turn session changes (769f3ab)
## [v0.4.4] - 2026-04-06
- fix: keep edit mode checkboxes compact on mobile (3d7c342)
- feat: support shift-select in recents edit mode (6181f5f)
- style: refine edit mode selection visuals (c7ca524)
- feat: add batch edit mode for sidebar recents and rewrite folder drag-sort (a1326d5)
- fix: persist panel layout and terminal positions (e1aeea8)
## [v0.4.3] - 2026-04-05
- fix: improve pane navigation and sidebar drag affordances (a3dd889)
- fix: keep input focus after sending (161e7b8)
- fix: render bash tool commands inline (b6a6db2)
- fix: remove sidebar footer divider (d6e59b9)
- fix: prefer pointer outline interaction on hybrid devices (a20ebc0)
## [v0.4.2] - 2026-04-03
- fix: switch folder recents to the clicked directory (d579407)
- fix: support sticky ctrl+alt combos in mobile terminal keyboard (4d9c5b3)
- fix: use tauri plugin-opener for external links in terminal and MCP auth (9b80bf3)
- fix: add background tint and equal spacing to plain code block copy button (102b5d4)
- fix: prevent global mode from being overridden by pane directory sync (b7a665c)
## [v0.4.1] - 2026-04-03
- fix: disable split-pane entry points on small touch screens (3c1a607)
## [v0.4.0] - 2026-04-03
- fix: align request dialogs with the input dock width (59b5962)
- perf: fix memo-defeating patterns in message rendering pipeline (f678d36)
- fix: stabilize ChatPane tree structure across fullscreen toggle (79b28f1)
- fix: preserve DOM across pane fullscreen toggle and hide split button in fullscreen (13c7f5e)
- fix: keep split resizing off the render path (750dd50)
- fix: normalize panel PTY restoration and dedupe terminal tabs (d1b4565)
- feat: add pane fullscreen mode and refine split header actions (cf959e0)
- fix: remove split container transition side effects (5332f61)
- fix: stabilize split-pane header interactions (6631e01)
- refactor: streamline split-pane chrome and transitions (cdb4a8b)
- fix: avoid first-frame tool expansion flicker on session switch (a71dea2)
- refactor: finish pane-first cleanup and auto-approve wiring (8af9e57)
- fix: unify router state and focused-pane directory sync (2cbb177)
- refactor: remove legacy focused-session compatibility layer (e4d0616)
- refactor: unify chat shell around focused pane state (e45dd17)
- fix: sidebar selectedSessionId follows focused pane in split mode (a82abaf)
- fix: isolate per-pane state — fullAutoMode, agent selection, session eviction, clearSession (18bc9af)
- fix: prevent duplicate SSE subscriptions in split-pane mode (e15ad08)
- feat: add split-pane UI with full-parity ChatPane, SplitContainer, PaneHeader, SplitToolbar (3af6b14)
- refactor: parameterize useChatSession for multi-instance support (1b37d8d)
- refactor: make session infrastructure multi-instance ready (e0ce47d)
- fix: hide assistant fork action when no text can be copied (f734462)
- fix: keep composer action blur out of transform layers (6eb3bf0)
## [v0.3.8] - 2026-03-31
- feat: add fullscreen button to file preview and changes diff preview (9e4ca2d)
- feat: enable fork from assistant messages to preserve AI replies (f2a0079)
- refactor: unify floating component shadows to a consistent two-tier system (shadow-sm / shadow-lg) (1cf526e)
- chore: upgrade dependencies (vite 8, i18next 26, lucide-react 1.x, etc.) (41498bf)
- fix: adjust ModelSelector padding so scrollbar doesn't overlap list content (e7d6e4f)
- refactor: unify ModelSelector into a single component for both PC and mobile (5b81638)
- fix: sync session title to messageStore on SSE update for real-time header refresh (b74bb6a)
- fix: align mobile header toggle spacing (1052826)
## [v0.3.7] - 2026-03-28
- fix: remove double border on attachment meta when no content preview (d29a2f4)
- fix: hide floating actions (undo/redo/permission) during todo panel swap (a3a7231)
- fix: apply glass effect to mobile collapsed capsule (2075818)
- fix: fallback fetch after send to prevent missing user message on SSE drop (e2138df)
- fix: skip overlay scrollbar on elements with no-scrollbar class (506cf24)
- feat: child sessions displayed under parent in sidebar with toggle for always-show (97ce7d8)
- feat: add diff toggle for folder recents (a1ed95e)
- fix: wire compact model selector to global shortcut (d14fb42)
## [v0.3.6] - 2026-03-28
- test: add chatViewport mock to InputBox and InputToolbar tests (7eb80ac)
- fix: remove unused let binding in overlayScrollbar (ab9eaba)
- feat: add horizontal overlay scrollbar support (ad8d21d)
- refactor: rewrite outline index with visual config, focus-based interaction and entry windowing (18bccf9)
- fix: align compact model selector trigger (480dd04)
- refactor: centralize chat viewport state (8512fcb)
- fix: improve coarse pointer support in desktop UI (4cfc8e5)
## [v0.3.5] - 2026-03-27
- fix: use previous stable tag for release changelog (b00cff7)
- fix: load chat header title from session detail (0b8443c)
- fix: stabilize chat history loading scroll behavior (87190c6)
- fix: animate todo panel swap without layout jank (ca8a672)
- feat: add frosted glass toggle in appearance settings (662070c)
- fix: apply overlay scrollbar to textarea, hide all native scrollbars (5dce51f)
- fix: frosted glass not rendering in Tauri production build & overlay scrollbar positioning (96e72c1)
- feat: replace native scrollbars with global overlay scrollbar system (da09781)
- refactor: simplify @ and / menus with cleaner layout and unified style (f5bddec)
- refactor: redesign ModelSelector for glass aesthetic (33a9dd2)
- refactor: unify frosted glass system with CSS utility classes (0e459f9)
- style: introduce frosted glass effect to all floating surfaces (ae10178)
- fix: remove extra padding from message toggles (4eb916a)
## [v0.3.4] - 2026-03-24
- fix: align markdown copy buttons with header text (80e59ce)
- fix: render markdown images without streamdown wrapper controls (650851f)
- fix: remove code size-based rendering limits (42171e7)
- fix: remove contain-intrinsic-size that caused scroll jank (7942144)
## [v0.3.3] - 2026-03-24
- fix: resolve TypeScript errors in MarkdownRenderer for release (1abcdf1)
- fix: exclude task tool from compact inline permission mode (5f39aff)
- style: remove left accent line from TaskRenderer, restore badge status colors (1cb6044)
- style: refine TaskRenderer visual hierarchy (263b74b)
- fix: show tool description from input while running, not just after completion (b65d16e)
- fix: reduce excessive right padding in expanded reasoning content (885668d)
- fix: panel dropdown menu hover overflow — use inset padding with rounded items (02653bf)
- fix: table copy button pinned outside scroll, mobile always-visible copy buttons (2cdef17)
- style: unify tool output header height to h-8 (32px) (c566b1a)
- style: unify message flow border-radius to a tighter 3-tier system (4a8a89f)
- refactor: redesign markdown code blocks, tables, and inline code styles (11f6222)
## [v0.3.2] - 2026-03-24
- fix: align fullscreen diff test mocks with typed children (fdf1a74)
- fix: restore release validation after fullscreen refactor (ef2d755)
- fix: adjust outline index spacing for visual balance (fb98097)
- refactor: unify fullscreen components into generic FullscreenViewer (9e6d8ed)
- refactor: redesign settings UI with section-based layout and cleaner primitives (478351a)
## [v0.3.1] - 2026-03-23
- fix: unify chevron arrow direction - collapsed points right, expanded points down (3557e5a)
- feat: compact inline permission - hide duplicate content when tool body already renders (63d571b)
- refactor: redesign descriptive steps summary - merge categories, per-category errors, truncation (870f490)
- fix: move diff stats next to title and remove exit code from descriptive steps (74e69c0)
- fix: deferred permission unmount and multi-select question answer parsing (a8dc9bd)
- fix: auto-expand readable tools that finish instantly in immersive mode (06dd4d4)
## [v0.3.0] - 2026-03-22
- fix: lint warnings, error tool diff stats, descriptive steps partial error coloring (ea53836)
- feat: add diff stats summary to descriptive steps, fix write tool diff display (ce29807)
- fix: immersive mode keeps non-readable tool groups collapsed even during execution (be61de2)
- fix: resolve lint errors and remove unused eslint-disable directives (6037d19)
- feat: add immersive mode with smart tool expand/collapse (0d4252d)
- fix: skip QuestionRenderer when user dismissed or error (2029584)
- feat: add QuestionRenderer with InlineQuestion-style read-only answered view (207f0c6)
- fix: allow long bash commands to wrap in terminal view (8a7ddfd)
- simplify: BashRenderer remove buttons, click command to copy, inline exit code (72186b5)
- refactor: BashRenderer with fixed bottom bar, exit code, fullscreen, mobile-friendly buttons (cb7bb70)
- fix: restore height limit and fullscreen button in compact mode (8f8689d)
- feat: add BashRenderer with terminal style, Shiki highlighting, ANSI color support (856d74d)
- feat: descriptive steps default collapsed, show output status on tool row (b25a0f2)
- refactor: unify InlinePermission with tool output style, remove unused inline variant (ac510d7)
- feat: add compact tool output mode (hide input, no collapse, no height limit) (1036133)
- feat: add descriptive tool steps mode (d7e153f)
- refactor: remove ambient tool mode and make inline requests opt-in (06034a2)
## [v0.2.10] - 2026-03-21
- feat: restore forked prompts in the composer (0792cf6)
- fix: keep folder recents aligned with live updates (a0c8899)
- fix: improve long duration formatting (62e6d94)
## [v0.2.9] - 2026-03-19
- fix: preserve custom audio when switching to builtin sounds (57119da)
- fix: resolve all eslint warnings across codebase (4b31da3)
- feat: add notification sound system with per-event configuration (b6019e2)
- perf: defer offscreen chat message rendering (52d8ba8)
- refactor: unify file and changes preview panels (9f315d8)
## [v0.2.8] - 2026-03-18
- fix: wrap panel tab label case blocks (d353b80)
- feat: expand right panel resize range (3e67623)
- feat: add tabbed session changes workspaces (ace0259)
- feat: add tabbed file preview workspaces (ffad629)
- fix: stabilize mobile terminal toolbar layout (1eb23b7)
- feat: refine mobile terminal extra keys behavior (6e04254)
- feat: add mobile extra keys toolbar for terminal (Termux-style) (2b185ac)
- feat: show collapsed folder activity status (0bd1378)
- feat: mark completed sessions as unread in recents (90cf8c3)
## [v0.2.7] - 2026-03-18
- feat: add markdown reasoning display mode (20fa476)
- fix: avoid action overlap in folder recents on mobile (83b40b9)
- feat: animate folder recents expansion (1b8ea13)
- fix: preserve folder recents expansion across tab switches (058c289)
- fix: limit streaming layout animation to bottom-follow mode (95c6aca)
- fix: restore reasoning thinking shimmer transition (5c631c8)
- feat: refine reasoning markdown presentation (71a012a)
- fix(chat): extract formatDuration to shared formatUtils (c405092)
- fix(chat): preserve aborted turn durations (13633fb)
- feat: add diff gutter style setting (markers vs change bars) (4c5e7b8)
- fix(i18n): improve permission dialog labels for request/rule clarity (e3730d5)
- refactor: replace react-markdown with Streamdown for streaming-optimized markdown rendering (fcf3307)
- refactor: extract useResponsiveMaxHeight hook for shared viewport-aware sizing (1993eb8)
- fix: make ContentBlock maxHeight responsive to viewport size (b584071)
- fix: resolve drag-to-reorder race condition causing stale closures (b08a763)
## [v0.2.6] - 2026-03-17
- refactor: redesign folder recents with drag-to-reorder and compact session items (88b4139)
- fix: resolve bugs introduced by Python-to-Rust router migration (335b82e)
- refactor: migrate gateway router from Python to Rust (290087f)
- Feature: Add ability for router to read config from environment variables (bddc46b)
- refactor: create new Rust project opencodeui-router (dd96377)
- Update image previews in README.md (be84586)
- fix: 服务器编辑/删除按钮始终可见 (0037a17)
## [v0.2.5] - 2026-03-16
- fix: 修复胶囊按钮和弹窗 header 图标与文字对齐 (a65e34e)
- fix: 工具 icon 光晕不再被父容器裁切 (c946f19)
- fix: 移动端设置 tab 选中态被 overflow 裁切 (027fcf9)
- refactor: 设置界面优化 — 修复双滚动条、服务器编辑/删除确认 (ec75fad)
- fix(i18n): 保留开发者工具常见英文术语不翻译 (f4a6022)
- fix(i18n): 修正中文翻译质量 (f2bd269)
- feat: add full i18n support with react-i18next (en + zh-CN) (bcd9850)
## [v0.2.4] - 2026-03-16
- fix: 会话级 Full Auto 恢复原有行为 — 只在当前所在页面的 session 生效,切走后不再自动放行 (e98bd4e)
- fix: Full Auto 全局模式在 SSE 事件层拦截,确保非当前会话的权限请求也能自动放行 (1046837)
- feat: Full Auto 三态模式 — 单击循环 off/会话级(黄)/全局(红),会话级只放行当前会话,全局放行所有,纯内存刷新即清 (db95fc7)
- fix: 去掉 steps header 入场动画,保持与其他元素一致不做特殊处理 (1e725a5)
- fix: 虚拟滚动横向滚动条修复 — 去掉 probe 元素改用 scrollWidth 历史最大值追踪,SplitDiffView proxy scrollbar 加 gutter 占位对齐 (beee631)
## [v0.2.3] - 2026-03-15
- fix: probe 最长行选取改用 monoDisplayWidth 估算渲染宽度,CJK/全角字符按双倍计,修复含中文注释时横向滚动不到位 (d1fb35a)
- Revert "fix: probe 元素去掉 overflow:hidden 修复横向滚动不到位 — hidden 在两个方向截断内容导致 scrollWidth 偏小" (0e5353f)
- fix: probe 元素去掉 overflow:hidden 修复横向滚动不到位 — hidden 在两个方向截断内容导致 scrollWidth 偏小 (c9788fc)
- fix: lint warnings — ref 写入移入 useEffect,CodePreview 提取 tokens 避免 render 期间读 ref,修复 CodePreview 测试,清理多余依赖和 eslint-disable (96955f7)
- fix: 消除胶囊⇄输入框切换闪烁 — FloatingActions 改为同一 DOM 切换定位避免 remount,胶囊去掉入场动画和防抖回归纯 UI (bc3cc0c)
- fix: 移动端胶囊⇄输入框过渡优化 — 胶囊退场不延迟避免与输入框重叠闪烁,收起方向加 120ms 防抖消除滚动边界抖动 (4060e83)
- refactor: 统一动画体系 — UndoStatus 去自带动画改由 PresenceItem 控制,CollapsedCapsule 从 CSS animate-in 换成 usePresence,PresenceItem 加 shrink-0 防挤压,清理 CSS 死代码 (5106ac0)
- fix: probe 元素精确撑开 scrollWidth 修复横向滚动不到位,去掉 backdrop-blur (0f3b764)
- Revert "fix: 代码预览/diff 横向滚动重构 — CodePreview/UnifiedDiffView 改原生滚动+sticky gutter,SplitDiffView 用 probe 元素精确撑宽,去掉 backdrop-blur" (9d12d72)
- fix: 代码预览/diff 横向滚动重构 — CodePreview/UnifiedDiffView 改原生滚动+sticky gutter,SplitDiffView 用 probe 元素精确撑宽,去掉 backdrop-blur (9e37646)
- feat: usePresence hook + 浮动按钮/权限框/提问框入场退场动画 — 命令式 animate() 零额外 bundle (fb8c59f)
- fix: 消息流底部间距增大,为浮动按钮预留空间 (7f32fa2)
- fix: 删除 Output 的 Running... 文字,Input/Output spinner 统一无文字对齐 (e8c409e)
- fix: 单工具调用始终 compact 布局,消除流结束时的缩进跳变 — SmoothHeight 平滑过渡 + steps header 入场动画 (031502f)
- fix: 代码预览和 diff 行号背景色统一 — gutter 去掉硬编码背景,继承父容器颜色 (9ef8223)
## [v0.2.2] - 2026-03-15
- ci: 恢复 codegen-units=1 减小产物体积,Rust cache 按平台隔离,精简工作流 (5018e3b)
- chore: 清理 suppressAutoScroll 死代码 (d47085e)
- fix: loadMore 不跳变 — 去掉 inner wrapper,消息反序直接作为 flex 子元素,prepend 时临时禁用 content-visibility (657e7a4)
- refactor: 用 column-reverse 替代 ResizeObserver 实现原生 stick-to-bottom (591a8eb)
- refactor: DiffView 复用 DiffViewer 组件,删除 150 行重复 diff 渲染代码 (e78d987)
## [v0.2.1] - 2026-03-14
- refactor: 用 ResizeObserver 替代 RAF 轮询实现流式自动滚动 (10d9e6a)
- fix: 空消息不参与可见列表,消除 abort 时的滚动跳变 (9dbaa4d)
## [v0.2.0] - 2026-03-14
- feat: RECENTS 列表标记活跃 session 状态 (closes #25) (98ecce9)
- fix: motion animate() 类型歧义 — 改用 motion/mini 单签名 API 解决 tsc -b TS2769 (7927bcf)
- fix: 移除 ContentBlock loading skeleton 骨架条,避免输出短于占位时的负增长跳变 (f6db986)
- feat: 消息入场生长动画 — 用户和助手消息统一从 height 0 平滑展开 (ce055fb)
- fix: SmoothHeight 激活时锁定 outer 高度,修复动画不触发的问题 (e5f6cf5)
- feat: 命令式 animate() 动画方案 — 高性能 + 零 React 组件开销 (4481aba)
- fix: 修复流结束后闪烁回弹问题 (192944e)
- fix: 修复流式文本不实时渲染的问题,移除 useSmoothStream (005b6bf)
- refactor: 连续助手消息分组渲染,共享容器浑然一体 (cfadad0)
- Revert "fix: 复制按钮始终占位,避免 text 到达时布局跳变" (1af197a)
- fix: 移除未使用的 hasMoreHistory 解构变量,修复 TS6133 编译错误 (cf9419b)
- fix: 复制按钮始终占位,避免 text 到达时布局跳变 (d51e7a8)
- fix: 移除 'Beginning of conversation' 常驻提示,仅保留加载中 spinner (11484c6)
## [v0.1.18] - 2026-03-13
- ci: 加速 release 编译 — Rust 多线程 codegen + Android 双架构并行 (2398c54)
- fix: FloatingActions 高度抖动、滚动按钮误显示 (466f4ea)
- refactor: gutter/content 分离架构 + 水平滚动独立化 + FullscreenViewer 确定高度 (e327e59)
- perf: streaming 渲染地基优化 — rAF 滚动、delta 批量化、布局稳定性 (f689910)
## [v0.1.17] - 2026-03-11
- fix: update CodePreview test mock to use useSyntaxHighlightRef (a10075f)
- fix: move history loading indicator below top spacing so it's visible (4cfc71c)
- fix: remove MAX_HISTORY_MESSAGES cap and restore loading UI (43d2273)
- perf: reduce sidebar resize lag with CSS containment and DOM-only sidebar drag (63bc0c5)
- fix: enable virtual scrolling in CodePreview by adding height constraint (43c9e3a)
- fix: mobile input collapses when tapping FloatingActions buttons (a777334)
- refactor: simplify loadMore pagination and remove prependedCount (8614df4)
- refactor: rewrite messageStore and ChatArea, remove IndexedDB cache layer (eecdeaf)
- refactor: remove loading spinners/skeletons and reduce scroll-related re-renders (eb7ebff)
## [v0.1.16] - 2026-03-11
- fix: scroll jitter after streaming ends caused by content-visibility height mismatch (765052a)
- fix: stop auto-scroll jitter when user scrolls slightly during streaming (171a62e)
## [v0.1.15] - 2026-03-10
- fix: default revertSteps to 0 in FloatingActions (400fbb4)
- chore: bump version to 0.1.15 (3f57df2)
- fix: slow scroll during streaming causes jitter by pulling user back to bottom (860683e)
- fix: isFocused stuck after toolbar button click prevents capsule collapse (aaaa727)
- refactor: extract FloatingActions and CollapsedCapsule components from InputBox (273bd98)
- refactor: extract useInputHistory hook from InputBox (d920da9)
- refactor: extract useAttachmentRail hook from InputBox (2150d82)
- refactor: extract useMobileCollapse hook from InputBox (c5fd39b)
- fix: mobile input capsule state not resetting on session switch + blur collapsing on toolbar interaction (474938e)
## [v0.1.14] - 2026-03-10
- fix: session switch scroll + eliminate all content flicker during load (d823799)
- fix: infinite history loading loop + stabilize observer + cleaner scroll-to-bottom (fe28777)
- fix: smooth shimmer gradient + disable browser scroll restoration + multi-frame scroll-to-bottom (2144260)
- fix: shimmer highlight sweep + remove unused visibleMessageIds state (ae767ca)
- fix: outline click retract, prepend scroll preservation, cross-message merge continuation (e3ca11a)
- fix: desktop label click navigation + mobile passive touch events in OutlineIndex (f35f758)
- refactor: rewrite OutlineIndex with clean fisheye engine (1057c1c)
- refactor: replace react-virtuoso with native scroll + content-visibility (2dc550a)
- fix: correct shimmer animation direction (left-to-right) and use linear timing (16bc419)
- fix: endsWithTool skips empty reasoning/text so cross-message tool merging works correctly (fdedbcd)
- style: replace thinking breath-bar with shimmer text animation in italic mode (1bed06b)
## [v0.1.13] - 2026-03-10
- fix: SSE reconnect race conditions and stale timeout constant (7614d53)
- test: add unit tests for HTTP module and Tauri environment detection (79b77af)
- chore: eliminate all 8 lint warnings (0 errors, 0 warnings) (b312bb6)
- fix: URL-encode query string values in buildQueryString (f6a434e)
- fix: add AbortController-based timeout to HTTP request() (7962278)
- fix: pending permission/question cache supports multiple requests per session (b421172)
- fix: SSE parser now handles CRLF line endings and multi-line data correctly (d4553d5)
- chore: remove dead storage key exports (WIDE_MODE, THEME_MODE, SIDEBAR_WIDTH, MODEL_VARIANT_PREFIX) (2901c99)
- refactor: replace bare console.error with unified error handlers across 14 files (7a0c86c)
- refactor: move theme/wideMode into themeStore, eliminate prop drilling through Sidebar and SettingsDialog (9c8b2d7)
- refactor: split messageStore into store, types, and React hooks layers (caef036)
- refactor: extract App.tsx hooks (useViewportHeight, useCancelHint, useCloseServiceDialog) (2dd70e3)
- refactor: split SidePanel into focused sidebar components (673db54)
- refactor: extract InputBox utility functions to input/inputUtils.ts (a1c0eee)
- refactor: split SettingsDialog into focused component files (72f5ed4)
- refactor: consolidate duplicated formatting functions into utils/formatUtils.ts (a981c89)
- refactor: deduplicate diff content extraction (fad37d4)
- refactor: extract ModalShell to unify modal overlay infrastructure (ae9f96e)
- chore: remove unnecessary eslint-disable in logger.ts (118740c)
- chore: clean up dependencies (0c50ac0)
- refactor: add dev-only logger, replace bare console.log with logger.log (91fc303)
- refactor: remove dead code files (test-shiki.ts, editorUtils.ts, toolUtils.ts) (3a3f37a)
## [v0.1.12] - 2026-03-09
- fix: preserve changelog ordering during release prep (039b906)
- chore: ignore generated tauri assets in lint (071e2db)
- fix: keep slash command composer responsive (42cdcf6)
- fix: restore cross-platform Tauri app handling (6b84717)
- perf: replace std default hasher with rapidhash (c6d5bdb)
- refactor: reorganize the file structure of Tauri backend (fa5edf6)
- perf: refactor OpenDirectoryState with papaya HashMap to reduce lock contention (6efd2a0)
- perf: optimize ServiceState.child_pid with AtomicU32 to reduce Mutex contention (a42df0b)
- perf: optimize SseState implementation - replace Mutex+Hashmap with papaya library to reduce contention and improve performance (f586a84)
## [v0.1.11] - 2026-03-08
- feat: add an optional folder-style Recent view while preserving the original session row details and per-folder ordering controls
- feat: aggregate Active sessions across all saved projects instead of limiting the list to the currently selected directory
## [v0.1.10] - 2026-03-08
- fix: harden session message sync and failed sends (b788dce)
- chore: add validated release preparation flow (010ffbe)
- docs: consolidate v0.1.9 release notes (79113da)
## [v0.1.9] - 2026-03-08
- fix: restore message attachment expand animation (2975fe3)
- fix: streamline composer attachment rail interactions (99db58a)
- fix: constrain expanded attachments and preserve composer blank lines (dd2d7ba)
- fix: harden composer attachment rail scrolling (b2bac29)
## [v0.1.7] - 2026-03-08
- fix: truncate tool description overflow in tool call row (3782c67)
- fix: tighten mobile model menu and attachment width (60b34a2)
- fix: preserve utf-8 across tauri stream chunks (1dcb15a)
## [v0.1.6] - 2026-03-07
- fix: restore tauri mobile file attachments (ffe3398)
## [v0.1.5] - 2026-03-07
- chore: keep tauri config formatted on release (48f6045)
- fix: sync settings version with app release (b815d18)
- chore: format release workflow (aef533b)
- ci: add build validation workflow (491a544)
- other: add "zed/\*" as ignored file (8f32b7d)
## [v0.1.4] - 2026-03-07
- fix: split frontend and api slash commands (bdb2e33)
- fix: support clipboard fallback in insecure contexts (edf4dd0)
- fix: align slash command descriptions (9d84a78)
## [v0.1.3] - 2026-03-07
- chore: restore release workflow formatting (73a41a4)
- perf: split code preview from file explorer (b94bfc5)
- perf: lazy load optional panels and split vendor chunks (9e0f7d6)
- chore: add test baseline and clean lint debt (0d5f175)
- chore: establish lint and formatting baseline (762786d)
- fix: shorten input footer disclaimer copy (d691b7b)
- chore: automate lockfile updates in release script (e678349)
## [v0.1.2] - 2026-03-07
- fix: scope active session state to the current directory (2503c6b)
## [v0.1.1] - 2026-03-07
Patch release focused on chat input polish, session list consistency, and smoother permission handling.
### Fixes
- Restored collapsed input dock bottom spacing
- Kept the session list in sync across directory filters and live updates
- Returned gracefully to a new chat after deleting the currently open session
- Aligned the todo popover with the input dock for desktop and mobile
- Removed extra polling from permission/question flows and synced reply state immediately
### Improvements
- Preloaded `@` root listing and `/` command data when entering a session to reduce first-open lag
## [v0.1.0] - 2026-03-05
First stable release of OpenCodeUI.
### Features
- Drag-and-drop file attachment support (desktop & mobile)
- Material file icons for file/folder display
- File @mention from explorer sidebar
- Context breakdown visualization in sidebar
- Live retry status display with expand/collapse
- Attachment detail viewer with copy/save functionality
- Capability-based file attachment upload
### Fixes
- Aligned capsule thinking chevron with italic/tool toggle arrows
- Stabilized Tauri desktop file drag-and-drop handling
- Fixed multiple task windows rendering the latest child session
- Eliminated scroll jank from high-frequency re-renders
- Fixed mobile overflow in project and diff headers
- Fixed sidebar notification/session meta row overflow
- Fixed attachment pill truncation and compact tool layout
### Improvements
- Migrated all icons to lucide-react
- Unified message part spacing and alignment
- Added Docker support with material icons build step
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+212
View File
@@ -0,0 +1,212 @@
<p align="center">
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="Logo de PiAgentUI" width="760" />
</p>
# PiAgentUI
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | **Español** | [日本語](./README.ja.md)
PiAgentUI es una interfaz moderna web y de escritorio para **Pi Agent**. No es solo una capa visual de chat: el objetivo es convertirse en una aplicación completa de agentes, en la misma categoría de producto que Codex, Claude Desktop y otros entornos de desarrollo agent-first.
PiAgentUI trata el runtime real de Pi como la fuente de verdad. Sesiones, metadatos de modelos, uso de contexto, tool calls, skills, configuración MCP y slash commands vienen de Pi siempre que sea posible, en lugar de recrearse como estado falso de interfaz.
> **Estado del proyecto:** desarrollo local activo. Algunas áreas ya son cercanas a producto, mientras otras siguen cambiando rápidamente.
## Demostración del software
Estas capturas muestran la experiencia actual de PiAgentUI: un workspace oscuro y enfocado para agentes, con sesiones reales de Pi, selección de modelos, metadatos MCP, settings, chat con herramientas y paneles laterales.
![Workspace de chat de PiAgentUI con panel lateral MCP](./src/assets/images/img1.png)
![Diálogo de settings y conexiones de servidor de PiAgentUI](./src/assets/images/img2.png)
![Selector de modelos de PiAgentUI dentro de una sesión activa](./src/assets/images/img3.png)
## Qué hace PiAgentUI hoy
- **Chat con streaming** — salida en vivo por SSE, reconciliación estable de mensajes, markdown, resaltado de código, partes de razonamiento y tarjetas de herramientas.
- **Sesiones reales de Pi** — lista de sesiones, enrutamiento a la sesión activa, historial seguro tras reinicio, títulos derivados y envío a la sesión seleccionada.
- **Salida de herramientas en tarjetas** — tool calls/results quedan anexados a los mensajes del asistente, sin filtrarse como texto normal en el chat.
- **Uso real de contexto** — el uso de contexto viene del runtime de Pi cuando está disponible; el contexto desconocido/compactado se muestra de forma segura en lugar de `NaN`.
- **Metadatos de modelos** — la identidad de provider/modelo viene de Pi; el catálogo local de OpenRouter se usa solo como enriquecimiento de detalles.
- **Panel de Skills** — las skills reales se leen del prompt efectivo de Pi y se agrupan por origen: global, project, package y other.
- **Panel MCP** — lee servidores MCP configurados en archivos locales/globales y muestra transporte, comando, URL, lifecycle, direct tools y origen.
- **Slash commands de Pi** — `/api/commands/list` expone comandos built-in de Pi y comandos dinámicos de extensiones, prompts y skills.
- **Adjuntos multimodales** — soporte visual para archivos/imágenes/PDF/audio/video según las capacidades del modelo seleccionado.
- **Terminal y archivos** — terminal integrado, explorador de archivos, syntax highlighting, componentes de diff e integración de escritorio con Tauri.
- **UI keyboard-first** — command palette, atajos configurables, split panes, selector de modelo, navegación por proyecto/sesión y layouts responsivos.
## Dirección del producto
PiAgentUI está evolucionando hacia una aplicación completa de agentes:
1. **Acciones visuales para todos los comandos de Pi** — los slash commands siguen disponibles, pero las acciones frecuentes deben convertirse en botones, menús, diálogos o paneles.
2. **Entrada por voz y transcripción en tiempo real** — grabación de micrófono, deltas parciales, inserción de la transcripción final en el composer y configuración de proveedores STT OpenAI-compatible.
3. **Integración MCP más profunda** — más allá de metadatos: conectar, autenticar, inspeccionar herramientas y ejecutar workflows MCP desde la UI.
4. **Control completo de sesiones** — fork, clone, navegación en árbol, import/export/share, branch summaries y gestión de contexto como flujos nativos.
5. **Experiencia de escritorio completa** — app Tauri pulida con descubrimiento local de runtime, settings seguros, notificaciones y controles ricos de workspace.
## Roadmap de audio y transcripción
PiAgentUI ya acepta adjuntos de audio cuando el modelo seleccionado declara soporte de entrada de audio. El sistema de voz planeado añade speech-to-text en vivo directamente en el composer.
Arquitectura recomendada:
- **Proveedor realtime por defecto:** OpenAI Realtime Transcription con `gpt-realtime-whisper` para transcripción parcial de baja latencia.
- **Fallback por archivo:** OpenAI Audio Transcriptions con `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` o `whisper-1`.
- **Proveedores personalizados:** APIs STT OpenAI-compatible configurables por el usuario, como proveedores que implementan `POST /v1/audio/transcriptions`.
UX planeada:
- botón de micrófono en el composer;
- estados de grabación y permisos;
- overlay de transcripción parcial;
- texto final insertado en el input;
- comportamiento configurable de append/replace;
- auto-send desactivado por defecto;
- manejo recuperable de errores de permisos, red y proveedor.
Ejemplo de configuración de proveedor:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "es"
}
```
El soporte realtime se trata como una capability separada, porque la compatibilidad HTTP con OpenAI no garantiza compatibilidad WebSocket.
## Arquitectura
```text
PiAgentUI
├─ Frontend React/Vite
│ ├─ chat, renderizado de mensajes, input, paneles y settings
│ ├─ stores locales para preferencias de UI
│ └─ clientes SSE/API para endpoints locales de PiAgentUI
├─ Backend como extensión Pi
│ └─ extensions/piagentui-server.ts
│ ├─ expone endpoints locales /api/*
│ ├─ conecta con runtime/sesiones/modelos de Pi
│ ├─ transmite eventos de Pi al navegador
│ └─ lee metadatos locales como MCP config y skills
└─ Runtime Pi Agent
├─ sesiones
├─ modelos/providers
├─ herramientas
├─ skills
├─ MCP
└─ slash commands
```
La UI no debe inventar estado de runtime cuando Pi ya conoce la respuesta. PiAgentUI puede cachear y enriquecer datos para UX, pero Pi sigue siendo autoritativo para el comportamiento del agente.
## Stack técnico
| Área | Stack |
| ------------------- | --------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Estilo | Tailwind CSS v4 y design tokens del proyecto |
| Escritorio | Tauri 2 |
| Markdown | Streamdown / pipeline de markdown |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Tests | Vitest, Testing Library |
| Backend local | Extensión Pi, Node HTTP server, WebSocket/SSE |
## Desarrollo local
Instalar dependencias:
```bash
npm install
```
Iniciar el frontend en modo dev:
```bash
npm run dev
```
Build:
```bash
npm run build
```
Tests:
```bash
npm run test:run
```
Type checks:
```bash
npm run typecheck
npm run typecheck:extensions
```
Validación completa:
```bash
npm run validate
```
## Runtime de la extensión Pi
PiAgentUI se registra como extensión Pi en `package.json`:
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
Cuando la extensión arranca, escribe metadatos de descubrimiento local en:
```text
~/.pi/agent/piagentui-port.json
```
La app web usa el puerto/token de ese archivo para hablar con el servidor local de la extensión. Algunos cambios de backend requieren reiniciar o recargar el proceso de la extensión Pi antes de verse en el navegador.
## Endpoints locales importantes
| Endpoint | Propósito |
| -------------------------------- | ---------------------------------------------------------- |
| `GET /api/models` | Lista real de modelos Pi con capacidades normalizadas |
| `GET /api/sessions` | Lista de sesiones leída desde los archivos de sesión de Pi |
| `GET /api/sessions/:id/messages` | Historial normalizado para renderizado en la UI |
| `GET /api/sessions/:id/context` | Uso de contexto runtime para la sesión activa |
| `POST /api/messages/send` | Envía un mensaje a la sesión Pi solicitada |
| `GET /api/skills` | Skills efectivas de Pi agrupadas por origen |
| `GET /api/mcp/status` | Metadatos de servidores MCP configurados |
| `GET /api/commands/list` | Slash commands built-in y dinámicos de Pi |
| `GET /global/event` | Stream SSE de eventos del backend PiAgentUI |
## Principios de desarrollo
- Preferir datos reales del runtime Pi en vez de estado falso solo de UI.
- Mantener OpenRouter como enriquecimiento de catálogo, nunca como sustituto de la identidad provider/modelo de Pi.
- Tratar valores runtime desconocidos explícitamente.
- Escribir tests antes de cambios de comportamiento siempre que sea posible.
- Mantener tool results dentro de tarjetas de herramienta.
- Seguir el sistema visual existente: tema, movimiento, espaciado y patrones de paneles.
- Separar claramente lo implementado del roadmap.
## Notas del repositorio
El proyecto todavía contiene nombres heredados de la base original OpenCodeUI en algunos puntos, incluyendo metadatos del paquete y documentación antigua. La dirección actual es PiAgentUI-first, y una limpieza futura debe migrar esos nombres sin romper los workflows locales.
## Licencia
Este repositorio sigue la licencia declarada en `package.json`: `GPL-3.0-only`.
+212
View File
@@ -0,0 +1,212 @@
<p align="center">
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="PiAgentUI ロゴ" width="760" />
</p>
# PiAgentUI
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | **日本語**
PiAgentUI は **Pi Agent** のためのモダンな Web / デスクトップ UI です。単なるチャット画面ではありません。最終的には Codex や Claude Desktop のような、エージェント中心の本格的な開発アプリになることを目指しています。
PiAgentUI は、実際の Pi runtime を信頼できる唯一の情報源として扱います。セッション、モデルメタデータ、コンテキスト使用量、tool call、skills、MCP 設定、slash command は、可能な限り Pi から取得し、UI 側だけの仮の状態として再実装しません。
> **プロジェクト状態:** アクティブなローカル開発中です。一部は製品に近い品質ですが、まだ高速に変化している領域もあります。
## ソフトウェアプレビュー
以下のスクリーンショットは、現在の PiAgentUI の体験を示しています。実際の Pi セッション、モデル選択、MCP メタデータ、settings、ツール対応チャット、サイドパネルを備えた、暗色で集中しやすいエージェント workspace です。
![MCP サイドパネルを表示した PiAgentUI のチャット workspace](./src/assets/images/img1.png)
![PiAgentUI の settings と server connections ダイアログ](./src/assets/images/img2.png)
![アクティブな agent session 内の PiAgentUI model selector](./src/assets/images/img3.png)
## 現在できること
- **ストリーミング対応チャット** — SSE によるライブ出力、安定したメッセージ同期、Markdown、コードハイライト、reasoning part、ツールカード。
- **実際の Pi セッション** — セッション一覧、アクティブセッションへのルーティング、再起動後も安全な履歴読み込み、セッションタイトル生成、選択中セッションへの送信。
- **ツール出力のカード表示** — tool calls/results は通常のチャット本文として漏れず、アシスタントメッセージに紐づいたカードとして表示されます。
- **実 runtime のコンテキスト使用量** — 利用可能な場合は Pi runtime から取得し、不明または compacted な値は `NaN` ではなく安全に表示します。
- **モデルメタデータ** — provider/model の実体は Pi が authoritative であり、ローカル OpenRouter カタログは context、output、modality、pricing、tokenizer などの補助情報としてのみ使います。
- **Skills パネル** — 有効な Pi prompt から実際の skills を読み取り、global / project / package / other に分類します。
- **MCP パネル** — ローカル/グローバル設定ファイルから MCP サーバーを読み取り、transport、command、URL、lifecycle、direct tools、source を表示します。
- **Pi slash commands** — `/api/commands/list` が Pi の built-in commands と、extension / prompt / skill 由来の動的コマンドを公開します。
- **マルチモーダル添付** — 選択中モデルの能力に応じて、ファイル、画像、PDF、音声、動画の添付 UI を表示します。
- **ターミナルとファイル操作** — 統合ターミナル、ファイルエクスプローラー、syntax highlighting、diff 関連コンポーネント、Tauri デスクトップ連携。
- **キーボード中心の UI** — command palette、カスタム keybindings、split panes、モデルセレクター、プロジェクト/セッションナビゲーション、レスポンシブレイアウト。
## プロダクトの方向性
PiAgentUI は、完全なエージェントアプリへ進化しています。
1. **すべての Pi コマンドに視覚的な操作を提供** — slash commands は残しつつ、よく使う操作はボタン、メニュー、ダイアログ、パネルにします。
2. **音声入力とリアルタイム文字起こし** — マイク録音、部分 transcript delta、最終 transcript の composer 挿入、OpenAI-compatible STT provider 設定。
3. **より深い MCP 統合** — メタデータ表示だけでなく、接続、認証、ツール確認、MCP workflow 実行を UI から行えるようにします。
4. **完全なセッション制御** — fork、clone、tree navigation、import/export/share、branch summary、context management をネイティブ UI として提供します。
5. **デスクトップグレードの体験** — Tauri アプリとして、ローカル runtime discovery、安全な settings、通知、より豊かな workspace control を提供します。
## 音声と文字起こしのロードマップ
PiAgentUI は、選択中モデルが audio input をサポートしている場合、すでに音声ファイル添付を扱えます。計画中の voice system では、composer にリアルタイム speech-to-text を追加します。
推奨アーキテクチャ:
- **デフォルト realtime provider:** 低レイテンシの部分 transcript 用に OpenAI Realtime Transcription と `gpt-realtime-whisper` を使用。
- **ファイル fallback:** OpenAI Audio Transcriptions の `gpt-4o-transcribe`、`gpt-4o-mini-transcribe`、または `whisper-1` を使用。
- **カスタム provider:** `POST /v1/audio/transcriptions` を実装する OpenAI-compatible STT API をユーザーが設定可能。
計画中の UX:
- composer のマイクボタン;
- 録音状態と権限状態;
- 部分 transcript overlay;
- 最終 transcript を input に挿入;
- append / replace 動作の設定;
- auto-send はデフォルト無効;
- 権限、ネットワーク、provider エラーを復旧可能な形で表示。
provider 設定例:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "ja"
}
```
OpenAI-compatible な HTTP transcription は WebSocket 互換性を保証しないため、realtime support は別 capability として扱います。
## アーキテクチャ
```text
PiAgentUI
├─ React/Vite frontend
│ ├─ chat, message rendering, input, panels, settings
│ ├─ UI preferences 用の local stores
│ └─ PiAgentUI local endpoints 用の SSE/API clients
├─ Pi extension backend
│ └─ extensions/piagentui-server.ts
│ ├─ local /api/* endpoints を公開
│ ├─ Pi runtime/session/model APIs への bridge
│ ├─ Pi events を browser に stream
│ └─ MCP config や skills などの local metadata を読み取り
└─ Pi Agent runtime
├─ sessions
├─ models/providers
├─ tools
├─ skills
├─ MCP
└─ slash commands
```
Pi がすでに知っている runtime state を UI が勝手に作るべきではありません。PiAgentUI は UX のために cache や enrichment を行えますが、agent behavior については Pi が authoritative です。
## 技術スタック
| 領域 | Stack |
| ------------------- | --------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Styling | Tailwind CSS v4 と project design tokens |
| Desktop | Tauri 2 |
| Markdown | Streamdown / markdown rendering pipeline |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Tests | Vitest, Testing Library |
| Local backend | Pi extension, Node HTTP server, WebSocket/SSE |
## ローカル開発
依存関係をインストール:
```bash
npm install
```
frontend dev server を起動:
```bash
npm run dev
```
ビルド:
```bash
npm run build
```
テスト:
```bash
npm run test:run
```
型チェック:
```bash
npm run typecheck
npm run typecheck:extensions
```
完全な検証:
```bash
npm run validate
```
## Pi extension runtime
PiAgentUI は `package.json` で Pi extension として登録されています。
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
extension 起動時、ローカル discovery metadata が以下に書き込まれます。
```text
~/.pi/agent/piagentui-port.json
```
Web アプリはこのファイルの port/token を使ってローカル extension server と通信します。一部の backend 変更は、ブラウザに反映される前に Pi extension process の再起動または reload が必要です。
## 重要なローカル endpoints
| Endpoint | 目的 |
| -------------------------------- | --------------------------------------------------- |
| `GET /api/models` | Pi の実モデル一覧と正規化された capability metadata |
| `GET /api/sessions` | Pi session files から読み取ったセッション一覧 |
| `GET /api/sessions/:id/messages` | UI rendering 用に正規化されたセッション履歴 |
| `GET /api/sessions/:id/context` | active session の runtime context usage |
| `POST /api/messages/send` | 指定された Pi session にユーザーメッセージを送信 |
| `GET /api/skills` | 有効な Pi skills を source ごとに分類 |
| `GET /api/mcp/status` | 設定済み MCP servers の metadata |
| `GET /api/commands/list` | Pi built-in / dynamic slash commands |
| `GET /global/event` | PiAgentUI backend からの SSE event stream |
## 開発原則
- UI-only の仮状態よりも、Pi runtime の実データを優先する。
- OpenRouter は catalog enrichment としてのみ使い、Pi の provider/model identity を置き換えない。
- 不明な runtime 値は明示的に扱う。
- 振る舞いを変える場合は、可能な限り先にテストを書く。
- tool results は tool cards 内に保持する。
- 既存の theme、motion、spacing、panel patterns に合わせる。
- 実装済み機能と roadmap を明確に分ける。
## リポジトリノート
このプロジェクトには、元の OpenCodeUI ベースから継承した名前が一部残っています。package metadata や古い documentation などです。現在の方向性は PiAgentUI-first であり、将来的な cleanup ではローカル workflow を壊さずに残りの名称を移行する必要があります。
## ライセンス
このリポジトリは `package.json` に記載されているライセンス `GPL-3.0-only` に従います。
+241
View File
@@ -0,0 +1,241 @@
<p align="center">
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="PiAgentUI logo" width="760" />
</p>
<h1 align="center">PiAgentUI</h1>
<p align="center">
<strong>Professional Desktop & Web UI for AI Coding Agents</strong><br/>
<em>Pi Agent · Claude Code · Codex · OpenCode · Cursor · Copilot</em>
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.6.10-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-GPL--3.0-green" alt="License" />
<img src="https://img.shields.io/badge/platform-Tauri%202%20%7C%20Web-brightgreen" alt="Platform" />
<img src="https://img.shields.io/badge/framework-React%2019%20%7C%20TypeScript-61DAFB" alt="Framework" />
</p>
**English (US)** | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | [日本語](./README.ja.md)
## TL;DR
PiAgentUI is a **full-featured workspace for AI coding agents** — sessions, model switching, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Built for Pi Agent but compatible with Claude Code, Codex, OpenCode, Cursor, and any agent that speaks SSE/HTTP.
> **Category:** AI Agent UI · Agent Desktop Client · MCP GUI · Coding Copilot Interface
> **Status:** Active development — production-grade chat, evolving fast. Expect weekly releases.
## Software preview
These screenshots show the current PiAgentUI experience: a dark, focused agent workspace with real Pi sessions, model selection, MCP metadata, settings, tool-aware chat, and side panels.
![PiAgentUI chat workspace with MCP side panel](./src/assets/images/img1.png)
![PiAgentUI settings and server connections dialog](./src/assets/images/img2.png)
![PiAgentUI model selector inside an active agent session](./src/assets/images/img3.png)
## Quick Install
```bash
# Clone and install
git clone https://github.com/lehhair/OpenCodeUI.git
cd OpenCodeUI
npm install
# Start dev server
npm run dev
# Or build desktop app
npm run tauri dev
```
Requires **Node.js ≥ 22** and **Pi Agent** installed. The UI auto-discovers the Pi runtime via `~/.pi/agent/piagentui-port.json`.
## What PiAgentUI does today
- **Agent chat with streaming** — live assistant output through SSE, stable message reconciliation, markdown rendering, code highlighting, reasoning parts, and tool cards.
- **Real Pi sessions** — session list, active-session routing, restart-safe history loading, derived session titles, and selected-session send routing.
- **Tool output as tool cards** — tool calls/results stay attached to assistant messages instead of leaking as normal chat text.
- **Runtime context usage** — context usage comes from Pi runtime when available; unknown/compacted context is shown safely instead of `NaN`.
- **Model metadata** — Pi provider/model identity remains authoritative, with optional OpenRouter catalog metadata used only for extra details like context, output, modality, pricing, and tokenizer hints.
- **Skills panel** — real skills are parsed from the effective Pi prompt and grouped by source: global, project, package, and other.
- **MCP metadata panel** — reads configured MCP servers from local/global config files and surfaces transport, command, URL, lifecycle, direct tools, and source.
- **Pi slash commands** — `/api/commands/list` exposes Pi built-in commands plus dynamic extension, prompt, and skill commands.
- **Attachments and multimodal input** — file/image/PDF/audio/video attachment affordances follow the selected model capabilities.
- **Terminal and file tooling** — integrated terminal, file explorer, syntax highlighting, diff-oriented components, and Tauri desktop integration.
- **Keyboard-first UI** — command palette, configurable keybindings, split panes, model selector, project/session navigation, and responsive layouts.
## Product direction
PiAgentUI is moving toward a complete agent app:
1. **Visual actions for every Pi command** — slash commands remain available, but frequent actions should become buttons, menus, dialogs, or panels.
2. **Voice input and realtime transcription** — microphone recording, partial transcript deltas, final transcript insertion into the composer, and OpenAI-compatible STT provider configuration.
3. **Deeper MCP integration** — beyond metadata: connect, authenticate, inspect tools, and execute MCP workflows from the UI.
4. **Full session control** — fork, clone, tree navigation, import/export/share, branch summaries, and context management as first-class UI flows.
5. **Desktop-grade experience** — a polished Tauri app with local runtime discovery, secure settings, notifications, and richer workspace controls.
## Audio and transcription roadmap
PiAgentUI already supports attaching audio files when the selected model declares audio input support. The planned voice system adds live speech-to-text directly in the composer.
Recommended architecture:
- **Default realtime provider:** OpenAI Realtime Transcription with `gpt-realtime-whisper` for low-latency partial transcripts.
- **Default file fallback:** OpenAI Audio Transcriptions with `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, or `whisper-1`.
- **Custom providers:** user-configurable OpenAI-compatible STT APIs, such as providers that implement `POST /v1/audio/transcriptions`.
Planned UX:
- microphone button in the composer;
- recording and permission states;
- partial transcript overlay;
- final transcript inserted into the input;
- optional append/replace behavior;
- auto-send disabled by default;
- recoverable error handling for permissions, network failures, and provider errors.
Planned provider config shape:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "en"
}
```
Realtime support is treated as a separate capability because OpenAI-compatible HTTP transcription does not guarantee WebSocket compatibility.
## Architecture
```text
PiAgentUI
├─ React/Vite frontend
│ ├─ chat, message rendering, input, panels, settings
│ ├─ local stores for UI preferences
│ └─ SSE/API clients for PiAgentUI local endpoints
├─ Pi extension backend
│ └─ extensions/piagentui-server.ts
│ ├─ exposes local /api/* endpoints
│ ├─ bridges to Pi runtime/session/model APIs
│ ├─ streams Pi events to the browser
│ └─ reads local metadata such as MCP config and skills
└─ Pi Agent runtime
├─ sessions
├─ models/providers
├─ tools
├─ skills
├─ MCP
└─ slash commands
```
The UI should not invent runtime state when Pi already knows the answer. PiAgentUI can cache and enrich data for UX, but Pi remains authoritative for agent behavior.
## Tech stack
| Area | Stack |
| ------------------- | --------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Styling | Tailwind CSS v4 plus project design tokens |
| Desktop | Tauri 2 |
| Markdown | Streamdown / markdown rendering pipeline |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Testing | Vitest, Testing Library |
| Local backend | Pi extension, Node HTTP server, WebSocket/SSE |
## Local development
Install dependencies:
```bash
npm install
```
Start the frontend dev server:
```bash
npm run dev
```
Build the app:
```bash
npm run build
```
Run tests:
```bash
npm run test:run
```
Run type checks:
```bash
npm run typecheck
npm run typecheck:extensions
```
Full validation:
```bash
npm run validate
```
## Pi extension runtime
PiAgentUI is registered as a Pi extension in `package.json`:
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
When the extension starts, it writes local discovery metadata to:
```text
~/.pi/agent/piagentui-port.json
```
The web app uses that local port/token information to talk to the running extension server. Some backend changes require restarting or reloading the Pi extension process before the browser sees the new behavior.
## Important local endpoints
| Endpoint | Purpose |
| -------------------------------- | ------------------------------------------------------ |
| `GET /api/models` | Real Pi model list plus normalized capability metadata |
| `GET /api/sessions` | Session list parsed from Pi session files |
| `GET /api/sessions/:id/messages` | Session history normalized for UI rendering |
| `GET /api/sessions/:id/context` | Runtime context usage for the active session |
| `POST /api/messages/send` | Send user message to the requested Pi session |
| `GET /api/skills` | Effective Pi skills grouped by source |
| `GET /api/mcp/status` | Configured MCP server metadata |
| `GET /api/commands/list` | Built-in and dynamic Pi slash commands |
| `GET /global/event` | SSE event stream from PiAgentUI backend |
## Development principles
- Prefer real Pi runtime data over mocked UI-only state.
- Keep OpenRouter metadata as catalog enrichment only; never replace Pi provider/model identity with it.
- Treat unknown runtime values explicitly instead of rendering misleading numbers.
- Add tests before behavior changes whenever possible.
- Keep tool results inside tool cards.
- Keep UI work aligned with the existing theme, motion, spacing, and panel patterns.
- Document what is implemented separately from what is planned.
## Repository notes
This project still contains inherited naming from the original OpenCodeUI base in a few places, including package metadata and older documentation files. The current direction is PiAgentUI-first, and future cleanup should migrate remaining OpenCodeUI naming without breaking local workflows.
## License
This repository currently follows the license declared in `package.json`: `GPL-3.0-only`.
+212
View File
@@ -0,0 +1,212 @@
<p align="center">
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="Logo do PiAgentUI" width="760" />
</p>
# PiAgentUI
[English (US)](./README.md) | **Português (BR)** | [Español](./README.es.md) | [日本語](./README.ja.md)
PiAgentUI é uma interface moderna web e desktop para o **Pi Agent**. Ele não é apenas uma camada visual de chat: o objetivo é evoluir para um aplicativo completo de agentes, na mesma categoria de produto de Codex, Claude Desktop e outros ambientes agent-first para desenvolvimento.
O PiAgentUI trata o runtime real do Pi como fonte de verdade. Sessões, metadados de modelo, uso de contexto, tool calls, skills, configuração MCP e slash commands vêm do Pi sempre que possível, em vez de serem recriados como estado falso de interface.
> **Status do projeto:** desenvolvimento local ativo. Algumas áreas já estão próximas de produto, enquanto outras ainda mudam rapidamente.
## Demonstração do software
Estas capturas mostram a experiência atual do PiAgentUI: um workspace escuro e focado para agentes, com sessões reais do Pi, seleção de modelos, metadados MCP, settings, chat com ferramentas e painéis laterais.
![Workspace de chat do PiAgentUI com painel lateral MCP](./src/assets/images/img1.png)
![Diálogo de settings e conexões de servidor do PiAgentUI](./src/assets/images/img2.png)
![Seletor de modelos do PiAgentUI dentro de uma sessão ativa](./src/assets/images/img3.png)
## O que o PiAgentUI já faz hoje
- **Chat com streaming** — saída ao vivo via SSE, reconciliação estável de mensagens, markdown, code highlight, partes de reasoning e cards de ferramentas.
- **Sessões reais do Pi** — lista de sessões, roteamento para sessão ativa, histórico seguro após restart, títulos derivados e envio para a sessão selecionada.
- **Tool output em cards** — tool calls/results ficam anexados às mensagens do assistente, sem vazar como texto normal no chat.
- **Uso real de contexto** — o uso de contexto vem do runtime do Pi quando disponível; contexto desconhecido/compactado é exibido de forma segura em vez de `NaN`.
- **Metadados de modelos** — identidade de provider/modelo vem do Pi; o catálogo OpenRouter local é usado apenas como enriquecimento de detalhes.
- **Painel de Skills** — skills reais são lidas do prompt efetivo do Pi e agrupadas por origem: global, project, package e other.
- **Painel MCP** — lê servidores MCP configurados em arquivos locais/globais e mostra transporte, comando, URL, lifecycle, direct tools e origem.
- **Slash commands do Pi** — `/api/commands/list` expõe comandos built-in do Pi e comandos dinâmicos de extensões, prompts e skills.
- **Anexos multimodais** — suporte visual a arquivos/imagens/PDF/áudio/vídeo conforme as capacidades do modelo selecionado.
- **Terminal e arquivos** — terminal integrado, explorador de arquivos, syntax highlighting, componentes de diff e integração desktop via Tauri.
- **UI keyboard-first** — command palette, atalhos configuráveis, split panes, seletor de modelo, navegação por projeto/sessão e layouts responsivos.
## Direção do produto
O PiAgentUI está caminhando para virar um app completo de agentes:
1. **Ações visuais para todos os comandos do Pi** — slash commands continuam disponíveis, mas ações frequentes devem virar botões, menus, diálogos ou painéis.
2. **Entrada por voz e transcrição em tempo real** — gravação de microfone, deltas parciais, inserção da transcrição final no composer e configuração de provedores STT OpenAI-compatible.
3. **Integração MCP mais profunda** — além de metadados: conectar, autenticar, inspecionar ferramentas e executar workflows MCP pela UI.
4. **Controle completo de sessões** — fork, clone, navegação em árvore, import/export/share, branch summaries e gerenciamento de contexto como fluxos nativos.
5. **Experiência desktop completa** — app Tauri polido com descoberta local de runtime, settings seguros, notificações e controles ricos de workspace.
## Roadmap de áudio e transcrição
O PiAgentUI já aceita anexos de áudio quando o modelo selecionado declara suporte a áudio. O sistema planejado de voz adiciona speech-to-text ao vivo diretamente no composer.
Arquitetura recomendada:
- **Provedor realtime padrão:** OpenAI Realtime Transcription com `gpt-realtime-whisper` para transcrição parcial de baixa latência.
- **Fallback por arquivo:** OpenAI Audio Transcriptions com `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` ou `whisper-1`.
- **Provedores customizados:** APIs STT OpenAI-compatible configuráveis pelo usuário, como provedores que implementam `POST /v1/audio/transcriptions`.
UX planejada:
- botão de microfone no composer;
- estados de gravação e permissão;
- overlay de transcrição parcial;
- texto final inserido no input;
- comportamento configurável de append/replace;
- auto-send desligado por padrão;
- tratamento recuperável para erros de permissão, rede e provedor.
Exemplo de configuração de provider:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "pt"
}
```
Suporte realtime é tratado como capability separada, porque compatibilidade HTTP com a OpenAI não garante compatibilidade WebSocket.
## Arquitetura
```text
PiAgentUI
├─ Frontend React/Vite
│ ├─ chat, renderização de mensagens, input, painéis e settings
│ ├─ stores locais para preferências de UI
│ └─ clientes SSE/API para endpoints locais do PiAgentUI
├─ Backend como extensão Pi
│ └─ extensions/piagentui-server.ts
│ ├─ expõe endpoints locais /api/*
│ ├─ faz ponte com runtime/sessões/modelos do Pi
│ ├─ transmite eventos do Pi para o browser
│ └─ lê metadados locais como MCP config e skills
└─ Runtime Pi Agent
├─ sessões
├─ modelos/providers
├─ ferramentas
├─ skills
├─ MCP
└─ slash commands
```
A UI não deve inventar estado de runtime quando o Pi já sabe a resposta. O PiAgentUI pode cachear e enriquecer dados para UX, mas o Pi continua autoritativo para o comportamento do agente.
## Stack técnica
| Área | Stack |
| ------------------- | -------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Estilo | Tailwind CSS v4 e design tokens do projeto |
| Desktop | Tauri 2 |
| Markdown | Streamdown / pipeline de markdown |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Testes | Vitest, Testing Library |
| Backend local | Extensão Pi, Node HTTP server, WebSocket/SSE |
## Desenvolvimento local
Instalar dependências:
```bash
npm install
```
Iniciar o frontend em modo dev:
```bash
npm run dev
```
Build:
```bash
npm run build
```
Testes:
```bash
npm run test:run
```
Type checks:
```bash
npm run typecheck
npm run typecheck:extensions
```
Validação completa:
```bash
npm run validate
```
## Runtime da extensão Pi
O PiAgentUI é registrado como extensão Pi em `package.json`:
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
Quando a extensão inicia, ela escreve metadados de descoberta local em:
```text
~/.pi/agent/piagentui-port.json
```
O app web usa a porta/token desse arquivo para falar com o servidor local da extensão. Algumas mudanças de backend exigem reiniciar ou recarregar o processo da extensão Pi antes de aparecerem no browser.
## Endpoints locais importantes
| Endpoint | Finalidade |
| -------------------------------- | ----------------------------------------------------- |
| `GET /api/models` | Lista real de modelos Pi com capacidades normalizadas |
| `GET /api/sessions` | Lista de sessões lida dos arquivos de sessão do Pi |
| `GET /api/sessions/:id/messages` | Histórico normalizado para renderização na UI |
| `GET /api/sessions/:id/context` | Uso de contexto runtime para a sessão ativa |
| `POST /api/messages/send` | Envia mensagem para a sessão Pi solicitada |
| `GET /api/skills` | Skills efetivas do Pi agrupadas por origem |
| `GET /api/mcp/status` | Metadados dos servidores MCP configurados |
| `GET /api/commands/list` | Slash commands built-in e dinâmicos do Pi |
| `GET /global/event` | Stream SSE de eventos do backend PiAgentUI |
## Princípios de desenvolvimento
- Preferir dados reais do runtime Pi em vez de estado fake só da UI.
- Manter OpenRouter como enriquecimento de catálogo, nunca como substituto da identidade provider/modelo do Pi.
- Tratar valores runtime desconhecidos explicitamente.
- Escrever testes antes de mudanças de comportamento sempre que possível.
- Manter tool results dentro dos cards de ferramenta.
- Seguir o sistema visual existente: tema, movimento, espaçamento e padrões de painéis.
- Separar claramente o que está implementado do que é roadmap.
## Notas do repositório
O projeto ainda contém nomes herdados da base original OpenCodeUI em alguns pontos, incluindo metadados do pacote e documentação antiga. A direção atual é PiAgentUI-first, e uma limpeza futura deve migrar esses nomes sem quebrar os workflows locais.
## Licença
Este repositório segue a licença declarada em `package.json`: `GPL-3.0-only`.
+11
View File
@@ -0,0 +1,11 @@
# PiAgentUI English README
The English (US) README is now the main project README and includes software screenshots from `src/assets/images/`:
- [README.md](./README.md)
Localized versions:
- [Português (BR)](./README.pt-BR.md)
- [Español](./README.es.md)
- [日本語](./README.ja.md)
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect width="512" height="512" fill="#1e1d1c"/>
</svg>

After

Width:  |  Height:  |  Size: 132 B

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<path
d="M178 194 L276 256 L178 318"
stroke="#d97706"
stroke-width="34"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<line
x1="294"
y1="314"
x2="348"
y2="314"
stroke="#d97706"
stroke-width="34"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<rect x="24" y="24" width="464" height="464" rx="112" fill="#1e1d1c"/>
<path
d="M170 188 L286 256 L170 324"
stroke="#d97706"
stroke-width="38"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<line
x1="304"
y1="318"
x2="356"
y2="318"
stroke="#d97706"
stroke-width="38"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 452 B

+31
View File
@@ -0,0 +1,31 @@
# ============================================
# 本地构建 override(gateway/frontend/backend)
# ============================================
# 用法: docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
services:
gateway:
image: opencodeui-gateway:local
build:
context: .
dockerfile: docker/Dockerfile.gateway
frontend:
image: opencodeui-frontend:local
build:
context: .
dockerfile: docker/Dockerfile.frontend
backend:
image: opencodeui-backend:local
build:
context: .
dockerfile: docker/Dockerfile.backend
args:
USE_CHINA_MIRROR: ${USE_CHINA_MIRROR:-1}
APT_MIRROR: ${APT_MIRROR:-http://mirrors.tuna.tsinghua.edu.cn/debian}
APT_SECURITY_MIRROR: ${APT_SECURITY_MIRROR:-http://mirrors.tuna.tsinghua.edu.cn/debian-security}
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
NODEJS_ORG_MIRROR: ${NODEJS_ORG_MIRROR:-https://npmmirror.com/mirrors/node}
MISE_INSTALL_URL: ${MISE_INSTALL_URL:-https://mise.run}
OPENCODE_INSTALL_URL: ${OPENCODE_INSTALL_URL:-https://opencode.ai/install}
+15
View File
@@ -0,0 +1,15 @@
# ============================================
# 宿主机能力 override(可选)
# ============================================
# 用法:
# docker compose -f docker-compose.yml -f docker-compose.host.yml up -d
#
# 开启后 backend 可以访问宿主机 Docker daemon 和宿主机文件系统。
# 只在确实需要容器内构建/管理 Docker、访问宿主机路径时使用。
services:
backend:
privileged: true
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /:/host
+25
View File
@@ -0,0 +1,25 @@
# ============================================
# OpenCode WebUI - 纯前端独立部署
# ============================================
# 适用于已有 opencode serve 的场景,只需前端 UI
#
# 使用方法:
# docker compose -f docker-compose.standalone.yml up -d
#
# 环境变量:
# BACKEND_URL - opencode serve 地址(默认 host.docker.internal:4096)
# PORT - 前端监听端口(默认 3000)
services:
frontend:
image: ghcr.io/lehhair/opencodeui-frontend:latest
container_name: opencodeui
restart: unless-stopped
ports:
- '${PORT:-3000}:3000'
command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile.standalone']
environment:
- BACKEND_URL=${BACKEND_URL:-host.docker.internal:4096}
# Linux 需要 host.docker.internal 支持
extra_hosts:
- 'host.docker.internal:host-gateway'
+100
View File
@@ -0,0 +1,100 @@
# ============================================
# OpenCode WebUI - Docker Compose
# ============================================
#
# 架构:
# gateway (Caddy) :6658 -> 统一入口(反代 + 端口路由)
# :6659 -> 独立预览端口(前端开发服务专用)
# frontend (Caddy) :3000 -> 静态文件 (GitHub Actions 预构建镜像)
# backend (opencode):4096 -> opencode serve (GitHub Actions 预构建镜像)
#
# 使用方法:
# cp .env.example .env # 填写 API Key
# docker compose up -d # 启动
#
# 本地构建全部服务 (可选):
# docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
services:
# ---- 统一入口:网关 ----
gateway:
image: ghcr.io/lehhair/opencodeui-gateway:latest
container_name: opencode-gateway
restart: unless-stopped
ports:
- '127.0.0.1:${GATEWAY_PORT:-6658}:6658'
- '127.0.0.1:${PREVIEW_PORT:-6659}:6659'
volumes:
- opencode-router-data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- frontend
- backend
environment:
- TARGET_CONTAINER=opencode-backend
- ROUTER_STATE_FILE=/data/routes.json
- ROUTER_SCAN_INTERVAL=${ROUTER_SCAN_INTERVAL:-5}
- ROUTER_TOKEN_LENGTH=${ROUTER_TOKEN_LENGTH:-12}
- ROUTER_PORT_RANGE=${ROUTER_PORT_RANGE:-3000-9999}
- ROUTER_EXCLUDE_PORTS=${ROUTER_EXCLUDE_PORTS:-4096}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-}
- PREVIEW_DOMAIN=${PREVIEW_DOMAIN:-}
- ROUTER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
- ROUTER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
networks:
- opencode-net
# ---- 前端:预构建镜像 ----
frontend:
image: ghcr.io/lehhair/opencodeui-frontend:latest
container_name: opencode-frontend
restart: unless-stopped
networks:
- opencode-net
# ---- 后端:预构建镜像 ----
backend:
image: ghcr.io/lehhair/opencodeui-backend:latest
container_name: opencode-backend
restart: unless-stopped
deploy:
resources:
limits:
memory: 4G
volumes:
- ${WORKSPACE:-./workspace}:/workspace
- opencode-home:/root
working_dir: /workspace
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
- XAI_API_KEY=${XAI_API_KEY:-}
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
- DOCKER_API_VERSION=${DOCKER_API_VERSION:-1.40}
- NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
- NODEJS_ORG_MIRROR=${NODEJS_ORG_MIRROR:-https://npmmirror.com/mirrors/node}
- PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}
- PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST:-pypi.tuna.tsinghua.edu.cn}
- LANG=C.UTF-8
- LC_ALL=C.UTF-8
- TERM=xterm-256color
- COLORTERM=truecolor
- MISE_DATA_DIR=/root/.local/share/mise
- MISE_CONFIG_DIR=/root/.config/mise
- MISE_STATE_DIR=/root/.local/state/mise
- PATH=/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:/root/.local/share/mise/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
networks:
- opencode-net
volumes:
opencode-home:
opencode-router-data:
networks:
opencode-net:
name: opencode-net
+19
View File
@@ -0,0 +1,19 @@
# OpenCode WebUI - Caddy 配置
# 纯静态文件服务
:3000 {
@api path /api /api/*
handle @api {
respond "API is not served by the frontend-only image" 404
}
handle {
root * /srv
file_server
# SPA 路由 - 所有非文件请求返回 index.html
try_files {path} /index.html
}
encode gzip
}
+36
View File
@@ -0,0 +1,36 @@
{
auto_https off
}
# ---- 主入口 :6658 — OpenCode 应用 ----
:6658 {
# API(SSE + WebSocket 自动处理)
handle_path /api/* {
reverse_proxy opencode-backend:4096 {
flush_interval -1
}
}
# Routes UI + Preview API(供面板切换预览端口)
@router path /routes /preview/*
handle @router {
reverse_proxy 127.0.0.1:7070
}
# 前端(兜底)
handle {
reverse_proxy opencode-frontend:3000
}
}
# ---- 开发服务入口 :6659 — 绑独立域名 ----
# 路径穿透 /p/{token}/ + 独占预览(兜底)
:6659 {
# 动态端口路由(由 router 自动生成)
import /etc/caddy/routes.conf
# 独占预览(兜底:未匹配路径穿透的请求走 preview 端口)
handle {
import /etc/caddy/preview.conf
}
}
+21
View File
@@ -0,0 +1,21 @@
# OpenCode WebUI - 纯前端独立部署
# 静态文件 + API 反代到外部 opencode serve
:3000 {
# API 反代(strip /api 前缀,SSE 需要 flush_interval -1)
handle_path /api/* {
reverse_proxy {$BACKEND_URL:host.docker.internal:4096} {
header_up Host {upstream_hostport}
flush_interval -1
}
}
# 前端静态文件
handle {
root * /srv
file_server
try_files {path} /index.html
}
encode gzip
}
+114
View File
@@ -0,0 +1,114 @@
# ============================================
# OpenCode WebUI - Backend
# ============================================
# Debian slim + opencode serve + agent tooling
FROM docker:29-cli AS docker-cli
FROM debian:bookworm-slim
ARG USE_CHINA_MIRROR=0
ARG APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian
ARG APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security
ARG NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
ARG NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
ARG MISE_INSTALL_URL=https://mise.run
ARG OPENCODE_INSTALL_URL=https://opencode.ai/install
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
TERM=xterm-256color \
COLORTERM=truecolor \
EDITOR=vim \
NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY} \
NODEJS_ORG_MIRROR=${NODEJS_ORG_MIRROR}
RUN if [ "${USE_CHINA_MIRROR}" = "1" ] && [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i \
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
-e "s|http://security.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
/etc/apt/sources.list.d/debian.sources; \
fi
# Agent 常用基础环境。语言运行时交给 mise,安装到持久化的 /root。
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
dnsutils \
fd-find \
file \
fonts-dejavu-core \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
g++ \
git \
git-lfs \
gnupg \
jq \
less \
make \
nano \
openssh-client \
patch \
procps \
python3 \
python3-venv \
ripgrep \
rsync \
tini \
tzdata \
unzip \
vim-tiny \
wget \
xz-utils \
zip \
&& rm -rf /var/lib/apt/lists/*
# Docker CLI + Compose/Buildx plugin,只复制客户端,不安装 dockerd/containerd/runc。
RUN mkdir -p /usr/local/libexec/docker/cli-plugins
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
# Debian 的 fd 二进制叫 fdfind,补一个常见命令名。
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
# 安装 mise。GitHub Actions 等 CI 环境会在构建时下载,运行时安装的工具仍落在持久化 /root。
RUN curl -fsSL "${MISE_INSTALL_URL}" | sh \
&& MISE_BIN="$(find /root -name mise -type f 2>/dev/null | head -1)" \
&& if [ -z "${MISE_BIN}" ]; then echo "mise binary not found" >&2; exit 1; fi \
&& cp "${MISE_BIN}" /usr/local/bin/mise \
&& chmod +x /usr/local/bin/mise \
&& mise --version
# 安装 opencode。
RUN curl -fsSL "${OPENCODE_INSTALL_URL}" | bash \
&& OPENCODE_BIN="$(find /root -name opencode -type f 2>/dev/null | head -1)" \
&& if [ -z "${OPENCODE_BIN}" ]; then echo "opencode binary not found" >&2; exit 1; fi \
&& cp "${OPENCODE_BIN}" /usr/local/bin/opencode \
&& chmod +x /usr/local/bin/opencode \
&& opencode --version
# 工作目录
RUN mkdir -p /workspace
WORKDIR /workspace
ENV MISE_DATA_DIR=/root/.local/share/mise \
MISE_CONFIG_DIR=/root/.config/mise \
MISE_STATE_DIR=/root/.local/state/mise \
OPENCODE_DISABLE_AUTOUPDATE=true \
OPENCODE_DISABLE_TERMINAL_TITLE=true \
WORKSPACE=/workspace
ENV PATH="/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:/root/.local/share/mise/bin:${PATH}"
COPY ./docker/backend-entrypoint.sh /usr/local/bin/backend-entrypoint.sh
RUN chmod +x /usr/local/bin/backend-entrypoint.sh
EXPOSE 4096
ENTRYPOINT ["tini", "--", "/usr/local/bin/backend-entrypoint.sh"]
CMD ["opencode", "serve", "--port", "4096", "--hostname", "0.0.0.0"]
+48
View File
@@ -0,0 +1,48 @@
# ============================================
# OpenCode WebUI - Frontend
# ============================================
# 纯前端静态文件 + Caddy 反向代理 API
# ---- Stage 1: 构建前端 ----
# 前端产物是静态文件,和目标 CPU 架构无关。
# 多架构镜像构建时让 Node builder 始终跑在构建机原生平台,避免 arm64 通过 QEMU 重跑 npm/vite 构建。
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
ARG NPM_REGISTRY=https://registry.npmjs.org/
ARG NPM_REGISTRY_FALLBACK=https://registry.npmmirror.com/
WORKDIR /app
COPY package.json package-lock.json ./
RUN set -eux; \
npm config set registry "${NPM_REGISTRY}"; \
npm config set fetch-retries 5; \
npm config set fetch-retry-factor 2; \
npm config set fetch-retry-mintimeout 10000; \
npm config set fetch-retry-maxtimeout 120000; \
npm config set audit false; \
npm config set fund false; \
if npm ci --ignore-scripts; then \
exit 0; \
fi; \
echo "npm ci failed on ${NPM_REGISTRY}, retrying with ${NPM_REGISTRY_FALLBACK}"; \
npm cache clean --force; \
npm config set registry "${NPM_REGISTRY_FALLBACK}"; \
npm ci --ignore-scripts
COPY . .
RUN node scripts/copy-material-icons.mjs
ENV VITE_API_BASE_URL=/api
RUN npm run build
# ---- Stage 2: Caddy 运行时 ----
FROM caddy:2-alpine
# 前端静态文件
COPY --from=builder /app/dist /srv
# Caddy 配置(默认 + standalone)
COPY docker/Caddyfile /etc/caddy/Caddyfile
COPY docker/Caddyfile.standalone /etc/caddy/Caddyfile.standalone
EXPOSE 3000
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]
+27
View File
@@ -0,0 +1,27 @@
FROM rust:1.94-alpine AS router-builder
RUN apk add --no-cache musl-dev
WORKDIR /build
COPY src-router/Cargo.toml src-router/Cargo.lock ./
COPY src-router/src ./src
RUN cargo build --release --locked
FROM caddy:2-alpine
RUN apk add --no-cache docker-cli
WORKDIR /app
COPY --from=router-builder /build/target/release/opencodeui-router /usr/local/bin/opencodeui-router
COPY docker/entrypoint-gateway.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
COPY docker/Caddyfile.gateway /etc/caddy/Caddyfile
RUN touch /etc/caddy/routes.conf && touch /etc/caddy/preview.conf
EXPOSE 6658 6659
CMD ["/app/entrypoint.sh"]
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -eu
ensure_mise() {
if command -v mise >/dev/null 2>&1; then
return
fi
if [ -x /root/.local/bin/mise ]; then
rm -f /usr/local/bin/mise
cp /root/.local/bin/mise /usr/local/bin/mise
chmod +x /usr/local/bin/mise
return
fi
curl -fsSL "${MISE_INSTALL_URL:-https://mise.run}" | sh
MISE_BIN="$(find /root -name mise -type f 2>/dev/null | head -1)"
if [ -z "${MISE_BIN}" ]; then
echo "mise install failed: binary not found" >&2
exit 1
fi
rm -f /usr/local/bin/mise
cp "${MISE_BIN}" /usr/local/bin/mise
chmod +x /usr/local/bin/mise
}
ensure_opencode() {
if opencode --version >/dev/null 2>&1; then
return
fi
OPENCODE_BIN="$(find /root -name opencode -type f 2>/dev/null | head -1)"
if [ -n "${OPENCODE_BIN}" ]; then
rm -f /usr/local/bin/opencode
cp "${OPENCODE_BIN}" /usr/local/bin/opencode
chmod +x /usr/local/bin/opencode
if opencode --version >/dev/null 2>&1; then
return
fi
fi
curl -fsSL "${OPENCODE_INSTALL_URL:-https://opencode.ai/install}" | bash
OPENCODE_BIN="$(find /root -name opencode -type f 2>/dev/null | head -1)"
if [ -z "${OPENCODE_BIN}" ]; then
echo "opencode install failed: binary not found" >&2
exit 1
fi
rm -f /usr/local/bin/opencode
cp "${OPENCODE_BIN}" /usr/local/bin/opencode
chmod +x /usr/local/bin/opencode
}
ensure_package_mirrors() {
mkdir -p /root/.config/pip
if [ ! -f /root/.npmrc ]; then
cat > /root/.npmrc <<EOF
registry=${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
fund=false
audit=false
EOF
fi
if [ ! -f /root/.config/pip/pip.conf ]; then
cat > /root/.config/pip/pip.conf <<EOF
[global]
index-url = ${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}
trusted-host = ${PIP_TRUSTED_HOST:-pypi.tuna.tsinghua.edu.cn}
timeout = 120
EOF
fi
}
ensure_mise
ensure_opencode
ensure_package_mirrors
if [ "$#" -eq 0 ]; then
set -- opencode serve --port 4096 --hostname 0.0.0.0
fi
exec "$@"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Start both processes and exit the container if either one dies.
cleanup() {
kill "$ROUTER_PID" "$CADDY_PID" 2>/dev/null
wait "$ROUTER_PID" "$CADDY_PID" 2>/dev/null
exit 1
}
trap cleanup TERM INT
opencodeui-router &
ROUTER_PID=$!
caddy run --config /etc/caddy/Caddyfile &
CADDY_PID=$!
# Wait for either process to exit, then tear down the other.
# `wait -n` is not available in busybox sh, so poll both.
while kill -0 "$ROUTER_PID" 2>/dev/null && kill -0 "$CADDY_PID" 2>/dev/null; do
wait -n "$ROUTER_PID" "$CADDY_PID" 2>/dev/null || sleep 1
done
echo "One of the processes exited, shutting down..."
cleanup
+46
View File
@@ -0,0 +1,46 @@
# ============================================
# 物理机 Nginx 参考配置
# ============================================
# 放到 /etc/nginx/sites-enabled/ 下
# 将域名请求代理到 Docker 容器
server {
listen 80;
server_name opencode.example.com;
# 前端
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API 反向代理
location /api/ {
proxy_pass http://127.0.0.1:4096/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# WebSocket(PTY 终端)
location /api/pty/ {
proxy_pass http://127.0.0.1:4096/pty/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
}
@@ -0,0 +1,845 @@
# Pi Agent Extension Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrate PiAgentUi from OpenCode to Pi Agent while preserving the current UI and interaction model.
**Architecture:** Build a Pi package/extension as the primary integration. Pi loads `extensions/piagentui-server.ts`, which starts a loopback local server, serves the existing React build, and exposes an owned compatibility API shaped to the current frontend. Tauri/Rust desktop service cleanup is a second track after the Pi package MVP works.
**Tech Stack:** React 19, Vite 8, TypeScript 5.9, Vitest, Tauri 2/Rust, Pi extension API (`@earendil-works/pi-coding-agent`), Node HTTP, SSE/WebSocket event streaming.
---
## Decisions Required Before Implementation
Default recommendations are listed; please confirm or override them before code changes.
1. **MVP session model:** recommended = active Pi session mirror first. The UI controls the currently running Pi session; full session CRUD/fork/switch is deferred.
2. **Desktop/Tauri scope:** recommended = defer process spawning. Tauri can connect to an already-running Pi extension server later, but Track A must not depend on Tauri.
3. **Unsupported features:** recommended = disable/stub PTY, MCP, LSP, provider OAuth, worktrees, and multi-session operations until Pi equivalents are verified.
4. **Token/bootstrap:** recommended = same-origin injected boot data in HTML served by the extension; bearer token stays in memory, never query strings/localStorage.
5. **Endpoint convention:** recommended = keep OpenCode-shaped endpoint paths/envelopes where the current frontend already expects them, then gradually move wrappers to owned names internally.
6. **Product name:** needs user decision. Options: `PiAgentUi`, `Pi Agent UI`, or another exact name.
Implementation should not begin until these are confirmed.
---
## Non-Negotiable Scope
- No UI redesign.
- No `/hud` command.
- No `Ctrl+Shift+H` HUD toggle.
- Remove OpenCode runtime usage completely by final migration.
- Remove `@opencode-ai/sdk` by final migration.
- Reuse user Pi settings/auth/model setup; do not collect provider credentials in PiAgentUi.
- Use `better-tau/extensions/mirror-server.ts` as the main reference for a Pi extension serving HTTP/WebSocket from inside Pi.
- Current known `better-tau` unrelated changes must not be touched: `.gitignore`, `extensions/helper-bridge.ts`, `extensions/helper/`.
---
## Core Corrections from Critical Review
- **Do not conflate Pi package and Tauri app.** Track A is Pi package/extension; Track B is desktop cleanup.
- **Do not flatten message DTOs.** Current UI expects `MessageWithParts { info, parts }`, and many callsites read `message.info.*`.
- **Do not emit raw event envelopes unless `events.ts` is rewritten first.** Current frontend accepts `{ directory, payload: { type, properties } }` from `/global/event`.
- **Use actual Pi lifecycle events.** The shutdown event is `session_shutdown`, not `session_end`.
- **Typecheck extension files.** Existing `tsc -b` does not include `extensions/**/*.ts`.
- **Resolve token bootstrap early.** The UI cannot call protected APIs unless it receives base URL/token safely.
- **Treat `pi.sendUserMessage(...)` as asynchronous fire-and-stream.** It should return `202 accepted`; real message IDs arrive through events.
---
## File/Module Responsibility Map
### New files
- `extensions/piagentui-server.ts`
- Pi extension entrypoint.
- Starts loopback HTTP/SSE/WS server.
- Serves built `dist/` assets.
- Injects safe runtime boot data into `index.html`.
- Bridges Pi session lifecycle and messages to current frontend-compatible events.
- `extensions/piagentui-server-core.ts`
- Pure helpers for testable server behavior: loopback validation, auth validation, body-size guard, path traversal prevention, event envelope helpers.
- `extensions/piagentui-server-core.test.ts`
- Unit tests for server security helpers.
- `tsconfig.extension.json`
- TypeScript project for extension files and extension tests/helpers.
- `src/api/piClient.ts`
- Typed fetch client replacing SDK usage.
- Sends local bearer auth in headers only.
- Normalizes typed errors.
- `src/api/piClient.test.ts`
- Request/auth/error behavior tests.
- `src/types/api/compat.ts`
- Owned compatibility DTOs shaped for current UI.
- `src/api/piContract.ts`
- Owned request/response contracts and current event envelope definitions.
- `src/api/piEventTranslator.test.ts`
- Contract tests for frontend event processing.
- `docs/superpowers/specs/2026-05-28-pi-agent-extension-migration-design.md`
- Design spec, endpoint matrix, unsupported features, security, acceptance criteria.
### Modified files
- `package.json`
- Add `pi.extensions`.
- Add package metadata needed for Pi package usage.
- Add extension typecheck script.
- Add/remove dependencies in the correct sequence.
- Remove `@opencode-ai/sdk` when no imports remain.
- `tsconfig.json`
- Include `tsconfig.extension.json` in references.
- `vite.config.ts`
- Remove OpenCode dev proxy when API layer no longer uses OpenCode server.
- Add Pi extension dev proxy only if chosen.
- `src/constants/api.ts`, `src/api/http.ts`, `src/store/serverStore.ts`
- Convert from OpenCode server/basic-auth concepts to PiAgentUi local server boot/connection state.
- No provider credential storage.
- `src/api/sdk.ts`
- Delete after all imports are removed, or temporarily replace with a non-SDK compatibility facade.
- `src/api/*.ts`
- Replace SDK calls with owned client calls or unsupported-feature stubs.
- Files likely in scope: `agent.ts`, `client.ts`, `command.ts`, `config.ts`, `file.ts`, `global.ts`, `lsp.ts`, `mcp.ts`, `message.ts`, `permission.ts`, `pty.ts`, `session.ts`, `skill.ts`, `todo.ts`, `tool.ts`, `vcs.ts`, `worktree.ts`.
- `src/types/api/*.ts`
- Replace SDK-derived type aliases with owned DTOs or explicit unsupported types.
- Files likely in scope: `agent.ts`, `common.ts`, `config.ts`, `event.ts`, `file.ts`, `mcp.ts`, `message.ts`, `model.ts`, `permission.ts`, `project.ts`, `pty.ts`, `session.ts`, `skill.ts`, `tool.ts`, `vcs.ts`, `worktree.ts`.
- `src/main.tsx`, `src/features/settings/components/ServiceSettings.tsx`, `src/hooks/useCloseServiceDialog.ts`, `src/store/serviceStore.ts`
- Track A: ensure OpenCode auto-start does not run while Pi extension mode is active.
- Track B: rename desktop service integration.
- `src-tauri/src/app/commands/opencode.rs`, `src-tauri/src/app/commands/mod.rs`, `src-tauri/src/app/mod.rs`
- Track B only: rename commands and remove OpenCode process management.
- `src-tauri/src/app/bridge/args.rs`, `src-tauri/src/app/commands/bridge.rs`
- Track B only: enforce loopback URL allowlist in Rust bridge.
- `src-tauri/capabilities/default.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`
- Track B only: remove OpenCode branding and tighten security.
---
## Compatibility Contract
### DTO shape must match current UI
Current UI expects message shape similar to:
```ts
export interface MessageWithParts {
info: Message
parts: Part[]
}
```
Therefore owned DTOs must preserve this shape first, then adapt internally:
```ts
export interface CompatMessageInfo {
id: string
sessionID: string
role: 'user' | 'assistant'
time?: { created?: number; completed?: number }
summary?: string
modelID?: string
providerID?: string
}
export interface CompatMessageWithParts {
info: CompatMessageInfo
parts: CompatPart[]
}
export type CompatPart =
| CompatTextPart
| CompatReasoningPart
| CompatToolPart
| CompatFilePart
| CompatStepStartPart
| CompatStepFinishPart
| CompatUnsupportedPart
export interface CompatTextPart {
id: string
sessionID: string
messageID: string
type: 'text'
text: string
}
export interface CompatReasoningPart {
id: string
sessionID: string
messageID: string
type: 'reasoning'
text: string
}
export interface CompatToolPart {
id: string
sessionID: string
messageID: string
type: 'tool'
tool: string
state: { status: 'pending' | 'running' | 'completed' | 'error'; input?: unknown; output?: unknown; error?: string }
}
export interface CompatFilePart {
id: string
sessionID: string
messageID: string
type: 'file'
mime?: string
filename?: string
url?: string
}
export interface CompatStepStartPart {
id: string
sessionID: string
messageID: string
type: 'step-start'
}
export interface CompatStepFinishPart {
id: string
sessionID: string
messageID: string
type: 'step-finish'
}
export interface CompatUnsupportedPart {
id: string
sessionID: string
messageID: string
type: 'unsupported'
label: string
data?: unknown
}
```
Exact fields must be finalized by a field inventory before implementation.
### Event envelope must match current frontend first
Current `src/api/events.ts` expects:
```ts
export interface CompatGlobalEvent {
directory?: string
payload: {
type: string
properties: unknown
}
}
```
For MVP, the extension should emit this envelope on the current path or the frontend parser must be rewritten before server events are implemented.
Recommended MVP: keep `/global/event` and OpenCode-style event names already handled by the UI:
- `server.connected`
- `session.updated`
- `session.status`
- `session.idle`
- `session.error`
- `message.updated`
- `message.part.updated`
- `message.part.delta`
- `message.part.removed`
- `permission.asked` only if mapped; otherwise unsupported notification
- `question.asked` only if mapped; otherwise unsupported notification
For text deltas, current store expects payload shape compatible with its `PartDeltaPayload`, including `sessionID`, `messageID`, `partID`, `field`, and `delta`.
### Send message contract
Current frontend send params include:
```ts
interface SendMessageParams {
sessionId: string
text: string
attachments: Attachment[]
model: { providerID: string; modelID: string }
agent?: string
variant?: string
directory?: string
}
```
MVP behavior:
- `text`: supported.
- `directory`: supported only as metadata/active project hint if Pi context allows it.
- `model`: support only if Pi allows model switch before send; otherwise ignore with warning event or reject with unsupported error.
- `attachments`: defer unless image/file input mapping is verified.
- `agent`, `variant`: defer unless Pi equivalent is verified.
- response: `202 accepted` plus optional synthetic client request ID; real assistant message ID is learned from events.
---
## Endpoint Coverage Matrix
### MVP supported
| Capability | Existing frontend area | Compatibility path | Backend source |
| ----------------------- | --------------------------------- | ---------------------------------------------------- | ------------------------------------- |
| Health | `serverStore`, `client.ts` | `/global/health` or current health path | extension runtime state |
| Event stream | `events.ts` | `/global/event` | Pi extension lifecycle/events |
| Active models | `client.ts`, model hooks | wrapper-owned endpoint, final path decided in Task 1 | Pi model registry/settings |
| Active session snapshot | `session.ts`, stores | wrapper-owned endpoint, OpenCode-shaped response | active Pi context/session mirror |
| Send message | `message.ts`, `useChatSession.ts` | wrapper-owned endpoint | `pi.sendUserMessage(...)` |
| Abort turn | message/session controls | wrapper-owned endpoint | current Pi context abort if available |
| Static assets | browser/webview | `/`, `/assets/*` | extension static server |
### Explicitly unsupported/deferred for MVP
- PTY terminal sessions.
- MCP management.
- LSP diagnostics endpoints.
- Provider OAuth/login.
- Full multi-session create/fork/switch/delete.
- Worktrees.
- VCS operations unless trivially provided by current UI independent of OpenCode.
- Permission/question flows unless Pi extension UI/RPC gives equivalent semantics.
- Share/export unless Pi exposes safe API from extension context.
- Config, project, agent, skill, todo, tool, file APIs that currently depend on OpenCode SDK.
Every deferred feature must return a typed unsupported error and must not call an OpenCode endpoint.
---
## Security Contract
Required for Track A:
- Bind only to `127.0.0.1` and optionally `::1`.
- Use OS-selected random high port by default.
- Generate per-run bearer token.
- Bootstrap token via same-origin injected boot data in served HTML, unless user chooses another mechanism.
- Keep bearer token in memory/session only; do not store it in `localStorage` or settings backups.
- Reject API and stream requests without `Authorization: Bearer <token>`.
- For browser-native SSE/WS where custom headers are constrained, prefer same-origin cookie/header strategy from extension-served page; do not put token in query strings or userinfo URLs.
- Validate `Host` and `Origin`.
- No wildcard CORS.
- Limit request body and WS frame sizes.
- Reject static path traversal.
- Never send provider API keys/OAuth tokens to browser.
- Never log local bearer token, provider secrets, or prompts by default.
- Close HTTP server and sockets on `session_shutdown`.
Required for Track B if touched:
- Rust bridge must validate URLs server-side, not only via Tauri capabilities.
- Allow only `http://127.0.0.1:*`, `http://localhost:*`, `ws://127.0.0.1:*`, `ws://localhost:*` as appropriate.
- Reject userinfo URLs, LAN IPs, non-loopback hosts, and external HTTPS URLs.
- Narrow `src-tauri/capabilities/default.json` from broad `http://**` / `https://**` to loopback-only.
- Reintroduce CSP instead of `csp: null` if compatible with current app.
---
## Validation Contract
Run during implementation milestones:
```bash
cd PiAgentUi
npm run typecheck
npm run lint
npm run test:run
npm run build
```
After extension tsconfig is added:
```bash
cd PiAgentUi
npm run typecheck:extensions
```
If Track B is touched:
```bash
cd PiAgentUi/src-tauri
cargo test
```
OpenCode cleanup check must use an allowlist because docs may mention migration history:
```bash
cd PiAgentUi
rg -n "@opencode-ai/sdk|getSDKClient|unwrap\(|opencode serve|OpenCode|opencode" src src-tauri extensions package.json vite.config.ts
```
Expected final runtime result:
- no `@opencode-ai/sdk` imports;
- no `opencode serve` launcher;
- no OpenCode user-facing app title;
- only temporary deprecated aliases if Track B explicitly keeps them for one release.
Pi package smoke:
```bash
cd PiAgentUi
npm run build
npm pack --dry-run
```
Then install/load the package in Pi using the local package workflow chosen for this repo and verify:
1. Pi loads the extension.
2. Extension serves the UI.
3. UI receives boot data.
4. UI sends a message to current Pi session.
5. Assistant response streams into existing chat UI.
6. Refresh/reconnect recovers current snapshot.
7. Invalid HTTP/SSE/WS auth is rejected.
---
## Implementation Tasks
### Task 1: Confirm Decisions and Write Design Spec
**Files:**
- Create: `docs/superpowers/specs/2026-05-28-pi-agent-extension-migration-design.md`
- [ ] Confirm all decisions in “Decisions Required Before Implementation”.
- [ ] Write the design spec with: MVP, non-goals, endpoint matrix, DTO/event contract, security contract, validation contract, unsupported-feature behavior, Track A/Track B split.
- [ ] Include exact better-tau reference: `D:/Documents/Github/PiAgentUI/better-tau/extensions/mirror-server.ts`.
- [ ] Run:
```bash
cd PiAgentUi
npm run typecheck
```
Expected: unchanged project still typechecks before migration starts.
### Task 2: Inventory Current Frontend API Fields and SDK Coupling
**Files:**
- Create: `docs/superpowers/specs/2026-05-28-pi-agent-ui-field-inventory.md`
- [ ] Run:
```bash
cd PiAgentUi
rg -n "@opencode-ai/sdk|getSDKClient|unwrap\(|from './sdk'|from '../api/sdk'|Client\[|SDK[A-Za-z]" src
```
- [ ] Inventory all files importing SDK-derived types or `getSDKClient`.
- [ ] Inventory consumed fields for:
- `Session`
- `MessageWithParts`
- `Message.info`
- every `Part` variant rendered by components/stores
- `GlobalEvent`
- `PartDeltaPayload`
- `SendMessageParams`
- [ ] Mark each API module as: supported, stubbed, hidden, or deferred.
- [ ] Do not edit runtime code in this task.
### Task 3: Add Owned Compatibility Types Without Breaking UI Shape
**Files:**
- Create: `src/types/api/compat.ts`
- Create: `src/api/piContract.ts`
- Modify: `src/types/api/message.ts`, `src/types/api/event.ts`, `src/types/api/session.ts` only after inventory confirms exact fields.
- [ ] Add owned compatibility DTOs preserving current UI shape.
- [ ] Replace SDK aliases gradually with owned DTO aliases that match current field names.
- [ ] Do not flatten `MessageWithParts`.
- [ ] Keep `GlobalEvent` envelope compatible with current `events.ts` unless rewriting `events.ts` in the same task.
- [ ] Run:
```bash
cd PiAgentUi
npm run typecheck
npm run test:run -- src/store/messageStore.test.ts src/api/events.test.ts src/hooks/useChatSession.test.tsx
```
Expected: failures only from intentional type differences fixed before proceeding.
### Task 4: Add Extension Typecheck and Package Metadata
**Files:**
- Create: `tsconfig.extension.json`
- Modify: `tsconfig.json`
- Modify: `package.json`
- [ ] Add `tsconfig.extension.json` including `extensions/**/*.ts`.
- [ ] Add script:
```json
"typecheck:extensions": "tsc -p tsconfig.extension.json --noEmit"
```
- [ ] Include `typecheck:extensions` in `validate` after extension files exist.
- [ ] Add Pi package metadata:
```json
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
```
- [ ] Add package contents/prepack strategy so `dist/` and `extensions/` are included when packed/installed.
- [ ] Decide dependency strategy for `@earendil-works/pi-coding-agent`:
- runtime should be supplied by Pi package loader if supported;
- local typecheck still needs a devDependency or path-compatible type resolution.
- [ ] Run:
```bash
cd PiAgentUi
npm run typecheck
npm run typecheck:extensions
npm pack --dry-run
```
### Task 5: Implement Extension Server Core Helpers and Tests
**Files:**
- Create: `extensions/piagentui-server-core.ts`
- Create: `extensions/piagentui-server-core.test.ts`
- [ ] Write tests first for:
- loopback host accepted;
- LAN/external host rejected;
- userinfo URL rejected;
- malformed auth rejected;
- valid bearer auth accepted;
- Origin/Host mismatch rejected;
- static path traversal rejected;
- body-size guard rejects oversized body;
- boot data injection does not put token in URL.
Use test token construction that is obviously fake and not a credential:
```ts
const sampleToken = ['test', 'token', 'value'].join('-')
```
- [ ] Implement pure helpers to pass tests.
- [ ] Run:
```bash
cd PiAgentUi
npm run test:run -- extensions/piagentui-server-core.test.ts
npm run typecheck:extensions
```
### Task 6: Implement Pi Extension Server Skeleton
**Files:**
- Create: `extensions/piagentui-server.ts`
- Modify: extension tests/helpers as needed
- [ ] Implement extension entrypoint importing `ExtensionAPI` from `@earendil-works/pi-coding-agent`.
- [ ] Start server on `session_start` or the earliest correct lifecycle event.
- [ ] Cleanup on `session_shutdown`.
- [ ] Bind loopback on random high port.
- [ ] Serve `dist/index.html`, injecting boot data same-origin.
- [ ] Serve `/assets/*` safely.
- [ ] Implement authenticated health endpoint compatible with frontend/serverStore path decision.
- [ ] Run:
```bash
cd PiAgentUi
npm run typecheck:extensions
npm run build
npm pack --dry-run
```
### Task 7: Replace Basic Auth Server Store with Pi Local Boot State
**Files:**
- Modify: `src/store/serverStore.ts`
- Modify: `src/store/serverStore.test.ts`
- Modify: `src/api/http.ts`
- Modify: `src/constants/api.ts`
- [ ] Remove provider/server password persistence from active Pi mode.
- [ ] Store only base URL and in-memory/session-scoped local token.
- [ ] Add migration tests proving old Basic auth/password values are not exported in backups after migration.
- [ ] Ensure auth headers use bearer token and never query strings.
- [ ] Run:
```bash
cd PiAgentUi
npm run test:run -- src/store/serverStore.test.ts src/api/http.test.ts
npm run typecheck
```
### Task 8: Add `piClient` and Replace SDK Wrapper Entry Points
**Files:**
- Create: `src/api/piClient.ts`
- Create: `src/api/piClient.test.ts`
- Modify: `src/api/client.ts`
- Modify: `src/api/session.ts`
- Modify: `src/api/message.ts`
- [ ] Write `piClient` tests for bearer headers, no token in URL, JSON errors, base URL normalization.
- [ ] Implement `get`, `post`, `delete`, and stream helper if needed.
- [ ] Replace model/session/message wrappers first because they are MVP-supported.
- [ ] Preserve existing exported function names consumed by hooks/components.
- [ ] For send message, return accepted state if Pi returns no message ID; rely on events for message updates.
- [ ] Run:
```bash
cd PiAgentUi
npm run test:run -- src/api/piClient.test.ts src/hooks/useSessions.test.tsx src/hooks/useChatSession.test.tsx src/hooks/useModelSelection.test.tsx
npm run typecheck
```
### Task 9: Implement Pi Event Translation Compatible with Existing `events.ts`
**Files:**
- Modify: `extensions/piagentui-server.ts`
- Create: `src/api/piEventTranslator.test.ts`
- Modify: `src/api/events.ts` only if necessary and covered by tests.
- [ ] Add tests for the current envelope `{ directory, payload: { type, properties } }`.
- [ ] Add tests for `message.part.delta` shape expected by `messageStore`.
- [ ] Map Pi events to existing frontend events:
- turn start -> `session.status` running;
- turn end -> `session.idle` or `session.status` idle;
- message update -> `message.updated`;
- part delta/update -> `message.part.delta` / `message.part.updated`;
- tool events -> tool part updates;
- errors -> `session.error`.
- [ ] Add initial snapshot event on connect.
- [ ] Add reconnect behavior using existing generation/snapshot logic.
- [ ] Run:
```bash
cd PiAgentUi
npm run test:run -- src/api/events.test.ts src/api/sse.test.ts src/api/piEventTranslator.test.ts src/store/messageStore.test.ts
npm run typecheck
```
### Task 10: Gate or Stub All Unsupported OpenCode-Only Features
**Files:**
- Modify: remaining `src/api/*.ts`
- Modify: affected hooks/components only when they crash on unsupported errors.
- [ ] Run:
```bash
cd PiAgentUi
rg -n "getSDKClient|unwrap\(|@opencode-ai/sdk|opencode|OpenCode" src
```
- [ ] For every remaining API module, implement one of:
- supported Pi-backed wrapper;
- typed unsupported error;
- hidden/disabled UI action with existing notification style.
- [ ] Cover at least:
- `agent.ts`
- `command.ts`
- `config.ts`
- `file.ts`
- `global.ts`
- `lsp.ts`
- `mcp.ts`
- `permission.ts`
- `pty.ts`
- `skill.ts`
- `todo.ts`
- `tool.ts`
- `vcs.ts`
- `worktree.ts`
- [ ] Ensure `pty.ts` never embeds credentials/tokens in URLs.
- [ ] Run:
```bash
cd PiAgentUi
npm run test:run
npm run typecheck
```
### Task 11: Prevent OpenCode Auto-Start in Pi Extension Mode
**Files:**
- Modify: `src/main.tsx`
- Modify: `src/store/serviceStore.ts`
- Modify: `src/features/settings/components/ServiceSettings.tsx`
- Modify: `src/hooks/useCloseServiceDialog.ts`
- [ ] Detect Pi extension mode from boot data.
- [ ] In Pi extension mode, do not auto-start `opencode serve`.
- [ ] Hide/disable OpenCode service settings without changing layout structure more than necessary.
- [ ] Do not rename desktop commands yet unless Track B is approved.
- [ ] Run:
```bash
cd PiAgentUi
npm run test:run -- src/store/serviceStore.test.ts src/utils/settingsBackup.test.ts
npm run typecheck
```
### Task 12: Remove `@opencode-ai/sdk` and SDK Facade
**Files:**
- Modify: `package.json`, lockfile if present
- Delete or rewrite: `src/api/sdk.ts`
- Modify: tests that mock SDK
- [ ] Confirm no imports remain:
```bash
cd PiAgentUi
rg -n "@opencode-ai/sdk|getSDKClient|unwrap\(" src
```
- [ ] Remove dependency using the repo's package manager:
```bash
cd PiAgentUi
npm uninstall @opencode-ai/sdk
```
If package manager/lockfile state is unclear, stop and ask.
- [ ] Delete `src/api/sdk.ts` if unused.
- [ ] Update tests to mock `piClient` or API wrappers instead of SDK.
- [ ] Run:
```bash
cd PiAgentUi
npm run validate
npm run typecheck:extensions
```
### Task 13: Track B — Tauri Desktop Cleanup and Security Hardening
Only start after Track A smoke passes and user approves desktop scope.
**Files:**
- Rename: `src-tauri/src/app/commands/opencode.rs` -> `src-tauri/src/app/commands/pi_agent.rs`
- Modify: `src-tauri/src/app/commands/mod.rs`
- Modify: `src-tauri/src/app/mod.rs`
- Modify: `src-tauri/src/app/bridge/args.rs`
- Modify: `src-tauri/src/app/commands/bridge.rs`
- Modify: `src/store/serviceStore.ts`
- Modify: `src-tauri/capabilities/default.json`
- Modify: `src-tauri/tauri.conf.json`
- Modify: `src-tauri/Cargo.toml`
- [ ] Decide desktop behavior: connect to existing Pi extension server, spawn `pi --mode rpc`, or defer.
- [ ] Rename service commands to Pi names if desktop remains.
- [ ] Keep old command aliases for one transition release only if required by existing frontend code.
- [ ] Migrate storage keys:
- `opencode-auto-start-service` -> `pi-agent-auto-start-service`
- `opencode-binary-path` -> `pi-agent-binary-path`
- `opencode-service-env-vars` -> `pi-agent-service-env-vars`
- [ ] Add Rust-side URL allowlist tests for bridge connect/send paths.
- [ ] Narrow Tauri HTTP permissions to loopback-only.
- [ ] Reintroduce CSP if compatible.
- [ ] Run:
```bash
cd PiAgentUi
npm run typecheck
npm run test:run
cd src-tauri
cargo test
```
### Task 14: Final Product Metadata and OpenCode Cleanup
**Files:**
- Modify: `package.json`
- Modify: `src-tauri/Cargo.toml`
- Modify: `src-tauri/tauri.conf.json`
- Modify: README/docs/user-facing strings
- [ ] Apply user-approved final product name.
- [ ] Update package name/description.
- [ ] Update Tauri product name/window title/identifier if Track B is in scope.
- [ ] Remove OpenCode user-facing strings.
- [ ] Run allowlisted grep:
```bash
cd PiAgentUi
rg -n "@opencode-ai/sdk|getSDKClient|unwrap\(|opencode serve|OpenCode|opencode" src src-tauri extensions package.json vite.config.ts README.md
```
Expected: no runtime/user-facing OpenCode references except approved temporary aliases.
- [ ] Run full validation:
```bash
cd PiAgentUi
npm run validate
npm run typecheck:extensions
npm run build
npm pack --dry-run
```
If Track B is in scope:
```bash
cd PiAgentUi/src-tauri
cargo test
```
---
## Review Checklist for Implementation Milestones
After Tasks 6, 9, 12, and 13, run a critical review pass before continuing:
- Correctness/regression review against this plan.
- Test coverage and validation review.
- Security review for local HTTP/SSE/WS, token handling, and Tauri bridge if touched.
- Simplicity review to catch unnecessary OpenCode compatibility that can be deleted.
The reviewer output should be synthesized before any fixes are applied. Apply fixes with one writer only.
---
## Current Recommended Milestone Split
1. **Milestone 1:** decisions + design spec + field inventory.
2. **Milestone 2:** owned DTOs + extension typecheck/package metadata.
3. **Milestone 3:** secure extension server skeleton + boot data.
4. **Milestone 4:** model/session/message wrappers + event streaming.
5. **Milestone 5:** unsupported feature gating + SDK removal.
6. **Milestone 6:** Pi package smoke and final OpenCode cleanup.
7. **Milestone 7:** optional Tauri desktop cleanup/hardening.
@@ -0,0 +1,531 @@
# Plano de Implementação — Troca Correta de Sessões no PiAgentUI
> **Para agentes executores:** SUB-SKILL OBRIGATÓRIA: use `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans` para implementar este plano tarefa por tarefa. Os passos usam checkbox (`- [ ]`) para acompanhamento.
**Objetivo:** garantir que uma mensagem enviada no front para a sessão B seja enviada para a sessão real B do Pi, nunca para a sessão que estava ativa quando o Pi abriu.
**Arquitetura:** o backend da extensão PiAgentUI será autoritativo para roteamento de mensagens. Antes de chamar `sendUserMessage`, ele compara `parsed.sessionId` com a sessão real ativa; se divergir, resolve o arquivo `.jsonl`, troca a sessão real do Pi com `switchSession`, e envia dentro do `withSession(replacedCtx)`.
**Tech Stack:** TypeScript, extensão Pi (`ExtensionContext` / `ReplacedSessionContext`), HTTP local Node, Vitest, React frontend sem mudança estrutural.
---
## Estrutura de arquivos
- Modificar: `../pi/packages/coding-agent/src/core/extensions/types.ts`
- Adicionar `switchSession()` em `ExtensionContext`.
- Modificar: `../pi/packages/coding-agent/src/core/extensions/runner.ts`
- Expor `switchSession` também em `createContext()` usando o handler já existente.
- Modificar: `../pi/packages/coding-agent/dist/core/extensions/types.d.ts`
- Atualizar declaração usada pelo typecheck da extensão, caso o build do Pi não regenere dist durante a sessão.
- Modificar: `PiAgentUi/extensions/piagentui-server.ts`
- Resolver arquivo da sessão por ID.
- Enviar mensagem para sessão solicitada com `switchSession(..., { withSession })`.
- Preservar servidor HTTP/SSE em `session_shutdown` causado por `resume` web-iniciado.
- Modificar: `PiAgentUi/extensions/piagentui-server.test.ts`
- Cobrir envio para sessão ativa, envio para sessão diferente, sessão inexistente e switch cancelado.
- Modificar: `PiAgentUi/src/api/message.ts`
- Garantir que erro HTTP de envio não seja engolido; `sendMessageAsync()` já envia `sessionId`.
- Modificar opcionalmente: `PiAgentUi/src/hooks/useChatSession.ts`
- Se necessário, manter erro visível via `handleError('send message', error)` sem mudar UX.
---
## Tarefa 1: Expor switchSession no contexto normal da extensão Pi
**Arquivos:**
- Modificar: `../pi/packages/coding-agent/src/core/extensions/types.ts`
- Modificar: `../pi/packages/coding-agent/src/core/extensions/runner.ts`
- Modificar: `../pi/packages/coding-agent/dist/core/extensions/types.d.ts`
- [ ] **Passo 1: Atualizar o contrato TypeScript do `ExtensionContext`**
Em `../pi/packages/coding-agent/src/core/extensions/types.ts`, dentro de `export interface ExtensionContext`, adicionar logo após `compact(options?: CompactOptions): void;`:
```ts
/**
* Switch to a different session file from long-lived extension services.
* Use `withSession` for work that must run after the replacement context is active.
*/
switchSession(
sessionPath: string,
options?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
): Promise<{ cancelled: boolean }>;
```
- [ ] **Passo 2: Expor o método no contexto criado pelo runner**
Em `../pi/packages/coding-agent/src/core/extensions/runner.ts`, dentro de `createContext(): ExtensionContext`, adicionar antes de `getSystemPrompt: () => {`:
```ts
switchSession: (sessionPath, options) => {
runner.assertActive();
return runner.switchSessionHandler(sessionPath, options);
},
```
O trecho final deve ficar assim:
```ts
compact: (options) => {
runner.assertActive();
runner.compactFn(options);
},
switchSession: (sessionPath, options) => {
runner.assertActive();
return runner.switchSessionHandler(sessionPath, options);
},
getSystemPrompt: () => {
runner.assertActive();
return runner.getSystemPromptFn();
},
```
- [ ] **Passo 3: Atualizar a declaração dist se o build do Pi não for executado**
Em `../pi/packages/coding-agent/dist/core/extensions/types.d.ts`, adicionar assinatura equivalente dentro de `ExtensionContext`:
```ts
/**
* Switch to a different session file from long-lived extension services.
* Use `withSession` for work that must run after the replacement context is active.
*/
switchSession(sessionPath: string, options?: {
withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
}): Promise<{
cancelled: boolean;
}>;
```
- [ ] **Passo 4: Verificar tipos do Pi core**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/pi/packages/coding-agent
pnpm test -- --runInBand test/suite/regressions/2860-replaced-session-context.test.ts
```
Se esse pacote não usa `pnpm test` com esse formato, executar o comando de teste já documentado no pacote. Resultado esperado: testes existentes passam sem erro de tipo. Se o comando correto não existir, registrar a saída e seguir para o typecheck do PiAgentUI na Tarefa 4.
---
## Tarefa 2: Roteamento de mensagem por sessão no servidor PiAgentUI
**Arquivos:**
- Modificar: `PiAgentUi/extensions/piagentui-server.ts`
- [ ] **Passo 1: Adicionar tipo local para contexto com switch**
Abaixo de `const MIME_TYPES`, adicionar:
```ts
type SwitchableExtensionContext = ExtensionContext & {
switchSession?: (
sessionPath: string,
options?: { withSession?: (ctx: any) => Promise<void> },
) => Promise<{ cancelled: boolean }>
}
```
- [ ] **Passo 2: Trocar o tipo de `latestCtx`**
Substituir:
```ts
let latestCtx: ExtensionContext | null = null
```
por:
```ts
let latestCtx: SwitchableExtensionContext | null = null
```
- [ ] **Passo 3: Criar helper de arquivo de sessão**
Adicionar depois de `getCurrentSessionId()`:
```ts
function getSessionFileForId(sessionId: string): string | null {
const sessionDir = latestCtx?.sessionManager?.getSessionDir()
if (!sessionDir || !fs.existsSync(sessionDir)) return null
const files = fs.readdirSync(sessionDir).filter(f => f.endsWith('.jsonl'))
const matchedFile = files.find(f => f.endsWith(`_${sessionId}.jsonl`))
return matchedFile ? path.join(sessionDir, matchedFile) : null
}
```
- [ ] **Passo 4: Extrair seleção de modelo para helper reutilizável**
Adicionar depois de `getSessionFileForId()`:
```ts
async function setRequestedModel(ctx: SwitchableExtensionContext, model: any) {
if (!model?.providerID || !model?.modelID) return
try {
const targetModel = ctx.modelRegistry?.find(model.providerID, model.modelID)
if (!targetModel) return
const currentModel = ctx.model
if (!currentModel || currentModel.id !== targetModel.id || currentModel.provider !== targetModel.provider) {
console.log(`[PiAgentUi] Switching session model to ${targetModel.provider}:${targetModel.id}`)
await pi.setModel(targetModel)
}
} catch (err) {
console.error('[PiAgentUi] Failed to set model:', err)
}
}
```
- [ ] **Passo 5: Adicionar flag para preservar servidor durante switch web-iniciado**
Perto dos estados do servidor, adicionar:
```ts
let preserveServerForSessionSwitch = false
```
- [ ] **Passo 6: Criar helper de envio correto por sessão**
Adicionar depois de `setRequestedModel()`:
```ts
async function sendUserMessageToRequestedSession(parsed: any): Promise<{ status: number; body: any }> {
const requestedSessionId = parsed.sessionId
const activeSessionId = getCurrentSessionId()
if (!parsed.text) {
return { status: 400, body: { error: 'Missing message text' } }
}
if (!requestedSessionId || requestedSessionId === activeSessionId) {
if (latestCtx) {
await setRequestedModel(latestCtx, parsed.model)
}
const p = pi.sendUserMessage(parsed.text) as any
if (p && typeof p.catch === 'function') {
p.catch((err: any) => {
console.error('[PiAgentUi] Failed to send user message:', err)
})
}
return { status: 202, body: { sessionID: activeSessionId } }
}
const sessionFile = getSessionFileForId(requestedSessionId)
if (!sessionFile) {
return { status: 404, body: { error: `Session not found: ${requestedSessionId}` } }
}
if (!latestCtx?.switchSession) {
return { status: 501, body: { error: 'Pi session switching is not available in this runtime' } }
}
preserveServerForSessionSwitch = true
try {
const result = await latestCtx.switchSession(sessionFile, {
withSession: async replacedCtx => {
latestCtx = replacedCtx as SwitchableExtensionContext
await setRequestedModel(latestCtx, parsed.model)
await replacedCtx.sendUserMessage(parsed.text)
},
})
if (result.cancelled) {
return { status: 409, body: { error: 'Session switch cancelled' } }
}
return { status: 202, body: { sessionID: requestedSessionId } }
} finally {
preserveServerForSessionSwitch = false
}
}
```
- [ ] **Passo 7: Substituir lógica atual de `/api/messages/send`**
Dentro do handler `if (pathParts[1] === 'messages' && pathParts[2] === 'send' && method === 'POST')`, substituir o bloco que faz `setModel`, calcula `activeSessionId`, alerta divergência e chama `pi.sendUserMessage(parsed.text)` por:
```ts
const result = await sendUserMessageToRequestedSession(parsed)
res.writeHead(result.status, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
messageID: 'msg-' + Math.random().toString(36).substring(2) + '-' + Date.now(),
...result.body,
}),
)
```
O `catch` do `JSON.parse` permanece igual.
- [ ] **Passo 8: Preservar servidor em `session_shutdown` de switch interno**
Substituir:
```ts
pi.on('session_shutdown', async () => {
stopServer()
})
```
por:
```ts
pi.on('session_shutdown', async event => {
if (preserveServerForSessionSwitch && event?.reason === 'resume') {
return
}
stopServer()
})
```
- [ ] **Passo 9: Verificar typecheck da extensão**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx tsc --noEmit -p tsconfig.extension.json
```
Resultado esperado: nenhum erro.
---
## Tarefa 3: Testes do servidor de extensão
**Arquivos:**
- Modificar: `PiAgentUi/extensions/piagentui-server.test.ts`
- [ ] **Passo 1: Adicionar sessão alternativa no teste existente**
Depois da criação de `sessionFile`, adicionar:
```ts
const otherSessionId = 'test-session-uuid-other'
const otherSessionFile = path.join(tempSessionDir, `session_${otherSessionId}.jsonl`)
fs.writeFileSync(otherSessionFile, JSON.stringify({
type: 'session',
id: otherSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd'
}) + '\n')
```
- [ ] **Passo 2: Adicionar mocks de switch no contexto**
Antes de `const mockContext = {`, adicionar:
```ts
const replacedSendUserMessage = vi.fn()
const switchSession = vi.fn(async (_sessionPath: string, options?: { withSession?: (ctx: any) => Promise<void> }) => {
await options?.withSession?.({
...mockContext,
sessionManager: {
getSessionDir: () => tempSessionDir,
getSessionId: () => otherSessionId,
},
sendUserMessage: replacedSendUserMessage,
})
return { cancelled: false }
})
```
Dentro de `mockContext`, adicionar:
```ts
switchSession,
```
- [ ] **Passo 3: Testar envio para sessão ativa sem switch**
Manter o teste existente de `/api/messages/send` sem `sessionId` e ajustar expectativa para aceitar qualquer segundo argumento opcional:
```ts
expect(mockPi.sendUserMessage).toHaveBeenCalledWith('hello world')
expect(switchSession).not.toHaveBeenCalled()
```
- [ ] **Passo 4: Testar envio para sessão diferente com switch**
Depois do teste de envio ativo, adicionar:
```ts
const sendOtherRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId: otherSessionId,
text: 'hello other session',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' }
})
})
expect(sendOtherRes.status).toBe(202)
expect(switchSession).toHaveBeenCalledWith(otherSessionFile, expect.objectContaining({
withSession: expect.any(Function),
}))
expect(replacedSendUserMessage).toHaveBeenCalledWith('hello other session')
```
- [ ] **Passo 5: Testar sessão inexistente**
Adicionar:
```ts
const missingSessionRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId: 'missing-session-id',
text: 'must not be sent',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' }
})
})
expect(missingSessionRes.status).toBe(404)
expect(replacedSendUserMessage).not.toHaveBeenCalledWith('must not be sent')
```
- [ ] **Passo 6: Rodar teste da extensão**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx vitest run extensions/piagentui-server.test.ts
```
Resultado esperado: teste passa. Se o teste monolítico ficar instável por porta/servidor, dividir em `describe` separado com porta limpa e `session_shutdown` no final.
---
## Tarefa 4: Garantir que erros de envio sejam visíveis no frontend
**Arquivos:**
- Modificar: `PiAgentUi/src/api/message.ts`
- Testar: `PiAgentUi/src/api/piClient.test.ts` ou criar caso em `PiAgentUi/src/api/message.test.ts`
- [ ] **Passo 1: Confirmar comportamento de `piClient.post`**
Ler `PiAgentUi/src/api/piClient.ts`. Se `piClient.post` já lança erro em status `>=400`, não alterar código. Se não lança, ajustar para lançar com status e body.
O comportamento esperado para `sendMessageAsync()` é:
```ts
export async function sendMessageAsync(params: SendMessageParams): Promise<void> {
await piClient.post('/api/messages/send', {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
}
```
- [ ] **Passo 2: Adicionar teste de erro HTTP se necessário**
Se `piClient` não tiver cobertura de erro, adicionar teste que simula `fetch` retornando `404` para `/api/messages/send` e espera rejeição.
Código esperado do teste:
```ts
it('rejects sendMessageAsync when the server rejects the target session', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'Session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})))
await expect(sendMessageAsync({
sessionId: 'missing-session',
text: 'hello',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
directory: '/mock/cwd',
})).rejects.toThrow()
})
```
- [ ] **Passo 3: Rodar testes de API relevantes**
Executar:
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx vitest run src/api/piClient.test.ts src/api/events.test.ts src/store/messageStore.test.ts
```
Resultado esperado: todos passam.
---
## Tarefa 5: Verificação integrada
**Arquivos:**
- Nenhum arquivo novo obrigatório.
- [ ] **Passo 1: Rodar typecheck completo do app**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx tsc --noEmit
npx tsc --noEmit -p tsconfig.extension.json
```
Resultado esperado: ambos sem erro.
- [ ] **Passo 2: Rodar testes focados**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npx vitest run extensions/piagentui-server.test.ts src/api/events.test.ts src/store/messageStore.test.ts
```
Resultado esperado: todos passam.
- [ ] **Passo 3: Rodar build**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
npm run build
```
Resultado esperado: build passa. Avisos de chunk grande do Vite são aceitáveis se não forem novos erros.
- [ ] **Passo 4: Verificação manual no navegador**
1. Reiniciar o Pi Agent para carregar a extensão atualizada.
2. Abrir PiAgentUI em `http://127.0.0.1:<porta>`.
3. Abrir sessão A e anotar o arquivo `.jsonl` dela.
4. Selecionar sessão B no sidebar.
5. Enviar mensagem `teste roteamento sessao B`.
6. Confirmar visualmente que user + assistant aparecem em B sem F5.
7. Confirmar que o arquivo `.jsonl` da sessão A não recebeu essa mensagem.
8. Confirmar que o arquivo `.jsonl` da sessão B recebeu essa mensagem.
9. Confirmar que `/global/event` continua com uma conexão viva e sem reconectar em loop.
- [ ] **Passo 5: Commit final**
```bash
cd D:/Documents/Github/PiAgentUI/PiAgentUi
git status --short
git add extensions/piagentui-server.ts extensions/piagentui-server.test.ts src/api/message.ts src/api/message.test.ts docs/superpowers/plans/2026-05-30-piagentui-active-session-switch.md
git commit -m "fix: route messages to selected Pi session"
```
Se a implementação modificar arquivos em `../pi`, fazer um commit separado dentro do repositório/árvore correspondente apenas se ele também for um repositório Git local. Como `D:/Documents/Github/PiAgentUI` não é repo e `PiAgentUi` é o repo local atual, registrar explicitamente os arquivos de Pi alterados no resumo final.
---
## Auto-revisão do plano
- Cobertura da spec: o plano cobre exposição de `switchSession`, roteamento backend, preservação do servidor, testes de erro e verificação manual.
- Placeholders: nenhum `TBD`, `TODO`, `implementar depois` ou instrução genérica sem ação concreta.
- Consistência de tipos: `SwitchableExtensionContext`, `switchSession`, `withSession`, `replacedCtx.sendUserMessage` e `sessionID` são usados consistentemente.
- Escopo: limitado ao sistema de sessão/roteamento; não redesenha UI nem lista de sessões.
@@ -0,0 +1,51 @@
# PiAgentUI SQL Settings and Voice Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move backend-owned PiAgentUI settings to a local SQLite database and fix/extend voice settings for local OpenAI-compatible STT/TTS providers such as Speaches.
**Architecture:** The backend extension becomes the source of truth for backend-owned settings using `~/.pi/agent/piagentui.db`, with a SQL table for namespaced JSON settings and migration from the legacy `piagentui-settings.json`. Frontend voice settings continue using HTTP APIs, now with STT API-key-required control and TTS model/voice configuration.
**Tech Stack:** Node `node:sqlite` `DatabaseSync`, TypeScript, Vitest, React settings components.
---
## Files
- Create: `extensions/piagentui-settings-store.ts` — SQLite settings repository, schema, defaults, legacy JSON migration.
- Create: `extensions/piagentui-settings-store.test.ts` — unit tests for SQL persistence and migration.
- Modify: `extensions/piagentui-server.ts` — replace JSON helpers with SQLite store; add TTS settings/speech endpoints; allow keyless OpenAI-compatible STT when configured.
- Modify: `extensions/piagentui-server.test.ts` — integration coverage for keyless STT and TTS settings.
- Modify: `src/types/api/stt.ts`, `src/api/stt.ts`, `src/hooks/useVoiceTranscription.ts` — expose `apiKeyRequired` and avoid blocking local Speaches.
- Create/Modify: `src/types/api/tts.ts`, `src/api/tts.ts`, `src/features/settings/components/TtsSettings.tsx`, `src/features/settings/components/VoiceSettings.tsx` — TTS settings UI with voice selection.
- Modify tests under `src/hooks` and `src/features/settings/components`.
## Tasks
### Task 1: SQL settings store
- [ ] Write failing store tests for SQLite persistence and legacy JSON migration.
- [ ] Implement `extensions/piagentui-settings-store.ts` with `app_meta` and `settings` tables.
- [ ] Run targeted store tests and commit.
### Task 2: Backend voice behavior
- [ ] Write failing server/integration tests for keyless OpenAI-compatible STT and TTS settings sanitization.
- [ ] Update backend STT to require API key only when `apiKeyRequired` is true.
- [ ] Add TTS settings and `/api/tts/speech` proxy.
- [ ] Run extension tests and commit.
### Task 3: Frontend voice UI
- [ ] Write failing hook/settings tests for keyless STT start and TTS voice selection.
- [ ] Add STT `apiKeyRequired` toggle.
- [ ] Add TTS settings card with model, voice, format, endpoint, API key behavior.
- [ ] Run targeted frontend tests and commit.
### Task 4: Validation
- [ ] Run `npx tsc --noEmit`.
- [ ] Run `npm run typecheck:extensions`.
- [ ] Run targeted voice/storage tests.
- [ ] Run `npm run build`.
- [ ] Run `git diff --check`.
@@ -0,0 +1,93 @@
# PiAgentUI STT LAN Real Sessions Project Folders Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement real Pi session creation, project-folder session history, LAN access with QR code, and file-based STT voice input for PiAgentUI.
**Architecture:** Keep runtime changes inside PiAgentUI's extension backend and focused frontend surfaces. Add pure helpers for network/session grouping/STT settings, test them first, then wire endpoints and UI with minimal component changes.
**Tech Stack:** TypeScript, React 19, Vite 8, Vitest, Testing Library, Node HTTP extension server, browser MediaRecorder, OpenAI-compatible transcription API.
---
## File Map
- `extensions/piagentui-server-core.ts`: host/origin validation helpers, LAN URL discovery, STT URL/settings sanitization helpers.
- `extensions/piagentui-server-core.test.ts`: unit tests for backend helpers.
- `extensions/piagentui-server.ts`: real session creation, LAN settings endpoints, STT settings/transcription endpoints, dynamic boot base URL.
- `extensions/piagentui-server.test.ts`: integration tests for endpoints.
- `src/types/api/network.ts`: network access response/request types.
- `src/api/network.ts`, `src/api/network.test.ts`: client API for LAN access.
- `src/types/api/stt.ts`: STT settings/transcription types.
- `src/api/stt.ts`, `src/api/stt.test.ts`: client API for STT.
- `src/hooks/useVoiceTranscription.ts`, `src/hooks/useVoiceTranscription.test.tsx`: browser recording/transcription state machine.
- `src/features/chat/input/VoiceInputButton.tsx`, `src/features/chat/input/VoiceInputButton.test.tsx`: mic UI.
- `src/features/chat/InputBox.tsx`: accept voice transcript insertion.
- `src/features/chat/input/InputToolbar.tsx`: render voice button.
- `src/features/sessions/sessionProjectGroups.ts`, `src/features/sessions/sessionProjectGroups.test.ts`: pure project grouping.
- `src/features/sessions/SessionList.tsx`, `src/features/sessions/SessionList.test.tsx`: project folder rendering.
- `src/features/settings/components/NetworkAccessSettings.tsx`, `src/features/settings/components/NetworkAccessSettings.test.tsx`: LAN toggle/URL/QR UI.
- `src/features/settings/components/ServersSettings.tsx`: include network section.
- `src/locales/en/*.json`, `src/locales/zh-CN/*.json`: minimal labels.
- `package.json`: add `qrcode` only if frontend QR generation needs it.
## Task 1: Backend helper tests and helpers
- [ ] Write tests in `extensions/piagentui-server-core.test.ts` for LAN host validation, local URL discovery injection, and STT URL/settings sanitization.
- [ ] Implement helpers in `extensions/piagentui-server-core.ts`.
- [ ] Run `npx vitest run extensions/piagentui-server-core.test.ts`.
- [ ] Commit `test/feat: add network and stt backend helpers`.
## Task 2: Real session creation endpoint
- [ ] Add failing tests in `extensions/piagentui-server.test.ts` proving `POST /api/sessions` creates a new `.jsonl`, calls `switchSession`, returns the new id, and returns `501` without `switchSession`.
- [ ] Implement minimal real session file creation in `extensions/piagentui-server.ts`.
- [ ] Run `npx vitest run extensions/piagentui-server.test.ts`.
- [ ] Commit `feat: create real pi sessions from ui`.
## Task 3: Project folder grouping
- [ ] Add failing tests in `src/features/sessions/sessionProjectGroups.test.ts` for Windows/POSIX grouping, basename labels, ordering, and search behavior.
- [ ] Implement `sessionProjectGroups.ts`.
- [ ] Add/adjust `SessionList.test.tsx` for folders expand/collapse and selection.
- [ ] Update `SessionList.tsx` minimally to render project folders by default when grouped.
- [ ] Run `npx vitest run src/features/sessions/sessionProjectGroups.test.ts src/features/sessions/SessionList.test.tsx`.
- [ ] Commit `feat: group sessions by project folder`.
## Task 4: LAN access endpoints and UI
- [ ] Add backend endpoint tests for `GET/POST /api/network/access`, LAN host allowed only when enabled, and host-based boot data.
- [ ] Implement network settings persistence and endpoint behavior.
- [ ] Add frontend API tests for `src/api/network.ts`.
- [ ] Add `NetworkAccessSettings` tests for toggle, URL, and QR placeholder/rendering.
- [ ] Implement API and settings component.
- [ ] Wire it into `ServersSettings.tsx`.
- [ ] Run targeted backend/frontend tests.
- [ ] Commit `feat: expose local network access settings`.
## Task 5: STT settings and transcription backend/client
- [ ] Add backend tests for redacted settings, save semantics, invalid URLs, and transcription provider errors.
- [ ] Implement settings persistence and transcription endpoint.
- [ ] Add `src/api/stt.test.ts` and implement `src/api/stt.ts`.
- [ ] Run targeted tests.
- [ ] Commit `feat: add stt settings and transcription api`.
## Task 6: Voice recording UI
- [ ] Add hook tests for permission denied, record/transcribe success, and error path.
- [ ] Implement `useVoiceTranscription.ts`.
- [ ] Add `VoiceInputButton` tests and component.
- [ ] Wire `InputToolbar` and `InputBox` to append transcript to composer without auto-send.
- [ ] Run targeted tests.
- [ ] Commit `feat: add voice transcription composer control`.
## Task 7: Final validation and review
- [ ] Run `npx tsc --noEmit`.
- [ ] Run `npx tsc --noEmit -p tsconfig.extension.json`.
- [ ] Run `npm run test:run`.
- [ ] Run `npm run build`.
- [ ] Run `git diff --check`.
- [ ] Review changed files for unnecessary edits.
- [ ] Commit any final fixes.
@@ -0,0 +1,81 @@
# Roadmap Contexto, Skills e MCP Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** concluir a integração de contexto real, skills reais, avisos de compaction/overflow e MCP nativo no PiAgentUI.
**Architecture:** O backend da extensão PiAgentUI deve ser a fonte de verdade para dados do runtime Pi. A UI deve consumir endpoints pequenos e tipados, preservando fallback seguro quando dados runtime não existirem.
**Tech Stack:** TypeScript, React, Vite, Vitest, extensão PiAgentUI HTTP/SSE, Pi runtime APIs.
---
## Task 1: Context metadata e uso real
**Status:** concluído em `f5a3fa5 feat: enrich context details with model metadata`.
## Task 2: Tool results somente em tool cards
**Status:** concluído em `41adf23 fix: keep tool results in tool cards`.
## Task 3: Skills reais por origem
**Status:** concluído em `257d63c feat: show real skills by source`.
## Task 4: Avisos de overflow e compaction
**Files:**
- Modify: `src/hooks/useSessionStats.ts`
- Modify: `src/features/chat/sidebar/SidebarFooter.tsx`
- Modify: `src/features/chat/sidebar/ContextDetailsDialog.tsx`
- Test: `src/hooks/useSessionStats.test.tsx`
- [ ] Adicionar estado derivado para `contextStatus`: `unknown`, `normal`, `warning`, `danger`.
- [ ] Mostrar fonte do dado: runtime, tokens ou estimativa.
- [ ] Mostrar aviso quando `contextKnown=false` após compaction.
- [ ] Mostrar aviso quando uso >= 70% e >= 90%.
- [ ] Rodar testes focados e build.
- [ ] Commit: `feat: surface context overflow notices`.
## Task 5: MCP backend real
**Files:**
- Modify/Create: `extensions/piagentui-server.ts`
- Modify/Create: `src/api/mcp.ts`
- Test: `extensions/piagentui-server.test.ts`
- [ ] Ler configuração MCP do Pi/agent config quando disponível.
- [ ] Expor `/api/mcp/servers` com servidores, status e origem.
- [ ] Preservar fallback vazio seguro.
- [ ] Testar config válida, ausente e inválida.
- [ ] Commit: `feat: expose real mcp servers`.
## Task 6: MCP UI real
**Files:**
- Modify: `src/components/McpPanel.tsx`
- Modify: `src/types/api/mcp.ts`
- Test: `src/components/McpPanel.test.tsx` se existir; caso contrário criar.
- [ ] Agrupar servidores por status/origem.
- [ ] Mostrar tools quando disponíveis.
- [ ] Mostrar erro de inicialização/config.
- [ ] Commit: `feat: render real mcp server status`.
## Task 7: Skills avançado
**Files:**
- Modify: `extensions/piagentui-server.ts`
- Modify: `src/components/SkillPanel.tsx`
- [ ] Expor diagnósticos/collisions quando Pi disponibilizar.
- [ ] Mostrar `disableModelInvocation` quando disponível.
- [ ] Melhorar origem com package/source quando disponível.
- [ ] Commit: `feat: add skill diagnostics metadata`.
## Task 8: Hardening e docs
- [ ] Extrair parsers grandes de `extensions/piagentui-server.ts` se necessário.
- [ ] Atualizar Obsidian/task list.
- [ ] Rodar full test/build.
- [ ] Fazer checkpoint commit/tag local se solicitado.
@@ -0,0 +1,58 @@
# PiAgentUI Git Composer Control Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a Git branch/control popover in the message composer using GitHub Octicons and safe backend Git endpoints.
**Architecture:** Add focused Git logic in `extensions/piagentui-git-manager.ts`, expose `/api/vcs/*` endpoints from the extension server, expand frontend VCS API/types, and add `GitComposerControl` inside `InputToolbar` before agent/variant selectors.
**Tech Stack:** React 19, TypeScript, Vite, Vitest, Node child_process, Git CLI, `@primer/octicons-react`.
---
## Files
- Create: `extensions/piagentui-git-manager.ts`
- Create: `extensions/piagentui-git-manager.test.ts`
- Modify: `extensions/piagentui-server.ts`
- Modify: `extensions/piagentui-server.test.ts`
- Modify: `src/types/api/vcs.ts`
- Modify: `src/types/api/index.ts`
- Modify: `src/api/vcs.ts`
- Create/modify: `src/api/vcs.test.ts`
- Create: `src/features/chat/input/GitComposerControl.tsx`
- Create: `src/features/chat/input/GitComposerControl.test.tsx`
- Modify: `src/features/chat/input/InputToolbar.tsx`
- Modify: `package.json`, `package-lock.json`
## Task 1 — Backend Git manager
- [ ] Write failing tests for branch parsing, status parsing, safe argv commands and commit validation.
- [ ] Implement `runGit`, `getGitStatus`, `listGitBranches`, `checkoutGitBranch`, `createGitBranch`, `stageGitPaths`, `unstageGitPaths`, `commitGitChanges`, `pullGitBranch`, `pushGitBranch`.
- [ ] Verify focused test passes.
## Task 2 — Backend endpoints
- [ ] Write failing server endpoint tests for `/api/vcs/status`, `/api/vcs/branches`, `/api/vcs/commit`.
- [ ] Add endpoint routing in `piagentui-server.ts` with request parsing and errors.
- [ ] Verify server tests pass.
## Task 3 — Frontend API contract
- [ ] Write failing API tests for query/body shapes.
- [ ] Expand `src/types/api/vcs.ts` and `src/api/vcs.ts`.
- [ ] Verify API tests pass.
## Task 4 — Composer UI
- [ ] Install `@primer/octicons-react`.
- [ ] Write failing component tests for branch chip, popover opening and commit action.
- [ ] Implement `GitComposerControl` with Octicons.
- [ ] Integrate into `InputToolbar` before agent selector.
- [ ] Verify component tests pass.
## Task 5 — Full verification
- [ ] Format touched files with `node node_modules/prettier/bin/prettier.cjs --write ...`.
- [ ] Run LSP diagnostics on touched files.
- [ ] Run `npm run typecheck`, `npm run typecheck:extensions`, focused tests, `npm run test:run`, `npm run build`, and `git diff --check`.
@@ -0,0 +1,66 @@
# Especificação Técnica: Migração do PiAgentUi para Extensão do Pi Agent
Esta especificação define o desenho arquitetônico, a segurança, as estruturas de dados e a integração desktop necessários para migrar o frontend `PiAgentUi` (originalmente construído para o OpenCode) para o ecossistema do **Pi Agent**.
---
## 1. Arquitetura e Fluxo de Dados
A arquitetura do `PiAgentUi` será descentralizada do OpenCode e integrada diretamente ao processo do Pi Agent através do modelo de extensões.
1. **Loopback Server (Porta Dinâmica):** A extensão `piagentui-server.ts` se conectará ao ciclo de vida do Pi e abrirá um servidor local seguro escutando em `127.0.0.1` em uma porta livre selecionada pelo sistema operacional.
2. **Same-Origin Boot Data:** A extensão servirá o HTML principal e injetará no próprio corpo do `index.html` um bloco JSON contendo as informações de boot (URL base, porta, e o token bearer gerado na inicialização).
3. **Ponte de Comunicação:** Toda chamada HTTP e WebSocket será autenticada usando o token em memória.
```
┌─────────────────┐ ┌────────────────────────┐ ┌────────────────┐
│ Tauri (Rust) │ ───> │ Pi Agent (Processo) │ <───> │ Webview (UI) │
│ Port Check & │ │ - piagentui-server │ │ - React 19 │
│ Launch State │ │ - HTTP + WS Server │ │ - Local Token │
└─────────────────┘ └────────────────────────┘ └────────────────┘
```
---
## 2. Matriz de Endpoints e Recursos
Para o MVP, a API do `PiAgentUi` responderá nos mesmos paths esperados pelo frontend, mapeando-os para chamadas da extensão e mockando recursos incompatíveis.
| Recurso / Funcionalidade | Endpoint Frontend | Ação no Backend (Pi Agent Extension) | Status no MVP |
| :--- | :--- | :--- | :--- |
| **Health Check** | `/global/health` | Retorna status da extensão do Pi | **Suportado** |
| **Event Stream** | `/global/event` | SSE/WebSocket que espelha os eventos do Pi | **Suportado** |
| **Modelos Ativos** | `/api/models` | Consulta o registro de modelos configurado no Pi | **Suportado** |
| **Multi-Sessão (CRUD)** | `/api/sessions` | Gerencia sessões locais da pasta de sessões do Pi | **Suportado** |
| **Enviar Mensagem** | `/api/messages/send` | Dispara `pi.sendUserMessage(...)` assincronamente | **Suportado** |
| **Parar Mensagem** | `/api/messages/abort` | Aborta o turno ativo do Pi Agent se disponível | **Suportado** |
| **PTY Terminal** | `/api/pty` | Retorna erro 501 / Desativado visualmente | **Stubbed** |
| **MCP / LSP** | `/api/mcp` / `/api/lsp`| Retorna lista vazia ou erro 501 | **Stubbed** |
| **Worktrees / VCS** | `/api/worktrees` | Retorna erro 501 / Desativado visualmente | **Stubbed** |
---
## 3. Segurança e Tokens
1. **Porta Dinâmica:** Impedir conflitos de porta iniciando sempre em portas dinâmicas alocadas pelo SO (`port: 0`).
2. **Bearer Token:** Gerar um UUID/token seguro de uso único na inicialização do servidor.
3. **Sem Persistência de Senhas:** Nenhuma credencial do usuário será armazenada fisicamente ou em `localStorage`. O token de autorização residirá unicamente na memória da sessão React.
4. **Validação de Host/Origin:** Rejeitar qualquer requisição com cabeçalho `Host` ou `Origin` que não seja estritamente local (`127.0.0.1` ou `localhost`).
5. **Prevenção de Traversal:** Servir arquivos estáticos validando canonicalização de caminhos para evitar vulnerabilidade de directory traversal no Windows.
---
## 4. Integração Tauri Desktop (Track B)
Com a decisão de **incluir o Tauri já no primeiro marco**, o app desktop sofrerá as seguintes modificações:
* **Renomeação de Comandos:** Todos os comandos Tauri registrados no Rust e no frontend serão renomeados de `*opencode*` para correspondentes de `*pi*` ou `*pi_agent*`.
* **Subprocesso do Pi:** Em vez de iniciar `opencode serve`, o Tauri iniciará o processo do Pi (ex: `pi --mode rpc`) adicionando dinamicamente o parâmetro `--extension` apontando para o nosso servidor local se necessário, ou confiando no auto-load das extensões instaladas.
* **Filtro de URL Restrito:** O Rust bridge do Tauri terá uma validação explícita em tempo de execução para rejeitar qualquer tráfego que tente se conectar a servidores externos, limitando a ponte estritamente a adaptadores de loopback permitidos.
---
## 5. Referências do Projeto
Esta migração baseia-se diretamente no comportamento implementado em:
`D:/Documents/Github/PiAgentUI/better-tau/extensions/mirror-server.ts`
utilizando o modelo de ciclo de vida e roteamento nativo lá desenvolvidos como garantia de estabilidade sob o runtime do Pi.
@@ -0,0 +1,105 @@
# Inventário de Campos e Dependências do SDK do OpenCode no PiAgentUi
Este documento registra todas as dependências da biblioteca `@opencode-ai/sdk` encontradas no `PiAgentUi`, detalhando as estruturas (DTOs) que o frontend consome e como elas serão mapeadas para a nova estrutura de compatibilidade com o Pi Agent.
---
## 1. Arquivos com Acoplamento ao SDK do OpenCode
Os seguintes arquivos realizam importações diretas do cliente SDK (`@opencode-ai/sdk`) ou utilizam tipos derivados dele:
### Arquivos em `src/api/`
* `agent.ts`
* `client.ts`
* `command.ts`
* `config.ts`
* `file.ts`
* `global.ts`
* `lsp.ts`
* `mcp.ts`
* `message.ts`
* `permission.ts`
* `pty.ts`
* `session.ts`
* `skill.ts`
* `todo.ts`
* `tool.ts`
* `vcs.ts`
* `worktree.ts`
* `sdk.ts`
### Arquivos em `src/types/api/`
* `agent.ts`
* `common.ts`
* `config.ts`
* `event.ts`
* `file.ts`
* `mcp.ts`
* `message.ts`
* `model.ts`
* `permission.ts`
* `project.ts`
* `pty.ts`
* `session.ts`
* `skill.ts`
* `tool.ts`
* `vcs.ts`
* `worktree.ts`
---
## 2. Inventário de Estruturas Consumidas pela UI
Para que a migração não quebre o frontend, a API da extensão Pi deve produzir e aceitar dados que correspondam a estas chaves essenciais:
### A. Estrutura de Mensagem (`MessageWithParts` / `Message`)
O frontend lê de `message.info` e de `message.parts`:
* **`info`** (`UserMessage` ou `AssistantMessage`):
* `id`: string (UUID da mensagem).
* `sessionID`: string (UUID da sessão).
* `role`: `'user' | 'assistant'`.
* `time`: `{ created?: number; completed?: number }`.
* `summary`: string (breve descrição se disponível).
* `modelID`: string (somente assistente).
* `providerID`: string (somente assistente).
* **`parts`** (Array de blocos que compõem a mensagem):
* **`TextPart`**: `{ type: 'text', text: string }`.
* **`ReasoningPart`**: `{ type: 'reasoning', text: string }`.
* **`ToolPart`**: `{ type: 'tool', tool: string, state: { status: 'pending' | 'running' | 'completed' | 'error', input?: any, output?: any, error?: string } }`.
* **`FilePart`**: `{ type: 'file', filename?: string, mime?: string, url?: string }`.
* **`StepStartPart`**: `{ type: 'step-start' }`.
* **`StepFinishPart`**: `{ type: 'step-finish' }`.
### B. Estrutura de Sessão (`Session`)
Utilizada no menu lateral de histórico e criação:
* `id`: string (UUID).
* `title`: string (nome da sessão).
* `directory`: string (diretório de trabalho correspondente).
* `time`: `{ created?: number; updated?: number }`.
* `summary`: `{ title?: string; category?: string; difficulty?: string; tokens?: number; cost?: number }`.
* `status`: `'idle' | 'running' | 'error'`.
### C. Envelopes de Eventos Globais (`GlobalEvent`)
Processados pelo pipeline de SSE em `events.ts`:
* `{ directory?: string, payload: { type: string, properties: any } }`
* Eventos chave:
* `server.connected`
* `session.updated`
* `session.status`
* `session.idle`
* `session.error`
* `message.updated`
* `message.part.updated`
* `message.part.delta`
---
## 3. Estratégia de Mapeamento no MVP
| Módulo Original | Ação / Novo Mapeamento |
| :--- | :--- |
| **`session`** | Consultas diretas ao Pi Session Manager via rotas REST locais da extensão. Mapeado para sessões nativas do Pi. |
| **`message`** | Mapeado para o envio assíncrono de prompts ao Pi (`pi.sendUserMessage`). Eventos nativos do Pi traduzidos em deltas de partes. |
| **`tool`** | Retorna ferramentas padrões disponíveis do Pi. |
| **`pty` / `lsp` / `mcp`** | Desativados visualmente / stubs de array vazio / erro 501. |
| **`worktree` / `vcs`** | Desativados visualmente. |
@@ -0,0 +1,140 @@
# PiAgentUI Active Session Switch Design
## Goal
When the user opens an existing session in PiAgentUI and sends a message, the message must be delivered to that selected Pi session, not to whichever session was active when the Pi TUI launched.
## Current finding
- The frontend route/store correctly loads selected sessions and their messages.
- `src/hooks/useChatSession.ts` sends the selected `sessionId` to `sendMessageAsync()`.
- `extensions/piagentui-server.ts` receives `sessionId`, but still calls `pi.sendUserMessage(parsed.text)`.
- `pi.sendUserMessage()` always targets the current active Pi runtime session.
- Pi exposes `switchSession()` only on `ExtensionCommandContext`, not on normal event `ExtensionContext` used by `session_start` and the HTTP server.
- `AgentSessionRuntime.switchSession()` intentionally emits `session_shutdown`, disposes the old session, creates the replacement session, rebinds extensions, then runs `withSession(replacedCtx)`.
- The existing PiAgentUI extension currently stops the web server on every `session_shutdown`, so a normal Pi session switch can close the HTTP/SSE server and force reconnects.
## Selected approach: activate and send on the backend
The backend should become authoritative for message routing:
1. Resolve requested `sessionId` to the JSONL session file in the current session directory.
2. If the requested session differs from the active Pi session, switch the real Pi runtime to that session.
3. Send the user message inside the replacement session callback so the message cannot land in the old session.
4. Keep the existing frontend model: route/session selection still works as today; `sendMessageAsync()` keeps passing `sessionId`.
This preserves the current UI while making the server guarantee correctness.
## Required changes
### Pi core
Expose a safe session-switch capability to extension event contexts, or a narrowly-scoped equivalent that can be used by long-lived extension services.
Preferred API shape:
```ts
interface ExtensionContext {
switchSession(
sessionPath: string,
options?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
): Promise<{ cancelled: boolean }>
}
```
Implementation should reuse the existing `switchSessionHandler` already used by `createCommandContext()`. The important behavior is not a new session implementation; it is making the already-existing runtime switch available to the PiAgentUI HTTP server context.
### PiAgentUI extension server
Add helpers in `extensions/piagentui-server.ts`:
- `getSessionFileForId(sessionId)` scans `latestCtx.sessionManager.getSessionDir()` for `*_${sessionId}.jsonl`.
- `sendUserMessageToSession(parsed)` validates `sessionId`, resolves the session file, switches if needed, and sends in the correct context.
- `session_shutdown` handling must not tear down the HTTP server on `reason === 'resume'` if the server is intentionally switching sessions for the web UI. The server should keep the port stable and update `latestCtx` on the following `session_start`.
Target send flow:
```ts
if (requestedSessionId && requestedSessionId !== activeSessionId) {
const sessionFile = getSessionFileForId(requestedSessionId)
const result = await latestCtx.switchSession(sessionFile, {
withSession: async replacedCtx => {
await maybeSetModel(replacedCtx, parsed.model)
await replacedCtx.sendUserMessage(parsed.text)
},
})
if (result.cancelled) return HTTP 409
return HTTP 202
}
await maybeSetModel(latestCtx, parsed.model)
pi.sendUserMessage(parsed.text)
```
If `sessionId` is missing or already active, keep the existing path.
### Frontend
Keep frontend mostly unchanged.
Small additions only if needed:
- `sendMessageAsync()` continues sending `sessionId`.
- Optional: handle HTTP `409`/`404` with a clear toast if target session cannot be activated.
- Optional: add `activeSessionId` from `server.connected`/`session.status` later, but not required for the core fix.
## Alternatives considered
### Activate on session selection
Frontend would call `/api/sessions/:id/activate` when a user clicks a session. This matches the user's mental model, but it introduces a race: if the user sends before activation completes, the message can still hit the old session unless send also validates activation. It is useful as a UX enhancement after backend routing is correct, not as the primary correctness mechanism.
### Frontend guard only
Frontend could block sending if selected session differs from active Pi session. This avoids Pi core changes but makes UX worse and still requires a trustworthy active-session signal from the backend. It detects the problem instead of solving it.
## Tests
### Extension/unit tests
Add or update extension server tests to cover:
1. Sending with active `sessionId` calls `pi.sendUserMessage()` without switching.
2. Sending with different `sessionId` resolves the matching JSONL file and calls `ctx.switchSession(sessionFile, { withSession })`.
3. The `withSession` callback calls `replacedCtx.sendUserMessage(text)`.
4. Unknown `sessionId` returns 404 and does not send to the active session.
5. Cancelled switch returns 409 and does not send.
6. `session_shutdown` with `reason: 'resume'` does not close the web server during web-initiated session switching.
### Frontend tests
Add or update API/hook tests to cover:
1. `sendMessageAsync()` includes `sessionId` in the payload.
2. Send error responses surface through the existing error handling path.
3. Existing route/session selection behavior remains unchanged.
### Manual verification
1. Start Pi in session A.
2. Open PiAgentUI.
3. Select existing session B in the sidebar.
4. Send a message.
5. Verify new user and assistant messages appear in session B.
6. Verify session A JSONL is unchanged.
7. Verify `/global/event` remains connected and streaming works without F5.
## Risks and mitigations
- Switching sessions invalidates old extension contexts. Mitigation: send inside `withSession(replacedCtx)` and avoid captured stale `pi`/ctx after switch.
- Current extension closes the HTTP server on `session_shutdown`. Mitigation: preserve server across `resume` switches and update `latestCtx` on `session_start`.
- Pi core API change can be too broad. Mitigation: expose only the existing switch handler; do not create parallel session logic.
- Session file lookup by suffix must be strict. Mitigation: only match `_${sessionId}.jsonl` inside the current session dir.
## Acceptance criteria
- Sending from selected session B never writes to active-at-launch session A.
- Frontend routing and message loading stay visually the same.
- The web server port remains stable during a session switch initiated by PiAgentUI.
- Streaming still updates live after switch.
- Typecheck, extension typecheck, relevant tests, and build pass.
@@ -0,0 +1,326 @@
# Design — README atual do PiAgentUI e sistema de áudio/STT
**Data:** 2026-05-30
**Status:** aprovado conceitualmente pelo usuário; aguardando revisão do arquivo antes da implementação
**Escopo:** reescrever o `README.md` do projeto atual e preparar a arquitetura completa para envio de áudio e transcrição em tempo real no app.
## Contexto
O projeto começou como uma adaptação do OpenCodeUI, mas atualmente está evoluindo para o **PiAgentUI**: uma interface web/desktop para o Pi Agent, com ambição explícita de se tornar um app inteiro no estilo Codex ou Claude Desktop.
O estado atual já inclui:
- frontend React 19 + TypeScript + Vite;
- app desktop via Tauri 2;
- extensão Pi local em `extensions/piagentui-server.ts` como backend/runtime bridge;
- chat com streaming/SSE;
- sessões reais do Pi, histórico e roteamento de sessão;
- tool calls e tool results renderizados em cards;
- contexto real do runtime via endpoint `/api/sessions/:sessionId/context`;
- enriquecimento de metadados de modelo via catálogo OpenRouter local em `src/assets/models.json`;
- lista real de Skills agrupadas por origem;
- metadados reais de MCP configurado;
- slash commands reais do Pi expostos por `/api/commands/list`;
- anexos de arquivos, incluindo áudio como arquivo quando o modelo selecionado declara suporte a áudio.
Ainda não existe:
- gravação de microfone no composer;
- transcrição parcial em tempo real;
- endpoint backend para STT;
- configurações STT;
- provedor STT customizável OpenAI-compatible;
- pipeline de áudio integrado ao envio de mensagens.
## Objetivos
1. Reescrever `README.md` em português para refletir o produto atual, não o projeto original OpenCodeUI.
2. Apresentar PiAgentUI como interface moderna para o Pi Agent e base futura de app completo tipo Codex/Claude.
3. Documentar capacidades existentes de forma honesta, separando **implementado**, **em andamento** e **roadmap**.
4. Projetar um sistema de áudio/STT completo, com opção padrão forte e configuração externa flexível.
5. Implementar o sistema em fases testáveis, com TDD para lógica, API, stores e componentes.
## Não objetivos
- Não renomear pacote npm, bundle, app Tauri ou metadados de release nesta primeira etapa, salvo menção no README como débito de branding.
- Não implementar agente de voz falante/TTS nesta fase; o foco é entrada por voz e transcrição para texto.
- Não substituir o fluxo normal de texto; áudio será uma camada opcional sobre o composer.
- Não acoplar PiAgentUI ao OpenRouter para STT. OpenRouter continua sendo catálogo/metadados quando útil; STT usa provedores próprios.
## README.md proposto
O novo README deve ser escrito em português e deve substituir integralmente o conteúdo herdado. Estrutura recomendada:
1. **Título e tagline**
- `# PiAgentUI`
- “Interface web/desktop para Pi Agent — caminhando para um app completo estilo Codex/Claude.”
2. **Visão geral**
- Explicar que o PiAgentUI é uma UI local para operar o Pi Agent com sessões, ferramentas, MCP, Skills, comandos e contexto real.
- Deixar claro que o runtime Pi é a fonte de verdade.
3. **Estado atual**
- Listar capacidades implementadas:
- chat streaming;
- sessões reais;
- tool cards;
- context usage;
- models metadata;
- skills;
- MCP metadata;
- slash commands;
- anexos multimodais;
- terminal/file explorer/Tauri quando aplicável.
4. **Roadmap do produto**
- Ações visuais para comandos Pi;
- áudio/STT em tempo real;
- experiência voice-first;
- MCP actions/connect/auth;
- app completo tipo Codex/Claude.
5. **Arquitetura**
- Frontend React/Vite/Tauri;
- extensão Pi como bridge local;
- Pi runtime como estado autoritativo;
- APIs locais `/api/*`;
- SSE para eventos.
6. **Desenvolvimento local**
- `npm install`;
- `npm run dev`;
- `npm run build`;
- `npm run test:run`;
- `npm run typecheck`;
- `npm run typecheck:extensions`.
7. **Notas de runtime**
- Algumas mudanças exigem reiniciar/recarregar o Pi/extension server.
- O arquivo de descoberta local fica em `~/.pi/agent/piagentui-port.json`.
8. **Áudio e transcrição — planejado**
- Resumir o plano STT sem prometer que já está implementado.
9. **Licença e origem**
- Manter licença atual se aplicável.
- Mencionar que o projeto deriva de uma base OpenCodeUI, mas agora segue direção própria para PiAgentUI.
## Sistema de áudio/STT — abordagem recomendada
### Opção padrão
Usar **OpenAI Realtime Transcription** com `gpt-realtime-whisper` como padrão para transcrição ao vivo.
Razões:
- foi projetado para live speech-to-text;
- entrega deltas parciais;
- tem latência baixa;
- usa sessão dedicada de transcrição, sem resposta falada do modelo;
- combina com um composer por voz onde o texto aparece enquanto o usuário fala.
### Fallback padrão
Usar **OpenAI Audio Transcriptions** para áudio fechado/arquivo:
- `gpt-4o-transcribe` como opção de maior qualidade;
- `gpt-4o-mini-transcribe` como opção de menor custo;
- `whisper-1` para compatibilidade com integrações antigas.
Esse fallback deve ser usado quando:
- o navegador/ambiente não suportar streaming confiável;
- o usuário enviar um arquivo de áudio;
- o provedor configurado só oferecer endpoint `/audio/transcriptions`;
- a conexão realtime falhar e houver áudio gravado em buffer.
### Provedor OpenAI-compatible externo
O usuário deve poder configurar qualquer API compatível com o endpoint de transcrição da OpenAI.
Configuração proposta:
```ts
interface SttProviderConfig {
id: string
label: string
kind: 'openai' | 'openai-compatible'
mode: 'realtime' | 'file' | 'both'
baseUrl: string
apiKey: string
realtimeModel?: string
transcriptionModel: string
transcriptionEndpoint?: string
language?: string
prompt?: string
responseFormat?: 'json' | 'text' | 'verbose_json'
}
```
Exemplo para Together AI:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.together.xyz/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3"
}
```
Observação: APIs “OpenAI-compatible” costumam ser compatíveis no endpoint HTTP de arquivo, mas realtime/WebSocket varia por provedor. Por isso, realtime deve ser capability separada, não assumida automaticamente.
## Arquitetura proposta do STT
### Frontend
Criar uma feature dedicada:
- `src/features/voice/VoiceRecorderButton.tsx`
- `src/features/voice/VoiceTranscriptOverlay.tsx`
- `src/features/voice/useVoiceRecorder.ts`
- `src/features/voice/useRealtimeTranscript.ts`
- `src/features/voice/audioEncoding.ts`
Responsabilidades:
- pedir permissão de microfone;
- mostrar estados: idle, requesting_permission, recording, connecting, transcribing, error;
- capturar áudio;
- enviar chunks para o backend;
- renderizar transcrição parcial e final;
- inserir texto final no composer;
- opcionalmente enviar automaticamente após confirmação do usuário.
### Store/configuração
Criar configuração local de STT:
- `src/store/sttSettingsStore.ts`
- persistência em localStorage inicialmente;
- campos para provider padrão, base URL, modelo, idioma, prompt, modo e preferências de UX.
Preferências de UX:
```ts
interface VoiceInputPreferences {
insertMode: 'append' | 'replace-selection'
autoSend: boolean
showPartialTranscript: boolean
pushToComposerWhileSpeaking: boolean
silenceAutoStopMs: number
}
```
### API frontend
Criar cliente dedicado:
- `src/api/stt.ts`
Endpoints planejados:
- `GET /api/stt/config/schema`
- `POST /api/stt/transcriptions`
- `POST /api/stt/realtime/session`
- `POST /api/stt/realtime/:sessionId/chunk`
- `POST /api/stt/realtime/:sessionId/commit`
- `DELETE /api/stt/realtime/:sessionId`
### Backend/extensão Pi
Adicionar ao `extensions/piagentui-server.ts` ou extrair para módulo dedicado se o arquivo ficar grande:
- parser seguro de multipart/audio para transcrição por arquivo;
- proxy para `/v1/audio/transcriptions`;
- gerenciador de sessão realtime;
- normalização de eventos para o frontend.
Formato de eventos para o frontend:
```ts
type SttEvent =
| { type: 'stt.session.started'; sessionId: string }
| { type: 'stt.transcript.delta'; sessionId: string; text: string; itemId?: string }
| { type: 'stt.transcript.completed'; sessionId: string; text: string; itemId?: string }
| { type: 'stt.error'; sessionId?: string; message: string; recoverable: boolean }
| { type: 'stt.session.closed'; sessionId: string }
```
### Segurança
- API keys não devem aparecer em logs.
- Configurações secretas devem ser mascaradas na UI.
- O backend deve limitar tamanho/duração de áudio.
- O backend deve validar `baseUrl` e impedir esquemas não HTTP(S).
- Erros do provedor devem ser normalizados, sem vazar headers.
- O usuário deve confirmar antes de habilitar auto-send de transcrição.
### Testes
Seguir TDD:
1. `src/api/stt.test.ts`
- monta payloads corretos;
- trata deltas/finais;
- mascara erros.
2. `src/store/sttSettingsStore.test.ts`
- defaults;
- persistência;
- atualização parcial;
- remoção de secret.
3. `src/features/voice/*.test.tsx`
- estados do botão;
- permissão negada;
- parcial/final no composer;
- auto-send desligado por padrão.
4. `extensions/piagentui-server.test.ts`
- endpoints STT;
- validação de config;
- proxy OpenAI-compatible;
- realtime session lifecycle com mocks.
## Plano de implementação recomendado
1. Atualizar `README.md` com foco PiAgentUI atual.
2. Adicionar uma seção curta no README para STT planejado.
3. Criar plano TDD específico para STT antes de escrever produção.
4. Implementar configuração STT.
5. Implementar transcrição por arquivo OpenAI-compatible.
6. Implementar botão de gravação e inserção no composer.
7. Implementar realtime OpenAI como provider padrão.
8. Adicionar fallback de arquivo para provedores sem realtime.
9. Fazer verificação manual no browser/Tauri.
## Critérios de aceite
### README
- Não deve mais vender o projeto como OpenCodeUI.
- Deve explicar claramente que o projeto atual é PiAgentUI.
- Deve listar funcionalidades reais já implementadas.
- Deve separar roadmap de funcionalidades existentes.
- Deve incluir comandos de desenvolvimento corretos.
### STT
- Usuário consegue configurar provider padrão OpenAI.
- Usuário consegue configurar provider externo OpenAI-compatible para `/audio/transcriptions`.
- Usuário consegue gravar áudio pelo microfone.
- Usuário vê transcrição parcial quando o provider realtime suporta.
- Usuário recebe texto final no composer.
- Auto-send fica desligado por padrão.
- Falhas de permissão, rede e provider são visíveis e recuperáveis.
- Testes cobrem API, store e UI principal.
## Fontes consultadas
- OpenAI Realtime transcription: https://developers.openai.com/api/docs/guides/realtime-transcription
- OpenAI GPT-4o Transcribe: https://developers.openai.com/api/docs/models/gpt-4o-transcribe
- OpenAI speech transcription methods cookbook: https://developers.openai.com/cookbook/examples/speech_transcription_methods
- Together AI transcription docs: https://docs.together.ai/docs/inference/transcription/overview
- Together AI Whisper Large v3: https://www.together.ai/models/openai-whisper-large-v3
@@ -0,0 +1,565 @@
# Spec — STT, acesso LAN/QR, sessões reais e histórico por projeto
**Data:** 2026-05-30
**Status:** proposta técnica pronta para revisão do usuário antes de implementação
**Produto:** PiAgentUI
**Escopo:** implementar, com mudanças mínimas e TDD, quatro melhorias conectadas ao uso real do PiAgentUI:
1. sistema de áudio/STT no composer;
2. exposição controlada do PiAgentUI na rede local com IP e QR Code;
3. criação de novas sessões que realmente criam novos chats no Pi Agent;
4. histórico lateral agrupado por pastas/projetos, expansível e recolhível.
## Contexto observado no projeto
Arquitetura atual relevante:
- Backend local da UI fica em `extensions/piagentui-server.ts`.
- Núcleo de helpers seguros fica em `extensions/piagentui-server-core.ts`.
- O servidor HTTP atualmente faz bind em `127.0.0.1` e valida `Host`/`Origin` como loopback-only.
- `serveIndexHtml()` injeta `window.PI_BOOT_DATA` com `baseUrl: http://127.0.0.1:<port>` e bearer token local.
- O discovery local é salvo em `~/.pi/agent/piagentui-port.json` com `{ port, token }`.
- `POST /api/sessions` atualmente **não cria sessão real**: ele apenas retorna `getSessionInfo(getCurrentSessionId())`.
- `GET /api/sessions` lê arquivos `.jsonl` do diretório de sessões e deriva metadados via `readSessionMetadata()`.
- `readSessionMetadata()` já extrai `header.cwd`, título por `session_info` ou primeira mensagem de usuário, timestamps e contagem básica.
- `sendUserMessageToRequestedSession()` já consegue trocar rapidamente para uma sessão solicitada via `latestCtx.switchSession(sessionFile, { withSession })` e enviar mensagem nela.
- A UI de sessões está em `src/features/sessions/SessionList.tsx`.
- O estado de sessões vem de `src/contexts/SessionContext.tsx` e `src/api/session.ts`.
- O input/composer está em `src/features/chat/InputBox.tsx` e `src/features/chat/input/InputToolbar.tsx`.
- Já existem anexos multimodais, incluindo áudio como arquivo quando o modelo suporta.
- A store de servidores fica em `src/store/serverStore.ts` e já suporta múltiplos backends, bearer token, Basic Auth e health checks.
## Objetivos
### Objetivo 1 — STT no composer
Adicionar entrada por voz sem substituir o fluxo de texto:
- botão de microfone no composer;
- gravação de áudio pelo navegador/Tauri webview;
- transcrição por provider configurável;
- inserção do texto transcrito no input;
- fallback por arquivo via OpenAI-compatible `/audio/transcriptions`;
- arquitetura preparada para realtime OpenAI depois, sem bloquear o MVP.
### Objetivo 2 — acesso LAN com QR Code
Permitir que o usuário acesse o PiAgentUI por outro dispositivo na mesma rede local:
- manter acesso LAN **desligado por padrão**;
- quando ligado, mostrar IP local do computador, URL de acesso e QR Code;
- não remover autenticação existente;
- não expor sem aviso que o controle do agente fica disponível na rede local;
- manter loopback funcionando normalmente.
### Objetivo 3 — novas sessões reais no Pi Agent
Corrigir o comportamento de “novo chat”:
- `New chat` deve criar um novo arquivo de sessão Pi `.jsonl`;
- deve alternar rapidamente para essa sessão real;
- enviar a primeira mensagem nessa sessão deve persistir no novo arquivo, não apenas simular um chat novo na UI;
- o retorno de `POST /api/sessions` deve conter o id real, diretório real e metadata real.
### Objetivo 4 — histórico por pastas/projetos
Agrupar sessões no histórico lateral por projeto:
- cada pasta representa um `cwd`/diretório de projeto vindo do header da sessão;
- nome visual da pasta = basename do diretório, com fallback legível;
- pastas podem expandir/recolher;
- sessões ficam dentro da pasta do projeto;
- busca continua funcionando;
- seleção e drag/drop de sessão continuam funcionando;
- sem reorganizar o backend inteiro de sessões.
## Não objetivos
- Não implementar TTS/voz falada nesta fase.
- Não criar sistema multiusuário, login ou permissões finas.
- Não abrir o servidor LAN por padrão.
- Não publicar automaticamente a extensão.
- Não renomear todo o legado `opencode`/OpenCodeUI agora.
- Não refatorar `SessionList.tsx` inteiro além do necessário para agrupamento por projeto.
- Não alterar arquitetura de pane layout, terminal, file explorer ou MCP além dos pontos necessários.
## Decisões recomendadas
### A. STT em duas fases, mas com base única
**Recomendado:** implementar primeiro `file transcription`, com tipos e UI já preparados para `realtime`.
Motivo:
- é mais rápido e testável;
- funciona com OpenAI e provedores OpenAI-compatible;
- evita acoplar a primeira entrega a WebSocket realtime;
- mantém o caminho aberto para `gpt-realtime-whisper`.
Configuração inicial:
```ts
export type SttMode = 'file' | 'realtime'
export type SttProviderKind = 'openai' | 'openai-compatible'
export interface SttSettings {
enabled: boolean
providerKind: SttProviderKind
mode: SttMode
baseUrl: string
apiKeyConfigured: boolean
transcriptionEndpoint: string
transcriptionModel: string
language?: string
insertMode: 'append' | 'replace'
}
```
O segredo real da API key não deve ser retornado ao frontend depois de salvo. A UI recebe apenas `apiKeyConfigured: true/false`.
### B. Settings STT server-side local
**Recomendado:** salvar configuração STT em arquivo local do usuário:
```text
~/.pi/agent/piagentui-settings.json
```
Estrutura mínima:
```json
{
"stt": {
"enabled": true,
"providerKind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.openai.com/v1",
"apiKey": "...",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "gpt-4o-mini-transcribe",
"language": "pt",
"insertMode": "append"
},
"network": {
"lanAccessEnabled": false
}
}
```
Motivo:
- evita persistir API key no localStorage do browser;
- permite LAN sem expor secrets no `window.PI_BOOT_DATA`;
- mantém tudo local e simples;
- evita banco de dados ou integração de credenciais complexa.
### C. LAN por bind amplo + validação dinâmica
**Recomendado:** o servidor pode escutar em `0.0.0.0`, mas responder a clientes não-loopback apenas quando `network.lanAccessEnabled === true`.
Mudanças necessárias:
- `listenOnAvailablePort(server, DEFAULT_PORT, '0.0.0.0')`;
- substituir validação loopback-only por função configurável:
- loopback sempre permitido;
- LAN permitido apenas se `lanAccessEnabled`;
- `Origin` deve bater com host loopback ou LAN permitido;
- `serveIndexHtml(req, res)` deve montar `baseUrl` a partir de `req.headers.host`, não sempre `127.0.0.1`;
- endpoint `GET /api/network/access` retorna estado, IPs e URLs;
- endpoint `POST /api/network/access` liga/desliga LAN.
Acesso LAN expõe controle do agente para quem estiver na rede e conseguir acessar a URL. A UI deve mostrar warning explícito.
### D. QR Code no frontend
**Recomendado:** gerar o QR Code no frontend a partir da URL LAN ativa.
Opções:
1. usar dependência pequena `qrcode`;
2. implementar QR manualmente;
3. usar serviço externo.
Decisão: usar `qrcode` se não houver lib existente. É a solução mais simples, testável e offline. Evitar serviço externo por privacidade.
### E. Criar sessão real sem mexer no core do Pi
O contexto regular da extensão tem `switchSession`, mas `newSession()` fica restrito ao command context do Pi. Para não mexer no core do Pi agora, o backend do PiAgentUI pode criar o arquivo `.jsonl` compatível com `SessionManager.newSession()` e depois chamar `latestCtx.switchSession(newSessionFile, { withSession })`.
Header da sessão compatível:
```json
{
"type": "session",
"version": 3,
"id": "<novo-id>",
"timestamp": "<iso-date>",
"cwd": "<diretorio-do-projeto>",
"parentSession": "<opcional>"
}
```
Fluxo:
1. receber `POST /api/sessions` com `{ directory?, title?, parentID? }`;
2. resolver `cwd = directory || latestCtx.cwd || process.cwd()`;
3. gerar id válido e filename com timestamp;
4. escrever arquivo `.jsonl` com header;
5. se `title` existir, opcionalmente adicionar entry `session_info` compatível com o parser atual;
6. trocar rapidamente para o novo arquivo com `latestCtx.switchSession()`;
7. atualizar `latestCtx` no `withSession`;
8. retornar metadata real lida de `readSessionMetadata()`.
Se `switchSession` não estiver disponível, retornar `501` em vez de fingir sucesso.
### F. Histórico por projeto no frontend
**Recomendado:** agrupar no frontend usando `session.directory` já retornado pelo backend.
Criar helper puro:
```text
src/features/sessions/sessionProjectGroups.ts
```
Responsabilidades:
- normalizar paths Windows/Linux;
- extrair nome do projeto pelo basename;
- criar chave estável por diretório normalizado;
- ordenar grupos por `max(session.time.updated)` desc;
- ordenar sessões dentro do grupo por `time.updated` desc;
- manter busca como modo flat ou grupos filtrados.
A UI pode adicionar modo `groupedByProject?: boolean` em `SessionList`, preservando o agrupamento temporal antigo se necessário.
Estado de expansão:
- store local leve em `localStorage`, por exemplo `piagentui-session-project-folders`;
- padrão: pasta do projeto atual expandida; demais recolhidas;
- se busca ativa, expandir todos os grupos com match.
## Endpoints propostos
### STT
#### `GET /api/settings/stt`
Retorna settings redigidos:
```json
{
"enabled": true,
"providerKind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.openai.com/v1",
"apiKeyConfigured": true,
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "gpt-4o-mini-transcribe",
"language": "pt",
"insertMode": "append"
}
```
#### `POST /api/settings/stt`
Atualiza settings. Se `apiKey` vier vazia/ausente, mantém chave anterior; se vier `null`, remove.
#### `POST /api/stt/transcriptions`
Recebe áudio como `multipart/form-data`, chama provider configurado e retorna:
```json
{
"text": "texto transcrito",
"durationMs": 1234,
"provider": "openai-compatible",
"model": "gpt-4o-mini-transcribe"
}
```
### LAN/QR
#### `GET /api/network/access`
```json
{
"lanAccessEnabled": false,
"port": 58785,
"localUrl": "http://127.0.0.1:58785",
"lanUrls": ["http://192.168.0.10:58785"],
"primaryLanUrl": "http://192.168.0.10:58785"
}
```
#### `POST /api/network/access`
```json
{ "lanAccessEnabled": true }
```
Retorna o mesmo shape do `GET`.
### Sessões
#### `POST /api/sessions`
Novo comportamento: cria sessão real.
Body:
```json
{
"directory": "D:/Documents/Github/PiAgentUI/PiAgentUi",
"title": "Nova conversa",
"parentID": "opcional"
}
```
Response:
```json
{
"id": "019...",
"title": "Nova conversa",
"directory": "D:/Documents/Github/PiAgentUI/PiAgentUi",
"time": { "created": 123, "updated": 123 },
"status": { "type": "idle" },
"summary": { "deletions": 0, "files": 0, "additions": 0 }
}
```
## Componentes/frontend propostos
### STT
Criar:
- `src/api/stt.ts` — chamadas de settings e transcrição;
- `src/types/api/stt.ts` — tipos de settings/resposta;
- `src/hooks/useVoiceTranscription.ts` — MediaRecorder, estados e envio para backend;
- `src/features/chat/input/VoiceInputButton.tsx` — botão mic/stop/loading/error;
- alterações pontuais em `InputBox.tsx` e `InputToolbar.tsx` para inserir texto transcrito.
Estados mínimos:
```ts
type VoiceState =
| 'idle'
| 'requesting-permission'
| 'recording'
| 'transcribing'
| 'error'
```
### LAN/QR
Criar:
- `src/api/network.ts`;
- `src/types/api/network.ts`;
- `src/features/settings/components/NetworkAccessSettings.tsx` ou adicionar seção pequena em `ServersSettings.tsx`;
- QR gerado a partir de `primaryLanUrl`.
Como o app já tem `ServersSettings.tsx`, a mudança mínima é adicionar uma seção “Local network access” dentro dele, sem criar novo painel de settings se não for necessário.
### Sessões por projeto
Criar:
- `src/features/sessions/sessionProjectGroups.ts`;
- testes unitários para grouping;
- alterar `SessionList.tsx` para renderizar project folders quando `groupedByProject` estiver ativo;
- adicionar traduções apenas nos namespaces já usados (`chat`/`commands`/`common`) conforme necessário.
## Segurança
- LAN fica desligado por padrão.
- Loopback continua sempre permitido.
- API continua exigindo bearer token.
- O HTML servido injeta o token apenas para o cliente que abriu a UI; isso significa que qualquer pessoa com acesso à URL LAN durante LAN enabled pode controlar o agente. A UI deve deixar isso claro.
- STT API key não deve aparecer em resposta de settings.
- Não logar API key.
- Não aceitar `baseUrl` sem `http:` ou `https:`.
- Limitar upload STT por tamanho. Sugestão inicial: 25 MB.
- Reusar `isRequestBodySizeAllowed` ou criar limite específico para multipart.
- Não permitir path traversal ou file reads novos.
## Plano de testes obrigatório
### Backend extension
Arquivo: `extensions/piagentui-server.test.ts`
Casos:
1. `POST /api/sessions` cria arquivo `.jsonl` com header `type=session`, `version=3`, `cwd` correto e id novo.
2. `POST /api/sessions` chama `switchSession()` com `withSession` e retorna metadata da sessão nova.
3. `POST /api/sessions` retorna `501` quando `switchSession` indisponível, sem fingir sucesso.
4. `GET /api/network/access` retorna loopback URL e lista de IPs LAN mockável.
5. LAN disabled rejeita host não-loopback.
6. LAN enabled aceita host LAN com bearer token.
7. `serveIndexHtml()` injeta `baseUrl` baseado no host da request.
8. `GET /api/settings/stt` redige `apiKey`.
9. `POST /api/settings/stt` salva settings e preserva/remover chave conforme contrato.
10. `POST /api/stt/transcriptions` valida provider config e trata erro do provider sem vazar segredo.
Arquivo: `extensions/piagentui-server-core.test.ts`
Casos:
1. `isLoopbackHost()` preserva comportamento atual.
2. nova validação aceita LAN apenas quando enabled.
3. `getLocalNetworkUrls()` ignora loopback, IPv6 local problemático e interfaces internas inválidas.
4. validação de URL STT recusa protocolos não HTTP(S).
### Frontend API/hooks
Arquivos novos:
- `src/api/stt.test.ts`;
- `src/api/network.test.ts`;
- `src/hooks/useVoiceTranscription.test.tsx`;
- `src/features/sessions/sessionProjectGroups.test.ts`.
Casos:
1. API STT envia/recebe shape correto.
2. API network chama endpoints corretos.
3. hook de voz passa por `idle -> recording -> transcribing -> idle` e chama callback com texto final.
4. hook trata permissão negada.
5. grouping por projeto cria pastas corretas para paths Windows e POSIX.
6. grouping ordena pastas por sessão mais recente.
7. grouping mantém sessões ordenadas por update.
### Componentes
Arquivos prováveis:
- `src/features/chat/input/VoiceInputButton.test.tsx`;
- `src/features/settings/components/ServersSettings.test.tsx` ou novo test de `NetworkAccessSettings`;
- `src/features/sessions/SessionList.test.tsx`.
Casos:
1. botão de microfone mostra estados e dispara start/stop.
2. seção LAN mostra URL e QR quando enabled.
3. SessionList renderiza pastas por projeto, expande/recolhe e preserva seleção.
## Ordem de implementação recomendada
1. **Sessões reais primeiro** — corrige fundação crítica de chat.
2. **Agrupamento por projeto** — depende de sessões reais/metadata confiável.
3. **LAN/QR** — isolado, mas tem implicações de segurança.
4. **STT file transcription** — maior superfície; deve entrar depois da base de rede/sessão estar sólida.
5. **Realtime STT** — fase seguinte, usando o contrato já criado.
## Critérios de aceite
### Sessões reais
- Clicar em New chat cria um novo arquivo `.jsonl` de sessão Pi.
- O id retornado pelo backend é diferente da sessão anterior.
- Enviar mensagem após criar novo chat persiste nessa nova sessão.
- Troca de sessão continua rápida e não perde SSE.
### Histórico por projeto
- Sidebar mostra pastas por projeto.
- Cada pasta contém sessões daquele `cwd`.
- Expand/recolher funciona.
- Busca ainda localiza sessões.
- Selecionar sessão dentro da pasta navega para o chat correto.
### LAN/QR
- Com LAN off, acesso por IP LAN recebe 403.
- Com LAN on, URL LAN abre a UI em outro dispositivo da rede.
- QR Code aponta para a URL LAN ativa.
- API continua exigindo token.
- UI mostra aviso de segurança.
### STT
- Usuário consegue gravar áudio e transcrever para texto.
- Texto final entra no composer sem auto-send.
- Provider OpenAI-compatible é configurável.
- API key não aparece em respostas/logs.
- Erros de permissão/rede/provider são visíveis e recuperáveis.
## Riscos e mitigação
| Risco | Mitigação |
| --- | --- |
| LAN expõe controle total do agente | default off, warning explícito, bearer obrigatório, permitir desligar rápido |
| Criar sessão manualmente diverge do Pi core | usar header exatamente compatível com `SessionManager.newSession()`; testes contra parser atual |
| `switchSession` indisponível em runtime antigo | retornar `501`, não simular sucesso |
| QR exige dependência nova | usar `qrcode` pequena e isolada; se rejeitado, renderizar URL primeiro e QR depois |
| STT com CORS se chamado direto do frontend | chamadas a providers sempre pelo backend local |
| API key em LAN | armazenar server-side local, responder apenas `apiKeyConfigured` |
| `SessionList.tsx` já é grande | extrair grouping puro para arquivo separado; modificar renderização o mínimo possível |
## Arquivos previstos
### Criar
- `src/api/stt.ts`
- `src/api/stt.test.ts`
- `src/types/api/stt.ts`
- `src/api/network.ts`
- `src/api/network.test.ts`
- `src/types/api/network.ts`
- `src/hooks/useVoiceTranscription.ts`
- `src/hooks/useVoiceTranscription.test.tsx`
- `src/features/chat/input/VoiceInputButton.tsx`
- `src/features/chat/input/VoiceInputButton.test.tsx`
- `src/features/sessions/sessionProjectGroups.ts`
- `src/features/sessions/sessionProjectGroups.test.ts`
### Modificar
- `extensions/piagentui-server.ts`
- `extensions/piagentui-server.test.ts`
- `extensions/piagentui-server-core.ts`
- `extensions/piagentui-server-core.test.ts`
- `src/features/chat/InputBox.tsx`
- `src/features/chat/input/InputToolbar.tsx`
- `src/features/sessions/SessionList.tsx`
- `src/features/sessions/SessionList.test.tsx`
- `src/features/settings/components/ServersSettings.tsx`
- `src/store/serverStore.ts` apenas se necessário para refletir LAN URL ativa
- `src/locales/en/*.json` e `src/locales/zh-CN/*.json` para labels mínimos
- `package.json` se a dependência `qrcode` for adotada
## Abordagens consideradas
### Abordagem 1 — implementar tudo no frontend
Rejeitada. STT direto do browser expõe API keys, sofre com CORS e não combina com LAN.
### Abordagem 2 — mexer no core do Pi para expor `newSession` ao extension context
Possível, mas maior. Já fizemos patch de `switchSession`; para este pacote o objetivo é mudança mínima no PiAgentUI. Só considerar se criação manual de sessão se mostrar incompatível.
### Abordagem 3 — backend PiAgentUI como orquestrador local
Recomendada. Mantém mudanças dentro do PiAgentUI, usa `switchSession` já disponível, preserva segurança local e permite testes focados.
## Gate de implementação
Esta spec cobre múltiplos subsistemas e altera segurança/rede/sessão. A implementação deve começar somente após aprovação explícita do usuário sobre este design.
Após aprovação, criar plano em:
```text
docs/superpowers/plans/2026-05-30-piagentui-stt-lan-real-sessions-project-folders.md
```
O plano deve usar TDD e commits pequenos por feature:
1. sessões reais;
2. agrupamento por projeto;
3. LAN/QR;
4. STT settings + file transcription;
5. voice UI;
6. validação final e revisão de código.
@@ -0,0 +1,87 @@
# Design — Controle Git no composer do PiAgentUI
## Objetivo
Adicionar à bolha de envio de mensagens um controle Git compacto, localizado no canto inferior esquerdo da toolbar do composer, mostrando a branch atual e abrindo um popover com branches e operações Git principais.
## Direção visual aprovada
- O controle aparece como um chip integrado à `InputToolbar`, antes dos seletores de agente e variante.
- O chip usa iconografia GitHub/Octicons, texto monoespaçado, estado dirty e chevron.
- Ao clicar, abre um popover acima da bolha de input, alinhado ao chip, seguindo o padrão visual de `DropdownMenu`: glass, borda sutil, radius alto e sombra do app.
- O popover tem duas áreas:
- lista de branches local/remota e ação de nova branch;
- status, ações Git e área dedicada de commit.
- As ações principais usam ícones: branch, commit, diff/status, pull, push, refresh, stage/unstage, arquivos e alertas.
## Escopo MVP seguro
Inclui:
- carregar branch atual, branches locais/remotas e status resumido;
- trocar branch local/remota;
- criar branch a partir da atual;
- listar arquivos alterados;
- stage/unstage arquivo;
- stage all/unstage all;
- commit com textarea dedicada;
- pull e push explícitos;
- refresh de status;
- mensagens de erro e loading.
Não inclui no MVP:
- merge manual;
- rebase;
- reset hard;
- revert;
- stash;
- force push;
- edição interativa de hunks.
## Segurança e comportamento
- Backend executa Git via `spawn`/`execFile` com argv fixo, nunca shell arbitrário.
- Diretório vem do projeto atual e precisa ser repositório Git válido.
- Commit exige mensagem não vazia e pelo menos um arquivo staged.
- Pull/push são ações explícitas no popover.
- Erros do Git são retornados como mensagens sanitizadas.
- Operações atualizam status e branch após sucesso.
## Componentes
- `GitComposerControl`: chip + popover Git do composer.
- `src/api/vcs.ts`: funções de API para status, branches e ações.
- `src/types/api/vcs.ts`: contratos frontend/backend.
- `extensions/piagentui-git-manager.ts`: camada backend focada em Git.
- `extensions/piagentui-server.ts`: endpoints `/api/vcs/*`.
## Endpoints planejados
- `GET /api/vcs/info`
- `GET /api/vcs/branches`
- `GET /api/vcs/status`
- `POST /api/vcs/checkout`
- `POST /api/vcs/branch`
- `POST /api/vcs/stage`
- `POST /api/vcs/unstage`
- `POST /api/vcs/commit`
- `POST /api/vcs/pull`
- `POST /api/vcs/push`
## Testes
- Testes unitários do git manager com runner fake.
- Testes de API frontend (`src/api/vcs.test.ts`).
- Teste de componente para renderizar branch, abrir popover, preencher commit e disparar ações.
- Testes de servidor para endpoints principais com mocks.
## Referência de ícones
Usar `@primer/octicons-react`, pacote oficial do Primer/GitHub Octicons. A documentação oficial mostra importação direta, por exemplo:
```ts
import { GitCommitIcon } from '@primer/octicons-react'
```
Fonte consultada via Context7: `https://primer.style/octicons/icon/git-commit-24` e `https://primer.style/octicons/code`.
+45
View File
@@ -0,0 +1,45 @@
import js from '@eslint/js'
import eslintConfigPrettier from 'eslint-config-prettier'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'node_modules', 'public/material-icons', 'src-tauri/target/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
eslintConfigPrettier,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'prefer-const': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/refs': 'warn',
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/use-memo': 'warn',
'react-refresh/only-export-components': 'warn',
},
},
])
@@ -0,0 +1,128 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildExtensionCatalog,
executeExtensionInstallCommand,
executeExtensionPackageRemoval,
parsePiInstallCommand,
} from './piagentui-extension-manager.js'
const tempRoots: string[] = []
function makeTempRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'piagentui-extension-manager-'))
tempRoots.push(root)
return root
}
function writeJson(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8')
}
afterEach(() => {
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true })
}
})
describe('parsePiInstallCommand', () => {
it('extracts source and local scope from a safe Pi install command', () => {
expect(parsePiInstallCommand('pi install npm:pi-hud')).toEqual({ source: 'npm:pi-hud', local: false })
expect(parsePiInstallCommand('pi install npm:@scope/pkg -l')).toEqual({ source: 'npm:@scope/pkg', local: true })
expect(parsePiInstallCommand('pi install -l "D:/Projects/my extension"')).toEqual({
source: 'D:/Projects/my extension',
local: true,
})
})
it('rejects commands that are not a single Pi install operation', () => {
expect(() => parsePiInstallCommand('npm install pi-hud')).toThrow(/pi install/i)
expect(() => parsePiInstallCommand('pi remove npm:pi-hud')).toThrow(/pi install/i)
expect(() => parsePiInstallCommand('pi install npm:pi-hud && rm -rf .')).toThrow(/unsupported/i)
expect(() => parsePiInstallCommand('pi install')).toThrow(/missing/i)
})
})
describe('buildExtensionCatalog', () => {
it('groups configured packages with metadata, resource counts, and active or inactive status', () => {
const homeDir = makeTempRoot()
const cwd = path.join(homeDir, 'project')
const userPackageRoot = path.join(homeDir, '.pi', 'agent', 'npm', 'node_modules', 'pi-demo')
const projectPackageRoot = path.join(cwd, '.pi', 'npm', 'node_modules', 'project-demo')
const missingPackageSource = 'npm:missing-demo'
writeJson(path.join(homeDir, '.pi', 'agent', 'settings.json'), {
packages: ['npm:pi-demo', { source: missingPackageSource, extensions: [] }],
})
writeJson(path.join(cwd, '.pi', 'settings.json'), {
packages: [{ source: 'npm:project-demo', extensions: [] }],
})
writeJson(path.join(userPackageRoot, 'package.json'), {
name: 'pi-demo',
version: '1.2.3',
description: 'Demo package',
pi: { extensions: ['./extensions'], skills: ['./skills'] },
})
fs.mkdirSync(path.join(userPackageRoot, 'extensions'), { recursive: true })
fs.writeFileSync(path.join(userPackageRoot, 'extensions', 'demo.ts'), 'export default {}', 'utf8')
fs.mkdirSync(path.join(userPackageRoot, 'skills', 'demo'), { recursive: true })
fs.writeFileSync(path.join(userPackageRoot, 'skills', 'demo', 'SKILL.md'), '---\nname: demo\n---', 'utf8')
writeJson(path.join(projectPackageRoot, 'package.json'), {
name: 'project-demo',
version: '0.1.0',
description: 'Project package',
pi: { extensions: ['./extensions'] },
})
fs.mkdirSync(path.join(projectPackageRoot, 'extensions'), { recursive: true })
fs.writeFileSync(path.join(projectPackageRoot, 'extensions', 'project.ts'), 'export default {}', 'utf8')
const catalog = buildExtensionCatalog({ cwd, homeDir })
expect(catalog.summary).toEqual({ total: 3, active: 1, inactive: 1, missing: 1, error: 0 })
const userPackage = catalog.packages.find(pkg => pkg.source === 'npm:pi-demo')
expect(userPackage).toMatchObject({
source: 'npm:pi-demo',
scope: 'user',
packageName: 'pi-demo',
version: '1.2.3',
description: 'Demo package',
status: 'active',
installedPath: userPackageRoot,
})
expect(userPackage?.resources.extensions).toEqual([
expect.objectContaining({
name: 'demo.ts',
enabled: true,
path: path.join(userPackageRoot, 'extensions', 'demo.ts'),
}),
])
expect(userPackage?.resources.skills).toEqual([expect.objectContaining({ name: 'demo', enabled: true })])
const projectPackage = catalog.packages.find(pkg => pkg.source === 'npm:project-demo')
expect(projectPackage).toMatchObject({ source: 'npm:project-demo', scope: 'project', status: 'inactive' })
expect(projectPackage?.resources.extensions).toEqual([
expect.objectContaining({ name: 'project.ts', enabled: false }),
])
const missingPackage = catalog.packages.find(pkg => pkg.source === missingPackageSource)
expect(missingPackage).toMatchObject({ source: missingPackageSource, scope: 'user', status: 'missing' })
})
})
describe('extension package operations', () => {
it('runs parsed install and removal through the Pi CLI without executing pasted shell text', async () => {
const runner = vi.fn(async () => ({ stdout: 'ok', stderr: '' }))
await executeExtensionInstallCommand('pi install npm:pi-hud -l', { cwd: '/repo', runner })
await executeExtensionPackageRemoval({ source: 'npm:pi-hud', scope: 'project' }, { cwd: '/repo', runner })
expect(runner).toHaveBeenNthCalledWith(1, ['install', 'npm:pi-hud', '-l'], { cwd: '/repo' })
expect(runner).toHaveBeenNthCalledWith(2, ['remove', 'npm:pi-hud', '-l'], { cwd: '/repo' })
})
})
+513
View File
@@ -0,0 +1,513 @@
import { spawn } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
export type ExtensionPackageScope = 'user' | 'project'
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
export type ExtensionResourceType = 'extensions' | 'skills' | 'prompts' | 'themes'
export interface ExtensionResource {
name: string
path: string
enabled: boolean
}
export interface ExtensionPackageResources {
extensions: ExtensionResource[]
skills: ExtensionResource[]
prompts: ExtensionResource[]
themes: ExtensionResource[]
}
export interface ExtensionPackageInfo {
source: string
scope: ExtensionPackageScope
filtered: boolean
installedPath?: string
packageName?: string
version?: string
description?: string
image?: string
keywords?: string[]
status: ExtensionPackageStatus
error?: string
resources: ExtensionPackageResources
}
export interface ExtensionCatalogSummary {
total: number
active: number
inactive: number
missing: number
error: number
}
export interface ExtensionCatalog {
packages: ExtensionPackageInfo[]
summary: ExtensionCatalogSummary
}
export interface BuildExtensionCatalogOptions {
cwd: string
homeDir?: string
}
interface PiSettings {
packages?: PackageSourceEntry[]
}
interface PackageSourceObject {
source: string
extensions?: string[]
skills?: string[]
prompts?: string[]
themes?: string[]
}
type PackageSourceEntry = string | PackageSourceObject
interface NormalizedPackageSource {
source: string
scope: ExtensionPackageScope
filtered: boolean
filters: Partial<Record<ExtensionResourceType, string[]>>
}
interface PackageJsonMetadata {
name?: string
version?: string
description?: string
keywords?: string[]
pi?: Partial<Record<ExtensionResourceType, string[]>> & { image?: string }
}
export interface ParsedPiInstallCommand {
source: string
local: boolean
}
export type PiCommandRunner = (args: string[], options: { cwd: string }) => Promise<{ stdout: string; stderr: string }>
const RESOURCE_TYPES: ExtensionResourceType[] = ['extensions', 'skills', 'prompts', 'themes']
const RESOURCE_FILE_PATTERNS: Record<ExtensionResourceType, RegExp> = {
extensions: /\.(?:ts|js)$/,
skills: /\.md$/,
prompts: /\.md$/,
themes: /\.json$/,
}
function getHomeDir(homeDir?: string): string {
return homeDir || process.env.USERPROFILE || process.env.HOME || os.homedir()
}
function getAgentDir(homeDir?: string): string {
return path.join(getHomeDir(homeDir), '.pi', 'agent')
}
function readJsonFile<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T
} catch {
return null
}
}
function readSettings(filePath: string): PiSettings {
return readJsonFile<PiSettings>(filePath) ?? {}
}
function normalizePackageEntry(
entry: PackageSourceEntry,
scope: ExtensionPackageScope,
): NormalizedPackageSource | null {
if (typeof entry === 'string') {
const source = entry.trim()
return source ? { source, scope, filtered: false, filters: {} } : null
}
if (!entry || typeof entry.source !== 'string') return null
const source = entry.source.trim()
if (!source) return null
return {
source,
scope,
filtered: true,
filters: {
extensions: entry.extensions,
skills: entry.skills,
prompts: entry.prompts,
themes: entry.themes,
},
}
}
function parseNpmPackageName(source: string): string | null {
if (!source.startsWith('npm:')) return null
const spec = source.slice('npm:'.length).trim()
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/)
return (match?.[1] ?? spec) || null
}
function isProbablyLocalSource(source: string): boolean {
return (
source.startsWith('.') ||
source.startsWith('~') ||
path.isAbsolute(source) ||
/^[a-zA-Z]:[\\/]/.test(source) ||
source.includes('\\')
)
}
function resolveLocalSource(source: string, scope: ExtensionPackageScope, cwd: string, homeDir?: string): string {
const baseDir = scope === 'project' ? path.join(cwd, '.pi') : getAgentDir(homeDir)
if (source.startsWith('~')) return path.resolve(path.join(getHomeDir(homeDir), source.slice(1)))
if (path.isAbsolute(source) || /^[a-zA-Z]:[\\/]/.test(source)) return path.resolve(source)
return path.resolve(baseDir, source)
}
function parseGitInstallPathParts(source: string): { host: string; repoPath: string } | null {
const normalized = source.replace(/^git:/, '')
const githubShorthand = normalized.match(/^github\.com[/:]([^\s]+)$/)
if (githubShorthand) return { host: 'github.com', repoPath: githubShorthand[1].replace(/\.git$/, '') }
const sshMatch = normalized.match(/^git@([^:]+):(.+)$/)
if (sshMatch) return { host: sshMatch[1], repoPath: sshMatch[2].replace(/\.git$/, '') }
try {
const url = new URL(normalized)
if (!url.hostname || !url.pathname) return null
return { host: url.hostname, repoPath: url.pathname.replace(/^\//, '').replace(/\.git$/, '') }
} catch {
return null
}
}
function resolveInstalledPath(
source: string,
scope: ExtensionPackageScope,
cwd: string,
homeDir?: string,
): string | undefined {
const agentDir = getAgentDir(homeDir)
if (source.startsWith('npm:')) {
const packageName = parseNpmPackageName(source)
if (!packageName) return undefined
const installRoot = scope === 'project' ? path.join(cwd, '.pi', 'npm') : path.join(agentDir, 'npm')
const packagePath = path.join(installRoot, 'node_modules', ...packageName.split('/'))
return fs.existsSync(packagePath) ? packagePath : undefined
}
if (isProbablyLocalSource(source)) {
const packagePath = resolveLocalSource(source, scope, cwd, homeDir)
return fs.existsSync(packagePath) ? packagePath : undefined
}
const gitParts = parseGitInstallPathParts(source)
if (gitParts) {
const installRoot = scope === 'project' ? path.join(cwd, '.pi', 'git') : path.join(agentDir, 'git')
const packagePath = path.join(installRoot, gitParts.host, ...gitParts.repoPath.split(/[\\/]+/))
return fs.existsSync(packagePath) ? packagePath : undefined
}
return undefined
}
function collectFiles(root: string, resourceType: ExtensionResourceType): string[] {
if (!fs.existsSync(root)) return []
const stat = fs.statSync(root)
if (stat.isFile()) return RESOURCE_FILE_PATTERNS[resourceType].test(root) ? [root] : []
if (!stat.isDirectory()) return []
const files: string[] = []
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name)
if (entry.isDirectory()) {
files.push(...collectFiles(entryPath, resourceType))
} else if (entry.isFile() && RESOURCE_FILE_PATTERNS[resourceType].test(entryPath)) {
files.push(entryPath)
}
}
return files.sort((a, b) => a.localeCompare(b))
}
function stripOverridePatterns(entries: string[]): string[] {
return entries.filter(entry => !entry.trim().startsWith('!'))
}
function resolveManifestEntry(packageRoot: string, entry: string, resourceType: ExtensionResourceType): string[] {
const normalized = entry.trim()
if (!normalized || normalized.startsWith('!')) return []
if (normalized.includes('*')) {
const conventionRoot = path.join(packageRoot, resourceType)
return collectFiles(conventionRoot, resourceType)
}
const entryPath = path.resolve(packageRoot, normalized)
return collectFiles(entryPath, resourceType)
}
function discoverResourcePaths(
packageRoot: string,
metadata: PackageJsonMetadata | null,
resourceType: ExtensionResourceType,
): string[] {
const manifestEntries = metadata?.pi?.[resourceType]
if (manifestEntries && manifestEntries.length > 0) {
const files = stripOverridePatterns(manifestEntries).flatMap(entry =>
resolveManifestEntry(packageRoot, entry, resourceType),
)
return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b))
}
return collectFiles(path.join(packageRoot, resourceType), resourceType)
}
function toRelativePosix(packageRoot: string, filePath: string): string {
return path.relative(packageRoot, filePath).replace(/\\/g, '/')
}
function wildcardToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`)
}
function matchesFilter(packageRoot: string, filePath: string, patterns: string[]): boolean {
const relativePath = toRelativePosix(packageRoot, filePath)
const normalizedPatterns = patterns.map(pattern => pattern.trim()).filter(Boolean)
if (normalizedPatterns.length === 0) return false
return normalizedPatterns.some(pattern => {
const positivePattern = pattern.startsWith('!') ? pattern.slice(1) : pattern
if (!positivePattern) return false
const normalizedPattern = positivePattern.replace(/\\/g, '/')
return (
relativePath === normalizedPattern ||
relativePath.endsWith(`/${normalizedPattern}`) ||
wildcardToRegExp(normalizedPattern).test(relativePath)
)
})
}
function resourceName(filePath: string): string {
if (path.basename(filePath).toLowerCase() === 'skill.md') return path.basename(path.dirname(filePath))
return path.basename(filePath)
}
function buildResources(
packageRoot: string,
metadata: PackageJsonMetadata | null,
filters: Partial<Record<ExtensionResourceType, string[]>>,
): ExtensionPackageResources {
const resources = {} as ExtensionPackageResources
for (const resourceType of RESOURCE_TYPES) {
const files = discoverResourcePaths(packageRoot, metadata, resourceType)
const patterns = filters[resourceType]
resources[resourceType] = files.map(filePath => ({
name: resourceName(filePath),
path: filePath,
enabled: patterns === undefined ? true : matchesFilter(packageRoot, filePath, patterns),
}))
}
return resources
}
function hasEnabledResource(resources: ExtensionPackageResources): boolean {
return RESOURCE_TYPES.some(resourceType => resources[resourceType].some(resource => resource.enabled))
}
function countByStatus(packages: ExtensionPackageInfo[]): ExtensionCatalogSummary {
return packages.reduce<ExtensionCatalogSummary>(
(summary, pkg) => {
summary.total += 1
summary[pkg.status] += 1
return summary
},
{ total: 0, active: 0, inactive: 0, missing: 0, error: 0 },
)
}
function readPackageMetadata(packageRoot: string): { metadata: PackageJsonMetadata | null; error?: string } {
const packageJsonPath = path.join(packageRoot, 'package.json')
if (!fs.existsSync(packageJsonPath)) return { metadata: null }
try {
return { metadata: JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonMetadata }
} catch (error) {
return { metadata: null, error: error instanceof Error ? error.message : String(error) }
}
}
export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): ExtensionCatalog {
const homeDir = getHomeDir(options.homeDir)
const agentDir = getAgentDir(homeDir)
const userSettings = readSettings(path.join(agentDir, 'settings.json'))
const projectSettings = readSettings(path.join(options.cwd, '.pi', 'settings.json'))
const configuredPackages = [
...(userSettings.packages ?? []).map(entry => normalizePackageEntry(entry, 'user')),
...(projectSettings.packages ?? []).map(entry => normalizePackageEntry(entry, 'project')),
].filter((entry): entry is NormalizedPackageSource => entry !== null)
const packages = configuredPackages.map<ExtensionPackageInfo>(entry => {
const installedPath = resolveInstalledPath(entry.source, entry.scope, options.cwd, homeDir)
if (!installedPath) {
return {
source: entry.source,
scope: entry.scope,
filtered: entry.filtered,
status: 'missing',
resources: { extensions: [], skills: [], prompts: [], themes: [] },
}
}
const { metadata, error } = readPackageMetadata(installedPath)
const resources = buildResources(installedPath, metadata, entry.filters)
const status: ExtensionPackageStatus = error ? 'error' : hasEnabledResource(resources) ? 'active' : 'inactive'
return {
source: entry.source,
scope: entry.scope,
filtered: entry.filtered,
installedPath,
packageName: metadata?.name,
version: metadata?.version,
description: metadata?.description,
image: metadata?.pi?.image,
keywords: metadata?.keywords,
status,
error,
resources,
}
})
packages.sort((a, b) => {
if (a.scope !== b.scope) return a.scope === 'project' ? -1 : 1
return (a.packageName || a.source).localeCompare(b.packageName || b.source)
})
return { packages, summary: countByStatus(packages) }
}
function tokenizeCommand(command: string): string[] {
const tokens: string[] = []
let current = ''
let quote: '"' | "'" | null = null
for (let index = 0; index < command.length; index += 1) {
const char = command[index]
if (quote) {
if (char === quote) {
quote = null
} else {
current += char
}
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
current += char
}
if (quote) throw new Error('Unterminated quote in install command')
if (current) tokens.push(current)
return tokens
}
export function parsePiInstallCommand(command: string): ParsedPiInstallCommand {
const tokens = tokenizeCommand(command.trim())
if (
tokens.length < 2 ||
path
.basename(tokens[0])
.replace(/\.(cmd|exe)$/i, '')
.toLowerCase() !== 'pi'
) {
throw new Error('Paste a pi install command, for example: pi install npm:pi-hud')
}
if (tokens[1] !== 'install') {
throw new Error('Only pi install commands are supported')
}
let local = false
let source: string | null = null
for (const token of tokens.slice(2)) {
if (token === '-l' || token === '--local') {
local = true
continue
}
if (token.startsWith('-')) throw new Error(`Unsupported install option: ${token}`)
if (source) throw new Error(`Unsupported extra argument: ${token}`)
if (/[;&|<>`]/.test(token)) throw new Error(`Unsupported shell syntax in install command: ${token}`)
source = token
}
if (!source) throw new Error('Missing extension source in pi install command')
return { source, local }
}
function getPiExecutable(): string {
return process.platform === 'win32' ? 'pi.cmd' : 'pi'
}
export function defaultPiCommandRunner(
args: string[],
options: { cwd: string },
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(getPiExecutable(), args, { cwd: options.cwd, shell: false })
let stdout = ''
let stderr = ''
child.stdout?.on('data', chunk => {
stdout += String(chunk)
})
child.stderr?.on('data', chunk => {
stderr += String(chunk)
})
child.on('error', reject)
child.on('close', code => {
if (code === 0) {
resolve({ stdout, stderr })
return
}
reject(new Error(stderr.trim() || `pi ${args.join(' ')} exited with code ${code ?? 'unknown'}`))
})
})
}
export async function executeExtensionInstallCommand(
command: string,
options: { cwd: string; runner?: PiCommandRunner },
): Promise<{ stdout: string; stderr: string; source: string; scope: ExtensionPackageScope }> {
const parsed = parsePiInstallCommand(command)
const runner = options.runner ?? defaultPiCommandRunner
const args = ['install', parsed.source, ...(parsed.local ? ['-l'] : [])]
const result = await runner(args, { cwd: options.cwd })
return { ...result, source: parsed.source, scope: parsed.local ? 'project' : 'user' }
}
export async function executeExtensionPackageRemoval(
target: { source: string; scope: ExtensionPackageScope },
options: { cwd: string; runner?: PiCommandRunner },
): Promise<{ stdout: string; stderr: string }> {
const runner = options.runner ?? defaultPiCommandRunner
return runner(['remove', target.source, ...(target.scope === 'project' ? ['-l'] : [])], { cwd: options.cwd })
}
+103
View File
@@ -0,0 +1,103 @@
import * as os from 'node:os'
import * as path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
checkoutGitBranch,
commitGitChanges,
createGitBranch,
getGitStatus,
listGitBranches,
stageGitPaths,
unstageGitPaths,
type GitCommandRunner,
} from './piagentui-git-manager.js'
const repoDir = path.join(os.tmpdir(), 'piagentui-git-manager-test')
function createRunner(outputs: Record<string, { stdout?: string; stderr?: string }>): GitCommandRunner {
return vi.fn(async args => {
const key = args.join(' ')
const output = outputs[key]
if (!output) throw new Error(`Unexpected git args: ${key}`)
return { stdout: output.stdout ?? '', stderr: output.stderr ?? '' }
})
}
describe('piagentui git manager', () => {
it('parses branch output into current local and remote branches', async () => {
const runner = createRunner({
'branch --format=%(refname:short)|%(upstream:short)|%(objectname:short)|%(HEAD) --all': {
stdout: [
'main|origin/main|1111111| ',
'feature/git-ui|origin/feature/git-ui|2222222|*',
'origin/main||3333333| ',
].join('\n'),
},
})
const result = await listGitBranches(repoDir, runner)
expect(result.current).toBe('feature/git-ui')
expect(result.branches).toEqual([
{ name: 'main', current: false, remote: false, upstream: 'origin/main', commit: '1111111' },
{ name: 'feature/git-ui', current: true, remote: false, upstream: 'origin/feature/git-ui', commit: '2222222' },
{ name: 'origin/main', current: false, remote: true, commit: '3333333' },
])
})
it('parses porcelain v2 status and ahead behind data', async () => {
const runner = createRunner({
'status --porcelain=v2 --branch': {
stdout: [
'# branch.oid abcdef1',
'# branch.head feature/git-ui',
'# branch.upstream origin/feature/git-ui',
'# branch.ab +2 -1',
'1 M. N... 100644 100644 100644 a b src/app.tsx',
'1 .M N... 100644 100644 100644 a b src/input.tsx',
'? src/new-file.ts',
].join('\n'),
},
})
const status = await getGitStatus(repoDir, runner)
expect(status.branch).toBe('feature/git-ui')
expect(status.upstream).toBe('origin/feature/git-ui')
expect(status.ahead).toBe(2)
expect(status.behind).toBe(1)
expect(status.dirty).toBe(true)
expect(status.files).toEqual([
{ path: 'src/app.tsx', index: 'M', workingTree: '.', staged: true, unstaged: false, untracked: false },
{ path: 'src/input.tsx', index: '.', workingTree: 'M', staged: false, unstaged: true, untracked: false },
{ path: 'src/new-file.ts', index: '?', workingTree: '?', staged: false, unstaged: false, untracked: true },
])
})
it('runs safe fixed argv for branch and stage operations', async () => {
const runner = createRunner({
'checkout main': {},
'checkout -b feature/new-branch': {},
'add -- src/app.tsx src/input.tsx': {},
'restore --staged -- src/app.tsx': {},
})
await checkoutGitBranch(repoDir, { branch: 'main' }, runner)
await createGitBranch(repoDir, { name: 'feature/new-branch' }, runner)
await stageGitPaths(repoDir, { paths: ['src/app.tsx', 'src/input.tsx'] }, runner)
await unstageGitPaths(repoDir, { paths: ['src/app.tsx'] }, runner)
expect(runner).toHaveBeenCalledWith(['checkout', 'main'], { cwd: repoDir })
expect(runner).toHaveBeenCalledWith(['checkout', '-b', 'feature/new-branch'], { cwd: repoDir })
expect(runner).toHaveBeenCalledWith(['add', '--', 'src/app.tsx', 'src/input.tsx'], { cwd: repoDir })
expect(runner).toHaveBeenCalledWith(['restore', '--staged', '--', 'src/app.tsx'], { cwd: repoDir })
})
it('rejects empty commit messages before running git', async () => {
const runner = vi.fn()
await expect(commitGitChanges(repoDir, { message: ' ' }, runner)).rejects.toThrow('Commit message is required')
expect(runner).not.toHaveBeenCalled()
})
})
+259
View File
@@ -0,0 +1,259 @@
import { spawn } from 'node:child_process'
export interface GitCommandResult {
stdout: string
stderr: string
}
export type GitCommandRunner = (args: string[], options: { cwd: string }) => Promise<GitCommandResult>
export interface GitBranchInfo {
name: string
current: boolean
remote: boolean
upstream?: string
commit?: string
}
export interface GitBranchesResult {
current?: string
branches: GitBranchInfo[]
}
export interface GitStatusFile {
path: string
index: string
workingTree: string
staged: boolean
unstaged: boolean
untracked: boolean
}
export interface GitStatusResult {
branch?: string
upstream?: string
ahead: number
behind: number
dirty: boolean
files: GitStatusFile[]
}
export interface GitActionResult {
ok: true
stdout?: string
stderr?: string
}
export interface CheckoutGitBranchRequest {
branch: string
}
export interface CreateGitBranchRequest {
name: string
}
export interface GitPathsRequest {
paths: string[]
}
export interface CommitGitChangesRequest {
message: string
}
export const runGit: GitCommandRunner = (args, options) => {
return new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd: options.cwd, shell: false })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', chunk => {
stdout += chunk
})
child.stderr.on('data', chunk => {
stderr += chunk
})
child.on('error', reject)
child.on('close', code => {
if (code === 0) {
resolve({ stdout, stderr })
return
}
reject(new Error((stderr || stdout || `git ${args.join(' ')} failed with exit code ${code}`).trim()))
})
})
}
function assertSafeRefName(value: string, label: string): string {
const name = value.trim()
if (!name) throw new Error(`${label} is required`)
if (name.startsWith('-')) throw new Error(`Invalid ${label}`)
if (/\s/.test(name) || /[\0~^:?*[\]\\]/.test(name) || name.includes('..') || name.includes('@{')) {
throw new Error(`Invalid ${label}`)
}
return name
}
function assertSafePath(value: string): string {
const filePath = value.trim()
if (!filePath) throw new Error('Git path is required')
if (filePath.includes('\0')) throw new Error('Invalid Git path')
return filePath
}
function normalizeRemoteName(name: string): string {
return name.replace(/^remotes\//, '')
}
export async function listGitBranches(cwd: string, runner: GitCommandRunner = runGit): Promise<GitBranchesResult> {
const { stdout } = await runner(
['branch', '--format=%(refname:short)|%(upstream:short)|%(objectname:short)|%(HEAD)', '--all'],
{
cwd,
},
)
const branches = stdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const [rawName, upstream, commit, head] = line.split('|')
const name = normalizeRemoteName(rawName)
return {
name,
current: head === '*',
remote: rawName.startsWith('remotes/') || name.startsWith('origin/'),
upstream: upstream || undefined,
commit: commit || undefined,
}
})
.filter(branch => branch.name !== 'origin/HEAD')
return { current: branches.find(branch => branch.current)?.name, branches }
}
function parseStatusPath(line: string): string {
if (line.includes('\t')) return line.split('\t').pop() || ''
const parts = line.split(' ')
return parts[parts.length - 1] || ''
}
export async function getGitStatus(cwd: string, runner: GitCommandRunner = runGit): Promise<GitStatusResult> {
const { stdout } = await runner(['status', '--porcelain=v2', '--branch'], { cwd })
const status: GitStatusResult = { ahead: 0, behind: 0, dirty: false, files: [] }
for (const line of stdout.split(/\r?\n/)) {
if (!line) continue
if (line.startsWith('# branch.head ')) {
const branch = line.slice('# branch.head '.length).trim()
if (branch && branch !== '(detached)') status.branch = branch
continue
}
if (line.startsWith('# branch.upstream ')) {
status.upstream = line.slice('# branch.upstream '.length).trim()
continue
}
if (line.startsWith('# branch.ab ')) {
const match = line.match(/\+(\d+)\s+-(\d+)/)
if (match) {
status.ahead = Number(match[1])
status.behind = Number(match[2])
}
continue
}
if (line.startsWith('1 ') || line.startsWith('2 ')) {
const xy = line.slice(2, 4)
const file = {
path: parseStatusPath(line),
index: xy[0],
workingTree: xy[1],
staged: xy[0] !== '.',
unstaged: xy[1] !== '.',
untracked: false,
}
status.files.push(file)
continue
}
if (line.startsWith('? ')) {
status.files.push({
path: line.slice(2).trim(),
index: '?',
workingTree: '?',
staged: false,
unstaged: false,
untracked: true,
})
}
}
status.dirty = status.files.length > 0
return status
}
export async function checkoutGitBranch(
cwd: string,
request: CheckoutGitBranchRequest,
runner: GitCommandRunner = runGit,
): Promise<GitActionResult> {
const branch = assertSafeRefName(request.branch, 'Branch')
const result = await runner(['checkout', branch], { cwd })
return { ok: true, ...result }
}
export async function createGitBranch(
cwd: string,
request: CreateGitBranchRequest,
runner: GitCommandRunner = runGit,
): Promise<GitActionResult> {
const name = assertSafeRefName(request.name, 'Branch name')
const result = await runner(['checkout', '-b', name], { cwd })
return { ok: true, ...result }
}
function normalizePaths(request: GitPathsRequest): string[] {
if (!Array.isArray(request.paths) || request.paths.length === 0) throw new Error('At least one path is required')
return request.paths.map(assertSafePath)
}
export async function stageGitPaths(
cwd: string,
request: GitPathsRequest,
runner: GitCommandRunner = runGit,
): Promise<GitActionResult> {
const paths = normalizePaths(request)
const result = await runner(['add', '--', ...paths], { cwd })
return { ok: true, ...result }
}
export async function unstageGitPaths(
cwd: string,
request: GitPathsRequest,
runner: GitCommandRunner = runGit,
): Promise<GitActionResult> {
const paths = normalizePaths(request)
const result = await runner(['restore', '--staged', '--', ...paths], { cwd })
return { ok: true, ...result }
}
export async function commitGitChanges(
cwd: string,
request: CommitGitChangesRequest,
runner: GitCommandRunner = runGit,
): Promise<GitActionResult> {
const message = request.message.trim()
if (!message) throw new Error('Commit message is required')
const result = await runner(['commit', '-m', message], { cwd })
return { ok: true, ...result }
}
export async function pullGitBranch(cwd: string, runner: GitCommandRunner = runGit): Promise<GitActionResult> {
const result = await runner(['pull', '--ff-only'], { cwd })
return { ok: true, ...result }
}
export async function pushGitBranch(cwd: string, runner: GitCommandRunner = runGit): Promise<GitActionResult> {
const result = await runner(['push'], { cwd })
return { ok: true, ...result }
}
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest'
import {
isLoopbackHost,
validateBearerToken,
preventPathTraversal,
validateHostAndOrigin,
validateHostAndOriginForAccess,
isRequestBodySizeAllowed,
getLocalNetworkUrls,
validateSttBaseUrl,
} from './piagentui-server-core.js'
describe('PiAgentUi Server Core Security Helpers', () => {
describe('isLoopbackHost', () => {
it('should accept loopback hostnames and IPs', () => {
expect(isLoopbackHost('localhost')).toBe(true)
expect(isLoopbackHost('127.0.0.1')).toBe(true)
expect(isLoopbackHost('[::1]')).toBe(true)
expect(isLoopbackHost('localhost:3000')).toBe(true)
expect(isLoopbackHost('127.0.0.1:8080')).toBe(true)
})
it('should reject external and LAN hostnames or IPs', () => {
expect(isLoopbackHost('192.168.1.1')).toBe(false)
expect(isLoopbackHost('example.com')).toBe(false)
expect(isLoopbackHost('0.0.0.0')).toBe(false)
expect(isLoopbackHost('10.0.0.1')).toBe(false)
})
})
describe('validateBearerToken', () => {
const sampleToken = 'test-token-value-12345'
it('should accept authorization headers with correct bearer token', () => {
expect(validateBearerToken(`Bearer ${sampleToken}`, sampleToken)).toBe(true)
})
it('should reject headers with incorrect token', () => {
expect(validateBearerToken(`Bearer wrong-token`, sampleToken)).toBe(false)
})
it('should reject headers with malformed format', () => {
expect(validateBearerToken(`Basic ${sampleToken}`, sampleToken)).toBe(false)
expect(validateBearerToken(sampleToken, sampleToken)).toBe(false)
expect(validateBearerToken('', sampleToken)).toBe(false)
})
})
describe('preventPathTraversal', () => {
const baseDir = '/workspace/app/dist'
it('should allow paths staying within the base directory', () => {
expect(preventPathTraversal(baseDir, 'index.html')).toBe(true)
expect(preventPathTraversal(baseDir, 'assets/main.js')).toBe(true)
})
it('should reject paths attempting to traverse out of the base directory', () => {
expect(preventPathTraversal(baseDir, '../index.html')).toBe(false)
expect(preventPathTraversal(baseDir, '../../etc/passwd')).toBe(false)
expect(preventPathTraversal(baseDir, 'assets/../../config.json')).toBe(false)
})
})
describe('validateHostAndOrigin', () => {
it('should allow local host and local origin', () => {
expect(validateHostAndOrigin('127.0.0.1', 'http://127.0.0.1:3000')).toBe(true)
expect(validateHostAndOrigin('localhost', 'http://localhost')).toBe(true)
expect(validateHostAndOrigin('localhost:5000', '')).toBe(true) // Optional origin
})
it('should reject non-local host or non-local origin', () => {
expect(validateHostAndOrigin('127.0.0.1', 'http://malicious.com')).toBe(false)
expect(validateHostAndOrigin('external.com', 'http://localhost')).toBe(false)
})
})
describe('validateHostAndOriginForAccess', () => {
it('should always allow loopback and only allow LAN hosts when enabled', () => {
expect(validateHostAndOriginForAccess('127.0.0.1:58785', undefined, false)).toBe(true)
expect(validateHostAndOriginForAccess('192.168.1.25:58785', undefined, false)).toBe(false)
expect(validateHostAndOriginForAccess('192.168.1.25:58785', 'http://192.168.1.25:58785', true)).toBe(true)
expect(validateHostAndOriginForAccess('192.168.1.25:58785', 'http://evil.test', true)).toBe(false)
})
})
describe('getLocalNetworkUrls', () => {
it('should build LAN URLs from non-internal IPv4 interfaces', () => {
const urls = getLocalNetworkUrls(58785, {
WiFi: [
{
address: '192.168.1.25',
family: 'IPv4',
internal: false,
netmask: '255.255.255.0',
mac: '00:00:00:00:00:01',
cidr: '192.168.1.25/24',
},
],
Loopback: [
{
address: '127.0.0.1',
family: 'IPv4',
internal: true,
netmask: '255.0.0.0',
mac: '00:00:00:00:00:00',
cidr: '127.0.0.1/8',
},
],
})
expect(urls).toEqual(['http://192.168.1.25:58785'])
})
it('should prefer physical LAN adapters over WSL and Hyper-V virtual adapters', () => {
const urls = getLocalNetworkUrls(58785, {
'vEthernet (WSL (Hyper-V firewall))': [
{
address: '172.18.192.1',
family: 'IPv4',
internal: false,
netmask: '255.255.240.0',
mac: '00:15:5d:56:b1:b1',
cidr: '172.18.192.1/20',
},
],
'vEthernet (Default Switch)': [
{
address: '172.25.224.1',
family: 'IPv4',
internal: false,
netmask: '255.255.240.0',
mac: '00:15:5d:15:fd:c0',
cidr: '172.25.224.1/20',
},
],
Ethernet: [
{
address: '192.168.0.150',
family: 'IPv4',
internal: false,
netmask: '255.255.255.0',
mac: '34:5a:60:fb:39:42',
cidr: '192.168.0.150/24',
},
],
})
expect(urls).toEqual(['http://192.168.0.150:58785'])
})
})
describe('validateSttBaseUrl', () => {
it('should allow only HTTP(S) base URLs', () => {
expect(validateSttBaseUrl('https://api.openai.com/v1')).toBe(true)
expect(validateSttBaseUrl('http://localhost:11434/v1')).toBe(true)
expect(validateSttBaseUrl('file:///tmp/key')).toBe(false)
expect(validateSttBaseUrl('not a url')).toBe(false)
})
})
describe('isRequestBodySizeAllowed', () => {
it('should allow small request content sizes', () => {
expect(isRequestBodySizeAllowed('100')).toBe(true)
expect(isRequestBodySizeAllowed('1048576')).toBe(true) // 1MB
})
it('should reject extremely large content sizes', () => {
expect(isRequestBodySizeAllowed('20000000')).toBe(false) // ~20MB
})
})
})
+172
View File
@@ -0,0 +1,172 @@
import * as path from 'node:path'
import * as os from 'node:os'
/**
* Checks if a host string matches a loopback host (localhost or 127.0.0.1 or ::1)
*/
export function isLoopbackHost(host: string | undefined): boolean {
if (!host) return false
// Handle bracketed IPv6 hosts (e.g. "[::1]" or "[::1]:3000")
if (host.startsWith('[')) {
const closingBracket = host.indexOf(']')
if (closingBracket !== -1) {
const hostname = host.slice(1, closingBracket)
return hostname === '::1'
}
}
// Handle port splitting for IPv4 or localhost
const lastColon = host.lastIndexOf(':')
let hostname = host
if (lastColon !== -1) {
const colonsCount = (host.match(/:/g) || []).length
if (colonsCount === 1 || host.includes('localhost')) {
hostname = host.slice(0, lastColon)
}
}
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
}
/**
* Validates the Authorization header against the expected bearer token
*/
export function validateBearerToken(authHeader: string | undefined, expectedToken: string): boolean {
if (!authHeader || !expectedToken) return false
if (!authHeader.startsWith('Bearer ')) return false
const token = authHeader.substring(7).trim()
return token === expectedToken
}
/**
* Prevents path traversal out of the designated base directory
*/
export function preventPathTraversal(baseDir: string, relativePath: string): boolean {
try {
const resolvedBase = path.resolve(baseDir)
const resolvedTarget = path.resolve(path.join(baseDir, relativePath))
return resolvedTarget.startsWith(resolvedBase)
} catch {
return false
}
}
/**
* Validates both Host and Origin headers to be local/loopback only
*/
export function validateHostAndOrigin(host: string | undefined, origin: string | undefined): boolean {
return validateHostAndOriginForAccess(host, origin, false)
}
function getHostname(host: string | undefined): string {
if (!host) return ''
if (host.startsWith('[')) {
const closingBracket = host.indexOf(']')
return closingBracket === -1 ? host : host.slice(1, closingBracket)
}
const lastColon = host.lastIndexOf(':')
const colonsCount = (host.match(/:/g) || []).length
if (lastColon !== -1 && colonsCount === 1) {
return host.slice(0, lastColon)
}
return host
}
function isAllowedLanHost(host: string | undefined): boolean {
const hostname = getHostname(host)
if (!hostname || isLoopbackHost(hostname)) return false
if (hostname === '0.0.0.0') return false
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname)) return true
return false
}
function isLikelyVirtualInterface(name: string, entry: os.NetworkInterfaceInfo): boolean {
const normalizedName = name.toLowerCase()
if (
normalizedName.includes('wsl') ||
normalizedName.includes('hyper-v') ||
normalizedName.includes('vethernet') ||
normalizedName.includes('default switch') ||
normalizedName.includes('docker') ||
normalizedName.includes('virtualbox') ||
normalizedName.includes('vmware') ||
normalizedName.includes('zerotier') ||
normalizedName.includes('tailscale')
) {
return true
}
// Hyper-V virtual adapters commonly use Microsoft's 00:15:5d OUI.
return entry.mac.toLowerCase().startsWith('00:15:5d:')
}
export function validateHostAndOriginForAccess(
host: string | undefined,
origin: string | undefined,
lanAccessEnabled: boolean,
): boolean {
const hostAllowed = isLoopbackHost(host) || (lanAccessEnabled && isAllowedLanHost(host))
if (!hostAllowed) return false
if (origin) {
try {
const url = new URL(origin)
const originAllowed = isLoopbackHost(url.host) || (lanAccessEnabled && isAllowedLanHost(url.host))
if (!originAllowed) return false
} catch {
return false
}
}
return true
}
export function getLocalNetworkUrls(
port: number,
interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces(),
): string[] {
const physicalUrls = new Set<string>()
const fallbackUrls = new Set<string>()
for (const [name, entries] of Object.entries(interfaces)) {
for (const entry of entries || []) {
if (entry.internal || entry.family !== 'IPv4') continue
if (!isAllowedLanHost(entry.address)) continue
const url = `http://${entry.address}:${port}`
if (isLikelyVirtualInterface(name, entry)) {
fallbackUrls.add(url)
} else {
physicalUrls.add(url)
}
}
}
const urls = physicalUrls.size > 0 ? physicalUrls : fallbackUrls
return Array.from(urls).sort()
}
export function validateSttBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false
try {
const url = new URL(baseUrl)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
/**
* Guards against oversized requests
*/
export function isRequestBodySizeAllowed(
contentLength: string | undefined,
maxBytes: number = 10 * 1024 * 1024,
): boolean {
if (!contentLength) return true
const size = parseInt(contentLength, 10)
if (isNaN(size)) return false
return size <= maxBytes
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,99 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createPiAgentUiSettingsStore, resolvePiAgentUiSettingsPaths } from './piagentui-settings-store.js'
const tempDirs: string[] = []
function makeTempHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'piagentui-settings-store-'))
tempDirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('PiAgentUI SQLite settings store', () => {
it('persists namespaced settings in a SQLite database independent of browser origin', () => {
const homeDir = makeTempHome()
const paths = resolvePiAgentUiSettingsPaths(homeDir)
const first = createPiAgentUiSettingsStore(paths)
first.saveSettings({
network: { lanAccessEnabled: true },
stt: {
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://127.0.0.1:8022/v1',
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
apiKeyRequired: false,
},
tts: {
enabled: true,
baseUrl: 'http://127.0.0.1:8022/v1',
model: 'speaches-ai/Kokoro-82M-v1.0-ONNX',
voice: 'pf_dora',
responseFormat: 'wav',
apiKeyRequired: false,
},
})
first.close()
const second = createPiAgentUiSettingsStore(paths)
expect(fs.existsSync(paths.databasePath)).toBe(true)
expect(second.getSettings()).toMatchObject({
network: { lanAccessEnabled: true },
stt: {
enabled: true,
baseUrl: 'http://127.0.0.1:8022/v1',
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
apiKeyRequired: false,
},
tts: {
enabled: true,
model: 'speaches-ai/Kokoro-82M-v1.0-ONNX',
voice: 'pf_dora',
responseFormat: 'wav',
apiKeyRequired: false,
},
})
second.close()
})
it('imports the legacy JSON settings file once into SQLite', () => {
const homeDir = makeTempHome()
const paths = resolvePiAgentUiSettingsPaths(homeDir)
fs.mkdirSync(path.dirname(paths.legacyJsonPath), { recursive: true })
fs.writeFileSync(
paths.legacyJsonPath,
JSON.stringify({
network: { lanAccessEnabled: true },
stt: {
enabled: true,
baseUrl: 'http://127.0.0.1:8022/v1',
apiKey: 'legacy-secret',
apiKeyRequired: false,
},
}),
'utf8',
)
const store = createPiAgentUiSettingsStore(paths)
expect(store.getSettings()).toMatchObject({
network: { lanAccessEnabled: true },
stt: {
enabled: true,
baseUrl: 'http://127.0.0.1:8022/v1',
apiKey: 'legacy-secret',
apiKeyRequired: false,
},
})
expect(store.getMeta('legacy_json_imported_at')).toEqual(expect.any(String))
store.close()
})
})
+214
View File
@@ -0,0 +1,214 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { createRequire } from 'node:module'
const nodeRequire = createRequire(import.meta.url)
const { DatabaseSync } = nodeRequire('node:sqlite') as typeof import('node:sqlite')
type DatabaseSyncInstance = InstanceType<typeof DatabaseSync>
export type SttProviderKind = 'openai' | 'openai-compatible'
export type SttMode = 'file' | 'realtime'
export type SttInsertMode = 'append' | 'replace'
export type TtsResponseFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
export type StoredSttSettings = {
enabled?: boolean
providerKind?: SttProviderKind
mode?: SttMode
baseUrl?: string
apiKey?: string
apiKeyRequired?: boolean
transcriptionEndpoint?: string
transcriptionModel?: string
language?: string
insertMode?: SttInsertMode
}
export type StoredTtsSettings = {
enabled?: boolean
providerKind?: SttProviderKind
baseUrl?: string
apiKey?: string
apiKeyRequired?: boolean
speechEndpoint?: string
model?: string
voice?: string
responseFormat?: TtsResponseFormat
speed?: number
}
export type PiAgentUiLocalSettings = {
schemaVersion?: number
network?: {
lanAccessEnabled?: boolean
}
stt?: StoredSttSettings
tts?: StoredTtsSettings
}
export interface PiAgentUiSettingsPaths {
databasePath: string
legacyJsonPath: string
}
type SettingsNamespace = 'network' | 'stt' | 'tts'
const SETTINGS_SCHEMA_VERSION = 1
const SETTINGS_KEYS: Record<SettingsNamespace, string> = {
network: 'access',
stt: 'settings',
tts: 'settings',
}
export function resolvePiAgentUiSettingsPaths(
homeDir = process.env.USERPROFILE || process.env.HOME || '',
): PiAgentUiSettingsPaths {
const settingsDir = path.join(homeDir, '.pi', 'agent')
return {
databasePath: path.join(settingsDir, 'piagentui.db'),
legacyJsonPath: path.join(settingsDir, 'piagentui-settings.json'),
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseJsonObject(value: string | undefined): Record<string, unknown> | undefined {
if (!value) return undefined
try {
const parsed = JSON.parse(value)
return isObject(parsed) ? parsed : undefined
} catch {
return undefined
}
}
function normalizeSettings(raw: unknown): PiAgentUiLocalSettings {
if (!isObject(raw)) return {}
const next: PiAgentUiLocalSettings = { schemaVersion: SETTINGS_SCHEMA_VERSION }
if (isObject(raw.network)) {
next.network = { lanAccessEnabled: raw.network.lanAccessEnabled === true }
}
if (isObject(raw.stt)) {
next.stt = raw.stt as StoredSttSettings
}
if (isObject(raw.tts)) {
next.tts = raw.tts as StoredTtsSettings
}
return next
}
function readLegacyJsonSettings(legacyJsonPath: string): PiAgentUiLocalSettings | null {
try {
if (!fs.existsSync(legacyJsonPath)) return null
return normalizeSettings(JSON.parse(fs.readFileSync(legacyJsonPath, 'utf8')))
} catch {
return null
}
}
export class PiAgentUiSettingsStore {
private readonly db: DatabaseSyncInstance
constructor(private readonly paths: PiAgentUiSettingsPaths) {
fs.mkdirSync(path.dirname(paths.databasePath), { recursive: true })
this.db = new DatabaseSync(paths.databasePath)
this.initialize()
this.importLegacyJsonOnce()
}
close(): void {
this.db.close()
}
getMeta(key: string): string | null {
const row = this.db.prepare('SELECT value FROM app_meta WHERE key = ?').get(key) as { value?: string } | undefined
return typeof row?.value === 'string' ? row.value : null
}
setMeta(key: string, value: string): void {
this.db
.prepare(
`INSERT INTO app_meta (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(key, value, Date.now())
}
getSettings(): PiAgentUiLocalSettings {
return {
schemaVersion: SETTINGS_SCHEMA_VERSION,
network: this.getNamespace('network') as PiAgentUiLocalSettings['network'],
stt: this.getNamespace('stt') as StoredSttSettings | undefined,
tts: this.getNamespace('tts') as StoredTtsSettings | undefined,
}
}
saveSettings(settings: PiAgentUiLocalSettings): void {
const normalized = normalizeSettings(settings)
this.db.exec('BEGIN')
try {
if (normalized.network) this.setNamespace('network', normalized.network)
if (normalized.stt) this.setNamespace('stt', normalized.stt)
if (normalized.tts) this.setNamespace('tts', normalized.tts)
this.setMeta('schema_version', String(SETTINGS_SCHEMA_VERSION))
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
private initialize(): void {
this.db.exec('PRAGMA journal_mode = WAL')
this.db.exec('PRAGMA foreign_keys = ON')
this.db.exec(`
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (namespace, key)
);
`)
this.setMeta('schema_version', String(SETTINGS_SCHEMA_VERSION))
}
private importLegacyJsonOnce(): void {
if (this.getMeta('legacy_json_imported_at')) return
const legacy = readLegacyJsonSettings(this.paths.legacyJsonPath)
if (!legacy) return
this.saveSettings(legacy)
this.setMeta('legacy_json_imported_at', new Date().toISOString())
}
private getNamespace(namespace: SettingsNamespace): Record<string, unknown> | undefined {
const row = this.db
.prepare('SELECT value_json FROM settings WHERE namespace = ? AND key = ?')
.get(namespace, SETTINGS_KEYS[namespace]) as { value_json?: string } | undefined
return parseJsonObject(row?.value_json)
}
private setNamespace(namespace: SettingsNamespace, value: unknown): void {
this.db
.prepare(
`INSERT INTO settings (namespace, key, value_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(namespace, key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
)
.run(namespace, SETTINGS_KEYS[namespace], JSON.stringify(value), Date.now())
}
}
export function createPiAgentUiSettingsStore(paths = resolvePiAgentUiSettingsPaths()): PiAgentUiSettingsStore {
return new PiAgentUiSettingsStore(paths)
}
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/piagentui-logo.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>Pi Agent</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#262524" />
<!-- iOS PWA -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Pi Agent" />
<link rel="apple-touch-icon" href="/piagentui-logo.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5378
View File
File diff suppressed because it is too large. Load diff
+10277
View File
File diff suppressed because it is too large. Load diff
+8808
View File
File diff suppressed because it is too large. Load diff
+127
View File
@@ -0,0 +1,127 @@
{
"name": "piagentui",
"version": "0.6.10",
"description": "Professional AI Agent Desktop & Web UI — sessions, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Works with Pi Agent, Claude Code, Codex, OpenCode, Cursor, and any SSE-compatible agent.",
"homepage": "https://github.com/lehhair/OpenCodeUI",
"repository": {
"type": "git",
"url": "https://github.com/lehhair/OpenCodeUI.git"
},
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc -b",
"typecheck:extensions": "tsc -p tsconfig.extension.json --noEmit",
"type-check": "npm run typecheck",
"build": "tsc -b && vite build",
"icons:tauri": "tauri icon src-tauri/app-icon.manifest.json -o src-tauri/icons",
"lint": "eslint .",
"format": "prettier . --write",
"format:check": "prettier . --check",
"test": "vitest",
"test:run": "vitest run",
"validate": "npm run typecheck && npm run typecheck:extensions && npm run lint && npm run test:run && npm run build",
"release:prepare": "node scripts/prepare-release.mjs",
"check": "npm run format:check && npm run lint && npm run test:run && npm run build",
"preview": "vite preview",
"tauri": "tauri",
"postinstall": "node scripts/copy-material-icons.mjs"
},
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1",
"@primer/octicons-react": "^19.28.0",
"@streamdown/math": "^1.0.2",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"clsx": "^2.1.1",
"diff": "^8.0.4",
"i18next": "^26.0.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^17.0.1",
"shiki": "^4.0.2",
"streamdown": "^2.5.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2.10.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/diff": "^7.0.2",
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.0.1",
"material-icon-theme": "^5.32.0",
"prettier": "^3.6.2",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.2",
"vite": "^8.0.3",
"vitest": "^4.1.2"
},
"pi": {
"extensions": [
"./extensions/piagentui-server.ts"
]
},
"keywords": [
"pi-package",
"pi-agent",
"pi-extension",
"agent-ui",
"chat-ui",
"mcp",
"voice-input",
"ai-agent-interface",
"coding-copilot",
"desktop-agent",
"tauri-app",
"claude-code-ui",
"codex-ui",
"opencode-ui",
"cursor-ui",
"copilot-ui",
"mcp-client",
"agent-workspace",
"session-manager",
"ai-chat-desktop"
],
"files": [
"dist",
"extensions",
"README.md",
"README.pt-BR.md",
"README.es.md",
"README.ja.md",
"README_EN.md",
"docs/superpowers/specs",
"package.json"
]
}
+6239
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
core-js: true
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<script>
// SPA redirect: GitHub Pages doesn't support client-side routing natively.
// This redirects all 404s back to index.html with the path preserved.
var pathSegmentsToKeep = 1 // '/OpenCodeUI' = 1 segment
var l = window.location
l.replace(
l.protocol +
'//' +
l.hostname +
(l.port ? ':' + l.port : '') +
l.pathname
.split('/')
.slice(0, 1 + pathSegmentsToKeep)
.join('/') +
'/?/' +
l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
l.hash,
)
</script>
</head>
</html>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "PiAgentUI",
"short_name": "PiAgentUI",
"description": "AI Coding Assistant",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#1a1a1a",
"theme_color": "#262524",
"icons": [
{
"src": "/piagentui-logo.png",
"sizes": "1254x1254",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 24 24"><path fill="#e53935" d="M9.87 2.5 3.022 5.666l.645 10.178zm4.26 0 6.202 13.344.645-10.178zM12 7.563l-2.451 5.964h4.906zm-3.73 8.959-.954 2.308L12 21.5l4.683-2.67-.953-2.308z"/></svg>

After

Width:  |  Height:  |  Size: 263 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff6e40" d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2v-1H2V2h2V1Zm10 0v1h2v12h-2v1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1ZM6 3c0 1 0 1-1 1v1h1v2h1V3Zm3 0a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm0 1h1v2H9ZM6 9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1Zm4 0c0 1 0 1-1 1v1h1v2h1V9Zm-4 1h1v2H6Z"/></svg>

After

Width:  |  Height:  |  Size: 404 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#757575" d="M15 2H6a2.006 2.006 0 0 0-2 2v22a2.006 2.006 0 0 0 2 2h6v-4H6v-2h6v-2H6v-2h6v-2H6v-2h6v-2h2V4l8 8h2v-1Z"/><path fill="#7c4dff" d="M12 12v18h18V12Zm10 16c-.9 0-2.025-1.267-2.025-3.005-.914 0-.975.464-.975 1.005-.881-.213-1-1.15-1-2h6c0 1.919-2 1.787-2 4m2.542-6a2.5 2.5 0 0 1-2.308-1.641l-.946-2.42a.305.305 0 0 0-.576 0l-.946 2.42A2.5 2.5 0 0 1 17.458 22H16l2.965-7.59a.63.63 0 0 1 .577-.41h2.916a.63.63 0 0 1 .577.41L26 22Z"/></svg>

After

Width:  |  Height:  |  Size: 517 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>

After

Width:  |  Height:  |  Size: 686 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ef5350" d="M16 2a14 14 0 1 0 14 14A14 14 0 0 0 16 2m6 10h-4v8a4 4 0 1 1-4-4 3.96 3.96 0 0 1 2 .555V8h6Z"/></svg>

After

Width:  |  Height:  |  Size: 185 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#fdd835" d="M18.23 11.21q-.045-.24-1.32-1.65c-.02-.19.29-.45.9-.8l1.74-1.55c.39-.5.62-1.28.69-2.38l-.02-.26c-.07-.78-.63-1.4-1.69-1.89-.63-.42-1.76-.65-3.38-.68-1.35.11-3.11.59-5.28 1.43-.6.43-1.28.86-2.04 1.28l.01.14.21-.08c.08-.01.13.03.14.11l.13-.07.07-.01.01.06c0 .07-.47.44-1.76 1.35l-.06.12c-.31.02-.61.25-.91.67l.08.12.25-.09.18.24c.32-.33.66-.62 1.03-.87.19.05.29.11.44.16 1.02-.75 2.03-1.3 3.04-1.64l.01.14c-.2.27-.32.42-.38.42l.1.23c.01.19-2.55 7-6.66 14.44l.08.19c.35-.08.58-.17.75-.26l.01.13.4-.03-.67 1.76.14.06c.57-.64 1-1.29 1.3-1.88 1.67-.49 2.94-.97 3.82-1.44.88-.08 1.56-.31 2.02-.7l.92-.47c1.27-.98 2.22-1.67 2.87-2.08 1.33-.98 2.2-1.93 2.6-2.85zm-3.46 2.31L13 14.91c-1.29.85-2 1.3-2.09 1.3-2.07 1.13-3.36 1.72-3.86 1.76l-.05.01c.04-.23.96-2.12 2.75-5.67.78-.06 2.02-.43 3.71-1.1l.41-.03c.85-.08 1.49.09 1.91.49l.03.26c-.31.9-.67 1.44-1.04 1.59m1.09-5.78q-.27.33-1.5 1.11c-.27.03-1.27.42-3.01 1.18l-.28-.05-.01-.12c-.02-.25.09-.57.34-.95.13-.7.28-1.12.44-1.2l1.45-3.28c-.02-.22.29-.35.93-.46l.21-.02.01.18 1.16-.16c1.15-.1 1.75.14 1.8.7l.13-.02-.03-.32.15-.02c.35.19.52.4.54.68.02.18-.08.41-.29.68-.09.01-.14-.06-.15-.18l-.14.01-.03.4c-.58.87-1.01 1.31-1.27 1.34z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#fff8e1" d="M30 17.045a9.8 9.8 0 0 0-.32-2.306l-.004.034a11.2 11.2 0 0 0-5.762-6.786c-3.495-1.89-5.243-3.326-6.8-3.811h.003c-1.95-.695-3.949.82-5.825 1.927-4.52 2.481-9.573 5.45-9.28 11.417.008-.029.017-.052.026-.08a9.97 9.97 0 0 0 3.934 7.257l-.01-.006C13.747 31.473 30.05 27.292 30 17.045"/><path fill="#37474f" d="M19.855 20.236A.8.8 0 0 0 19.26 20h-6.514a.8.8 0 0 0-.596.236.51.51 0 0 0-.137.463 4.37 4.37 0 0 0 1.641 2.339 4.2 4.2 0 0 0 2.349.926 4.2 4.2 0 0 0 2.343-.926 4.37 4.37 0 0 0 1.642-2.339.5.5 0 0 0-.132-.463Z"/><ellipse cx="22.5" cy="18.5" fill="#f8bbd0" rx="2.5" ry="1.5"/><ellipse cx="9.5" cy="18.5" fill="#f8bbd0" rx="2.5" ry="1.5"/><circle cx="10" cy="16" r="2" fill="#37474f"/><circle cx="22" cy="16" r="2" fill="#37474f"/><path fill="#455a64" d="M9.996 18A2 2 0 1 0 8 15.996V16a2 2 0 0 0 1.996 2"/><circle cx="9" cy="15" r="1" fill="#fafafa"/><circle cx="21" cy="15" r="1" fill="#fafafa"/></svg>

After

Width:  |  Height:  |  Size: 990 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>

After

Width:  |  Height:  |  Size: 245 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff5722" d="M2 2a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h6v3l2-1.25L12 14v-3h2a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Zm0 1h4v1H2Zm6 0 2 1.25L12 3v2.5l2 1-2 1V10l-2-1.25L8 10V7.5l-2-1 2-1zM2 5h3v1H2Zm0 2h3v1H2Zm0 2h4v1H2Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#8bc34a" d="M13 3a9 9 0 0 0-9 9H1l4 4 4-4H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z"/></svg>

After

Width:  |  Height:  |  Size: 314 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M11.94 2.984 2.928 21.017l9.875-8.47z"/><path fill="#e53935" d="m11.958 2.982.002.29 1.312 14.499-.002.006.023.26 7.363 2.978h.415l-.158-.31-.114-.228h-.001l-8.84-17.494z"/><path fill="#7cb342" d="m8.558 16.13-5.627 4.884h17.743v-.016z"/></svg>

After

Width:  |  Height:  |  Size: 328 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>

After

Width:  |  Height:  |  Size: 223 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>

After

Width:  |  Height:  |  Size: 325 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>

After

Width:  |  Height:  |  Size: 331 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M26 11.998v2h2v14H14v-2h-2v4L30 30V11.998Z"/><path fill="#7e57c2" d="M16 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 16 18h2v2h2v-2c0-.388-.562-.851-1.254-1.034C16.356 16.34 16 14.84 16 14m-3.254 2.966C10.356 16.34 10 14.84 10 14H8v-2H6v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M2 2v21.998h22V2Zm20 12h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 22 18v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>

After

Width:  |  Height:  |  Size: 687 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>

After

Width:  |  Height:  |  Size: 674 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>

After

Width:  |  Height:  |  Size: 691 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>

After

Width:  |  Height:  |  Size: 332 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#cfd8dc" d="M3.288 23.102A13.937 14.098 0 0 1 2 17.16c0-.553.028-1.09.091-1.622a14.049 14.211 0 0 1 .273-1.586 14 14.162 0 0 1 4.333-7.37 14.007 14.169 0 0 1 6.37-3.272 14.028 14.19 0 0 1 3.997-.27 13.958 14.12 0 0 1 4.76 1.24 14.021 14.183 0 0 1 3.241 2.089 14.028 14.19 0 0 1 4.732 8.355A14.063 14.225 0 0 1 30 17.161a14.175 14.339 0 0 1-.042 1.076 13.986 14.147 0 0 1-.847 3.902 14.007 14.169 0 0 1-2.779 4.574A7.245 7.329 0 0 1 21.166 29a5.054 5.112 0 0 1-4.816-6.444c.119-.453.434-1.317.889-1.699a5.334 5.396 0 0 1-1.435-.977c-.049-.057-.042-.156.007-.22a.182.184 0 0 1 .203-.056 10.206 10.324 0 0 0 1.596.41c.777.128 1.736.298 2.709.34 2.366.12 4.844-.956 5.614-3.094.763-2.146.469-4.263-2.289-5.53-2.758-1.275-4.032-2.783-6.258-3.696-1.456-.595-3.073-.241-4.732.694-4.48 2.5-8.491 10.408-6.643 17.737a.224.227 0 0 1-.364.22 14.077 14.24 0 0 1-1.463-1.927 14.021 14.183 0 0 1-.896-1.656M15.503 9.018c.749-.057 1.414.595 1.526 1.459.147 1.154-.273 2.35-1.645 2.379-1.183.02-1.54-1.176-1.456-1.905.077-.73.665-1.862 1.568-1.933z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M21.81 10.25c-.06-.04-.56-.43-1.64-.43-.28 0-.56.03-.84.08-.21-1.4-1.38-2.11-1.43-2.14l-.29-.17-.18.27c-.24.36-.43.77-.51 1.19-.2.8-.08 1.56.33 2.21-.49.28-1.29.35-1.46.35H2.62c-.34 0-.62.28-.62.63 0 1.15.18 2.3.58 3.38.45 1.19 1.13 2.07 2 2.61.98.6 2.59.94 4.42.94.79 0 1.61-.07 2.42-.22 1.12-.2 2.2-.59 3.19-1.16A8.3 8.3 0 0 0 16.78 16c1.05-1.17 1.67-2.5 2.12-3.65h.19c1.14 0 1.85-.46 2.24-.85.26-.24.45-.53.59-.87l.08-.24zm-17.96.99h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H3.85c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.43 0h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H6.28c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.47 0h1.75c.1 0 .17-.07.17-.16V9.5c0-.08-.06-.16-.17-.16H8.75c-.08 0-.15.07-.15.16v1.58c0 .09.06.16.15.16m2.44 0h1.77c.08 0 .15-.07.15-.16V9.5c0-.08-.06-.16-.15-.16h-1.77c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16M6.28 9h1.76c.08 0 .16-.09.16-.18V7.25c0-.09-.07-.16-.16-.16H6.28c-.09 0-.16.06-.16.16v1.57c.01.09.07.18.16.18m2.47 0h1.75c.1 0 .17-.09.17-.18V7.25c0-.09-.06-.16-.17-.16H8.75c-.08 0-.15.06-.15.16v1.57c0 .09.06.18.15.18m2.44 0h1.77c.08 0 .15-.09.15-.18V7.25c0-.09-.07-.16-.15-.16h-1.77c-.08 0-.15.06-.15.16v1.57c0 .09.07.18.15.18m0-2.28h1.77c.08 0 .15-.07.15-.16V5c0-.1-.07-.17-.15-.17h-1.77c-.08 0-.15.06-.15.17v1.56c0 .08.07.16.15.16m2.46 4.52h1.76c.09 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16h-1.76c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>

After

Width:  |  Height:  |  Size: 234 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>

After

Width:  |  Height:  |  Size: 290 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>

After

Width:  |  Height:  |  Size: 543 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#3f51b5" d="M22.713 4H9.287a.5.5 0 0 0-.432.248l-6.708 11.5a.5.5 0 0 0 0 .504l6.708 11.5a.5.5 0 0 0 .432.248h13.426a.5.5 0 0 0 .432-.248l6.708-11.5a.5.5 0 0 0 0-.504l-6.708-11.5A.5.5 0 0 0 22.713 4m-6.937 20.888-7.5-3.75A.5.5 0 0 1 8 20.691v-9.382a.5.5 0 0 1 .276-.447l7.5-3.75a.5.5 0 0 1 .448 0l7.5 3.75a.5.5 0 0 1 .276.447v9.382a.5.5 0 0 1-.276.447l-7.5 3.75a.5.5 0 0 1-.448 0"/><path fill="#7986cb" d="M22 19.441v-6.882a.5.5 0 0 0-.276-.447l-5.5-2.75a.5.5 0 0 0-.448 0l-5.5 2.75a.5.5 0 0 0-.276.447v6.882a.5.5 0 0 0 .276.447l5.5 2.75a.5.5 0 0 0 .448 0l5.5-2.75a.5.5 0 0 0 .276-.447"/></svg>

After

Width:  |  Height:  |  Size: 665 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>

After

Width:  |  Height:  |  Size: 319 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff9100" d="M18.217 8.974c-.45-.623-1.482-1.904-3.07-3.808-.689-.825-1.28-1.526-1.57-1.87l-.408-.48-.173-.205-.094-.11-.018-.027-.008-.004-.4-.47-.509.407a11.1 11.1 0 0 0-3.069 3.866 9.5 9.5 0 0 0-.87 2.647q-.06.303-.1.615a9 9 0 0 0-.577-.03 6.2 6.2 0 0 0-1.901.229l-.265.074-.136.238a8 8 0 0 0-1.044 3.68 8 8 0 0 0 5.006 7.697l.197.079.06.02h.002a8 8 0 0 0 2.452.473q.143.005.286.005a7.9 7.9 0 0 0 3.076-.618l.007.003.261-.12a7.99 7.99 0 0 0 4.643-6.981 8.5 8.5 0 0 0-1.778-5.31M9.837 19.82l-.192-.074-.051-.02a6.31 6.31 0 0 1-3.897-6.048 6.2 6.2 0 0 1 .697-2.667 4.6 4.6 0 0 1 .759-.103l.065-.002a8 8 0 0 1 .378 0c.108.005.215.021.322.034a13 13 0 0 0 .918 4.007 10.1 10.1 0 0 0 2.474 3.61 6.4 6.4 0 0 1-1.473 1.263m.351-5.486a11.4 11.4 0 0 1-.767-3.125 4.6 4.6 0 0 1 .95.461 4.73 4.73 0 0 1 1.94 2.884 5 5 0 0 1 .12.649 4.2 4.2 0 0 1-.288 2.023 8.3 8.3 0 0 1-1.955-2.892m1.741 5.858a8 8 0 0 0 .553-.62c.233.177.485.332.73.495a6.3 6.3 0 0 1-1.283.125m5.432-2.97a6.34 6.34 0 0 1-2.212 2.138 12.4 12.4 0 0 1-1.851-1.15 5.84 5.84 0 0 0 .309-3.998 6.02 6.02 0 0 0-2.504-3.664 6.1 6.1 0 0 0-1.679-.74 8 8 0 0 1 .064-.496 9 9 0 0 1 .465-1.598q.117-.298.253-.584l.004-.007c.14-.282.296-.567.481-.872l.073-.12h-.002a9.2 9.2 0 0 1 1.534-1.824l.227.269c.53.628 1.03 1.222 1.483 1.765 1.02 1.223 2.342 2.828 2.852 3.536a6.8 6.8 0 0 1 1.446 4.242 6.3 6.3 0 0 1-.943 3.104"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#8bc34a" d="M28.967 12H9.442a2 2 0 0 0-1.898 1.368L4 24V10h24a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464l-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h22l4.805-11.212A2 2 0 0 0 28.967 12"/><path fill="#dcedc8" d="M27.943 19.471 32 15.414 30.586 14l-4.333 4.333a11.01 11.01 0 0 0-10.505 0L11.414 14 10 15.414l4.057 4.057A10.98 10.98 0 0 0 10 28h22a10.98 10.98 0 0 0-4.057-8.529M18 26h-4v-4h4Zm10 0h-4v-4h4Z"/></svg>

After

Width:  |  Height:  |  Size: 503 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#8bc34a" d="m13.844 7.536-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h24a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464"/><path fill="#dcedc8" d="M27.943 19.471 32 15.414 30.586 14l-4.333 4.333a11.01 11.01 0 0 0-10.505 0L11.414 14 10 15.414l4.057 4.057A10.98 10.98 0 0 0 10 28h22a10.98 10.98 0 0 0-4.057-8.529M18 26h-4v-4h4Zm10 0h-4v-4h4Z"/></svg>

After

Width:  |  Height:  |  Size: 450 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#fbc02d" d="M28.967 12H9.442a2 2 0 0 0-1.898 1.368L4 24V10h24a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464l-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h22l4.805-11.212A2 2 0 0 0 28.967 12"/><path fill="#fffde7" d="M20 18h-4v2h6v-6h-2zm8 0v-4h-2v6h6v-2zm-12 8h4v4h2v-6h-6zm10 0v4h2v-4h4v-2h-6z"/></svg>

After

Width:  |  Height:  |  Size: 389 B

Loaded 100 of 983 files, more files were not shown because too many files have changed in this diff. Show more