diff --git a/.github/workflows/clifcode-release.yml b/.github/workflows/clifcode-release.yml
new file mode 100644
index 0000000..1f7ba65
--- /dev/null
+++ b/.github/workflows/clifcode-release.yml
@@ -0,0 +1,257 @@
+name: ClifCode Release & Publish
+
+on:
+ push:
+ tags:
+ - "clifcode-v*"
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version to publish (e.g. 0.1.0)"
+ required: true
+
+permissions:
+ contents: write
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ # --------------------------------------------------
+ # Build native binaries on each platform
+ # --------------------------------------------------
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: aarch64-apple-darwin
+ os: macos-14
+ name: cli-darwin-arm64
+ - target: x86_64-apple-darwin
+ os: macos-14
+ name: cli-darwin-x64
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ name: cli-linux-arm64
+ cross: true
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ name: cli-linux-x64
+ - target: aarch64-pc-windows-msvc
+ os: windows-latest
+ name: cli-win32-arm64
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+ name: cli-win32-x64
+
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Determine version
+ id: version
+ shell: bash
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
+ else
+ TAG="${GITHUB_REF#refs/tags/clifcode-v}"
+ echo "version=$TAG" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Bump version
+ shell: bash
+ working-directory: clif-code-tui
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml
+ rm -f Cargo.toml.bak
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Rust Cache
+ uses: swatinem/rust-cache@v2
+ with:
+ workspaces: clif-code-tui -> target
+ cache-on-failure: true
+
+ - name: Install cross (Linux ARM64)
+ if: matrix.cross
+ run: cargo install cross --git https://github.com/cross-rs/cross
+
+ - name: Build
+ working-directory: clif-code-tui
+ shell: bash
+ run: |
+ if [[ "${{ matrix.cross }}" == "true" ]]; then
+ cross build --release --target ${{ matrix.target }}
+ else
+ cargo build --release --target ${{ matrix.target }}
+ fi
+
+ - name: Strip binary (Unix)
+ if: runner.os != 'Windows'
+ shell: bash
+ run: |
+ strip clif-code-tui/target/${{ matrix.target }}/release/clifcode || true
+
+ - name: Prepare artifact (Unix)
+ if: runner.os != 'Windows'
+ shell: bash
+ run: |
+ mkdir -p dist
+ cp clif-code-tui/target/${{ matrix.target }}/release/clifcode dist/clifcode-${{ matrix.target }}
+
+ - name: Prepare artifact (Windows)
+ if: runner.os == 'Windows'
+ shell: bash
+ run: |
+ mkdir -p dist
+ cp clif-code-tui/target/${{ matrix.target }}/release/clifcode.exe dist/clifcode-${{ matrix.target }}.exe
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.name }}
+ path: dist/
+
+ - name: Upload to GitHub Release
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/*
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # --------------------------------------------------
+ # Publish platform-specific npm packages
+ # --------------------------------------------------
+ publish-platform:
+ needs: build
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - artifact: cli-darwin-arm64
+ package: cli-darwin-arm64
+ binary: clifcode-aarch64-apple-darwin
+ - artifact: cli-darwin-x64
+ package: cli-darwin-x64
+ binary: clifcode-x86_64-apple-darwin
+ - artifact: cli-linux-arm64
+ package: cli-linux-arm64
+ binary: clifcode-aarch64-unknown-linux-gnu
+ - artifact: cli-linux-x64
+ package: cli-linux-x64
+ binary: clifcode-x86_64-unknown-linux-gnu
+ - artifact: cli-win32-arm64
+ package: cli-win32-arm64
+ binary: clifcode-aarch64-pc-windows-msvc.exe
+ - artifact: cli-win32-x64
+ package: cli-win32-x64
+ binary: clifcode-x86_64-pc-windows-msvc.exe
+
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ registry-url: https://registry.npmjs.org
+
+ - name: Determine version
+ id: version
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
+ else
+ TAG="${GITHUB_REF#refs/tags/clifcode-v}"
+ echo "version=$TAG" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Download artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ matrix.artifact }}
+ path: artifact/
+
+ - name: Prepare package
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ PKG_DIR="clif-code-tui/npm/@clifcode/${{ matrix.package }}"
+
+ # Patch version
+ cd "$PKG_DIR"
+ node -e "
+ const pkg = require('./package.json');
+ pkg.version = '$VERSION';
+ require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
+ "
+
+ # Copy binary
+ mkdir -p bin
+ IS_WIN=$([[ "${{ matrix.binary }}" == *.exe ]] && echo true || echo false)
+ if [[ "$IS_WIN" == "true" ]]; then
+ cp "$GITHUB_WORKSPACE/artifact/${{ matrix.binary }}" bin/clifcode.exe
+ else
+ cp "$GITHUB_WORKSPACE/artifact/${{ matrix.binary }}" bin/clifcode
+ chmod +x bin/clifcode
+ fi
+
+ - name: Publish
+ working-directory: clif-code-tui/npm/@clifcode/${{ matrix.package }}
+ run: npm publish --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ # --------------------------------------------------
+ # Publish main wrapper package
+ # --------------------------------------------------
+ publish-main:
+ needs: publish-platform
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ registry-url: https://registry.npmjs.org
+
+ - name: Determine version
+ id: version
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
+ else
+ TAG="${GITHUB_REF#refs/tags/clifcode-v}"
+ echo "version=$TAG" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Patch version
+ working-directory: clif-code-tui/npm/clifcode
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ node -e "
+ const pkg = require('./package.json');
+ pkg.version = '$VERSION';
+ if (pkg.optionalDependencies) {
+ for (const dep of Object.keys(pkg.optionalDependencies)) {
+ pkg.optionalDependencies[dep] = '$VERSION';
+ }
+ }
+ require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
+ "
+
+ - name: Publish
+ working-directory: clif-code-tui/npm/clifcode
+ run: npm publish --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8d4285a..a962873 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,4 +1,4 @@
-name: Release & Build
+name: ClifPad Release & Build
on:
push:
@@ -22,8 +22,10 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
- - run: npm ci
+ cache-dependency-path: clif-pad-ide/package-lock.json
+ - run: cd clif-pad-ide && npm ci
- name: Semantic Release
+ working-directory: clif-pad-ide
id: semantic
uses: cycjimmy/semantic-release-action@v4
env:
@@ -64,14 +66,15 @@ jobs:
VERSION="${{ needs.semantic-release.outputs.new-release-version }}"
echo "Syncing version: $VERSION"
git pull origin main || true
- sed -i '' "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
- node scripts/bump-version.js "$VERSION"
+ sed -i '' "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" clif-pad-ide/src-tauri/tauri.conf.json
+ cd clif-pad-ide && node scripts/bump-version.js "$VERSION"
echo "Version synchronized"
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
+ cache-dependency-path: clif-pad-ide/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -81,18 +84,18 @@ jobs:
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
- workspaces: src-tauri -> target
+ workspaces: clif-pad-ide/src-tauri -> target
cache-on-failure: true
- name: Install Dependencies
run: |
- npm ci
+ cd clif-pad-ide && npm ci
echo "Node: $(node --version)"
echo "Rust: $(rustc --version)"
- name: Build Frontend
run: |
- npm run build
+ cd clif-pad-ide && npm run build
echo "Frontend built: $(du -sh dist/)"
- name: Build Tauri App
@@ -101,15 +104,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v${{ needs.semantic-release.outputs.new-release-version }}
- releaseName: Clif v${{ needs.semantic-release.outputs.new-release-version }}
- releaseBody: "See the assets to download and install Clif."
+ releaseName: ClifPad v${{ needs.semantic-release.outputs.new-release-version }}
+ releaseBody: "See the assets to download and install ClifPad."
releaseDraft: false
prerelease: false
+ projectPath: clif-pad-ide
args: --target ${{ matrix.target }}
- name: Generate Checksums
run: |
- cd src-tauri/target/${{ matrix.target }}/release/bundle
+ cd clif-pad-ide/src-tauri/target/${{ matrix.target }}/release/bundle
find . -name "*.dmg" -o -name "*.tar.gz" -o -name "*.zip" | while read f; do
shasum -a 256 "$f" > "$f.sha256"
echo "Checksum: $(cat "$f.sha256")"
@@ -119,7 +123,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.semantic-release.outputs.new-release-version }}
- files: src-tauri/target/${{ matrix.target }}/release/bundle/**/*.sha256
+ files: clif-pad-ide/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -145,13 +149,14 @@ jobs:
$VERSION = "${{ needs.semantic-release.outputs.new-release-version }}"
echo "Syncing version: $VERSION"
git pull origin main
- node scripts/bump-version.js "$VERSION"
+ cd clif-pad-ide && node scripts/bump-version.js "$VERSION"
echo "Version synchronized"
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
+ cache-dependency-path: clif-pad-ide/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -159,14 +164,14 @@ jobs:
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
- workspaces: src-tauri -> target
+ workspaces: clif-pad-ide/src-tauri -> target
cache-on-failure: true
- name: Install Dependencies
- run: npm ci
+ run: cd clif-pad-ide && npm ci
- name: Build Frontend
- run: npm run build
+ run: cd clif-pad-ide && npm run build
- name: Build Tauri App
uses: tauri-apps/tauri-action@v0
@@ -174,15 +179,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v${{ needs.semantic-release.outputs.new-release-version }}
- releaseName: Clif v${{ needs.semantic-release.outputs.new-release-version }}
- releaseBody: "See the assets to download and install Clif."
+ releaseName: ClifPad v${{ needs.semantic-release.outputs.new-release-version }}
+ releaseBody: "See the assets to download and install ClifPad."
releaseDraft: false
prerelease: false
+ projectPath: clif-pad-ide
- name: Generate Checksums
shell: bash
run: |
- cd src-tauri/target/release/bundle
+ cd clif-pad-ide/src-tauri/target/release/bundle
find . -name "*.msi" -o -name "*.exe" -o -name "*.zip" | while read f; do
sha256sum "$f" > "$f.sha256"
echo "Checksum: $(cat "$f.sha256")"
@@ -192,7 +198,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.semantic-release.outputs.new-release-version }}
- files: src-tauri/target/release/bundle/**/*.sha256
+ files: clif-pad-ide/src-tauri/target/release/bundle/**/*.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -230,13 +236,14 @@ jobs:
VERSION="${{ needs.semantic-release.outputs.new-release-version }}"
echo "Syncing version: $VERSION"
git pull origin main || true
- node scripts/bump-version.js "$VERSION"
+ cd clif-pad-ide && node scripts/bump-version.js "$VERSION"
echo "Version synchronized"
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
+ cache-dependency-path: clif-pad-ide/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -244,14 +251,14 @@ jobs:
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
- workspaces: src-tauri -> target
+ workspaces: clif-pad-ide/src-tauri -> target
cache-on-failure: true
- name: Install Dependencies
- run: npm ci
+ run: cd clif-pad-ide && npm ci
- name: Build Frontend
- run: npm run build
+ run: cd clif-pad-ide && npm run build
- name: Build Tauri App
uses: tauri-apps/tauri-action@v0
@@ -259,14 +266,15 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v${{ needs.semantic-release.outputs.new-release-version }}
- releaseName: Clif v${{ needs.semantic-release.outputs.new-release-version }}
- releaseBody: "See the assets to download and install Clif."
+ releaseName: ClifPad v${{ needs.semantic-release.outputs.new-release-version }}
+ releaseBody: "See the assets to download and install ClifPad."
releaseDraft: false
prerelease: false
+ projectPath: clif-pad-ide
- name: Generate Checksums
run: |
- cd src-tauri/target/release/bundle
+ cd clif-pad-ide/src-tauri/target/release/bundle
find . -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.gz" | while read f; do
sha256sum "$f" > "$f.sha256"
echo "Checksum: $(cat "$f.sha256")"
@@ -276,6 +284,6 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.semantic-release.outputs.new-release-version }}
- files: src-tauri/target/release/bundle/**/*.sha256
+ files: clif-pad-ide/src-tauri/target/release/bundle/**/*.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 94c948b..568c2d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,7 +5,16 @@ node_modules/
# Build output
dist/
-src-tauri/target/
+
+# ClifPad (Tauri desktop app)
+clif-pad-ide/src-tauri/target/
+clif-pad-ide/src-tauri/gen/
+clif-pad-ide/dist/
+clif-pad-ide/node_modules/
+
+# ClifCode (Rust TUI agent)
+clif-code-tui/target/
+clif-code-tui/npm/@clifcode/*/bin/
# Environment
.env
@@ -30,10 +39,6 @@ Thumbs.db
Desktop.ini
WixTools/
-# Tauri
-src-tauri/target/
-src-tauri/gen/
-
# Logs
*.log
npm-debug.log*
diff --git a/CLAUDE.md b/CLAUDE.md
index 510cf55..fd1ce69 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,33 +1,51 @@
-# Clif — AI-Native Code Editor
-
-## Project Overview
-Clif is a blazing-fast, privacy-first, open-source AI-native code editor built with Tauri 2 + SolidJS + Monaco Editor. It competes with Cursor, Zed, and Windsurf.
-
-## Tech Stack
-- **App shell**: Tauri 2 (Rust backend, ~20MB binary)
-- **Frontend**: SolidJS + TypeScript (reactive, ~7KB runtime)
-- **Editor**: Monaco Editor (VS Code quality editing)
-- **Styling**: Tailwind CSS 4 (utility-first)
-- **Package manager**: npm
-- **Build**: Vite 6
-- **AI**: OpenRouter API (cloud) + Ollama (local) + Claude Code CLI
-
-## Architecture
-- `src-tauri/` — Rust backend: file I/O, AI API calls, git ops, Claude Code subprocess
-- `src/` — SolidJS frontend: components, stores, types, lib utilities
-- IPC via Tauri commands (invoke) and events (emit/listen)
+# Clif-Code Monorepo
+
+Two products in one repo:
+
+## ClifPad (`clif-pad-ide/`)
+Desktop AI-native code editor — Tauri 2 + SolidJS + Monaco Editor.
+
+**Tech**: Tauri 2 (Rust), SolidJS, TypeScript, Monaco, Tailwind CSS 4, Vite 6
+**AI**: OpenRouter API, Ollama, Claude Code CLI
+
+```
+cd clif-pad-ide && npm install && npm run tauri dev
+```
+
+### Layout
+- `clif-pad-ide/src/` — SolidJS frontend (components, stores, types)
+- `clif-pad-ide/src-tauri/` — Rust backend (commands, services, state)
+- `clif-pad-ide/www/` — Landing page (clifcode.io, deployed via Vercel)
+- `clif-pad-ide/scripts/` — Version bump script
+
+### Conventions
+- SolidJS: `class=` not `className=`, stores in `src/stores/`
+- Tauri commands in `src-tauri/src/commands/`, IPC wrappers in `src/lib/tauri.ts`
- AI streaming via Tauri events ("ai_stream", "claude-code-output")
-## Conventions
-- SolidJS uses `class=` not `className=`
-- CSS custom properties for theming via `style={{ prop: "var(--name)" }}`
-- Stores are in `src/stores/` using SolidJS signals and createStore
-- Tauri commands are in `src-tauri/src/commands/`
-- Type definitions in `src/types/`
-- All Tauri IPC wrappers in `src/lib/tauri.ts`
-
-## Commands
-- `npm install` — Install dependencies
-- `npm run tauri dev` — Run in development mode with hot reload
-- `npm run tauri build` — Build production binary
-- `cd src-tauri && cargo check` — Check Rust compilation
+## ClifCode (`clif-code-tui/`)
+TUI terminal agent — Rust, API-only (no local model inference).
+
+```
+cd clif-code-tui && cargo run --release
+```
+
+### Layout
+- `clif-code-tui/src/main.rs` — CLI, TUI loop, agent orchestration
+- `clif-code-tui/src/backend.rs` — API backend (OpenRouter, OpenAI, Ollama)
+- `clif-code-tui/src/tools.rs` — Tool definitions and execution
+- `clif-code-tui/src/ui.rs` — Terminal UI rendering
+- `clif-code-tui/src/session.rs` — Session persistence
+- `clif-code-tui/src/config.rs` — Config (API keys, provider setup)
+- `clif-code-tui/src/git.rs` — Git integration
+- `clif-code-tui/src/repomap.rs` — Workspace structure analysis
+
+### npm Distribution (`clif-code-tui/npm/`)
+- `clif-code-tui/npm/clifcode/` — Main wrapper package (`npm i -g clifcode`)
+- `clif-code-tui/npm/@clifcode/cli-*` — Platform-specific binary packages
+- `clif-code-tui/scripts/bump-version.js` — Syncs version across Cargo.toml + npm packages
+
+## CI/CD
+- `.github/workflows/release.yml` — Semantic release + multi-platform ClifPad builds
+- `.github/workflows/clifcode-release.yml` — ClifCode TUI builds + npm publish
+- Vercel deploys `clif-pad-ide/www/` to clifcode.io
diff --git a/README.md b/README.md
index 4fb6bf6..d1519e1 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,31 @@
-
+
+
+
+
+
-Clif
-
- ~20MB. Native. AI-native. Your code never leaves your machine.
+ ~20MB desktop IDE. Terminal AI agent. Both native. Both open source.
-
-
-
-
-
-
+
+
+
+
+
+
+
- Install ·
- Features ·
- Architecture ·
- Development
+ 🌐 Website ·
+ 🖥️ ClifPad ·
+ ⚡ ClifCode ·
+ 🛠️ Development ·
+ 📦 Releases
---
@@ -32,68 +36,82 @@ Cursor is 400MB. VS Code is 350MB. Zed doesn't do AI.
No Electron. No telemetry. No subscription. Open source.
-## Download v1.3.0
+
+
+
+
+```
+Clif-Code/
+├── clif-pad-ide/ 🖥️ Desktop IDE — Tauri 2 + SolidJS + Monaco
+├── clif-code-tui/ ⚡ Terminal AI agent — pure Rust, any API
+└── .github/ 🔄 CI/CD (auto-release, npm publish)
+```
+
+---
+
+## 🖥️ ClifPad — Download
-
+
-
+
-
+
-
+
-
+
-> [All releases & checksums](https://github.com/DLhugly/Clif/releases)
+> [All releases & checksums](https://github.com/DLhugly/Clif-Code/releases)
### macOS — "App can't be opened"
Clif is open source but not yet notarized with Apple ($99/year). macOS blocks unsigned apps by default. This is normal for open source software — run one command to fix it:
```bash
-xattr -cr /Applications/Clif.app
+xattr -cr /Applications/ClifPad.app
```
-Then open Clif normally. This removes the quarantine flag that macOS sets on downloads. [Why does this happen?](#faq)
+Then open ClifPad normally. This removes the quarantine flag that macOS sets on downloads. [Why does this happen?](#-faq)
+
+**From source:**
-**From source** —
```bash
-git clone https://github.com/DLhugly/Clif.git && cd Clif
-npm install && npm run tauri dev
+git clone https://github.com/DLhugly/Clif-Code.git && cd Clif-Code
+cd clif-pad-ide && npm install && npm run tauri dev
```
-## Features
+### ✨ Features
-**Monaco Editor** — 70+ languages, IntelliSense, multi-cursor, minimap, code folding. The same engine as VS Code.
+**📝 Monaco Editor** — 70+ languages, IntelliSense, multi-cursor, minimap, code folding. The same engine as VS Code.
-**Real Terminal** — Native PTY via Rust. Your actual shell with 256-color, resize, 10K scrollback. Not a simulation.
+**🖥️ Real Terminal** — Native PTY via Rust. Your actual shell with 256-color, resize, 10K scrollback. Not a simulation.
-**Dev Preview** — One-click `npm run dev`, auto-detects `localhost` URLs, live iframe preview. Run and see your app without switching windows.
+**🔍 Dev Preview** — One-click `npm run dev`, auto-detects `localhost` URLs, live iframe preview. Run and see your app without switching windows.
-**Git** — Branch, status, stage, commit, per-file `+/-` diff stats, visual commit graph. All Rust, no shelling out.
+**🌿 Git** — Branch, status, stage, commit, per-file `+/-` diff stats, visual commit graph. All Rust, no shelling out.
-**AI** — OpenRouter (Claude, GPT-4, Gemini, 100+ models), Ollama (fully local), Claude Code CLI. All opt-in. Works fine offline with zero keys.
+**🤖 AI** — OpenRouter (Claude, GPT-4, Gemini, 100+ models), Ollama (fully local), Claude Code CLI. Ghost text completions. All opt-in. Works fine offline with zero keys.
-**5 Themes** — Midnight, Graphite, Dawn, Arctic, Dusk. Editor, terminal, and UI stay in sync.
+**🎨 5 Themes** — Midnight, Graphite, Dawn, Arctic, Dusk. Editor, terminal, and UI stay in sync.
-**Keys** — `Ctrl+`` ` terminal, `Ctrl+B` sidebar, `Ctrl+S` save, `Ctrl+Shift+P` palette.
+**⌨️ Keys** — `Ctrl+`` ` terminal, `Ctrl+B` sidebar, `Ctrl+S` save, `Ctrl+Shift+P` palette.
-## The Size Flex
+### 📊 The Size Flex
| | Binary | Runtime | RAM idle |
|---|--------|---------|----------|
-| **Clif** | **~20MB** | **7KB** | **~80MB** |
-| Cursor | ~400MB | ~50MB | ~500MB+ |
-| VS Code | ~350MB | ~40MB | ~400MB+ |
-| Zed | ~100MB | native | ~200MB |
+| **ClifPad** | **~20MB** 🟢 | **7KB** 🟢 | **~80MB** 🟢 |
+| Cursor | ~400MB 🔴 | ~50MB 🔴 | ~500MB+ 🔴 |
+| VS Code | ~350MB 🔴 | ~40MB 🔴 | ~400MB+ 🔴 |
+| Zed | ~100MB 🟡 | native | ~200MB 🟡 |
Tauri 2 compiles to a single native binary. SolidJS has no virtual DOM overhead. Rust handles all heavy lifting — file I/O, git, PTY, AI streaming — with zero garbage collection.
-## Architecture
+### 🏗️ Architecture
```
┌─────────────────────────────────────────┐
@@ -118,44 +136,209 @@ Tauri 2 compiles to a single native binary. SolidJS has no virtual DOM overhead.
| Build | Vite 6 | <5s HMR |
| CI/CD | Semantic Release | auto-versioned |
-## Development
+---
+
+## ⚡ ClifCode — Install
+
+> **Open-source AI coding agent for your terminal. Like Claude Code — but you own it.**
+
+```bash
+npm i -g clifcode
+```
+
+That's it. Run `clifcode` in any project directory.
+
+
+Other install methods
+
+```bash
+# Build from source
+git clone https://github.com/DLhugly/Clif-Code.git && cd Clif-Code
+cd clif-code-tui && cargo install --path .
+
+# Or just run it directly
+cd clif-code-tui && cargo run --release
+```
+
+
+
+### 🎬 How it looks
+
+ClifCode is a tool-calling AI agent that reads your codebase, writes code, runs commands, searches files, and auto-commits — all from a beautiful TUI.
+
+```
+ _____ _ _ __ _____ _
+ / ____| (_)/ _/ ____| | |
+ | | | |_| || | ___ __| | ___
+ | | | | | _| | / _ \ / _` |/ _ \
+ | |____| | | | | |___| (_) | (_| | __/
+ \_____|_|_|_| \_____\___/ \__,_|\___|
+
+ AI coding assistant — works anywhere, ships fast
+
+ ◆ Model anthropic/claude-sonnet-4 ◆ Mode auto-edit
+ ◆ Path ~/projects/my-app
+
+ Type a task to get started, or /help for commands
+ ─────────────────────────────────────────────
+
+ ❯ refactor the auth module to use JWT tokens
+
+ [1/7] ••• thinking
+ ▶ read src/auth/mod.rs
+ ▶ read src/auth/session.rs
+ ◇ find config.toml
+ ✎ edit src/auth/mod.rs +42 -18
+ ✎ edit src/auth/session.rs +15 -8
+ ▸ run cargo test
+ ✓ All 23 tests passed
+
+ ✦ ClifCode Refactored auth module to use JWT tokens.
+ Replaced session-based auth with stateless JWT
+ verification. Added token expiry and refresh logic.
+
+ ∙ 2.1k tokens ∙ ~$0.0312
+```
+
+### 🛠️ Features
+
+| | Feature | Details |
+|---|---------|---------|
+| 🔄 | **Agentic loop** | Up to 7 tool calls per turn — reads, writes, runs, searches, commits automatically |
+| 🌐 | **Any provider** | OpenRouter, OpenAI, Anthropic, Ollama, or any OpenAI-compatible endpoint |
+| ⚡ | **Parallel tools** | Read-only calls (file reads, searches) execute concurrently on threads |
+| 📡 | **Streaming** | Responses render live with markdown formatting, code blocks, and syntax hints |
+| 🎛️ | **3 autonomy modes** | `suggest` — confirm writes · `auto-edit` — apply with diffs · `full-auto` — hands-off |
+| 💾 | **Sessions** | Auto-saves every conversation. Resume any session with `/resume` |
+| 🔀 | **Auto-commit** | Commits changes with descriptive messages. One-command `/undo` |
+| 💰 | **Cost tracking** | Per-turn and session-wide token usage with estimated cost |
+| 🧠 | **Workspace intel** | Auto-scans project structure, reads README/Cargo.toml/package.json for context |
+| 🔧 | **Non-interactive** | `clifcode -p "fix the bug"` for scripts and CI |
+
+### 🔧 9 Built-in Tools
+
+```
+ ▶ read_file Read files (with offset for large files)
+ ✎ write_file Create new files
+ ✎ edit_file Surgical find-and-replace with diff preview
+ ◇ find_file Locate files by name across the workspace
+ ☰ list_files Directory listing with structure
+ ⌕ search Regex search across your codebase
+ ▸ run_command Execute shell commands
+ → change_directory Switch workspace context
+ ✓ submit Signal task completion + auto-commit
+```
+
+### 💻 Usage
```bash
-npm install # deps
-npm run tauri dev # dev mode + hot reload
-npm run tauri build # production binary
-cd src-tauri && cargo check # check rust
+clifcode # interactive, auto-detect backend
+clifcode -p "explain this codebase" # non-interactive single prompt
+clifcode --backend api --api-model gpt-4o # specific model
+clifcode --backend ollama # local Ollama
+clifcode --autonomy suggest # confirm every write
+clifcode --resume # resume last session
+```
+
+### ⌨️ Commands
+
+```
+ ◆ Session /new /sessions /resume /cost /clear /quit
+ ◆ Workspace /cd /add /drop /context
+ ◆ Settings /mode /backend /config
+ ◆ Git /status /undo
```
+### 🔌 Supported Providers
+
+| Provider | Config |
+|----------|--------|
+| **OpenRouter** (default) | `CLIFCODE_API_KEY` — access to 100+ models |
+| **OpenAI** | `--api-url https://api.openai.com/v1` |
+| **Anthropic** | Via OpenRouter or compatible proxy |
+| **Ollama** | `--backend ollama` — fully local, no API key needed |
+| **Any OpenAI-compatible** | `--api-url ` |
+
+---
+
+## 🛠️ Development
+
+```bash
+# ClifPad — desktop IDE
+cd clif-pad-ide
+npm install && npm run tauri dev # dev mode + hot reload
+npm run tauri build # production binary
+
+# ClifCode — terminal agent
+cd clif-code-tui
+cargo run --release # run directly
+cargo install --path . # install to PATH
```
-src/ # SolidJS frontend
-├── components/ # editor, terminal, layout, explorer
-├── stores/ # reactive state (signals + stores)
-├── lib/ # IPC wrappers, keybindings, themes
-└── types/ # TypeScript interfaces
-src-tauri/src/ # Rust backend
-├── commands/ # fs, git, pty, ai, search, settings
-└── services/ # file watcher, ai providers
+### Project Structure
+
+```
+clif-pad-ide/
+├── src/ # SolidJS frontend
+│ ├── components/ # editor, terminal, layout, explorer
+│ ├── stores/ # reactive state (signals + stores)
+│ ├── lib/ # IPC wrappers, keybindings, themes
+│ └── types/ # TypeScript interfaces
+├── src-tauri/src/ # Rust backend
+│ ├── commands/ # fs, git, pty, ai, search, settings
+│ └── services/ # file watcher, ai providers
+└── www/ # Landing page (clifcode.io)
+
+clif-code-tui/
+├── src/
+│ ├── main.rs # CLI, TUI loop, agent orchestration
+│ ├── backend.rs # API backend (OpenRouter, OpenAI, Ollama)
+│ ├── tools.rs # Tool definitions and execution
+│ ├── ui.rs # Terminal UI rendering
+│ ├── session.rs # Session persistence
+│ ├── config.rs # Config (API keys, provider setup)
+│ ├── git.rs # Git integration
+│ └── repomap.rs # Workspace structure analysis
+├── npm/ # npm distribution packages
+│ ├── clifcode/ # Main wrapper (npm i -g clifcode)
+│ └── @clifcode/cli-*/ # 6 platform-specific binaries
+└── scripts/
+ └── bump-version.js # Syncs versions across Cargo.toml + npm
```
[Conventional commits](https://www.conventionalcommits.org/) — `feat:` bumps minor, `fix:` bumps patch, `feat!:` bumps major. Semantic release handles the rest.
-## FAQ
+---
+
+## ❓ FAQ
**Why does macOS say "App can't be opened"?**
-macOS Gatekeeper blocks apps that aren't signed with a $99/year Apple Developer certificate. Clif is open source and safe — run `xattr -cr /Applications/Clif.app` in Terminal to remove the quarantine flag, then open normally.
+macOS Gatekeeper blocks apps that aren't signed with a $99/year Apple Developer certificate. ClifPad is open source and safe — run `xattr -cr /Applications/ClifPad.app` in Terminal to remove the quarantine flag, then open normally.
**Is Clif safe?**
-100% open source. Read every line: [github.com/DLhugly/Clif](https://github.com/DLhugly/Clif). No telemetry, no network calls unless you enable AI. The `xattr` command just removes Apple's download flag — it doesn't disable any security.
+100% open source. Read every line: [github.com/DLhugly/Clif-Code](https://github.com/DLhugly/Clif-Code). No telemetry, no network calls unless you enable AI. The `xattr` command just removes Apple's download flag — it doesn't disable any security.
**Why not just pay for code signing?**
We will. For now, the $99/year Apple Developer fee goes toward more important things. Proper signing + notarization is on the roadmap.
-## License
+**Does it work offline?**
+ClifPad: Yes — AI features are opt-in. Without API keys, it's a fully offline editor with terminal and git. ClifCode: Needs an API provider (but Ollama runs fully local with no internet).
-[MIT](LICENSE)
+**What models does ClifCode support?**
+Any OpenAI-compatible API. Default is `anthropic/claude-sonnet-4` via OpenRouter. Also works with GPT-4o, Gemini, Llama, Qwen, Mistral, DeepSeek — anything on OpenRouter or Ollama.
---
-20MB. Native. Private. Fast.
+## 📜 License
+
+[MIT](LICENSE) — use it however you want.
+
+
+
+
+ 20MB. Native. Private. Fast.
+
+
+
+ Built with 🦀 Rust and ❤️ by DLhugly
+
diff --git a/clif-code-tui/Cargo.lock b/clif-code-tui/Cargo.lock
new file mode 100644
index 0000000..1d0cf39
--- /dev/null
+++ b/clif-code-tui/Cargo.lock
@@ -0,0 +1,1179 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "clap"
+version = "4.5.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
+
+[[package]]
+name = "clifcode"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "crossterm",
+ "ctrlc",
+ "serde",
+ "serde_json",
+ "similar",
+ "tracing",
+ "tracing-subscriber",
+ "ureq",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "ctrlc"
+version = "3.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
+dependencies = [
+ "dispatch2",
+ "nix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "litemap"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nix"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+dependencies = [
+ "base64",
+ "flate2",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.6",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+[[package]]
+name = "yoke"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/clif-code-tui/Cargo.toml b/clif-code-tui/Cargo.toml
new file mode 100644
index 0000000..d9c6531
--- /dev/null
+++ b/clif-code-tui/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "clifcode"
+version = "0.1.0"
+edition = "2021"
+description = "ClifCode: AI coding assistant TUI"
+
+[[bin]]
+name = "clifcode"
+path = "src/main.rs"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+anyhow = "1"
+tracing = "0.1"
+tracing-subscriber = "0.3"
+clap = { version = "4", features = ["derive", "env"] }
+ureq = { version = "2", features = ["json"] }
+similar = "2"
+crossterm = "0.28"
+ctrlc = "3"
+
+[profile.release]
+opt-level = 3
+lto = true
diff --git a/clif-code-tui/npm/@clifcode/cli-darwin-arm64/package.json b/clif-code-tui/npm/@clifcode/cli-darwin-arm64/package.json
new file mode 100644
index 0000000..d8d66d0
--- /dev/null
+++ b/clif-code-tui/npm/@clifcode/cli-darwin-arm64/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@clifcode/cli-darwin-arm64",
+ "version": "0.1.0",
+ "description": "ClifCode binary for macOS Apple Silicon (aarch64)",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DLhugly/Clif-Code"
+ },
+ "os": ["darwin"],
+ "cpu": ["arm64"],
+ "files": ["bin/clifcode"]
+}
diff --git a/clif-code-tui/npm/@clifcode/cli-darwin-x64/package.json b/clif-code-tui/npm/@clifcode/cli-darwin-x64/package.json
new file mode 100644
index 0000000..1ee4357
--- /dev/null
+++ b/clif-code-tui/npm/@clifcode/cli-darwin-x64/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@clifcode/cli-darwin-x64",
+ "version": "0.1.0",
+ "description": "ClifCode binary for macOS Intel (x86_64)",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DLhugly/Clif-Code"
+ },
+ "os": ["darwin"],
+ "cpu": ["x64"],
+ "files": ["bin/clifcode"]
+}
diff --git a/clif-code-tui/npm/@clifcode/cli-linux-arm64/package.json b/clif-code-tui/npm/@clifcode/cli-linux-arm64/package.json
new file mode 100644
index 0000000..86908e8
--- /dev/null
+++ b/clif-code-tui/npm/@clifcode/cli-linux-arm64/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@clifcode/cli-linux-arm64",
+ "version": "0.1.0",
+ "description": "ClifCode binary for Linux ARM64 (aarch64)",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DLhugly/Clif-Code"
+ },
+ "os": ["linux"],
+ "cpu": ["arm64"],
+ "files": ["bin/clifcode"]
+}
diff --git a/clif-code-tui/npm/@clifcode/cli-linux-x64/package.json b/clif-code-tui/npm/@clifcode/cli-linux-x64/package.json
new file mode 100644
index 0000000..c162e8d
--- /dev/null
+++ b/clif-code-tui/npm/@clifcode/cli-linux-x64/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@clifcode/cli-linux-x64",
+ "version": "0.1.0",
+ "description": "ClifCode binary for Linux x86_64",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DLhugly/Clif-Code"
+ },
+ "os": ["linux"],
+ "cpu": ["x64"],
+ "files": ["bin/clifcode"]
+}
diff --git a/clif-code-tui/npm/@clifcode/cli-win32-arm64/package.json b/clif-code-tui/npm/@clifcode/cli-win32-arm64/package.json
new file mode 100644
index 0000000..98d3f29
--- /dev/null
+++ b/clif-code-tui/npm/@clifcode/cli-win32-arm64/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@clifcode/cli-win32-arm64",
+ "version": "0.1.0",
+ "description": "ClifCode binary for Windows ARM64",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DLhugly/Clif-Code"
+ },
+ "os": ["win32"],
+ "cpu": ["arm64"],
+ "files": ["bin/clifcode.exe"]
+}
diff --git a/clif-code-tui/npm/@clifcode/cli-win32-x64/package.json b/clif-code-tui/npm/@clifcode/cli-win32-x64/package.json
new file mode 100644
index 0000000..a6fd85d
--- /dev/null
+++ b/clif-code-tui/npm/@clifcode/cli-win32-x64/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@clifcode/cli-win32-x64",
+ "version": "0.1.0",
+ "description": "ClifCode binary for Windows x86_64",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DLhugly/Clif-Code"
+ },
+ "os": ["win32"],
+ "cpu": ["x64"],
+ "files": ["bin/clifcode.exe"]
+}
diff --git a/clif-code-tui/npm/clifcode/bin/clifcode b/clif-code-tui/npm/clifcode/bin/clifcode
new file mode 100755
index 0000000..37e3111
--- /dev/null
+++ b/clif-code-tui/npm/clifcode/bin/clifcode
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+
+const { spawnSync } = require("child_process");
+const path = require("path");
+const fs = require("fs");
+
+const PLATFORM_MAP = {
+ "darwin-arm64": "@clifcode/cli-darwin-arm64",
+ "darwin-x64": "@clifcode/cli-darwin-x64",
+ "linux-arm64": "@clifcode/cli-linux-arm64",
+ "linux-x64": "@clifcode/cli-linux-x64",
+ "win32-arm64": "@clifcode/cli-win32-arm64",
+ "win32-x64": "@clifcode/cli-win32-x64",
+};
+
+function getBinaryPath() {
+ const key = `${process.platform}-${process.arch}`;
+ const pkg = PLATFORM_MAP[key];
+
+ if (!pkg) {
+ console.error(`Unsupported platform: ${key}`);
+ console.error(`Supported: ${Object.keys(PLATFORM_MAP).join(", ")}`);
+ process.exit(1);
+ }
+
+ // Try resolving the platform package
+ try {
+ const pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
+ const ext = process.platform === "win32" ? ".exe" : "";
+ const bin = path.join(pkgDir, "bin", `clifcode${ext}`);
+ if (fs.existsSync(bin)) return bin;
+ } catch {}
+
+ // Fallback: check if postinstall downloaded the binary
+ const ext = process.platform === "win32" ? ".exe" : "";
+ const fallback = path.join(__dirname, `clifcode${ext}`);
+ if (fs.existsSync(fallback)) return fallback;
+
+ console.error(`ClifCode binary not found for ${key}.`);
+ console.error(`Try reinstalling: npm i -g clifcode`);
+ process.exit(1);
+}
+
+const bin = getBinaryPath();
+const result = spawnSync(bin, process.argv.slice(2), {
+ stdio: "inherit",
+ env: process.env,
+});
+
+if (result.error) {
+ if (result.error.code === "ENOENT") {
+ console.error(`Binary not found: ${bin}`);
+ } else {
+ console.error(result.error.message);
+ }
+ process.exit(1);
+}
+
+process.exit(result.status ?? 1);
diff --git a/clif-code-tui/npm/clifcode/install.js b/clif-code-tui/npm/clifcode/install.js
new file mode 100644
index 0000000..027de26
--- /dev/null
+++ b/clif-code-tui/npm/clifcode/install.js
@@ -0,0 +1,90 @@
+const https = require("https");
+const http = require("http");
+const fs = require("fs");
+const path = require("path");
+
+const PLATFORM_MAP = {
+ "darwin-arm64": "aarch64-apple-darwin",
+ "darwin-x64": "x86_64-apple-darwin",
+ "linux-arm64": "aarch64-unknown-linux-gnu",
+ "linux-x64": "x86_64-unknown-linux-gnu",
+ "win32-arm64": "aarch64-pc-windows-msvc",
+ "win32-x64": "x86_64-pc-windows-msvc",
+};
+
+function getPlatformPackage() {
+ const key = `${process.platform}-${process.arch}`;
+ return `@clifcode/cli-${process.platform}-${process.arch}`;
+}
+
+function hasPlatformBinary() {
+ try {
+ const pkg = getPlatformPackage();
+ const pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
+ const ext = process.platform === "win32" ? ".exe" : "";
+ return fs.existsSync(path.join(pkgDir, "bin", `clifcode${ext}`));
+ } catch {
+ return false;
+ }
+}
+
+function download(url, dest, redirects = 0) {
+ if (redirects > 5) {
+ throw new Error("Too many redirects");
+ }
+
+ return new Promise((resolve, reject) => {
+ const client = url.startsWith("https") ? https : http;
+ client.get(url, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ return resolve(download(res.headers.location, dest, redirects + 1));
+ }
+ if (res.statusCode !== 200) {
+ return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
+ }
+ const file = fs.createWriteStream(dest);
+ res.pipe(file);
+ file.on("finish", () => {
+ file.close(resolve);
+ });
+ file.on("error", reject);
+ }).on("error", reject);
+ });
+}
+
+async function main() {
+ // If the platform package already has the binary, nothing to do
+ if (hasPlatformBinary()) {
+ return;
+ }
+
+ const key = `${process.platform}-${process.arch}`;
+ const target = PLATFORM_MAP[key];
+ if (!target) {
+ console.warn(`[clifcode] Unsupported platform: ${key}. Skipping download.`);
+ return;
+ }
+
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"));
+ const version = pkg.version;
+ const ext = process.platform === "win32" ? ".exe" : "";
+ const filename = `clifcode${ext}`;
+ const url = `https://github.com/DLhugly/Clif-Code/releases/download/clifcode-v${version}/clifcode-${target}${ext}`;
+ const dest = path.join(__dirname, "bin", filename);
+
+ console.log(`[clifcode] Downloading binary for ${key}...`);
+
+ try {
+ await download(url, dest);
+ if (process.platform !== "win32") {
+ fs.chmodSync(dest, 0o755);
+ }
+ console.log(`[clifcode] Binary installed successfully.`);
+ } catch (err) {
+ console.warn(`[clifcode] Failed to download binary: ${err.message}`);
+ console.warn(`[clifcode] You can build from source: cd clif-code-tui && cargo install --path .`);
+ // Exit 0 so npm install doesn't fail
+ }
+}
+
+main();
diff --git a/clif-code-tui/npm/clifcode/package.json b/clif-code-tui/npm/clifcode/package.json
new file mode 100644
index 0000000..bafea13
--- /dev/null
+++ b/clif-code-tui/npm/clifcode/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "clifcode",
+ "version": "0.1.0",
+ "description": "ClifCode — AI coding assistant TUI for your terminal",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DLhugly/Clif-Code"
+ },
+ "homepage": "https://clifcode.io",
+ "keywords": ["ai", "coding", "assistant", "tui", "terminal", "cli", "agent"],
+ "bin": {
+ "clifcode": "bin/clifcode"
+ },
+ "scripts": {
+ "postinstall": "node install.js"
+ },
+ "optionalDependencies": {
+ "@clifcode/cli-darwin-arm64": "0.1.0",
+ "@clifcode/cli-darwin-x64": "0.1.0",
+ "@clifcode/cli-linux-arm64": "0.1.0",
+ "@clifcode/cli-linux-x64": "0.1.0",
+ "@clifcode/cli-win32-arm64": "0.1.0",
+ "@clifcode/cli-win32-x64": "0.1.0"
+ }
+}
diff --git a/clif-code-tui/scripts/bump-version.js b/clif-code-tui/scripts/bump-version.js
new file mode 100644
index 0000000..ff24a49
--- /dev/null
+++ b/clif-code-tui/scripts/bump-version.js
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+
+// Syncs version across Cargo.toml and all npm package.json files
+// Usage: node bump-version.js
+
+const fs = require("fs");
+const path = require("path");
+
+const version = process.argv[2];
+if (!version) {
+ console.error("Usage: node bump-version.js ");
+ process.exit(1);
+}
+
+const root = path.resolve(__dirname, "..");
+
+// 1. Update Cargo.toml
+const cargoPath = path.join(root, "Cargo.toml");
+let cargo = fs.readFileSync(cargoPath, "utf8");
+cargo = cargo.replace(/^version\s*=\s*".*"/m, `version = "${version}"`);
+fs.writeFileSync(cargoPath, cargo);
+console.log(`Updated ${cargoPath}`);
+
+// 2. Update main package.json (including optionalDependencies versions)
+const mainPkgPath = path.join(root, "npm", "clifcode", "package.json");
+const mainPkg = JSON.parse(fs.readFileSync(mainPkgPath, "utf8"));
+mainPkg.version = version;
+if (mainPkg.optionalDependencies) {
+ for (const dep of Object.keys(mainPkg.optionalDependencies)) {
+ mainPkg.optionalDependencies[dep] = version;
+ }
+}
+fs.writeFileSync(mainPkgPath, JSON.stringify(mainPkg, null, 2) + "\n");
+console.log(`Updated ${mainPkgPath}`);
+
+// 3. Update all platform package.json files
+const platformDir = path.join(root, "npm", "@clifcode");
+const platforms = fs.readdirSync(platformDir).filter((d) =>
+ fs.statSync(path.join(platformDir, d)).isDirectory()
+);
+
+for (const platform of platforms) {
+ const pkgPath = path.join(platformDir, platform, "package.json");
+ if (!fs.existsSync(pkgPath)) continue;
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
+ pkg.version = version;
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
+ console.log(`Updated ${pkgPath}`);
+}
+
+console.log(`\nAll versions set to ${version}`);
diff --git a/clif-code-tui/src/backend.rs b/clif-code-tui/src/backend.rs
new file mode 100644
index 0000000..a2d4e7c
--- /dev/null
+++ b/clif-code-tui/src/backend.rs
@@ -0,0 +1,436 @@
+//! Model backend abstraction — API and stub.
+
+use crate::tools::{ApiToolCall, parse_api_tool_calls};
+use crate::ui;
+use anyhow::Result;
+use std::io::{BufRead, BufReader};
+
+/// Token usage from a single API call
+#[derive(Debug, Clone, Default)]
+pub struct TokenUsage {
+ pub prompt_tokens: usize,
+ pub completion_tokens: usize,
+}
+
+/// Result of a chat call — may contain text, tool calls, or both
+pub struct ChatResponse {
+ pub content: String,
+ pub tool_calls: Vec,
+ /// The raw assistant message for re-sending in conversation
+ pub raw_message: serde_json::Value,
+ /// Whether content was already streamed to terminal (skip print_assistant)
+ pub streamed: bool,
+ /// Token usage (if available from API)
+ pub usage: Option,
+}
+
+pub enum ModelBackend {
+ /// OpenAI-compatible API (OpenRouter, OpenAI, Anthropic, Ollama, etc.)
+ Api {
+ url: String,
+ key: Option,
+ model: String,
+ max_tokens: usize,
+ },
+ /// Testing stub (no model)
+ Stub,
+}
+
+impl ModelBackend {
+ pub fn name(&self) -> &str {
+ match self {
+ ModelBackend::Api { model, .. } => model.as_str(),
+ ModelBackend::Stub => "stub",
+ }
+ }
+
+ pub fn chat_with_tools(
+ &self,
+ messages: &[serde_json::Value],
+ tools: Option<&serde_json::Value>,
+ ) -> Result {
+ match self {
+ ModelBackend::Api { url, key, model, max_tokens } => {
+ api_chat_with_tools(url, key.as_deref(), model, messages, *max_tokens, tools)
+ }
+ ModelBackend::Stub => stub_response(messages),
+ }
+ }
+
+ /// Streaming chat — prints tokens live for API, falls back to non-streaming for others.
+ pub fn chat_stream(
+ &self,
+ messages: &[serde_json::Value],
+ tools: Option<&serde_json::Value>,
+ ) -> Result {
+ match self {
+ ModelBackend::Api { url, key, model, max_tokens } => {
+ api_chat_stream(url, key.as_deref(), model, messages, *max_tokens, tools)
+ }
+ // Local and stub don't support streaming — fall back
+ _ => self.chat_with_tools(messages, tools),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Stub backend
+// ---------------------------------------------------------------------------
+
+fn stub_response(messages: &[serde_json::Value]) -> Result {
+ let last_content = messages
+ .last()
+ .and_then(|m| m.get("content"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ let has_tool_results = messages
+ .iter()
+ .any(|m| m.get("role").and_then(|v| v.as_str()) == Some("tool"));
+
+ if has_tool_results {
+ Ok(ChatResponse {
+ content: String::new(),
+ tool_calls: vec![ApiToolCall {
+ id: "stub_1".into(),
+ name: "submit".into(),
+ arguments: r#"{"summary":"Explored the workspace."}"#.into(),
+ }],
+ raw_message: serde_json::json!({
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [{
+ "id": "stub_1",
+ "type": "function",
+ "function": {
+ "name": "submit",
+ "arguments": "{\"summary\":\"Explored the workspace.\"}"
+ }
+ }]
+ }),
+ streamed: false,
+ usage: None,
+ })
+ } else if last_content.len() < 20 {
+ Ok(ChatResponse {
+ content: "Hello! I'm ClifCode. Give me a coding task and I'll get to work.".into(),
+ tool_calls: vec![],
+ raw_message: serde_json::json!({
+ "role": "assistant",
+ "content": "Hello! I'm ClifCode. Give me a coding task and I'll get to work."
+ }),
+ streamed: false,
+ usage: None,
+ })
+ } else {
+ Ok(ChatResponse {
+ content: "Let me explore the project.".into(),
+ tool_calls: vec![ApiToolCall {
+ id: "stub_0".into(),
+ name: "run_command".into(),
+ arguments: r#"{"command":"ls -la"}"#.into(),
+ }],
+ raw_message: serde_json::json!({
+ "role": "assistant",
+ "content": "Let me explore the project.",
+ "tool_calls": [{
+ "id": "stub_0",
+ "type": "function",
+ "function": {
+ "name": "run_command",
+ "arguments": "{\"command\":\"ls -la\"}"
+ }
+ }]
+ }),
+ streamed: false,
+ usage: None,
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// API backend (OpenAI-compatible)
+// ---------------------------------------------------------------------------
+
+fn api_chat_with_tools(
+ base_url: &str,
+ api_key: Option<&str>,
+ model: &str,
+ messages: &[serde_json::Value],
+ max_tokens: usize,
+ tools: Option<&serde_json::Value>,
+) -> Result {
+ let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
+
+ let mut body = serde_json::json!({
+ "model": model,
+ "messages": messages,
+ "max_tokens": max_tokens,
+ "temperature": 0.7,
+ });
+
+ if let Some(tools) = tools {
+ body["tools"] = tools.clone();
+ }
+
+ let mut req = ureq::post(&url).set("Content-Type", "application/json");
+
+ if let Some(key) = api_key {
+ req = req.set("Authorization", &format!("Bearer {key}"));
+ }
+
+ let resp = req
+ .send_string(&body.to_string())
+ .map_err(|e| anyhow::anyhow!("API request failed: {e}"))?;
+
+ let resp_body: serde_json::Value = resp.into_json()?;
+
+ let content = resp_body
+ .pointer("/choices/0/message/content")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let tool_calls = parse_api_tool_calls(&resp_body);
+
+ let raw_message = resp_body
+ .pointer("/choices/0/message")
+ .cloned()
+ .unwrap_or_else(|| serde_json::json!({"role": "assistant", "content": content}));
+
+ let usage = extract_usage(&resp_body);
+
+ Ok(ChatResponse { content, tool_calls, raw_message, streamed: false, usage })
+}
+
+// ---------------------------------------------------------------------------
+// Streaming API (SSE)
+// ---------------------------------------------------------------------------
+
+fn api_chat_stream(
+ base_url: &str,
+ api_key: Option<&str>,
+ model: &str,
+ messages: &[serde_json::Value],
+ max_tokens: usize,
+ tools: Option<&serde_json::Value>,
+) -> Result {
+ let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
+
+ let mut body = serde_json::json!({
+ "model": model,
+ "messages": messages,
+ "max_tokens": max_tokens,
+ "temperature": 0.7,
+ "stream": true,
+ "stream_options": { "include_usage": true },
+ });
+
+ if let Some(tools) = tools {
+ body["tools"] = tools.clone();
+ }
+
+ let mut req = ureq::post(&url).set("Content-Type", "application/json");
+ if let Some(key) = api_key {
+ req = req.set("Authorization", &format!("Bearer {key}"));
+ }
+
+ let resp = req
+ .send_string(&body.to_string())
+ .map_err(|e| anyhow::anyhow!("API stream request failed: {e}"))?;
+
+ let reader = BufReader::new(resp.into_reader());
+
+ let mut full_content = String::new();
+ let mut started_printing = false;
+ let mut usage: Option = None;
+
+ // Streaming markdown state
+ let mut line_buffer = String::new();
+ let mut in_code_block = false;
+
+ // Tool call accumulators: index -> (id, name, arguments_buffer)
+ let mut tool_acc: Vec<(String, String, String)> = Vec::new();
+
+ for line_result in reader.lines() {
+ let line = match line_result {
+ Ok(l) => l,
+ Err(_) => break,
+ };
+
+ // SSE format: empty lines are separators, "data: " prefix carries payload
+ if line.is_empty() || !line.starts_with("data: ") {
+ continue;
+ }
+
+ let data = &line[6..]; // strip "data: "
+
+ // End of stream
+ if data == "[DONE]" {
+ break;
+ }
+
+ let chunk: serde_json::Value = match serde_json::from_str(data) {
+ Ok(v) => v,
+ Err(_) => continue,
+ };
+
+ let delta = match chunk.pointer("/choices/0/delta") {
+ Some(d) => d,
+ None => {
+ // Final chunk may have usage but no delta
+ if let Some(u) = chunk.get("usage") {
+ usage = extract_usage_from_obj(u);
+ }
+ continue;
+ }
+ };
+
+ // Stream text content with line-buffered markdown rendering
+ if let Some(token) = delta.get("content").and_then(|v| v.as_str()) {
+ if !token.is_empty() {
+ if !started_printing {
+ print!("\n {}{}\u{2726} ClifCode{} ", ui::BOLD, ui::BRIGHT_MAGENTA, ui::RESET);
+ started_printing = true;
+ }
+ full_content.push_str(token);
+ line_buffer.push_str(token);
+
+ // Process completed lines
+ while let Some(nl_pos) = line_buffer.find('\n') {
+ let completed_line: String = line_buffer[..nl_pos].to_string();
+ line_buffer = line_buffer[nl_pos + 1..].to_string();
+
+ // Track code block state
+ if completed_line.trim_start().starts_with("```") {
+ in_code_block = !in_code_block;
+ }
+
+ let rendered = ui::render_streaming_line(&completed_line, in_code_block && !completed_line.trim_start().starts_with("```"));
+ println!("{rendered}");
+ }
+ }
+ }
+
+ // Extract usage from final chunk (OpenAI/OpenRouter include it)
+ if let Some(u) = chunk.get("usage") {
+ usage = extract_usage_from_obj(u);
+ }
+
+ // Accumulate tool call deltas
+ if let Some(tc_array) = delta.get("tool_calls").and_then(|v| v.as_array()) {
+ for tc in tc_array {
+ let idx = tc.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
+
+ // Grow accumulator if needed
+ while tool_acc.len() <= idx {
+ tool_acc.push((String::new(), String::new(), String::new()));
+ }
+
+ // Capture tool call id (only sent in first delta for each tool call)
+ if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
+ if !id.is_empty() {
+ tool_acc[idx].0 = id.to_string();
+ }
+ }
+
+ // Capture function name (only sent in first delta)
+ if let Some(name) = tc.pointer("/function/name").and_then(|v| v.as_str()) {
+ if !name.is_empty() {
+ tool_acc[idx].1 = name.to_string();
+ }
+ }
+
+ // Accumulate function arguments (streamed across multiple deltas)
+ if let Some(args) = tc.pointer("/function/arguments").and_then(|v| v.as_str()) {
+ tool_acc[idx].2.push_str(args);
+ }
+ }
+ }
+ }
+
+ // Flush remaining line buffer
+ if !line_buffer.is_empty() {
+ if !started_printing {
+ print!("\n {}{}\u{2726} ClifCode{} ", ui::BOLD, ui::BRIGHT_MAGENTA, ui::RESET);
+ started_printing = true;
+ }
+ if line_buffer.trim_start().starts_with("```") {
+ in_code_block = !in_code_block;
+ }
+ let rendered = ui::render_streaming_line(&line_buffer, in_code_block && !line_buffer.trim_start().starts_with("```"));
+ println!("{rendered}");
+ }
+
+ // Finish the streamed output
+ if started_printing {
+ println!();
+ }
+
+ // Build tool calls from accumulated deltas
+ let tool_calls: Vec = tool_acc
+ .into_iter()
+ .filter(|(_, name, _)| !name.is_empty())
+ .map(|(id, name, arguments)| ApiToolCall { id, name, arguments })
+ .collect();
+
+ // Build raw_message for conversation history
+ let raw_message = if tool_calls.is_empty() {
+ serde_json::json!({"role": "assistant", "content": full_content})
+ } else {
+ let tc_json: Vec = tool_calls
+ .iter()
+ .map(|tc| {
+ serde_json::json!({
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": tc.arguments
+ }
+ })
+ })
+ .collect();
+
+ if full_content.is_empty() {
+ serde_json::json!({
+ "role": "assistant",
+ "content": null,
+ "tool_calls": tc_json
+ })
+ } else {
+ serde_json::json!({
+ "role": "assistant",
+ "content": full_content,
+ "tool_calls": tc_json
+ })
+ }
+ };
+
+ Ok(ChatResponse {
+ content: full_content,
+ tool_calls,
+ raw_message,
+ streamed: started_printing,
+ usage,
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Token usage extraction
+// ---------------------------------------------------------------------------
+
+fn extract_usage(resp: &serde_json::Value) -> Option {
+ resp.get("usage").and_then(extract_usage_from_obj)
+}
+
+fn extract_usage_from_obj(u: &serde_json::Value) -> Option {
+ let prompt = u.get("prompt_tokens").and_then(|v| v.as_u64())? as usize;
+ let completion = u.get("completion_tokens").and_then(|v| v.as_u64())? as usize;
+ Some(TokenUsage { prompt_tokens: prompt, completion_tokens: completion })
+}
+
+/// Quick check if Ollama is running locally
+pub fn detect_ollama() -> bool {
+ ureq::get("http://localhost:11434/api/tags").call().is_ok()
+}
diff --git a/clif-code-tui/src/config.rs b/clif-code-tui/src/config.rs
new file mode 100644
index 0000000..7695a3f
--- /dev/null
+++ b/clif-code-tui/src/config.rs
@@ -0,0 +1,155 @@
+//! Configuration persistence and interactive setup.
+
+use crate::ui;
+use std::path::PathBuf;
+
+pub struct ProviderInfo {
+ pub name: &'static str,
+ pub url: &'static str,
+ pub default_model: &'static str,
+ pub needs_key: bool,
+}
+
+pub const PROVIDERS: &[ProviderInfo] = &[
+ ProviderInfo {
+ name: "OpenRouter",
+ url: "https://openrouter.ai/api/v1",
+ default_model: "anthropic/claude-sonnet-4",
+ needs_key: true,
+ },
+ ProviderInfo {
+ name: "OpenAI",
+ url: "https://api.openai.com/v1",
+ default_model: "gpt-4o",
+ needs_key: true,
+ },
+ ProviderInfo {
+ name: "Anthropic",
+ url: "https://api.anthropic.com/v1",
+ default_model: "claude-sonnet-4-20250514",
+ needs_key: true,
+ },
+ ProviderInfo {
+ name: "Ollama (local)",
+ url: "http://localhost:11434/v1",
+ default_model: "qwen2.5-coder:7b",
+ needs_key: false,
+ },
+ ProviderInfo {
+ name: "Custom endpoint",
+ url: "",
+ default_model: "",
+ needs_key: true,
+ },
+];
+
+pub fn config_dir() -> PathBuf {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
+ PathBuf::from(home).join(".clifcode")
+}
+
+pub fn config_path() -> PathBuf {
+ config_dir().join("config.json")
+}
+
+pub fn load_config() -> serde_json::Value {
+ let path = config_path();
+ if path.exists() {
+ let text = std::fs::read_to_string(&path).unwrap_or_default();
+ serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({}))
+ } else {
+ serde_json::json!({})
+ }
+}
+
+pub fn save_config(config: &serde_json::Value) {
+ let dir = config_dir();
+ let _ = std::fs::create_dir_all(&dir);
+ let text = serde_json::to_string_pretty(config).unwrap_or_default();
+ let _ = std::fs::write(config_path(), text);
+}
+
+pub fn saved_api_key() -> Option {
+ load_config()
+ .get("api_key")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+}
+
+pub fn saved_api_model() -> Option {
+ load_config()
+ .get("api_model")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+}
+
+pub fn saved_api_url() -> Option {
+ load_config()
+ .get("api_url")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+}
+
+/// Interactive first-run setup. Returns (key, url, model) or None on cancel.
+pub fn interactive_setup() -> Option<(String, String, String)> {
+ println!();
+ println!(" {}{}Setup{}", ui::BOLD, ui::YELLOW, ui::RESET);
+ println!(
+ " {}─────────────────────────────────────────{}",
+ ui::DIM, ui::RESET
+ );
+ println!();
+
+ let names: Vec<&str> = PROVIDERS.iter().map(|p| p.name).collect();
+ let choice = ui::select_menu("Choose a provider:", &names)?;
+
+ let provider = &PROVIDERS[choice];
+
+ let url = if provider.url.is_empty() {
+ let u = ui::prompt_input(" API base URL:");
+ if u.is_empty() {
+ println!(" {}No URL — skipping.{}", ui::DIM, ui::RESET);
+ return None;
+ }
+ u
+ } else {
+ provider.url.to_string()
+ };
+
+ let key = if provider.needs_key {
+ let k = ui::prompt_input(" API key:");
+ if k.is_empty() {
+ println!(" {}No key — skipping.{}", ui::DIM, ui::RESET);
+ return None;
+ }
+ k
+ } else {
+ String::new()
+ };
+
+ let model = if provider.default_model.is_empty() {
+ ui::prompt_input(" Model name:")
+ } else {
+ ui::prompt_input_default(" Model:", provider.default_model)
+ };
+
+ if model.is_empty() {
+ println!(" {}No model — skipping.{}", ui::DIM, ui::RESET);
+ return None;
+ }
+
+ let mut config = load_config();
+ config["provider"] = serde_json::json!(provider.name);
+ config["api_key"] = serde_json::json!(key);
+ config["api_url"] = serde_json::json!(url);
+ config["api_model"] = serde_json::json!(model);
+ save_config(&config);
+
+ println!();
+ println!(
+ " {}Saved to ~/.clifcode/config.json{}",
+ ui::GREEN, ui::RESET
+ );
+
+ Some((key, url, model))
+}
diff --git a/clif-code-tui/src/git.rs b/clif-code-tui/src/git.rs
new file mode 100644
index 0000000..020fb3f
--- /dev/null
+++ b/clif-code-tui/src/git.rs
@@ -0,0 +1,111 @@
+//! Git integration — auto-commit, undo, status.
+
+use std::path::Path;
+use std::process::Command;
+
+/// Check if workspace is a git repository
+pub fn is_git_repo(workspace: &str) -> bool {
+ Path::new(workspace).join(".git").exists()
+}
+
+/// Initialize a git repository
+pub fn git_init(workspace: &str) -> Result<(), String> {
+ let output = Command::new("git")
+ .args(["init"])
+ .current_dir(workspace)
+ .output()
+ .map_err(|e| format!("git init failed: {e}"))?;
+
+ if output.status.success() {
+ Ok(())
+ } else {
+ Err(String::from_utf8_lossy(&output.stderr).to_string())
+ }
+}
+
+/// Auto-commit all changes with a descriptive message. Returns the commit hash.
+pub fn git_auto_commit(workspace: &str, message: &str) -> Result {
+ // Stage all changes
+ let _ = Command::new("git")
+ .args(["add", "-A"])
+ .current_dir(workspace)
+ .output();
+
+ // Check if there are staged changes
+ let status = Command::new("git")
+ .args(["diff", "--cached", "--quiet"])
+ .current_dir(workspace)
+ .status()
+ .map_err(|e| format!("git status failed: {e}"))?;
+
+ if status.success() {
+ return Err("No changes to commit".into());
+ }
+
+ // Commit with ClifCode as author
+ let output = Command::new("git")
+ .args([
+ "commit",
+ "-m",
+ message,
+ "--author",
+ "ClifCode ",
+ ])
+ .current_dir(workspace)
+ .output()
+ .map_err(|e| format!("git commit failed: {e}"))?;
+
+ if output.status.success() {
+ let hash_output = Command::new("git")
+ .args(["rev-parse", "--short", "HEAD"])
+ .current_dir(workspace)
+ .output()
+ .map_err(|e| format!("git rev-parse failed: {e}"))?;
+ let hash = String::from_utf8_lossy(&hash_output.stdout)
+ .trim()
+ .to_string();
+ Ok(hash)
+ } else {
+ Err(String::from_utf8_lossy(&output.stderr).to_string())
+ }
+}
+
+/// Undo the last ClifCode commit (soft reset — keeps changes in working dir)
+pub fn git_undo(workspace: &str) -> Result {
+ // Verify last commit was by ClifCode
+ let log = Command::new("git")
+ .args(["log", "-1", "--format=%an|%s"])
+ .current_dir(workspace)
+ .output()
+ .map_err(|e| format!("git log failed: {e}"))?;
+
+ let log_str = String::from_utf8_lossy(&log.stdout).trim().to_string();
+ if !log_str.starts_with("ClifCode|") {
+ return Err("Last commit was not by ClifCode — refusing to undo".into());
+ }
+
+ let message = log_str.splitn(2, '|').nth(1).unwrap_or("").to_string();
+
+ // Soft reset (keeps changes staged)
+ let output = Command::new("git")
+ .args(["reset", "--soft", "HEAD~1"])
+ .current_dir(workspace)
+ .output()
+ .map_err(|e| format!("git reset failed: {e}"))?;
+
+ if output.status.success() {
+ Ok(format!("Undid: {message}"))
+ } else {
+ Err(String::from_utf8_lossy(&output.stderr).to_string())
+ }
+}
+
+/// Get short git status
+pub fn git_status(workspace: &str) -> Result {
+ let output = Command::new("git")
+ .args(["status", "--short"])
+ .current_dir(workspace)
+ .output()
+ .map_err(|e| format!("git status failed: {e}"))?;
+ Ok(String::from_utf8_lossy(&output.stdout).to_string())
+}
diff --git a/clif-code-tui/src/main.rs b/clif-code-tui/src/main.rs
new file mode 100644
index 0000000..5073d78
--- /dev/null
+++ b/clif-code-tui/src/main.rs
@@ -0,0 +1,1021 @@
+//! ClifCode — AI coding assistant.
+//!
+//! A TUI that runs in any terminal.
+//! Supports multiple backends:
+//! - API: OpenRouter, OpenAI, Anthropic, or any OpenAI-compatible endpoint
+//! - Ollama: local LLM server
+//! - Stub: testing mode (no model needed)
+//!
+//! Install: cargo install --path clifcode
+//! Usage:
+//! clifcode # auto-detect backend
+//! clifcode --backend api --api-model gpt-4o
+//! clifcode --backend ollama --api-model codellama
+
+mod backend;
+mod config;
+mod git;
+mod repomap;
+mod session;
+mod tools;
+mod ui;
+
+use anyhow::Result;
+use clap::{Parser, ValueEnum};
+use std::io::{self, BufRead, Write};
+use std::path::PathBuf;
+
+// ---------------------------------------------------------------------------
+// CLI
+// ---------------------------------------------------------------------------
+
+#[derive(Clone, ValueEnum, Debug)]
+enum Backend {
+ /// Auto-detect: try api → ollama → stub
+ Auto,
+ /// OpenAI-compatible API
+ Api,
+ /// Ollama local server
+ Ollama,
+ /// Testing stub (no model)
+ Stub,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Autonomy {
+ /// Show diff, ask Y/n before each write/edit
+ Suggest,
+ /// Show diff, apply automatically
+ AutoEdit,
+ /// Apply silently
+ FullAuto,
+}
+
+impl std::fmt::Display for Autonomy {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Autonomy::Suggest => write!(f, "suggest"),
+ Autonomy::AutoEdit => write!(f, "auto-edit"),
+ Autonomy::FullAuto => write!(f, "full-auto"),
+ }
+ }
+}
+
+#[derive(Parser)]
+#[command(
+ name = "clifcode",
+ about = "ClifCode — AI coding assistant",
+ long_about = "AI coding assistant with tool-calling agent loop.\n\
+ Works with any OpenAI-compatible API, Ollama, or local models.\n\
+ Runs in any terminal."
+)]
+struct Cli {
+ /// Backend to use
+ #[arg(long, value_enum, default_value = "auto")]
+ backend: Backend,
+
+ /// API base URL (default: OpenRouter)
+ #[arg(long, env = "CLIFCODE_API_URL")]
+ api_url: Option,
+
+ /// API key
+ #[arg(long, env = "CLIFCODE_API_KEY")]
+ api_key: Option,
+
+ /// Model name for API calls
+ #[arg(long, env = "CLIFCODE_API_MODEL")]
+ api_model: Option,
+
+ /// Working directory (defaults to cwd)
+ #[arg(long, short = 'w')]
+ workspace: Option,
+
+ /// Max tokens to generate per response
+ #[arg(long, default_value = "1024")]
+ max_tokens: usize,
+
+ /// Non-interactive: run a single prompt and exit
+ #[arg(long, short = 'p')]
+ prompt: Option,
+
+ /// Autonomy level: suggest, auto-edit, full-auto
+ #[arg(long, default_value = "auto-edit")]
+ autonomy: String,
+
+ /// Resume a previous session by ID
+ #[arg(long)]
+ resume: Option,
+}
+
+// ---------------------------------------------------------------------------
+// Conversation state
+// ---------------------------------------------------------------------------
+
+struct Conversation {
+ messages: Vec,
+}
+
+impl Conversation {
+ fn new(workspace: &str, autonomy: &Autonomy, context_files: &[String]) -> Self {
+ let repo_map = repomap::scan_workspace(workspace);
+ let auto_ctx = repomap::auto_context(workspace);
+
+ let mut system_parts = vec![
+ format!(
+ "You are ClifCode, an AI assistant that helps with coding and file tasks.\n\
+ Workspace: {workspace}\n\
+ Mode: {autonomy}\n\
+ Max turns: {}\n\n\
+ CRITICAL BEHAVIOR RULES:\n\
+ 1. BE PROACTIVE. When the user asks a question, READ files to find the answer. NEVER say \"could you provide more details\" or \"let me know\" when you can look it up yourself.\n\
+ 2. When the user asks \"which is best/most likely/top\", READ the relevant data files and ANALYZE them. Give a direct answer with reasoning.\n\
+ 3. If a file was truncated, use read_file with offset to get the rest. Read the ENTIRE file before answering.\n\
+ 4. Use find_file to locate files by name when you don't know the path.\n\
+ 5. Use change_directory when the user wants to switch to a different folder.\n\
+ 6. Prefer edit_file for targeted changes, write_file for new files.\n\
+ 7. Call submit when a coding task is done.\n\
+ 8. Remember context from earlier in the conversation.\n\
+ 9. NEVER ask the user to clarify something you can figure out from the files.\n\
+ 10. READ COMPREHENSIVELY. When asked to analyze, summarize, or make recommendations about a directory or project, FIRST use list_files to see everything available, THEN read ALL relevant files — not just 1-2. Read every doc, every config, every data file that could inform your answer. Use multiple read_file calls in the same turn. Partial reading leads to bad answers.\n\
+ 11. When creating a file based on analysis, make sure you have read ALL source material first. If there are 10 relevant files, read all 10 before writing your summary.",
+ tools::MAX_TURNS
+ ),
+ format!("Repo map:\n{repo_map}"),
+ ];
+
+ // Auto-context: inject project identity files (README, Cargo.toml, etc.)
+ if !auto_ctx.is_empty() {
+ let names: Vec<&str> = auto_ctx.iter().map(|(n, _)| n.as_str()).collect();
+ ui::print_dim(&format!(" Auto-context: {}", names.join(", ")));
+ for (name, content) in &auto_ctx {
+ system_parts.push(format!("Project file {name}:\n```\n{content}\n```"));
+ }
+ }
+
+ // Manually added context files
+ for file_path in context_files {
+ let full = std::path::Path::new(workspace).join(file_path);
+ if let Ok(content) = std::fs::read_to_string(&full) {
+ let truncated: String = content.chars().take(4000).collect();
+ system_parts.push(format!("File {file_path}:\n```\n{truncated}\n```"));
+ }
+ }
+
+ let system_content = system_parts.join("\n\n");
+
+ Conversation {
+ messages: vec![serde_json::json!({"role": "system", "content": system_content})],
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Agent loop — operates on a persistent conversation
+// ---------------------------------------------------------------------------
+
+fn run_turn(
+ bk: &backend::ModelBackend,
+ conv: &mut Conversation,
+ input: &str,
+ workspace: &mut String,
+ autonomy: &Autonomy,
+ auto_commit: bool,
+) -> Result {
+ // Add the user message to the ongoing conversation
+ conv.messages
+ .push(serde_json::json!({"role": "user", "content": input}));
+
+ let tool_defs = tools::tool_definitions();
+ let confirm_writes = *autonomy == Autonomy::Suggest;
+ let collapse_diffs = *autonomy == Autonomy::AutoEdit;
+ let mut files_changed = Vec::new();
+ let mut turn_usage = backend::TokenUsage::default();
+
+ for turn in 1..=tools::MAX_TURNS {
+ ui::print_turn_indicator(turn, tools::MAX_TURNS);
+ ui::print_thinking();
+
+ // Use streaming for API backend — tokens print live
+ let response = bk.chat_stream(&conv.messages, Some(&tool_defs))?;
+ ui::clear_thinking();
+
+ // Accumulate token usage
+ if let Some(ref u) = response.usage {
+ turn_usage.prompt_tokens += u.prompt_tokens;
+ turn_usage.completion_tokens += u.completion_tokens;
+ }
+
+ // Only print via render_markdown if content wasn't already streamed
+ if !response.content.is_empty() && !response.streamed {
+ ui::print_assistant(&response.content);
+ }
+
+ // No tool calls — model just responded with text, conversation continues
+ if response.tool_calls.is_empty() {
+ conv.messages.push(response.raw_message);
+ return Ok(turn_usage);
+ }
+
+ conv.messages.push(response.raw_message.clone());
+
+ // Parse all tool calls, track order for message insertion
+ let parsed: Vec<(usize, &tools::ApiToolCall, Option)> = response
+ .tool_calls
+ .iter()
+ .enumerate()
+ .map(|(i, api_call)| (i, api_call, tools::ToolCall::from_api(api_call)))
+ .collect();
+
+ // Allocate result slots (index -> tool message JSON)
+ let mut result_slots: Vec> = vec![None; parsed.len()];
+
+ // --- Phase 1: Handle control-flow calls (submit, change_directory) immediately ---
+ for (idx, api_call, tool_call) in &parsed {
+ if let Some(tools::ToolCall::Submit { ref summary }) = tool_call {
+ if response.content.is_empty() {
+ ui::print_assistant(summary);
+ }
+ conv.messages.push(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": api_call.id,
+ "content": format!("Task complete: {summary}")
+ }));
+ if auto_commit && !files_changed.is_empty() && git::is_git_repo(workspace) {
+ let msg = format!(
+ "ClifCode: {}",
+ summary.chars().take(72).collect::()
+ );
+ match git::git_auto_commit(workspace, &msg) {
+ Ok(hash) => ui::print_dim(&format!(" [committed {hash}]")),
+ Err(e) => ui::print_dim(&format!(" [commit skipped: {e}]")),
+ }
+ }
+ return Ok(turn_usage);
+ }
+
+ if let Some(tools::ToolCall::ChangeDir { ref path }) = tool_call {
+ let target = std::path::Path::new(path);
+ if target.is_dir() {
+ let canonical = target.canonicalize().unwrap_or_else(|_| target.to_path_buf());
+ *workspace = canonical.to_string_lossy().to_string();
+ ui::print_tool_action("cd", workspace);
+ ui::print_success(&format!(" Workspace: {workspace}"));
+ result_slots[*idx] = Some(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": api_call.id,
+ "content": format!("Changed workspace to {}. The repo map for this directory:\n{}", workspace, repomap::scan_workspace(workspace))
+ }));
+ } else {
+ ui::print_error(&format!(" Not a directory: {path}"));
+ result_slots[*idx] = Some(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": api_call.id,
+ "content": format!("Error: {path} is not a directory")
+ }));
+ }
+ }
+ }
+
+ // --- Phase 2: Partition remaining into parallel (read-only) and sequential ---
+ let mut parallel_indices = Vec::new();
+ let mut sequential_indices = Vec::new();
+
+ for (idx, api_call, tool_call) in &parsed {
+ if result_slots[*idx].is_some() {
+ continue; // Already handled (change_directory)
+ }
+ match tool_call {
+ None => {
+ result_slots[*idx] = Some(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": api_call.id,
+ "content": format!("Unknown tool: {}", api_call.name)
+ }));
+ }
+ Some(tc) if tc.is_read_only() => {
+ parallel_indices.push(*idx);
+ }
+ Some(_) => {
+ sequential_indices.push(*idx);
+ }
+ }
+ }
+
+ // --- Phase 3: Run parallel batch on threads ---
+ if parallel_indices.len() > 1 {
+ let ws: &str = workspace;
+ let parallel_items: Vec<(usize, &tools::ToolCall, &str)> = parallel_indices
+ .iter()
+ .filter_map(|&idx| {
+ parsed[idx].2.as_ref().map(|tc| (idx, tc, parsed[idx].1.id.as_str()))
+ })
+ .collect();
+
+ let results: Vec<(usize, String, String)> = std::thread::scope(|s| {
+ let handles: Vec<_> = parallel_items
+ .iter()
+ .map(|&(idx, tc, call_id)| {
+ s.spawn(move || {
+ let result = tools::execute_tool(tc, ws, false, false);
+ let json = serde_json::to_string(&result).unwrap_or_default();
+ (idx, call_id.to_string(), json)
+ })
+ })
+ .collect();
+ handles.into_iter().map(|h| h.join().unwrap()).collect()
+ });
+
+ for (idx, call_id, result_json) in results {
+ result_slots[idx] = Some(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": call_id,
+ "content": result_json
+ }));
+ }
+ } else if parallel_indices.len() == 1 {
+ // Single read-only call — no need for threads
+ let idx = parallel_indices[0];
+ if let Some(ref tc) = parsed[idx].2 {
+ let result = tools::execute_tool(tc, workspace, confirm_writes, collapse_diffs);
+ let result_json = serde_json::to_string(&result)?;
+ result_slots[idx] = Some(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": parsed[idx].1.id,
+ "content": result_json
+ }));
+ }
+ }
+
+ // --- Phase 4: Run sequential batch in order ---
+ for idx in sequential_indices {
+ if let Some(ref tc) = parsed[idx].2 {
+ // Track file changes
+ match tc {
+ tools::ToolCall::WriteFile { path, .. }
+ | tools::ToolCall::EditFile { path, .. } => {
+ if !files_changed.contains(path) {
+ files_changed.push(path.clone());
+ }
+ }
+ _ => {}
+ }
+
+ let result = tools::execute_tool(tc, workspace, confirm_writes, collapse_diffs);
+ let result_json = serde_json::to_string(&result)?;
+ result_slots[idx] = Some(serde_json::json!({
+ "role": "tool",
+ "tool_call_id": parsed[idx].1.id,
+ "content": result_json
+ }));
+ }
+ }
+
+ // --- Phase 5: Push results in original order ---
+ for slot in result_slots {
+ if let Some(msg) = slot {
+ conv.messages.push(msg);
+ }
+ }
+
+ // Context compaction
+ session::compact_messages(&mut conv.messages, 8000);
+ }
+
+ ui::print_dim(" (reached turn limit)");
+
+ if auto_commit && !files_changed.is_empty() && git::is_git_repo(workspace) {
+ let msg = format!("ClifCode: modified {}", files_changed.join(", "));
+ match git::git_auto_commit(workspace, &msg) {
+ Ok(hash) => ui::print_dim(&format!(" [committed {hash}]")),
+ Err(e) => ui::print_dim(&format!(" [commit skipped: {e}]")),
+ }
+ }
+
+ Ok(turn_usage)
+}
+
+/// Simple ISO-ish timestamp without external deps
+fn chrono_now() -> String {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let secs = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+ // Convert to rough YYYY-MM-DD HH:MM (good enough for session listing)
+ let days = secs / 86400;
+ let time_of_day = secs % 86400;
+ let hours = time_of_day / 3600;
+ let minutes = (time_of_day % 3600) / 60;
+ // Days since 1970-01-01 → approximate date
+ let mut y = 1970i64;
+ let mut remaining = days as i64;
+ loop {
+ let days_in_year = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 };
+ if remaining < days_in_year {
+ break;
+ }
+ remaining -= days_in_year;
+ y += 1;
+ }
+ let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
+ let mdays = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ let mut m = 0;
+ for (i, &d) in mdays.iter().enumerate() {
+ if remaining < d {
+ m = i + 1;
+ break;
+ }
+ remaining -= d;
+ }
+ let day = remaining + 1;
+ format!("{y:04}-{m:02}-{day:02} {hours:02}:{minutes:02}")
+}
+
+// ---------------------------------------------------------------------------
+// Backend resolution
+// ---------------------------------------------------------------------------
+
+fn resolve_backend(cli: &Cli) -> Result {
+ match cli.backend {
+ Backend::Api => {
+ let url = cli
+ .api_url
+ .clone()
+ .unwrap_or_else(|| "https://openrouter.ai/api/v1".into());
+ let key = cli.api_key.clone();
+ let model = cli
+ .api_model
+ .clone()
+ .unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
+ Ok(backend::ModelBackend::Api {
+ url,
+ key,
+ model,
+ max_tokens: cli.max_tokens,
+ })
+ }
+ Backend::Ollama => {
+ let url = cli
+ .api_url
+ .clone()
+ .unwrap_or_else(|| "http://localhost:11434/v1".into());
+ let model = cli
+ .api_model
+ .clone()
+ .unwrap_or_else(|| "qwen2.5-coder:7b".into());
+ Ok(backend::ModelBackend::Api {
+ url,
+ key: None,
+ model,
+ max_tokens: cli.max_tokens,
+ })
+ }
+ Backend::Stub => Ok(backend::ModelBackend::Stub),
+ Backend::Auto => {
+ // 1. CLI/env API key
+ if cli.api_key.is_some() {
+ let url = cli
+ .api_url
+ .clone()
+ .unwrap_or_else(|| "https://openrouter.ai/api/v1".into());
+ let model = cli
+ .api_model
+ .clone()
+ .unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
+ return Ok(backend::ModelBackend::Api {
+ url,
+ key: cli.api_key.clone(),
+ model,
+ max_tokens: cli.max_tokens,
+ });
+ }
+ // 3. Saved config
+ if let Some(key) = config::saved_api_key() {
+ let url = cli
+ .api_url
+ .clone()
+ .or_else(config::saved_api_url)
+ .unwrap_or_else(|| "https://openrouter.ai/api/v1".into());
+ let model = cli
+ .api_model
+ .clone()
+ .or_else(config::saved_api_model)
+ .unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
+ return Ok(backend::ModelBackend::Api {
+ url,
+ key: Some(key),
+ model,
+ max_tokens: cli.max_tokens,
+ });
+ }
+ // 4. Ollama
+ if backend::detect_ollama() {
+ let model = cli
+ .api_model
+ .clone()
+ .unwrap_or_else(|| "qwen2.5-coder:7b".into());
+ return Ok(backend::ModelBackend::Api {
+ url: "http://localhost:11434/v1".into(),
+ key: None,
+ model,
+ max_tokens: cli.max_tokens,
+ });
+ }
+ // 5. Interactive setup
+ if let Some((key, url, model)) = config::interactive_setup() {
+ return Ok(backend::ModelBackend::Api {
+ url,
+ key: Some(key),
+ model,
+ max_tokens: cli.max_tokens,
+ });
+ }
+ // 6. Stub fallback
+ Ok(backend::ModelBackend::Stub)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Slash commands help
+// ---------------------------------------------------------------------------
+
+fn print_help() {
+ println!();
+ println!(
+ " {}{}Commands{}",
+ ui::BOLD, ui::WHITE, ui::RESET
+ );
+ println!(
+ " {}Type any coding task and ClifCode will solve it.{}",
+ ui::DIM, ui::RESET
+ );
+ println!();
+
+ // Session group
+ println!(
+ " {}{}\u{25c6} Session{}",
+ ui::BOLD, ui::BRIGHT_CYAN, ui::RESET
+ );
+ let session_cmds = [
+ ("new", "Start a new conversation"),
+ ("sessions", "List saved sessions"),
+ ("resume", "Resume a saved session"),
+ ("cost", "Show token usage and cost"),
+ ("clear", "Clear screen"),
+ ("quit", "Exit ClifCode"),
+ ];
+ for (cmd, desc) in &session_cmds {
+ println!(
+ " {}{}{:<12}{} {}{}{}",
+ ui::BOLD, ui::BRIGHT_CYAN, cmd, ui::RESET,
+ ui::DIM, desc, ui::RESET
+ );
+ }
+ println!();
+
+ // Workspace group
+ println!(
+ " {}{}\u{25c6} Workspace{}",
+ ui::BOLD, ui::BRIGHT_MAGENTA, ui::RESET
+ );
+ let workspace_cmds = [
+ ("cd ", "Change workspace directory"),
+ ("add ", "Add file to context"),
+ ("drop ", "Remove file from context"),
+ ("context", "Show context files"),
+ ];
+ for (cmd, desc) in &workspace_cmds {
+ println!(
+ " {}{}{:<12}{} {}{}{}",
+ ui::BOLD, ui::BRIGHT_MAGENTA, cmd, ui::RESET,
+ ui::DIM, desc, ui::RESET
+ );
+ }
+ println!();
+
+ // Tools group
+ println!(
+ " {}{}\u{25c6} Settings{}",
+ ui::BOLD, ui::BRIGHT_YELLOW, ui::RESET
+ );
+ let tools_cmds = [
+ ("mode", "Switch autonomy level"),
+ ("backend", "Show current backend"),
+ ("config", "Re-run provider setup"),
+ ];
+ for (cmd, desc) in &tools_cmds {
+ println!(
+ " {}{}{:<12}{} {}{}{}",
+ ui::BOLD, ui::BRIGHT_YELLOW, cmd, ui::RESET,
+ ui::DIM, desc, ui::RESET
+ );
+ }
+ println!();
+
+ // Git group
+ println!(
+ " {}{}\u{25c6} Git{}",
+ ui::BOLD, ui::BRIGHT_GREEN, ui::RESET
+ );
+ let git_cmds = [
+ ("status", "Git status"),
+ ("undo", "Undo last ClifCode commit"),
+ ];
+ for (cmd, desc) in &git_cmds {
+ println!(
+ " {}{}{:<12}{} {}{}{}",
+ ui::BOLD, ui::BRIGHT_GREEN, cmd, ui::RESET,
+ ui::DIM, desc, ui::RESET
+ );
+ }
+
+ println!();
+ println!(
+ " {}{}Tip:{} {}{}{} to expand diffs in auto-edit mode",
+ ui::BOLD, ui::WHITE, ui::RESET,
+ ui::BOLD, "Ctrl+O", ui::RESET
+ );
+ println!();
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+fn main() -> Result<()> {
+ // Ctrl+C handler — ensure clean exit even if raw mode is active
+ ctrlc::set_handler(move || {
+ // Restore terminal state before exiting
+ let _ = crossterm::terminal::disable_raw_mode();
+ print!("\x1b[0m"); // reset ANSI
+ println!();
+ println!(" \x1b[2mInterrupted.\x1b[0m");
+ std::process::exit(0);
+ })
+ .ok();
+
+ let cli = Cli::parse();
+
+ let workspace = cli
+ .workspace
+ .clone()
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
+ let mut workspace_str = workspace.to_string_lossy().to_string();
+
+ // Non-interactive mode
+ if cli.prompt.is_some() {
+ let bk = resolve_backend(&cli)?;
+ let mut conv = Conversation::new(&workspace_str, &Autonomy::AutoEdit, &[]);
+ let usage = run_turn(&bk, &mut conv, cli.prompt.as_ref().unwrap(), &mut workspace_str, &Autonomy::AutoEdit, false)?;
+ if usage.prompt_tokens > 0 || usage.completion_tokens > 0 {
+ ui::print_usage(usage.prompt_tokens, usage.completion_tokens);
+ }
+ return Ok(());
+ }
+
+ // Interactive mode
+ ui::print_logo();
+ let mut bk = resolve_backend(&cli)?;
+
+ let mut autonomy = match cli.autonomy.as_str() {
+ "suggest" => Autonomy::Suggest,
+ "full" | "full-auto" => Autonomy::FullAuto,
+ _ => Autonomy::AutoEdit,
+ };
+
+ let mut context_files: Vec = Vec::new();
+ let auto_commit = git::is_git_repo(&workspace_str);
+ let mut session_prompt_tokens: usize = 0;
+ let mut session_completion_tokens: usize = 0;
+ let mut session_id = session::new_session_id();
+
+ // Persistent conversation — remembers context across turns
+ let mut conv;
+
+ // Resume previous session if requested
+ if let Some(ref resume_id) = cli.resume {
+ match session::load_session(resume_id) {
+ Ok(s) => {
+ session_id = s.id.clone();
+ workspace_str = s.workspace.clone();
+ context_files = s.context_files.clone();
+ autonomy = match s.autonomy.as_str() {
+ "suggest" => Autonomy::Suggest,
+ "full-auto" => Autonomy::FullAuto,
+ _ => Autonomy::AutoEdit,
+ };
+ conv = Conversation { messages: s.messages };
+ ui::print_success(&format!(" Resumed session {resume_id}"));
+ }
+ Err(e) => {
+ ui::print_error(&format!(" Cannot resume: {e}"));
+ conv = Conversation::new(&workspace_str, &autonomy, &context_files);
+ }
+ }
+ } else {
+ conv = Conversation::new(&workspace_str, &autonomy, &context_files);
+ }
+
+ println!();
+ ui::print_banner(&workspace_str, bk.name(), &autonomy.to_string());
+
+ let stdin = io::stdin();
+ loop {
+ ui::print_prompt();
+
+ let mut input = String::new();
+ if stdin.lock().read_line(&mut input)? == 0 {
+ break;
+ }
+ let input = input.trim();
+ if input.is_empty() {
+ continue;
+ }
+
+ // Normalize: strip leading `/`, lowercase for command matching
+ let cmd = input.trim_start_matches('/').to_lowercase();
+ let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
+
+ match parts[0] {
+ "quit" | "exit" | "q" => {
+ println!(" {}Goodbye.{}", ui::DIM, ui::RESET);
+ break;
+ }
+ "help" | "h" | "?" => {
+ print_help();
+ continue;
+ }
+ "new" | "reset" => {
+ session_id = session::new_session_id();
+ conv = Conversation::new(&workspace_str, &autonomy, &context_files);
+ session_prompt_tokens = 0;
+ session_completion_tokens = 0;
+ ui::print_dim(" New conversation started.");
+ continue;
+ }
+ "sessions" => {
+ let sessions = session::list_sessions();
+ if sessions.is_empty() {
+ ui::print_dim(" No saved sessions.");
+ } else {
+ println!();
+ println!(" {}Saved sessions{}", ui::BOLD, ui::RESET);
+ for (id, date, preview) in &sessions {
+ println!(
+ " {}{}{} {}{}{} {}",
+ ui::CYAN, id, ui::RESET,
+ ui::DIM, date, ui::RESET,
+ preview
+ );
+ }
+ println!();
+ }
+ continue;
+ }
+ "resume" => {
+ let resume_id = if let Some(id) = parts.get(1) {
+ id.trim().to_string()
+ } else {
+ // Show sessions and let user pick
+ let sessions = session::list_sessions();
+ if sessions.is_empty() {
+ ui::print_dim(" No saved sessions.");
+ continue;
+ }
+ println!();
+ for (i, (id, date, preview)) in sessions.iter().enumerate() {
+ println!(
+ " {}{}.{} {}{}{} {} {}",
+ ui::CYAN, i + 1, ui::RESET,
+ ui::DIM, date, ui::RESET,
+ id, preview
+ );
+ }
+ println!();
+ let choice = ui::prompt_input(" Session #:");
+ let idx: usize = match choice.parse::() {
+ Ok(n) if n >= 1 && n <= sessions.len() => n - 1,
+ _ => {
+ ui::print_error(" Invalid selection.");
+ continue;
+ }
+ };
+ sessions[idx].0.clone()
+ };
+ match session::load_session(&resume_id) {
+ Ok(s) => {
+ session_id = s.id.clone();
+ workspace_str = s.workspace.clone();
+ context_files = s.context_files.clone();
+ autonomy = match s.autonomy.as_str() {
+ "suggest" => Autonomy::Suggest,
+ "full-auto" => Autonomy::FullAuto,
+ _ => Autonomy::AutoEdit,
+ };
+ conv = Conversation { messages: s.messages };
+ session_prompt_tokens = 0;
+ session_completion_tokens = 0;
+ ui::print_success(&format!(" Resumed session {resume_id}"));
+ }
+ Err(e) => {
+ ui::print_error(&format!(" Cannot resume: {e}"));
+ }
+ }
+ continue;
+ }
+ "cd" => {
+ if let Some(dir) = parts.get(1) {
+ let dir = dir.trim();
+ let target = if dir.starts_with('/') || dir.starts_with('~') {
+ let expanded = dir.replace('~', &std::env::var("HOME").unwrap_or_default());
+ PathBuf::from(expanded)
+ } else {
+ PathBuf::from(&workspace_str).join(dir)
+ };
+ if target.is_dir() {
+ let canonical = target.canonicalize().unwrap_or(target);
+ workspace_str = canonical.to_string_lossy().to_string();
+ conv = Conversation::new(&workspace_str, &autonomy, &context_files);
+ context_files.clear();
+ ui::print_success(&format!(" Workspace: {workspace_str}"));
+ } else {
+ ui::print_error(&format!(" Not a directory: {}", target.display()));
+ }
+ } else {
+ // cd with no args → go home
+ workspace_str = std::env::var("HOME").unwrap_or_else(|_| ".".into());
+ conv = Conversation::new(&workspace_str, &autonomy, &context_files);
+ context_files.clear();
+ ui::print_success(&format!(" Workspace: {workspace_str}"));
+ }
+ continue;
+ }
+ "add" => {
+ if let Some(file) = parts.get(1) {
+ let file = file.trim().to_string();
+ let full = std::path::Path::new(&workspace_str).join(&file);
+ if full.exists() {
+ if !context_files.contains(&file) {
+ context_files.push(file.clone());
+ }
+ ui::print_success(&format!(" Added {file} to context"));
+ } else {
+ ui::print_error(&format!(" File not found: {file}"));
+ }
+ } else {
+ ui::print_dim(" Usage: add ");
+ }
+ continue;
+ }
+ "drop" => {
+ if let Some(file) = parts.get(1) {
+ let file = file.trim();
+ context_files.retain(|f| f != file);
+ ui::print_success(&format!(" Dropped {file} from context"));
+ } else {
+ ui::print_dim(" Usage: drop ");
+ }
+ continue;
+ }
+ "context" | "ctx" => {
+ let msg_count = conv.messages.len() - 1; // minus system prompt
+ if context_files.is_empty() && msg_count == 0 {
+ ui::print_dim(" Empty conversation. No context files.");
+ } else {
+ println!();
+ println!(
+ " {}Conversation:{} {} messages",
+ ui::BOLD, ui::RESET, msg_count
+ );
+ if !context_files.is_empty() {
+ println!(" {}Files:{}", ui::BOLD, ui::RESET);
+ for f in &context_files {
+ println!(" {}{}{}", ui::CYAN, f, ui::RESET);
+ }
+ }
+ println!();
+ }
+ continue;
+ }
+ "mode" => {
+ let modes = &["suggest", "auto-edit", "full-auto"];
+ if let Some(choice) = ui::select_menu("Autonomy level:", modes) {
+ autonomy = match choice {
+ 0 => Autonomy::Suggest,
+ 1 => Autonomy::AutoEdit,
+ _ => Autonomy::FullAuto,
+ };
+ ui::print_success(&format!(" Mode: {autonomy}"));
+ }
+ continue;
+ }
+ "undo" => {
+ match git::git_undo(&workspace_str) {
+ Ok(msg) => ui::print_success(&format!(" {msg}")),
+ Err(e) => ui::print_error(&format!(" {e}")),
+ }
+ continue;
+ }
+ "status" | "st" => {
+ match git::git_status(&workspace_str) {
+ Ok(s) if s.is_empty() => ui::print_dim(" Clean working tree"),
+ Ok(s) => {
+ println!();
+ for line in s.lines() {
+ println!(" {line}");
+ }
+ println!();
+ }
+ Err(e) => ui::print_error(&format!(" {e}")),
+ }
+ continue;
+ }
+ "backend" => {
+ println!();
+ match &bk {
+ backend::ModelBackend::Api { url, model, .. } => {
+ println!(" Backend: {}api{}", ui::CYAN, ui::RESET);
+ println!(" Model: {}{}{}", ui::CYAN, model, ui::RESET);
+ println!(" URL: {}{}{}", ui::DIM, url, ui::RESET);
+ }
+ backend::ModelBackend::Stub => {
+ println!(
+ " Backend: {}stub{} (testing)",
+ ui::YELLOW, ui::RESET
+ );
+ }
+ }
+ println!();
+ continue;
+ }
+ "config" | "setup" => {
+ if let Some((key, url, model)) = config::interactive_setup() {
+ let key_opt = if key.is_empty() { None } else { Some(key) };
+ bk = backend::ModelBackend::Api {
+ url: url.clone(),
+ key: key_opt,
+ model: model.clone(),
+ max_tokens: cli.max_tokens,
+ };
+ println!();
+ ui::print_success(&format!(
+ " Switched to {}{}{} via {}",
+ ui::CYAN, model, ui::RESET, url
+ ));
+ }
+ println!();
+ continue;
+ }
+ "cost" | "usage" | "tokens" => {
+ ui::print_session_cost(session_prompt_tokens, session_completion_tokens);
+ continue;
+ }
+ "clear" => {
+ conv = Conversation::new(&workspace_str, &autonomy, &context_files);
+ print!("\x1b[2J\x1b[H");
+ io::stdout().flush().unwrap();
+ ui::print_logo();
+ println!();
+ ui::print_banner(&workspace_str, bk.name(), &autonomy.to_string());
+ continue;
+ }
+ _ => {}
+ }
+
+ // It's a message — send to the ongoing conversation
+ match run_turn(
+ &bk,
+ &mut conv,
+ input,
+ &mut workspace_str,
+ &autonomy,
+ auto_commit,
+ ) {
+ Ok(usage) => {
+ if usage.prompt_tokens > 0 || usage.completion_tokens > 0 {
+ ui::print_usage(usage.prompt_tokens, usage.completion_tokens);
+ session_prompt_tokens += usage.prompt_tokens;
+ session_completion_tokens += usage.completion_tokens;
+ }
+ }
+ Err(e) => {
+ ui::print_error(&format!(" Error: {e}"));
+ }
+ }
+
+ // Auto-save session after each turn
+ let _ = session::save_session(&session::Session {
+ id: session_id.clone(),
+ workspace: workspace_str.clone(),
+ messages: conv.messages.clone(),
+ context_files: context_files.clone(),
+ autonomy: autonomy.to_string(),
+ created_at: chrono_now(),
+ });
+
+ println!();
+ }
+
+ Ok(())
+}
diff --git a/clif-code-tui/src/repomap.rs b/clif-code-tui/src/repomap.rs
new file mode 100644
index 0000000..0394183
--- /dev/null
+++ b/clif-code-tui/src/repomap.rs
@@ -0,0 +1,119 @@
+//! Workspace scanning — builds a repo map for LLM context.
+
+use std::path::Path;
+
+const IDENTITY_FILES: &[&str] = &[
+ "README.md", "README.rst", "README.txt", "README",
+ "Cargo.toml", "package.json", "pyproject.toml", "setup.py", "setup.cfg",
+ "go.mod", "Gemfile", "build.gradle", "pom.xml", "Makefile",
+ "docker-compose.yml", "Dockerfile",
+ ".clifcode.toml",
+];
+
+const SKIP_DIRS: &[&str] = &[
+ "node_modules", ".git", "target", "__pycache__", ".next",
+ "dist", "build", ".cache", "vendor", ".venv", "venv",
+ ".tox", "coverage", ".mypy_cache", ".pytest_cache",
+];
+
+const SKIP_FILES: &[&str] = &[
+ ".DS_Store", "Thumbs.db", "package-lock.json", "yarn.lock", "Cargo.lock",
+];
+
+const CODE_EXTENSIONS: &[&str] = &[
+ "rs", "py", "ts", "tsx", "js", "jsx", "go", "c", "cpp", "h",
+ "java", "kt", "swift", "rb", "toml", "yaml", "yml", "json",
+ "md", "txt", "sh", "bash", "zsh", "css", "scss", "html",
+];
+
+/// Scan the workspace and return a concise repo map string for LLM context.
+pub fn scan_workspace(workspace: &str) -> String {
+ let mut lines = Vec::new();
+ let root = Path::new(workspace);
+ lines.push(format!("Workspace: {workspace}"));
+ lines.push(String::new());
+ walk_dir(root, root, 0, &mut lines);
+
+ // Truncate to ~4000 chars for LLM context window
+ let mut result = String::new();
+ for line in &lines {
+ if result.len() + line.len() > 4000 {
+ result.push_str(" ... (truncated)\n");
+ break;
+ }
+ result.push_str(line);
+ result.push('\n');
+ }
+ result
+}
+
+fn walk_dir(base: &Path, dir: &Path, depth: usize, lines: &mut Vec) {
+ if depth > 4 {
+ return;
+ }
+
+ let mut entries: Vec<_> = match std::fs::read_dir(dir) {
+ Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
+ Err(_) => return,
+ };
+ entries.sort_by_key(|e| e.file_name());
+
+ let indent = " ".repeat(depth);
+ let mut dirs = Vec::new();
+ let mut files = Vec::new();
+
+ for entry in &entries {
+ let name = entry.file_name().to_string_lossy().to_string();
+ let path = entry.path();
+
+ if path.is_dir() {
+ if !SKIP_DIRS.contains(&name.as_str()) {
+ dirs.push((name, path));
+ }
+ } else if !SKIP_FILES.contains(&name.as_str()) {
+ let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
+ if CODE_EXTENSIONS.contains(&ext)
+ || name == "Makefile"
+ || name == "Dockerfile"
+ {
+ files.push(name);
+ }
+ }
+ }
+
+ // Directories first, then files
+ for (name, path) in &dirs {
+ lines.push(format!("{indent}{name}/"));
+ walk_dir(base, path, depth + 1, lines);
+ }
+ for name in &files {
+ lines.push(format!("{indent}{name}"));
+ }
+}
+
+/// Auto-detect and read project identity files for LLM context.
+/// Returns Vec<(filename, truncated_content)>.
+pub fn auto_context(workspace: &str) -> Vec<(String, String)> {
+ let root = Path::new(workspace);
+ let mut results = Vec::new();
+ let mut total_chars = 0usize;
+ const MAX_TOTAL: usize = 8000; // cap total auto-context
+ const MAX_PER_FILE: usize = 2000;
+
+ for &name in IDENTITY_FILES {
+ let path = root.join(name);
+ if path.is_file() {
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ let budget = MAX_PER_FILE.min(MAX_TOTAL.saturating_sub(total_chars));
+ if budget == 0 {
+ break;
+ }
+ let truncated: String = content.chars().take(budget).collect();
+ total_chars += truncated.len();
+ results.push((name.to_string(), truncated));
+ }
+ }
+ }
+
+ results
+}
diff --git a/clif-code-tui/src/session.rs b/clif-code-tui/src/session.rs
new file mode 100644
index 0000000..872c712
--- /dev/null
+++ b/clif-code-tui/src/session.rs
@@ -0,0 +1,134 @@
+//! Session persistence and context compaction.
+
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+#[derive(Serialize, Deserialize)]
+pub struct Session {
+ pub id: String,
+ pub workspace: String,
+ pub messages: Vec,
+ #[serde(default)]
+ pub context_files: Vec,
+ #[serde(default)]
+ pub autonomy: String,
+ pub created_at: String,
+}
+
+fn sessions_dir() -> PathBuf {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
+ PathBuf::from(home).join(".clifcode").join("sessions")
+}
+
+/// Save a session to disk
+pub fn save_session(session: &Session) -> Result<(), String> {
+ let dir = sessions_dir();
+ let _ = std::fs::create_dir_all(&dir);
+ let path = dir.join(format!("{}.json", session.id));
+ let json = serde_json::to_string_pretty(session)
+ .map_err(|e| format!("Serialize error: {e}"))?;
+ std::fs::write(path, json).map_err(|e| format!("Write error: {e}"))
+}
+
+/// Load a session by ID
+pub fn load_session(id: &str) -> Result {
+ let path = sessions_dir().join(format!("{id}.json"));
+ let text = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
+ serde_json::from_str(&text).map_err(|e| format!("Parse error: {e}"))
+}
+
+/// List all saved sessions: (id, created_at, preview)
+pub fn list_sessions() -> Vec<(String, String, String)> {
+ let dir = sessions_dir();
+ let mut sessions = Vec::new();
+ if let Ok(entries) = std::fs::read_dir(&dir) {
+ for entry in entries.filter_map(|e| e.ok()) {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if name.ends_with(".json") {
+ let id = name.trim_end_matches(".json").to_string();
+ if let Ok(session) = load_session(&id) {
+ let preview = session
+ .messages
+ .iter()
+ .find(|m| m.get("role").and_then(|v| v.as_str()) == Some("user"))
+ .and_then(|m| m.get("content"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("(empty)")
+ .chars()
+ .take(50)
+ .collect::();
+ sessions.push((id, session.created_at, preview));
+ }
+ }
+ }
+ }
+ sessions.sort_by(|a, b| b.1.cmp(&a.1)); // newest first
+ sessions
+}
+
+/// Generate a short session ID from timestamp
+pub fn new_session_id() -> String {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis();
+ format!("{ts:x}")
+}
+
+/// Rough token estimate (~4 chars per token)
+pub fn estimate_tokens(messages: &[serde_json::Value]) -> usize {
+ messages
+ .iter()
+ .map(|m| {
+ m.get("content")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .len()
+ / 4
+ })
+ .sum()
+}
+
+/// Compact messages when approaching context limit.
+/// Keeps system prompt and recent messages, summarizes the middle.
+pub fn compact_messages(messages: &mut Vec, max_tokens: usize) {
+ let tokens = estimate_tokens(messages);
+ if tokens < max_tokens || messages.len() < 6 {
+ return;
+ }
+
+ // Keep first (system prompt) and last 4 messages
+ let keep_start = 1; // right after system prompt
+ let keep_end = messages.len().saturating_sub(4);
+
+ if keep_end <= keep_start {
+ return;
+ }
+
+ // Build summary of compacted messages
+ let mut summary_parts = Vec::new();
+ for msg in &messages[keep_start..keep_end] {
+ let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("?");
+ let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("");
+ let preview: String = content.chars().take(100).collect();
+ if !preview.is_empty() {
+ summary_parts.push(format!("[{role}] {preview}..."));
+ }
+ }
+
+ let summary = format!(
+ "[Context compacted — {} earlier messages summarized]\n{}",
+ keep_end - keep_start,
+ summary_parts.join("\n")
+ );
+
+ // Replace middle with summary
+ let tail: Vec<_> = messages[keep_end..].to_vec();
+ messages.truncate(keep_start);
+ messages.push(serde_json::json!({
+ "role": "system",
+ "content": summary
+ }));
+ messages.extend(tail);
+}
diff --git a/clif-code-tui/src/tools.rs b/clif-code-tui/src/tools.rs
new file mode 100644
index 0000000..ff9b76f
--- /dev/null
+++ b/clif-code-tui/src/tools.rs
@@ -0,0 +1,686 @@
+//! Tool definitions and execution for the ClifCode agent.
+
+use crate::ui;
+use serde::{Deserialize, Serialize};
+use std::path::Path;
+
+pub const MAX_TURNS: usize = 7;
+
+/// Tool call from the API response (OpenAI format)
+#[derive(Debug, Clone)]
+pub struct ApiToolCall {
+ pub id: String,
+ pub name: String,
+ pub arguments: String,
+}
+
+/// Parsed tool call
+#[derive(Debug, Clone)]
+pub enum ToolCall {
+ ReadFile { path: String, offset: Option },
+ FindFile { name: String, dir: Option },
+ WriteFile { path: String, content: String },
+ EditFile { path: String, old_string: String, new_string: String },
+ ListFiles { path: Option },
+ Search { query: String, path: Option },
+ RunCommand { command: String },
+ ChangeDir { path: String },
+ Submit { summary: String },
+}
+
+impl ToolCall {
+ /// Whether this tool call is read-only and safe to run in parallel
+ pub fn is_read_only(&self) -> bool {
+ matches!(
+ self,
+ ToolCall::ReadFile { .. }
+ | ToolCall::FindFile { .. }
+ | ToolCall::ListFiles { .. }
+ | ToolCall::Search { .. }
+ )
+ }
+
+ pub fn from_api(call: &ApiToolCall) -> Option {
+ let args: serde_json::Value = serde_json::from_str(&call.arguments).ok()?;
+ match call.name.as_str() {
+ "read_file" => Some(ToolCall::ReadFile {
+ path: args.get("path")?.as_str()?.to_string(),
+ offset: args.get("offset").and_then(|v| v.as_u64()).map(|n| n as usize),
+ }),
+ "find_file" => Some(ToolCall::FindFile {
+ name: args.get("name")?.as_str()?.to_string(),
+ dir: args.get("dir").and_then(|v| v.as_str()).map(|s| s.to_string()),
+ }),
+ "write_file" => Some(ToolCall::WriteFile {
+ path: args.get("path")?.as_str()?.to_string(),
+ content: args.get("content")?.as_str()?.to_string(),
+ }),
+ "edit_file" => Some(ToolCall::EditFile {
+ path: args.get("path")?.as_str()?.to_string(),
+ old_string: args.get("old_string")?.as_str()?.to_string(),
+ new_string: args.get("new_string")?.as_str()?.to_string(),
+ }),
+ "list_files" => Some(ToolCall::ListFiles {
+ path: args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()),
+ }),
+ "search" => Some(ToolCall::Search {
+ query: args.get("query")?.as_str()?.to_string(),
+ path: args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()),
+ }),
+ "run_command" => Some(ToolCall::RunCommand {
+ command: args.get("command")?.as_str()?.to_string(),
+ }),
+ "change_directory" => Some(ToolCall::ChangeDir {
+ path: args.get("path")?.as_str()?.to_string(),
+ }),
+ "submit" => Some(ToolCall::Submit {
+ summary: args.get("summary")?.as_str()?.to_string(),
+ }),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolResult {
+ pub success: bool,
+ pub output: String,
+}
+
+/// OpenAI-compatible tool definitions — works with all providers
+pub fn tool_definitions() -> serde_json::Value {
+ serde_json::json!([
+ {
+ "type": "function",
+ "function": {
+ "name": "read_file",
+ "description": "Read a file's contents. Returns up to 16000 chars at a time. Use offset to read further into large files.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": { "type": "string", "description": "File path (relative to workspace, or absolute)" },
+ "offset": { "type": "integer", "description": "Character offset to start reading from. Use this to continue reading a large file." }
+ },
+ "required": ["path"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "find_file",
+ "description": "Find files by name anywhere on the filesystem. Searches recursively. Use this when you don't know where a file is.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string", "description": "File or directory name to search for (partial match)" },
+ "dir": { "type": "string", "description": "Starting directory. Defaults to user home (~)." }
+ },
+ "required": ["name"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "write_file",
+ "description": "Write content to a file (full replacement). Creates parent directories. Use edit_file for targeted changes instead.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": { "type": "string", "description": "File path relative to workspace" },
+ "content": { "type": "string", "description": "Full file content to write" }
+ },
+ "required": ["path", "content"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "edit_file",
+ "description": "Edit a file by replacing a specific string with new text. Preferred over write_file for targeted changes. Uses fuzzy matching if exact match fails.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": { "type": "string", "description": "File path relative to workspace" },
+ "old_string": { "type": "string", "description": "Exact text to find (include surrounding context for uniqueness)" },
+ "new_string": { "type": "string", "description": "Replacement text" }
+ },
+ "required": ["path", "old_string", "new_string"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "list_files",
+ "description": "List files and directories in a tree view.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": { "type": "string", "description": "Directory path relative to workspace. Defaults to root." }
+ }
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "search",
+ "description": "Search for text pattern in files using grep. Returns matching lines with file paths and line numbers.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": { "type": "string", "description": "Text pattern to search for" },
+ "path": { "type": "string", "description": "Directory to search in. Defaults to workspace root." }
+ },
+ "required": ["query"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "run_command",
+ "description": "Execute a shell command in the workspace directory. Returns stdout and stderr.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "command": { "type": "string", "description": "Shell command to execute" }
+ },
+ "required": ["command"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "change_directory",
+ "description": "Change the working directory. Use this when the user wants to switch to a different folder. Accepts absolute paths.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": { "type": "string", "description": "Absolute path to the directory to switch to" }
+ },
+ "required": ["path"]
+ }
+ }
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "submit",
+ "description": "Mark the task as complete. Call when finished with the user's request.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "summary": { "type": "string", "description": "Brief summary of what was accomplished" }
+ },
+ "required": ["summary"]
+ }
+ }
+ }
+ ])
+}
+
+/// Parse tool_calls from an OpenAI-format API response
+pub fn parse_api_tool_calls(resp: &serde_json::Value) -> Vec {
+ let mut calls = Vec::new();
+ if let Some(tool_calls) = resp
+ .pointer("/choices/0/message/tool_calls")
+ .and_then(|v| v.as_array())
+ {
+ for tc in tool_calls {
+ let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let name = tc
+ .pointer("/function/name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ let arguments = tc
+ .pointer("/function/arguments")
+ .and_then(|v| v.as_str())
+ .unwrap_or("{}")
+ .to_string();
+ if !name.is_empty() {
+ calls.push(ApiToolCall { id, name, arguments });
+ }
+ }
+ }
+ calls
+}
+
+/// Execute a tool call.
+/// `confirm_writes` — prompt Y/n before writes (suggest mode).
+/// `collapse_diffs` — show collapsed diff with Ctrl+O to expand (auto-edit mode).
+pub fn execute_tool(call: &ToolCall, workspace: &str, confirm_writes: bool, collapse_diffs: bool) -> ToolResult {
+ match call {
+ ToolCall::ReadFile { path, offset } => exec_read_file(workspace, path, *offset),
+ ToolCall::FindFile { name, dir } => exec_find_file(name, dir.as_deref()),
+ ToolCall::WriteFile { path, content } => exec_write_file(workspace, path, content, confirm_writes, collapse_diffs),
+ ToolCall::EditFile { path, old_string, new_string } => {
+ exec_edit_file(workspace, path, old_string, new_string, confirm_writes, collapse_diffs)
+ }
+ ToolCall::ListFiles { path } => exec_list_files(workspace, path.as_deref()),
+ ToolCall::Search { query, path } => exec_search(workspace, query, path.as_deref()),
+ ToolCall::RunCommand { command } => exec_run_command(workspace, command),
+ ToolCall::ChangeDir { path } => {
+ // Handled in run_turn — this is a fallback
+ ui::print_tool_action("cd", path);
+ ToolResult { success: true, output: format!("Changed to {path}") }
+ }
+ ToolCall::Submit { summary } => {
+ ui::print_success(summary);
+ ToolResult { success: true, output: format!("Task complete: {summary}") }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tool implementations
+// ---------------------------------------------------------------------------
+
+const READ_CHUNK: usize = 16000;
+
+fn exec_read_file(workspace: &str, path: &str, offset: Option) -> ToolResult {
+ // Support absolute paths too (for find_file results)
+ let full = if Path::new(path).is_absolute() {
+ Path::new(path).to_path_buf()
+ } else {
+ Path::new(workspace).join(path)
+ };
+ let offset = offset.unwrap_or(0);
+ ui::print_tool_action("read", &format!("{}", full.display()));
+ match std::fs::read_to_string(&full) {
+ Ok(content) => {
+ let total = content.len();
+ let chunk: String = content.chars().skip(offset).take(READ_CHUNK).collect();
+ let end = offset + chunk.len();
+ if offset > 0 || total > end {
+ ui::print_dim(&format!(
+ " ({total} chars total, showing {offset}..{end})"
+ ));
+ }
+ let mut output = chunk;
+ if end < total {
+ output.push_str(&format!(
+ "\n\n[{} more chars remaining — call read_file with offset={} to continue]",
+ total - end,
+ end
+ ));
+ }
+ ToolResult { success: true, output }
+ }
+ Err(e) => {
+ ui::print_error(&format!("Cannot read {}: {e}", full.display()));
+ ToolResult { success: false, output: format!("Error: {e}") }
+ }
+ }
+}
+
+fn exec_find_file(name: &str, dir: Option<&str>) -> ToolResult {
+ let search_dir = dir.unwrap_or_else(|| {
+ // Default to home directory
+ "~"
+ });
+ let expanded = if search_dir == "~" {
+ std::env::var("HOME").unwrap_or_else(|_| "/".into())
+ } else {
+ search_dir.to_string()
+ };
+
+ ui::print_tool_action("find", &format!("\"{name}\" in {expanded}"));
+
+ let output = std::process::Command::new("find")
+ .args([
+ &expanded,
+ "-maxdepth", "5",
+ "-iname", &format!("*{name}*"),
+ "-not", "-path", "*/node_modules/*",
+ "-not", "-path", "*/.git/*",
+ "-not", "-path", "*/target/*",
+ "-not", "-path", "*/__pycache__/*",
+ "-not", "-path", "*/Library/*",
+ "-not", "-path", "*/.Trash/*",
+ ])
+ .output();
+
+ match output {
+ Ok(out) => {
+ let text = String::from_utf8_lossy(&out.stdout);
+ let results: Vec<&str> = text.lines().take(30).collect();
+ let count = results.len();
+ if count > 0 {
+ ui::print_dim(&format!(" {count} results"));
+ } else {
+ ui::print_dim(" No results");
+ }
+ ToolResult { success: true, output: results.join("\n") }
+ }
+ Err(e) => ToolResult { success: false, output: format!("Find error: {e}") },
+ }
+}
+
+fn exec_write_file(workspace: &str, path: &str, content: &str, confirm: bool, collapse_diffs: bool) -> ToolResult {
+ let full = Path::new(workspace).join(path);
+ ui::print_tool_action("write", &format!("{}", full.display()));
+
+ let old_content = std::fs::read_to_string(&full).unwrap_or_default();
+
+ // Show diff if file exists
+ if full.exists() {
+ let has_changes = if collapse_diffs {
+ ui::print_diff_collapsible(path, &old_content, content)
+ } else {
+ ui::print_diff(path, &old_content, content)
+ };
+ if !has_changes {
+ ui::print_dim(" (no changes)");
+ return ToolResult { success: true, output: format!("{path} unchanged") };
+ }
+ }
+
+ // Confirm in suggest mode
+ if confirm && full.exists() {
+ if !ui::confirm("Apply this change?") {
+ return ToolResult { success: false, output: "User declined the change".into() };
+ }
+ }
+
+ if let Some(parent) = full.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ match std::fs::write(&full, content) {
+ Ok(()) => {
+ let lines = content.lines().count();
+ ui::print_success(&format!(" Wrote {path} ({lines} lines)"));
+ ToolResult { success: true, output: format!("Wrote {path}") }
+ }
+ Err(e) => {
+ ui::print_error(&format!("Cannot write {}: {e}", full.display()));
+ ToolResult { success: false, output: format!("Error: {e}") }
+ }
+ }
+}
+
+fn exec_edit_file(
+ workspace: &str,
+ path: &str,
+ old_string: &str,
+ new_string: &str,
+ confirm: bool,
+ collapse_diffs: bool,
+) -> ToolResult {
+ let full = Path::new(workspace).join(path);
+ ui::print_tool_action("edit", &format!("{}", full.display()));
+
+ let content = match std::fs::read_to_string(&full) {
+ Ok(c) => c,
+ Err(e) => {
+ ui::print_error(&format!("Cannot read {}: {e}", full.display()));
+ return ToolResult { success: false, output: format!("Error: {e}") };
+ }
+ };
+
+ // Try exact match first
+ if let Some(pos) = content.find(old_string) {
+ let new_content = format!(
+ "{}{}{}",
+ &content[..pos],
+ new_string,
+ &content[pos + old_string.len()..]
+ );
+ if collapse_diffs {
+ ui::print_diff_collapsible(path, &content, &new_content);
+ } else {
+ ui::print_diff(path, &content, &new_content);
+ }
+
+ if confirm && !ui::confirm("Apply this change?") {
+ return ToolResult { success: false, output: "User declined the change".into() };
+ }
+
+ match std::fs::write(&full, &new_content) {
+ Ok(()) => {
+ ui::print_success(&format!(" Edited {path}"));
+ ToolResult { success: true, output: format!("Edited {path}") }
+ }
+ Err(e) => {
+ ui::print_error(&format!("Cannot write {}: {e}", full.display()));
+ ToolResult { success: false, output: format!("Error: {e}") }
+ }
+ }
+ } else {
+ // Fuzzy fallback
+ match fuzzy_find(&content, old_string) {
+ Some((start, end, score)) => {
+ let matched_preview: String = content[start..end].chars().take(60).collect();
+ ui::print_dim(&format!(" (fuzzy match, {score}% similar: \"{matched_preview}...\")"));
+ let new_content = format!("{}{}{}", &content[..start], new_string, &content[end..]);
+ if collapse_diffs {
+ ui::print_diff_collapsible(path, &content, &new_content);
+ } else {
+ ui::print_diff(path, &content, &new_content);
+ }
+
+ if confirm && !ui::confirm("Apply this fuzzy match?") {
+ return ToolResult { success: false, output: "User declined the change".into() };
+ }
+
+ match std::fs::write(&full, &new_content) {
+ Ok(()) => {
+ ui::print_success(&format!(" Edited {path} (fuzzy match)"));
+ ToolResult { success: true, output: format!("Edited {path} (fuzzy match, {score}% similar)") }
+ }
+ Err(e) => ToolResult { success: false, output: format!("Error: {e}") }
+ }
+ }
+ None => {
+ ui::print_error(" String not found (exact or fuzzy)");
+ ToolResult {
+ success: false,
+ output: "Error: old_string not found in file (exact and fuzzy search failed)".into(),
+ }
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Fuzzy matching for edit_file fallback
+// ---------------------------------------------------------------------------
+
+/// Find the best fuzzy match for `needle` in `haystack` using line-based sliding window.
+/// Returns (start_byte, end_byte, similarity_percent) or None if <60% similar.
+fn fuzzy_find(haystack: &str, needle: &str) -> Option<(usize, usize, usize)> {
+ if needle.is_empty() || haystack.is_empty() {
+ return None;
+ }
+
+ let needle_lines: Vec<&str> = needle.lines().collect();
+ let haystack_lines: Vec<&str> = haystack.lines().collect();
+
+ if needle_lines.is_empty() || haystack_lines.is_empty() {
+ return None;
+ }
+
+ let window = needle_lines.len();
+ if window > haystack_lines.len() {
+ return None;
+ }
+
+ let mut best_score = 0usize;
+ let mut best_start_line = 0usize;
+
+ for i in 0..=(haystack_lines.len() - window) {
+ let candidate: String = haystack_lines[i..i + window].join("\n");
+ let score = line_similarity(needle, &candidate);
+ if score > best_score {
+ best_score = score;
+ best_start_line = i;
+ }
+ }
+
+ // Require at least 60% similarity
+ if best_score < 60 {
+ return None;
+ }
+
+ // Convert line indices to byte offsets
+ let mut start_byte = 0;
+ for line in &haystack_lines[..best_start_line] {
+ start_byte += line.len() + 1; // +1 for newline
+ }
+ let mut end_byte = start_byte;
+ for line in &haystack_lines[best_start_line..best_start_line + window] {
+ end_byte += line.len() + 1;
+ }
+ end_byte = end_byte.min(haystack.len());
+
+ Some((start_byte, end_byte, best_score))
+}
+
+/// Line-based similarity (0-100). Compares trimmed lines.
+fn line_similarity(a: &str, b: &str) -> usize {
+ let a_lines: Vec<&str> = a.lines().collect();
+ let b_lines: Vec<&str> = b.lines().collect();
+ let max_len = a_lines.len().max(b_lines.len());
+ if max_len == 0 {
+ return 100;
+ }
+ let matching = a_lines
+ .iter()
+ .zip(b_lines.iter())
+ .filter(|(a, b)| a.trim() == b.trim())
+ .count();
+ (matching * 100) / max_len
+}
+
+// ---------------------------------------------------------------------------
+// list_files and search implementations
+// ---------------------------------------------------------------------------
+
+fn exec_list_files(workspace: &str, path: Option<&str>) -> ToolResult {
+ let dir = path
+ .map(|p| Path::new(workspace).join(p))
+ .unwrap_or_else(|| Path::new(workspace).to_path_buf());
+ ui::print_tool_action("list", &format!("{}", dir.display()));
+
+ let mut entries = Vec::new();
+ list_dir_recursive(&dir, &dir, 0, 3, &mut entries);
+ let output = entries.join("\n");
+ let count = entries.len();
+ ui::print_dim(&format!(" {count} entries"));
+ ToolResult { success: true, output }
+}
+
+fn list_dir_recursive(
+ base: &Path,
+ dir: &Path,
+ depth: usize,
+ max_depth: usize,
+ entries: &mut Vec,
+) {
+ if depth > max_depth || entries.len() > 200 {
+ return;
+ }
+
+ let skip = [
+ "node_modules", ".git", "target", "__pycache__", ".next",
+ "dist", "build", ".DS_Store", ".cache", "vendor",
+ ];
+
+ let mut items: Vec<_> = match std::fs::read_dir(dir) {
+ Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
+ Err(_) => return,
+ };
+ items.sort_by_key(|e| e.file_name());
+
+ for entry in items {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if skip.contains(&name.as_str()) {
+ continue;
+ }
+ let indent = " ".repeat(depth);
+ let rel = entry
+ .path()
+ .strip_prefix(base)
+ .unwrap_or(&entry.path())
+ .to_string_lossy()
+ .to_string();
+
+ if entry.path().is_dir() {
+ entries.push(format!("{indent}{rel}/"));
+ list_dir_recursive(base, &entry.path(), depth + 1, max_depth, entries);
+ } else {
+ let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
+ let size_str = if size > 1024 * 1024 {
+ format!("{}MB", size / (1024 * 1024))
+ } else if size > 1024 {
+ format!("{}KB", size / 1024)
+ } else {
+ format!("{size}B")
+ };
+ entries.push(format!("{indent}{rel} ({size_str})"));
+ }
+ }
+}
+
+fn exec_search(workspace: &str, query: &str, path: Option<&str>) -> ToolResult {
+ let dir = path
+ .map(|p| Path::new(workspace).join(p).to_string_lossy().to_string())
+ .unwrap_or_else(|| workspace.to_string());
+ ui::print_tool_action("search", &format!("\"{query}\" in {dir}"));
+
+ let output = std::process::Command::new("grep")
+ .args([
+ "-rn",
+ "--include=*.py", "--include=*.rs", "--include=*.ts", "--include=*.tsx",
+ "--include=*.js", "--include=*.jsx", "--include=*.go", "--include=*.toml",
+ "--include=*.json", "--include=*.md", "--include=*.yaml", "--include=*.yml",
+ "--include=*.c", "--include=*.cpp", "--include=*.h", "--include=*.java",
+ "--include=*.swift", "--include=*.kt", "--include=*.rb",
+ query, &dir,
+ ])
+ .output();
+
+ match output {
+ Ok(out) => {
+ let text: String = String::from_utf8_lossy(&out.stdout)
+ .chars()
+ .take(4096)
+ .collect();
+ let count = text.lines().count();
+ if count > 0 {
+ ui::print_dim(&format!(" {count} matches"));
+ } else {
+ ui::print_dim(" No matches");
+ }
+ ToolResult { success: true, output: text }
+ }
+ Err(e) => ToolResult { success: false, output: format!("Search error: {e}") },
+ }
+}
+
+fn exec_run_command(workspace: &str, command: &str) -> ToolResult {
+ ui::print_tool_action("run", command);
+ let output = std::process::Command::new("sh")
+ .args(["-c", command])
+ .current_dir(workspace)
+ .output();
+ match output {
+ Ok(out) => {
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ let combined: String = format!("{stdout}{stderr}").chars().take(4096).collect();
+ if out.status.success() {
+ ui::print_dim(&format!(" exit 0 ({} chars)", combined.len()));
+ } else {
+ ui::print_error(&format!(" exit {}", out.status));
+ }
+ ToolResult { success: out.status.success(), output: combined }
+ }
+ Err(e) => ToolResult { success: false, output: format!("Command error: {e}") },
+ }
+}
diff --git a/clif-code-tui/src/ui.rs b/clif-code-tui/src/ui.rs
new file mode 100644
index 0000000..f8cb7dd
--- /dev/null
+++ b/clif-code-tui/src/ui.rs
@@ -0,0 +1,579 @@
+//! Pretty printing, colors, diffs, interactive menus.
+
+use crossterm::event::{self, Event, KeyCode, KeyEventKind};
+use crossterm::terminal;
+use similar::ChangeTag;
+use std::io::{self, BufRead, Write};
+
+pub const RESET: &str = "\x1b[0m";
+pub const BOLD: &str = "\x1b[1m";
+pub const DIM: &str = "\x1b[2m";
+pub const ITALIC: &str = "\x1b[3m";
+pub const UNDERLINE: &str = "\x1b[4m";
+pub const CYAN: &str = "\x1b[36m";
+pub const GREEN: &str = "\x1b[32m";
+pub const YELLOW: &str = "\x1b[33m";
+pub const RED: &str = "\x1b[31m";
+pub const MAGENTA: &str = "\x1b[35m";
+pub const BLUE: &str = "\x1b[34m";
+pub const WHITE: &str = "\x1b[97m";
+
+// Bright variants for more pop
+pub const BRIGHT_CYAN: &str = "\x1b[96m";
+pub const BRIGHT_GREEN: &str = "\x1b[92m";
+pub const BRIGHT_MAGENTA: &str = "\x1b[95m";
+pub const BRIGHT_YELLOW: &str = "\x1b[93m";
+pub const BRIGHT_BLUE: &str = "\x1b[94m";
+
+// 256-color for gradient effect
+const C_BLUE: &str = "\x1b[38;5;39m"; // bright blue
+const C_CYAN: &str = "\x1b[38;5;44m"; // teal
+const C_TEAL: &str = "\x1b[38;5;43m"; // green-teal
+const C_GREEN: &str = "\x1b[38;5;48m"; // bright green
+const C_LIME: &str = "\x1b[38;5;83m"; // lime
+const C_PURPLE: &str = "\x1b[38;5;141m"; // soft purple
+
+pub fn print_logo() {
+ println!();
+ println!(" {BOLD}{C_BLUE} _____ _ _ __ _____ _ {RESET}");
+ println!(" {BOLD}{C_CYAN} / ____| (_)/ _/ ____| | | {RESET}");
+ println!(" {BOLD}{C_TEAL} | | | |_| || | ___ __| | ___ {RESET}");
+ println!(" {BOLD}{C_GREEN} | | | | | _| | / _ \\ / _` |/ _ \\{RESET}");
+ println!(" {BOLD}{C_LIME} | |____| | | | | |___| (_) | (_| | __/{RESET}");
+ println!(" {BOLD}{C_GREEN} \\_____|_|_|_| \\_____\\___/ \\__,_|\\___|{RESET}");
+ println!();
+}
+
+pub fn print_banner(workspace: &str, backend_name: &str, mode: &str) {
+ println!(" {BOLD}{WHITE}AI coding assistant{RESET} {DIM}— works anywhere, ships fast{RESET}");
+ println!();
+
+ // Status pills
+ let mode_color = match mode {
+ "suggest" => YELLOW,
+ "full-auto" => RED,
+ _ => BRIGHT_GREEN,
+ };
+ println!(
+ " {BRIGHT_CYAN}\u{25c6}{RESET} {DIM}Model{RESET} {BOLD}{WHITE}{backend_name}{RESET} \
+ {mode_color}\u{25c6}{RESET} {DIM}Mode{RESET} {BOLD}{WHITE}{mode}{RESET}"
+ );
+ println!(
+ " {BRIGHT_MAGENTA}\u{25c6}{RESET} {DIM}Path{RESET} {workspace}"
+ );
+ println!();
+ println!(
+ " {DIM}Type a task to get started, or {RESET}{BOLD}{BRIGHT_CYAN}/help{RESET}{DIM} for commands{RESET}"
+ );
+ println!(" {DIM}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}{RESET}");
+ println!();
+}
+
+pub fn print_prompt() {
+ print!("{BOLD}{BRIGHT_GREEN} \u{276f}{RESET} ");
+ io::stdout().flush().unwrap();
+}
+
+pub fn print_thinking() {
+ print!("{DIM}{ITALIC}\u{2022}\u{2022}\u{2022} thinking{RESET}");
+ io::stdout().flush().unwrap();
+}
+
+pub fn clear_thinking() {
+ print!("\r\x1b[K");
+ io::stdout().flush().unwrap();
+}
+
+pub fn print_tool_action(action: &str, detail: &str) {
+ let icon = match action {
+ "read" => "\u{25b6}", // play triangle
+ "write" => "\u{270e}", // pencil
+ "edit" => "\u{270e}",
+ "find" => "\u{25c7}", // diamond outline
+ "search" => "\u{2315}", // search
+ "list" => "\u{2630}", // trigram / hamburger
+ "run" => "\u{25b8}", // small play
+ "cd" => "\u{2192}", // arrow
+ _ => "\u{2022}", // bullet
+ };
+ println!(" {BRIGHT_YELLOW}{icon} {BOLD}{action}{RESET} {DIM}{detail}{RESET}");
+}
+
+pub fn print_dim(text: &str) {
+ println!("{DIM}{text}{RESET}");
+}
+
+pub fn print_success(text: &str) {
+ println!(" {BRIGHT_GREEN}\u{2713}{RESET} {GREEN}{text}{RESET}");
+}
+
+pub fn print_error(text: &str) {
+ println!(" {RED}\u{2717} {BOLD}{text}{RESET}");
+}
+
+pub fn print_assistant(text: &str) {
+ println!();
+ print!(" {BOLD}{BRIGHT_MAGENTA}\u{2726} ClifCode{RESET} ");
+ let rendered = render_markdown(text);
+ for (i, line) in rendered.lines().enumerate() {
+ if i == 0 {
+ println!("{line}");
+ } else {
+ println!(" {line}");
+ }
+ }
+ println!();
+}
+
+/// Convert markdown to ANSI terminal formatting.
+fn render_markdown(text: &str) -> String {
+ let mut out = String::new();
+ let mut in_code_block = false;
+
+ for line in text.lines() {
+ if line.trim_start().starts_with("```") {
+ in_code_block = !in_code_block;
+ if in_code_block {
+ // Extract language hint if present
+ let lang = line.trim_start().trim_start_matches('`');
+ if lang.is_empty() {
+ out.push_str(&format!("{DIM}\u{256d}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}{RESET}\n"));
+ } else {
+ out.push_str(&format!("{DIM}\u{256d}\u{2500}\u{2500} {RESET}{BRIGHT_CYAN}{BOLD}{lang}{RESET}{DIM} \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}{RESET}\n"));
+ }
+ } else {
+ out.push_str(&format!("{DIM}\u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}{RESET}\n"));
+ }
+ continue;
+ }
+
+ if in_code_block {
+ out.push_str(&format!("{DIM}\u{2502}{RESET} {line}\n"));
+ continue;
+ }
+
+ let trimmed = line.trim_start();
+
+ // Headers: # ## ###
+ if trimmed.starts_with("### ") {
+ let content = &trimmed[4..];
+ out.push_str(&format!("{BOLD}{content}{RESET}\n"));
+ continue;
+ }
+ if trimmed.starts_with("## ") {
+ let content = &trimmed[3..];
+ out.push_str(&format!("{BOLD}{content}{RESET}\n"));
+ continue;
+ }
+ if trimmed.starts_with("# ") {
+ let content = &trimmed[2..];
+ out.push_str(&format!("{BOLD}{CYAN}{content}{RESET}\n"));
+ continue;
+ }
+
+ // Bullet points: - or *
+ if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
+ let content = &trimmed[2..];
+ let rendered_content = render_inline(content);
+ out.push_str(&format!(" {CYAN}•{RESET} {rendered_content}\n"));
+ continue;
+ }
+
+ // Numbered lists: 1. 2. etc
+ if trimmed.len() > 2 {
+ let first_char = trimmed.chars().next().unwrap_or(' ');
+ if first_char.is_ascii_digit() {
+ if let Some(dot_pos) = trimmed.find(". ") {
+ if dot_pos <= 2 {
+ let num = &trimmed[..dot_pos + 1];
+ let content = &trimmed[dot_pos + 2..];
+ let rendered_content = render_inline(content);
+ out.push_str(&format!(" {CYAN}{num}{RESET} {rendered_content}\n"));
+ continue;
+ }
+ }
+ }
+ }
+
+ // Regular line — process inline formatting
+ let rendered_line = render_inline(line);
+ out.push_str(&rendered_line);
+ out.push('\n');
+ }
+
+ // Remove trailing newline
+ if out.ends_with('\n') {
+ out.pop();
+ }
+ out
+}
+
+/// Render inline markdown: **bold**, *italic*, `code`, [links](url)
+pub fn render_inline(text: &str) -> String {
+ let mut out = String::new();
+ let chars: Vec = text.chars().collect();
+ let len = chars.len();
+ let mut i = 0;
+
+ while i < len {
+ // **bold**
+ if i + 1 < len && chars[i] == '*' && chars[i + 1] == '*' {
+ if let Some(end) = find_closing(&chars, i + 2, &['*', '*']) {
+ let content: String = chars[i + 2..end].iter().collect();
+ out.push_str(&format!("{BOLD}{content}{RESET}"));
+ i = end + 2;
+ continue;
+ }
+ }
+
+ // `code`
+ if chars[i] == '`' {
+ if let Some(end) = find_single_closing(&chars, i + 1, '`') {
+ let content: String = chars[i + 1..end].iter().collect();
+ out.push_str(&format!("{CYAN}{content}{RESET}"));
+ i = end + 1;
+ continue;
+ }
+ }
+
+ // *italic* (single star, not **)
+ if chars[i] == '*' && (i + 1 >= len || chars[i + 1] != '*') {
+ if let Some(end) = find_single_closing(&chars, i + 1, '*') {
+ let content: String = chars[i + 1..end].iter().collect();
+ out.push_str(&format!("{DIM}{content}{RESET}"));
+ i = end + 1;
+ continue;
+ }
+ }
+
+ out.push(chars[i]);
+ i += 1;
+ }
+
+ out
+}
+
+/// Find closing ** pair
+fn find_closing(chars: &[char], start: usize, pattern: &[char; 2]) -> Option {
+ let len = chars.len();
+ let mut i = start;
+ while i + 1 < len {
+ if chars[i] == pattern[0] && chars[i + 1] == pattern[1] {
+ return Some(i);
+ }
+ i += 1;
+ }
+ None
+}
+
+/// Find closing single char
+fn find_single_closing(chars: &[char], start: usize, ch: char) -> Option {
+ for i in start..chars.len() {
+ if chars[i] == ch {
+ return Some(i);
+ }
+ }
+ None
+}
+
+/// Print token usage and estimated cost for a turn
+pub fn print_usage(prompt_tokens: usize, completion_tokens: usize) {
+ let total = prompt_tokens + completion_tokens;
+ let cost = (prompt_tokens as f64 * 3.0 + completion_tokens as f64 * 15.0) / 1_000_000.0;
+
+ let total_str = if total >= 1000 {
+ format!("{:.1}k", total as f64 / 1000.0)
+ } else {
+ format!("{total}")
+ };
+
+ println!(
+ " {DIM}\u{2219} {total_str} tokens \u{2219} ~${cost:.4}{RESET}"
+ );
+}
+
+/// Print cumulative session cost summary
+pub fn print_session_cost(prompt_tokens: usize, completion_tokens: usize) {
+ let total = prompt_tokens + completion_tokens;
+ let cost = (prompt_tokens as f64 * 3.0 + completion_tokens as f64 * 15.0) / 1_000_000.0;
+
+ let total_str = if total >= 1000 {
+ format!("{:.1}k", total as f64 / 1000.0)
+ } else {
+ format!("{total}")
+ };
+
+ println!();
+ println!(" {BOLD}{WHITE}\u{2261} Session Usage{RESET}");
+ println!(" {DIM}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}{RESET}");
+ println!(" {BRIGHT_CYAN}\u{25b8}{RESET} {DIM}Prompt:{RESET} {BOLD}{prompt_tokens}{RESET}");
+ println!(" {BRIGHT_MAGENTA}\u{25b8}{RESET} {DIM}Completion:{RESET} {BOLD}{completion_tokens}{RESET}");
+ println!(" {BRIGHT_GREEN}\u{25b8}{RESET} {DIM}Total:{RESET} {BOLD}{total_str}{RESET}");
+ println!(" {BRIGHT_YELLOW}\u{25b8}{RESET} {DIM}Cost:{RESET} {BOLD}${cost:.4}{RESET}");
+ println!();
+}
+
+pub fn print_turn_indicator(turn: usize, max: usize) {
+ // Color the turn number: green early, yellow mid, red near limit
+ let color = if turn <= max / 3 {
+ BRIGHT_GREEN
+ } else if turn <= 2 * max / 3 {
+ BRIGHT_YELLOW
+ } else {
+ RED
+ };
+ print!(" {DIM}[{RESET}{color}{BOLD}{turn}{RESET}{DIM}/{max}]{RESET} ");
+ io::stdout().flush().unwrap();
+}
+
+/// Print a colored unified diff. Returns false if old == new.
+pub fn print_diff(path: &str, old: &str, new: &str) -> bool {
+ let diff = similar::TextDiff::from_lines(old, new);
+ let has_changes = diff
+ .iter_all_changes()
+ .any(|c| c.tag() != ChangeTag::Equal);
+ if !has_changes {
+ return false;
+ }
+ println!(" {DIM}--- {path}{RESET}");
+ println!(" {DIM}+++ {path}{RESET}");
+ for change in diff.iter_all_changes() {
+ match change.tag() {
+ ChangeTag::Delete => print!(" {RED}-{change}{RESET}"),
+ ChangeTag::Insert => print!(" {GREEN}+{change}{RESET}"),
+ ChangeTag::Equal => print!(" {change}"),
+ }
+ }
+ true
+}
+
+/// Show a collapsed diff summary in auto-edit mode.
+/// User can press Ctrl+O to expand the full diff before it auto-applies.
+/// Returns false if old == new (no changes).
+pub fn print_diff_collapsible(path: &str, old: &str, new: &str) -> bool {
+ let diff = similar::TextDiff::from_lines(old, new);
+ let mut adds: usize = 0;
+ let mut dels: usize = 0;
+ let changes: Vec<_> = diff.iter_all_changes().collect();
+ for c in &changes {
+ match c.tag() {
+ ChangeTag::Insert => adds += 1,
+ ChangeTag::Delete => dels += 1,
+ _ => {}
+ }
+ }
+ if adds == 0 && dels == 0 {
+ return false;
+ }
+
+ // Show compact summary with expand hint
+ print!(
+ " {DIM}{path}:{RESET} {GREEN}+{adds}{RESET} {RED}-{dels}{RESET} {DIM}[{RESET}{CYAN}Ctrl+O{RESET}{DIM} to expand diff]{RESET}"
+ );
+ io::stdout().flush().unwrap();
+
+ // Brief poll for Ctrl+O (1.5 seconds)
+ let expanded = poll_for_ctrl_o(std::time::Duration::from_millis(1500));
+
+ // Clear the hint line
+ print!("\r\x1b[2K");
+ io::stdout().flush().unwrap();
+
+ if expanded {
+ // Show full diff
+ println!(" {DIM}--- {path}{RESET}");
+ println!(" {DIM}+++ {path}{RESET}");
+ for change in &changes {
+ match change.tag() {
+ ChangeTag::Delete => print!(" {RED}-{change}{RESET}"),
+ ChangeTag::Insert => print!(" {GREEN}+{change}{RESET}"),
+ ChangeTag::Equal => print!(" {change}"),
+ }
+ }
+ } else {
+ // Just reprint compact summary without the hint
+ println!(
+ " {DIM}{path}:{RESET} {GREEN}+{adds}{RESET} {RED}-{dels}{RESET}"
+ );
+ }
+ true
+}
+
+/// Poll for Ctrl+O keypress within a timeout. Returns true if pressed.
+fn poll_for_ctrl_o(timeout: std::time::Duration) -> bool {
+ if terminal::enable_raw_mode().is_err() {
+ return false;
+ }
+
+ let result = if event::poll(timeout).unwrap_or(false) {
+ if let Ok(Event::Key(key)) = event::read() {
+ // Ctrl+O = KeyCode::Char('o') with ctrl modifier
+ key.kind == KeyEventKind::Press
+ && key.code == KeyCode::Char('o')
+ && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
+ } else {
+ false
+ }
+ } else {
+ false
+ };
+
+ terminal::disable_raw_mode().ok();
+ result
+}
+
+/// Prompt for text input
+pub fn prompt_input(label: &str) -> String {
+ print!("{BOLD}{CYAN}{label}{RESET} ");
+ io::stdout().flush().unwrap();
+ let mut input = String::new();
+ io::stdin().lock().read_line(&mut input).unwrap_or(0);
+ input.trim().to_string()
+}
+
+/// Prompt with a default value
+pub fn prompt_input_default(label: &str, default: &str) -> String {
+ print!("{BOLD}{CYAN}{label}{RESET} {DIM}({default}){RESET} ");
+ io::stdout().flush().unwrap();
+ let mut input = String::new();
+ io::stdin().lock().read_line(&mut input).unwrap_or(0);
+ let input = input.trim();
+ if input.is_empty() {
+ default.to_string()
+ } else {
+ input.to_string()
+ }
+}
+
+/// Confirm yes/no (default yes)
+pub fn confirm(prompt: &str) -> bool {
+ print!(" {BOLD}{prompt}{RESET} {DIM}[Y/n]{RESET} ");
+ io::stdout().flush().unwrap();
+ let mut input = String::new();
+ io::stdin().lock().read_line(&mut input).unwrap_or(0);
+ let input = input.trim().to_lowercase();
+ input.is_empty() || input == "y" || input == "yes"
+}
+
+/// Render a single completed line during streaming with markdown formatting.
+/// Returns the ANSI-formatted string ready for printing.
+pub fn render_streaming_line(line: &str, in_code_block: bool) -> String {
+ if in_code_block {
+ return format!("{DIM}\u{2502}{RESET} {line}");
+ }
+
+ let trimmed = line.trim_start();
+
+ // Code block fences
+ if trimmed.starts_with("```") {
+ let lang = trimmed.trim_start_matches('`');
+ if !lang.is_empty() {
+ return format!("{DIM}\u{256d}\u{2500}\u{2500} {RESET}{BRIGHT_CYAN}{BOLD}{lang}{RESET}{DIM} \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}{RESET}");
+ }
+ return format!("{DIM}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}{RESET}");
+ }
+
+ // Headers
+ if trimmed.starts_with("### ") {
+ let content = &trimmed[4..];
+ return format!(" {BOLD}{content}{RESET}");
+ }
+ if trimmed.starts_with("## ") {
+ let content = &trimmed[3..];
+ return format!(" {BOLD}{content}{RESET}");
+ }
+ if trimmed.starts_with("# ") {
+ let content = &trimmed[2..];
+ return format!(" {BOLD}{CYAN}{content}{RESET}");
+ }
+
+ // Bullet points
+ if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
+ let content = &trimmed[2..];
+ let rendered = render_inline(content);
+ return format!(" {CYAN}•{RESET} {rendered}");
+ }
+
+ // Numbered lists
+ if trimmed.len() > 2 {
+ let first_char = trimmed.chars().next().unwrap_or(' ');
+ if first_char.is_ascii_digit() {
+ if let Some(dot_pos) = trimmed.find(". ") {
+ if dot_pos <= 2 {
+ let num = &trimmed[..dot_pos + 1];
+ let content = &trimmed[dot_pos + 2..];
+ let rendered = render_inline(content);
+ return format!(" {CYAN}{num}{RESET} {rendered}");
+ }
+ }
+ }
+ }
+
+ // Regular text with inline formatting
+ format!(" {}", render_inline(line))
+}
+
+/// Arrow-key interactive selector. Returns chosen index, or None on Escape.
+pub fn select_menu(title: &str, items: &[&str]) -> Option {
+ let mut selected: usize = 0;
+
+ println!(" {BOLD}{WHITE}{title}{RESET}");
+ println!(" {DIM}\u{2191}\u{2193} navigate \u{21b5} select esc cancel{RESET}");
+ println!();
+
+ fn draw_items(items: &[&str], selected: usize) {
+ for (i, item) in items.iter().enumerate() {
+ if i == selected {
+ println!(" {BRIGHT_CYAN}{BOLD}\u{276f} {item}{RESET}");
+ } else {
+ println!(" {DIM}{item}{RESET}");
+ }
+ }
+ }
+
+ draw_items(items, selected);
+ terminal::enable_raw_mode().ok()?;
+
+ loop {
+ if let Ok(Event::Key(key)) = event::read() {
+ if key.kind != KeyEventKind::Press {
+ continue;
+ }
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k') => {
+ if selected > 0 {
+ selected -= 1;
+ }
+ }
+ KeyCode::Down | KeyCode::Char('j') => {
+ if selected < items.len() - 1 {
+ selected += 1;
+ }
+ }
+ KeyCode::Enter => {
+ terminal::disable_raw_mode().ok();
+ let lines_to_clear = items.len() + 3;
+ for _ in 0..lines_to_clear {
+ print!("\x1b[A\x1b[2K");
+ }
+ io::stdout().flush().unwrap();
+ println!(" {BRIGHT_CYAN}\u{2713}{RESET} {BOLD}{}{RESET}", items[selected]);
+ println!();
+ return Some(selected);
+ }
+ KeyCode::Esc | KeyCode::Char('q') => {
+ terminal::disable_raw_mode().ok();
+ return None;
+ }
+ _ => {}
+ }
+
+ for _ in 0..items.len() {
+ print!("\x1b[A\x1b[2K");
+ }
+ io::stdout().flush().unwrap();
+ draw_items(items, selected);
+ io::stdout().flush().unwrap();
+ }
+ }
+}
diff --git a/CHANGELOG.md b/clif-pad-ide/CHANGELOG.md
similarity index 100%
rename from CHANGELOG.md
rename to clif-pad-ide/CHANGELOG.md
diff --git a/index.html b/clif-pad-ide/index.html
similarity index 96%
rename from index.html
rename to clif-pad-ide/index.html
index 627d3db..e162d43 100644
--- a/index.html
+++ b/clif-pad-ide/index.html
@@ -3,7 +3,7 @@
- Clif
+ ClifPad
diff --git a/package-lock.json b/clif-pad-ide/package-lock.json
similarity index 100%
rename from package-lock.json
rename to clif-pad-ide/package-lock.json
diff --git a/package.json b/clif-pad-ide/package.json
similarity index 97%
rename from package.json
rename to clif-pad-ide/package.json
index faf06ff..16c1a8e 100644
--- a/package.json
+++ b/clif-pad-ide/package.json
@@ -1,5 +1,5 @@
{
- "name": "clif",
+ "name": "clifpad",
"version": "1.3.0",
"private": true,
"type": "module",
@@ -62,7 +62,6 @@
{
"assets": [
"CHANGELOG.md",
- "README.md",
"www/index.html",
"package.json",
"package-lock.json",
diff --git a/scripts/bump-version.js b/clif-pad-ide/scripts/bump-version.js
similarity index 76%
rename from scripts/bump-version.js
rename to clif-pad-ide/scripts/bump-version.js
index e8c2322..b25b424 100644
--- a/scripts/bump-version.js
+++ b/clif-pad-ide/scripts/bump-version.js
@@ -60,16 +60,26 @@ readme = readme.replace(
`## Download v${version}`
);
-// Update all download URLs: /download/vOLD/Clif_OLD_ -> /download/vNEW/Clif_NEW_
+// Update all download URLs: /download/vOLD/ClifPad_OLD_ -> /download/vNEW/ClifPad_NEW_
readme = readme.replace(
- /\/download\/v[\d.]+\/Clif_[\d.]+_/g,
- `/download/v${version}/Clif_${version}_`
+ /\/download\/v[\d.]+\/ClifPad_[\d.]+_/g,
+ `/download/v${version}/ClifPad_${version}_`
);
writeFileSync(readmePath, readme);
console.log(` Updated README.md -> v${version}`);
-// 5. www/index.html — version badge and download links
+// 5. Monorepo root README.md — download links
+const rootReadmePath = resolve(root, "..", "README.md");
+let rootReadme = readFileSync(rootReadmePath, "utf-8");
+rootReadme = rootReadme.replace(
+ /\/download\/v[\d.]+\/ClifPad_[\d.]+_/g,
+ `/download/v${version}/ClifPad_${version}_`
+);
+writeFileSync(rootReadmePath, rootReadme);
+console.log(` Updated root README.md -> v${version}`);
+
+// 6. www/index.html — version badge and download links
const wwwPath = resolve(root, "www", "index.html");
let www = readFileSync(wwwPath, "utf-8");
@@ -78,8 +88,8 @@ www = www.replace(/>v[\d.]+<\/div>/, `>v${version}`);
// Update download URLs
www = www.replace(
- /\/download\/v[\d.]+\/Clif_[\d.]+_/g,
- `/download/v${version}/Clif_${version}_`
+ /\/download\/v[\d.]+\/ClifPad_[\d.]+_/g,
+ `/download/v${version}/ClifPad_${version}_`
);
writeFileSync(wwwPath, www);
diff --git a/src-tauri/Cargo.lock b/clif-pad-ide/src-tauri/Cargo.lock
similarity index 99%
rename from src-tauri/Cargo.lock
rename to clif-pad-ide/src-tauri/Cargo.lock
index 57441bb..cfeb426 100644
--- a/src-tauri/Cargo.lock
+++ b/clif-pad-ide/src-tauri/Cargo.lock
@@ -349,7 +349,7 @@ dependencies = [
[[package]]
name = "clif"
-version = "1.1.0"
+version = "1.3.0"
dependencies = [
"env_logger",
"futures",
diff --git a/src-tauri/Cargo.toml b/clif-pad-ide/src-tauri/Cargo.toml
similarity index 93%
rename from src-tauri/Cargo.toml
rename to clif-pad-ide/src-tauri/Cargo.toml
index 2fc3807..8b874a5 100644
--- a/src-tauri/Cargo.toml
+++ b/clif-pad-ide/src-tauri/Cargo.toml
@@ -1,10 +1,10 @@
[package]
-name = "clif"
+name = "clifpad"
version = "1.3.0"
edition = "2021"
[lib]
-name = "clif_lib"
+name = "clifpad_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
diff --git a/src-tauri/build.rs b/clif-pad-ide/src-tauri/build.rs
similarity index 100%
rename from src-tauri/build.rs
rename to clif-pad-ide/src-tauri/build.rs
diff --git a/src-tauri/capabilities/default.json b/clif-pad-ide/src-tauri/capabilities/default.json
similarity index 82%
rename from src-tauri/capabilities/default.json
rename to clif-pad-ide/src-tauri/capabilities/default.json
index 75e8d68..4a921d4 100644
--- a/src-tauri/capabilities/default.json
+++ b/clif-pad-ide/src-tauri/capabilities/default.json
@@ -1,8 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
- "description": "Capability for the main window",
- "windows": ["main"],
+ "description": "Capability for all windows",
+ "windows": ["main", "window-*"],
"permissions": [
"core:default",
"shell:allow-open",
@@ -17,6 +17,7 @@
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-title",
+ "core:window:allow-create",
"core:event:default",
"core:event:allow-emit",
"core:event:allow-listen"
diff --git a/src-tauri/icons/.gitkeep b/clif-pad-ide/src-tauri/icons/.gitkeep
similarity index 100%
rename from src-tauri/icons/.gitkeep
rename to clif-pad-ide/src-tauri/icons/.gitkeep
diff --git a/src-tauri/icons/128x128.png b/clif-pad-ide/src-tauri/icons/128x128.png
similarity index 100%
rename from src-tauri/icons/128x128.png
rename to clif-pad-ide/src-tauri/icons/128x128.png
diff --git a/src-tauri/icons/128x128@2x.png b/clif-pad-ide/src-tauri/icons/128x128@2x.png
similarity index 100%
rename from src-tauri/icons/128x128@2x.png
rename to clif-pad-ide/src-tauri/icons/128x128@2x.png
diff --git a/src-tauri/icons/32x32.png b/clif-pad-ide/src-tauri/icons/32x32.png
similarity index 100%
rename from src-tauri/icons/32x32.png
rename to clif-pad-ide/src-tauri/icons/32x32.png
diff --git a/src-tauri/icons/icon.icns b/clif-pad-ide/src-tauri/icons/icon.icns
similarity index 100%
rename from src-tauri/icons/icon.icns
rename to clif-pad-ide/src-tauri/icons/icon.icns
diff --git a/src-tauri/icons/icon.ico b/clif-pad-ide/src-tauri/icons/icon.ico
similarity index 100%
rename from src-tauri/icons/icon.ico
rename to clif-pad-ide/src-tauri/icons/icon.ico
diff --git a/src-tauri/src/commands/ai.rs b/clif-pad-ide/src-tauri/src/commands/ai.rs
similarity index 94%
rename from src-tauri/src/commands/ai.rs
rename to clif-pad-ide/src-tauri/src/commands/ai.rs
index 47eeace..310312b 100644
--- a/src-tauri/src/commands/ai.rs
+++ b/clif-pad-ide/src-tauri/src/commands/ai.rs
@@ -1,7 +1,7 @@
use serde_json::json;
use std::fs;
use std::path::PathBuf;
-use tauri::Emitter;
+use tauri::{Emitter, Manager};
#[derive(serde::Deserialize, Clone)]
pub struct ChatMessage {
@@ -53,12 +53,14 @@ fn get_provider_url(provider: &str) -> String {
#[tauri::command]
pub async fn ai_chat(
- app: tauri::AppHandle,
+ window: tauri::Window,
messages: Vec,
model: String,
api_key: Option,
provider: String,
) -> Result<(), String> {
+ let app = window.app_handle().clone();
+ let label = window.label().to_string();
let url = get_provider_url(&provider);
// Build the messages array for the API
@@ -110,7 +112,7 @@ pub async fn ai_chat(
let response = match req_builder.json(&request_body).send().await {
Ok(resp) => resp,
Err(e) => {
- let _ = app.emit("ai_stream_error", format!("Request failed: {}", e));
+ let _ = app.emit_to(&label, "ai_stream_error", format!("Request failed: {}", e));
return;
}
};
@@ -118,7 +120,8 @@ pub async fn ai_chat(
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
- let _ = app.emit(
+ let _ = app.emit_to(
+ &label,
"ai_stream_error",
format!("API error {}: {}", status, body),
);
@@ -150,7 +153,7 @@ pub async fn ai_chat(
let data = &line[6..];
if data == "[DONE]" {
- let _ = app.emit("ai_stream", "[DONE]");
+ let _ = app.emit_to(&label, "ai_stream", "[DONE]");
return;
}
@@ -165,7 +168,7 @@ pub async fn ai_chat(
if let Some(content) =
delta.get("content").and_then(|c| c.as_str())
{
- let _ = app.emit("ai_stream", content);
+ let _ = app.emit_to(&label, "ai_stream", content);
}
}
}
@@ -175,7 +178,8 @@ pub async fn ai_chat(
}
}
Err(e) => {
- let _ = app.emit(
+ let _ = app.emit_to(
+ &label,
"ai_stream_error",
format!("Stream read error: {}", e),
);
@@ -185,7 +189,7 @@ pub async fn ai_chat(
}
// If we get here without a [DONE], still signal completion
- let _ = app.emit("ai_stream", "[DONE]");
+ let _ = app.emit_to(&label, "ai_stream", "[DONE]");
});
Ok(())
@@ -205,11 +209,11 @@ pub async fn ai_complete(
"messages": [
{
"role": "system",
- "content": "You are a code completion assistant. Given the code context, provide only the completion text. Do not include explanations, markdown formatting, or code fences. Output only the raw code that should be inserted."
+ "content": "You are a code completion assistant. The user provides code with <|fim_prefix|> before the cursor and <|fim_suffix|> after. Output ONLY the code to insert at the cursor. No explanations, no markdown, no fences."
},
{
"role": "user",
- "content": format!("Complete the following code:\n\n{}", context)
+ "content": context
}
],
"max_tokens": 256,
diff --git a/src-tauri/src/commands/claude_code.rs b/clif-pad-ide/src-tauri/src/commands/claude_code.rs
similarity index 89%
rename from src-tauri/src/commands/claude_code.rs
rename to clif-pad-ide/src-tauri/src/commands/claude_code.rs
index 20736e2..e7b62d6 100644
--- a/src-tauri/src/commands/claude_code.rs
+++ b/clif-pad-ide/src-tauri/src/commands/claude_code.rs
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
-use tauri::Emitter;
+use tauri::{Emitter, Manager};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use uuid::Uuid;
@@ -37,12 +37,14 @@ struct ClaudeCodeEvent {
#[tauri::command]
pub async fn claude_code_start(
- app: tauri::AppHandle,
+ window: tauri::Window,
task: String,
working_dir: String,
) -> Result {
let session_id = Uuid::new_v4().to_string();
let sid = session_id.clone();
+ let app = window.app_handle().clone();
+ let label = window.label().to_string();
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
@@ -53,13 +55,12 @@ pub async fn claude_code_start(
sessions.insert(session_id.clone(), cancel_tx);
}
- let app_handle = app.clone();
-
tokio::spawn(async move {
- let result = run_claude_process(app_handle.clone(), &sid, &task, &working_dir, cancel_rx).await;
+ let result = run_claude_process(app.clone(), &label, &sid, &task, &working_dir, cancel_rx).await;
if let Err(e) = result {
- let _ = app_handle.emit(
+ let _ = app.emit_to(
+ &label,
"claude-code-output",
ClaudeCodeEvent {
session_id: sid.clone(),
@@ -69,7 +70,8 @@ pub async fn claude_code_start(
);
}
- let _ = app_handle.emit(
+ let _ = app.emit_to(
+ &label,
"claude-code-output",
ClaudeCodeEvent {
session_id: sid.clone(),
@@ -88,6 +90,7 @@ pub async fn claude_code_start(
async fn run_claude_process(
app: tauri::AppHandle,
+ label: &str,
session_id: &str,
task: &str,
working_dir: &str,
@@ -95,7 +98,6 @@ async fn run_claude_process(
) -> Result<(), String> {
let claude_bin = resolve_claude_path();
- // Use --verbose and pipe through to get real-time output
let mut child = Command::new(&claude_bin)
.arg("-p")
.arg(task)
@@ -114,18 +116,19 @@ async fn run_claude_process(
let sid = session_id.to_string();
let app_stdout = app.clone();
+ let label_stdout = label.to_string();
let sid_stdout = sid.clone();
- // Spawn a task to read stdout in raw chunks
let stdout_handle = tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stdout);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf).await {
- Ok(0) => break, // EOF
+ Ok(0) => break,
Ok(n) => {
let text = String::from_utf8_lossy(&buf[..n]).to_string();
- let _ = app_stdout.emit(
+ let _ = app_stdout.emit_to(
+ &label_stdout,
"claude-code-output",
ClaudeCodeEvent {
session_id: sid_stdout.clone(),
@@ -140,9 +143,9 @@ async fn run_claude_process(
});
let app_stderr = app.clone();
+ let label_stderr = label.to_string();
let sid_stderr = sid.clone();
- // Spawn a task to read stderr in raw chunks
let stderr_handle = tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stderr);
let mut buf = [0u8; 4096];
@@ -151,7 +154,8 @@ async fn run_claude_process(
Ok(0) => break,
Ok(n) => {
let text = String::from_utf8_lossy(&buf[..n]).to_string();
- let _ = app_stderr.emit(
+ let _ = app_stderr.emit_to(
+ &label_stderr,
"claude-code-output",
ClaudeCodeEvent {
session_id: sid_stderr.clone(),
@@ -165,7 +169,6 @@ async fn run_claude_process(
}
});
- // Wait for either cancellation or process completion
tokio::select! {
_ = &mut cancel_rx => {
let _ = child.kill().await;
@@ -173,7 +176,6 @@ async fn run_claude_process(
_ = child.wait() => {}
}
- // Wait for readers to finish draining
let _ = stdout_handle.await;
let _ = stderr_handle.await;
diff --git a/src-tauri/src/commands/fs.rs b/clif-pad-ide/src-tauri/src/commands/fs.rs
similarity index 95%
rename from src-tauri/src/commands/fs.rs
rename to clif-pad-ide/src-tauri/src/commands/fs.rs
index 1eee7cb..a7e7192 100644
--- a/src-tauri/src/commands/fs.rs
+++ b/clif-pad-ide/src-tauri/src/commands/fs.rs
@@ -1,7 +1,7 @@
use crate::services::file_watcher::WatcherState;
-use crate::state::AppState;
use std::fs;
use std::path::Path;
+use tauri::Manager;
#[derive(serde::Serialize, Clone)]
pub struct FileEntry {
@@ -13,7 +13,7 @@ pub struct FileEntry {
}
#[tauri::command]
-pub fn read_dir(path: String, _state: tauri::State<'_, AppState>) -> Result, String> {
+pub fn read_dir(path: String) -> Result, String> {
let dir_path = Path::new(&path);
if !dir_path.exists() {
@@ -170,8 +170,9 @@ pub fn delete_entry(path: String) -> Result<(), String> {
#[tauri::command]
pub fn watch_dir(
path: String,
- app: tauri::AppHandle,
+ window: tauri::Window,
watcher_state: tauri::State<'_, WatcherState>,
) -> Result<(), String> {
- crate::services::file_watcher::start_watching(&app, &watcher_state, &path)
+ let app = window.app_handle();
+ crate::services::file_watcher::start_watching(app, &watcher_state, &path, window.label())
}
diff --git a/src-tauri/src/commands/git.rs b/clif-pad-ide/src-tauri/src/commands/git.rs
similarity index 100%
rename from src-tauri/src/commands/git.rs
rename to clif-pad-ide/src-tauri/src/commands/git.rs
diff --git a/src-tauri/src/commands/mod.rs b/clif-pad-ide/src-tauri/src/commands/mod.rs
similarity index 86%
rename from src-tauri/src/commands/mod.rs
rename to clif-pad-ide/src-tauri/src/commands/mod.rs
index 244bc55..9f87af9 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/clif-pad-ide/src-tauri/src/commands/mod.rs
@@ -5,3 +5,4 @@ pub mod git;
pub mod pty;
pub mod search;
pub mod settings;
+pub mod window;
diff --git a/src-tauri/src/commands/pty.rs b/clif-pad-ide/src-tauri/src/commands/pty.rs
similarity index 85%
rename from src-tauri/src/commands/pty.rs
rename to clif-pad-ide/src-tauri/src/commands/pty.rs
index a8a63af..42edd18 100644
--- a/src-tauri/src/commands/pty.rs
+++ b/clif-pad-ide/src-tauri/src/commands/pty.rs
@@ -13,6 +13,7 @@ struct PtySession {
master: Box,
child: Box,
kill_flag: Arc>,
+ window_label: String,
}
impl PtyState {
@@ -21,6 +22,26 @@ impl PtyState {
sessions: Mutex::new(HashMap::new()),
}
}
+
+ pub fn kill_all_for_window(&self, label: &str) {
+ if let Ok(mut sessions) = self.sessions.lock() {
+ let to_remove: Vec = sessions
+ .iter()
+ .filter(|(_, s)| s.window_label == label)
+ .map(|(id, _)| id.clone())
+ .collect();
+
+ for id in to_remove {
+ if let Some(mut session) = sessions.remove(&id) {
+ if let Ok(mut flag) = session.kill_flag.lock() {
+ *flag = true;
+ }
+ let _ = session.child.kill();
+ drop(session);
+ }
+ }
+ }
+ }
}
#[derive(Clone, serde::Serialize)]
@@ -36,9 +57,11 @@ struct PtyExit {
#[tauri::command]
pub async fn pty_spawn(
- app: AppHandle,
+ window: tauri::Window,
working_dir: Option,
) -> Result {
+ let app = window.app_handle().clone();
+ let label = window.label().to_string();
let session_id = uuid::Uuid::new_v4().to_string();
let pty_system = native_pty_system();
@@ -94,6 +117,7 @@ pub async fn pty_spawn(
master: pair.master,
child,
kill_flag: kill_flag.clone(),
+ window_label: label.clone(),
};
let state = app.state::();
@@ -106,6 +130,7 @@ pub async fn pty_spawn(
// Spawn reader thread to stream output
let sid = session_id.clone();
let app_clone = app.clone();
+ let label_clone = label.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 32768]; // 32KB buffer for heavy TUI output
loop {
@@ -118,7 +143,8 @@ pub async fn pty_spawn(
Ok(0) => break, // EOF — shell exited
Ok(n) => {
let data = String::from_utf8_lossy(&buf[..n]).to_string();
- let _ = app_clone.emit(
+ let _ = app_clone.emit_to(
+ &label_clone,
"pty-output",
PtyOutput {
session_id: sid.clone(),
@@ -139,7 +165,8 @@ pub async fn pty_spawn(
}
// Emit exit event so the frontend knows the session died
- let _ = app_clone.emit(
+ let _ = app_clone.emit_to(
+ &label_clone,
"pty-exit",
PtyExit {
session_id: sid.clone(),
diff --git a/src-tauri/src/commands/search.rs b/clif-pad-ide/src-tauri/src/commands/search.rs
similarity index 100%
rename from src-tauri/src/commands/search.rs
rename to clif-pad-ide/src-tauri/src/commands/search.rs
diff --git a/src-tauri/src/commands/settings.rs b/clif-pad-ide/src-tauri/src/commands/settings.rs
similarity index 100%
rename from src-tauri/src/commands/settings.rs
rename to clif-pad-ide/src-tauri/src/commands/settings.rs
diff --git a/clif-pad-ide/src-tauri/src/commands/window.rs b/clif-pad-ide/src-tauri/src/commands/window.rs
new file mode 100644
index 0000000..37eaf5c
--- /dev/null
+++ b/clif-pad-ide/src-tauri/src/commands/window.rs
@@ -0,0 +1,16 @@
+#[tauri::command]
+pub async fn create_window(app: tauri::AppHandle) -> Result {
+ let label = format!("window-{}", &uuid::Uuid::new_v4().to_string()[..8]);
+
+ tauri::WebviewWindowBuilder::new(
+ &app,
+ &label,
+ tauri::WebviewUrl::App("index.html".into()),
+ )
+ .title("Clif")
+ .inner_size(1400.0, 900.0)
+ .build()
+ .map_err(|e| format!("Failed to create window: {}", e))?;
+
+ Ok(label)
+}
diff --git a/clif-pad-ide/src-tauri/src/lib.rs b/clif-pad-ide/src-tauri/src/lib.rs
new file mode 100644
index 0000000..dc176e1
--- /dev/null
+++ b/clif-pad-ide/src-tauri/src/lib.rs
@@ -0,0 +1,119 @@
+mod commands;
+mod services;
+mod state;
+
+use commands::pty::PtyState;
+use services::file_watcher::WatcherState;
+use tauri::Manager;
+
+fn build_menu(app: &tauri::AppHandle) -> Result, tauri::Error> {
+ use tauri::menu::{MenuBuilder, SubmenuBuilder, PredefinedMenuItem, MenuItemBuilder};
+
+ let new_window = MenuItemBuilder::with_id("new_window", "New Window")
+ .accelerator("CmdOrCtrl+Shift+N")
+ .build(app)?;
+
+ let file_menu = SubmenuBuilder::new(app, "File")
+ .item(&new_window)
+ .separator()
+ .item(&PredefinedMenuItem::close_window(app, None)?)
+ .item(&PredefinedMenuItem::quit(app, None)?)
+ .build()?;
+
+ let edit_menu = SubmenuBuilder::new(app, "Edit")
+ .item(&PredefinedMenuItem::undo(app, None)?)
+ .item(&PredefinedMenuItem::redo(app, None)?)
+ .separator()
+ .item(&PredefinedMenuItem::cut(app, None)?)
+ .item(&PredefinedMenuItem::copy(app, None)?)
+ .item(&PredefinedMenuItem::paste(app, None)?)
+ .item(&PredefinedMenuItem::select_all(app, None)?)
+ .build()?;
+
+ let window_menu = SubmenuBuilder::new(app, "Window")
+ .item(&PredefinedMenuItem::minimize(app, None)?)
+ .item(&PredefinedMenuItem::maximize(app, None)?)
+ .build()?;
+
+ MenuBuilder::new(app)
+ .item(&file_menu)
+ .item(&edit_menu)
+ .item(&window_menu)
+ .build()
+}
+
+pub fn run() {
+ tauri::Builder::default()
+ .plugin(tauri_plugin_shell::init())
+ .plugin(tauri_plugin_dialog::init())
+ .manage(PtyState::new())
+ .manage(WatcherState::new())
+ .setup(|app| {
+ let menu = build_menu(app.handle())?;
+ app.set_menu(menu)?;
+ Ok(())
+ })
+ .on_menu_event(|app, event| {
+ if event.id().as_ref() == "new_window" {
+ let app = app.clone();
+ tauri::async_runtime::spawn(async move {
+ let _ = commands::window::create_window(app).await;
+ });
+ }
+ })
+ .on_window_event(|window, event| {
+ if let tauri::WindowEvent::Destroyed = event {
+ let label = window.label().to_string();
+ let app = window.app_handle();
+
+ // Clean up PTY sessions for this window
+ if let Some(pty_state) = app.try_state::() {
+ pty_state.kill_all_for_window(&label);
+ }
+
+ // Clean up file watchers for this window
+ if let Some(watcher_state) = app.try_state::() {
+ services::file_watcher::stop_all_for_window(&watcher_state, &label);
+ }
+ }
+ })
+ .invoke_handler(tauri::generate_handler![
+ commands::fs::read_dir,
+ commands::fs::read_file,
+ commands::fs::write_file,
+ commands::fs::create_file,
+ commands::fs::create_dir,
+ commands::fs::rename_entry,
+ commands::fs::delete_entry,
+ commands::fs::watch_dir,
+ commands::ai::ai_chat,
+ commands::ai::ai_complete,
+ commands::ai::get_models,
+ commands::ai::set_api_key,
+ commands::ai::get_api_key,
+ commands::git::git_status,
+ commands::git::git_diff,
+ commands::git::git_commit,
+ commands::git::git_branches,
+ commands::git::git_checkout,
+ commands::git::git_stage,
+ commands::git::git_unstage,
+ commands::git::git_diff_stat,
+ commands::git::git_diff_numstat,
+ commands::git::git_init,
+ commands::git::git_log,
+ commands::search::search_files,
+ commands::claude_code::claude_code_start,
+ commands::claude_code::claude_code_send,
+ commands::claude_code::claude_code_stop,
+ commands::settings::get_settings,
+ commands::settings::set_settings,
+ commands::pty::pty_spawn,
+ commands::pty::pty_write,
+ commands::pty::pty_resize,
+ commands::pty::pty_kill,
+ commands::window::create_window,
+ ])
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
diff --git a/src-tauri/src/main.rs b/clif-pad-ide/src-tauri/src/main.rs
similarity index 78%
rename from src-tauri/src/main.rs
rename to clif-pad-ide/src-tauri/src/main.rs
index dcdba28..bdb82a9 100644
--- a/src-tauri/src/main.rs
+++ b/clif-pad-ide/src-tauri/src/main.rs
@@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
- clif_lib::run()
+ clifpad_lib::run()
}
diff --git a/src-tauri/src/services/ai_provider.rs b/clif-pad-ide/src-tauri/src/services/ai_provider.rs
similarity index 100%
rename from src-tauri/src/services/ai_provider.rs
rename to clif-pad-ide/src-tauri/src/services/ai_provider.rs
diff --git a/src-tauri/src/services/file_watcher.rs b/clif-pad-ide/src-tauri/src/services/file_watcher.rs
similarity index 71%
rename from src-tauri/src/services/file_watcher.rs
rename to clif-pad-ide/src-tauri/src/services/file_watcher.rs
index 3fa93d8..22b50ec 100644
--- a/src-tauri/src/services/file_watcher.rs
+++ b/clif-pad-ide/src-tauri/src/services/file_watcher.rs
@@ -1,6 +1,6 @@
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
+use std::collections::HashMap;
use std::path::PathBuf;
-use std::sync::mpsc;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
@@ -11,7 +11,7 @@ pub struct FileChangeEvent {
}
pub struct WatcherState {
- watcher: Mutex>,
+ watchers: Mutex>,
}
struct WatcherHandle {
@@ -21,7 +21,7 @@ struct WatcherHandle {
impl WatcherState {
pub fn new() -> Self {
WatcherState {
- watcher: Mutex::new(None),
+ watchers: Mutex::new(HashMap::new()),
}
}
}
@@ -30,11 +30,12 @@ pub fn start_watching(
app: &AppHandle,
state: &WatcherState,
path: &str,
+ window_label: &str,
) -> Result<(), String> {
- // Stop any existing watcher
- stop_watching(state);
+ // Stop any existing watcher for this window
+ stop_watching(state, window_label);
- let (tx, rx) = mpsc::channel::>();
+ let (tx, rx) = std::sync::mpsc::channel::>();
let mut watcher = RecommendedWatcher::new(tx, Config::default())
.map_err(|e| format!("Failed to create watcher: {}", e))?;
@@ -45,6 +46,7 @@ pub fn start_watching(
let app_clone = app.clone();
let watch_path = path.to_string();
+ let label = window_label.to_string();
// Spawn thread to process file system events
std::thread::spawn(move || {
@@ -67,7 +69,8 @@ pub fn start_watching(
// Only emit for actual files, not directories
if path.is_file() || kind_str == "remove" {
- let _ = app_clone.emit(
+ let _ = app_clone.emit_to(
+ &label,
"file-changed",
FileChangeEvent {
path: path_str,
@@ -80,19 +83,23 @@ pub fn start_watching(
}
});
- let mut guard = state.watcher.lock().map_err(|e| format!("Lock error: {}", e))?;
- *guard = Some(WatcherHandle { _watcher: watcher });
+ let mut guard = state.watchers.lock().map_err(|e| format!("Lock error: {}", e))?;
+ guard.insert(window_label.to_string(), WatcherHandle { _watcher: watcher });
- log::info!("File watcher started for: {}", watch_path);
+ log::info!("File watcher started for: {} (window: {})", watch_path, window_label);
Ok(())
}
-pub fn stop_watching(state: &WatcherState) {
- if let Ok(mut guard) = state.watcher.lock() {
- *guard = None;
+pub fn stop_watching(state: &WatcherState, window_label: &str) {
+ if let Ok(mut guard) = state.watchers.lock() {
+ guard.remove(window_label);
}
}
+pub fn stop_all_for_window(state: &WatcherState, window_label: &str) {
+ stop_watching(state, window_label);
+}
+
fn should_ignore(path: &str) -> bool {
let ignore_patterns = [
"/node_modules/",
diff --git a/src-tauri/src/services/mod.rs b/clif-pad-ide/src-tauri/src/services/mod.rs
similarity index 100%
rename from src-tauri/src/services/mod.rs
rename to clif-pad-ide/src-tauri/src/services/mod.rs
diff --git a/clif-pad-ide/src-tauri/src/state.rs b/clif-pad-ide/src-tauri/src/state.rs
new file mode 100644
index 0000000..560ffa0
--- /dev/null
+++ b/clif-pad-ide/src-tauri/src/state.rs
@@ -0,0 +1,2 @@
+// App state is managed per-window via Tauri's built-in state management.
+// PTY sessions and file watchers are tracked in their respective state structs.
diff --git a/src-tauri/tauri.conf.json b/clif-pad-ide/src-tauri/tauri.conf.json
similarity index 95%
rename from src-tauri/tauri.conf.json
rename to clif-pad-ide/src-tauri/tauri.conf.json
index 3d9440b..6ca4d38 100644
--- a/src-tauri/tauri.conf.json
+++ b/clif-pad-ide/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/nickt4/tauri-v2-schema/main/tauri.conf.json",
- "productName": "Clif",
+ "productName": "ClifPad",
"version": "1.3.0",
"identifier": "com.clif.app",
"build": {
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
- "title": "Clif",
+ "title": "ClifPad",
"width": 1400,
"height": 900,
"decorations": true,
diff --git a/src/App.tsx b/clif-pad-ide/src/App.tsx
similarity index 89%
rename from src/App.tsx
rename to clif-pad-ide/src/App.tsx
index ad2ee50..0e39dea 100644
--- a/src/App.tsx
+++ b/clif-pad-ide/src/App.tsx
@@ -1,4 +1,4 @@
-import { Component, onMount, Show, createSignal, lazy, Suspense } from "solid-js";
+import { Component, onMount, createEffect, Show, createSignal, lazy, Suspense } from "solid-js";
import TopBar from "./components/layout/TopBar";
import EditorArea from "./components/layout/EditorArea";
import StatusBar from "./components/layout/StatusBar";
@@ -24,6 +24,12 @@ const App: Component = () => {
}
}
+ function handleLaunchClifCode() {
+ if (terminalRef && projectRoot()) {
+ terminalRef.sendCommand("clifcode\n");
+ }
+ }
+
async function handleOpenFolder() {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
@@ -84,6 +90,17 @@ const App: Component = () => {
document.addEventListener("mouseup", onMouseUp);
}
+ createEffect(async () => {
+ const root = projectRoot();
+ const { getCurrentWindow } = await import("@tauri-apps/api/window");
+ if (root) {
+ const folder = root.split("/").pop() || root;
+ getCurrentWindow().setTitle(`${folder} — Clif`);
+ } else {
+ getCurrentWindow().setTitle("Clif");
+ }
+ });
+
onMount(async () => {
configureMonaco();
@@ -106,7 +123,7 @@ const App: Component = () => {
style={{ background: "var(--bg-base)", color: "var(--text-primary)" }}
>
{/* Top Bar */}
-
+
{/* Main content: Terminal (left) + Editor (center) + Sidebar (right) */}
@@ -126,7 +143,7 @@ const App: Component = () => {
}
>
- (terminalRef = r)} />
+ (terminalRef = r)} workingDir={projectRoot() || undefined} />
diff --git a/src/components/editor/DiffView.tsx b/clif-pad-ide/src/components/editor/DiffView.tsx
similarity index 100%
rename from src/components/editor/DiffView.tsx
rename to clif-pad-ide/src/components/editor/DiffView.tsx
diff --git a/clif-pad-ide/src/components/editor/GhostText.tsx b/clif-pad-ide/src/components/editor/GhostText.tsx
new file mode 100644
index 0000000..c13b853
--- /dev/null
+++ b/clif-pad-ide/src/components/editor/GhostText.tsx
@@ -0,0 +1,106 @@
+import * as monaco from "monaco-editor";
+import { settings } from "../../stores/settingsStore";
+import { aiComplete, getApiKey } from "../../lib/tauri";
+
+/**
+ * Register an inline completions provider that fetches FIM ghost text
+ * from the configured AI backend. Returns a disposable to tear down
+ * the provider when the editor unmounts.
+ */
+export function registerGhostTextProvider(
+ editor: monaco.editor.IStandaloneCodeEditor
+): monaco.IDisposable {
+ let debounceTimer: ReturnType | null = null;
+
+ const provider = monaco.languages.registerInlineCompletionsProvider("*", {
+ provideInlineCompletions: async (model, position, _ctx, token) => {
+ // Cancel any pending debounce
+ if (debounceTimer) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ }
+
+ // 500ms debounce — wait for the user to pause typing
+ const completion = await new Promise((resolve) => {
+ debounceTimer = setTimeout(async () => {
+ if (token.isCancellationRequested) {
+ resolve(null);
+ return;
+ }
+
+ try {
+ // Extract prefix (~1500 chars before cursor) and suffix (~500 chars after)
+ const fullText = model.getValue();
+ const offset = model.getOffsetAt(position);
+ const prefix = fullText.slice(Math.max(0, offset - 1500), offset);
+ const suffix = fullText.slice(offset, offset + 500);
+
+ // Format as FIM prompt
+ const context = `<|fim_prefix|>${prefix}<|fim_suffix|>${suffix}<|fim_middle|>`;
+
+ const apiKey = await getApiKey(settings().aiProvider);
+ const result = await aiComplete(
+ context,
+ settings().aiModel,
+ apiKey,
+ settings().aiProvider
+ );
+
+ if (token.isCancellationRequested) {
+ resolve(null);
+ return;
+ }
+
+ // Strip markdown fences if the model wrapped its output
+ let cleaned = result.trim();
+ if (cleaned.startsWith("```")) {
+ const firstNewline = cleaned.indexOf("\n");
+ const lastFence = cleaned.lastIndexOf("```");
+ if (firstNewline !== -1 && lastFence > firstNewline) {
+ cleaned = cleaned.slice(firstNewline + 1, lastFence).trim();
+ }
+ }
+
+ resolve(cleaned || null);
+ } catch {
+ resolve(null);
+ }
+ }, 500);
+ });
+
+ if (!completion || token.isCancellationRequested) {
+ return { items: [] };
+ }
+
+ const range = new monaco.Range(
+ position.lineNumber,
+ position.column,
+ position.lineNumber,
+ position.column
+ );
+
+ return {
+ items: [
+ {
+ insertText: completion,
+ range,
+ },
+ ],
+ };
+ },
+
+ freeInlineCompletions() {
+ // no-op — nothing to free
+ },
+ });
+
+ // Clean up debounce timer when provider is disposed
+ return {
+ dispose() {
+ if (debounceTimer) {
+ clearTimeout(debounceTimer);
+ }
+ provider.dispose();
+ },
+ };
+}
diff --git a/clif-pad-ide/src/components/editor/MarkdownPreview.tsx b/clif-pad-ide/src/components/editor/MarkdownPreview.tsx
new file mode 100644
index 0000000..043c499
--- /dev/null
+++ b/clif-pad-ide/src/components/editor/MarkdownPreview.tsx
@@ -0,0 +1,139 @@
+import { Component, createMemo } from "solid-js";
+import { marked } from "marked";
+import { activeFile } from "../../stores/fileStore";
+
+marked.setOptions({ async: false, breaks: true, gfm: true });
+
+const MarkdownPreview: Component = () => {
+ const html = createMemo(() => marked.parse(activeFile()?.content ?? "") as string);
+
+ return (
+
+ );
+};
+
+export default MarkdownPreview;
diff --git a/src/components/editor/MonacoEditor.tsx b/clif-pad-ide/src/components/editor/MonacoEditor.tsx
similarity index 93%
rename from src/components/editor/MonacoEditor.tsx
rename to clif-pad-ide/src/components/editor/MonacoEditor.tsx
index 84c350f..d65522f 100644
--- a/src/components/editor/MonacoEditor.tsx
+++ b/clif-pad-ide/src/components/editor/MonacoEditor.tsx
@@ -3,6 +3,7 @@ import * as monaco from "monaco-editor";
import { activeFile, updateFileContent, saveActiveFile } from "../../stores/fileStore";
import { theme, fontSize } from "../../stores/uiStore";
import { monacoThemes } from "../../lib/themes";
+import { registerGhostTextProvider } from "./GhostText";
import type { Theme } from "../../stores/uiStore";
// Model + viewstate cache
@@ -41,6 +42,7 @@ function getMonacoThemeName(t: Theme): string {
const MonacoEditor: Component = () => {
let containerRef!: HTMLDivElement;
let editorInstance: monaco.editor.IStandaloneCodeEditor | undefined;
+ let ghostTextDisposable: monaco.IDisposable | undefined;
let currentPath: string | null = null;
let onChangeDisposable: monaco.IDisposable | undefined;
@@ -70,6 +72,9 @@ const MonacoEditor: Component = () => {
theme: getMonacoThemeName(theme()),
});
+ // Register FIM ghost text completions
+ ghostTextDisposable = registerGhostTextProvider(editorInstance);
+
// Register Cmd+S / Ctrl+S to save
editorInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
saveActiveFile();
@@ -140,6 +145,10 @@ const MonacoEditor: Component = () => {
}
}
+ if (ghostTextDisposable) {
+ ghostTextDisposable.dispose();
+ }
+
if (onChangeDisposable) {
onChangeDisposable.dispose();
}
diff --git a/clif-pad-ide/src/components/editor/Tab.tsx b/clif-pad-ide/src/components/editor/Tab.tsx
new file mode 100644
index 0000000..965bd74
--- /dev/null
+++ b/clif-pad-ide/src/components/editor/Tab.tsx
@@ -0,0 +1,207 @@
+import { Component, Show, createSignal, onCleanup } from "solid-js";
+import { Portal } from "solid-js/web";
+import type { OpenFile } from "../../types/files";
+
+interface TabProps {
+ file: OpenFile;
+ isActive: boolean;
+ onSelect: () => void;
+ onClose: () => void;
+ onCloseOthers: () => void;
+ onCloseAll: () => void;
+ onCloseToRight: () => void;
+ onPreview?: () => void;
+}
+
+function getExtensionColor(name: string): string {
+ const ext = name.split(".").pop()?.toLowerCase() || "";
+ const colorMap: Record = {
+ ts: "#3178c6",
+ tsx: "#3178c6",
+ js: "#f7df1e",
+ jsx: "#61dafb",
+ rs: "#dea584",
+ py: "#3572a5",
+ go: "#00add8",
+ html: "#e34c26",
+ css: "#563d7c",
+ scss: "#c6538c",
+ json: "#a8b1c1",
+ md: "#519aba",
+ toml: "#9c4121",
+ yaml: "#cb171e",
+ yml: "#cb171e",
+ sh: "#89e051",
+ sql: "#e38c00",
+ lua: "#000080",
+ rb: "#cc342d",
+ java: "#b07219",
+ kt: "#a97bff",
+ swift: "#f05138",
+ c: "#555555",
+ cpp: "#f34b7d",
+ vue: "#41b883",
+ svelte: "#ff3e00",
+ };
+ return colorMap[ext] || "#8b949e";
+}
+
+const Tab: Component = (props) => {
+ const [showMenu, setShowMenu] = createSignal(false);
+ const [menuPos, setMenuPos] = createSignal({ x: 0, y: 0 });
+
+ const handleContextMenu = (e: MouseEvent) => {
+ e.preventDefault();
+ setMenuPos({ x: e.clientX, y: e.clientY });
+ setShowMenu(true);
+
+ const closeMenu = (ev: MouseEvent) => {
+ if (!(ev.target as HTMLElement).closest("[data-tab-menu]")) {
+ setShowMenu(false);
+ document.removeEventListener("mousedown", closeMenu);
+ }
+ };
+ document.addEventListener("mousedown", closeMenu);
+ onCleanup(() => document.removeEventListener("mousedown", closeMenu));
+ };
+
+ return (
+
+
props.onSelect()}
+ onContextMenu={handleContextMenu}
+ onMouseDown={(e) => {
+ if (e.button === 1) {
+ e.preventDefault();
+ props.onClose();
+ }
+ }}
+ >
+ {/* Eye icon for preview tabs, color dot for regular */}
+
+ }
+ >
+
+
+
+
+
+
+ {/* Dirty indicator */}
+
+
+
+
+ {/* File name */}
+
+ {props.file.name}
+
+
+ {/* Close button */}
+
{
+ e.stopPropagation();
+ props.onClose();
+ }}
+ >
+
+
+
+
+
+
+ {/* Context menu (portaled to body to avoid overflow clipping) */}
+
+
+
+
{ setShowMenu(false); props.onClose(); }}
+ >
+ Close
+
+
{ setShowMenu(false); props.onCloseOthers(); }}
+ >
+ Close Others
+
+
{ setShowMenu(false); props.onCloseAll(); }}
+ >
+ Close All
+
+
{ setShowMenu(false); props.onCloseToRight(); }}
+ >
+ Close to the Right
+
+
+
+ { setShowMenu(false); props.onPreview?.(); }}
+ >
+
+
+
+
+ Preview Markdown
+
+
+
+
+
+
+ );
+};
+
+export default Tab;
diff --git a/src/components/editor/TabBar.tsx b/clif-pad-ide/src/components/editor/TabBar.tsx
similarity index 70%
rename from src/components/editor/TabBar.tsx
rename to clif-pad-ide/src/components/editor/TabBar.tsx
index cc97eab..09aa381 100644
--- a/src/components/editor/TabBar.tsx
+++ b/clif-pad-ide/src/components/editor/TabBar.tsx
@@ -1,5 +1,5 @@
import { Component, For, Show } from "solid-js";
-import { openFiles, activeFilePath, setActiveFilePath, closeFile } from "../../stores/fileStore";
+import { openFiles, activeFilePath, setActiveFilePath, closeFile, closeOtherFiles, closeAllFiles, closeFilesToRight, openPreview } from "../../stores/fileStore";
import Tab from "./Tab";
const TabBar: Component = () => {
@@ -16,6 +16,10 @@ const TabBar: Component = () => {
isActive={activeFilePath() === file.path}
onSelect={() => setActiveFilePath(file.path)}
onClose={() => closeFile(file.path)}
+ onCloseOthers={() => closeOtherFiles(file.path)}
+ onCloseAll={() => closeAllFiles()}
+ onCloseToRight={() => closeFilesToRight(file.path)}
+ onPreview={!file.isPreview && file.name.endsWith(".md") ? () => openPreview(file.path) : undefined}
/>
)}
diff --git a/src/components/explorer/FileTree.tsx b/clif-pad-ide/src/components/explorer/FileTree.tsx
similarity index 100%
rename from src/components/explorer/FileTree.tsx
rename to clif-pad-ide/src/components/explorer/FileTree.tsx
diff --git a/src/components/explorer/FileTreeItem.tsx b/clif-pad-ide/src/components/explorer/FileTreeItem.tsx
similarity index 100%
rename from src/components/explorer/FileTreeItem.tsx
rename to clif-pad-ide/src/components/explorer/FileTreeItem.tsx
diff --git a/src/components/layout/DevPreviewPanel.tsx b/clif-pad-ide/src/components/layout/DevPreviewPanel.tsx
similarity index 96%
rename from src/components/layout/DevPreviewPanel.tsx
rename to clif-pad-ide/src/components/layout/DevPreviewPanel.tsx
index 246e00e..e11fbdb 100644
--- a/src/components/layout/DevPreviewPanel.tsx
+++ b/clif-pad-ide/src/components/layout/DevPreviewPanel.tsx
@@ -234,6 +234,25 @@ const DevPreviewPanel: Component = () => {
}
});
+ // Respawn session when project root changes
+ let prevRoot: string | null | undefined = undefined;
+ createEffect(() => {
+ const root = projectRoot();
+ if (prevRoot !== undefined && root && root !== prevRoot) {
+ const sid = sessionId();
+ if (sid && terminalMounted) {
+ ptyKill(sid).catch(() => {});
+ unlistenOutput?.();
+ unlistenExit?.();
+ dataDisposable?.dispose();
+ setSessionId(null);
+ terminal?.clear();
+ spawnSession();
+ }
+ }
+ prevRoot = root;
+ });
+
// Watch theme changes
createEffect(() => {
const t = theme();
diff --git a/src/components/layout/EditorArea.tsx b/clif-pad-ide/src/components/layout/EditorArea.tsx
similarity index 90%
rename from src/components/layout/EditorArea.tsx
rename to clif-pad-ide/src/components/layout/EditorArea.tsx
index b5f5989..99a517d 100644
--- a/src/components/layout/EditorArea.tsx
+++ b/clif-pad-ide/src/components/layout/EditorArea.tsx
@@ -3,6 +3,7 @@ import { activeFile, openFiles } from "../../stores/fileStore";
const TabBar = lazy(() => import("../editor/TabBar"));
const MonacoEditor = lazy(() => import("../editor/MonacoEditor"));
+const MarkdownPreview = lazy(() => import("../editor/MarkdownPreview"));
const EmptyState: Component = () => (
{
}
>
-
+ }>
+
+
diff --git a/src/components/layout/RightSidebar.tsx b/clif-pad-ide/src/components/layout/RightSidebar.tsx
similarity index 78%
rename from src/components/layout/RightSidebar.tsx
rename to clif-pad-ide/src/components/layout/RightSidebar.tsx
index b328ee7..8843503 100644
--- a/src/components/layout/RightSidebar.tsx
+++ b/clif-pad-ide/src/components/layout/RightSidebar.tsx
@@ -1,9 +1,9 @@
import { Component, Show, For, createSignal, createMemo, lazy, Suspense } from "solid-js";
-import { projectRoot, openFile } from "../../stores/fileStore";
+import { projectRoot, openFile, refreshFileTree } from "../../stores/fileStore";
import {
isGitRepo, currentBranch, changedFiles, diffStat,
stagedFiles, unstagedFiles, commitLog, fileNumstats,
- refreshGitStatus, stageFile, unstageFile, stageAll, unstageAll, commitChanges, initializeRepo,
+ refreshGitStatus, refreshBranches, stageFile, unstageFile, stageAll, unstageAll, commitChanges, initializeRepo,
} from "../../stores/gitStore";
import { devDrawerOpen, devDrawerHeight, setDevDrawerHeight } from "../../stores/uiStore";
import type { GitLogEntry } from "../../types/git";
@@ -162,8 +162,6 @@ const GitGraphRow: Component<{
isLast: boolean;
isMerge: boolean;
}> = (props) => {
- const [hovered, setHovered] = createSignal(false);
-
const refLabels = createMemo(() => {
return props.entry.refs.filter((r) => r !== "").map((r) => {
const isHead = r.includes("HEAD");
@@ -173,103 +171,130 @@ const GitGraphRow: Component<{
});
return (
- setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- >
- {/* Graph column */}
-
- {/* Line above dot */}
-
- {/* Commit dot */}
+
+
+ {/* Graph column */}
- {/* Line below dot */}
-
+ class="shrink-0 flex flex-col items-center"
+ style={{ width: "16px", "min-height": "28px" }}
+ >
+ {/* Line above dot */}
-
-
+ {/* Commit dot */}
+
+ {/* Line below dot */}
+
+
+
+
- {/* Commit info */}
-
- {/* Ref labels */}
-
0}>
-
-
- {(ref, i) => (
-
- {ref.label}
-
- )}
-
+ {/* Commit info */}
+
+ {/* Ref labels */}
+
0}>
+
+
+ {(ref, i) => (
+
+ {ref.label}
+
+ )}
+
+
+
+ {/* Message */}
+
+ {props.entry.message}
-
- {/* Message */}
+ {/* Hash + author + date */}
+
+
+ {props.entry.short_hash}
+
+ {props.entry.author}
+ {props.entry.date}
+
+
+
+
+ {/* Expanded detail — shown on hover via CSS */}
+
@@ -395,6 +420,28 @@ const RightSidebar: Component<{ onOpenFolder?: () => void }> = (props) => {
}}
>
+
+
+
{ (e.currentTarget as HTMLElement).style.color = "var(--text-primary)"; (e.currentTarget as HTMLElement).style.background = "var(--bg-hover)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--text-muted)"; (e.currentTarget as HTMLElement).style.background = "transparent"; }}
+ onClick={() => { refreshFileTree(); refreshGitStatus(); }}
+ title="Refresh file tree"
+ >
+
+
+
+
+
+
+
+
+
@@ -612,7 +659,7 @@ const RightSidebar: Component<{ onOpenFolder?: () => void }> = (props) => {
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
- onClick={() => refreshGitStatus()}
+ onClick={() => { refreshGitStatus(); refreshBranches(); }}
>
Refresh
diff --git a/src/components/layout/StatusBar.tsx b/clif-pad-ide/src/components/layout/StatusBar.tsx
similarity index 100%
rename from src/components/layout/StatusBar.tsx
rename to clif-pad-ide/src/components/layout/StatusBar.tsx
diff --git a/src/components/layout/TopBar.tsx b/clif-pad-ide/src/components/layout/TopBar.tsx
similarity index 87%
rename from src/components/layout/TopBar.tsx
rename to clif-pad-ide/src/components/layout/TopBar.tsx
index 7f1dcfe..7068412 100644
--- a/src/components/layout/TopBar.tsx
+++ b/clif-pad-ide/src/components/layout/TopBar.tsx
@@ -29,8 +29,16 @@ function getProjectName(): string {
return parts[parts.length - 1] || "";
}
+const ClifCodeIcon = () => (
+
+
+
+
+);
+
const TopBar: Component<{
onLaunchClaude: () => void;
+ onLaunchClifCode: () => void;
onOpenFolder: () => void;
}> = (props) => {
const hasProject = () => !!projectRoot();
@@ -263,6 +271,40 @@ const TopBar: Component<{
{/* Divider */}
+ {/* Launch ClifCode button */}
+ {
+ if (hasProject()) {
+ (e.currentTarget as HTMLElement).style.background = "var(--bg-active)";
+ (e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (hasProject()) {
+ (e.currentTarget as HTMLElement).style.background = "var(--bg-hover)";
+ (e.currentTarget as HTMLElement).style.transform = "translateY(0)";
+ }
+ }}
+ onClick={() => {
+ if (hasProject()) props.onLaunchClifCode();
+ }}
+ disabled={!hasProject()}
+ title={hasProject() ? "Launch ClifCode (offline AI) in terminal" : "Open a folder first"}
+ >
+
+ ClifCode
+
+
{/* Launch Claude button */}
void; workingD
}
});
+ // Respawn session when workingDir changes
+ let prevWorkingDir: string | undefined = undefined;
+ createEffect(() => {
+ const dir = props.workingDir;
+ if (prevWorkingDir !== undefined && dir && dir !== prevWorkingDir) {
+ const sid = sessionId();
+ if (sid && terminal) {
+ ptyKill(sid).catch(() => {});
+ unlistenOutput?.();
+ unlistenExit?.();
+ dataDisposable?.dispose();
+ setSessionId(null);
+ terminal.clear();
+ spawnSession(dir);
+ }
+ }
+ prevWorkingDir = dir;
+ });
+
onCleanup(async () => {
alive = false;
resizeObserver?.disconnect();
diff --git a/src/lib/keybindings.ts b/clif-pad-ide/src/lib/keybindings.ts
similarity index 100%
rename from src/lib/keybindings.ts
rename to clif-pad-ide/src/lib/keybindings.ts
diff --git a/src/lib/monaco-setup.ts b/clif-pad-ide/src/lib/monaco-setup.ts
similarity index 100%
rename from src/lib/monaco-setup.ts
rename to clif-pad-ide/src/lib/monaco-setup.ts
diff --git a/src/lib/tauri.ts b/clif-pad-ide/src/lib/tauri.ts
similarity index 100%
rename from src/lib/tauri.ts
rename to clif-pad-ide/src/lib/tauri.ts
diff --git a/src/lib/themes.ts b/clif-pad-ide/src/lib/themes.ts
similarity index 100%
rename from src/lib/themes.ts
rename to clif-pad-ide/src/lib/themes.ts
diff --git a/src/lib/utils.ts b/clif-pad-ide/src/lib/utils.ts
similarity index 100%
rename from src/lib/utils.ts
rename to clif-pad-ide/src/lib/utils.ts
diff --git a/src/main.tsx b/clif-pad-ide/src/main.tsx
similarity index 100%
rename from src/main.tsx
rename to clif-pad-ide/src/main.tsx
diff --git a/src/stores/fileStore.ts b/clif-pad-ide/src/stores/fileStore.ts
similarity index 74%
rename from src/stores/fileStore.ts
rename to clif-pad-ide/src/stores/fileStore.ts
index ba4ba05..46d94ce 100644
--- a/src/stores/fileStore.ts
+++ b/clif-pad-ide/src/stores/fileStore.ts
@@ -98,10 +98,11 @@ async function openProject(path: string) {
const entries = await loadDirectory(r);
setFileTree(entries);
}
- // Refresh git status too
+ // Refresh git status and branches
try {
- const { refreshGitStatus } = await import("./gitStore");
+ const { refreshGitStatus, refreshBranches } = await import("./gitStore");
refreshGitStatus();
+ refreshBranches();
} catch {}
}, 500);
});
@@ -168,6 +169,41 @@ function closeFile(path: string) {
}
}
+function closeOtherFiles(path: string) {
+ setOpenFiles(
+ produce((files) => {
+ for (let i = files.length - 1; i >= 0; i--) {
+ if (files[i].path !== path) files.splice(i, 1);
+ }
+ })
+ );
+ setActiveFilePath(path);
+}
+
+function closeAllFiles() {
+ setOpenFiles(
+ produce((files) => {
+ files.splice(0, files.length);
+ })
+ );
+ setActiveFilePath(null);
+}
+
+function closeFilesToRight(path: string) {
+ const idx = openFiles.findIndex((f) => f.path === path);
+ if (idx === -1) return;
+ setOpenFiles(
+ produce((files) => {
+ files.splice(idx + 1);
+ })
+ );
+ // If the active file was to the right, switch to the given file
+ const activePath = activeFilePath();
+ if (activePath && !openFiles.find((f) => f.path === activePath)) {
+ setActiveFilePath(path);
+ }
+}
+
function updateFileContent(path: string, content: string) {
const idx = openFiles.findIndex((f) => f.path === path);
if (idx === -1) return;
@@ -193,6 +229,40 @@ async function saveActiveFile() {
if (path) await saveFile(path);
}
+async function openPreview(sourcePath: string) {
+ const previewPath = sourcePath + "::preview";
+
+ // If preview already open, switch to it
+ const existing = openFiles.find((f) => f.path === previewPath);
+ if (existing) {
+ setActiveFilePath(previewPath);
+ return;
+ }
+
+ // Get content from already-open source file or read from disk
+ let content: string;
+ const sourceFile = openFiles.find((f) => f.path === sourcePath);
+ if (sourceFile) {
+ content = sourceFile.content;
+ } else {
+ try {
+ content = await readFile(sourcePath);
+ } catch (e) {
+ console.error("Failed to read file for preview:", e);
+ return;
+ }
+ }
+
+ const name = "Preview: " + getFileName(sourcePath);
+
+ setOpenFiles(
+ produce((files) => {
+ files.push({ path: previewPath, name, content, language: "markdown", isDirty: false, isPreview: true });
+ })
+ );
+ setActiveFilePath(previewPath);
+}
+
export {
projectRoot,
setProjectRoot,
@@ -213,4 +283,8 @@ export {
updateFileContent,
saveFile,
saveActiveFile,
+ openPreview,
+ closeOtherFiles,
+ closeAllFiles,
+ closeFilesToRight,
};
diff --git a/src/stores/gitStore.ts b/clif-pad-ide/src/stores/gitStore.ts
similarity index 94%
rename from src/stores/gitStore.ts
rename to clif-pad-ide/src/stores/gitStore.ts
index fb2f1dc..7b770c9 100644
--- a/src/stores/gitStore.ts
+++ b/clif-pad-ide/src/stores/gitStore.ts
@@ -124,9 +124,15 @@ async function initializeRepo() {
await refreshBranches();
}
+let branchPollTimer: ReturnType | undefined;
+
async function initGit() {
await refreshGitStatus();
await refreshBranches();
+
+ // Poll for branch changes from external tools (e.g. CLI git checkout)
+ if (branchPollTimer) clearInterval(branchPollTimer);
+ branchPollTimer = setInterval(refreshBranches, 3000);
}
export {
diff --git a/src/stores/settingsStore.ts b/clif-pad-ide/src/stores/settingsStore.ts
similarity index 100%
rename from src/stores/settingsStore.ts
rename to clif-pad-ide/src/stores/settingsStore.ts
diff --git a/src/stores/uiStore.ts b/clif-pad-ide/src/stores/uiStore.ts
similarity index 100%
rename from src/stores/uiStore.ts
rename to clif-pad-ide/src/stores/uiStore.ts
diff --git a/src/styles/global.css b/clif-pad-ide/src/styles/global.css
similarity index 96%
rename from src/styles/global.css
rename to clif-pad-ide/src/styles/global.css
index ad9a515..752b11c 100644
--- a/src/styles/global.css
+++ b/clif-pad-ide/src/styles/global.css
@@ -246,6 +246,15 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
+ /* ─── Git graph row hover ─── */
+ .git-graph-row:hover {
+ background: var(--bg-hover);
+ }
+
+ .git-graph-row:hover .git-graph-tooltip {
+ max-height: 80px !important;
+ }
+
/* ─── Monaco editor overrides ─── */
.monaco-editor .overflow-guard {
border-radius: 0 !important;
diff --git a/src/types/ai.ts b/clif-pad-ide/src/types/ai.ts
similarity index 100%
rename from src/types/ai.ts
rename to clif-pad-ide/src/types/ai.ts
diff --git a/src/types/files.ts b/clif-pad-ide/src/types/files.ts
similarity index 91%
rename from src/types/files.ts
rename to clif-pad-ide/src/types/files.ts
index 5ce0296..d6afe7e 100644
--- a/src/types/files.ts
+++ b/clif-pad-ide/src/types/files.ts
@@ -12,4 +12,5 @@ export interface OpenFile {
content: string;
language: string;
isDirty: boolean;
+ isPreview?: boolean;
}
diff --git a/src/types/git.ts b/clif-pad-ide/src/types/git.ts
similarity index 100%
rename from src/types/git.ts
rename to clif-pad-ide/src/types/git.ts
diff --git a/tsconfig.json b/clif-pad-ide/tsconfig.json
similarity index 100%
rename from tsconfig.json
rename to clif-pad-ide/tsconfig.json
diff --git a/tsconfig.node.json b/clif-pad-ide/tsconfig.node.json
similarity index 100%
rename from tsconfig.node.json
rename to clif-pad-ide/tsconfig.node.json
diff --git a/vercel.json b/clif-pad-ide/vercel.json
similarity index 100%
rename from vercel.json
rename to clif-pad-ide/vercel.json
diff --git a/vite.config.ts b/clif-pad-ide/vite.config.ts
similarity index 100%
rename from vite.config.ts
rename to clif-pad-ide/vite.config.ts
diff --git a/www/index.html b/clif-pad-ide/www/index.html
similarity index 79%
rename from www/index.html
rename to clif-pad-ide/www/index.html
index 70c6178..449deb9 100644
--- a/www/index.html
+++ b/clif-pad-ide/www/index.html
@@ -3,10 +3,10 @@
- Clif — AI-native code editor
-
-
-
+ Clif — AI-native code editor & terminal agent
+
+
+
@@ -169,6 +169,32 @@
padding: 2px 8px; border-radius: 4px; margin-bottom: 12px;
}
+ /* CLIFCODE SECTION */
+ .clifcode-section {
+ padding: 80px 0; text-align: center;
+ border-top: 1px solid rgba(255,255,255,0.06);
+ }
+ .clifcode-section h2 {
+ font-size: 40px; font-weight: 800; letter-spacing: -1px; margin-bottom: 12px;
+ }
+ .clifcode-section .sub { color: var(--muted); font-size: 16px; margin-bottom: 32px; }
+ .clifcode-install {
+ display: inline-block; text-align: left; background: var(--bg2);
+ border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
+ padding: 20px 28px; font-family: 'JetBrains Mono', monospace; font-size: 15px;
+ line-height: 1.8; margin-bottom: 32px;
+ }
+ .clifcode-features {
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px;
+ background: rgba(255,255,255,0.04); border-radius: 16px; overflow: hidden;
+ margin-top: 40px;
+ }
+ .clifcode-feat {
+ padding: 28px 20px; background: var(--bg2); text-align: center;
+ }
+ .clifcode-feat h4 { font-size: 14px; font-weight: 700; margin-bottom: 6px; }
+ .clifcode-feat p { font-size: 13px; color: var(--muted); line-height: 1.5; }
+
/* SOURCE */
.source {
padding: 60px 0 80px; text-align: center;
@@ -205,6 +231,7 @@
.feat:first-child { border-radius: 16px 16px 2px 2px !important; }
.feat:last-child { border-radius: 2px 2px 16px 16px !important; }
.downloads { flex-direction: column; align-items: center; }
+ .clifcode-features { grid-template-columns: 1fr; }
}
@@ -217,8 +244,9 @@
@@ -350,15 +378,42 @@ Privacy first
+
+
+
+
ClifCode
+
AI coding agent for your terminal. Tool-calling loop, streaming markdown, session persistence. Works with any API.
+
+
+ $ npm i -g clifcode
+
+
+
+
+
Agent loop
+
Read/write files, run commands, search code, git ops — all via tool calls
+
+
+
Any provider
+
OpenRouter, OpenAI, Anthropic, Ollama, or any OpenAI-compatible API
+
+
+
Sessions
+
Auto-saves context, resume previous sessions, per-turn cost tracking
+
+
+
+
+
macOS — "App can't be opened"?
Clif is open source but not yet notarized with Apple. macOS blocks unsigned apps by default. This is normal for open source software. One command fixes it:
- $ xattr -cr /Applications/Clif.app
+ $ xattr -cr /Applications/ClifPad.app
-
This removes the quarantine flag macOS sets on downloads. Then open Clif normally.
+
This removes the quarantine flag macOS sets on downloads. Then open ClifPad normally.
@@ -369,7 +424,7 @@
FAQ
Is Clif safe?
-
100% open source. Read every line on GitHub . No telemetry, no network calls unless you enable AI. The xattr command just removes Apple's download flag — it doesn't disable any security.
+
100% open source. Read every line on GitHub . No telemetry, no network calls unless you enable AI. The xattr command just removes Apple's download flag — it doesn't disable any security.
Why isn't it signed?
@@ -387,11 +442,16 @@
Does it work offline?
Or build it yourself.
-
Three commands. Under a minute.
+
Clone the monorepo and pick your product.
- $ git clone https://github.com/DLhugly/Clif.git && cd Clif
- $ npm install
- $ npm run tauri dev
+ # Clone
+ $ git clone https://github.com/DLhugly/Clif-Code.git && cd Clif-Code
+
+ # ClifPad (desktop IDE)
+ $ cd clif-pad-ide && npm install && npm run tauri dev
+
+ # ClifCode (terminal agent)
+ $ cd clif-code-tui && cargo run --release
@@ -400,7 +460,7 @@
Or build it yourself.
diff --git a/logo.svg b/logo.svg
new file mode 100644
index 0000000..5e98102
--- /dev/null
+++ b/logo.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ _____ _ _ __ _____ _
+ / ____| (_)/ _/ ____| | |
+ | | | |_| || | ___ __| | ___
+ | | | | | _| | / _ \ / _` |/ _ \
+ | |____| | | | | |___| (_) | (_| | __/
+ \_____|_|_|_| \_____\___/ \__,_|\___|
+
+ AI-native code editor & terminal agent
+
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
deleted file mode 100644
index 9d6dafe..0000000
--- a/src-tauri/src/lib.rs
+++ /dev/null
@@ -1,60 +0,0 @@
-mod commands;
-mod services;
-mod state;
-
-use commands::pty::PtyState;
-use services::file_watcher::WatcherState;
-use state::AppState;
-use std::sync::Mutex;
-
-pub fn run() {
- let app_state = AppState {
- project_root: Mutex::new(None),
- open_files: Mutex::new(std::collections::HashMap::new()),
- };
-
- tauri::Builder::default()
- .plugin(tauri_plugin_shell::init())
- .plugin(tauri_plugin_dialog::init())
- .manage(app_state)
- .manage(PtyState::new())
- .manage(WatcherState::new())
- .invoke_handler(tauri::generate_handler![
- commands::fs::read_dir,
- commands::fs::read_file,
- commands::fs::write_file,
- commands::fs::create_file,
- commands::fs::create_dir,
- commands::fs::rename_entry,
- commands::fs::delete_entry,
- commands::fs::watch_dir,
- commands::ai::ai_chat,
- commands::ai::ai_complete,
- commands::ai::get_models,
- commands::ai::set_api_key,
- commands::ai::get_api_key,
- commands::git::git_status,
- commands::git::git_diff,
- commands::git::git_commit,
- commands::git::git_branches,
- commands::git::git_checkout,
- commands::git::git_stage,
- commands::git::git_unstage,
- commands::git::git_diff_stat,
- commands::git::git_diff_numstat,
- commands::git::git_init,
- commands::git::git_log,
- commands::search::search_files,
- commands::claude_code::claude_code_start,
- commands::claude_code::claude_code_send,
- commands::claude_code::claude_code_stop,
- commands::settings::get_settings,
- commands::settings::set_settings,
- commands::pty::pty_spawn,
- commands::pty::pty_write,
- commands::pty::pty_resize,
- commands::pty::pty_kill,
- ])
- .run(tauri::generate_context!())
- .expect("error while running tauri application");
-}
diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
deleted file mode 100644
index 24a1166..0000000
--- a/src-tauri/src/state.rs
+++ /dev/null
@@ -1,8 +0,0 @@
-use std::collections::HashMap;
-use std::path::PathBuf;
-use std::sync::Mutex;
-
-pub struct AppState {
- pub project_root: Mutex
>,
- pub open_files: Mutex>,
-}
diff --git a/src/components/editor/GhostText.tsx b/src/components/editor/GhostText.tsx
deleted file mode 100644
index 1a125cc..0000000
--- a/src/components/editor/GhostText.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Component } from "solid-js";
-import * as monaco from "monaco-editor";
-
-export function registerGhostTextProvider(editor: monaco.editor.IStandaloneCodeEditor) {
- // Will be implemented in Phase 2
- // This will register an InlineCompletionsProvider with Monaco
- // that provides AI-powered ghost text completions as the user types.
- //
- // The provider will:
- // 1. Detect when the user pauses typing
- // 2. Send context to the AI backend (OpenRouter/Ollama)
- // 3. Return inline completion items for Monaco to render as ghost text
- // 4. Handle accept (Tab), dismiss (Escape), and partial accept
- void editor;
-}
-
-const GhostText: Component = () => {
- return null;
-};
-
-export default GhostText;
diff --git a/src/components/editor/Tab.tsx b/src/components/editor/Tab.tsx
deleted file mode 100644
index 3e5f934..0000000
--- a/src/components/editor/Tab.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { Component, Show } from "solid-js";
-import type { OpenFile } from "../../types/files";
-
-interface TabProps {
- file: OpenFile;
- isActive: boolean;
- onSelect: () => void;
- onClose: () => void;
-}
-
-function getExtensionColor(name: string): string {
- const ext = name.split(".").pop()?.toLowerCase() || "";
- const colorMap: Record = {
- ts: "#3178c6",
- tsx: "#3178c6",
- js: "#f7df1e",
- jsx: "#61dafb",
- rs: "#dea584",
- py: "#3572a5",
- go: "#00add8",
- html: "#e34c26",
- css: "#563d7c",
- scss: "#c6538c",
- json: "#a8b1c1",
- md: "#519aba",
- toml: "#9c4121",
- yaml: "#cb171e",
- yml: "#cb171e",
- sh: "#89e051",
- sql: "#e38c00",
- lua: "#000080",
- rb: "#cc342d",
- java: "#b07219",
- kt: "#a97bff",
- swift: "#f05138",
- c: "#555555",
- cpp: "#f34b7d",
- vue: "#41b883",
- svelte: "#ff3e00",
- };
- return colorMap[ext] || "#8b949e";
-}
-
-const Tab: Component = (props) => {
- return (
- props.onSelect()}
- onMouseDown={(e) => {
- // Middle click to close
- if (e.button === 1) {
- e.preventDefault();
- props.onClose();
- }
- }}
- >
- {/* File extension color dot */}
-
-
- {/* Dirty indicator */}
-
-
-
-
- {/* File name */}
-
- {props.file.name}
-
-
- {/* Close button */}
-
{
- e.stopPropagation();
- props.onClose();
- }}
- >
-
-
-
-
-
- );
-};
-
-export default Tab;
diff --git a/unnamed.jpg b/unnamed.jpg
new file mode 100644
index 0000000..6e8849b
Binary files /dev/null and b/unnamed.jpg differ