diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9b89b69ae..def19c3e5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,5 +16,5 @@ jobs:
with:
go-version-file: go.mod
- - name: Build
+ - name: Build core binaries
run: make build-all
diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml
index e03357566..a2221bb70 100644
--- a/.github/workflows/create_dmg.yml
+++ b/.github/workflows/create_dmg.yml
@@ -17,29 +17,35 @@ jobs:
with:
ref: main
- # 1. 安装指定版本的 Go (可选,但推荐)
+ # 1. Install Go from go.mod
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- # 2. 安装 pnpm
- - name: Install pnpm
- run: brew install pnpm
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
- # 3. 运行你的 Makefile 编译二进制文件
+ - name: Setup pnpm
+ run: corepack enable && corepack install
+
+ # 3. Build the application bundle
- name: Build with Make
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
- # 4. 签名
+ # 4. Apply ad-hoc signing
- name: Ad-hoc Sign
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
- # 5. 安装打包工具
+ # 5. Install the DMG packaging tool
- name: Install create-dmg
run: brew install create-dmg
- # 6. 执行打包命令
+ # 6. Create the DMG
- name: Create DMG
run: |
mkdir -p dist
@@ -54,7 +60,7 @@ jobs:
"dist/picoclaw-${{ matrix.arch }}.dmg" \
"build/PicoClaw Launcher.app"
- # 7. 上传文件到 GitHub Artifacts (供你下载)
+ # 7. Upload the DMG as a GitHub artifact
- name: Upload DMG
uses: actions/upload-artifact@v7
with:
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index a5002fec5..f713c4db2 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -51,9 +51,11 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 22
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
- name: Setup pnpm
- run: corepack enable && corepack prepare pnpm@latest --activate
+ run: corepack enable && corepack install
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -75,6 +77,9 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Install zip
+ run: sudo apt-get install -y zip
+
- name: Create local tag for GoReleaser
run: git tag "${{ steps.version.outputs.version }}"
@@ -90,6 +95,7 @@ jobs:
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
+ INCLUDE_ANDROID_BUNDLE: "true"
NIGHTLY_BUILD: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
@@ -123,7 +129,7 @@ jobs:
# Collect release artifacts from goreleaser dist/
ASSETS=()
- for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
+ for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do
[ -f "$f" ] && ASSETS+=("$f")
done
@@ -135,4 +141,3 @@ jobs:
--prerelease \
--latest=false \
"${ASSETS[@]}"
-
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ce341770..41218032c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,9 +69,11 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 22
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
- name: Setup pnpm
- run: corepack enable && corepack prepare pnpm@latest --activate
+ run: corepack enable && corepack install
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -93,6 +95,9 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Install zip
+ run: sudo apt-get install -y zip
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
@@ -104,6 +109,7 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
+ INCLUDE_ANDROID_BUNDLE: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 9c26de34f..d8c51b069 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -9,11 +9,10 @@ git:
before:
hooks:
- - go mod tidy
- go generate ./...
- - sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
- - go install github.com/tc-hib/go-winres@latest
- - go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
+ - sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend'
+ - sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}'
+ - sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi'
builds:
- id: picoclaw
@@ -27,7 +26,7 @@ builds:
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -67,6 +66,10 @@ builds:
- stdjson
ldflags:
- -s -w
+ - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
+ - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -106,6 +109,10 @@ builds:
- stdjson
ldflags:
- -s -w
+ - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
+ - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -245,6 +252,8 @@ changelog:
release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
+ extra_files:
+ - glob: ./build/picoclaw-android-universal.zip
footer: >-
---
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ceff723d2..cbb6a6347 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -108,7 +108,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
- Reference the related issue when relevant: `Fix session leak (#123)`.
- Keep commits focused. One logical change per commit is preferred.
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
-- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
+- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
### Keeping Up to Date
diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md
index 196aecc65..ca6c66b3d 100644
--- a/CONTRIBUTING.zh.md
+++ b/CONTRIBUTING.zh.md
@@ -108,7 +108,7 @@ git checkout -b 你的功能分支名
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
- 保持 commit 专注,每个 commit 只做一件事。
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
-- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
+- 按照 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 规范来撰写
### 保持与上游同步
diff --git a/Makefile b/Makefile
index f7ebc7411..afaa7c29a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: all build install uninstall clean help test
+.PHONY: all build install uninstall clean help test build-all
# Build variables
BINARY_NAME=picoclaw
@@ -205,11 +205,44 @@ build-linux-mipsle: generate
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
+## build-android-arm64: Build core for Android ARM64
+build-android-arm64: generate
+ @echo "Building for android/arm64..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
+ @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
+
+## build-launcher-android-arm64: Build launcher for Android ARM64
+build-launcher-android-arm64:
+ @echo "Building picoclaw-launcher for android/arm64..."
+ @mkdir -p $(BUILD_DIR)
+ @$(MAKE) -C web build-android-arm64 \
+ OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
+ GO='$(GO)' \
+ LDFLAGS='$(LDFLAGS)'
+ @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
+
+## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
+build-android-bundle: generate
+ @echo "Building core for all Android architectures..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
+ @echo "Building launcher for Android arm64..."
+ @$(MAKE) build-launcher-android-arm64
+ @echo "Staging JNI libs..."
+ @rm -rf $(BUILD_DIR)/android-staging
+ @mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
+ @cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
+ @cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
+ @cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
+ @rm -rf $(BUILD_DIR)/android-staging
+ @echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
+
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
build-pi-zero: build-linux-arm build-linux-arm64
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
-## build-all: Build picoclaw for all platforms
+## build-all: Build the picoclaw core binary for all Makefile-managed platforms
build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
@@ -226,7 +259,7 @@ build-all: generate
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
- @echo "All builds complete"
+ @echo "Core builds complete"
## install: Install picoclaw to system and copy builtin skills
install: build
diff --git a/README.fr.md b/README.fr.md
index 3b2552f6d..570365d00 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -167,19 +167,27 @@ Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page
### Compiler depuis les sources (pour le développement)
+Prérequis :
+
+- Go 1.25+
+- Node.js 22+ avec Corepack activé pour les builds Web UI / launcher
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# Installer le gestionnaire de paquets frontend déclaré par le dépôt
+(cd web/frontend && corepack install)
+
# Compiler le binaire principal
make build
# Compiler le Web UI Launcher (requis pour le mode WebUI)
make build-launcher
-# Compiler pour plusieurs plateformes
+# Compiler les binaires core pour toutes les plateformes gérées par le Makefile
make build-all
# Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64)
@@ -620,5 +628,3 @@ Discord :
WeChat :
-
-
diff --git a/README.id.md b/README.id.md
index 5aa7b58f5..f4257f338 100644
--- a/README.id.md
+++ b/README.id.md
@@ -164,19 +164,27 @@ Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://gi
### Build dari source (untuk pengembangan)
+Prasyarat:
+
+- Go 1.25+
+- Node.js 22+ dengan Corepack aktif untuk build Web UI / launcher
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# Instal package manager frontend yang dideklarasikan repo
+(cd web/frontend && corepack install)
+
# Build binary inti
make build
# Build Web UI Launcher (diperlukan untuk mode WebUI)
make build-launcher
-# Build untuk berbagai platform
+# Build binary inti untuk semua platform yang dikelola Makefile
make build-all
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
diff --git a/README.it.md b/README.it.md
index 57dd014b3..b559cda2e 100644
--- a/README.it.md
+++ b/README.it.md
@@ -164,19 +164,27 @@ In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [Gi
### Compila dai sorgenti (per lo sviluppo)
+Prerequisiti:
+
+- Go 1.25+
+- Node.js 22+ con Corepack abilitato per le build Web UI / launcher
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# Installa il package manager frontend dichiarato dal repository
+(cd web/frontend && corepack install)
+
# Compila il binario core
make build
# Compila il Web UI Launcher (necessario per la modalità WebUI)
make build-launcher
-# Compila per più piattaforme
+# Compila i binari core per tutte le piattaforme gestite dal Makefile
make build-all
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
diff --git a/README.ja.md b/README.ja.md
index 64bff9ee9..0e6483be6 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -164,19 +164,27 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます!
### ソースからビルド(開発用)
+前提条件:
+
+- Go 1.25+
+- Web UI / launcher のビルドには Corepack を有効にした Node.js 22+
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# リポジトリで宣言されたフロントエンド用パッケージマネージャーをインストール
+(cd web/frontend && corepack install)
+
# コアバイナリをビルド
make build
# Web UI Launcher をビルド(WebUI モードに必要)
make build-launcher
-# 複数プラットフォーム向けビルド
+# Makefile が管理するすべてのプラットフォーム向けにコアバイナリをビルド
make build-all
# Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
diff --git a/README.ko.md b/README.ko.md
index 341c09812..e520ffd29 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -164,19 +164,27 @@ PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다!
### 소스에서 빌드(개발용)
+필수 사항:
+
+- Go 1.25+
+- Web UI / launcher 빌드를 위한 Corepack 활성화된 Node.js 22+
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# 저장소에 선언된 프런트엔드 패키지 매니저 설치
+(cd web/frontend && corepack install)
+
# 코어 바이너리 빌드
make build
# WebUI 런처 빌드 (WebUI 모드에 필요)
make build-launcher
-# 여러 플랫폼용 빌드
+# Makefile이 관리하는 모든 플랫폼용 코어 바이너리 빌드
make build-all
# Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64)
diff --git a/README.md b/README.md
index eb0d389d2..bbe48061a 100644
--- a/README.md
+++ b/README.md
@@ -164,22 +164,32 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
### Build from source (for development)
+Prerequisites:
+
+- Go 1.25+
+- Node.js 22+ with Corepack enabled for Web UI / launcher builds
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build core binary
+# Install frontend package manager declared by the repo
+(cd web/frontend && corepack install)
+
+# Build the core binary for the current platform
make build
-# Build Web UI Launcher (required for WebUI mode)
+# Build the Web UI Launcher (required for WebUI mode)
make build-launcher
-# Build for multiple platforms
+# Build core binaries for all Makefile-managed platforms
make build-all
-# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+# Build for Raspberry Pi Zero 2 W
+# 32-bit: make build-linux-arm
+# 64-bit: make build-linux-arm64
make build-pi-zero
# Build and install
@@ -215,7 +225,7 @@ picoclaw-launcher
-**Getting started:**
+**Getting started:**
Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat!
@@ -293,7 +303,7 @@ picoclaw-launcher-tui
-**Getting started:**
+**Getting started:**
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
@@ -368,7 +378,7 @@ This creates `~/.picoclaw/config.json` and the workspace directory.
```
> See `config/config.example.json` in the repo for a complete configuration template with all available options.
->
+>
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details.
diff --git a/README.my.md b/README.my.md
index f8e602f83..255773263 100644
--- a/README.my.md
+++ b/README.my.md
@@ -165,18 +165,26 @@ Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://git
### Bina dari sumber (untuk pembangunan)
+Prasyarat:
+
+- Go 1.25+
+- Node.js 22+ dengan Corepack diaktifkan untuk binaan Web UI / launcher
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# Pasang pengurus pakej frontend yang diisytiharkan oleh repositori
+(cd web/frontend && corepack install)
+
# Bina binari teras
make build
# Bina Pelancar Web UI (diperlukan untuk mod WebUI)
make build-launcher
-# Bina untuk pelbagai platform
+# Bina binari teras untuk semua platform yang diuruskan oleh Makefile
make build-all
# Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
diff --git a/README.pt-br.md b/README.pt-br.md
index 65d23d1d1..36d65d8c4 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -164,19 +164,27 @@ Alternativamente, baixe o binário para sua plataforma na página de [GitHub Rel
### Compilar a partir do código-fonte (para desenvolvimento)
+Pré-requisitos:
+
+- Go 1.25+
+- Node.js 22+ com Corepack habilitado para builds do Web UI / launcher
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# Instalar o gerenciador de pacotes de frontend declarado pelo repositório
+(cd web/frontend && corepack install)
+
# Compilar o binário principal
make build
# Compilar o Web UI Launcher (necessário para o modo WebUI)
make build-launcher
-# Compilar para múltiplas plataformas
+# Compilar os binários core para todas as plataformas gerenciadas pelo Makefile
make build-all
# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
diff --git a/README.vi.md b/README.vi.md
index 1d70d0615..67845d073 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -164,19 +164,27 @@ Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases
### Xây dựng từ mã nguồn (để phát triển)
+Yêu cầu:
+
+- Go 1.25+
+- Node.js 22+ với Corepack được bật cho các bản build Web UI / launcher
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build core binary
+# Cài đặt trình quản lý gói frontend được khai báo bởi repo
+(cd web/frontend && corepack install)
+
+# Build binary lõi
make build
-# Build Web UI Launcher (required for WebUI mode)
+# Build Web UI Launcher (cần cho chế độ WebUI)
make build-launcher
-# Build for multiple platforms
+# Build các binary lõi cho mọi nền tảng do Makefile quản lý
make build-all
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
diff --git a/README.zh.md b/README.zh.md
index e61ff7e28..329fedb86 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -164,19 +164,27 @@ PicoClaw 几乎可以部署在任何 Linux 设备上!
### 从源码构建(开发用)
+前置要求:
+
+- Go 1.25+
+- Node.js 22+,并启用 Corepack(用于 Web UI / launcher 构建)
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
+# 安装仓库声明的前端包管理器
+(cd web/frontend && corepack install)
+
# 构建核心二进制文件
make build
# 构建 Web UI Launcher(WebUI 模式必需)
make build-launcher
-# 为多平台构建
+# 为 Makefile 管理的所有平台构建核心二进制文件
make build-all
# 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64)
@@ -618,5 +626,3 @@ WeChat:
-
-
diff --git a/cmd/picoclaw/internal/cliui/cliui.go b/cmd/picoclaw/internal/cliui/cliui.go
new file mode 100644
index 000000000..b1ba636c9
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/cliui.go
@@ -0,0 +1,147 @@
+// Package cliui renders human-oriented CLI output: bordered panels and columns
+// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
+// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
+// stdout falls back to plain line-oriented output.
+package cliui
+
+import (
+ "os"
+ "sync"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/muesli/termenv"
+ "golang.org/x/term"
+)
+
+// Minimum terminal width (columns) for bordered / structured layout.
+// Below this, plain line-oriented output is used so boxes do not wrap badly.
+const minWidthFancy = 88
+
+// Minimum width to lay out some views in two columns (e.g. status providers).
+const minWidthColumns = 104
+
+var initMu sync.Mutex
+
+// Init configures lipgloss for this process. When disableAnsiColors is true
+// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
+// borders still render when UseFancyLayout() is true.
+func Init(disableAnsiColors bool) {
+ initMu.Lock()
+ defer initMu.Unlock()
+ if disableAnsiColors {
+ lipgloss.SetColorProfile(termenv.Ascii)
+ return
+ }
+ lipgloss.SetColorProfile(termenv.EnvColorProfile())
+}
+
+// StdoutWidth returns the terminal width or a sane default if unknown.
+func StdoutWidth() int {
+ w, _, err := term.GetSize(int(os.Stdout.Fd()))
+ if err != nil || w < 20 {
+ return 80
+ }
+ return w
+}
+
+// UseFancyLayout is true when styled boxes/columns should be used.
+func UseFancyLayout() bool {
+ if !term.IsTerminal(int(os.Stdout.Fd())) {
+ return false
+ }
+ return StdoutWidth() >= minWidthFancy
+}
+
+// UseColumnLayout is true when a second content column is viable.
+func UseColumnLayout() bool {
+ return UseFancyLayout() && StdoutWidth() >= minWidthColumns
+}
+
+// InnerWidth is the target content width inside borders/margins.
+func InnerWidth() int {
+ w := StdoutWidth()
+ // Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
+ const borderBudget = 8
+ if w > borderBudget+48 {
+ return w - borderBudget
+ }
+ return 48
+}
+
+// StderrWidth returns stderr terminal width or a sane default.
+func StderrWidth() int {
+ w, _, err := term.GetSize(int(os.Stderr.Fd()))
+ if err != nil || w < 20 {
+ return 80
+ }
+ return w
+}
+
+// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
+func UseFancyStderr() bool {
+ if !term.IsTerminal(int(os.Stderr.Fd())) {
+ return false
+ }
+ return StderrWidth() >= minWidthFancy
+}
+
+// InnerStderrWidth mirrors InnerWidth but for stderr.
+func InnerStderrWidth() int {
+ w := StderrWidth()
+ const borderBudget = 8
+ if w > borderBudget+48 {
+ return w - borderBudget
+ }
+ return 48
+}
+
+var (
+ accentBlue = lipgloss.Color("#3E5DB9")
+ accentRed = lipgloss.Color("#D54646")
+ colorMuted = lipgloss.Color("#6B6B6B")
+ colorOK = lipgloss.Color("#2E7D32")
+)
+
+func borderStyle() lipgloss.Style {
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(accentBlue).
+ Padding(0, 1)
+}
+
+func titleBarStyle() lipgloss.Style {
+ return lipgloss.NewStyle().
+ Foreground(accentRed).
+ Bold(true)
+}
+
+func mutedStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(colorMuted)
+}
+
+func bodyStyle() lipgloss.Style {
+ return lipgloss.NewStyle()
+}
+
+func kvKeyStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
+}
+
+func kvValStyle() lipgloss.Style {
+ return lipgloss.NewStyle()
+}
+
+// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
+func helpIntroStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
+}
+
+// helpIdentStyle is the left column for commands and flags (blue identifiers).
+func helpIdentStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
+}
+
+// helpPlaceholderStyle highlights in usage lines (red accent).
+func helpPlaceholderStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
+}
diff --git a/cmd/picoclaw/internal/cliui/cliui_test.go b/cmd/picoclaw/internal/cliui/cliui_test.go
new file mode 100644
index 000000000..c07e220ee
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/cliui_test.go
@@ -0,0 +1,180 @@
+package cliui
+
+import (
+ "testing"
+
+ flag "github.com/spf13/pflag"
+)
+
+func init() {
+ // Disable ANSI colors in tests so output is predictable plain text.
+ Init(true)
+}
+
+// ---------------------------------------------------------------------------
+// showErrHint
+// ---------------------------------------------------------------------------
+
+func TestShowErrHint(t *testing.T) {
+ cases := []struct {
+ msg string
+ want bool
+ }{
+ // Cobra flag errors — should show hint
+ {"unknown flag: --foo", true},
+ {"unknown shorthand flag: 'f' in -f", true},
+ {"flag needs an argument: --output", true},
+ {"required flag(s) \"model\" not set", true},
+ // Generic invalid-argument errors — should show hint
+ {"invalid argument \"abc\" for --count", true},
+ // required flag errors — should show hint
+ {"required flag(s) \"model\" not set", true},
+ // usage: in message — should show hint
+ {"bad input\nusage: picoclaw ...", true},
+ // Should NOT false-positive on broad words
+ {"connection flagged by remote", false},
+ {"feature flag not set", false},
+ {"invalid API key provided", false},
+ {"authentication required", false},
+ // Unrelated messages — no hint
+ {"something went wrong", false},
+ {"network timeout", false},
+ }
+
+ for _, tc := range cases {
+ got := showErrHint(tc.msg)
+ if got != tc.want {
+ t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// styleUsageTokens
+// ---------------------------------------------------------------------------
+
+func TestStyleUsageTokensContainsTokens(t *testing.T) {
+ cases := []struct {
+ input string
+ contains []string // substrings that must appear in plain output
+ }{
+ {
+ "picoclaw agent ",
+ []string{"picoclaw agent", ""},
+ },
+ {
+ "picoclaw [command] [flags]",
+ []string{"picoclaw", "[command]", "[flags]"},
+ },
+ {
+ "picoclaw",
+ []string{"picoclaw"},
+ },
+ {
+ "cmd [--flag]",
+ []string{"cmd", "", "[--flag]"},
+ },
+ }
+
+ for _, tc := range cases {
+ out := styleUsageTokens(tc.input)
+ for _, sub := range tc.contains {
+ if !containsStripped(out, sub) {
+ t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
+ }
+ }
+ }
+}
+
+// containsStripped checks whether plain contains sub after stripping ANSI escapes.
+// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
+// so this is just a plain substring check.
+func containsStripped(plain, sub string) bool {
+ return len(plain) >= len(sub) && findSubstring(plain, sub)
+}
+
+func findSubstring(s, sub string) bool {
+ for i := 0; i <= len(s)-len(sub); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
+
+// ---------------------------------------------------------------------------
+// collectFlagRows
+// ---------------------------------------------------------------------------
+
+func TestCollectFlagRows_Empty(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ rows := collectFlagRows(fs)
+ if len(rows) != 0 {
+ t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
+ }
+}
+
+func TestCollectFlagRows_BasicFlags(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.String("output", "", "output file path")
+ fs.Bool("verbose", false, "enable verbose mode")
+ fs.Int("count", 1, "number of items")
+
+ rows := collectFlagRows(fs)
+
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+
+ // Rows must be sorted alphabetically by flag name.
+ names := make([]string, 0, len(rows))
+ for _, r := range rows {
+ names = append(names, r[0])
+ }
+ if names[0] > names[1] || names[1] > names[2] {
+ t.Errorf("rows not sorted: %v", names)
+ }
+}
+
+func TestCollectFlagRows_Shorthand(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.StringP("model", "m", "", "model name")
+
+ rows := collectFlagRows(fs)
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row, got %d", len(rows))
+ }
+ left := rows[0][0]
+ if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
+ t.Errorf("expected shorthand and long form in %q", left)
+ }
+}
+
+func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.String("visible", "", "this shows up")
+ hidden := fs.String("hidden", "", "this should not show up")
+ _ = hidden
+ _ = fs.MarkHidden("hidden")
+
+ rows := collectFlagRows(fs)
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
+ }
+ if !findSubstring(rows[0][0], "visible") {
+ t.Errorf("expected visible flag in rows, got %q", rows[0][0])
+ }
+}
+
+func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ fs.String("format", "json", "output format: json or text")
+
+ rows := collectFlagRows(fs)
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row, got %d", len(rows))
+ }
+ if rows[0][1] != "output format: json or text" {
+ t.Errorf("expected usage in right column, got %q", rows[0][1])
+ }
+}
diff --git a/cmd/picoclaw/internal/cliui/help_cmd.go b/cmd/picoclaw/internal/cliui/help_cmd.go
new file mode 100644
index 000000000..72956afaa
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/help_cmd.go
@@ -0,0 +1,298 @@
+package cliui
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/spf13/cobra"
+ flag "github.com/spf13/pflag"
+)
+
+// RenderCommandHelp builds Ruff-style sectioned, two-column help when
+// UseFancyLayout(); otherwise plain Cobra-style text.
+func RenderCommandHelp(c *cobra.Command) string {
+ if !UseFancyLayout() {
+ return plainCommandHelp(c)
+ }
+ syncFlags(c)
+
+ var b strings.Builder
+ head, sub := helpIntro(c)
+ if head != "" {
+ b.WriteString(helpIntroStyle().Render(head))
+ b.WriteString("\n")
+ }
+ if sub != "" {
+ b.WriteString(mutedStyle().Render(sub))
+ b.WriteString("\n")
+ }
+ if head != "" || sub != "" {
+ b.WriteString("\n")
+ }
+
+ inner := InnerWidth()
+ contentW := inner - 6
+ if contentW < 36 {
+ contentW = 36
+ }
+
+ // Usage
+ usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
+ b.WriteString(sectionPanel("Usage", usageBody, inner))
+ b.WriteString("\n")
+
+ // Examples
+ if ex := strings.TrimSpace(c.Example); ex != "" {
+ exBody := bodyStyle().Width(contentW).Render(ex)
+ b.WriteString(sectionPanel("Examples", exBody, inner))
+ b.WriteString("\n")
+ }
+
+ // Subcommands
+ subs := visibleSubcommands(c)
+ if len(subs) > 0 {
+ rows := make([][2]string, 0, len(subs))
+ for _, sub := range subs {
+ left := sub.Name()
+ if a := sub.Aliases; len(a) > 0 {
+ left += " (" + strings.Join(a, ", ") + ")"
+ }
+ rows = append(rows, [2]string{left, sub.Short})
+ }
+ b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
+ b.WriteString("\n")
+ }
+
+ // Local options
+ local := c.LocalFlags()
+ opts := collectFlagRows(local)
+ if len(opts) > 0 {
+ title := "Options"
+ if !c.HasParent() {
+ title = "Flags"
+ }
+ b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
+ b.WriteString("\n")
+ }
+
+ // Global (inherited) options
+ if c.HasAvailableInheritedFlags() {
+ inh := collectFlagRows(c.InheritedFlags())
+ if len(inh) > 0 {
+ b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
+ b.WriteString("\n")
+ }
+ }
+
+ return b.String()
+}
+
+// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
+// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
+func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
+ if c == nil || outerW < 40 {
+ return ""
+ }
+ syncFlags(c)
+ contentW := outerW - 6
+ if contentW < 36 {
+ contentW = 36
+ }
+ var b strings.Builder
+ usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
+ b.WriteString(sectionPanel("Usage", usageBody, outerW))
+ b.WriteString("\n")
+ if len(c.Aliases) > 0 {
+ al := "Aliases: " + strings.Join(c.Aliases, ", ")
+ alBody := mutedStyle().MaxWidth(contentW).Render(al)
+ b.WriteString(sectionPanel("Aliases", alBody, outerW))
+ b.WriteString("\n")
+ }
+ opts := collectFlagRows(c.LocalFlags())
+ if len(opts) > 0 {
+ title := "Options"
+ if !c.HasParent() {
+ title = "Flags"
+ }
+ b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
+ b.WriteString("\n")
+ }
+ if c.HasAvailableInheritedFlags() {
+ inh := collectFlagRows(c.InheritedFlags())
+ if len(inh) > 0 {
+ b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
+ b.WriteString("\n")
+ }
+ }
+ return b.String()
+}
+
+func syncFlags(c *cobra.Command) {
+ _ = c.LocalFlags()
+ if c.HasAvailableInheritedFlags() {
+ _ = c.InheritedFlags()
+ }
+}
+
+func plainCommandHelp(c *cobra.Command) string {
+ desc := c.Long
+ if desc == "" {
+ desc = c.Short
+ }
+ desc = strings.TrimRight(desc, " \t\n\r")
+ var b strings.Builder
+ if desc != "" {
+ fmt.Fprintln(&b, desc)
+ fmt.Fprintln(&b)
+ }
+ if c.Runnable() || c.HasSubCommands() {
+ b.WriteString(c.UsageString())
+ }
+ return b.String()
+}
+
+func helpIntro(c *cobra.Command) (head, sub string) {
+ head = strings.TrimSpace(c.Short)
+ long := strings.TrimSpace(c.Long)
+ if long == "" || long == head {
+ return head, ""
+ }
+ lines := strings.Split(long, "\n")
+ var rest []string
+ for i, ln := range lines {
+ ln = strings.TrimSpace(ln)
+ if ln == "" {
+ continue
+ }
+ if i == 0 && ln == head {
+ continue
+ }
+ rest = append(rest, ln)
+ }
+ sub = strings.Join(rest, "\n")
+ return head, sub
+}
+
+func visibleSubcommands(c *cobra.Command) []*cobra.Command {
+ var out []*cobra.Command
+ for _, sub := range c.Commands() {
+ if sub.Hidden {
+ continue
+ }
+ out = append(out, sub)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
+ return out
+}
+
+func sectionPanel(title, body string, width int) string {
+ head := titleBarStyle().Render(title) + "\n\n"
+ return borderStyle().Width(width).Render(head + body)
+}
+
+// styleUsageTokens highlights PicoClaw-blue command tokens and red /[groups].
+func styleUsageTokens(s string) string {
+ var b strings.Builder
+ for len(s) > 0 {
+ ia := strings.Index(s, "<")
+ ib := strings.Index(s, "[")
+ next, kind := -1, 0 // 1 = angle, 2 = bracket
+ switch {
+ case ia >= 0 && (ib < 0 || ia < ib):
+ next, kind = ia, 1
+ case ib >= 0:
+ next, kind = ib, 2
+ }
+ if next < 0 {
+ b.WriteString(helpIdentStyle().Render(s))
+ break
+ }
+ if next > 0 {
+ b.WriteString(helpIdentStyle().Render(s[:next]))
+ }
+ s = s[next:]
+ if kind == 1 {
+ j := strings.Index(s, ">")
+ if j < 0 {
+ b.WriteString(helpIdentStyle().Render(s))
+ break
+ }
+ b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
+ s = s[j+1:]
+ continue
+ }
+ j := strings.Index(s, "]")
+ if j < 0 {
+ b.WriteString(helpIdentStyle().Render(s))
+ break
+ }
+ b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
+ s = s[j+1:]
+ }
+ return b.String()
+}
+
+func collectFlagRows(fs *flag.FlagSet) [][2]string {
+ var names []string
+ seen := map[string][2]string{}
+ fs.VisitAll(func(f *flag.Flag) {
+ if f.Hidden {
+ return
+ }
+ left := formatFlagLeft(f)
+ right := f.Usage
+ if f.Deprecated != "" {
+ right += " (deprecated: " + f.Deprecated + ")"
+ }
+ names = append(names, f.Name)
+ seen[f.Name] = [2]string{left, right}
+ })
+ sort.Strings(names)
+ rows := make([][2]string, 0, len(names))
+ for _, n := range names {
+ rows = append(rows, seen[n])
+ }
+ return rows
+}
+
+func formatFlagLeft(f *flag.Flag) string {
+ if len(f.Shorthand) > 0 {
+ return "-" + f.Shorthand + ", --" + f.Name
+ }
+ return "--" + f.Name
+}
+
+func renderTwoColPairs(rows [][2]string, contentW int) string {
+ if len(rows) == 0 {
+ return ""
+ }
+ leftW := 0
+ for _, r := range rows {
+ if w := lipgloss.Width(r[0]); w > leftW {
+ leftW = w
+ }
+ }
+ const minLeft, maxLeft = 16, 34
+ if leftW < minLeft {
+ leftW = minLeft
+ }
+ if leftW > maxLeft {
+ leftW = maxLeft
+ }
+ gap := " "
+ rightW := contentW - leftW - lipgloss.Width(gap)
+ if rightW < 24 {
+ rightW = 24
+ }
+
+ var b strings.Builder
+ for _, r := range rows {
+ left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
+ right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
+ b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
+ b.WriteString("\n")
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
diff --git a/cmd/picoclaw/internal/cliui/help_error.go b/cmd/picoclaw/internal/cliui/help_error.go
new file mode 100644
index 000000000..1e859b08f
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/help_error.go
@@ -0,0 +1,75 @@
+package cliui
+
+import (
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// FormatCLIError formats errors with the same boxed sections as help. When ctx
+// is the command that was running when the error occurred, Usage / Flags panels
+// are appended so styling matches picoclaw -h.
+func FormatCLIError(msg string, ctx *cobra.Command) string {
+ msg = strings.TrimRight(msg, "\n")
+ if !UseFancyStderr() {
+ s := "Error: " + msg + "\n"
+ if ctx != nil && showErrHint(msg) {
+ s += "\n" + plainCommandHelp(ctx)
+ }
+ return s
+ }
+ w := InnerStderrWidth()
+ contentW := w - 6
+ if contentW < 36 {
+ contentW = 36
+ }
+
+ title := titleBarStyle().Render("Error") + "\n\n"
+
+ paras := strings.Split(msg, "\n")
+ var body strings.Builder
+ for i, p := range paras {
+ p = strings.TrimRight(p, " ")
+ if p == "" {
+ continue
+ }
+ st := bodyStyle().Width(contentW)
+ if i > 0 {
+ body.WriteString("\n")
+ }
+ if i == 0 {
+ body.WriteString(st.Render(p))
+ } else {
+ body.WriteString(mutedStyle().Width(contentW).Render(p))
+ }
+ }
+
+ foot := ""
+ if showErrHint(msg) {
+ if ctx != nil {
+ foot = "\n\n" + mutedStyle().Width(contentW).
+ Render("Full command help: "+ctx.CommandPath()+" --help")
+ } else {
+ foot = "\n\n" + mutedStyle().Width(contentW).
+ Render("Tip: picoclaw --help · picoclaw --help")
+ }
+ }
+
+ out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
+ if ctx != nil && showErrHint(msg) {
+ if ref := RenderCommandQuickRef(ctx, w); ref != "" {
+ out += "\n" + ref
+ }
+ }
+ return out
+}
+
+func showErrHint(msg string) bool {
+ m := strings.ToLower(msg)
+ return strings.Contains(m, "unknown flag") ||
+ strings.Contains(m, "unknown shorthand flag") ||
+ strings.Contains(m, "flag needs an argument") ||
+ strings.Contains(m, "invalid argument") ||
+ strings.Contains(m, "required flag") ||
+ strings.Contains(m, "usage:")
+}
diff --git a/cmd/picoclaw/internal/cliui/onboard.go b/cmd/picoclaw/internal/cliui/onboard.go
new file mode 100644
index 000000000..e74cf68c6
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/onboard.go
@@ -0,0 +1,110 @@
+package cliui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
+func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
+ if !UseFancyLayout() {
+ printOnboardPlain(logo, encrypt, configPath)
+ return
+ }
+ printOnboardFancy(logo, encrypt, configPath)
+}
+
+func printOnboardPlain(logo string, encrypt bool, configPath string) {
+ fmt.Printf("\n%s picoclaw is ready!\n", logo)
+ fmt.Println("\nNext steps:")
+ if encrypt {
+ fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
+ fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS")
+ fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd")
+ fmt.Println("")
+ fmt.Println(" 2. Add your API key to", configPath)
+ } else {
+ fmt.Println(" 1. Add your API key to", configPath)
+ }
+ fmt.Println("")
+ fmt.Println(" Recommended:")
+ fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
+ fmt.Println(" - Ollama: https://ollama.com (local, free)")
+ fmt.Println("")
+ fmt.Println(" See README.md for 17+ supported providers.")
+ fmt.Println("")
+ if encrypt {
+ fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
+ } else {
+ fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
+ }
+}
+
+func printOnboardFancy(logo string, encrypt bool, configPath string) {
+ inner := InnerWidth()
+ box := borderStyle().MaxWidth(inner + 8)
+
+ ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
+ fmt.Println()
+ fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
+ fmt.Println()
+
+ steps := buildOnboardingSteps(encrypt, configPath)
+ rec := recommendedBlock()
+ chat := chatStep(encrypt)
+
+ if UseColumnLayout() {
+ leftW := min(inner/2-2, 52)
+ rightW := inner - leftW - 4
+ if rightW < 36 {
+ rightW = 36
+ }
+ leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
+ Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
+ rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
+ Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
+ gap := strings.Repeat(" ", 2)
+ fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
+ fmt.Println()
+ full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
+ fmt.Println(full)
+ return
+ }
+
+ // Same order as plain output: numbered steps → recommended → chat line.
+ next := titleBarStyle().Render("Next steps") + "\n\n" +
+ bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
+ fmt.Println(borderStyle().Width(inner).Render(next))
+}
+
+func buildOnboardingSteps(encrypt bool, configPath string) string {
+ var b strings.Builder
+ if encrypt {
+ b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
+ b.WriteString(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS\n")
+ b.WriteString(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd\n\n")
+ b.WriteString("2. Add your API key to\n ")
+ b.WriteString(configPath)
+ b.WriteString("\n")
+ } else {
+ b.WriteString("1. Add your API key to\n ")
+ b.WriteString(configPath)
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+func recommendedBlock() string {
+ return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
+ "• Ollama: https://ollama.com\n (local, free)\n\n" +
+ "See README.md for 17+ supported providers."
+}
+
+func chatStep(encrypt bool) string {
+ if encrypt {
+ return "3. Chat:\n picoclaw agent -m \"Hello!\""
+ }
+ return "2. Chat:\n picoclaw agent -m \"Hello!\""
+}
diff --git a/cmd/picoclaw/internal/cliui/status.go b/cmd/picoclaw/internal/cliui/status.go
new file mode 100644
index 000000000..f01fe296d
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/status.go
@@ -0,0 +1,168 @@
+package cliui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// ProviderRow holds one provider's display name and status value.
+type ProviderRow struct {
+ Name string
+ Val string
+}
+
+// StatusReport is a structured status view for PrintStatus.
+type StatusReport struct {
+ Logo string
+ Version string
+ Build string
+ ConfigPath string
+ ConfigOK bool
+ WorkspacePath string
+ WorkspaceOK bool
+ Model string
+ Providers []ProviderRow
+ OAuthLines []string // each full line "provider (method): state"
+}
+
+// PrintStatus renders picoclaw status (plain or fancy).
+func PrintStatus(r StatusReport) {
+ if !UseFancyLayout() {
+ printStatusPlain(r)
+ return
+ }
+ printStatusFancy(r)
+}
+
+func printStatusPlain(r StatusReport) {
+ fmt.Printf("%s picoclaw Status\n", r.Logo)
+ fmt.Printf("Version: %s\n", r.Version)
+ if r.Build != "" {
+ fmt.Printf("Build: %s\n", r.Build)
+ }
+ fmt.Println()
+
+ printPathLine("Config", r.ConfigPath, r.ConfigOK)
+ printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
+
+ if r.ConfigOK {
+ fmt.Printf("Model: %s\n", r.Model)
+ for _, p := range r.Providers {
+ fmt.Printf("%s: %s\n", p.Name, p.Val)
+ }
+ if len(r.OAuthLines) > 0 {
+ fmt.Println("\nOAuth/Token Auth:")
+ for _, line := range r.OAuthLines {
+ fmt.Printf(" %s\n", line)
+ }
+ }
+ }
+}
+
+func printPathLine(label, path string, ok bool) {
+ mark := "✗"
+ if ok {
+ mark = "✓"
+ }
+ fmt.Println(label+":", path, mark)
+}
+
+func printStatusFancy(r StatusReport) {
+ inner := InnerWidth()
+ topBox := borderStyle().Width(inner)
+
+ var head strings.Builder
+ head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
+ head.WriteString("\n\n")
+ head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
+ if r.Build != "" {
+ head.WriteString("\n")
+ head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
+ }
+ fmt.Println(topBox.Render(head.String()))
+ fmt.Println()
+
+ if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
+ leftW := (inner - 2) / 2
+ rightW := inner - leftW - 2
+ pathsNarrow := pathStatusPanel(r, leftW)
+ prov := providerTablePanel(r, rightW)
+ gap := strings.Repeat(" ", 2)
+ fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
+ } else {
+ fmt.Println(pathStatusPanel(r, inner))
+ if len(r.Providers) > 0 && r.ConfigOK {
+ fmt.Println(providerTablePanel(r, inner))
+ }
+ }
+
+ if len(r.OAuthLines) > 0 && r.ConfigOK {
+ var ob strings.Builder
+ ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
+ for _, line := range r.OAuthLines {
+ ob.WriteString(" • " + line + "\n")
+ }
+ fmt.Println()
+ fmt.Println(borderStyle().Width(inner).Render(ob.String()))
+ }
+}
+
+func pathStatusPanel(r StatusReport, inner int) string {
+ cfgMark := statusMark(r.ConfigOK)
+ wsMark := statusMark(r.WorkspaceOK)
+ var b strings.Builder
+ b.WriteString(kvKeyStyle().Render("Config") + "\n")
+ b.WriteString(mutedStyle().Render(r.ConfigPath))
+ b.WriteString(" " + cfgMark + "\n\n")
+ b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
+ b.WriteString(mutedStyle().Render(r.WorkspacePath))
+ b.WriteString(" " + wsMark + "\n")
+ if r.ConfigOK {
+ b.WriteString("\n")
+ b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
+ }
+ return borderStyle().Width(inner).Render(b.String())
+}
+
+func statusMark(ok bool) string {
+ if ok {
+ return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
+ }
+ return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
+}
+
+func providerTablePanel(r StatusReport, colW int) string {
+ if len(r.Providers) == 0 {
+ return ""
+ }
+ keyW := min(22, colW/3)
+ if keyW < 14 {
+ keyW = 14
+ }
+ valW := colW - keyW - 3
+ if valW < 12 {
+ valW = 12
+ }
+
+ var b strings.Builder
+ b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
+ for _, p := range r.Providers {
+ k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
+ v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
+ b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
+ b.WriteString("\n")
+ }
+ return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
+}
+
+func styleProviderVal(s string) lipgloss.Style {
+ if s == "✓" || strings.HasPrefix(s, "✓ ") {
+ return lipgloss.NewStyle().Foreground(colorOK)
+ }
+ if s == "not set" {
+ return mutedStyle()
+ }
+ return lipgloss.NewStyle()
+}
diff --git a/cmd/picoclaw/internal/cliui/version.go b/cmd/picoclaw/internal/cliui/version.go
new file mode 100644
index 000000000..7ecbdae7f
--- /dev/null
+++ b/cmd/picoclaw/internal/cliui/version.go
@@ -0,0 +1,61 @@
+package cliui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// PrintVersion prints version, optional build info, and Go toolchain line.
+func PrintVersion(logo, versionLine string, build, goVer string) {
+ if !UseFancyLayout() {
+ fmt.Printf("%s %s\n", logo, versionLine)
+ if build != "" {
+ fmt.Printf(" Build: %s\n", build)
+ }
+ if goVer != "" {
+ fmt.Printf(" Go: %s\n", goVer)
+ }
+ return
+ }
+
+ inner := InnerWidth()
+ box := borderStyle().Width(inner)
+
+ if UseColumnLayout() {
+ leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
+ rightW := inner - 16
+ rightStyle := kvValStyle().Width(rightW)
+
+ rows := [][]string{
+ {leftCol.Render("Version"), rightStyle.Render(versionLine)},
+ }
+ if build != "" {
+ rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
+ }
+ if goVer != "" {
+ rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
+ }
+ var body strings.Builder
+ for _, r := range rows {
+ body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
+ body.WriteString("\n")
+ }
+ header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
+ fmt.Println(box.Render(header + body.String()))
+ return
+ }
+
+ var lines []string
+ lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
+ lines = append(lines, "")
+ lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
+ if build != "" {
+ lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
+ }
+ if goVer != "" {
+ lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
+ }
+ fmt.Println(box.Render(strings.Join(lines, "\n")))
+}
diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go
index 626698fec..721d74552 100644
--- a/cmd/picoclaw/internal/onboard/helpers.go
+++ b/cmd/picoclaw/internal/onboard/helpers.go
@@ -9,6 +9,7 @@ import (
"golang.org/x/term"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
)
@@ -79,29 +80,7 @@ func onboard(encrypt bool) {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
- fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
- fmt.Println("\nNext steps:")
- if encrypt {
- fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
- fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS")
- fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd")
- fmt.Println("")
- fmt.Println(" 2. Add your API key to", configPath)
- } else {
- fmt.Println(" 1. Add your API key to", configPath)
- }
- fmt.Println("")
- fmt.Println(" Recommended:")
- fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
- fmt.Println(" - Ollama: https://ollama.com (local, free)")
- fmt.Println("")
- fmt.Println(" See README.md for 17+ supported providers.")
- fmt.Println("")
- if encrypt {
- fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
- } else {
- fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
- }
+ cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
}
// promptPassphrase reads the encryption passphrase twice from the terminal
diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go
index 43c5786a8..e8e4fee9a 100644
--- a/cmd/picoclaw/internal/status/helpers.go
+++ b/cmd/picoclaw/internal/status/helpers.go
@@ -3,8 +3,10 @@ package status
import (
"fmt"
"os"
+ "strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -17,43 +19,125 @@ func statusCmd() {
}
configPath := internal.GetConfigPath()
-
- fmt.Printf("%s picoclaw Status\n", internal.Logo)
- fmt.Printf("Version: %s\n", config.FormatVersion())
build, _ := config.FormatBuildInfo()
- if build != "" {
- fmt.Printf("Build: %s\n", build)
- }
- fmt.Println()
- if _, err := os.Stat(configPath); err == nil {
- fmt.Println("Config:", configPath, "✓")
- } else {
- fmt.Println("Config:", configPath, "✗")
- }
+ _, configStatErr := os.Stat(configPath)
+ configOK := configStatErr == nil
workspace := cfg.WorkspacePath()
- if _, err := os.Stat(workspace); err == nil {
- fmt.Println("Workspace:", workspace, "✓")
- } else {
- fmt.Println("Workspace:", workspace, "✗")
+ _, wsErr := os.Stat(workspace)
+ wsOK := wsErr == nil
+
+ report := cliui.StatusReport{
+ Logo: internal.Logo,
+ Version: config.FormatVersion(),
+ Build: build,
+ ConfigPath: configPath,
+ ConfigOK: configOK,
+ WorkspacePath: workspace,
+ WorkspaceOK: wsOK,
+ Model: cfg.Agents.Defaults.GetModelName(),
}
- if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
+ if configOK {
+ // PicoClaw moved to a model-centric configuration (model_list). Status should
+ // not depend on a legacy cfg.Providers field (which may not exist under some
+ // build tags). We infer provider availability from model_list entries.
+ hasProtocolKey := func(protocol string) bool {
+ prefix := protocol + "/"
+ for _, m := range cfg.ModelList {
+ if m == nil {
+ continue
+ }
+ if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
+ return true
+ }
+ }
+ return false
+ }
+ findLocalModelBase := func(modelName string) (string, bool) {
+ for _, m := range cfg.ModelList {
+ if m == nil {
+ continue
+ }
+ if m.ModelName == modelName && m.APIBase != "" {
+ return m.APIBase, true
+ }
+ }
+ return "", false
+ }
+ findProtocolBase := func(protocol string) (string, bool) {
+ prefix := protocol + "/"
+ for _, m := range cfg.ModelList {
+ if m == nil {
+ continue
+ }
+ if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
+ return m.APIBase, true
+ }
+ }
+ return "", false
+ }
+
+ hasOpenRouter := hasProtocolKey("openrouter")
+ hasAnthropic := hasProtocolKey("anthropic")
+ hasOpenAI := hasProtocolKey("openai")
+ hasGemini := hasProtocolKey("gemini")
+ hasZhipu := hasProtocolKey("zhipu")
+ hasQwen := hasProtocolKey("qwen")
+ hasGroq := hasProtocolKey("groq")
+ hasMoonshot := hasProtocolKey("moonshot")
+ hasDeepSeek := hasProtocolKey("deepseek")
+ hasVolcEngine := hasProtocolKey("volcengine")
+ hasNvidia := hasProtocolKey("nvidia")
+
+ // Local endpoints: allow both the special reserved name and protocol-based entries.
+ vllmBase, hasVLLM := findLocalModelBase("local-model")
+ if !hasVLLM {
+ vllmBase, hasVLLM = findProtocolBase("vllm")
+ }
+ ollamaBase, hasOllama := findProtocolBase("ollama")
+
+ val := func(enabled bool, extra ...string) string {
+ if enabled {
+ if len(extra) > 0 && extra[0] != "" {
+ return "✓ " + extra[0]
+ }
+ return "✓"
+ }
+ return "not set"
+ }
+
+ report.Providers = []cliui.ProviderRow{
+ {Name: "OpenRouter API", Val: val(hasOpenRouter)},
+ {Name: "Anthropic API", Val: val(hasAnthropic)},
+ {Name: "OpenAI API", Val: val(hasOpenAI)},
+ {Name: "Gemini API", Val: val(hasGemini)},
+ {Name: "Zhipu API", Val: val(hasZhipu)},
+ {Name: "Qwen API", Val: val(hasQwen)},
+ {Name: "Groq API", Val: val(hasGroq)},
+ {Name: "Moonshot API", Val: val(hasMoonshot)},
+ {Name: "DeepSeek API", Val: val(hasDeepSeek)},
+ {Name: "VolcEngine API", Val: val(hasVolcEngine)},
+ {Name: "Nvidia API", Val: val(hasNvidia)},
+ {Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
+ {Name: "Ollama", Val: val(hasOllama, ollamaBase)},
+ }
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
- fmt.Println("\nOAuth/Token Auth:")
for provider, cred := range store.Credentials {
- status := "authenticated"
+ st := "authenticated"
if cred.IsExpired() {
- status = "expired"
+ st = "expired"
} else if cred.NeedsRefresh() {
- status = "needs refresh"
+ st = "needs refresh"
}
- fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
+ report.OAuthLines = append(report.OAuthLines,
+ fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
}
}
}
+
+ cliui.PrintStatus(report)
}
diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go
index 71c7dd2f8..81da4b878 100644
--- a/cmd/picoclaw/internal/version/command.go
+++ b/cmd/picoclaw/internal/version/command.go
@@ -1,11 +1,10 @@
package version
import (
- "fmt"
-
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command {
}
func printVersion() {
- fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
build, goVer := config.FormatBuildInfo()
- if build != "" {
- fmt.Printf(" Build: %s\n", build)
- }
- if goVer != "" {
- fmt.Printf(" Go: %s\n", goVer)
- }
+ cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer)
}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 543577e68..0867203a6 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -16,6 +16,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
+ "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
@@ -28,15 +29,57 @@ import (
"github.com/sipeed/picoclaw/pkg/updater"
)
+var rootNoColor bool
+
+func syncCliUIColor(root *cobra.Command) {
+ no, _ := root.PersistentFlags().GetBool("no-color")
+ cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
+}
+
+// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
+func earlyColorDisabled() bool {
+ if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
+ return true
+ }
+ for i := 1; i < len(os.Args); i++ {
+ arg := os.Args[i]
+ if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
+ return true
+ }
+ }
+ return false
+}
+
func NewPicoclawCommand() *cobra.Command {
- short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
+ short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
+ long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
+
+Version: %s`, internal.Logo, config.FormatVersion())
cmd := &cobra.Command{
- Use: "picoclaw",
- Short: short,
- Example: "picoclaw version",
+ Use: "picoclaw",
+ Short: short,
+ Long: long,
+ Example: `picoclaw version
+picoclaw onboard
+picoclaw --no-color status`,
+ SilenceErrors: true,
+ // Avoid plain UsageString() on stderr/stdout when a command fails; cliui
+ // renders matching panels on stderr instead.
+ SilenceUsage: true,
+ PersistentPreRun: func(c *cobra.Command, _ []string) {
+ syncCliUIColor(c.Root())
+ },
}
+ cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
+ "Disable colors (boxed layout unchanged)")
+
+ cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
+ syncCliUIColor(c.Root())
+ fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
+ })
+
cmd.AddCommand(
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
@@ -65,17 +108,31 @@ const (
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\033[0m\r\n"
+ plainBanner = "\r\n" +
+ "██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
+ "██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
+ "██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
+ "██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
+ "██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
+ "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
+ "\r\n"
)
func main() {
- fmt.Printf("%s", banner)
+ cliui.Init(earlyColorDisabled())
- tz_env := os.Getenv("TZ")
- if tz_env != "" {
- fmt.Println("TZ environment:", tz_env)
- zoneinfo_env := os.Getenv("ZONEINFO")
- fmt.Println("ZONEINFO environment:", zoneinfo_env)
- loc, err := time.LoadLocation(tz_env)
+ if earlyColorDisabled() {
+ fmt.Print(plainBanner)
+ } else {
+ fmt.Printf("%s", banner)
+ }
+
+ tzEnv := os.Getenv("TZ")
+ if tzEnv != "" {
+ fmt.Println("TZ environment:", tzEnv)
+ zoneinfoEnv := os.Getenv("ZONEINFO")
+ fmt.Println("ZONEINFO environment:", zoneinfoEnv)
+ loc, err := time.LoadLocation(tzEnv)
if err != nil {
fmt.Println("Error loading time zone:", err)
} else {
@@ -85,7 +142,10 @@ func main() {
}
cmd := NewPicoclawCommand()
- if err := cmd.Execute(); err != nil {
+ last, err := cmd.ExecuteC()
+ if err != nil {
+ syncCliUIColor(cmd)
+ fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
os.Exit(1)
}
}
diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go
index 3e147cbfe..309e60ba9 100644
--- a/cmd/picoclaw/main_test.go
+++ b/cmd/picoclaw/main_test.go
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"slices"
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) {
require.NotNil(t, cmd)
- short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
+ short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
+ longHas := strings.Contains(cmd.Long, config.FormatVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)
+ assert.True(t, longHas)
assert.True(t, cmd.HasSubCommands())
assert.True(t, cmd.HasAvailableSubCommands())
- assert.False(t, cmd.HasFlags())
+ assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
assert.Nil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
- assert.Nil(t, cmd.PersistentPreRun)
+ assert.NotNil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
allowedCommands := []string{
diff --git a/go.mod b/go.mod
index 9eaa72a0b..b7259bde7 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
@@ -25,6 +26,7 @@ require (
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
+ github.com/muesli/termenv v0.16.0
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/mymmrac/telego v1.8.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
@@ -35,6 +37,7 @@ require (
github.com/rs/zerolog v1.35.0
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
+ github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/util v0.9.7
@@ -65,7 +68,12 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/x/ansi v0.8.0 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -79,6 +87,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
@@ -88,10 +97,10 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
- github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
diff --git a/go.sum b/go.sum
index 6a2194960..8306976c4 100644
--- a/go.sum
+++ b/go.sum
@@ -55,6 +55,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -67,6 +69,16 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -179,12 +191,16 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
@@ -218,6 +234,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -276,6 +293,8 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 97e5dd7a7..53c245568 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -105,6 +105,8 @@ const (
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
handledToolResponseSummary = "Requested output delivered via tool attachment."
sessionKeyAgentPrefix = "agent:"
+ metadataKeyMessageKind = "message_kind"
+ messageKindThought = "thought"
metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id"
metadataKeyTeamID = "team_id"
@@ -1718,6 +1720,41 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
return ""
}
+func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) {
+ if reasoningContent == "" || chatID == "" {
+ return
+ }
+
+ if ctx.Err() != nil {
+ return
+ }
+
+ pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
+ defer pubCancel()
+
+ if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
+ Channel: "pico",
+ ChatID: chatID,
+ Content: reasoningContent,
+ Metadata: map[string]string{
+ metadataKeyMessageKind: messageKindThought,
+ },
+ }); err != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
+ errors.Is(err, bus.ErrBusClosed) {
+ logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{
+ "channel": "pico",
+ "error": err.Error(),
+ })
+ } else {
+ logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{
+ "channel": "pico",
+ "error": err.Error(),
+ })
+ }
+ }
+}
+
func (al *AgentLoop) handleReasoning(
ctx context.Context,
reasoningContent, channelName, channelID string,
@@ -2319,12 +2356,16 @@ turnLoop:
if reasoningContent == "" {
reasoningContent = response.ReasoningContent
}
- go al.handleReasoning(
- turnCtx,
- reasoningContent,
- ts.channel,
- al.targetReasoningChannelID(ts.channel),
- )
+ if ts.channel == "pico" {
+ go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
+ } else {
+ go al.handleReasoning(
+ turnCtx,
+ reasoningContent,
+ ts.channel,
+ al.targetReasoningChannelID(ts.channel),
+ )
+ }
al.emitEvent(
EventKindLLMResponse,
ts.eventMeta("runTurn", "turn.llm.response"),
@@ -2373,7 +2414,7 @@ turnLoop:
if len(response.ToolCalls) == 0 || gracefulTerminal {
responseContent := response.Content
- if responseContent == "" && response.ReasoningContent != "" {
+ if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" {
responseContent = response.ReasoningContent
}
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 7e6f89b05..7616aa6f6 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -1972,7 +1972,7 @@ func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) {
},
{
ModelName: "gemma-fallback",
- Model: "gemini/gemma-3-27b-it",
+ Model: "openrouter/gemma-3-27b-it",
APIBase: fallbackServer.URL,
APIKeys: config.SimpleSecureStrings("fallback-key"),
Workspace: workspace,
@@ -2711,6 +2711,62 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
}
}
+func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
+ tmpDir := t.TempDir()
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ ModelName: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider := &reasoningContentProvider{
+ response: "final answer",
+ reasoningContent: "thinking trace",
+ }
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ response, err := al.processMessage(context.Background(), bus.InboundMessage{
+ Channel: "pico",
+ SenderID: "user1",
+ ChatID: "pico:test-session",
+ Content: "hello",
+ })
+ if err != nil {
+ t.Fatalf("processMessage() error = %v", err)
+ }
+ if response != "final answer" {
+ t.Fatalf("processMessage() response = %q, want %q", response, "final answer")
+ }
+
+ var thoughtMsg *bus.OutboundMessage
+ deadline := time.After(3 * time.Second)
+
+ for thoughtMsg == nil {
+ select {
+ case outbound := <-msgBus.OutboundChan():
+ msg := outbound
+ if msg.Content == "thinking trace" {
+ thoughtMsg = &msg
+ }
+ case <-deadline:
+ t.Fatal("expected thought outbound message for pico")
+ }
+ }
+
+ if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" {
+ t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID)
+ }
+ if thoughtMsg.Metadata[metadataKeyMessageKind] != messageKindThought {
+ t.Fatalf("thought metadata kind = %q, want %q", thoughtMsg.Metadata[metadataKeyMessageKind], messageKindThought)
+ }
+}
+
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go
index b4bfd09e5..bf3e38cf4 100644
--- a/pkg/channels/pico/client.go
+++ b/pkg/channels/pico/client.go
@@ -242,7 +242,11 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) {
}
func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
- content, _ := msg.Payload["content"].(string)
+ if isThoughtPayload(msg.Payload) {
+ return
+ }
+
+ content, _ := msg.Payload[PayloadKeyContent].(string)
if strings.TrimSpace(content) == "" {
return
}
@@ -285,7 +289,7 @@ func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) (
}
outMsg := newMessage(TypeMessageSend, map[string]any{
- "content": msg.Content,
+ PayloadKeyContent: msg.Content,
})
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
return nil, pc.writeJSON(outMsg)
diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go
index b40606647..732589432 100644
--- a/pkg/channels/pico/client_test.go
+++ b/pkg/channels/pico/client_test.go
@@ -316,3 +316,67 @@ func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
t.Fatal("timed out waiting for inbound media message")
}
}
+
+func TestIsThoughtPayload(t *testing.T) {
+ tests := []struct {
+ name string
+ payload map[string]any
+ want bool
+ }{
+ {
+ name: "explicit thought bool",
+ payload: map[string]any{PayloadKeyThought: true},
+ want: true,
+ },
+ {
+ name: "thought false",
+ payload: map[string]any{PayloadKeyThought: false},
+ want: false,
+ },
+ {
+ name: "thought string ignored",
+ payload: map[string]any{PayloadKeyThought: "true"},
+ want: false,
+ },
+ {
+ name: "default normal",
+ payload: map[string]any{PayloadKeyContent: "hello"},
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isThoughtPayload(tt.payload); got != tt.want {
+ t.Fatalf("isThoughtPayload() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) {
+ mb := bus.NewMessageBus()
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: "ws://localhost:8080/ws",
+ }, mb)
+ if err != nil {
+ t.Fatalf("NewPicoClientChannel() error = %v", err)
+ }
+
+ ch.ctx = context.Background()
+ pc := &picoConn{sessionID: "sess-thought"}
+
+ ch.handleServerMessage(pc, PicoMessage{
+ Type: TypeMessageCreate,
+ Payload: map[string]any{
+ PayloadKeyContent: "internal reasoning",
+ PayloadKeyThought: true,
+ },
+ })
+
+ select {
+ case msg := <-mb.InboundChan():
+ t.Fatalf("expected no inbound publish for thought payload, got %+v", msg)
+ case <-time.After(150 * time.Millisecond):
+ }
+}
diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go
index e22da1ba1..6525c2d4a 100644
--- a/pkg/channels/pico/pico.go
+++ b/pkg/channels/pico/pico.go
@@ -39,6 +39,13 @@ var allowedInlineImageMIMETypes = map[string]struct{}{
"image/bmp": {},
}
+func outboundMessageIsThought(metadata map[string]string) bool {
+ if len(metadata) == 0 {
+ return false
+ }
+ return strings.EqualFold(strings.TrimSpace(metadata["message_kind"]), MessageKindThought)
+}
+
// writeJSON sends a JSON message to the connection with write locking.
func (pc *picoConn) writeJSON(v any) error {
if pc.closed.Load() {
@@ -247,9 +254,11 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
+ isThought := outboundMessageIsThought(msg.Metadata)
outMsg := newMessage(TypeMessageCreate, map[string]any{
- "content": msg.Content,
+ PayloadKeyContent: msg.Content,
+ PayloadKeyThought: isThought,
})
return nil, c.broadcastToSession(msg.ChatID, outMsg)
@@ -288,8 +297,9 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
- "content": text,
- "message_id": msgID,
+ PayloadKeyContent: text,
+ PayloadKeyThought: false,
+ "message_id": msgID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {
diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go
index 3f8ba8643..ecdc2d140 100644
--- a/pkg/channels/pico/protocol.go
+++ b/pkg/channels/pico/protocol.go
@@ -19,6 +19,11 @@ const (
TypePong = "pong"
PicoTokenPrefix = "pico-"
+
+ PayloadKeyContent = "content"
+ PayloadKeyThought = "thought"
+
+ MessageKindThought = "thought"
)
// PicoMessage is the wire format for all Pico Protocol messages.
@@ -39,6 +44,11 @@ func newMessage(msgType string, payload map[string]any) PicoMessage {
}
}
+func isThoughtPayload(payload map[string]any) bool {
+ thought, _ := payload[PayloadKeyThought].(bool)
+ return thought
+}
+
func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage {
payload := map[string]any{
"code": code,
diff --git a/pkg/gateway/channel_matrix.go b/pkg/gateway/channel_matrix.go
index a46addae1..b6adbe498 100644
--- a/pkg/gateway/channel_matrix.go
+++ b/pkg/gateway/channel_matrix.go
@@ -1,4 +1,4 @@
-//go:build !mipsle && !netbsd && !(freebsd && arm)
+//go:build !mipsle && !netbsd && !(freebsd && arm) && !android
package gateway
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
index 8a1890212..b5ab847d5 100644
--- a/pkg/providers/antigravity_provider.go
+++ b/pkg/providers/antigravity_provider.go
@@ -389,6 +389,7 @@ type antigravityJSONResponse struct {
Content struct {
Parts []struct {
Text string `json:"text,omitempty"`
+ Thought bool `json:"thought,omitempty"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
@@ -406,6 +407,7 @@ type antigravityJSONResponse struct {
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
var contentParts []string
+ var reasoningParts []string
var toolCalls []ToolCall
var usage *UsageInfo
var finishReason string
@@ -433,7 +435,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
for _, candidate := range resp.Candidates {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
- contentParts = append(contentParts, part.Text)
+ if part.Thought {
+ reasoningParts = append(reasoningParts, part.Text)
+ } else {
+ contentParts = append(contentParts, part.Text)
+ }
}
if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
@@ -475,10 +481,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
}
return &LLMResponse{
- Content: strings.Join(contentParts, ""),
- ToolCalls: toolCalls,
- FinishReason: mappedFinish,
- Usage: usage,
+ Content: strings.Join(contentParts, ""),
+ ReasoningContent: strings.Join(reasoningParts, ""),
+ ToolCalls: toolCalls,
+ FinishReason: mappedFinish,
+ Usage: usage,
}, nil
}
diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/antigravity_provider_test.go
index 238765321..9155e2d56 100644
--- a/pkg/providers/antigravity_provider_test.go
+++ b/pkg/providers/antigravity_provider_test.go
@@ -54,3 +54,27 @@ func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
t.Fatalf("expected inferred tool name search_docs, got %q", got)
}
}
+
+func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) {
+ p := &AntigravityProvider{}
+ body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" +
+ "data: [DONE]\n"
+
+ resp, err := p.parseSSEResponse(body)
+ if err != nil {
+ t.Fatalf("parseSSEResponse() error = %v", err)
+ }
+
+ if resp.Content != "visible answer" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "visible answer")
+ }
+ if resp.ReasoningContent != "hidden reasoning" {
+ t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden reasoning")
+ }
+ if resp.FinishReason != "stop" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "stop")
+ }
+ if resp.Usage == nil || resp.Usage.TotalTokens != 216 {
+ t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216)
+ }
+}
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index f13dc646c..ab68b326a 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -114,7 +114,7 @@ func ResolveAPIBase(cfg *config.ModelConfig) string {
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
-// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),
+// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq),
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
// See the switch on protocol in this function for the authoritative list.
// Returns the provider, the model ID (without protocol prefix), and any error.
@@ -218,7 +218,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
- case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice",
+ case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
@@ -242,6 +242,24 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.CustomHeaders,
), modelID, nil
+ case "gemini":
+ if cfg.APIKey() == "" && cfg.APIBase == "" {
+ return nil, "", fmt.Errorf("api_key or api_base is required for gemini protocol (model: %s)", cfg.Model)
+ }
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = getDefaultAPIBase(protocol)
+ }
+ return NewGeminiProvider(
+ cfg.APIKey(),
+ apiBase,
+ cfg.Proxy,
+ userAgent,
+ cfg.RequestTimeout,
+ cfg.ExtraBody,
+ cfg.CustomHeaders,
+ ), modelID, nil
+
case "minimax":
// Minimax requires reasoning_split: true in the request body
if cfg.APIKey() == "" && cfg.APIBase == "" {
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index c362463ae..20cdd8a30 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -434,6 +434,62 @@ func TestCreateProviderFromConfig_Antigravity(t *testing.T) {
}
}
+func TestCreateProviderFromConfig_Gemini(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-gemini",
+ Model: "gemini/gemini-2.5-flash",
+ }
+ cfg.SetAPIKey("test-key")
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "gemini-2.5-flash" {
+ t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
+ }
+ if _, ok := provider.(*GeminiProvider); !ok {
+ t.Fatalf("expected *GeminiProvider, got %T", provider)
+ }
+}
+
+func TestCreateProviderFromConfig_GeminiMissingAPIKey(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-gemini-no-key",
+ Model: "gemini/gemini-2.5-flash",
+ }
+
+ _, _, err := CreateProviderFromConfig(cfg)
+ if err == nil {
+ t.Fatal("CreateProviderFromConfig() expected error for missing gemini API key")
+ }
+}
+
+func TestCreateProviderFromConfig_GeminiCustomAPIBaseWithoutKey(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-gemini-custom-base",
+ Model: "gemini/gemini-2.5-flash",
+ APIBase: "https://proxy.example.com/v1beta",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "gemini-2.5-flash" {
+ t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash")
+ }
+ if _, ok := provider.(*GeminiProvider); !ok {
+ t.Fatalf("expected *GeminiProvider, got %T", provider)
+ }
+}
+
func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-claude-cli",
diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/gemini_provider.go
new file mode 100644
index 000000000..561387534
--- /dev/null
+++ b/pkg/providers/gemini_provider.go
@@ -0,0 +1,796 @@
+package providers
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers/common"
+)
+
+const (
+ geminiDefaultAPIBase = "https://generativelanguage.googleapis.com/v1beta"
+ geminiDefaultModel = "gemini-2.0-flash"
+)
+
+type GeminiProvider struct {
+ apiKey string
+ apiBase string
+ httpClient *http.Client
+ extraBody map[string]any
+ customHeaders map[string]string
+ userAgent string
+}
+
+func NewGeminiProvider(
+ apiKey string,
+ apiBase string,
+ proxy string,
+ userAgent string,
+ requestTimeoutSeconds int,
+ extraBody map[string]any,
+ customHeaders map[string]string,
+) *GeminiProvider {
+ if strings.TrimSpace(apiBase) == "" {
+ apiBase = geminiDefaultAPIBase
+ }
+ client := common.NewHTTPClient(proxy)
+ if requestTimeoutSeconds > 0 {
+ client.Timeout = time.Duration(requestTimeoutSeconds) * time.Second
+ }
+
+ return &GeminiProvider{
+ apiKey: strings.TrimSpace(apiKey),
+ apiBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"),
+ httpClient: client,
+ extraBody: cloneAnyMap(extraBody),
+ customHeaders: cloneStringMap(customHeaders),
+ userAgent: strings.TrimSpace(userAgent),
+ }
+}
+
+func (p *GeminiProvider) GetDefaultModel() string {
+ return geminiDefaultModel
+}
+
+func (p *GeminiProvider) SupportsThinking() bool {
+ return true
+}
+
+func (p *GeminiProvider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ model = normalizeGeminiModel(model)
+ requestBody := p.buildRequestBody(messages, tools, model, options)
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/models/%s:generateContent", p.apiBase, model)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ p.applyHeaders(req)
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, common.HandleErrorResponse(resp, p.apiBase)
+ }
+
+ var apiResp geminiGenerateContentResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return parseGeminiResponse(&apiResp), nil
+}
+
+func (p *GeminiProvider) ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onChunk func(accumulated string),
+) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ model = normalizeGeminiModel(model)
+ requestBody := p.buildRequestBody(messages, tools, model, options)
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/models/%s:streamGenerateContent?alt=sse", p.apiBase, model)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ p.applyHeaders(req)
+ req.Header.Set("Accept", "text/event-stream")
+
+ // Streaming should not use a whole-request timeout; context cancellation is the guard.
+ streamClient := &http.Client{Transport: p.httpClient.Transport}
+ resp, err := streamClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, common.HandleErrorResponse(resp, p.apiBase)
+ }
+
+ return parseGeminiStreamResponse(ctx, resp.Body, onChunk)
+}
+
+func (p *GeminiProvider) applyHeaders(req *http.Request) {
+ req.Header.Set("Content-Type", "application/json")
+ if p.apiKey != "" {
+ req.Header.Set("X-Goog-Api-Key", p.apiKey)
+ }
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
+ for k, v := range p.customHeaders {
+ if strings.TrimSpace(k) == "" {
+ continue
+ }
+ req.Header.Set(k, v)
+ }
+}
+
+func (p *GeminiProvider) buildRequestBody(
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) map[string]any {
+ contents := make([]geminiContent, 0, len(messages))
+ toolCallNames := make(map[string]string)
+ systemPrompts := make([]string, 0, 1)
+
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ if strings.TrimSpace(msg.Content) != "" {
+ systemPrompts = append(systemPrompts, msg.Content)
+ }
+
+ case "user":
+ if msg.ToolCallID != "" {
+ toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
+ contents = append(contents, geminiContent{
+ Role: "user",
+ Parts: []geminiPart{{
+ FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
+ }},
+ })
+ continue
+ }
+
+ parts := make([]geminiPart, 0, 1+len(msg.Media))
+ if strings.TrimSpace(msg.Content) != "" {
+ parts = append(parts, geminiPart{Text: msg.Content})
+ }
+ parts = append(parts, buildInlineMediaParts(msg.Media)...)
+ if len(parts) > 0 {
+ contents = append(contents, geminiContent{Role: "user", Parts: parts})
+ }
+
+ case "assistant":
+ content := geminiContent{Role: "model"}
+ if strings.TrimSpace(msg.Content) != "" {
+ content.Parts = append(content.Parts, geminiPart{Text: msg.Content})
+ }
+ for _, tc := range msg.ToolCalls {
+ toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
+ if toolName == "" {
+ continue
+ }
+ if tc.ID != "" {
+ toolCallNames[tc.ID] = toolName
+ }
+ part := geminiPart{
+ FunctionCall: &geminiFunctionCall{
+ Name: toolName,
+ Args: toolArgs,
+ ID: tc.ID,
+ },
+ }
+ if thoughtSignature != "" {
+ part.ThoughtSignature = thoughtSignature
+ }
+ content.Parts = append(content.Parts, part)
+ }
+ if len(content.Parts) > 0 {
+ contents = append(contents, content)
+ }
+
+ case "tool":
+ toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
+ contents = append(contents, geminiContent{
+ Role: "user",
+ Parts: []geminiPart{{
+ FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media),
+ }},
+ })
+ }
+ }
+
+ body := map[string]any{
+ "contents": contents,
+ }
+ if len(systemPrompts) > 0 {
+ systemParts := make([]geminiPart, 0, len(systemPrompts))
+ for _, prompt := range systemPrompts {
+ systemParts = append(systemParts, geminiPart{Text: prompt})
+ }
+ body["systemInstruction"] = &geminiContent{Parts: systemParts}
+ }
+
+ if len(tools) > 0 {
+ funcDecls := make([]geminiFunctionDeclaration, 0, len(tools))
+ for _, t := range tools {
+ if t.Type != "function" {
+ continue
+ }
+ funcDecls = append(funcDecls, geminiFunctionDeclaration{
+ Name: t.Function.Name,
+ Description: t.Function.Description,
+ Parameters: sanitizeSchemaForGemini(t.Function.Parameters),
+ })
+ }
+ if len(funcDecls) > 0 {
+ body["tools"] = []geminiTool{{FunctionDeclarations: funcDecls}}
+ }
+ }
+
+ generationConfig := make(map[string]any)
+ if val, ok := options["max_tokens"]; ok {
+ if maxTokens, ok := val.(int); ok && maxTokens > 0 {
+ generationConfig["maxOutputTokens"] = maxTokens
+ } else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
+ generationConfig["maxOutputTokens"] = int(maxTokens)
+ }
+ }
+ if temp, ok := options["temperature"].(float64); ok {
+ generationConfig["temperature"] = temp
+ }
+
+ if thinkingConfig := buildGeminiThinkingConfig(model, options); len(thinkingConfig) > 0 {
+ generationConfig["thinkingConfig"] = thinkingConfig
+ }
+
+ if len(generationConfig) > 0 {
+ body["generationConfig"] = generationConfig
+ }
+
+ for k, v := range p.extraBody {
+ body[k] = v
+ }
+
+ return body
+}
+
+func normalizeGeminiModel(model string) string {
+ model = strings.TrimSpace(model)
+ model = strings.TrimPrefix(model, "models/")
+ if strings.Contains(model, "/") {
+ _, modelID := ExtractProtocol(model)
+ if modelID != "" {
+ return modelID
+ }
+ }
+ if model == "" {
+ return geminiDefaultModel
+ }
+ return model
+}
+
+func mapGeminiThinkingLevel(level string) string {
+ switch strings.ToLower(strings.TrimSpace(level)) {
+ case "minimal", "off":
+ return "minimal"
+ case "low":
+ return "low"
+ case "medium":
+ return "medium"
+ case "high", "xhigh", "adaptive":
+ return "high"
+ default:
+ return ""
+ }
+}
+
+func buildGeminiThinkingConfig(model string, options map[string]any) map[string]any {
+ if !geminiModelSupportsThinkingConfig(model) {
+ return nil
+ }
+
+ config := map[string]any{}
+ rawLevel, _ := options["thinking_level"].(string)
+ rawLevel = strings.ToLower(strings.TrimSpace(rawLevel))
+ if rawLevel == "" {
+ // Align with agent-level default: unset means ThinkingOff.
+ rawLevel = "off"
+ }
+
+ includeThoughts := rawLevel != "off" && rawLevel != "minimal"
+ config["includeThoughts"] = includeThoughts
+
+ if isGemini25Model(model) {
+ if isGemini25ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
+ // Gemini 2.5 Pro cannot disable thinking; keep model-default thinking.
+ return config
+ }
+ if budget, ok := mapGeminiThinkingBudget(rawLevel); ok {
+ config["thinkingBudget"] = budget
+ }
+ return config
+ }
+
+ if isGemini3ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") {
+ // Gemini 3.x Pro does not support minimal thinking level.
+ return config
+ }
+
+ if thinkingLevel := mapGeminiThinkingLevel(rawLevel); thinkingLevel != "" {
+ config["thinkingLevel"] = thinkingLevel
+ }
+ return config
+}
+
+func geminiModelSupportsThinkingConfig(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return strings.Contains(lowerModel, "gemini-3") || isGemini25Model(lowerModel)
+}
+
+func isGemini25Model(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25")
+}
+
+func isGemini25ProModel(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return isGemini25Model(lowerModel) && strings.Contains(lowerModel, "pro")
+}
+
+func isGemini3ProModel(model string) bool {
+ lowerModel := strings.ToLower(strings.TrimSpace(model))
+ return strings.Contains(lowerModel, "gemini-3") && strings.Contains(lowerModel, "pro")
+}
+
+func mapGeminiThinkingBudget(level string) (int, bool) {
+ level = strings.ToLower(strings.TrimSpace(level))
+ if level == "" {
+ return 0, false
+ }
+
+ switch level {
+ case "adaptive":
+ return -1, true
+ case "minimal":
+ return 0, true
+ case "off":
+ return 0, true
+ case "low":
+ return 1024, true
+ case "medium":
+ return 4096, true
+ case "high":
+ return 8192, true
+ case "xhigh":
+ return 16384, true
+ default:
+ return 0, false
+ }
+}
+
+func parseGeminiResponse(resp *geminiGenerateContentResponse) *LLMResponse {
+ contentParts := make([]string, 0)
+ reasoningParts := make([]string, 0)
+ toolCalls := make([]ToolCall, 0)
+ finishReason := ""
+
+ for _, candidate := range resp.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ if part.Thought {
+ reasoningParts = append(reasoningParts, part.Text)
+ } else {
+ contentParts = append(contentParts, part.Text)
+ }
+ }
+ if part.FunctionCall != nil {
+ toolCalls = append(toolCalls, buildGeminiToolCall(part))
+ }
+ }
+ if candidate.FinishReason != "" {
+ finishReason = candidate.FinishReason
+ }
+ }
+
+ var usage *UsageInfo
+ if resp.UsageMetadata.TotalTokenCount > 0 {
+ usage = &UsageInfo{
+ PromptTokens: resp.UsageMetadata.PromptTokenCount,
+ CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
+ TotalTokens: resp.UsageMetadata.TotalTokenCount,
+ }
+ }
+
+ return &LLMResponse{
+ Content: strings.Join(contentParts, ""),
+ ReasoningContent: strings.Join(reasoningParts, ""),
+ ToolCalls: toolCalls,
+ FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
+ Usage: usage,
+ }
+}
+
+func parseGeminiStreamResponse(
+ ctx context.Context,
+ reader io.Reader,
+ onChunk func(accumulated string),
+) (*LLMResponse, error) {
+ var contentBuilder strings.Builder
+ var reasoningBuilder strings.Builder
+ var finishReason string
+ var usage *UsageInfo
+
+ toolCallsByID := make(map[string]ToolCall)
+ toolCallOrder := make([]string, 0)
+ fallbackIndex := 0
+
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
+ for scanner.Scan() {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
+ if data == "" {
+ continue
+ }
+ if data == "[DONE]" {
+ break
+ }
+
+ var chunk geminiGenerateContentResponse
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ return nil, fmt.Errorf("invalid gemini stream chunk: %w", err)
+ }
+
+ for _, candidate := range chunk.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ if part.Thought {
+ reasoningBuilder.WriteString(part.Text)
+ } else {
+ contentBuilder.WriteString(part.Text)
+ if onChunk != nil {
+ onChunk(contentBuilder.String())
+ }
+ }
+ }
+ if part.FunctionCall != nil {
+ tc := buildGeminiToolCall(part)
+ if strings.TrimSpace(tc.Name) == "" {
+ continue
+ }
+
+ key := strings.TrimSpace(part.FunctionCall.ID)
+ if key == "" {
+ if len(toolCallOrder) > 0 {
+ lastKey := toolCallOrder[len(toolCallOrder)-1]
+ if lastTC, exists := toolCallsByID[lastKey]; exists && lastTC.Name == tc.Name {
+ key = lastKey
+ }
+ }
+ if key == "" {
+ fallbackIndex++
+ key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex)
+ }
+ }
+
+ tc.ID = key
+ if _, exists := toolCallsByID[key]; !exists {
+ toolCallOrder = append(toolCallOrder, key)
+ }
+ toolCallsByID[key] = tc
+ }
+ }
+ if candidate.FinishReason != "" {
+ finishReason = candidate.FinishReason
+ }
+ }
+
+ if chunk.UsageMetadata.TotalTokenCount > 0 {
+ usage = &UsageInfo{
+ PromptTokens: chunk.UsageMetadata.PromptTokenCount,
+ CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount,
+ TotalTokens: chunk.UsageMetadata.TotalTokenCount,
+ }
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("streaming read error: %w", err)
+ }
+
+ toolCalls := make([]ToolCall, 0, len(toolCallOrder))
+ for _, key := range toolCallOrder {
+ toolCalls = append(toolCalls, toolCallsByID[key])
+ }
+
+ return &LLMResponse{
+ Content: contentBuilder.String(),
+ ReasoningContent: reasoningBuilder.String(),
+ ToolCalls: toolCalls,
+ FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)),
+ Usage: usage,
+ }, nil
+}
+
+func normalizeGeminiFinishReason(reason string, toolCalls int) string {
+ if toolCalls > 0 {
+ return "tool_calls"
+ }
+
+ switch strings.ToUpper(strings.TrimSpace(reason)) {
+ case "MAX_TOKENS":
+ return "length"
+ case "", "STOP":
+ return "stop"
+ default:
+ return strings.ToLower(strings.TrimSpace(reason))
+ }
+}
+
+func buildGeminiToolCall(part geminiPart) ToolCall {
+ if part.FunctionCall == nil {
+ return ToolCall{}
+ }
+
+ args := part.FunctionCall.Args
+ if args == nil {
+ args = make(map[string]any)
+ }
+ argsJSON, _ := json.Marshal(args)
+ thoughtSignature := extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake)
+
+ toolCall := ToolCall{
+ ID: part.FunctionCall.ID,
+ Name: part.FunctionCall.Name,
+ Arguments: args,
+ ThoughtSignature: thoughtSignature,
+ Function: &FunctionCall{
+ Name: part.FunctionCall.Name,
+ Arguments: string(argsJSON),
+ ThoughtSignature: thoughtSignature,
+ },
+ }
+
+ if thoughtSignature != "" {
+ toolCall.ExtraContent = &ExtraContent{
+ Google: &GoogleExtra{ThoughtSignature: thoughtSignature},
+ }
+ }
+ if strings.TrimSpace(toolCall.ID) == "" {
+ toolCall.ID = fmt.Sprintf("call_%s_%d", toolCall.Name, time.Now().UnixNano())
+ }
+
+ return toolCall
+}
+
+func buildInlineMediaParts(media []string) []geminiPart {
+ parts := make([]geminiPart, 0, len(media))
+ for _, mediaURL := range media {
+ mimeType, data, ok := parseBase64DataURL(mediaURL)
+ if !ok {
+ continue
+ }
+ parts = append(parts, geminiPart{
+ InlineData: &geminiInlineData{
+ MIMEType: mimeType,
+ Data: data,
+ },
+ })
+ }
+ return parts
+}
+
+func buildGeminiFunctionResponse(
+ toolName string,
+ toolCallID string,
+ result string,
+ media []string,
+) *geminiFunctionResponse {
+ response := &geminiFunctionResponse{
+ ID: toolCallID,
+ Name: toolName,
+ Response: map[string]any{
+ "result": result,
+ },
+ }
+
+ if parts := buildFunctionResponseMediaParts(media); len(parts) > 0 {
+ response.Parts = parts
+ }
+
+ return response
+}
+
+func buildFunctionResponseMediaParts(media []string) []geminiFunctionResponsePart {
+ parts := make([]geminiFunctionResponsePart, 0, len(media))
+ for i, mediaURL := range media {
+ mimeType, data, ok := parseBase64DataURL(mediaURL)
+ if !ok {
+ continue
+ }
+ parts = append(parts, geminiFunctionResponsePart{
+ InlineData: &geminiInlineData{
+ MIMEType: mimeType,
+ Data: data,
+ DisplayName: defaultFunctionResponseDisplayName(mimeType, i+1),
+ },
+ })
+ }
+ return parts
+}
+
+func defaultFunctionResponseDisplayName(mimeType string, index int) string {
+ suffix := "bin"
+ switch strings.ToLower(strings.TrimSpace(mimeType)) {
+ case "image/png":
+ suffix = "png"
+ case "image/jpeg":
+ suffix = "jpg"
+ case "image/webp":
+ suffix = "webp"
+ case "application/pdf":
+ suffix = "pdf"
+ case "text/plain":
+ suffix = "txt"
+ }
+ return fmt.Sprintf("attachment-%d.%s", index, suffix)
+}
+
+func parseBase64DataURL(mediaURL string) (mimeType string, data string, ok bool) {
+ if !strings.HasPrefix(mediaURL, "data:") {
+ return "", "", false
+ }
+
+ payload := strings.TrimPrefix(mediaURL, "data:")
+ header, data, found := strings.Cut(payload, ",")
+ if !found {
+ return "", "", false
+ }
+ mimeType, params, _ := strings.Cut(header, ";")
+ mimeType = strings.TrimSpace(mimeType)
+ data = strings.TrimSpace(data)
+ if mimeType == "" || data == "" {
+ return "", "", false
+ }
+ if !strings.Contains(strings.ToLower(params), "base64") {
+ return "", "", false
+ }
+ return mimeType, data, true
+}
+
+func cloneAnyMap(in map[string]any) map[string]any {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]any, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+func cloneStringMap(in map[string]string) map[string]string {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+type geminiGenerateContentResponse struct {
+ Candidates []struct {
+ Content struct {
+ Role string `json:"role"`
+ Parts []geminiPart `json:"parts"`
+ } `json:"content"`
+ FinishReason string `json:"finishReason"`
+ } `json:"candidates"`
+ UsageMetadata struct {
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ } `json:"usageMetadata"`
+}
+
+type geminiContent struct {
+ Role string `json:"role,omitempty"`
+ Parts []geminiPart `json:"parts"`
+}
+
+type geminiPart struct {
+ Text string `json:"text,omitempty"`
+ Thought bool `json:"thought,omitempty"`
+ ThoughtSignature string `json:"thoughtSignature,omitempty"`
+ ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
+ InlineData *geminiInlineData `json:"inlineData,omitempty"`
+ FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
+ FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
+}
+
+type geminiInlineData struct {
+ MIMEType string `json:"mimeType"`
+ Data string `json:"data"`
+ DisplayName string `json:"displayName,omitempty"`
+}
+
+type geminiFunctionCall struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Args map[string]any `json:"args,omitempty"`
+}
+
+type geminiFunctionResponse struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Response map[string]any `json:"response"`
+ Parts []geminiFunctionResponsePart `json:"parts,omitempty"`
+}
+
+type geminiFunctionResponsePart struct {
+ InlineData *geminiInlineData `json:"inlineData,omitempty"`
+}
+
+type geminiTool struct {
+ FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"`
+}
+
+type geminiFunctionDeclaration struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters any `json:"parameters,omitempty"`
+}
diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/gemini_provider_test.go
new file mode 100644
index 000000000..a0ab748eb
--- /dev/null
+++ b/pkg/providers/gemini_provider_test.go
@@ -0,0 +1,763 @@
+package providers
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) {
+ var capturedBody map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Fatalf("method = %s, want POST", r.Method)
+ }
+ if !strings.Contains(r.URL.Path, ":generateContent") {
+ t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path)
+ }
+ if got := r.Header.Get("X-Goog-Api-Key"); got != "test-key" {
+ t.Fatalf("X-Goog-Api-Key = %q, want %q", got, "test-key")
+ }
+ if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil {
+ t.Fatalf("decode request body: %v", err)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "candidates": []any{
+ map[string]any{
+ "content": map[string]any{
+ "role": "model",
+ "parts": []any{
+ map[string]any{"text": "hidden", "thought": true},
+ map[string]any{"text": "visible"},
+ map[string]any{
+ "functionCall": map[string]any{
+ "id": "call_1",
+ "name": "search",
+ "args": map[string]any{"q": "hi"},
+ },
+ "thoughtSignature": "sig-1",
+ },
+ },
+ },
+ "finishReason": "STOP",
+ },
+ },
+ "usageMetadata": map[string]any{
+ "promptTokenCount": 2,
+ "candidatesTokenCount": 3,
+ "totalTokenCount": 5,
+ },
+ })
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "picoclaw-test", 0, nil, nil)
+ resp, err := provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-3-flash-preview",
+ map[string]any{"thinking_level": "high"},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if resp.Content != "visible" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "visible")
+ }
+ if resp.ReasoningContent != "hidden" {
+ t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden")
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+ if resp.Usage == nil || resp.Usage.TotalTokens != 5 {
+ t.Fatalf("Usage = %#v, expected total tokens = 5", resp.Usage)
+ }
+ if len(resp.ToolCalls) != 1 {
+ t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
+ }
+ if resp.ToolCalls[0].ID != "call_1" {
+ t.Fatalf("ToolCall ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
+ }
+ if resp.ToolCalls[0].Name != "search" {
+ t.Fatalf("ToolCall Name = %q, want %q", resp.ToolCalls[0].Name, "search")
+ }
+ if resp.ToolCalls[0].ThoughtSignature != "sig-1" {
+ t.Fatalf("ToolCall ThoughtSignature = %q, want %q", resp.ToolCalls[0].ThoughtSignature, "sig-1")
+ }
+ if resp.ToolCalls[0].Function == nil || !strings.Contains(resp.ToolCalls[0].Function.Arguments, `"q":"hi"`) {
+ t.Fatalf("ToolCall Function arguments = %#v, want q=hi", resp.ToolCalls[0].Function)
+ }
+
+ generationConfig, ok := capturedBody["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("request missing generationConfig: %#v", capturedBody)
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("request missing thinkingConfig: %#v", generationConfig)
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
+ t.Fatalf("thinkingConfig.includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
+ }
+ if got := thinkingConfig["thinkingLevel"]; got != "high" {
+ t.Fatalf("thinkingConfig.thinkingLevel = %#v, want %q", got, "high")
+ }
+}
+
+func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.Contains(r.URL.Path, ":streamGenerateContent") {
+ t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path)
+ }
+ if got := r.URL.Query().Get("alt"); got != "sse" {
+ t.Fatalf("alt query = %q, want %q", got, "sse")
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ chunks := []map[string]any{
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{"text": "think ", "thought": true},
+ map[string]any{"text": "Hello "},
+ },
+ },
+ }},
+ },
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{"text": "World"},
+ map[string]any{
+ "functionCall": map[string]any{
+ "id": "call_stream",
+ "name": "search",
+ "args": map[string]any{"q": "stream"},
+ },
+ },
+ },
+ },
+ "finishReason": "STOP",
+ }},
+ "usageMetadata": map[string]any{
+ "promptTokenCount": 1,
+ "candidatesTokenCount": 2,
+ "totalTokenCount": 3,
+ },
+ },
+ }
+
+ for _, chunk := range chunks {
+ raw, err := json.Marshal(chunk)
+ if err != nil {
+ t.Fatalf("marshal chunk: %v", err)
+ }
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
+ t.Fatalf("write chunk: %v", err)
+ }
+ flusher.Flush()
+ }
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ updates := make([]string, 0)
+ resp, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ func(accumulated string) {
+ updates = append(updates, accumulated)
+ },
+ )
+ if err != nil {
+ t.Fatalf("ChatStream() error = %v", err)
+ }
+ if resp.Content != "Hello World" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "Hello World")
+ }
+ if resp.ReasoningContent != "think " {
+ t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think ")
+ }
+ if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_stream" {
+ t.Fatalf("ToolCalls = %#v, want single call_stream", resp.ToolCalls)
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+ if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
+ t.Fatalf("Usage = %#v, expected total tokens = 3", resp.Usage)
+ }
+ if len(updates) < 2 || updates[len(updates)-1] != "Hello World" {
+ t.Fatalf("stream updates = %#v, expected final accumulated text", updates)
+ }
+}
+
+func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ _, _ = fmt.Fprint(w, "data: \n\n")
+ flusher.Flush()
+
+ chunk := map[string]any{
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{map[string]any{"text": "ok"}},
+ },
+ "finishReason": "STOP",
+ }},
+ }
+ raw, err := json.Marshal(chunk)
+ if err != nil {
+ t.Fatalf("marshal chunk: %v", err)
+ }
+ _, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
+ flusher.Flush()
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ resp, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("ChatStream() error = %v", err)
+ }
+ if resp.Content != "ok" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "ok")
+ }
+}
+
+func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ _, _ = fmt.Fprint(w, "data: {invalid-json}\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ _, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ nil,
+ )
+ if err == nil {
+ t.Fatal("ChatStream() expected error for invalid SSE data frame")
+ }
+ if !strings.Contains(err.Error(), "invalid gemini stream chunk") {
+ t.Fatalf("error = %v, want contains %q", err, "invalid gemini stream chunk")
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+
+ body := provider.buildRequestBody(
+ []Message{{
+ Role: "assistant",
+ ToolCalls: []ToolCall{{
+ ID: "call_1",
+ Name: "search",
+ Arguments: map[string]any{"q": "hello"},
+ Function: &FunctionCall{
+ Name: "search",
+ Arguments: `{"q":"hello"}`,
+ ThoughtSignature: "sig-1",
+ },
+ }},
+ }},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
+ jsonBody := string(raw)
+
+ if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) {
+ t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody)
+ }
+ if strings.Contains(jsonBody, `"thought_signature"`) {
+ t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody)
+ }
+}
+
+func TestGeminiProvider_ChatStreamCoalescesToolCallWithoutWireID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ t.Fatal("response writer is not flushable")
+ }
+
+ chunks := []map[string]any{
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{
+ "functionCall": map[string]any{
+ "name": "search",
+ "args": map[string]any{"q": "first"},
+ },
+ },
+ },
+ },
+ }},
+ },
+ {
+ "candidates": []any{map[string]any{
+ "content": map[string]any{
+ "parts": []any{
+ map[string]any{
+ "functionCall": map[string]any{
+ "name": "search",
+ "args": map[string]any{"q": "second"},
+ },
+ },
+ },
+ },
+ "finishReason": "STOP",
+ }},
+ },
+ }
+
+ for _, chunk := range chunks {
+ raw, err := json.Marshal(chunk)
+ if err != nil {
+ t.Fatalf("marshal chunk: %v", err)
+ }
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
+ t.Fatalf("write chunk: %v", err)
+ }
+ flusher.Flush()
+ }
+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
+ flusher.Flush()
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
+ resp, err := provider.ChatStream(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("ChatStream() error = %v", err)
+ }
+ if len(resp.ToolCalls) != 1 {
+ t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
+ }
+ tc := resp.ToolCalls[0]
+ if tc.ID != "search#1" {
+ t.Fatalf("ToolCall ID = %q, want %q", tc.ID, "search#1")
+ }
+ if tc.Name != "search" {
+ t.Fatalf("ToolCall Name = %q, want %q", tc.Name, "search")
+ }
+ if argQ, ok := tc.Arguments["q"].(string); !ok || argQ != "second" {
+ t.Fatalf("ToolCall Arguments = %#v, want q=second", tc.Arguments)
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+}
+
+func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+
+ body := provider.buildRequestBody(
+ []Message{{
+ Role: "user",
+ Content: "analyze attachments",
+ Media: []string{
+ "data:application/pdf;base64,UEZERGF0YQ==",
+ "data:image/png;base64,aW1hZ2VEYXRh",
+ },
+ }},
+ nil,
+ "gemini-3-flash-preview",
+ map[string]any{
+ "thinking_level": "low",
+ "max_tokens": 128,
+ "temperature": 0.2,
+ },
+ )
+
+ contents, ok := body["contents"].([]geminiContent)
+ if !ok || len(contents) != 1 {
+ t.Fatalf("contents = %#v, want one gemini content", body["contents"])
+ }
+ parts := contents[0].Parts
+ mimeSet := map[string]bool{}
+ for _, part := range parts {
+ if part.InlineData != nil {
+ mimeSet[part.InlineData.MIMEType] = true
+ }
+ }
+ if !mimeSet["application/pdf"] {
+ t.Fatalf("inline media missing application/pdf: %#v", parts)
+ }
+ if !mimeSet["image/png"] {
+ t.Fatalf("inline media missing image/png: %#v", parts)
+ }
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ if got := generationConfig["maxOutputTokens"]; got != 128 {
+ t.Fatalf("maxOutputTokens = %#v, want 128", got)
+ }
+ if got := generationConfig["temperature"]; got != 0.2 {
+ t.Fatalf("temperature = %#v, want 0.2", got)
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
+ }
+ if got := thinkingConfig["thinkingLevel"]; got != "low" {
+ t.Fatalf("thinkingLevel = %#v, want %q", got, "low")
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_UsesThinkingBudgetForGemini25(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ map[string]any{"thinking_level": "medium"},
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if got := thinkingConfig["thinkingBudget"]; got != 4096 {
+ t.Fatalf("thinkingBudget = %#v, want 4096", got)
+ }
+ if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
+ t.Fatalf("thinkingLevel should not be set for Gemini 2.5: %#v", thinkingConfig)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.0-flash-exp",
+ map[string]any{"thinking_level": "high"},
+ )
+
+ if _, ok := body["generationConfig"]; ok {
+ t.Fatalf("generationConfig should be omitted for Gemini 2.0 when only thinking_level is set: %#v", body)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if got := thinkingConfig["thinkingBudget"]; got != 0 {
+ t.Fatalf("thinkingBudget = %#v, want 0 for default/off", got)
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-3-flash-preview",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if got := thinkingConfig["thinkingLevel"]; got != "minimal" {
+ t.Fatalf("thinkingLevel = %#v, want minimal for default/off", got)
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25Pro(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-pro",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+ if _, hasBudget := thinkingConfig["thinkingBudget"]; hasBudget {
+ t.Fatalf("thinkingBudget should be omitted for Gemini 2.5 Pro default/off: %#v", thinkingConfig)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini31Pro(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-3.1-pro",
+ nil,
+ )
+
+ generationConfig, ok := body["generationConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
+ }
+ thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
+ if !ok {
+ t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
+ }
+ if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
+ t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
+ }
+ if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
+ t.Fatalf("thinkingLevel should be omitted for Gemini 3.1 Pro default/off: %#v", thinkingConfig)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{
+ {Role: "system", Content: "You are helpful."},
+ {Role: "system", Content: "Be concise."},
+ {Role: "user", Content: "hello"},
+ },
+ nil,
+ "gemini-3-flash-preview",
+ nil,
+ )
+
+ systemInstruction, ok := body["systemInstruction"].(*geminiContent)
+ if !ok || systemInstruction == nil {
+ t.Fatalf("systemInstruction = %#v, want *geminiContent", body["systemInstruction"])
+ }
+ if len(systemInstruction.Parts) != 2 {
+ t.Fatalf("systemInstruction.Parts len = %d, want 2", len(systemInstruction.Parts))
+ }
+ if systemInstruction.Parts[0].Text != "You are helpful." || systemInstruction.Parts[1].Text != "Be concise." {
+ t.Fatalf("systemInstruction.Parts = %#v, want ordered system prompts", systemInstruction.Parts)
+ }
+}
+
+func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) {
+ provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
+ body := provider.buildRequestBody(
+ []Message{
+ {
+ Role: "assistant",
+ ToolCalls: []ToolCall{{
+ ID: "call_1",
+ Name: "load_image",
+ Arguments: map[string]any{"path": "demo.png"},
+ }},
+ },
+ {
+ Role: "tool",
+ ToolCallID: "call_1",
+ Content: "tool result",
+ Media: []string{
+ "data:image/png;base64,aW1hZ2VEYXRh",
+ "data:application/pdf;base64,UEZERGF0YQ==",
+ },
+ },
+ },
+ nil,
+ "gemini-3-flash-preview",
+ nil,
+ )
+
+ contents, ok := body["contents"].([]geminiContent)
+ if !ok || len(contents) != 2 {
+ t.Fatalf("contents = %#v, want two content entries", body["contents"])
+ }
+ parts := contents[1].Parts
+ if len(parts) != 1 || parts[0].FunctionResponse == nil {
+ t.Fatalf("tool response part = %#v, want functionResponse", parts)
+ }
+ response := parts[0].FunctionResponse
+ if response.Name != "load_image" {
+ t.Fatalf("functionResponse.Name = %q, want %q", response.Name, "load_image")
+ }
+ if response.Response["result"] != "tool result" {
+ t.Fatalf("functionResponse.Response = %#v, want result=tool result", response.Response)
+ }
+ if len(response.Parts) != 2 {
+ t.Fatalf("functionResponse.Parts len = %d, want 2", len(response.Parts))
+ }
+}
+
+func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
+ t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
+ }
+ if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
+ t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "candidates": []any{
+ map[string]any{
+ "content": map[string]any{
+ "parts": []any{map[string]any{"text": "ok"}},
+ },
+ "finishReason": "STOP",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider(
+ "",
+ server.URL,
+ "",
+ "",
+ 0,
+ nil,
+ map[string]string{"Authorization": "Bearer test-token"},
+ )
+
+ resp, err := provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if resp.Content != "ok" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "ok")
+ }
+}
+
+func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
+ t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "candidates": []any{
+ map[string]any{
+ "content": map[string]any{"parts": []any{map[string]any{"text": "ok"}}},
+ "finishReason": "STOP",
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ provider := NewGeminiProvider("", server.URL, "", "", 0, nil, nil)
+ resp, err := provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hello"}},
+ nil,
+ "gemini-2.5-flash",
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if resp.Content != "ok" {
+ t.Fatalf("Content = %q, want %q", resp.Content, "ok")
+ }
+}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index d25a0fce4..98a70cfd2 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
+ "maps"
"net/http"
"net/url"
"strings"
@@ -181,9 +182,7 @@ func (p *Provider) buildRequestBody(
// Merge extra body fields configured per-provider/model.
// These are injected last so they take precedence over defaults.
- for k, v := range p.extraBody {
- requestBody[k] = v
- }
+ maps.Copy(requestBody, p.extraBody)
return requestBody
}
diff --git a/web/Makefile b/web/Makefile
index 891c170c2..4dca810e7 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -1,4 +1,5 @@
-.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean
+.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \
+ build-android-arm64 build-android-bundle
# Go variables
GO?=CGO_ENABLED=0 go
@@ -9,7 +10,9 @@ GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
# Build variables
BUILD_DIR=build
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
+OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64
FRONTEND_DIR=frontend
+FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp
BACKEND_DIR=backend
BACKEND_DIST=$(BACKEND_DIR)/dist
PICOCLAW_BINARY_NAME=picoclaw
@@ -91,12 +94,26 @@ build: build-frontend
@mkdir -p "$$(dirname "$(OUTPUT)")"
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
+# Build launcher for Android ARM64 (frontend must already be built)
+build-android-arm64: build-frontend
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
+
+# Build launcher for all Android architectures
+build-android-bundle: build-frontend
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
+ @echo "All Android launcher builds complete"
+
build-frontend:
- @if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
- [ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \
- [ $(FRONTEND_DIR)/pnpm-lock.yaml -nt $(FRONTEND_DIR)/node_modules ]; then \
+ @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
+ if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
+ [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
+ [ ! -f $(FRONTEND_INSTALL_STAMP) ] || \
+ [ "$$(cat $(FRONTEND_INSTALL_STAMP) 2>/dev/null)" != "$$expected_stamp" ]; then \
echo "Installing frontend dependencies..."; \
- cd $(FRONTEND_DIR) && pnpm install --frozen-lockfile; \
+ (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
+ printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
fi
@echo "Building frontend..."
@cd $(FRONTEND_DIR) && pnpm build:backend
diff --git a/web/backend/api/session.go b/web/backend/api/session.go
index ae580d9aa..9bb6055e2 100644
--- a/web/backend/api/session.go
+++ b/web/backend/api/session.go
@@ -281,6 +281,12 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
}
case "assistant":
+ // Reasoning-only assistant messages are transient display artifacts and
+ // should not be restored from session history.
+ if assistantMessageTransientThought(msg) {
+ continue
+ }
+
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
if len(toolSummaryMessages) > 0 {
transcript = append(transcript, toolSummaryMessages...)
@@ -309,6 +315,13 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
return transcript
}
+func assistantMessageTransientThought(msg providers.Message) bool {
+ return strings.TrimSpace(msg.Content) == "" &&
+ strings.TrimSpace(msg.ReasoningContent) != "" &&
+ len(msg.ToolCalls) == 0 &&
+ len(msg.Media) == 0
+}
+
func assistantMessageInternalOnly(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
}
diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go
index 5d7620362..599921bfe 100644
--- a/web/backend/api/session_test.go
+++ b/web/backend/api/session_test.go
@@ -218,6 +218,59 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
}
}
+func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ dir := sessionsTestDir(t, configPath)
+ store, err := memory.NewJSONLStore(dir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ sessionKey := picoSessionPrefix + "detail-transient-thought"
+ for _, msg := range []providers.Message{
+ {Role: "user", Content: "hello"},
+ {Role: "assistant", ReasoningContent: "internal chain of thought"},
+ {Role: "assistant", Content: "final visible answer"},
+ } {
+ if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
+ t.Fatalf("AddFullMessage() error = %v", err)
+ }
+ }
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-transient-thought", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(resp.Messages) != 2 {
+ t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
+ }
+ if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
+ t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
+ }
+ if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" {
+ t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1])
+ }
+}
+
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
diff --git a/web/backend/systray.go b/web/backend/systray.go
index 9dcc025df..41fea1fbe 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -1,4 +1,4 @@
-//go:build (!darwin && !freebsd) || cgo
+//go:build !android && ((!darwin && !freebsd) || cgo)
package main
diff --git a/web/backend/systray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go
index 9e75e112a..41514feef 100644
--- a/web/backend/systray_stub_nocgo.go
+++ b/web/backend/systray_stub_nocgo.go
@@ -1,4 +1,4 @@
-//go:build (darwin || freebsd) && !cgo
+//go:build (darwin || freebsd || android) && !cgo
package main
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 51e6f1dd9..40d5cf3d8 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
+ "packageManager": "pnpm@10.33.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index 9966226b2..8dcbe15a1 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -1,5 +1,6 @@
-import { IconCheck, IconCopy } from "@tabler/icons-react"
+import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react"
+import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
@@ -7,16 +8,20 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
+import { cn } from "@/lib/utils"
interface AssistantMessageProps {
content: string
+ isThought?: boolean
timestamp?: string | number
}
export function AssistantMessage({
content,
+ isThought = false,
timestamp = "",
}: AssistantMessageProps) {
+ const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
@@ -33,6 +38,12 @@ export function AssistantMessage({
PicoClaw
+ {isThought && (
+
+
+ {t("chat.reasoningLabel")}
+
+ )}
{formattedTimestamp && (
<>
•
@@ -42,8 +53,22 @@ export function AssistantMessage({
-
-
+
+
{isCopied ? (
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx
index 38a0fc6b1..e8e07a801 100644
--- a/web/frontend/src/components/chat/chat-page.tsx
+++ b/web/frontend/src/components/chat/chat-page.tsx
@@ -247,6 +247,7 @@ export function ChatPage() {
{msg.role === "assistant" ? (
) : (
diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts
index 850b3319e..92beb06b7 100644
--- a/web/frontend/src/features/chat/history.ts
+++ b/web/frontend/src/features/chat/history.ts
@@ -24,6 +24,7 @@ export async function loadSessionMessages(
id: `hist-${index}-${Date.now()}`,
role: message.role,
content: message.content,
+ kind: message.role === "assistant" ? "normal" : undefined,
attachments: toChatAttachments(message.media),
timestamp: fallbackTime,
}))
@@ -50,7 +51,7 @@ function messageSignature(message: ChatMessage): string {
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
message.timestamp,
- )}\u0000${attachmentSignature}`
+ )}\u0000${message.kind ?? ""}\u0000${attachmentSignature}`
}
function comparableTimestamp(timestamp: number | string): number {
diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts
index 7429aef01..a7edfc21b 100644
--- a/web/frontend/src/features/chat/protocol.ts
+++ b/web/frontend/src/features/chat/protocol.ts
@@ -1,7 +1,10 @@
import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state"
-import { updateChatStore } from "@/store/chat"
+import {
+ type AssistantMessageKind,
+ updateChatStore,
+} from "@/store/chat"
export interface PicoMessage {
type: string
@@ -11,6 +14,16 @@ export interface PicoMessage {
payload?: Record
}
+function parseAssistantMessageKind(
+ payload: Record,
+): AssistantMessageKind {
+ return payload.thought === true ? "thought" : "normal"
+}
+
+function hasAssistantKindPayload(payload: Record): boolean {
+ return typeof payload.thought === "boolean"
+}
+
export function handlePicoMessage(
message: PicoMessage,
expectedSessionId: string,
@@ -25,6 +38,7 @@ export function handlePicoMessage(
case "message.create": {
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
+ const kind = parseAssistantMessageKind(payload)
const timestamp =
message.timestamp !== undefined &&
Number.isFinite(Number(message.timestamp))
@@ -38,6 +52,7 @@ export function handlePicoMessage(
id: messageId,
role: "assistant",
content,
+ kind,
timestamp,
},
],
@@ -49,13 +64,21 @@ export function handlePicoMessage(
case "message.update": {
const content = (payload.content as string) || ""
const messageId = payload.message_id as string
+ const hasKind = hasAssistantKindPayload(payload)
+ const kind = parseAssistantMessageKind(payload)
if (!messageId) {
break
}
updateChatStore((prev) => ({
messages: prev.messages.map((msg) =>
- msg.id === messageId ? { ...msg, content } : msg,
+ msg.id === messageId
+ ? {
+ ...msg,
+ content,
+ ...(hasKind ? { kind } : {}),
+ }
+ : msg,
),
}))
break
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index b53abeb76..2434d4576 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -47,6 +47,7 @@
"step3": "Preparing response...",
"step4": "Almost there..."
},
+ "reasoningLabel": "Reasoning",
"history": "History",
"noHistory": "No chat history yet",
"historyLoadFailed": "Failed to load chat history",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index e2e8eae04..c03d4181d 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -47,6 +47,7 @@
"step3": "准备回复...",
"step4": "马上就好..."
},
+ "reasoningLabel": "思考",
"history": "历史记录",
"noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败",
diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts
index 21eb5edff..2c6f70610 100644
--- a/web/frontend/src/store/chat.ts
+++ b/web/frontend/src/store/chat.ts
@@ -11,11 +11,14 @@ export interface ChatAttachment {
filename?: string
}
+export type AssistantMessageKind = "normal" | "thought"
+
export interface ChatMessage {
id: string
role: "user" | "assistant"
content: string
timestamp: number | string
+ kind?: AssistantMessageKind
attachments?: ChatAttachment[]
}