make it fancy
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What This Is
|
||||
|
||||
agent0 is an agentic coding TUI built with Babashka (Clojure). It runs local LLMs via Ollama with tool-use capabilities (file editing, shell commands, code search, web research) in a terminal interface.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
agent # Interactive
|
||||
agent "prompt" # With initial prompt
|
||||
agent --continue # Resume latest session
|
||||
agent --session <uuid> # Resume specific session
|
||||
```
|
||||
|
||||
**Requirements:** Babashka, Ollama running locally. Model defaults to `qwen3-coder-next` (override with `$AGENT_MODEL`). Ollama host defaults to `http://localhost:11434` (override with `$OLLAMA_HOST`).
|
||||
|
||||
## Architecture
|
||||
|
||||
**Runtime:** Babashka scripts (`bb.edn`), depends on local `../clojure-tui` for the TUI framework.
|
||||
|
||||
**Source files (`src/agent/`):**
|
||||
|
||||
- **app.clj** — TUI layer. Elm-architecture: `view` renders state, `update-fn` handles events. The agent loop runs async in a background thread; UI polls an event queue every 100ms. Manages session persistence, skill expansion, scrolling, and input.
|
||||
- **core.clj** — Agent brain. `run-agent-loop!` iterates: call LLM → execute tool calls → append results → repeat (max 50 iterations). Tools return strings or `{:message str :diff str}` maps. Includes stuck-loop detection (exact repeats and research loops). `build-system-prompt` composes the base prompt with project context.
|
||||
- **context.clj** — Loads project context from `.agent0/context.md`, `CLAUDE.md`, `.cursorrules`, or `.github/copilot-instructions.md`. Parses skill definitions from `~/.config/agent0/skills.md` (global) and `.agent0/skills.md` (project). Skills are `/name <arg>` templates with `{param}` substitution.
|
||||
- **markdown.clj** — Converts markdown to ANSI-styled terminal text. Handles inline formatting, code blocks, headers, lists, links, and ANSI-aware word wrapping.
|
||||
- **web_search.clj** — Subagent config for web research. Uses DuckDuckGo HTML search + URL fetching. Delegated to via the `delegate` tool.
|
||||
|
||||
**Data flow:** User input → skill expansion (if `/command`) → conversation history → `run-agent-loop!` (background future) → LLM call → tool execution → events pushed to atom queue → UI polls and renders.
|
||||
|
||||
**Event types:** `:text` (assistant response), `:tool` (tool label), `:diff` (file change preview), `:error`, `:done`.
|
||||
|
||||
**Persistence:** Sessions in `~/.local/share/agent0/sessions/`, logs in `~/.local/share/agent0/logs/`.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Tools that modify files (`edit-file`, `create-file`) return `{:message ... :diff ...}` maps. `execute-tool` extracts the diff and passes it as a separate UI event. The LLM only sees the `:message` string.
|
||||
- The TUI uses clojure-tui's hiccup-like syntax: `[:row {:widths [22 :flex]} left right]`, `[:col {:heights [:flex 3]} content input]`, `[:box {:border :rounded}]`, `[:text {:fg :red :bold true} "text"]`.
|
||||
- `run-agent-loop!` returns `{:future f :cancel! atom}`. Setting `cancel!` to true + `future-cancel` interrupts the loop.
|
||||
- Subagents (`web_search`) are synchronous mini-loops with their own tools and system prompt, invoked via the `delegate` tool.
|
||||
|
||||
## No Tests
|
||||
|
||||
There is no test suite. Verify changes by loading namespaces in the nREPL (`bb nrepl-server`) and testing functions interactively.
|
||||
@@ -0,0 +1,152 @@
|
||||
# agent0
|
||||
|
||||
A local AI coding assistant that runs in your terminal. You type questions or instructions, and the AI reads your code, edits files, runs commands, and searches the web — all from a simple text interface.
|
||||
|
||||
No cloud accounts or API keys required for local use. Your code stays on your machine.
|
||||
|
||||
## What it does
|
||||
|
||||
agent0 gives you an AI assistant that understands your codebase. You can ask it to:
|
||||
|
||||
- **Read and edit files** — it can open any file in your project, make targeted edits, or create new files
|
||||
- **Run shell commands** — compile code, run scripts, install packages, anything you'd do in a terminal
|
||||
- **Search your code** — find files by name patterns or search inside files for specific text
|
||||
- **Look up Clojure docs** — instant documentation for Clojure standard library functions
|
||||
- **Research the web** — search the internet and read web pages to answer questions
|
||||
- **Run skills** — execute predefined workflows you set up for common tasks
|
||||
|
||||
Everything happens in a terminal UI with a chat-style interface. You type, the AI responds and uses tools as needed, and you see what it's doing in real time.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Self-hosted (local, no API keys)
|
||||
|
||||
This is the default setup. The AI model runs entirely on your machine.
|
||||
|
||||
- **[Babashka](https://github.com/babashka/babashka)** — a fast Clojure scripting runtime (used to run agent0)
|
||||
- **[Ollama](https://ollama.com)** — runs AI models locally on your computer
|
||||
- **A downloaded model** — agent0 defaults to `qwen3-coder-next`, a coding-focused model
|
||||
|
||||
Hardware considerations: local models need a decent GPU or a machine with enough RAM. Smaller models work on modest hardware; larger models need more resources. Check [Ollama's model library](https://ollama.com/library) for size and requirements.
|
||||
|
||||
### Remote LLM (Ajet Cloud, OpenAI, Anthropic)
|
||||
|
||||
If you'd rather use a cloud-hosted model instead of running one locally, agent0 can connect to any remote server that speaks the Ollama API:
|
||||
|
||||
- **[Babashka](https://github.com/babashka/babashka)** — still required to run agent0 itself
|
||||
- **A remote Ollama-compatible endpoint** — set `OLLAMA_HOST` to the server URL
|
||||
|
||||
Some providers that work:
|
||||
- [Ajet Cloud](https://ajet.fyi) — managed Ollama-compatible hosting
|
||||
- Any remote machine running Ollama
|
||||
- OpenAI or Anthropic via an Ollama-compatible proxy (e.g. [LiteLLM](https://github.com/BerriAI/litellm))
|
||||
|
||||
With a remote provider, you don't need Ollama installed locally or a powerful machine — the model runs on the remote server.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install Babashka
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install borkdude/brew/babashka
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)
|
||||
```
|
||||
|
||||
See the [Babashka install docs](https://github.com/babashka/babashka#installation) for other methods.
|
||||
|
||||
### 2. Set up a model
|
||||
|
||||
**For local use** — install Ollama and pull a model:
|
||||
```bash
|
||||
# Install Ollama from https://ollama.com, then:
|
||||
ollama pull qwen3-coder-next
|
||||
```
|
||||
|
||||
**For remote use** — point to your provider:
|
||||
```bash
|
||||
export OLLAMA_HOST="https://your-provider-url"
|
||||
```
|
||||
|
||||
### 3. Clone the repository
|
||||
|
||||
```bash
|
||||
cd ~/repos # or wherever you keep projects
|
||||
git clone https://git.ajet.fyi/ajet/agent0.git
|
||||
```
|
||||
|
||||
Dependencies (including the TUI framework) are fetched automatically via git deps.
|
||||
|
||||
### 4. Add to your PATH
|
||||
|
||||
Make the `agent` command available from anywhere:
|
||||
|
||||
```bash
|
||||
# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.)
|
||||
export PATH="$HOME/repos/agent0:$PATH"
|
||||
```
|
||||
|
||||
Then restart your terminal or run `source ~/.bashrc`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
agent # Start a new conversation
|
||||
agent "explain this codebase" # Start with a question or instruction
|
||||
agent --continue # Resume your most recent session
|
||||
agent --session <id> # Resume a specific session by ID
|
||||
```
|
||||
|
||||
Once running, type your message and press Enter. The AI will respond, using tools as needed. You'll see what it's doing — reading files, running commands, searching — in real time.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | What it does | Default |
|
||||
|---|---|---|
|
||||
| `AGENT_MODEL` | Which AI model to use | `qwen3-coder-next` |
|
||||
| `OLLAMA_HOST` | Where the model is running | `http://localhost:11434` |
|
||||
|
||||
### Project context
|
||||
|
||||
agent0 automatically reads instructions from your project to give the AI relevant context. It checks for these files (in order):
|
||||
|
||||
1. `.agent0/context.md` — agent0-specific project instructions
|
||||
2. `CLAUDE.md` — also used by Claude Code
|
||||
3. `.cursorrules` — also used by Cursor
|
||||
4. `.github/copilot-instructions.md` — also used by GitHub Copilot
|
||||
|
||||
You can also put global instructions (applied to all projects) in `~/.config/agent0/context.md`.
|
||||
|
||||
### Skills
|
||||
|
||||
Skills are reusable prompt templates you trigger with `/name`. Define them in:
|
||||
|
||||
- `~/.config/agent0/skills.md` — available in all projects
|
||||
- `.agent0/skills.md` — project-specific skills
|
||||
|
||||
Example skill definition:
|
||||
```markdown
|
||||
# /deploy <env>
|
||||
|
||||
Deploy the application to the {env} environment. Run the deploy script,
|
||||
verify it completes successfully, and report the result.
|
||||
```
|
||||
|
||||
Then use it: type `/deploy staging` and it expands into the full prompt.
|
||||
|
||||
## Sessions
|
||||
|
||||
Every conversation is automatically saved. You can pick up where you left off:
|
||||
|
||||
```bash
|
||||
agent --continue # Resume the latest session
|
||||
agent --session <id> # Resume a specific session
|
||||
```
|
||||
|
||||
Sessions are stored in `~/.local/share/agent0/sessions/`. Logs are in `~/.local/share/agent0/logs/`.
|
||||
@@ -1,2 +1,3 @@
|
||||
{:paths ["src"]
|
||||
:deps {tui/tui {:local/root "../clojure-tui"}}}
|
||||
:deps {tui/tui {:git/url "https://git.ajet.fyi/ajet/clojure-tui.git"
|
||||
:git/sha "4a051304888d5b4d937262decba919e1a79dd03d"}}}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# agent0 Architecture Outline
|
||||
|
||||
## Core Overview
|
||||
agent0 is an **agentic TUI** built with Babashka (Clojure) that interacts with local LLMs via Ollama. It features an Elm-architecture UI with background agent processing.
|
||||
|
||||
---
|
||||
|
||||
## File Structure & Responsibilities
|
||||
|
||||
| File | Responsibility |
|
||||
|------|------|
|
||||
| `app.clj` | TUI layer - Elm architecture, event handling, rendering |
|
||||
| `core.clj` | Agent brain - LLM calls, tool execution, async loop |
|
||||
| `context.clj` | Project context loading, skill parsing/expansion |
|
||||
| `markdown.clj` | Markdown → ANSI-styled terminal output |
|
||||
| `web_search.clj` | Web research subagent configuration |
|
||||
| `clojuredocs.clj` | ClojureDocs integration (not shown, imported) |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User Input → Skill Expansion → Agent Loop → LLM → Tools → Events → UI
|
||||
```
|
||||
|
||||
1. **Input** → Optional skill expansion (`/name args`)
|
||||
2. **Conversation** → Added to history
|
||||
3. **Agent Loop** (async future) → Calls LLM, executes tools, appends results
|
||||
4. **Events** → Pushed to event queue every 100ms
|
||||
5. **UI** → Polls queue and re-renders
|
||||
|
||||
---
|
||||
|
||||
## Key Functions
|
||||
|
||||
### app.clj (TUI Layer)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `view` | Elm view - renders state as hiccup UI |
|
||||
| `update-fn` | Elm update - handles events, user input, agent polling |
|
||||
| `process-agent-event` | Pushes LLM responses to messages list |
|
||||
| `format-messages` | Converts messages to display lines with ANSI styling |
|
||||
| `wrap-line` / `wrap-text` | Word-wrap with ANSI awareness |
|
||||
| `-main` | Entry point - parses args, loads context/skills, starts TUI |
|
||||
|
||||
### core.clj (Agent Brain)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `run-agent-loop!` | Main async loop - iterates: call LLM → execute tools → repeat (max 50) |
|
||||
| `call-llm*` | HTTP POST to Ollama `/api/chat` |
|
||||
| `call-llm` | Wrapper with tool definitions |
|
||||
| `execute-tool` | Runs tool function, extracts diff for UI |
|
||||
| `tool-call-label` | Human-readable label for tool calls (displayed as "⚙") |
|
||||
| `detect-stuck-loop` | Detects infinite loops (exact repeats + research loops) |
|
||||
| `run-subagent!` | Subagent execution (e.g., web_search) |
|
||||
| `delegate` | Tool to invoke subagents |
|
||||
| `skills-tool` | Tool for listing/running skills |
|
||||
| Session persistence: `save-session!`, `load-session`, `new-session-id` | |
|
||||
| Tools: `read-file`, `list-files`, `edit-file`, `create-file`, `run-shell-command`, `glob-files`, `grep-files` | File & shell operations |
|
||||
|
||||
### context.clj (Context & Skills)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `load-project-context` | Loads `.agent0/context.md`, `CLAUDE.md`, etc. |
|
||||
| `load-skills` | Loads skills from `~/.claude/skills/`, `~/.config/agent0/skills.md`, `.agent0/skills.md` |
|
||||
| `parse-skills` | Parses `/name <arg>` templates with `{param}` substitution |
|
||||
| `expand-skill` | Expands skill invocations like `/test --watch` |
|
||||
| `format-skill-list` | Formats skill list for display |
|
||||
|
||||
### markdown.clj (Markdown Rendering)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `render-markdown` | Converts markdown to ANSI-styled lines |
|
||||
| `apply-inline` | Bold, italic, strikethrough, links |
|
||||
| `protect-code-spans` / `restore-code-spans` | Preserves inline code |
|
||||
| `wrap-words` | ANSI-aware word wrapping |
|
||||
|
||||
---
|
||||
|
||||
## Event Types
|
||||
|
||||
```clojure
|
||||
{:type :text :content "..."} ; Assistant response
|
||||
{:type :tool :label "..."} ; Tool being called (displayed as "⚙")
|
||||
{:type :diff :content "..."} ; File edit preview (diff format)
|
||||
{:type :error :message "..."} ; Error message
|
||||
{:type :done :conversation [...]} ; Loop finished
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Pattern
|
||||
|
||||
Tools that modify files return `{:message ".." :diff ".."}`:
|
||||
- `:message` → sent to LLM as tool result
|
||||
- `:diff` → pushed as separate `:diff` event to UI
|
||||
|
||||
---
|
||||
|
||||
## Agent Loop Algorithm
|
||||
|
||||
```
|
||||
1. Call LLM with conversation + tool definitions
|
||||
2. If tool_calls in response:
|
||||
a. Push tool labels to UI
|
||||
b. Execute each tool function
|
||||
c. Push diffs to UI (if any)
|
||||
d. Append tool results to conversation
|
||||
e. Repeat (stuck-loop detection at ~3 repeats)
|
||||
3. If no tool_calls:
|
||||
a. Push final assistant text
|
||||
b. Signal :done
|
||||
```
|
||||
|
||||
**Stuck-loop detection:**
|
||||
- Exact repeat: same tool calls 3x in a row → hard stop
|
||||
- Research loop: `web_search` called 3+ times with varying args → inject system nudge, stop at 6
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
**Model state (UI):**
|
||||
```clojure
|
||||
{:messages [...] ; Display messages
|
||||
:input "" ; User input field
|
||||
:conversation [...] ; LLM conversation history
|
||||
:event-queue (atom []) ; Background-to-UI events
|
||||
:session-id ... ; For persistence
|
||||
:agent-running? bool ; Spinner state
|
||||
:agent-handle ... ; Future + cancel atom
|
||||
:skills {...} ; Available skills}
|
||||
```
|
||||
|
||||
**Sessions saved to:** `~/.local/share/agent0/sessions/{uuid}.edn`
|
||||
**Logs saved to:** `~/.local/share/agent0/logs/agent-{timestamp}.log`
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Async agent loop** returns `{:future f :cancel! atom}` for interruption
|
||||
2. **TUI uses clojure-tui** hiccup-like syntax with ANSI escape codes
|
||||
3. **Polling UI** checks event queue every 100ms (drains and processes)
|
||||
4. **Dynamic binding** `*log-file*` passes log context to subagents
|
||||
5. **Skills** use `{param}` substitution from `/name <arg>` format
|
||||
+123
-69
@@ -1,6 +1,8 @@
|
||||
(ns agent.app
|
||||
(:require [agent.core :as core]
|
||||
(:require [agent.context :as context]
|
||||
[agent.core :as core]
|
||||
[agent.markdown :as md]
|
||||
[agent.syntax :as syntax]
|
||||
[tui.core :as tui]
|
||||
[tui.events :as ev]
|
||||
[tui.terminal :as term]
|
||||
@@ -57,28 +59,15 @@
|
||||
|
||||
(def ^:private spinner-frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"])
|
||||
|
||||
(defn- strip-ansi
|
||||
"Strip ANSI escape codes to get visible length."
|
||||
[s]
|
||||
(str/replace s #"\033\[[0-9;]*m" ""))
|
||||
|
||||
(defn- header-lines
|
||||
"Generate inline header box with ASCII Clojure logo and welcome message."
|
||||
[width]
|
||||
(let [p "\033[38;5;135m"
|
||||
w "\033[1;97m"
|
||||
d "\033[2m"
|
||||
r "\033[0m"
|
||||
box-w (min 48 (max 24 (- width 4)))
|
||||
iw (- box-w 2)
|
||||
|
||||
;; Hat lines padded to 20 chars to align with 20-char braille logo
|
||||
lines [(str p " ✦ " r)
|
||||
(def ^:private hat-lines
|
||||
(let [p "\033[38;5;135m" r "\033[0m"]
|
||||
[(str p " ✦ " r)
|
||||
(str p " ▄█▄ " r)
|
||||
(str p " ▄█████▄ " r)
|
||||
(str p " ▀▀▀▀▀▀▀▀▀ " r)
|
||||
;; Clojure logo - generated from SVG via chafa (20x10 braille, 256-color)
|
||||
" \033[38;5;255m⢀\033[38;5;231m⣠\033[38;5;254m⣴\033[38;5;189m⣶\033[38;5;146m⣿⣿⣿⣿\033[38;5;189m⣶\033[38;5;254m⣦\033[38;5;231m⣄\033[38;5;255m⡀\033[0m "
|
||||
(str p " ▀▀▀▀▀▀▀▀▀ " r)]))
|
||||
|
||||
(def ^:private logo-lines
|
||||
[" \033[38;5;255m⢀\033[38;5;231m⣠\033[38;5;254m⣴\033[38;5;189m⣶\033[38;5;146m⣿⣿⣿⣿\033[38;5;189m⣶\033[38;5;254m⣦\033[38;5;231m⣄\033[38;5;255m⡀\033[0m "
|
||||
" \033[38;5;246m⣠\033[38;5;253m⣶\033[38;5;147m⣿\033[38;5;68m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;146m⣿\033[38;5;188m⣶\033[38;5;246m⣄\033[0m "
|
||||
" \033[38;5;253m⣴\033[38;5;231m⣿\033[38;5;189m⣿\033[38;5;254m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;153m⣿⣿⣿\033[38;5;147m⣿\033[38;5;104m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;253m⣦\033[0m "
|
||||
"\033[38;5;250m⣸\033[38;5;255m⣿\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;187m⣿\033[38;5;156m⣿\033[38;5;149m⣿\033[38;5;194m⣿\033[38;5;153m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;250m⣇\033[0m"
|
||||
@@ -87,38 +76,26 @@
|
||||
"\033[38;5;253m⢹\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;107m⣿\033[38;5;194m⣿\033[38;5;150m⣿⣿\033[38;5;193m⣿\033[38;5;113m⣿\033[38;5;193m⣿\033[38;5;189m⣿\033[38;5;111m⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿\033[38;5;110m⣿\033[38;5;189m⣿\033[38;5;253m⡏\033[0m"
|
||||
" \033[38;5;254m⢻\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿\033[38;5;150m⣿\033[38;5;187m⣿⣿\033[38;5;193m⣿⣿\033[38;5;194m⣿\033[38;5;253m⣿⣿\033[38;5;252m⣿⣿\033[38;5;253m⣿\033[38;5;231m⣿\033[38;5;254m⡟\033[0m "
|
||||
" \033[38;5;231m⠙\033[38;5;254m⢿\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;150m⣿\033[38;5;194m⡿\033[38;5;231m⠋\033[0m "
|
||||
" \033[38;5;188m⠉\033[38;5;231m⠛\033[38;5;187m⠿\033[38;5;150m⣿⣿⣿⣿⣿⣿\033[38;5;187m⠿\033[38;5;231m⠛\033[38;5;188m⠉\033[0m "
|
||||
""
|
||||
(str w " agent0" r)
|
||||
""
|
||||
" Welcome! Type a message"
|
||||
" and press Enter to chat."
|
||||
""
|
||||
(str d " Esc to interrupt · Ctrl+C to quit" r)]
|
||||
" \033[38;5;188m⠉\033[38;5;231m⠛\033[38;5;187m⠿\033[38;5;150m⣿⣿⣿⣿⣿⣿\033[38;5;187m⠿\033[38;5;231m⠛\033[38;5;188m⠉\033[0m "])
|
||||
|
||||
pad-line
|
||||
(fn [s]
|
||||
(let [vis (count (strip-ansi s))
|
||||
lp (max 0 (quot (- iw vis) 2))
|
||||
rp (max 0 (- iw vis lp))]
|
||||
(str d "│" r
|
||||
(apply str (repeat lp \space))
|
||||
s
|
||||
(apply str (repeat rp \space))
|
||||
d "│" r)))
|
||||
|
||||
h (apply str (repeat iw "─"))
|
||||
top (str d "╭" h "╮" r)
|
||||
bot (str d "╰" h "╯" r)
|
||||
|
||||
all (concat [top (pad-line "")]
|
||||
(map pad-line lines)
|
||||
[(pad-line "") bot])
|
||||
|
||||
lp (max 0 (quot (- width box-w) 2))
|
||||
prefix (apply str (repeat lp \space))]
|
||||
|
||||
(mapv (fn [line] [:text (str prefix line)]) all)))
|
||||
(defn- header-view
|
||||
"Two-column header: logo on left, welcome text on right."
|
||||
[]
|
||||
(let [left-col (into [:col] (map (fn [l] [:text l]) (concat hat-lines logo-lines)))
|
||||
right-col [:col
|
||||
[:text ""]
|
||||
[:text {:fg :white :bold true} "agent0"]
|
||||
[:text ""]
|
||||
[:text "Agentic TUI for"]
|
||||
[:text "Clojure wizards"]
|
||||
[:text ""]
|
||||
[:text "Type a command and"]
|
||||
[:text "press Enter"]
|
||||
[:text ""]
|
||||
[:text {:dim true} "Esc · Ctrl+C to quit"]]]
|
||||
[:row {:widths [22 :flex]}
|
||||
left-col
|
||||
right-col]))
|
||||
|
||||
(defn- format-messages
|
||||
"Convert display messages to a flat vector of hiccup line elements."
|
||||
@@ -148,6 +125,58 @@
|
||||
:tool
|
||||
[[:text {:dim true} (str " ⚙ " (:content msg))]]
|
||||
|
||||
:diff
|
||||
(let [raw-lines (str/split-lines (:content msg))
|
||||
header (first raw-lines)
|
||||
has-header? (and header (str/starts-with? header "@@"))
|
||||
change-start (if has-header?
|
||||
(or (some-> (re-find #":(\d+)" header) second parse-long) 1)
|
||||
1)
|
||||
;; Extract file extension from @@ header for syntax highlighting
|
||||
diff-lang (when has-header?
|
||||
(when-let [path (second (re-find #"@@\s+(?:new:\s+)?(\S+)" header))]
|
||||
(let [dot-idx (str/last-index-of path ".")]
|
||||
(when dot-idx
|
||||
(syntax/lang-for-ext (subs path dot-idx))))))
|
||||
body-lines (if has-header? (rest raw-lines) raw-lines)
|
||||
ctx-before (count (take-while #(str/starts-with? % " ") body-lines))
|
||||
first-lineno (- change-start ctx-before)
|
||||
grey "\033[38;5;242m"
|
||||
rst "\033[0m"
|
||||
[body-els _]
|
||||
(reduce
|
||||
(fn [[acc {:keys [old-n new-n]}] line]
|
||||
(cond
|
||||
(str/starts-with? line "-")
|
||||
(let [code (subs line 1)
|
||||
highlighted (str "-" (syntax/highlight-line code diff-lang "\033[38;5;210m") rst)]
|
||||
[(conj acc [:text (str " " grey (format "%4d " old-n) rst
|
||||
"\033[48;5;52m" highlighted)])
|
||||
{:old-n (inc old-n) :new-n new-n}])
|
||||
|
||||
(str/starts-with? line "+")
|
||||
(let [code (subs line 1)
|
||||
highlighted (str "+" (syntax/highlight-line code diff-lang "\033[38;5;114m") rst)]
|
||||
[(conj acc [:text (str " " grey (format "%4d " new-n) rst
|
||||
"\033[48;5;22m" highlighted)])
|
||||
{:old-n old-n :new-n (inc new-n)}])
|
||||
|
||||
(str/starts-with? line "...")
|
||||
[(conj acc [:text {:dim true} (str " " line)])
|
||||
{:old-n old-n :new-n new-n}]
|
||||
|
||||
:else
|
||||
(let [code (if (str/starts-with? line " ") (subs line 1) line)
|
||||
highlighted (str " " (syntax/highlight-line code diff-lang "") rst)]
|
||||
[(conj acc [:text {:dim true} (str " " grey (format "%4d " old-n) rst highlighted)])
|
||||
{:old-n (inc old-n) :new-n (inc new-n)}])))
|
||||
[[] {:old-n first-lineno :new-n first-lineno}]
|
||||
body-lines)]
|
||||
(into (if has-header?
|
||||
[[:text (str " \033[36;2m" header "\033[0m")]]
|
||||
[])
|
||||
body-els))
|
||||
|
||||
:error
|
||||
[[:text {:fg :red} (str " ✗ " (:content msg))]]
|
||||
|
||||
@@ -168,12 +197,8 @@
|
||||
chat-height (max 1 (- height input-box-height))
|
||||
content-width (max 10 (- width 2))
|
||||
|
||||
;; Format messages with inline header
|
||||
all-lines
|
||||
(let [header (header-lines width)]
|
||||
(if (empty? messages)
|
||||
header
|
||||
(into header (format-messages messages content-width))))
|
||||
;; Format chat messages as flat text lines
|
||||
all-lines (format-messages messages content-width)
|
||||
|
||||
;; Add thinking indicator
|
||||
all-lines
|
||||
@@ -203,14 +228,22 @@
|
||||
max-input-width (max 1 (- width 6))
|
||||
display-input (if (> (count input) max-input-width)
|
||||
(subs input (- (count input) max-input-width))
|
||||
input)]
|
||||
input)
|
||||
|
||||
[:col {:heights [chat-height input-box-height]}
|
||||
(into [:col] display-lines)
|
||||
[:box {:border :rounded :width :fill}
|
||||
input-box [:box {:border :rounded :width :fill}
|
||||
(if (pos? clamped-offset)
|
||||
[:text {:fg :cyan} (str "↑" clamped-offset " " display-input "█")]
|
||||
[:text {:fg :green} (str "> " display-input "█")])]]))
|
||||
[:text {:fg :green} (str "> " display-input "█")])]]
|
||||
|
||||
(if (empty? messages)
|
||||
;; Empty state: show two-column header
|
||||
[:col {:heights [:flex input-box-height]}
|
||||
(header-view)
|
||||
input-box]
|
||||
;; Chat state: scrollable messages
|
||||
[:col {:heights [chat-height input-box-height]}
|
||||
(into [:col] display-lines)
|
||||
input-box])))
|
||||
|
||||
;; ============================================================
|
||||
;; Update
|
||||
@@ -229,6 +262,7 @@
|
||||
(case (:type event)
|
||||
:text (update model :messages conj {:role :assistant :content (:content event)})
|
||||
:tool (update model :messages conj {:role :tool :content (:label event)})
|
||||
:diff (update model :messages conj {:role :diff :content (:content event)})
|
||||
:error (update model :messages conj {:role :error :content (:message event)})
|
||||
:done (let [m (assoc model
|
||||
:agent-running? false
|
||||
@@ -258,10 +292,23 @@
|
||||
(and (ev/key= event :enter)
|
||||
(seq (:input model))
|
||||
(not (:agent-running? model)))
|
||||
(let [text (:input model)
|
||||
(let [text (str/trim (:input model))]
|
||||
(cond
|
||||
;; Built-in: /skills — list loaded skills
|
||||
(= text "/skills")
|
||||
(let [listing (context/format-skill-list (:skills model))]
|
||||
{:model (-> model
|
||||
(update :messages conj {:role :user :content text})
|
||||
(update :messages conj {:role :assistant :content listing})
|
||||
(assoc :input "" :scroll-offset 0))})
|
||||
|
||||
;; Skill expansion or normal message → send to LLM
|
||||
:else
|
||||
(let [expanded (context/expand-skill text (:skills model))
|
||||
llm-content (or expanded text)
|
||||
new-messages (conj (:messages model) {:role :user :content text})
|
||||
new-conversation (conj (:conversation model) {:role "user" :content text})
|
||||
agent-handle (core/run-agent-loop! new-conversation (:event-queue model))]
|
||||
new-conversation (conj (:conversation model) {:role "user" :content llm-content})
|
||||
agent-handle (core/run-agent-loop! (:system-prompt model) new-conversation (:event-queue model))]
|
||||
{:model (assoc model
|
||||
:messages new-messages
|
||||
:input ""
|
||||
@@ -271,7 +318,7 @@
|
||||
:spinner-frame 0
|
||||
:scroll-offset 0)
|
||||
:events [(ev/delayed-event 100 {:type :poll})
|
||||
(ev/delayed-event 80 {:type :spinner})]})
|
||||
(ev/delayed-event 80 {:type :spinner})]})))
|
||||
|
||||
;; Backspace
|
||||
(ev/key= event :backspace)
|
||||
@@ -345,6 +392,11 @@
|
||||
|
||||
(defn -main [& args]
|
||||
(let [{:keys [continue? session-id prompt]} (parse-args args)
|
||||
;; Load project context and skills
|
||||
project-context (context/load-project-context)
|
||||
skills (context/load-skills)
|
||||
_ (reset! core/skills-atom skills)
|
||||
system-prompt (core/build-system-prompt project-context skills)
|
||||
;; Resolve session to resume
|
||||
resume-id (cond
|
||||
session-id session-id
|
||||
@@ -364,13 +416,15 @@
|
||||
(conj base-messages {:role :user :content prompt})
|
||||
true]
|
||||
[base-conversation base-messages false])
|
||||
agent-handle (when start? (core/run-agent-loop! conversation eq))
|
||||
agent-handle (when start? (core/run-agent-loop! system-prompt conversation eq))
|
||||
initial-model {:messages messages
|
||||
:input ""
|
||||
:conversation conversation
|
||||
:event-queue eq
|
||||
:session-id sid
|
||||
:created created
|
||||
:system-prompt system-prompt
|
||||
:skills skills
|
||||
:agent-running? start?
|
||||
:agent-handle agent-handle
|
||||
:spinner-frame 0
|
||||
@@ -386,4 +440,4 @@
|
||||
:init-events initial-events})
|
||||
;; Post-exit: print session info
|
||||
(println (str "\nSession: " sid))
|
||||
(println (str "To continue: ./agent --session " sid))))
|
||||
(println (str "To continue: agent --session " sid))))
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
(ns agent.clojuredocs
|
||||
"ClojureDocs lookup tool. Downloads and caches the ClojureDocs JSON export,
|
||||
builds an in-memory index, and provides instant lookups by name."
|
||||
(:require [babashka.http-client :as http]
|
||||
[cheshire.core :as json]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]))
|
||||
|
||||
;; ============================================================
|
||||
;; Download & Cache
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private export-url "https://clojuredocs.org/clojuredocs-export.json")
|
||||
|
||||
(def ^:private cache-path
|
||||
(let [home (System/getProperty "user.home")]
|
||||
(.getPath (io/file home ".local" "share" "agent0" "clojuredocs-export.json"))))
|
||||
|
||||
(def ^:private ttl-ms (* 7 24 60 60 1000)) ;; 7 days
|
||||
|
||||
(defn- cache-fresh? []
|
||||
(let [f (io/file cache-path)]
|
||||
(and (.exists f)
|
||||
(> (.length f) 0)
|
||||
(< (- (System/currentTimeMillis) (.lastModified f)) ttl-ms))))
|
||||
|
||||
(defn- ensure-cache! []
|
||||
(when-not (cache-fresh?)
|
||||
(let [f (io/file cache-path)]
|
||||
(io/make-parents f)
|
||||
(let [resp (http/get export-url {:headers {"User-Agent" "agent0/1.0"}})]
|
||||
(spit f (:body resp)))))
|
||||
cache-path)
|
||||
|
||||
;; ============================================================
|
||||
;; In-memory Index (delay — computed once per session)
|
||||
;; ============================================================
|
||||
|
||||
(defn- build-index
|
||||
"Parse the export JSON and build a lookup map.
|
||||
Returns {:by-fqn {\"ns/name\" var-map}, :by-name {\"name\" [var-map ...]}}."
|
||||
[path]
|
||||
(let [data (json/parse-string (slurp path) true)
|
||||
vars (:vars data)]
|
||||
(reduce
|
||||
(fn [idx v]
|
||||
(let [ns-name (:ns v)
|
||||
var-name (:name v)
|
||||
fqn (str ns-name "/" var-name)]
|
||||
(-> idx
|
||||
(assoc-in [:by-fqn fqn] v)
|
||||
(update-in [:by-name var-name] (fnil conj []) v))))
|
||||
{:by-fqn {} :by-name {}}
|
||||
vars)))
|
||||
|
||||
(def ^:private index
|
||||
(delay (build-index (ensure-cache!))))
|
||||
|
||||
;; ============================================================
|
||||
;; Formatting
|
||||
;; ============================================================
|
||||
|
||||
(defn- format-arglists [v]
|
||||
(let [arglists (:arglists v)]
|
||||
(when (seq arglists)
|
||||
(str/join "\n" (map #(str " " %) arglists)))))
|
||||
|
||||
(defn- format-see-alsos [v]
|
||||
(let [see-alsos (:see-alsos v)]
|
||||
(when (seq see-alsos)
|
||||
(str "See also: "
|
||||
(str/join ", " (map (fn [sa]
|
||||
(let [tv (:to-var sa)]
|
||||
(if (and (:ns tv) (:name tv))
|
||||
(str (:ns tv) "/" (:name tv))
|
||||
(str sa))))
|
||||
see-alsos))))))
|
||||
|
||||
(defn- format-examples [v mode]
|
||||
(let [examples (:examples v)]
|
||||
(case mode
|
||||
"none" nil
|
||||
"full" (when (seq examples)
|
||||
(str/join "\n---\n"
|
||||
(map-indexed (fn [i ex]
|
||||
(str "Example " (inc i) ":\n" (:body ex)))
|
||||
examples)))
|
||||
;; default: "short" — up to 3, truncated at 600 chars each
|
||||
(when (seq examples)
|
||||
(str/join "\n---\n"
|
||||
(map-indexed (fn [i ex]
|
||||
(let [body (:body ex)
|
||||
body (if (> (count body) 600)
|
||||
(str (subs body 0 600) "...")
|
||||
body)]
|
||||
(str "Example " (inc i) ":\n" body)))
|
||||
(take 3 examples)))))))
|
||||
|
||||
(defn- format-var [v examples-mode]
|
||||
(let [fqn (str (:ns v) "/" (:name v))
|
||||
doc (:doc v)
|
||||
arglists (format-arglists v)
|
||||
see-alsos (format-see-alsos v)
|
||||
examples (format-examples v examples-mode)]
|
||||
(str/join "\n\n"
|
||||
(remove nil?
|
||||
[(str "## " fqn)
|
||||
(when arglists (str "Arglists:\n" arglists))
|
||||
(when doc (str/trim doc))
|
||||
see-alsos
|
||||
examples]))))
|
||||
|
||||
;; ============================================================
|
||||
;; Lookup Logic
|
||||
;; ============================================================
|
||||
|
||||
(defn lookup
|
||||
"Look up a Clojure var in ClojureDocs.
|
||||
Tries: FQN exact match → bare name match → substring fallback (capped at 10)."
|
||||
[query examples-mode]
|
||||
(let [{:keys [by-fqn by-name]} @index
|
||||
query (str/trim query)]
|
||||
(cond
|
||||
;; FQN exact match
|
||||
(get by-fqn query)
|
||||
(format-var (get by-fqn query) examples-mode)
|
||||
|
||||
;; Bare name exact match
|
||||
(get by-name query)
|
||||
(let [matches (get by-name query)]
|
||||
(if (= 1 (count matches))
|
||||
(format-var (first matches) examples-mode)
|
||||
(str (count matches) " vars named \"" query "\":\n\n"
|
||||
(str/join "\n\n" (map #(format-var % examples-mode) matches)))))
|
||||
|
||||
;; Substring fallback
|
||||
:else
|
||||
(let [q (str/lower-case query)
|
||||
matches (->> (vals by-fqn)
|
||||
(filter (fn [v]
|
||||
(or (str/includes? (str/lower-case (str (:name v))) q)
|
||||
(str/includes? (str/lower-case (str (:ns v) "/" (:name v))) q))))
|
||||
(take 10))]
|
||||
(if (seq matches)
|
||||
(str (count matches) " matches for \"" query "\":\n\n"
|
||||
(str/join "\n\n" (map #(format-var % examples-mode) matches)))
|
||||
(str "No ClojureDocs entry found for \"" query "\"."))))))
|
||||
|
||||
;; ============================================================
|
||||
;; Tool Definition
|
||||
;; ============================================================
|
||||
|
||||
(def tool
|
||||
{:type "function"
|
||||
:function {:name "clojuredocs"
|
||||
:description "Look up Clojure function/macro documentation from ClojureDocs. Supports fully-qualified names (e.g. \"clojure.core/map\"), bare names (e.g. \"map\"), and substring search (e.g. \"reduc\"). Returns doc, arglists, see-alsos, and examples. Use this instead of web search for Clojure standard library docs."
|
||||
:parameters {:type "object"
|
||||
:properties {:name {:type "string"
|
||||
:description "Function or macro name to look up. Can be fully-qualified (\"clojure.core/map\"), bare (\"map\"), or a substring (\"reduc\")."}
|
||||
:examples {:type "string"
|
||||
:description "How many examples to include: \"none\", \"short\" (default, up to 3 truncated), or \"full\" (all examples, untruncated)."}}
|
||||
:required ["name"]}}
|
||||
:impl (fn [{:keys [name examples]}]
|
||||
(lookup name (or examples "short")))})
|
||||
@@ -0,0 +1,183 @@
|
||||
(ns agent.context
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.string :as str]))
|
||||
|
||||
;; ============================================================
|
||||
;; Project Context Loading
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private project-context-files
|
||||
"Context files to search for in CWD, in order."
|
||||
[{:path ".agent0/context.md" :label "agent0 context"}
|
||||
{:path "CLAUDE.md" :label "CLAUDE.md"}
|
||||
{:path ".cursorrules" :label ".cursorrules"}
|
||||
{:path ".github/copilot-instructions.md" :label "copilot-instructions.md"}])
|
||||
|
||||
(defn- read-context-section
|
||||
"Read a file and wrap its content in a labeled section, or nil if missing/empty."
|
||||
[file label]
|
||||
(when (.exists file)
|
||||
(let [content (str/trim (slurp file))]
|
||||
(when (seq content)
|
||||
(str "## " label "\n\n" content)))))
|
||||
|
||||
(defn load-project-context
|
||||
"Load context from global (~/.config/agent0/context.md) and project files in CWD.
|
||||
Global context is included first, then project-local files.
|
||||
Returns a single string with section headers, or nil if none found."
|
||||
[]
|
||||
(let [home (System/getProperty "user.home")
|
||||
global-file (io/file home ".config" "agent0" "context.md")
|
||||
global-section (read-context-section global-file "Global Instructions (from ~/.config/agent0/context.md)")
|
||||
project-sections
|
||||
(for [{:keys [path label]} project-context-files
|
||||
:let [section (read-context-section (io/file path)
|
||||
(str "Project Instructions (from " label ")"))]
|
||||
:when section]
|
||||
section)
|
||||
all (cond-> []
|
||||
global-section (conj global-section)
|
||||
(seq project-sections) (into project-sections))]
|
||||
(when (seq all)
|
||||
(str/join "\n\n" all))))
|
||||
|
||||
;; ============================================================
|
||||
;; Skills Parsing
|
||||
;; ============================================================
|
||||
|
||||
(defn parse-skills
|
||||
"Parse a skills.md string into a map of skill-name -> {:params [...] :template \"...\" :source source}
|
||||
Format: headings like `# /name` or `# /name <arg1> <arg2>` followed by template body.
|
||||
source is attached by the caller (e.g. :global or :project)."
|
||||
([text] (parse-skills text nil))
|
||||
([text source]
|
||||
(when (and text (seq (str/trim text)))
|
||||
(let [;; Split on lines starting with # /
|
||||
parts (str/split text #"(?m)^# +/")
|
||||
;; First part before any heading is ignored
|
||||
skill-parts (rest parts)]
|
||||
(into {}
|
||||
(for [part skill-parts
|
||||
:let [lines (str/split-lines part)
|
||||
header (first lines)
|
||||
;; Parse name and params from header line
|
||||
tokens (str/split (str/trim header) #"\s+")
|
||||
skill-name (first tokens)
|
||||
params (vec (for [t (rest tokens)
|
||||
:when (and (str/starts-with? t "<")
|
||||
(str/ends-with? t ">"))]
|
||||
(subs t 1 (dec (count t)))))
|
||||
;; Body is everything after header, trimmed
|
||||
body (str/trim (str/join "\n" (rest lines)))]
|
||||
:when (and skill-name (seq body))]
|
||||
[skill-name (cond-> {:params params :template body}
|
||||
source (assoc :source source))]))))))
|
||||
|
||||
|
||||
(defn- parse-frontmatter
|
||||
"Parse YAML-ish frontmatter from a string starting with ---.
|
||||
Returns [metadata-map body-string]. Handles simple key: value pairs."
|
||||
[text]
|
||||
(if (str/starts-with? text "---")
|
||||
(let [after-open (subs text 3)
|
||||
end-idx (str/index-of after-open "\n---")]
|
||||
(if end-idx
|
||||
(let [fm-str (subs after-open 0 end-idx)
|
||||
body (str/trim (subs after-open (+ end-idx 4)))
|
||||
meta (->> (str/split-lines fm-str)
|
||||
(keep (fn [line]
|
||||
(when-let [colon-idx (str/index-of line ":")]
|
||||
(let [k (str/trim (subs line 0 colon-idx))
|
||||
v (str/trim (subs line (inc colon-idx)))]
|
||||
(when (and (seq k) (seq v))
|
||||
[k v])))))
|
||||
(into {}))]
|
||||
[meta body])
|
||||
[nil text]))
|
||||
[nil text]))
|
||||
|
||||
(defn- load-claude-skills
|
||||
"Load skills from ~/.claude/skills/*/SKILL.md.
|
||||
Each SKILL.md has YAML frontmatter with 'name' and a markdown body template."
|
||||
[]
|
||||
(let [home (System/getProperty "user.home")
|
||||
skills-dir (io/file home ".claude" "skills")]
|
||||
(when (.isDirectory skills-dir)
|
||||
(->> (.listFiles skills-dir)
|
||||
(filter #(.isDirectory %))
|
||||
(keep (fn [dir]
|
||||
(let [skill-file (io/file dir "SKILL.md")]
|
||||
(when (.exists skill-file)
|
||||
(let [text (slurp skill-file)
|
||||
[fm body] (parse-frontmatter text)
|
||||
skill-name (get fm "name")]
|
||||
(when (and skill-name (seq body))
|
||||
[skill-name {:params [] :template body :source :claude}]))))))
|
||||
(into {})))))
|
||||
|
||||
(defn load-skills
|
||||
"Load skills from Claude Code (~/.claude/skills/), global (~/.config/agent0/skills.md),
|
||||
and project (.agent0/skills.md). Later sources override earlier for same-name skills.
|
||||
Each skill carries :source (:claude, :global, or :project)."
|
||||
[]
|
||||
(let [home (System/getProperty "user.home")
|
||||
global-file (io/file home ".config" "agent0" "skills.md")
|
||||
project-file (io/file ".agent0" "skills.md")
|
||||
claude-skills (load-claude-skills)
|
||||
global-skills (when (.exists global-file)
|
||||
(parse-skills (slurp global-file) :global))
|
||||
project-skills (when (.exists project-file)
|
||||
(parse-skills (slurp project-file) :project))]
|
||||
(merge claude-skills global-skills project-skills)))
|
||||
|
||||
(defn format-skill-list
|
||||
"Format loaded skills into a displayable string.
|
||||
Groups by source, sorted by name within each group."
|
||||
[skills]
|
||||
(if (or (nil? skills) (empty? skills))
|
||||
"No skills loaded."
|
||||
(let [sorted (sort-by key skills)
|
||||
by-source (group-by (fn [[_ v]] (:source v)) sorted)
|
||||
format-entry (fn [[name {:keys [params]}]]
|
||||
(if (seq params)
|
||||
(str " /" name " " (str/join " " (map #(str "<" % ">") params)))
|
||||
(str " /" name)))
|
||||
sections (cond-> []
|
||||
(seq (:project by-source))
|
||||
(conj (str "Project skills:\n"
|
||||
(str/join "\n" (map format-entry (:project by-source)))))
|
||||
(seq (:global by-source))
|
||||
(conj (str "Global skills:\n"
|
||||
(str/join "\n" (map format-entry (:global by-source)))))
|
||||
(seq (:claude by-source))
|
||||
(conj (str "Claude Code skills:\n"
|
||||
(str/join "\n" (map format-entry (:claude by-source)))))
|
||||
;; Skills without a source tag (shouldn't happen, but defensive)
|
||||
(seq (get by-source nil))
|
||||
(conj (str "Skills:\n"
|
||||
(str/join "\n" (map format-entry (get by-source nil))))))]
|
||||
(str/join "\n\n" sections))))
|
||||
|
||||
(defn expand-skill
|
||||
"If input starts with /name, look up the skill and expand its template.
|
||||
Positional args from the input are substituted for {param} placeholders.
|
||||
Returns the expanded string, or nil if input is not a skill invocation."
|
||||
[input skills]
|
||||
(when (and input (str/starts-with? input "/"))
|
||||
(let [tokens (str/split (str/trim input) #"\s+" 2)
|
||||
skill-name (subs (first tokens) 1) ;; strip leading /
|
||||
args-str (second tokens)
|
||||
skill (get skills skill-name)]
|
||||
(when skill
|
||||
(let [{:keys [params template]} skill
|
||||
;; Split remaining input into positional args
|
||||
args (if args-str
|
||||
(str/split (str/trim args-str) #"\s+")
|
||||
[])
|
||||
;; Build substitution map: {param} -> arg value
|
||||
expanded (reduce
|
||||
(fn [tmpl [param arg]]
|
||||
(str/replace tmpl (str "{" param "}") arg))
|
||||
template
|
||||
(map vector params args))]
|
||||
expanded)))))
|
||||
+147
-23
@@ -1,5 +1,7 @@
|
||||
(ns agent.core
|
||||
(:require [agent.web-search :as web-search]
|
||||
(:require [agent.clojuredocs :as clojuredocs]
|
||||
[agent.context :as context]
|
||||
[agent.web-search :as web-search]
|
||||
[babashka.fs :as fs]
|
||||
[babashka.http-client :as http]
|
||||
[cheshire.core :as json]
|
||||
@@ -16,9 +18,9 @@
|
||||
|
||||
(def ollama-host (or (System/getenv "OLLAMA_HOST") "http://localhost:11434"))
|
||||
(def model (or (System/getenv "AGENT_MODEL") "qwen3-coder-next"))
|
||||
(def max-tokens 65536)
|
||||
(def max-tokens 131072)
|
||||
|
||||
(def system-prompt
|
||||
(def base-system-prompt
|
||||
"You are a helpful coding assistant. You can read, list, create, edit, search, and find files to help the user with their coding tasks.
|
||||
|
||||
When researching or exploring code:
|
||||
@@ -26,13 +28,33 @@ When researching or exploring code:
|
||||
2. Use grep_files to search file contents and find relevant line numbers
|
||||
3. Use read_file with offset and limit to read only the relevant sections — avoid reading entire files unless they are small or you need the full context
|
||||
|
||||
For Clojure documentation:
|
||||
- Use the clojuredocs tool for instant lookups of Clojure standard library functions and macros.
|
||||
- Supports fully-qualified names (clojure.core/map), bare names (map), and substring search (reduc).
|
||||
|
||||
For web information:
|
||||
- Use delegate with the web_search agent. It can search the web AND fetch full page content for you.
|
||||
- Describe what you need clearly and the agent will return a concise summary of its findings.
|
||||
- You do NOT have direct web search or URL fetch tools — always delegate.
|
||||
|
||||
For predefined workflows:
|
||||
- Use the skills tool to run predefined skill workflows by name.
|
||||
- If the user invokes a skill (e.g. /deploy, /test), use skills(action: 'run', name: '<skill-name>') to execute it.
|
||||
|
||||
Always explain what you're doing before using tools. Use the tools when needed to complete the task.")
|
||||
|
||||
(defn build-system-prompt
|
||||
"Build the full system prompt by appending project context (if any) and loaded skills to the base prompt."
|
||||
[project-context skills]
|
||||
(let [skills-section (when (seq skills)
|
||||
(let [listing (context/format-skill-list skills)]
|
||||
(str "## Available Skills\n\n"
|
||||
"The following skills are loaded and ready to use via the skills tool with action 'run':\n\n"
|
||||
listing)))]
|
||||
(cond-> base-system-prompt
|
||||
project-context (str "\n\n" project-context)
|
||||
skills-section (str "\n\n" skills-section))))
|
||||
|
||||
;; ============================================================
|
||||
;; Logging
|
||||
;; ============================================================
|
||||
@@ -91,10 +113,25 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
(when (seq files)
|
||||
(str/replace (.getName (first files)) ".edn" ""))))))
|
||||
|
||||
;; ============================================================
|
||||
;; Skills Atom (populated by app.clj at startup)
|
||||
;; ============================================================
|
||||
|
||||
(def skills-atom (atom {}))
|
||||
|
||||
;; ============================================================
|
||||
;; Tools Implementation
|
||||
;; ============================================================
|
||||
|
||||
(defn- ->long
|
||||
"Coerce a value to long. LLMs sometimes return numbers as strings."
|
||||
[x]
|
||||
(cond
|
||||
(nil? x) nil
|
||||
(number? x) (long x)
|
||||
(string? x) (parse-long x)
|
||||
:else nil))
|
||||
|
||||
(defn read-file [{:keys [path offset limit]}]
|
||||
(let [file (io/file path)]
|
||||
(cond
|
||||
@@ -107,6 +144,8 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
:else
|
||||
(let [lines (str/split-lines (slurp file))
|
||||
total (count lines)
|
||||
offset (->long offset)
|
||||
limit (->long limit)
|
||||
start (max 0 (dec (or offset 1)))
|
||||
selected (cond->> (drop start lines)
|
||||
limit (take limit))
|
||||
@@ -135,6 +174,32 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
(str/join "\n"))
|
||||
(str "Error: Not a directory: " path))))
|
||||
|
||||
(defn- generate-edit-diff
|
||||
"Generate a unified-diff-like string for a file edit."
|
||||
[path content old_str new_str]
|
||||
(let [pos (str/index-of content old_str)
|
||||
pre (subs content 0 pos)
|
||||
start-line (count (filter #(= % \newline) pre))
|
||||
all-lines (vec (str/split-lines content))
|
||||
old-lines (if (empty? old_str) [] (str/split-lines old_str))
|
||||
new-lines (if (empty? new_str) [] (str/split-lines new_str))
|
||||
end-line (+ start-line (count old-lines))
|
||||
ctx 3
|
||||
ctx-start (max 0 (- start-line ctx))
|
||||
ctx-end (min (count all-lines) (+ end-line ctx))
|
||||
before-ctx (subvec all-lines ctx-start start-line)
|
||||
after-ctx (subvec all-lines end-line ctx-end)
|
||||
diff-lines (concat
|
||||
[(str "@@ " path ":" (inc start-line) " @@")]
|
||||
(map #(str " " %) before-ctx)
|
||||
(map #(str "-" %) old-lines)
|
||||
(map #(str "+" %) new-lines)
|
||||
(map #(str " " %) after-ctx))
|
||||
max-lines 30]
|
||||
(str/join "\n" (if (> (count diff-lines) max-lines)
|
||||
(concat (take max-lines diff-lines) ["..."])
|
||||
diff-lines))))
|
||||
|
||||
(defn edit-file [{:keys [path old_str new_str]}]
|
||||
(let [file (io/file path)]
|
||||
(if (.exists file)
|
||||
@@ -148,18 +213,30 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
(str "Error: old_str appears " occurrences " times. Must be unique.")
|
||||
|
||||
:else
|
||||
(do (spit file (str/replace-first content old_str new_str))
|
||||
(str "Successfully edited " path))))
|
||||
(let [diff (generate-edit-diff path content old_str new_str)]
|
||||
(spit file (str/replace-first content old_str new_str))
|
||||
{:message (str "Successfully edited " path)
|
||||
:diff diff})))
|
||||
(str "Error: File not found: " path))))
|
||||
|
||||
(defn create-file [{:keys [path content]}]
|
||||
(let [file (io/file path)]
|
||||
(if (.exists file)
|
||||
(str "Error: File already exists: " path)
|
||||
(do
|
||||
(let [lines (str/split-lines content)
|
||||
max-lines 20
|
||||
truncated? (> (count lines) max-lines)
|
||||
shown (if truncated? (take max-lines lines) lines)
|
||||
diff (str/join "\n"
|
||||
(concat
|
||||
[(str "@@ new: " path " @@")]
|
||||
(map #(str "+" %) shown)
|
||||
(when truncated?
|
||||
[(str "... +" (- (count lines) max-lines) " more lines")])))]
|
||||
(io/make-parents file)
|
||||
(spit file content)
|
||||
(str "Successfully created " path)))))
|
||||
{:message (str "Successfully created " path)
|
||||
:diff diff}))))
|
||||
|
||||
(defn run-shell-command [{:keys [command]}]
|
||||
(let [proc (-> (ProcessBuilder. ["bash" "-c" command])
|
||||
@@ -226,6 +303,10 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
;; Ollama API (parameterized)
|
||||
;; ============================================================
|
||||
|
||||
(def llm-timeout-ms
|
||||
"Timeout for LLM API calls in milliseconds (5 minutes)."
|
||||
(* 5 60 1000))
|
||||
|
||||
(defn- call-llm* [sys-prompt tool-defs messages]
|
||||
(let [body {:model model
|
||||
:options {:num_predict max-tokens}
|
||||
@@ -234,7 +315,8 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
:stream false}
|
||||
response (http/post (str ollama-host "/api/chat")
|
||||
{:headers {"Content-Type" "application/json"}
|
||||
:body (json/generate-string body)})]
|
||||
:body (json/generate-string body)
|
||||
:timeout llm-timeout-ms})]
|
||||
(json/parse-string (:body response) true)))
|
||||
|
||||
(defn- truncate [s max-len]
|
||||
@@ -312,6 +394,21 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
(str "Error: Unknown agent '" agent "'. Available agents: "
|
||||
(str/join ", " (keys subagent-registry))))))
|
||||
|
||||
;; ============================================================
|
||||
;; Skills Tool
|
||||
;; ============================================================
|
||||
|
||||
(defn skills-tool [{:keys [action name args]}]
|
||||
(let [skills @skills-atom]
|
||||
(case action
|
||||
"list" (context/format-skill-list skills)
|
||||
"run" (if-let [skill (get skills name)]
|
||||
(let [input (str "/" name (when args (str " " args)))]
|
||||
(or (context/expand-skill input skills)
|
||||
(str "Error: Failed to expand skill '" name "'")))
|
||||
(str "Error: Unknown skill '" name "'. Use action 'list' to see available skills."))
|
||||
(str "Error: Unknown action '" action "'. Use 'list' or 'run'."))))
|
||||
|
||||
;; ============================================================
|
||||
;; Tool Registry (OpenAI format)
|
||||
;; ============================================================
|
||||
@@ -389,7 +486,19 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
:properties {:agent {:type "string" :description "The subagent to delegate to (e.g. \"web_search\")"}
|
||||
:task {:type "string" :description "A clear description of what you want the subagent to research or do"}}
|
||||
:required ["agent" "task"]}}
|
||||
:impl delegate}])
|
||||
:impl delegate}
|
||||
|
||||
{:type "function"
|
||||
:function {:name "skills"
|
||||
:description "Discover and run predefined skill workflows. Use 'list' to see available skills, 'run' to execute one."
|
||||
:parameters {:type "object"
|
||||
:properties {:action {:type "string" :description "Action: 'list' to see available skills, 'run' to execute a skill"}
|
||||
:name {:type "string" :description "Skill name (required for 'run')"}
|
||||
:args {:type "string" :description "Arguments for the skill (optional, space-separated)"}}
|
||||
:required ["action"]}}
|
||||
:impl skills-tool}
|
||||
|
||||
clojuredocs/tool])
|
||||
|
||||
(def tool-map
|
||||
(into {} (map (fn [t] [(get-in t [:function :name]) (:impl t)]) tools)))
|
||||
@@ -401,7 +510,7 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
;; Main Agent LLM
|
||||
;; ============================================================
|
||||
|
||||
(defn call-llm [messages]
|
||||
(defn call-llm [system-prompt messages]
|
||||
(let [result (call-llm* system-prompt tool-definitions messages)]
|
||||
{:choices [{:message (:message result)
|
||||
:finish_reason (if (seq (get-in result [:message :tool_calls]))
|
||||
@@ -424,9 +533,13 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
(tool-fn args))
|
||||
(str "Error: Unknown tool '" tool-name "'"))
|
||||
(catch Exception e
|
||||
(str "Error: " (.getMessage e))))]
|
||||
(log log-file "<- Result:" (truncate (str/replace (str result) #"\n" "\\n") 200))
|
||||
{:role "tool" :tool_call_id id :content result})))
|
||||
(str "Error: " (.getMessage e))))
|
||||
[content diff] (if (map? result)
|
||||
[(:message result) (:diff result)]
|
||||
[(str result) nil])]
|
||||
(log log-file "<- Result:" (truncate (str/replace (str content) #"\n" "\\n") 200))
|
||||
(cond-> {:role "tool" :tool_call_id id :content content}
|
||||
diff (assoc :diff diff)))))
|
||||
|
||||
(defn tool-call-label
|
||||
"Generate a human-readable label for a tool call."
|
||||
@@ -435,11 +548,13 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
raw-args (get-in tc [:function :arguments])
|
||||
args (if (string? raw-args) (json/parse-string raw-args true) raw-args)]
|
||||
(case tname
|
||||
"read_file" (str "Reading " (:path args)
|
||||
(if (or (:offset args) (:limit args))
|
||||
(str ":" (or (:offset args) 1)
|
||||
(when (:limit args) (str "-" (+ (dec (or (:offset args) 1)) (:limit args)))))
|
||||
" (entire file)"))
|
||||
"read_file" (let [offset (->long (:offset args))
|
||||
limit (->long (:limit args))]
|
||||
(str "Reading " (:path args)
|
||||
(if (or offset limit)
|
||||
(str ":" (or offset 1)
|
||||
(when limit (str "-" (+ (dec (or offset 1)) limit))))
|
||||
" (entire file)")))
|
||||
"list_files" (str "Listing " (or (:path args) "."))
|
||||
"edit_file" (str "Editing " (:path args))
|
||||
"create_file" (str "Creating " (:path args))
|
||||
@@ -447,6 +562,10 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
"glob_files" (str "Globbing " (:pattern args) (when (:path args) (str " in " (:path args))))
|
||||
"grep_files" (str "Grepping " (:pattern args) (when (:include args) (str " in " (:include args))))
|
||||
"delegate" (str "Spawning " (:agent args) " subagent")
|
||||
"clojuredocs" (str "Looking up " (:name args))
|
||||
"skills" (if (= (:action args) "run")
|
||||
(str "Running skill /" (:name args))
|
||||
"Listing skills")
|
||||
(str "Calling " tname))))
|
||||
|
||||
;; ============================================================
|
||||
@@ -501,7 +620,7 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
{:type :error :message \"...\"} - error
|
||||
{:type :done :conversation [...]} - loop finished
|
||||
Returns {:future f :cancel! cancel-atom}."
|
||||
[conversation event-queue]
|
||||
[system-prompt conversation event-queue]
|
||||
(let [log-file (init-log)
|
||||
cancelled? (atom false)]
|
||||
(log log-file "Agent loop started | model:" model "| messages:" (count conversation))
|
||||
@@ -528,7 +647,7 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
:else
|
||||
(do
|
||||
(log log-file "Iteration" iteration "| messages:" (count messages))
|
||||
(let [response (call-llm messages)
|
||||
(let [response (call-llm system-prompt messages)
|
||||
choice (first (:choices response))
|
||||
message (:message choice)
|
||||
finish-reason (:finish_reason choice)
|
||||
@@ -554,9 +673,14 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
(doseq [tc tool-calls]
|
||||
(swap! event-queue conj {:type :tool :label (tool-call-label tc)}))
|
||||
;; Execute tools
|
||||
(let [tool-results (mapv #(execute-tool log-file %) tool-calls)
|
||||
(let [tool-results (mapv #(execute-tool log-file %) tool-calls)]
|
||||
;; Push diffs to UI
|
||||
(doseq [tr tool-results]
|
||||
(when-let [diff (:diff tr)]
|
||||
(swap! event-queue conj {:type :diff :content diff})))
|
||||
(let [clean-results (mapv #(dissoc % :diff) tool-results)
|
||||
assistant-msg (select-keys message [:role :content :tool_calls])
|
||||
new-messages (into (conj messages assistant-msg) tool-results)
|
||||
new-messages (into (conj messages assistant-msg) clean-results)
|
||||
;; If nudge, inject a system hint to stop researching
|
||||
new-messages (if nudge?
|
||||
(do (log log-file "Research loop nudge injected")
|
||||
@@ -564,7 +688,7 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
||||
{:role "system"
|
||||
:content "You have already performed several web searches. You have enough information to answer. Stop searching and synthesize your findings into a clear response now."}))
|
||||
new-messages)]
|
||||
(recur new-messages (inc iteration) signatures)))))
|
||||
(recur new-messages (inc iteration) signatures))))))
|
||||
;; Done - no more tool calls
|
||||
(do
|
||||
(log log-file "Agent finished after" iteration "iterations")
|
||||
|
||||
+12
-9
@@ -1,6 +1,7 @@
|
||||
(ns agent.markdown
|
||||
"Convert markdown text to ANSI-styled terminal output."
|
||||
(:require [clojure.string :as str]
|
||||
(:require [agent.syntax :as syntax]
|
||||
[clojure.string :as str]
|
||||
[tui.ansi :as ansi]))
|
||||
|
||||
;; ============================================================
|
||||
@@ -126,21 +127,23 @@
|
||||
(if (or (nil? text) (empty? text))
|
||||
[""]
|
||||
(loop [[line & remaining] (str/split-lines text)
|
||||
in-code-block? false
|
||||
code-block nil
|
||||
result []]
|
||||
(if-not line
|
||||
result
|
||||
(cond
|
||||
;; Code fence toggle
|
||||
(str/starts-with? (str/trimr line) "```")
|
||||
(if in-code-block?
|
||||
(recur remaining false result)
|
||||
(recur remaining true result))
|
||||
(if code-block
|
||||
(recur remaining nil result)
|
||||
(let [tag (str/trim (subs (str/trimr line) 3))
|
||||
lang (when (seq tag) (syntax/lang-for-fence tag))]
|
||||
(recur remaining {:lang lang} result)))
|
||||
|
||||
;; Inside code block — preserve formatting, no wrapping
|
||||
in-code-block?
|
||||
(recur remaining true
|
||||
(conj result (ansi/style (str " " line) :fg :bright-black)))
|
||||
;; Inside code block — preserve formatting, syntax highlight
|
||||
code-block
|
||||
(recur remaining code-block
|
||||
(conj result (str " " (syntax/highlight-line line (:lang code-block) "") "\033[0m")))
|
||||
|
||||
;; Header: # text
|
||||
(re-matches #"^(#{1,6})\s+(.*)" line)
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
(ns agent.syntax
|
||||
"Regex-based syntax highlighting for code blocks and diffs.
|
||||
Single-pass tokenizer using java.util.regex.Matcher.lookingAt()."
|
||||
(:require [clojure.string :as str])
|
||||
(:import [java.util.regex Pattern Matcher]))
|
||||
|
||||
;; ============================================================
|
||||
;; Color Palette (ANSI 256)
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private colors
|
||||
{:string "\033[38;5;108m"
|
||||
:comment "\033[38;5;245m"
|
||||
:keyword "\033[38;5;176m"
|
||||
:number "\033[38;5;216m"
|
||||
:builtin "\033[38;5;75m"
|
||||
:constant "\033[38;5;216m"
|
||||
:type "\033[38;5;180m"
|
||||
:clj-kw "\033[38;5;73m"
|
||||
:param "\033[38;5;208m"})
|
||||
|
||||
;; ============================================================
|
||||
;; Tokenizer Engine
|
||||
;; ============================================================
|
||||
|
||||
(defn- highlight-line*
|
||||
"Walk left-to-right through `line`. For each position, try rules in order;
|
||||
first match wins. `rules` is a vector of [compiled-Pattern color-or-fn].
|
||||
When color-or-fn is a function, it receives the matched text and returns
|
||||
an ANSI color string (or nil for default).
|
||||
`default-fg` is the ANSI code for unhighlighted text."
|
||||
[^String line rules ^String default-fg]
|
||||
(let [len (.length line)
|
||||
sb (StringBuilder.)
|
||||
matcher-cache (object-array (count rules))]
|
||||
;; Pre-create matchers for each rule
|
||||
(dotimes [i (count rules)]
|
||||
(let [[^Pattern pat _] (nth rules i)]
|
||||
(aset matcher-cache i (.matcher pat line))))
|
||||
(loop [pos 0]
|
||||
(if (>= pos len)
|
||||
(.toString sb)
|
||||
(let [matched?
|
||||
(loop [ri 0]
|
||||
(if (>= ri (count rules))
|
||||
false
|
||||
(let [^Matcher m (aget matcher-cache ri)
|
||||
_ (.region m pos len)]
|
||||
(if (.lookingAt m)
|
||||
(let [[_ color-or-fn] (nth rules ri)
|
||||
text (.group m)
|
||||
color (if (fn? color-or-fn)
|
||||
(color-or-fn text)
|
||||
color-or-fn)]
|
||||
(if color
|
||||
(do (.append sb color)
|
||||
(.append sb text)
|
||||
(.append sb default-fg))
|
||||
(do (.append sb text)))
|
||||
(.end m))
|
||||
(recur (inc ri))))))]
|
||||
(if matched?
|
||||
(recur (long matched?))
|
||||
(do (.append sb (.charAt line pos))
|
||||
(recur (inc pos)))))))))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: Clojure
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private clj-special-forms
|
||||
#{"def" "defn" "defn-" "defmacro" "defmethod" "defmulti" "defonce" "defprotocol"
|
||||
"defrecord" "deftype" "defstruct" "definline" "definterface"
|
||||
"fn" "fn*" "if" "if-let" "if-not" "if-some"
|
||||
"when" "when-let" "when-not" "when-first" "when-some"
|
||||
"do" "let" "letfn" "binding" "loop" "recur"
|
||||
"cond" "condp" "cond->" "cond->>" "case"
|
||||
"try" "catch" "finally" "throw"
|
||||
"quote" "var" "import" "require" "use" "refer" "ns"
|
||||
"and" "or" "not"
|
||||
"doseq" "dotimes" "doto" "dorun" "doall"
|
||||
"for" "while"
|
||||
"new" "set!" "monitor-enter" "monitor-exit"
|
||||
"->" "->>" "as->" "some->" "some->>"})
|
||||
|
||||
(def ^:private clj-builtins
|
||||
#{"map" "filter" "reduce" "apply" "partial" "comp" "juxt" "complement"
|
||||
"mapv" "filterv" "mapcat" "keep" "remove"
|
||||
"first" "second" "last" "rest" "next" "cons" "conj" "into"
|
||||
"assoc" "dissoc" "update" "get" "get-in" "assoc-in" "update-in" "select-keys"
|
||||
"merge" "merge-with"
|
||||
"atom" "deref" "reset!" "swap!" "compare-and-set!"
|
||||
"str" "subs" "format" "name" "keyword" "symbol"
|
||||
"println" "print" "prn" "pr" "pr-str" "prn-str"
|
||||
"count" "empty?" "seq" "seq?" "sequential?"
|
||||
"vec" "vector" "vector?" "list" "list?" "set" "hash-set" "sorted-set"
|
||||
"hash-map" "sorted-map" "zipmap" "frequencies" "group-by"
|
||||
"keys" "vals" "contains?" "find"
|
||||
"range" "repeat" "repeatedly" "iterate" "cycle" "interleave" "interpose"
|
||||
"take" "drop" "take-while" "drop-while" "split-at" "split-with" "partition"
|
||||
"partition-by" "partition-all"
|
||||
"concat" "flatten" "distinct" "sort" "sort-by" "reverse" "shuffle"
|
||||
"every?" "some" "not-every?" "not-any?"
|
||||
"identity" "constantly"
|
||||
"inc" "dec" "+" "-" "*" "/" "mod" "rem" "quot"
|
||||
"=" "==" "not=" "<" ">" "<=" ">="
|
||||
"zero?" "pos?" "neg?" "even?" "odd?" "number?" "integer?"
|
||||
"nil?" "true?" "false?" "string?" "keyword?" "symbol?" "map?" "coll?" "fn?"
|
||||
"type" "class" "instance?" "satisfies?" "extends?"
|
||||
"meta" "with-meta" "vary-meta"
|
||||
"read-string" "slurp" "spit"
|
||||
"re-find" "re-matches" "re-seq" "re-pattern"
|
||||
"future" "promise" "deliver" "realized?" "pmap"
|
||||
"resolve" "ns-resolve" "eval"
|
||||
"max" "min" "abs" "rand" "rand-int"
|
||||
"nth" "nfirst" "nnext" "fnext" "ffirst"
|
||||
"not-empty" "bounded-count" "transduce" "sequence"
|
||||
"volatile!" "vswap!" "vreset!"
|
||||
"reduced" "reduced?" "unreduced" "ensure-reduced"
|
||||
"ex-info" "ex-data" "ex-message"})
|
||||
|
||||
(def ^:private clj-constants
|
||||
#{"nil" "true" "false"})
|
||||
|
||||
(defn- clj-classify [text]
|
||||
(cond
|
||||
(contains? clj-constants text) (:constant colors)
|
||||
(contains? clj-special-forms text) (:keyword colors)
|
||||
(contains? clj-builtins text) (:builtin colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private clj-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[[";.*" (:comment colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["#\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["\\\\(?:newline|space|tab|backspace|formfeed|return|[a-zA-Z])" (:string colors)]
|
||||
[":[a-zA-Z_*+!?<>=/.\\-][a-zA-Z0-9_*+!?<>=/.\\-:#]*" (:clj-kw colors)]
|
||||
["-?0[xX][0-9a-fA-F]+" (:number colors)]
|
||||
["-?\\d+\\.\\d+" (:number colors)]
|
||||
["-?\\d+/\\d+" (:number colors)]
|
||||
["-?\\d+" (:number colors)]
|
||||
["##(?:Inf|-Inf|NaN)" (:constant colors)]
|
||||
["[a-zA-Z_*+!?<>=/.\\-][a-zA-Z0-9_*+!?<>=/.\\-:#]*" clj-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: JavaScript / TypeScript
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private js-keywords
|
||||
#{"async" "await" "break" "case" "catch" "class" "const" "continue"
|
||||
"debugger" "default" "delete" "do" "else" "export" "extends"
|
||||
"finally" "for" "from" "function" "if" "import" "in" "instanceof"
|
||||
"let" "new" "of" "return" "static" "super" "switch" "this"
|
||||
"throw" "try" "typeof" "var" "void" "while" "with" "yield"
|
||||
;; TS extras
|
||||
"type" "interface" "enum" "namespace" "declare" "implements"
|
||||
"abstract" "as" "readonly" "keyof" "infer"})
|
||||
|
||||
(def ^:private js-builtins
|
||||
#{"console" "Math" "JSON" "Object" "Array" "String" "Number" "Boolean"
|
||||
"Promise" "Map" "Set" "WeakMap" "WeakSet" "Symbol" "Proxy" "Reflect"
|
||||
"parseInt" "parseFloat" "isNaN" "isFinite" "undefined" "NaN" "Infinity"
|
||||
"require" "module" "exports" "process" "Buffer" "global" "window" "document"})
|
||||
|
||||
(def ^:private js-constants
|
||||
#{"true" "false" "null" "undefined" "NaN" "Infinity"})
|
||||
|
||||
(defn- js-classify [text]
|
||||
(cond
|
||||
(contains? js-constants text) (:constant colors)
|
||||
(contains? js-keywords text) (:keyword colors)
|
||||
(contains? js-builtins text) (:builtin colors)
|
||||
(and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private js-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["//.*" (:comment colors)]
|
||||
["/\\*[\\s\\S]*?\\*/" (:comment colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["'(?:[^'\\\\]|\\\\.)*'" (:string colors)]
|
||||
["`(?:[^`\\\\]|\\\\.)*`" (:string colors)]
|
||||
["/(?![*/])(?:[^/\\\\]|\\\\.)+/[gimsuy]*" (:string colors)]
|
||||
["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)]
|
||||
["0[xX][0-9a-fA-F]+" (:number colors)]
|
||||
["\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)]
|
||||
["\\d+" (:number colors)]
|
||||
["[a-zA-Z_$][a-zA-Z0-9_$]*" js-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: Python
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private py-keywords
|
||||
#{"and" "as" "assert" "async" "await" "break" "class" "continue"
|
||||
"def" "del" "elif" "else" "except" "finally" "for" "from"
|
||||
"global" "if" "import" "in" "is" "lambda" "nonlocal" "not"
|
||||
"or" "pass" "raise" "return" "try" "while" "with" "yield"
|
||||
"match" "case"})
|
||||
|
||||
(def ^:private py-builtins
|
||||
#{"print" "len" "range" "int" "str" "float" "list" "dict" "set" "tuple"
|
||||
"bool" "type" "isinstance" "issubclass" "hasattr" "getattr" "setattr"
|
||||
"super" "property" "staticmethod" "classmethod" "enumerate" "zip"
|
||||
"map" "filter" "sorted" "reversed" "any" "all" "min" "max" "sum"
|
||||
"abs" "round" "input" "open" "repr" "id" "hash" "callable" "iter" "next"
|
||||
"ValueError" "TypeError" "KeyError" "IndexError" "RuntimeError"
|
||||
"Exception" "StopIteration" "AttributeError" "ImportError" "OSError"
|
||||
"self" "cls"})
|
||||
|
||||
(def ^:private py-constants
|
||||
#{"True" "False" "None"})
|
||||
|
||||
(defn- py-classify [text]
|
||||
(cond
|
||||
(contains? py-constants text) (:constant colors)
|
||||
(contains? py-keywords text) (:keyword colors)
|
||||
(contains? py-builtins text) (:builtin colors)
|
||||
(and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private py-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["#.*" (:comment colors)]
|
||||
["\"\"\"[\\s\\S]*?\"\"\"" (:string colors)]
|
||||
["'''[\\s\\S]*?'''" (:string colors)]
|
||||
["f\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["f'(?:[^'\\\\]|\\\\.)*'" (:string colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["'(?:[^'\\\\]|\\\\.)*'" (:string colors)]
|
||||
["@[a-zA-Z_][a-zA-Z0-9_.]*" (:param colors)]
|
||||
["0[xX][0-9a-fA-F]+" (:number colors)]
|
||||
["\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)]
|
||||
["\\d+" (:number colors)]
|
||||
["[a-zA-Z_][a-zA-Z0-9_]*" py-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: Java
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private java-keywords
|
||||
#{"abstract" "assert" "boolean" "break" "byte" "case" "catch" "char"
|
||||
"class" "const" "continue" "default" "do" "double" "else" "enum"
|
||||
"extends" "final" "finally" "float" "for" "goto" "if" "implements"
|
||||
"import" "instanceof" "int" "interface" "long" "native" "new"
|
||||
"package" "private" "protected" "public" "return" "short" "static"
|
||||
"strictfp" "super" "switch" "synchronized" "this" "throw" "throws"
|
||||
"transient" "try" "var" "void" "volatile" "while" "yield" "record"
|
||||
"sealed" "permits" "non-sealed"})
|
||||
|
||||
(def ^:private java-constants
|
||||
#{"true" "false" "null"})
|
||||
|
||||
(defn- java-classify [text]
|
||||
(cond
|
||||
(contains? java-constants text) (:constant colors)
|
||||
(contains? java-keywords text) (:keyword colors)
|
||||
(and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private java-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["//.*" (:comment colors)]
|
||||
["/\\*[\\s\\S]*?\\*/" (:comment colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["'(?:[^'\\\\]|\\\\.)*'" (:string colors)]
|
||||
["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)]
|
||||
["0[xX][0-9a-fA-F]+[lL]?" (:number colors)]
|
||||
["\\d+\\.\\d+[fFdD]?" (:number colors)]
|
||||
["\\d+[lLfFdD]?" (:number colors)]
|
||||
["[a-zA-Z_$][a-zA-Z0-9_$]*" java-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: Kotlin
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private kt-keywords
|
||||
#{"abstract" "annotation" "as" "break" "by" "catch" "class" "companion"
|
||||
"const" "constructor" "continue" "crossinline" "data" "do" "else" "enum"
|
||||
"expect" "external" "final" "finally" "for" "fun" "get" "if" "import"
|
||||
"in" "infix" "init" "inline" "inner" "interface" "internal" "is"
|
||||
"lateinit" "noinline" "object" "open" "operator" "out" "override"
|
||||
"package" "private" "protected" "public" "reified" "return" "sealed"
|
||||
"set" "super" "suspend" "tailrec" "this" "throw" "try" "typealias"
|
||||
"val" "var" "vararg" "when" "where" "while" "yield"})
|
||||
|
||||
(def ^:private kt-builtins
|
||||
#{"println" "print" "listOf" "mutableListOf" "mapOf" "mutableMapOf"
|
||||
"setOf" "mutableSetOf" "arrayOf" "intArrayOf" "emptyList" "emptyMap"
|
||||
"require" "check" "error" "TODO" "repeat" "run" "with" "apply" "also" "let"
|
||||
"takeIf" "takeUnless" "lazy" "coroutineScope" "launch" "async"
|
||||
"String" "Int" "Long" "Double" "Float" "Boolean" "Char" "Unit" "Any" "Nothing"})
|
||||
|
||||
(def ^:private kt-constants
|
||||
#{"true" "false" "null"})
|
||||
|
||||
(defn- kt-classify [text]
|
||||
(cond
|
||||
(contains? kt-constants text) (:constant colors)
|
||||
(contains? kt-keywords text) (:keyword colors)
|
||||
(contains? kt-builtins text) (:builtin colors)
|
||||
(and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private kt-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["//.*" (:comment colors)]
|
||||
["/\\*[\\s\\S]*?\\*/" (:comment colors)]
|
||||
["\"\"\"[\\s\\S]*?\"\"\"" (:string colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["'(?:[^'\\\\]|\\\\.)*'" (:string colors)]
|
||||
["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)]
|
||||
["0[xX][0-9a-fA-F]+[lL]?" (:number colors)]
|
||||
["\\d+\\.\\d+[fFdD]?" (:number colors)]
|
||||
["\\d+[lLfFdD]?" (:number colors)]
|
||||
["[a-zA-Z_][a-zA-Z0-9_]*" kt-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: Rust
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private rust-keywords
|
||||
#{"as" "async" "await" "break" "const" "continue" "crate" "dyn"
|
||||
"else" "enum" "extern" "fn" "for" "if" "impl" "in"
|
||||
"let" "loop" "match" "mod" "move" "mut" "pub" "ref"
|
||||
"return" "self" "Self" "static" "struct" "super" "trait" "type"
|
||||
"unsafe" "use" "where" "while" "yield" "macro_rules"})
|
||||
|
||||
(def ^:private rust-builtins
|
||||
#{"println" "eprintln" "format" "vec" "panic" "assert" "assert_eq"
|
||||
"assert_ne" "debug_assert" "todo" "unimplemented" "unreachable"
|
||||
"cfg" "derive" "include" "include_str" "env" "concat" "stringify"
|
||||
"Some" "None" "Ok" "Err" "Box" "Rc" "Arc" "Vec" "String"
|
||||
"Option" "Result" "HashMap" "HashSet" "BTreeMap" "BTreeSet"
|
||||
"Iterator" "IntoIterator" "From" "Into" "TryFrom" "TryInto"
|
||||
"Display" "Debug" "Clone" "Copy" "Default" "PartialEq" "Eq"
|
||||
"PartialOrd" "Ord" "Hash" "Send" "Sync" "Sized" "Drop" "Fn" "FnMut" "FnOnce"})
|
||||
|
||||
(def ^:private rust-constants
|
||||
#{"true" "false"})
|
||||
|
||||
(defn- rust-classify [text]
|
||||
(cond
|
||||
(contains? rust-constants text) (:constant colors)
|
||||
(contains? rust-keywords text) (:keyword colors)
|
||||
(contains? rust-builtins text) (:builtin colors)
|
||||
(and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private rust-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["//.*" (:comment colors)]
|
||||
["/\\*[\\s\\S]*?\\*/" (:comment colors)]
|
||||
["r#\"[^\"]*\"#" (:string colors)]
|
||||
["r\"[^\"]*\"" (:string colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["'[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)] ;; lifetimes
|
||||
["'(?:[^'\\\\]|\\\\.)*'" (:string colors)] ;; char literals
|
||||
["[a-zA-Z_][a-zA-Z0-9_]*!" (:builtin colors)] ;; macros
|
||||
["0[xX][0-9a-fA-F_]+" (:number colors)]
|
||||
["0[bB][01_]+" (:number colors)]
|
||||
["0[oO][0-7_]+" (:number colors)]
|
||||
["\\d[\\d_]*\\.\\d[\\d_]*(?:[eE][+-]?\\d+)?(?:f32|f64)?" (:number colors)]
|
||||
["\\d[\\d_]*(?:u8|u16|u32|u64|u128|usize|i8|i16|i32|i64|i128|isize|f32|f64)?" (:number colors)]
|
||||
["[a-zA-Z_][a-zA-Z0-9_]*" rust-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: Bash
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private bash-keywords
|
||||
#{"if" "then" "else" "elif" "fi" "for" "while" "until" "do" "done"
|
||||
"case" "esac" "in" "function" "select" "time" "coproc"
|
||||
"return" "exit" "break" "continue" "shift" "trap"
|
||||
"local" "export" "declare" "typeset" "readonly" "unset"})
|
||||
|
||||
(def ^:private bash-builtins
|
||||
#{"echo" "printf" "read" "cd" "pwd" "ls" "cp" "mv" "rm" "mkdir" "rmdir"
|
||||
"cat" "grep" "sed" "awk" "find" "sort" "uniq" "wc" "head" "tail"
|
||||
"chmod" "chown" "curl" "wget" "tar" "gzip" "gunzip" "zip" "unzip"
|
||||
"git" "docker" "make" "ssh" "scp" "rsync"
|
||||
"test" "true" "false" "source" "eval" "exec" "set" "env"})
|
||||
|
||||
(defn- bash-classify [text]
|
||||
(cond
|
||||
(contains? bash-keywords text) (:keyword colors)
|
||||
(contains? bash-builtins text) (:builtin colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private bash-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["#.*" (:comment colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["'[^']*'" (:string colors)]
|
||||
["\\$\\{[^}]+\\}" (:param colors)]
|
||||
["\\$[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)]
|
||||
["\\$[0-9@#?!$*-]" (:param colors)]
|
||||
["\\d+" (:number colors)]
|
||||
["[a-zA-Z_][a-zA-Z0-9_]*" bash-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: JSON
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private json-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["\"(?:[^\"\\\\]|\\\\.)*\"\\s*:" (:clj-kw colors)] ;; keys
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["-?\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)]
|
||||
["-?\\d+" (:number colors)]
|
||||
["\\b(?:true|false)\\b" (:constant colors)]
|
||||
["\\bnull\\b" (:constant colors)]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language: Generic (Go, C, C++, Ruby, CSS, etc.)
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private generic-keywords
|
||||
#{"if" "else" "for" "while" "do" "switch" "case" "default" "break"
|
||||
"continue" "return" "goto" "try" "catch" "throw" "finally"
|
||||
"class" "struct" "enum" "interface" "extends" "implements"
|
||||
"public" "private" "protected" "static" "const" "final" "abstract"
|
||||
"virtual" "override" "new" "delete" "this" "self" "super"
|
||||
"import" "export" "package" "module" "use" "require" "include"
|
||||
"void" "int" "long" "float" "double" "char" "bool" "string"
|
||||
"var" "let" "val" "def" "fn" "func" "fun" "function"
|
||||
"type" "typedef" "namespace" "template" "typename"
|
||||
"async" "await" "yield" "defer" "select" "chan" "go"
|
||||
"begin" "end" "then" "elsif" "unless" "rescue" "ensure" "raise"})
|
||||
|
||||
(def ^:private generic-constants
|
||||
#{"true" "false" "nil" "null" "none" "None" "True" "False" "NULL"
|
||||
"undefined" "NaN" "Infinity"})
|
||||
|
||||
(defn- generic-classify [text]
|
||||
(cond
|
||||
(contains? generic-constants text) (:constant colors)
|
||||
(contains? generic-keywords text) (:keyword colors)
|
||||
(and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors)
|
||||
:else nil))
|
||||
|
||||
(def ^:private generic-rules
|
||||
(mapv (fn [[re c]] [(Pattern/compile re) c])
|
||||
[["//.*" (:comment colors)]
|
||||
["#.*" (:comment colors)]
|
||||
["/\\*[\\s\\S]*?\\*/" (:comment colors)]
|
||||
["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)]
|
||||
["'(?:[^'\\\\]|\\\\.)*'" (:string colors)]
|
||||
["`(?:[^`\\\\]|\\\\.)*`" (:string colors)]
|
||||
["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)]
|
||||
["0[xX][0-9a-fA-F]+" (:number colors)]
|
||||
["\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)]
|
||||
["\\d+" (:number colors)]
|
||||
["[a-zA-Z_][a-zA-Z0-9_]*" generic-classify]]))
|
||||
|
||||
;; ============================================================
|
||||
;; Language Registry
|
||||
;; ============================================================
|
||||
|
||||
(def ^:private lang-rules
|
||||
{:clojure clj-rules
|
||||
:javascript js-rules
|
||||
:python py-rules
|
||||
:java java-rules
|
||||
:kotlin kt-rules
|
||||
:rust rust-rules
|
||||
:bash bash-rules
|
||||
:json json-rules
|
||||
:generic generic-rules})
|
||||
|
||||
(def ^:private fence-tag->lang
|
||||
{"clojure" :clojure "clj" :clojure "cljs" :clojure "edn" :clojure
|
||||
"javascript" :javascript "js" :javascript "typescript" :javascript "ts" :javascript
|
||||
"jsx" :javascript "tsx" :javascript
|
||||
"python" :python "py" :python
|
||||
"java" :java
|
||||
"kotlin" :kotlin "kt" :kotlin
|
||||
"rust" :rust "rs" :rust
|
||||
"bash" :bash "sh" :bash "shell" :bash "zsh" :bash
|
||||
"json" :json "jsonc" :json
|
||||
"go" :generic "c" :generic "cpp" :generic "c++" :generic
|
||||
"ruby" :generic "rb" :generic
|
||||
"css" :generic "scss" :generic "less" :generic
|
||||
"html" :generic "xml" :generic "svg" :generic
|
||||
"yaml" :generic "yml" :generic "toml" :generic
|
||||
"sql" :generic "graphql" :generic "gql" :generic
|
||||
"lua" :generic "perl" :generic "r" :generic
|
||||
"swift" :generic "scala" :generic "groovy" :generic
|
||||
"haskell" :generic "hs" :generic "elixir" :generic "ex" :generic
|
||||
"erlang" :generic "erl" :generic
|
||||
"zig" :generic "nim" :generic "ocaml" :generic "ml" :generic
|
||||
"dart" :generic "php" :generic
|
||||
"dockerfile" :generic "makefile" :generic
|
||||
"diff" :generic "patch" :generic})
|
||||
|
||||
(def ^:private ext->lang
|
||||
{".clj" :clojure ".cljs" :clojure ".cljc" :clojure ".edn" :clojure ".bb" :clojure
|
||||
".js" :javascript ".jsx" :javascript ".ts" :javascript ".tsx" :javascript ".mjs" :javascript
|
||||
".py" :python ".pyw" :python
|
||||
".java" :java
|
||||
".kt" :kotlin ".kts" :kotlin
|
||||
".rs" :rust
|
||||
".sh" :bash ".bash" :bash ".zsh" :bash
|
||||
".json" :json ".jsonc" :json
|
||||
".go" :generic ".c" :generic ".h" :generic ".cpp" :generic ".hpp" :generic ".cc" :generic
|
||||
".rb" :generic ".css" :generic ".scss" :generic ".less" :generic
|
||||
".html" :generic ".xml" :generic ".svg" :generic
|
||||
".yaml" :generic ".yml" :generic ".toml" :generic
|
||||
".sql" :generic ".lua" :generic ".pl" :generic ".r" :generic
|
||||
".swift" :generic ".scala" :generic ".groovy" :generic
|
||||
".hs" :generic ".ex" :generic ".exs" :generic ".erl" :generic
|
||||
".zig" :generic ".nim" :generic ".ml" :generic
|
||||
".dart" :generic ".php" :generic})
|
||||
|
||||
;; ============================================================
|
||||
;; Public API
|
||||
;; ============================================================
|
||||
|
||||
(defn lang-for-fence
|
||||
"Map a code fence tag (e.g. \"clojure\", \"js\") to a language keyword."
|
||||
[tag]
|
||||
(when tag
|
||||
(get fence-tag->lang (str/lower-case (str/trim tag)))))
|
||||
|
||||
(defn lang-for-ext
|
||||
"Map a file extension (e.g. \".clj\", \".rs\") to a language keyword."
|
||||
[ext]
|
||||
(when ext
|
||||
(get ext->lang (str/lower-case ext))))
|
||||
|
||||
(defn highlight-line
|
||||
"Syntax-highlight a single line of code. Returns string with ANSI fg codes.
|
||||
`lang` — keyword like :clojure, :javascript, etc. (nil = no highlighting)
|
||||
`default-fg` — ANSI code for unhighlighted text (\"\" for terminal default,
|
||||
or e.g. \"\\033[38;5;210m\" for diff removed lines).
|
||||
Caller should append \\033[0m after the returned string."
|
||||
[line lang default-fg]
|
||||
(if-let [rules (get lang-rules lang)]
|
||||
(str default-fg (highlight-line* line rules (or default-fg "")))
|
||||
line))
|
||||
Reference in New Issue
Block a user