Compare commits

...

37 Commits

Author SHA1 Message Date
Ed Zynda 4ae03aab7c refactor: move extension testing package to pkg/
Move the extension testing package from internal/extensions/test to
pkg/extensions/test to make it publicly importable by external extension
authors.

Changes:
- Moved test package files to pkg/extensions/test/
- Updated all imports from internal/ to pkg/ path:
  - README.md
  - examples/extensions/tool-logger_test.go
  - examples/extensions/extension_test_template.go
  - skills/kit-extensions/SKILL.md
  - www/pages/extensions/testing.md
  - pkg/extensions/test/README.md
  - pkg/extensions/test/harness.go

The test package is now available for external import as:
  github.com/mark3labs/kit/pkg/extensions/test

All tests pass with race detector.
2026-03-20 13:40:15 +03:00
Ed Zynda 93895392e6 docs: add PromptMultiSelect and full SpawnSubagent API to skill guide
Add missing PromptMultiSelect example to Interactive Prompts section.
Replace the minimal subprocess pattern with comprehensive SpawnSubagent
documentation including blocking/background modes, all SubagentConfig
fields, SubagentResult fields, SubagentEvent types, and handle methods.
2026-03-20 13:28:00 +03:00
Ed Zynda 473070e78b docs: add theme API to kit-extensions skill guide (correct file)
Add Themes section to Context API reference with RegisterTheme,
SetTheme, ListThemes examples and ThemeColorConfig field reference.
Add Custom Theme with Slash Command pattern to Common Patterns.
Remove mistakenly committed .agents/skills copy.
2026-03-20 13:24:48 +03:00
Ed Zynda 12268a777f docs: add theme API to kit-extensions skill guide
Add Themes section to Context API reference with RegisterTheme,
SetTheme, ListThemes examples and ThemeColorConfig field reference.
Add Custom Theme with Slash Command pattern to Common Patterns.
2026-03-20 13:21:25 +03:00
Ed Zynda 351c10d814 docs: update SKILL.md with extension testing documentation
Add comprehensive testing documentation to the kit-extensions skill:

- Add code example showing basic test structure with LoadFile(), Emit(), and assertions
- Document key testing patterns (LoadFile vs LoadString, event emission, assertions)
- List 25+ assertion helpers available in test package
- Reference tool-logger_test.go as complete example with 14 test cases
- Add link to internal/extensions/test/ in Key Files section
- Maintain existing CLI testing commands section

The skill now provides complete guidance for testing extensions alongside development.
2026-03-20 13:19:08 +03:00
Ed Zynda 9de3843605 docs: add extension testing documentation
Add comprehensive documentation for the extension testing package:

- README.md: Add 'Testing Extensions' section with basic usage and links
- www/pages/extensions/testing.md: Complete testing guide with:
  - Overview and basic usage
  - Common testing patterns (handlers, tools, widgets, etc.)
  - Available assertions reference table
  - Advanced usage (mock context access, multiple extensions)
  - Best practices and limitations
  - Links to complete examples
- www/pages/extensions/examples.md: Add test examples and template references
- www/pages/extensions/overview.md: Link to testing documentation

All documentation cross-references existing test files and examples.
2026-03-20 13:18:46 +03:00
Ed Zynda 1d5473e111 chore: remove unnecessary test/ ignore rule
Remove the test/ directory ignore rule from .gitignore since
there is no root-level test directory in the project.

The internal/extensions/test/ package remains tracked as expected.
2026-03-20 13:17:22 +03:00
Ed Zynda b6adcf159e feat: add extension testing package
Add comprehensive testing utilities for Kit extensions:

- internal/extensions/test/: New test package with:
  - harness.go: Test harness for loading extensions into Yaegi
  - mock.go: Mock context that records all context interactions
  - assert.go: 20+ assertion helpers (AssertBlocked, AssertWidgetSet, etc.)
  - harness_test.go: 18 comprehensive test examples
  - README.md: Complete documentation with usage examples

- internal/extensions/test_api.go: Helper function for creating test API objects

- examples/extensions/tool-logger_test.go: 14 tests demonstrating real extension testing

- examples/extensions/extension_test_template.go: Copy-and-paste template for extension authors

- .gitignore: Allow internal/extensions/test/ directory

All 93 tests pass including race detector.
2026-03-20 13:16:11 +03:00
Ed Zynda b1da4a28e6 docs: add comprehensive theming documentation
Add dedicated themes page (www/pages/themes.md) covering built-in
themes, custom theme files, config integration, extension API, and
precedence rules. Update README with theming section and feature
listing. Add /theme to CLI commands reference, theme config to
configuration docs, and RegisterTheme/SetTheme/ListThemes to
extension capabilities. Add neon-theme to examples index.
2026-03-20 13:15:20 +03:00
Ed Zynda 95abb6fa6e feat: add extension API for programmatic theme registration and switching
Add RegisterTheme, SetTheme, and ListThemes to the extension Context,
allowing extensions to create custom themes at runtime and switch
between them. Uses ThemeColor/ThemeColorConfig concrete structs (no
interfaces) for Yaegi safety.

Include neon-theme.go example extension demonstrating the API.
2026-03-20 13:03:23 +03:00
Ed Zynda a9970cf346 feat: add /theme command with 22 built-in themes and file-based discovery
Add a theme registry that discovers themes from three sources (in
precedence order): built-in presets, user dir (~/.config/kit/themes/),
and project-local dir (.kit/themes/). Later sources override earlier
ones with the same name.

Ship 22 built-in presets ported from the OpenCode theme collection:
amoled, ayu, catppuccin, dracula, everforest, flexoki, github,
gruvbox, kanagawa, kitt, material, matrix, monokai, nord, one-dark,
rose-pine, solarized, synthwave, tokyonight, vercel, vesper, zenburn.

Add /theme slash command with tab-completion for listing available
themes and switching at runtime. Also fix .yml extension handling
in config.FilepathOr.
2026-03-20 12:54:16 +03:00
Ed Zynda 13060a20f9 feat: add KITT-inspired theme system with unified markdown colors
Replace the Catppuccin color palette with a Knight Rider KITT-inspired
theme — scanner reds, amber dashboard glows, and dark cockpit tones.
No blues or bright greens; the entire palette stays in the warm
red/amber/gray family.

Unify the theme system by folding the standalone MarkdownTheme config
into the main Theme struct, eliminating the separate config path.
Replace all hardcoded lipgloss.Color() calls across input, overlay,
and CLI components with semantic theme references so every color
responds to theme customization.
2026-03-19 18:04:56 +03:00
Ed Zynda adf603e944 fix: add favicon link to index.html 2026-03-19 17:29:35 +03:00
Ed Zynda af486133a5 chore: remove dead code, unexport internal symbols, clean up stale comments
- Remove never-called functions: ListChildSessions, NewMessageEntryFromRaw,
  ProviderPool.Stats/PoolStats, CLI.DisplayToolCallMessage
- Remove deprecated ValidateModel (migrate callers to LookupModel)
- Remove deprecated colon-separated model format shim
- Unexport package-internal symbols: EstimateTokens, GetRequiredEnvVars,
  GeneratePKCE, ErrNoClipboardTool, ThinkingBudgetTokens, NewMessageRenderer
- Remove stale TAS-15/TAS-16 placeholder comments (both fully implemented)
- Fix misleading 'temporary approach' comment in clipboard_darwin.go
- Replace interface{} with any in extension examples
- Simplify auto-commit.go dead variable (CombinedOutput → Run)
2026-03-19 17:25:53 +03:00
Ed Zynda a97cd47ced docs: add GitHub source links to extension examples page 2026-03-19 17:11:14 +03:00
Ed Zynda 68518a2bdb docs: add bun and pnpm as installation options 2026-03-19 17:02:09 +03:00
Ed Zynda fd61db3e12 chore: commit .tome/ as Tome intends, remove CI workaround 2026-03-19 16:48:38 +03:00
Ed Zynda e49066a119 chore: gitignore .tome/ and generate entry in CI instead 2026-03-19 16:46:59 +03:00
Ed Zynda efaff7f44f fix: include .tome/entry.tsx for CI builds 2026-03-19 16:42:04 +03:00
Ed Zynda d3c970b607 chore: set baseUrl to go-kit.dev 2026-03-19 16:30:08 +03:00
Ed Zynda 23254fee64 feat: add static docs site using Tome with GitHub Pages deployment
Scaffold Tome docs site in www/ with 17 pages covering installation,
configuration, CLI reference, extensions, sessions, Go SDK, and advanced
usage. Custom Knight Rider theme (cipher base + red accent, dark mode,
Space Grotesk fonts). GitHub Pages workflow deploys via Bun on push to
master.
2026-03-19 16:27:35 +03:00
Ed Zynda fe072ad2e1 fix: correct README inaccuracies and document missing features
Fix critical errors: MIT license (was Apache 2.0), broken CONTRIBUTING.md
link, wrong session path, nonexistent SDK methods (SaveSession/LoadSession).
Add missing CLI flag (--thinking-level), commands (install, skill),
spawn_subagent tool, 3 lifecycle events, 17 extension examples, 8 extension
capabilities, 7 internal directories, and complete JSON output schema.
2026-03-19 15:49:55 +03:00
Ed Zynda 8840cbfabc feat: show spinner during shell command execution (! and !!)
Shell commands (! and !!) now display the KITT spinner animation while
running, matching the behavior of LLM agent steps and compaction.

On shellCommandMsg: transition to stateWorking and start the spinner.
On shellCommandResultMsg: stop the spinner and return to stateInput.
2026-03-18 17:48:15 +03:00
Ed Zynda a11b41cda4 fix: prevent duplicate spinner tick loops causing double-speed animation
The KITT spinner animation would sometimes run at 2x (or higher) speed
because multiple concurrent tick loops could accumulate in the Bubble Tea
command queue.

The problematic sequence: SpinnerEvent{Show:true} starts tick loop A,
SpinnerEvent{Show:false} sets spinning=false but tick A is still
in-flight, then ToolExecutionEvent restarts spinning and starts tick
loop B. When the stale tick A fires, spinning is true again so it
continues — now two loops advance spinnerFrame simultaneously.

Fix: add a generation counter (uint64) to StreamComponent. Each
streamSpinnerTickMsg carries the generation it was created for. The tick
handler only processes ticks matching the current generation — stale
ticks from previous start/stop cycles are silently discarded.

Generation is bumped on every spinner start, stop, and Reset() to
ensure at most one tick loop is ever active.
2026-03-18 17:44:11 +03:00
Ed Zynda 8b7be8b735 fix: use immediate parent dir for main.go extension names
deriveExtensionName was using the full directory path (e.g.
examples/extensions/kit-telegram) to derive the display name for
main.go extensions, producing verbose names like 'Examples Extensions
Kit Telegram Extension'. Now uses filepath.Base(dir) so only the
immediate parent directory is used, giving 'Kit Telegram Extension'.

Also fix TestLoadExtensions_SkipsBadFiles which was flaky when
globally-installed git packages existed — isolate the test from the
host environment by overriding XDG_CONFIG_HOME, XDG_DATA_HOME, and
the working directory.
2026-03-18 17:36:06 +03:00
Ed Zynda caa6d1c178 Add kit-telegram example extension
- Copy Telegram relay extension from ../kit-telegram
- Add README with quickstart, commands, API reference, and architecture
- Update examples/extensions/README.md with Integrations section and details
2026-03-18 17:14:47 +03:00
Ed Zynda 001156053d chore: untrack .agents and skills-lock.json, update skills SKILL.md 2026-03-18 17:05:03 +03:00
Ed Zynda 54717e32bc refactor: Auto-show multi-select when repo has multiple extensions
Remove --select flag. Multi-select now appears automatically when a repo
contains more than one extension. Add --all flag to skip selection.
2026-03-18 16:53:42 +03:00
Ed Zynda 5b214b9fdf refactor: Use huh for CLI prompts, fix extension discovery in mixed repos
- Replace custom multi-select with huh.NewMultiSelect for kit install --select
- Replace raw bufio prompts in cmd/auth.go with huh.NewConfirm and huh.NewInput
- Fix extension discovery to use opinionated conventions (only scan root,
  extensions/, ext/, examples/extensions/ directories, skip cmd/internal/pkg/)
- Fix loader to use same convention-based scanning for installed git repos
- Fix errcheck lint warning in loader.go
2026-03-18 16:49:48 +03:00
Ed Zynda c5e6ca6e4d feat: Add kit install command for git-based extension distribution
Add comprehensive extension installation system for Kit:

Features:
- kit install <git-url> - Install extensions from git repos
- kit install <url> --local - Install to project .kit/git/ directory
- kit install <url> --select - Interactive selection for multi-extension repos
- kit install <url> --update - Update installed extensions
- kit install <url> --uninstall - Remove installed extensions
- Version pinning via @ref (tags, branches, commits)
- Support multiple URL formats (shorthand, git:, https, ssh)

Implementation:
- internal/extensions/installer.go - Git clone, checkout, validation
- internal/extensions/manifest.go - Package tracking with Include filtering
- internal/extensions/loader.go - Respect Include field when loading
- cmd/install.go - Cobra command with interactive prompts
- PromptMultiSelectConfig API - Multi-select prompts for extensions

Storage:
- Global: ~/.local/share/kit/git/<host>/<owner>/<repo>/
- Project: .kit/git/<host>/<owner>/<repo>/
- Manifests: packages.json tracking installed packages

Examples:
- Reorganized examples/extensions/ with README.md
- Added status-tools/ multi-file extension example
- Created comprehensive install guide in SKILL.md

Testing:
- Added installer_test.go with 15+ test cases
- All tests pass, build clean

Closes #extension-distribution
2026-03-18 16:21:31 +03:00
Ed Zynda 419a139137 fix: make TUI responsive for terminal resizing at any dimension
Prevent layout corruption and visual breakage when the terminal is
resized to narrow or short dimensions:

- Status bar: progressively drops middle/right sections instead of
  wrapping to multiple lines (broke height calculation)
- Autocomplete popup: guard against negative widths, truncate names
  before rendering to prevent text wrapping inside fixed-width columns,
  adapt to name-only mode at very narrow widths
- Tree selector: use full height minus chrome instead of halved height,
  guard against negative width in node truncation
- Model selector: rune-aware name truncation preserving provider tags,
  width-adaptive entry rendering
- Overlay dialog: clamp dimensions to terminal bounds instead of using
  fixed minimums that could exceed the terminal
- Input hint, popup footer, and all help text: tiered adaptive variants
  for different terminal widths
- Queued messages: measure actual rendered height instead of fixed
  5-line-per-message estimate
2026-03-18 14:52:43 +03:00
Ed Zynda 7b963624c1 fix: ensure all message blocks appear below previous content in scrollback
tea.Println inserts above BubbleTea's managed region, but after
StepCompleteEvent the previous response stays in the stream component
(managed region). Any subsequent print (tool results, shell commands,
slash output, errors) would appear above that response — out of order.

Introduce a scrollback buffer: all print helpers now buffer rendered
content via appendScrollback(). At the end of each Update cycle,
drainScrollback() combines everything into a single tea.Println. If
the stream component has unflushed content it is auto-prepended, so
new messages always appear below the previous assistant response.
2026-03-18 14:16:37 +03:00
Ed Zynda 66f2ba543b refactor: align message styling with iteratr conventions
Swap user/assistant border colors (user=blue, assistant=mauve), remove
per-message timestamps and username labels, simplify system messages to
borderless muted text with diamond prefix, change tool name color from
peach to blue, and redesign thinking blocks with surface background,
line truncation, and duration footer.
2026-03-17 15:11:33 +03:00
Ed Zynda 6dd052b990 fix: improve input keybindings, user message rendering, and scrollback ordering
- Change newline keybinding from alt+enter to shift+enter across all
  input components (main input, slash command input, prompt overlay)
- Skip markdown rendering for plain-text user messages so newlines are
  preserved without extra paragraph spacing from glamour
- Fix scrollback ordering: defer queued user message printing to
  SpinnerEvent where previous stream content is guaranteed complete,
  combining flush + user message into a single tea.Println call
2026-03-17 14:23:16 +03:00
Ed Zynda ef8628eecc fix: forward subagent events to parent event bus in core spawn_subagent tool
The spawner closure in generate() called m.Subagent() without setting
OnEvent, so child events (tool calls, text streaming, reasoning deltas)
were silently discarded. Wire OnEvent to re-emit on the parent's bus,
matching the behavior already present in the extension SpawnSubagent path.
2026-03-17 13:03:41 +03:00
Ed Zynda 3167222b72 fix: gracefully recover from bad model names in subagents
If the requested model fails (bad name, unsupported provider), fall
back to the parent's model instead of returning a hard error. The
original prompt is prepended with a note so the agent knows which
model is actually running and can adjust future calls.
2026-03-16 13:43:52 +03:00
Ed Zynda e3b37191b1 fix: inherit parent provider for bare model names in subagents
When spawn_subagent is called with a model name like 'claude-haiku'
(no provider prefix), prepend the parent's provider instead of letting
ParseModelString guess. Only full 'provider/model' strings bypass this.
2026-03-16 13:41:02 +03:00
101 changed files with 13433 additions and 1748 deletions
-64
View File
@@ -1,64 +0,0 @@
---
name: btca-cli
description: Operate the btca CLI for local resources and source-first answers. Use when setting up btca in a project, connecting a provider, adding or managing resources, and asking questions via btca commands. Invoke this skill when the user says "use btca" or needs to do more detailed research on a specific library or framework.
---
# btca CLI
`btca` is a source-first research CLI. It hydrates resources (git, local, npm) into searchable context, then answers questions grounded in those sources. Use configured resources for ongoing work, or one-off anonymous resources directly in `btca ask`.
Full CLI reference: https://docs.btca.dev/guides/cli-reference
Add resources:
```bash
# Git resource
btca add -n svelte-dev https://github.com/sveltejs/svelte.dev
# Local directory
btca add -n my-docs -t local /absolute/path/to/docs
# npm package
btca add npm:@types/node@22.10.1 -n node-types -t npm
```
Verify resources:
```bash
btca resources
```
Ask a question:
```bash
btca ask -r svelte-dev -q "How do I define remote functions?"
```
## Common Tasks
- Ask with multiple resources:
```bash
btca ask -r react -r typescript -q "How do I type useState?"
```
- Ask with anonymous one-off resources (not saved to config):
```bash
# One-off git repo
btca ask -r https://github.com/sveltejs/svelte -q "Where is the implementation of writable stores?"
# One-off npm package
btca ask -r npm:react@19.0.0 -q "How is useTransition exported?"
```
## Config Overview
- Config lives in `btca.config.jsonc` (project) and `~/.config/btca/btca.config.jsonc` (global).
- Project config overrides global and controls provider/model and resources.
## Troubleshooting
- "No resources configured": add resources with `btca add ...` and re-run `btca resources`.
- "Provider not connected": run `btca connect` and follow the prompts.
- "Unknown resource": use `btca resources` for configured names, or pass a valid HTTPS git URL / `npm:<package>` as an anonymous one-off in `btca ask`.
@@ -1,3 +0,0 @@
interface:
display_name: "BTCA CLI"
short_description: "Help with BTCA CLI setup and usage workflows"
-853
View File
@@ -1,853 +0,0 @@
---
name: kit-extensions
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
---
# Kit Extensions Development Guide
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
## Extension Structure
Every extension must export a `package main` with an `Init(api ext.API)` function:
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
// Register event handlers, tools, commands, etc.
}
```
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
## Extension Locations
Extensions are auto-loaded from these directories:
- `~/.config/kit/extensions/*.go` (global, single files)
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
- `.kit/extensions/*.go` (project-local, single files)
- `.kit/extensions/*/main.go` (project-local, subdirectories)
Or loaded explicitly:
```bash
kit -e path/to/extension.go
kit --extension path/to/extension.go
```
## Import Path
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
## API Overview
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
---
## Lifecycle Events
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
### Session Events
```go
// Fired when session is loaded/created.
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
// e.SessionID string
})
// Fired when Kit is shutting down. Use for cleanup.
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
// No fields.
})
```
### Agent Turn Events
```go
// Before agent starts processing. Can inject system prompt or text.
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
// e.Prompt string
// Return nil to pass through.
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
return nil
})
// Agent loop has started.
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
// e.Prompt string
})
// Agent finished responding.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// e.Response string
// e.StopReason string — "completed", "cancelled", "error"
})
```
### Tool Events
```go
// Before a tool executes. Can block the call.
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
// e.ToolName string
// e.ToolCallID string
// e.Input string — JSON-encoded parameters
// e.Source string — "llm" or "user"
// Return nil to allow.
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
return nil
})
// Tool execution started (informational only).
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
// e.ToolName string
})
// Tool execution ended (informational only).
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
// e.ToolName string
})
// After a tool returns. Can modify the result.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
// e.ToolName string
// e.Input string
// e.Content string
// e.IsError bool
// Return nil to pass through.
// Return &ext.ToolResultResult{Content: &s} to replace content.
// Return &ext.ToolResultResult{IsError: &b} to change error status.
return nil
})
```
### Input Events
```go
// User submitted input. Can handle or transform it.
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
// e.Text string
// e.Source string — "interactive", "cli", "script", "queue"
// Return nil to pass through to agent.
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
return nil
})
```
### Streaming Events
```go
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
// e.Chunk string — streaming text chunk
})
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
// e.Content string — full message content
})
```
### Model Events
```go
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
// e.NewModel string
// e.PreviousModel string
// e.Source string — "extension" or "user"
})
```
### Context Filtering
```go
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
// e.Messages []ext.ContextMessage
// Each ContextMessage has: Index int, Role string, Content string
// Index -1 means a new injected message (not from session).
// Return nil to pass through.
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
return nil
})
```
### Session Control Events
```go
// Before forking the session tree. Can cancel.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
// e.TargetID string, e.IsUserMessage bool, e.UserText string
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
})
// Before switching/clearing session. Can cancel.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
// e.Reason string — "new" or "clear"
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
})
// Before context compaction. Can cancel.
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
// e.EstimatedTokens, e.ContextLimit int
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
})
```
### Custom Events
```go
// Subscribe to custom events emitted by other extensions.
api.OnCustomEvent("event-name", func(data string) {
// data is arbitrary string payload
})
// Emit from Context:
ctx.EmitCustomEvent("event-name", "payload")
```
---
## Registering Tools
Tools are functions the LLM can invoke:
```go
api.RegisterTool(ext.ToolDef{
Name: "current_time",
Description: "Get the current date and time",
Parameters: `{"type":"object","properties":{}}`,
Execute: func(input string) (string, error) {
return time.Now().Format(time.RFC3339), nil
},
})
```
For long-running tools with cancellation and progress:
```go
api.RegisterTool(ext.ToolDef{
Name: "slow_task",
Description: "A long-running task with progress reporting",
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
for i := 0; i < 10; i++ {
if tc.IsCancelled() {
return "cancelled", nil
}
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
time.Sleep(time.Second)
}
return "done", nil
},
})
```
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
---
## Registering Slash Commands
Commands are user-facing actions invoked with `/name` in the input:
```go
api.RegisterCommand(ext.CommandDef{
Name: "echo",
Description: "Echo back the provided text",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.PrintInfo("You said: " + args)
return "", nil
},
// Optional tab-completion:
Complete: func(prefix string, ctx ext.Context) []string {
return []string{"hello", "world"}
},
})
```
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
---
## Registering Keyboard Shortcuts
```go
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handler runs when shortcut is pressed
})
```
---
## Registering Options
Options are configurable values resolved from env vars, config, or defaults:
```go
api.RegisterOption(ext.OptionDef{
Name: "my-setting",
Description: "Controls something",
Default: "false",
})
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
val := ctx.GetOption("my-setting")
// Set at runtime:
ctx.SetOption("my-setting", "true")
```
---
## Context API Reference
The `ext.Context` struct provides runtime capabilities via function fields.
### Output
```go
ctx.Print("plain text") // plain output
ctx.PrintInfo("styled info block") // bordered info block
ctx.PrintError("styled error block") // red error block
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
Text: "content",
BorderColor: "#a6e3a1",
Subtitle: "my-ext",
})
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
```
### Message Injection
```go
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
```
### Widgets
Persistent UI elements displayed above or below the input area:
```go
ctx.SetWidget(ext.WidgetConfig{
ID: "my-widget",
Placement: ext.WidgetAbove, // or ext.WidgetBelow
Content: ext.WidgetContent{
Text: "Status: Active",
Markdown: false, // set true for markdown rendering
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1", // hex color
NoBorder: false,
},
Priority: 0, // lower values render first
})
ctx.RemoveWidget("my-widget")
```
### Header and Footer
```go
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Header"},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
ctx.RemoveHeader()
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Footer"},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
ctx.RemoveFooter()
```
### Status Bar
```go
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
ctx.RemoveStatus("key")
```
### Interactive Prompts
These block until the user responds (safe in slash commands and goroutines):
```go
// Selection list
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Pick one:",
Options: []string{"Option A", "Option B", "Option C"},
})
if !result.Cancelled {
// result.Value string, result.Index int
}
// Yes/No confirmation
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure?",
DefaultValue: false,
})
if !result.Cancelled {
// result.Value bool
}
// Text input
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter name:",
Placeholder: "my-project",
Default: "",
})
if !result.Cancelled {
// result.Value string
}
```
### Overlay Dialogs
Modal dialogs with optional action buttons:
```go
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Confirmation",
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
Width: 60, // 0 = 60% of terminal width
MaxHeight: 20, // 0 = 80% of terminal height
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
Actions: []string{"Confirm", "Cancel"},
})
if !result.Cancelled {
// result.Action string, result.Index int
}
```
### Editor Interceptor
Wrap the built-in text input with custom key handling and rendering:
```go
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
if key == "ctrl+s" {
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
Render: func(width int, defaultContent string) string {
return "[custom] " + defaultContent
},
})
ctx.ResetEditor() // remove interceptor
ctx.SetEditorText("prefilled") // set editor text content
```
**EditorKeyAction types:**
- `ext.EditorKeyPassthrough` — let the default editor handle the key
- `ext.EditorKeyConsumed` — swallow the key, do nothing
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
### UI Visibility
```go
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
```
### Session Data
```go
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
path := ctx.GetSessionPath() // file path of session JSONL
// Persist custom data in the session tree:
id, err := ctx.AppendEntry("my-type", "data string")
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
```
### Model Management
```go
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
```
### Tool Management
```go
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
ctx.SetActiveTools(nil) // re-enable all tools
```
### LLM Completions
Make standalone LLM calls (bypasses the agent tool loop):
```go
resp, err := ctx.Complete(ext.CompleteRequest{
Model: "", // empty = current model
System: "You are ...", // optional system prompt
Prompt: "Summarize...", // the prompt
MaxTokens: 1000, // 0 = provider default
OnChunk: func(chunk string) { /* streaming */ },
})
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
```
### TUI Suspension
Temporarily release the terminal for interactive subprocesses:
```go
ctx.SuspendTUI(func() {
cmd := exec.Command("vim", "file.go")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
```
### Application Control
```go
ctx.Exit() // graceful shutdown
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
```
### Context Fields
```go
ctx.SessionID // string
ctx.CWD // string — current working directory
ctx.Model // string — active model name
ctx.Interactive // bool — true if running in TUI mode
```
---
## Tool Renderers
Customize how tool calls are displayed in the TUI:
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell", // replaces auto-capitalized name
BorderColor: "#89b4fa",
Background: "",
BodyMarkdown: true, // render body through markdown
RenderHeader: func(toolArgs string, width int) string {
var args struct{ Command string `json:"command"` }
json.Unmarshal([]byte(toolArgs), &args)
return "$ " + args.Command
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "ERROR: " + toolResult
}
return toolResult
},
})
```
## Message Renderers
Define named output styles for `ctx.RenderMessage()`:
```go
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
return " " + content // green checkmark prefix
},
})
// Usage in handlers:
ctx.RenderMessage("success", "All tests passed")
```
---
## Critical Yaegi Constraints
### No Named Function References in Struct Fields
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
```go
// WRONG - will silently return zero values:
func myHandler(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
// CORRECT - use anonymous closure:
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
```
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
### No Interfaces Across the Boundary
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
### Package-Level Variables for State
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
```go
package main
import "kit/ext"
var callCount int
var lastTool string
func Init(api ext.API) {
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
callCount++
lastTool = e.ToolName
return nil
})
}
```
---
## Common Patterns
### Pattern: Tool Call Blocking
Block dangerous operations by intercepting tool calls:
```go
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "bash" {
var input struct{ Command string `json:"command"` }
json.Unmarshal([]byte(tc.Input), &input)
if strings.Contains(input.Command, "rm -rf") {
return &ext.ToolCallResult{
Block: true,
Reason: "Dangerous command blocked",
}
}
}
return nil
})
```
### Pattern: System Prompt Injection
Augment the agent's behavior by injecting instructions:
```go
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
prompt := "Always respond with bullet points."
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
```
### Pattern: Background Processing with SendMessage
Run work in a goroutine and inject results back:
```go
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command in the background",
Execute: func(args string, ctx ext.Context) (string, error) {
go func() {
out, err := exec.Command("sh", "-c", args).CombinedOutput()
if err != nil {
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
return
}
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
}()
return "Running in background...", nil
},
})
```
### Pattern: Ephemeral Context Injection
Inject information into every LLM turn without persisting in session history:
```go
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
data, err := os.ReadFile(".kit/context.md")
if err != nil {
return nil
}
injected := ext.ContextMessage{
Index: -1, // -1 = new message, not from session
Role: "system",
Content: string(data),
}
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
```
### Pattern: Live Widget Updates
Update a widget periodically from a goroutine:
```go
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetWidget(ext.WidgetConfig{
ID: "clock",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
}()
})
```
### Pattern: Spawning Kit as a Sub-Agent
Extensions can spawn Kit as a subprocess for delegation:
```bash
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
---
## Testing Extensions
```bash
# Validate syntax of all discovered extensions
kit extensions validate
# List loaded extensions
kit extensions list
# Run with a specific extension
kit -e path/to/extension.go
# Run with multiple extensions
kit -e ext1.go -e ext2.go
# Disable all extensions
kit --no-extensions
# Generate an example extension scaffold
kit extensions init
```
---
## Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
```go
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
func Init(api ext.API) {
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if strings.ToLower(ctx.GetOption("plan")) == "true" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
Focus on understanding, analysis, and generating plans.`
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
}
func applyMode(ctx ext.Context, active bool, tools []string) {
if active {
ctx.SetActiveTools(tools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON")
} else {
ctx.SetActiveTools(nil)
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF")
}
}
```
## Key Files for Reference
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
+32
View File
@@ -0,0 +1,32 @@
name: Build and Deploy Docs to GitHub Pages
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install Dependencies
working-directory: ./www
run: bun install
- name: Build
working-directory: ./www
run: bun run build
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: www/out
branch: gh-pages
+2 -1
View File
@@ -6,9 +6,10 @@ aidocs/
*.log
/kit
.idea
test/
build/
dist/
contribute/output/
CONTEXT.md
output/
.agents/
skills-lock.json
+210 -48
View File
@@ -18,9 +18,10 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
## Features
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls - no MCP overhead
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, spawn_subagent - no MCP overhead
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
- **Theming**: 22 built-in color themes (KITT, Catppuccin, Dracula, Nord, etc.) with runtime switching and custom theme files
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
@@ -29,10 +30,14 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
## Installation
### Using npm (recommended)
### Using npm / bun / pnpm
```bash
npm install -g @mark3labs/kit
# or
bun install -g @mark3labs/kit
# or
pnpm install -g @mark3labs/kit
```
### Using Go
@@ -103,8 +108,8 @@ Kit looks for configuration in the following locations (in order of priority):
1. CLI flags
2. Environment variables (with `KIT_` prefix)
3. `./.kit.yml` (project-local)
4. `~/.kit.yml` (global)
3. `./.kit.yml` / `./.kit.yaml` / `./.kit.json` (project-local)
4. `~/.kit.yml` / `~/.kit.yaml` / `~/.kit.json` (global)
### Basic Configuration
@@ -179,6 +184,7 @@ mcpServers:
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
--top-k Limit top K tokens (default: 40)
--stop-sequences Custom stop sequences (comma-separated)
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
# System
--config Config file path (default: ~/.kit.yml)
@@ -190,28 +196,61 @@ mcpServers:
```bash
# Authentication (for OAuth-enabled providers)
kit auth login # Start OAuth flow
kit auth logout # Remove credentials
kit auth status # Check authentication status
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
# Model database
kit models # List available models
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models # Update local model database from models.dev
kit models [provider] # List available models (optionally filter by provider)
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models [source] # Update model database (from models.dev, URL, file, or 'embedded')
# Extension management
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
kit install <git-url> # Install extensions from git repositories
kit install -l <git-url> # Install to project-local .kit/git/ directory
kit install -u <git-url> # Update an already-installed package
kit install --uninstall <pkg> # Remove an installed package
# Skills
kit skill # Install the Kit extensions skill via skills.sh
# ACP server
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
```
## Themes
Kit ships with 22 built-in color themes that control all UI elements. Switch at runtime:
```
/theme dracula
/theme catppuccin
/theme tokyonight
```
### Custom themes
Drop a `.yml` file in `~/.config/kit/themes/` (user) or `.kit/themes/` (project):
```yaml
# ~/.config/kit/themes/my-theme.yml
primary:
light: "#8839ef"
dark: "#cba6f7"
success:
light: "#40a02b"
dark: "#a6e3a1"
```
Built-in themes: `kitt`, `catppuccin`, `dracula`, `tokyonight`, `nord`, `gruvbox`, `monokai`, `solarized`, `github`, `one-dark`, `rose-pine`, `ayu`, `material`, `everforest`, `kanagawa`, `amoled`, `synthwave`, `vesper`, `flexoki`, `matrix`, `vercel`, `zenburn`
## Extension System
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, and intercept lifecycle events.
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, themes, and intercept lifecycle events.
### Minimal Extension
@@ -239,37 +278,69 @@ kit -e examples/extensions/minimal.go
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnAgentStart, OnAgentEnd, OnToolCall, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
- **Commands**: Register slash commands (e.g., `/mycommand`)
- **Options**: Register configurable extension options
- **Widgets**: Persistent status displays above/below input
- **Headers/Footers**: Persistent content above/below the conversation
- **Status Bar**: Custom status bar entries
- **Shortcuts**: Global keyboard shortcuts
- **Overlays**: Modal dialogs with markdown content
- **Tool Renderers**: Customize how tool calls display
- **Message Renderers**: Custom rendering for assistant messages
- **Editor Interceptors**: Handle key events and wrap rendering
- **Interactive Prompts**: Select, confirm, input, and multi-select dialogs
- **Subagents**: Spawn in-process child Kit instances
- **LLM Completion**: Direct model calls via `Complete()`
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
### Extension Examples
See the `examples/extensions/` directory:
- `minimal.go` - Clean UI with custom footer
- `notify.go` - Desktop notifications
- `widget-status.go` - Persistent status widgets
- `custom-editor-demo.go` - Vim-like modal editor
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `tool-logger.go` - Log all tool calls
- `overlay-demo.go` - Modal dialogs
- `plan-mode.go` - Read-only planning mode
- `subagent-widget.go` - Multi-agent orchestration
- `auto-commit.go` - Auto-commit on shutdown
- `bookmark.go` - Bookmark conversations
- `branded-output.go` - Branded output rendering
- `compact-notify.go` - Notification on compaction
- `confirm-destructive.go` - Confirm destructive operations
- `context-inject.go` - Inject context into conversations
- `custom-editor-demo.go` - Vim-like modal editor
- `dev-reload.go` - Development live-reload
- `header-footer-demo.go` - Custom headers and footers
- `inline-bash.go` - Inline bash execution
- `interactive-shell.go` - Interactive shell integration
- `kit-kit.go` - Kit-in-Kit (sub-agent spawning)
- `lsp-diagnostics.go` - LSP diagnostic integration
- `notify.go` - Desktop notifications
- `overlay-demo.go` - Modal dialogs
- `permission-gate.go` - Permission gating for tools
- `pirate.go` - Pirate-themed personality
- `plan-mode.go` - Read-only planning mode
- `project-rules.go` - Project-specific rules
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `protected-paths.go` - Path protection for sensitive files
- `subagent-widget.go` - Multi-agent orchestration with status widget
- `subagent-test.go` - Subagent testing utilities
- `summarize.go` - Conversation summarization
- `tool-logger.go` - Log all tool calls
- `neon-theme.go` - Custom theme registration and switching
- `tool-renderer-demo.go` - Custom tool call rendering
- `widget-status.go` - Persistent status widgets
### Loading Extensions
**Auto-discovery** (loads automatically):
- `./.kit/extensions/*.go` (project-local)
- `~/.config/kit/extensions/*.go` (global)
- `~/.config/kit/extensions/*.go` (global single files)
- `~/.config/kit/extensions/*/main.go` (global subdirectory extensions)
- `.kit/extensions/*.go` (project-local single files)
- `.kit/extensions/*/main.go` (project-local subdirectory extensions)
- `~/.local/share/kit/git/` (global git-installed packages)
- `.kit/git/` (project-local git-installed packages)
**Explicit loading**:
```bash
@@ -282,13 +353,50 @@ kit -e ext1.go -e ext2.go # Multiple extensions
kit --no-extensions
```
### Testing Extensions
Kit provides a testing package to help you write unit tests for your extensions:
```go
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
func TestMyExtension(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Emit events and verify behavior
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the extension printed something
test.AssertPrinted(t, harness, "session started")
}
```
**Available assertions:**
- `AssertBlocked()`, `AssertNotBlocked()` — Verify tool blocking
- `AssertWidgetSet()`, `AssertWidgetText()` — Verify widget content
- `AssertPrinted()`, `AssertPrintedContains()` — Verify output
- `AssertToolRegistered()`, `AssertCommandRegistered()` — Verify registration
See `examples/extensions/tool-logger_test.go` for a complete example with 14 test cases covering tool calls, input handling, and session lifecycle.
## Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
### Session Locations
- Default: `~/.local/share/kit/sessions/<cwd-hash>/<uuid>.jsonl`
- Default: `~/.kit/sessions/<cwd-path>/<timestamp>_<id>.jsonl`
- Path separators in the working directory are replaced with `--` (e.g., `/home/user/project` becomes `home--user--project`)
- Each line is a session entry (messages, tool calls, extension data)
- Supports branching from any message to explore alternate paths
@@ -355,6 +463,19 @@ host, err := kit.New(ctx, &kit.Options{
MaxSteps: 10,
Streaming: true,
Quiet: true,
// Session options
SessionPath: "./session.jsonl", // Open specific session
Continue: true, // Resume most recent session
NoSession: true, // Ephemeral mode
// Tool options
ExtraTools: []kit.Tool{...}, // Additional tools alongside defaults
// Compaction
AutoCompact: true, // Auto-compact near context limit
Debug: true, // Debug logging
})
```
@@ -384,14 +505,29 @@ response, err := host.PromptWithCallbacks(
### Session Management
```go
// Multi-turn conversations retain context automatically
host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
host.SaveSession("./session.json")
host.LoadSession("./session.json")
// Sessions are persisted automatically to JSONL files.
// Access session info:
path := host.GetSessionPath()
id := host.GetSessionID()
// Clear conversation history
host.ClearSession()
```
Session persistence is configured via `Options`:
```go
host, _ := kit.New(ctx, &kit.Options{
SessionPath: "./my-session.jsonl", // Open specific session
Continue: true, // Resume most recent session
NoSession: true, // Ephemeral mode
})
```
## Advanced Usage
### Subagent Pattern
@@ -413,12 +549,25 @@ Parse the JSON output:
{
"response": "Final assistant response text",
"model": "anthropic/claude-haiku-3-5-20241022",
"stop_reason": "end_turn",
"session_id": "a1b2c3d4e5f6",
"usage": {
"input_tokens": 1024,
"output_tokens": 512,
"total_tokens": 1536
"total_tokens": 1536,
"cache_read_tokens": 0,
"cache_creation_tokens": 0
},
"messages": [...]
"messages": [
{
"role": "assistant",
"parts": [
{"type": "text", "data": "..."},
{"type": "tool_call", "data": {"name": "...", "args": "..."}},
{"type": "tool_result", "data": {"name": "...", "result": "..."}}
]
}
]
}
```
@@ -468,19 +617,27 @@ go fmt ./...
### Project Structure
```
cmd/kit/ - CLI entry point
cmd/ - CLI command implementations
pkg/kit/ - Go SDK
internal/agent/ - Agent loop and tool execution
internal/ui/ - Bubble Tea TUI components
cmd/kit/ - CLI entry point (main.go)
cmd/ - CLI command implementations (root, auth, models, etc.)
pkg/kit/ - Go SDK for embedding Kit
internal/app/ - Application orchestrator (agent loop, message store, queue)
internal/agent/ - Agent execution and tool dispatch
internal/auth/ - OAuth authentication and credential storage
internal/acpserver/ - ACP (Agent Client Protocol) server
internal/clipboard/ - Cross-platform clipboard operations
internal/compaction/ - Conversation compaction and summarization
internal/config/ - Configuration management
internal/core/ - Built-in tools (bash, read, write, edit, grep, find, ls)
internal/extensions/ - Yaegi extension system
internal/core/ - Built-in tools
internal/tools/ - MCP tool integration
internal/config/ - Configuration management
internal/acpserver/ - ACP (Agent Client Protocol) server
internal/session/ - Session persistence
internal/models/ - Provider and model management
internal/kitsetup/ - Initial setup wizard
internal/message/ - Message content types and structured content blocks
internal/models/ - Provider and model management
internal/session/ - Session persistence (tree-based JSONL)
internal/skills/ - Skill loading and system prompt composition
internal/tools/ - MCP tool integration
internal/ui/ - Bubble Tea TUI components
examples/extensions/ - Example extension files
npm/ - NPM package wrapper for distribution
```
## Supported Providers
@@ -509,18 +666,23 @@ google/gemini-2.0-flash-exp
### Model Aliases
```bash
claude-opus-latest → claude-opus-4-20250514
claude-sonnet-latest → claude-sonnet-4-5-20250929
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
claude-opus-latest → claude-opus-4-20250514
claude-sonnet-latest → claude-sonnet-4-5-20250929
claude-4-opus-latest → claude-opus-4-20250514
claude-4-sonnet-latest → claude-sonnet-4-5-20250929
claude-3-7-sonnet-latest → claude-3-7-sonnet-20250219
claude-3-5-sonnet-latest → claude-3-5-sonnet-20241022
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
claude-3-opus-latest → claude-3-opus-20240229
```
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Contributions are welcome! Please see the [contribution guide](contribute/contribute.md) for guidelines.
## License
[Apache 2.0](LICENSE)
[MIT](LICENSE)
## Community
+25 -21
View File
@@ -1,11 +1,11 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"charm.land/huh/v2"
"github.com/mark3labs/kit/internal/auth"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
@@ -171,14 +171,15 @@ func loginAnthropic() error {
// Check if already authenticated
if hasAuth, err := cm.HasAnthropicCredentials(); err == nil && hasAuth {
fmt.Print("You are already authenticated with Anthropic. Do you want to re-authenticate? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
var reauth bool
err := huh.NewConfirm().
Title("You are already authenticated with Anthropic").
Description("Do you want to re-authenticate?").
Affirmative("Yes").
Negative("No").
Value(&reauth).
Run()
if err != nil || !reauth {
fmt.Println("Authentication cancelled.")
return nil
}
@@ -204,10 +205,13 @@ func loginAnthropic() error {
// Wait for user to complete OAuth flow
fmt.Println("After authorizing the application, you'll receive an authorization code.")
fmt.Print("Please enter the authorization code: ")
reader := bufio.NewReader(os.Stdin)
code, err := reader.ReadString('\n')
var code string
err = huh.NewInput().
Title("Authorization code").
Description("Paste the code from your browser").
Value(&code).
Run()
if err != nil {
return fmt.Errorf("failed to read authorization code: %w", err)
}
@@ -255,15 +259,15 @@ func logoutAnthropic() error {
}
// Confirm logout
fmt.Print("Are you sure you want to remove your Anthropic credentials? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
var confirm bool
err = huh.NewConfirm().
Title("Remove Anthropic credentials").
Description("Are you sure you want to remove your stored credentials?").
Affirmative("Yes").
Negative("No").
Value(&confirm).
Run()
if err != nil || !confirm {
fmt.Println("Logout cancelled.")
return nil
}
+225
View File
@@ -0,0 +1,225 @@
package cmd
import (
"fmt"
"os/exec"
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extensions"
"github.com/spf13/cobra"
)
var (
installLocalFlag bool
installUpdateFlag bool
installUninstallFlag bool
installAllFlag bool
)
var installCmd = &cobra.Command{
Use: "install <git-url>",
Short: "Install extensions from git repositories",
Long: `Install extensions from git repositories.
The install command downloads and installs Kit extensions from git repositories.
Extensions are stored in the global extensions directory by default, or in the
project's .kit/git/ directory when using the --local flag.
When a repo contains multiple extensions, an interactive multi-select is shown
so you can choose which to install. Use --all to skip selection and install everything.
Supported URL formats:
- github.com/user/repo (shorthand, defaults to HTTPS)
- git:github.com/user/repo
- https://github.com/user/repo
- ssh://git@github.com/user/repo
- git@github.com:user/repo
You can pin to a specific version, tag, or commit using @:
- github.com/user/repo@v1.0.0
- github.com/user/repo@main
- github.com/user/repo@abc1234
Examples:
kit install github.com/user/my-extension
kit install github.com/user/my-extension@v1.0.0
kit install github.com/user/my-extension --local
kit install github.com/user/collection --all`,
Args: cobra.ExactArgs(1),
RunE: runInstall,
}
func init() {
installCmd.Flags().BoolVarP(&installLocalFlag, "local", "l", false, "Install to project-local .kit/git/ directory")
installCmd.Flags().BoolVarP(&installUpdateFlag, "update", "u", false, "Update an already-installed package")
installCmd.Flags().BoolVar(&installUninstallFlag, "uninstall", false, "Remove an installed package")
installCmd.Flags().BoolVar(&installAllFlag, "all", false, "Install all extensions without prompting")
rootCmd.AddCommand(installCmd)
}
func runInstall(cmd *cobra.Command, args []string) error {
sourceStr := args[0]
// Check that git is available
if _, err := exec.LookPath("git"); err != nil {
return fmt.Errorf("git is not installed or not in PATH")
}
// Parse the source
source, err := extensions.ParseGitSource(sourceStr)
if err != nil {
return fmt.Errorf("invalid source: %w", err)
}
// Determine scope
scope := extensions.ScopeGlobal
if installLocalFlag {
scope = extensions.ScopeProject
}
installer := extensions.NewInstaller(".")
// Handle uninstall
if installUninstallFlag {
return runUninstall(installer, source, scope)
}
// Handle update
if installUpdateFlag {
return runUpdate(installer, source, scope)
}
// Handle install
return runInstallPackage(installer, source, scope)
}
func runInstallPackage(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Check if already installed
existingScope, installed := installer.IsInstalled(source)
if installed {
return fmt.Errorf("extension already installed (scope: %s). Use --update to update or --uninstall to remove", existingScope)
}
// Preview extensions to decide if we need multi-select
previews, tempDir, err := installer.PreviewExtensions(source)
if err != nil {
return fmt.Errorf("previewing extensions: %w", err)
}
defer extensions.CleanupTempDir(tempDir)
if len(previews) == 0 {
return fmt.Errorf("no extensions found in %s", source.String())
}
scopeStr := "globally"
if scope == extensions.ScopeProject {
scopeStr = "locally in .kit/git/"
}
// Single extension or --all flag: install everything directly
if len(previews) == 1 || installAllFlag {
if err := installer.Install(source, scope); err != nil {
return fmt.Errorf("install failed: %w", err)
}
if source.Pinned {
fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr)
} else {
fmt.Printf("Installed %d extension(s) from %s %s\n", len(previews), source.String(), scopeStr)
}
log.Info("extension installed", "source", source.String(), "scope", scope)
return nil
}
// Multiple extensions: show interactive selection
includePaths, err := multiSelectForInstall(previews)
if err != nil {
if err.Error() == "selection cancelled" || err.Error() == "no extensions selected" {
fmt.Println("Install cancelled.")
return nil
}
return fmt.Errorf("selection failed: %w", err)
}
if err := installer.InstallWithInclude(source, scope, includePaths); err != nil {
return fmt.Errorf("install failed: %w", err)
}
fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr)
for _, path := range includePaths {
fmt.Printf(" - %s\n", path)
}
log.Info("extension installed", "source", source.String(), "scope", scope, "selected", len(includePaths))
return nil
}
func runUpdate(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Find the installed package
existingScope, installed := installer.IsInstalled(source)
if !installed {
// Try to find with wildcard (no version)
entry, foundScope, err := extensions.FindInManifest(source.Identity())
if err != nil || entry == nil {
return fmt.Errorf("extension not installed: %s", source.Identity())
}
// Parse the found entry's source
foundSource, err := extensions.ParseGitSource(entry.Source)
if err != nil {
return fmt.Errorf("failed to parse installed source: %w", err)
}
existingScope = foundScope
source = foundSource
}
// Override scope if specified
if installLocalFlag && scope != existingScope {
return fmt.Errorf("extension installed in %s scope, cannot update with --local flag", existingScope)
}
scope = existingScope
// Check if pinned
if source.Pinned {
fmt.Printf("Skipping %s (pinned at %s)\n", source.Identity(), source.Ref)
return nil
}
// Update
if err := installer.Update(source, scope); err != nil {
return fmt.Errorf("update failed: %w", err)
}
fmt.Printf("Updated %s\n", source.Identity())
log.Info("extension updated", "source", source.Identity(), "scope", scope)
return nil
}
func runUninstall(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Find where it's installed (ignore scope flag for uninstall - remove from wherever it exists)
existingScope, installed := installer.IsInstalled(source)
if !installed {
// Try to find in manifests
entry, foundScope, err := extensions.FindInManifest(source.Identity())
if err != nil || entry == nil {
return fmt.Errorf("extension not installed: %s", source.Identity())
}
existingScope = foundScope
// Parse the found entry's source
foundSource, err := extensions.ParseGitSource(entry.Source)
if err != nil {
return fmt.Errorf("failed to parse installed source: %w", err)
}
source = foundSource
}
// Uninstall from the scope where it's installed
if err := installer.Uninstall(source, existingScope); err != nil {
return fmt.Errorf("uninstall failed: %w", err)
}
fmt.Printf("Uninstalled %s from %s scope\n", source.Identity(), existingScope)
log.Info("extension uninstalled", "source", source.Identity(), "scope", existingScope)
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package cmd
import (
"fmt"
"os"
"charm.land/huh/v2"
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extensions"
)
// multiSelectForInstall runs a multi-select prompt for extension selection.
// Returns the selected extension paths, or an error if cancelled.
func multiSelectForInstall(previews []extensions.ExtensionPreview) ([]string, error) {
if len(previews) == 0 {
return nil, fmt.Errorf("no extensions to select")
}
// Non-interactive: select all
if !isInteractive() {
log.Info("Non-interactive mode, selecting all extensions")
paths := make([]string, len(previews))
for i, p := range previews {
paths[i] = p.Path
}
return paths, nil
}
// Single extension: just return it
if len(previews) == 1 {
return []string{previews[0].Path}, nil
}
// Build options for huh MultiSelect
options := make([]huh.Option[string], len(previews))
for i, p := range previews {
label := fmt.Sprintf("%s %s", p.Name, p.Path)
options[i] = huh.NewOption(label, p.Path).Selected(true)
}
var selected []string
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Select extensions to install").
Options(options...).
Value(&selected),
),
)
if err := form.Run(); err != nil {
return nil, fmt.Errorf("selection cancelled")
}
if len(selected) == 0 {
return nil, fmt.Errorf("no extensions selected")
}
return selected, nil
}
// isInteractive checks if the terminal is interactive.
func isInteractive() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
+74 -17
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"image/color"
"log"
"os"
"strings"
@@ -141,24 +142,58 @@ func LoadConfigWithEnvSubstitution(configPath string) error {
return kit.LoadConfigWithEnvSubstitution(configPath)
}
func configToUiTheme(theme config.Theme) ui.Theme {
// adaptiveOrDefault converts a config.AdaptiveColor to a resolved color.Color,
// falling back to fallback when both Light and Dark are empty.
func adaptiveOrDefault(ac config.AdaptiveColor, fallback color.Color) color.Color {
if ac.Light == "" && ac.Dark == "" {
return fallback
}
return ui.AdaptiveColor(ac.Light, ac.Dark)
}
func configToUiTheme(cfg config.Theme) ui.Theme {
def := ui.DefaultTheme()
return ui.Theme{
Primary: ui.AdaptiveColor(theme.Primary.Light, theme.Primary.Dark),
Secondary: ui.AdaptiveColor(theme.Secondary.Light, theme.Secondary.Dark),
Success: ui.AdaptiveColor(theme.Success.Light, theme.Success.Dark),
Warning: ui.AdaptiveColor(theme.Warning.Light, theme.Warning.Dark),
Error: ui.AdaptiveColor(theme.Error.Light, theme.Error.Dark),
Info: ui.AdaptiveColor(theme.Info.Light, theme.Info.Dark),
Text: ui.AdaptiveColor(theme.Text.Light, theme.Text.Dark),
Muted: ui.AdaptiveColor(theme.Muted.Light, theme.Muted.Dark),
VeryMuted: ui.AdaptiveColor(theme.VeryMuted.Light, theme.VeryMuted.Dark),
Background: ui.AdaptiveColor(theme.Background.Light, theme.Background.Dark),
Border: ui.AdaptiveColor(theme.Border.Light, theme.Border.Dark),
MutedBorder: ui.AdaptiveColor(theme.MutedBorder.Light, theme.MutedBorder.Dark),
System: ui.AdaptiveColor(theme.System.Light, theme.System.Dark),
Tool: ui.AdaptiveColor(theme.Tool.Light, theme.Tool.Dark),
Accent: ui.AdaptiveColor(theme.Accent.Light, theme.Accent.Dark),
Highlight: ui.AdaptiveColor(theme.Highlight.Light, theme.Highlight.Dark),
Primary: adaptiveOrDefault(cfg.Primary, def.Primary),
Secondary: adaptiveOrDefault(cfg.Secondary, def.Secondary),
Success: adaptiveOrDefault(cfg.Success, def.Success),
Warning: adaptiveOrDefault(cfg.Warning, def.Warning),
Error: adaptiveOrDefault(cfg.Error, def.Error),
Info: adaptiveOrDefault(cfg.Info, def.Info),
Text: adaptiveOrDefault(cfg.Text, def.Text),
Muted: adaptiveOrDefault(cfg.Muted, def.Muted),
VeryMuted: adaptiveOrDefault(cfg.VeryMuted, def.VeryMuted),
Background: adaptiveOrDefault(cfg.Background, def.Background),
Border: adaptiveOrDefault(cfg.Border, def.Border),
MutedBorder: adaptiveOrDefault(cfg.MutedBorder, def.MutedBorder),
System: adaptiveOrDefault(cfg.System, def.System),
Tool: adaptiveOrDefault(cfg.Tool, def.Tool),
Accent: adaptiveOrDefault(cfg.Accent, def.Accent),
Highlight: adaptiveOrDefault(cfg.Highlight, def.Highlight),
DiffInsertBg: adaptiveOrDefault(cfg.DiffInsertBg, def.DiffInsertBg),
DiffDeleteBg: adaptiveOrDefault(cfg.DiffDeleteBg, def.DiffDeleteBg),
DiffEqualBg: adaptiveOrDefault(cfg.DiffEqualBg, def.DiffEqualBg),
DiffMissingBg: adaptiveOrDefault(cfg.DiffMissingBg, def.DiffMissingBg),
CodeBg: adaptiveOrDefault(cfg.CodeBg, def.CodeBg),
GutterBg: adaptiveOrDefault(cfg.GutterBg, def.GutterBg),
WriteBg: adaptiveOrDefault(cfg.WriteBg, def.WriteBg),
Markdown: ui.MarkdownThemeColors{
Text: adaptiveOrDefault(cfg.Markdown.Text, def.Markdown.Text),
Muted: adaptiveOrDefault(cfg.Markdown.Muted, def.Markdown.Muted),
Heading: adaptiveOrDefault(cfg.Markdown.Heading, def.Markdown.Heading),
Emph: adaptiveOrDefault(cfg.Markdown.Emph, def.Markdown.Emph),
Strong: adaptiveOrDefault(cfg.Markdown.Strong, def.Markdown.Strong),
Link: adaptiveOrDefault(cfg.Markdown.Link, def.Markdown.Link),
Code: adaptiveOrDefault(cfg.Markdown.Code, def.Markdown.Code),
Error: adaptiveOrDefault(cfg.Markdown.Error, def.Markdown.Error),
Keyword: adaptiveOrDefault(cfg.Markdown.Keyword, def.Markdown.Keyword),
String: adaptiveOrDefault(cfg.Markdown.String, def.Markdown.String),
Number: adaptiveOrDefault(cfg.Markdown.Number, def.Markdown.Number),
Comment: adaptiveOrDefault(cfg.Markdown.Comment, def.Markdown.Comment),
},
}
}
@@ -901,6 +936,28 @@ func runNormalMode(ctx context.Context) error {
SetActiveTools: func(names []string) {
kitInstance.SetExtensionActiveTools(names)
},
RegisterTheme: func(name string, config extensions.ThemeColorConfig) {
tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} }
ui.RegisterThemeFromConfig(name,
tc(config.Primary), tc(config.Secondary),
tc(config.Success), tc(config.Warning),
tc(config.Error), tc(config.Info),
tc(config.Text), tc(config.Muted),
tc(config.VeryMuted), tc(config.Background),
tc(config.Border), tc(config.MutedBorder),
tc(config.System), tc(config.Tool),
tc(config.Accent), tc(config.Highlight),
tc(config.MdHeading), tc(config.MdLink),
tc(config.MdKeyword), tc(config.MdString),
tc(config.MdNumber), tc(config.MdComment),
)
},
SetTheme: func(name string) error {
return ui.ApplyTheme(name)
},
ListThemes: func() []string {
return ui.ListThemes()
},
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
ch := make(chan app.OverlayResponse, 1)
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
+197
View File
@@ -0,0 +1,197 @@
# Kit Extension Examples
A collection of example extensions demonstrating various Kit capabilities. These can be installed individually or as a complete collection.
## Installation
### Install all examples
```bash
kit install github.com/mark3labs/kit/examples/extensions
```
### Install with interactive selection
```bash
kit install github.com/mark3labs/kit/examples/extensions --select
```
### Install locally in your project
```bash
kit install github.com/mark3labs/kit/examples/extensions --local
```
## Extension Index
### Core Concepts
| Extension | Description | Key API |
|-----------|-------------|---------|
| `minimal.go` | Minimal viable extension | Basic `Init()` function |
| `plan-mode.go` | Restrict agent to read-only tools | `OnBeforeAgentStart`, `SetActiveTools` |
| `tool-logger.go` | Log all tool calls to file | `OnToolCall`, `OnToolResult` |
| `notify.go` | Display notifications | `PrintInfo`, `PrintBlock` |
### UI & Widgets
| Extension | Description | Key API |
|-----------|-------------|---------|
| `widget-status.go` | Persistent status widget | `SetWidget`, `RemoveWidget` |
| `header-footer-demo.go` | Custom header/footer | `SetHeader`, `SetFooter` |
| `overlay-demo.go` | Modal overlay dialogs | `ShowOverlay` |
| `compact-notify.go` | Compact mode notifications | `PrintBlock` |
| `branded-output.go` | Custom styled output | `PrintBlock` with colors |
### Input & Editor
| Extension | Description | Key API |
|-----------|-------------|---------|
| `custom-editor-demo.go` | Custom key handling | `SetEditor`, `EditorKeyAction` |
| `pirate.go` | Transform user input | `OnInput`, `InputResult` |
| `interactive-shell.go` | Custom command input | Slash commands with prompts |
| `inline-bash.go` | Execute bash inline | Input handling, `exec` |
### Session & Context
| Extension | Description | Key API |
|-----------|-------------|---------|
| `context-inject.go` | Inject context into prompts | `OnContextPrepare` |
| `bookmark.go` | Bookmark messages | `AppendEntry`, `GetEntries` |
| `project-rules.go` | Project-specific rules | Session data, file reading |
| `protected-paths.go` | Block dangerous operations | `OnToolCall` with blocking |
| `permission-gate.go` | Confirm destructive actions | `OnToolCall` with confirmation |
### Tools & Commands
| Extension | Description | Key API |
|-----------|-------------|---------|
| `auto-commit.go` | Auto-commit changes | Custom tool, git operations |
| `summarize.go` | Summarize conversation | Custom tool with parameters |
| `confirm-destructive.go` | Confirm destructive commands | `OnToolCall` blocking |
| `lsp-diagnostics.go` | LSP integration | Complex extension, external process |
### Subagents & Background Tasks
| Extension | Description | Key API |
|-----------|-------------|---------|
| `kit-kit.go` | Spawn Kit as subagent | Subagent spawning |
| `subagent-test.go` | Test subagent functionality | `SpawnSubagent` |
| `subagent-widget.go` | Widget with subagent updates | Goroutines + widgets |
| `dev-reload.go` | Hot reload extensions | `ReloadExtensions` |
### Integrations
| Extension | Description | Key API |
|-----------|-------------|---------|
| `kit-telegram/` | Telegram relay for remote monitoring & control | `RegisterCommand`, `OnAgentStart/End`, `SetStatus`, `SendMessage` |
### Themes
| Extension | Description | Key API |
|-----------|-------------|---------|
| `neon-theme.go` | Register and switch custom themes | `RegisterTheme`, `SetTheme` |
### Rendering
| Extension | Description | Key API |
|-----------|-------------|---------|
| `tool-renderer-demo.go` | Custom tool output styling | `RegisterToolRenderer` |
| `prompt-demo.go` | Interactive prompts | `PromptSelect`, `PromptConfirm` |
## Extension Details
### minimal.go
The bare minimum extension showing the required structure:
- Package `main`
- Import `kit/ext`
- Export `Init(api ext.API)` function
### plan-mode.go
A complete example demonstrating:
- Slash command (`/plan`)
- Keyboard shortcut (`ctrl+alt+p`)
- Option registration
- Status bar indicators
- System prompt injection
- Tool filtering
### widget-status.go
Shows how to create persistent UI elements:
- Create widgets with `SetWidget`
- Update content dynamically
- Remove when done
- Handle session lifecycle
### context-inject.go
Advanced context manipulation:
- Read project files
- Inject into LLM context
- Filter messages
- Use negative indices for ephemeral content
### lsp-diagnostics.go
Complex real-world example:
- Multi-file extension
- External process management (LSP server)
- File watching
- Diagnostics aggregation
### kit-telegram/
Full-featured Telegram integration:
- Slash command with subcommands and tab completion
- Interactive guided setup flow with prompts
- Background long-polling goroutine
- Progress message rendering edited in place
- Message queue with edit-before-dispatch
- Remote command handling from Telegram
- Status bar and widget updates
- Config persistence with atomic writes
## Multi-File Extension Example
The `kit-kit-agents/` directory demonstrates the multi-file pattern:
```
kit-kit-agents/
├── main.go # Entry point with Init()
├── agent.go # Agent configuration
├── manager.go # Agent lifecycle management
└── README.md # Documentation
```
When the repo is installed, all files in subdirectories with `main.go` are loaded as separate extensions.
## Testing & Validation
After installing, test the extensions:
```bash
# List all loaded extensions
kit extensions list
# Validate all extensions
kit extensions validate
# Run with a specific extension
kit -e ~/.local/share/kit/git/github.com/mark3labs/kit/examples/extensions/plan-mode.go
```
## Creating Your Own
1. Copy `minimal.go` as a starting point
2. Modify the `Init()` function to register your handlers
3. Use the other examples for reference on specific APIs
4. Test with `kit -e your-extension.go`
5. Share by pushing to a git repository!
## Update
To get the latest examples:
```bash
kit install github.com/mark3labs/kit/examples/extensions --update
```
## See Also
- [Kit Extensions Guide](https://github.com/mark3labs/kit/blob/main/.agents/skills/kit-extensions/SKILL.md)
- [API Reference](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go)
- [Example Extensions Source](https://github.com/mark3labs/kit/tree/main/examples/extensions)
+1 -2
View File
@@ -23,8 +23,7 @@ import (
func Init(api ext.API) {
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
// Check for staged changes.
diff, err := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput()
_ = diff
err := exec.Command("git", "diff", "--cached", "--quiet").Run()
if err == nil {
return // exit code 0 means no staged changes
}
@@ -0,0 +1,170 @@
// Extension Test Template
//
// This is a template for writing tests for your Kit extension.
// Copy this file to your extension directory, rename it to something like
// "my-ext_test.go", and customize it for your extension.
//
// Run tests with: go test -v
//
// IMPORTANT: This file should be in the same directory as your extension
// and use package main, NOT package test.
package main
import (
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// Test that your extension loads without errors
func TestExtension_Loads(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile("my-ext.go") // Change to your extension filename
// Verify the extension was loaded
if ext == nil {
t.Fatal("extension should not be nil")
}
}
// Test your event handlers are registered
func TestExtension_EventHandlers(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Uncomment the handlers your extension uses:
// test.AssertHasHandlers(t, harness, extensions.ToolCall)
// test.AssertHasHandlers(t, harness, extensions.Input)
// test.AssertHasHandlers(t, harness, extensions.SessionStart)
// test.AssertHasHandlers(t, harness, extensions.AgentEnd)
}
// Test tool registration
func TestExtension_Tools(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test that your tools are registered
// test.AssertToolRegistered(t, harness, "my_tool")
// Or test all registered tools
tools := harness.RegisteredTools()
t.Logf("Registered %d tools", len(tools))
for _, tool := range tools {
t.Logf(" - %s: %s", tool.Name, tool.Description)
}
}
// Test command registration
func TestExtension_Commands(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test that your commands are registered
// test.AssertCommandRegistered(t, harness, "mycommand")
// Or test all registered commands
cmds := harness.RegisteredCommands()
t.Logf("Registered %d commands", len(cmds))
for _, cmd := range cmds {
t.Logf(" - %s: %s", cmd.Name, cmd.Description)
}
}
// Test session start behavior
func TestExtension_SessionStart(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Emit session start event
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "test-session",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify expected behavior:
// - Did it print something?
// test.AssertPrinted(t, harness, "expected output")
// - Did it set a widget?
// test.AssertWidgetSet(t, harness, "my-widget")
// test.AssertWidgetText(t, harness, "my-widget", "expected text")
// - Did it set the header/footer?
// test.AssertHeaderSet(t, harness)
// test.AssertFooterSet(t, harness)
// - Did it set a status?
// test.AssertStatusSet(t, harness, "myext:status")
}
// Test tool call handling
func TestExtension_ToolCall(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test a specific tool call
result, err := harness.Emit(extensions.ToolCallEvent{
ToolName: "some_tool",
Input: `{"key": "value"}`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// If your extension blocks certain tools:
// test.AssertNotBlocked(t, result)
// OR
// test.AssertBlocked(t, result, "expected reason")
// Suppress unused variable warning (remove this when using result)
_ = result
// Check for print output
// test.AssertPrinted(t, harness, "expected message")
}
// Test input handling
func TestExtension_InputHandling(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test input that should be handled
result, err := harness.Emit(extensions.InputEvent{
Text: "test input",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// If your extension handles/transforms input:
// test.AssertInputHandled(t, result, "handled")
// OR
// test.AssertInputTransformed(t, result, "transformed text")
// Suppress unused variable warning (remove this when using result)
_ = result
}
// Test with configured prompt results
func TestExtension_WithPrompts(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Configure what prompts should return
harness.Context().SetPromptSelectResult(extensions.PromptSelectResult{
Value: "option1",
Index: 0,
Cancelled: false,
})
// Now when your extension calls ctx.PromptSelect(), it gets the configured result
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
// Verify behavior based on the selected options
}
+111
View File
@@ -0,0 +1,111 @@
# kit-telegram
A Kit extension that relays all Kit agent runs to Telegram and lets approved Telegram users reply back into Kit.
## What it does
- Relays **all Kit runs** to one Telegram chat while connected
- Edits one Telegram progress message in place during a run
- Lets approved Telegram users send normal text replies back into Kit
- Shows `Telegram Connected` or `Telegram Disconnected` in the status bar
- Shows a small spinner animation as `⠋ Telegram Connecting` only while the relay is still connecting
- On startup with an already validated enabled config, sends a short Telegram connection message to confirm the relay is up
## Requirements
- `kit` installed and working
- A Telegram bot token from `@BotFather`
- Either:
- A Telegram chat where you can message the bot, or
- A numeric Telegram chat id you want to enter manually
- For group chats, one or more allowed Telegram user ids
## Quickstart
### 1. Install the extension
```bash
kit install github.com/mark3labs/kit/examples/extensions/kit-telegram
```
Or run directly:
```bash
kit -e path/to/kit-telegram/main.go
```
### 2. Start Kit and connect Telegram
```bash
kit
```
Inside Kit, run:
```
/telegram connect
```
You will be prompted for:
- Bot token from `@BotFather`
- Whether to auto-detect the chat by messaging the bot or enter the chat id manually
- Allowed user ids when needed
### 3. Verify the relay
```
/telegram test
```
Reply in Telegram with the code from the test message.
## Commands
| Command | Description |
|---------|-------------|
| `/telegram` | Human-friendly overview and subcommand list |
| `/telegram status` | Raw deterministic relay state |
| `/telegram test` | Verify outbound and inbound relay |
| `/telegram toggle` | Enable or disable relay without deleting credentials |
| `/telegram logout` | Remove saved credentials and disconnect relay |
| `/telegram connect` | Run the setup flow again |
| `/telegram clear` | Clear Telegram status and working messages from the TUI |
## Remote commands (from Telegram)
| Command | Description |
|---------|-------------|
| `/telegram` | Sends the overview back to Telegram |
| `/telegram status` | Sends the deterministic state report to Telegram |
| `/telegram test` | Sends a reply-code test message from Telegram |
| `/telegram toggle` | Flips the enabled flag |
| `/telegram logout yes` | Logs out (requires `yes` confirmation) |
| `/telegram clear` | Clears the TUI footer and working messages |
## Key APIs Used
- `RegisterCommand` — Slash command with subcommands and tab completion
- `OnSessionStart` / `OnSessionShutdown` — Lifecycle management
- `OnAgentStart` / `OnAgentEnd` — Run tracking and progress rendering
- `OnToolCall` / `OnToolResult` — Action tracking
- `OnMessageEnd` — Capture assistant responses
- `OnInput` — Mirror local messages to Telegram
- `SetStatus` / `RemoveStatus` — Status bar indicators
- `SetWidget` / `RemoveWidget` — Working message display
- `PromptInput` / `PromptSelect` / `PromptConfirm` — Interactive setup flow
- `SendMessage` — Inject Telegram replies as Kit prompts
## Architecture
Single Go file interpreted by Yaegi at runtime. Core components:
- **Telegram Bot API client** — HTTP calls via `net/http` for getMe, getChat, getChatMember, getUpdates (long-polling), sendMessage, editMessageText
- **Config persistence** — JSON file at `.kit/kit-telegram.json` with atomic writes
- **Long-polling goroutine** — Background polling for Telegram updates with warmup poll, retry, and client-side timeouts
- **Message queue** — In-memory FIFO queue for Telegram prompt input with edit-before-dispatch support
- **Progress rendering** — `⏳ elapsed · step N` with action lines, edited in place
- **Final rendering** — `✅/❌ elapsed` with response text, split into chunks for long output
## Debug mode
Set environment variable `KIT_TELEGRAM_DEBUG=1` to enable verbose debug logging.
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
//go:build ignore
package main
import "kit/ext"
// Init registers a "neon" theme and a /neon slash command to apply it.
// Demonstrates how extensions can create and set themes programmatically.
//
// Usage: kit -e examples/extensions/neon-theme.go
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
// Register a cyberpunk neon theme at startup.
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
})
ctx.PrintInfo("Neon theme registered! Use /theme neon to activate.")
})
// Also register a /neon slash command as a shortcut.
api.RegisterCommand(ext.CommandDef{
Name: "neon",
Description: "Switch to the neon cyberpunk theme",
Execute: func(args string, ctx ext.Context) (string, error) {
if err := ctx.SetTheme("neon"); err != nil {
return "", err
}
return "Neon theme activated!", nil
},
})
}
@@ -0,0 +1,43 @@
//go:build ignore
package main
import (
"fmt"
"kit/ext"
)
// Helper functions for the status-tools extension
// These are used by main.go but kept in a separate file
// to demonstrate the multi-file extension pattern.
// formatMemory converts bytes to human-readable format
func formatMemory(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}
// showMemoryStatus displays memory usage (placeholder)
func showMemoryStatus(ctx ext.Context) {
// This is a placeholder that would show memory stats
// In a real extension, you'd integrate with system metrics
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "Memory status monitoring not yet implemented",
BorderColor: "#f9e2af",
Subtitle: "Memory",
})
}
+49
View File
@@ -0,0 +1,49 @@
//go:build ignore
package main
import (
"fmt"
"time"
"kit/ext"
)
// Init registers the status tools extension.
// This extension provides multiple status-related utilities as a
// multi-file extension example.
func Init(api ext.API) {
// Register a status bar widget that shows time
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetStatus("clock", time.Now().Format("15:04:05"), 5)
}
}()
})
// Register a /status command
api.RegisterCommand(ext.CommandDef{
Name: "status",
Description: "Show system status information",
Execute: func(args string, ctx ext.Context) (string, error) {
stats := ctx.GetContextStats()
info := fmt.Sprintf(
"Model: %s\nTokens: %d/%d (%.1f%%)\nMessages: %d",
ctx.Model,
stats.EstimatedTokens,
stats.ContextLimit,
stats.UsagePercent*100,
stats.MessageCount,
)
ctx.PrintBlock(ext.PrintBlockOpts{
Text: info,
BorderColor: "#89b4fa",
Subtitle: "System Status",
})
return "", nil
},
})
}
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"os"
"strings"
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// Test that the tool-logger extension loads and registers handlers
func TestToolLogger_Loads(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile("tool-logger.go")
if ext == nil {
t.Fatal("extension should not be nil")
}
// Verify all expected handlers are registered
test.AssertHasHandlers(t, harness, extensions.ToolCall)
test.AssertHasHandlers(t, harness, extensions.ToolResult)
test.AssertHasHandlers(t, harness, extensions.SessionStart)
test.AssertHasHandlers(t, harness, extensions.SessionShutdown)
test.AssertHasHandlers(t, harness, extensions.Input)
}
// Test that tool calls are logged (handlers run without errors)
func TestToolLogger_ToolCall(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Emit a tool call event
result, err := harness.Emit(extensions.ToolCallEvent{
ToolName: "Read",
ToolCallID: "call-123",
Input: `{"file": "test.txt"}`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Tool logger should not block any tools
test.AssertNotBlocked(t, result)
}
// Test that tool results are processed
func TestToolLogger_ToolResult(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
content := "Hello, World!"
result, err := harness.Emit(extensions.ToolResultEvent{
ToolName: "Read",
Content: content,
IsError: false,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Tool logger should not modify results
if result != nil {
t.Error("expected nil result (no modification)")
}
}
// Test that error tool results are handled
func TestToolLogger_ToolResultError(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.ToolResultEvent{
ToolName: "Bash",
Content: "command not found",
IsError: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Error("expected nil result (no modification)")
}
}
// Test session start handler
func TestToolLogger_SessionStart(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "test-session-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Handler should run without errors (logs to file)
// Since file logging happens outside our mock, we just verify no errors
}
// Test session shutdown handler
func TestToolLogger_SessionShutdown(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
_, err := harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// Test the !time command
func TestToolLogger_TimeCommand(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "!time",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
test.AssertInputHandled(t, result, "handled")
// Verify PrintInfo was called with a time message
infos := harness.Context().GetPrintInfos()
found := false
for _, info := range infos {
if strings.Contains(info, "Current time:") {
found = true
break
}
}
if !found {
t.Errorf("expected PrintInfo with 'Current time:', got: %v", infos)
}
}
// Test the !status command
func TestToolLogger_StatusCommand(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "!status",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
test.AssertInputHandled(t, result, "handled")
// Verify PrintBlock was called
blocks := harness.Context().PrintBlocks
if len(blocks) != 1 {
t.Fatalf("expected 1 PrintBlock call, got %d", len(blocks))
}
block := blocks[0]
if block.Subtitle != "tool-logger extension" {
t.Errorf("expected subtitle 'tool-logger extension', got %q", block.Subtitle)
}
if block.BorderColor != "#a6e3a1" {
t.Errorf("expected border color '#a6e3a1', got %q", block.BorderColor)
}
if !strings.Contains(block.Text, "Session active") {
t.Errorf("expected text to contain 'Session active', got %q", block.Text)
}
}
// Test that unknown commands are not handled
func TestToolLogger_UnknownCommand(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "!unknown",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil result for unknown command, got %v", result)
}
// Verify no info/block prints for unknown commands
if len(harness.Context().GetPrintInfos()) != 0 {
t.Error("expected no PrintInfo calls for unknown command")
}
if len(harness.Context().PrintBlocks) != 0 {
t.Error("expected no PrintBlock calls for unknown command")
}
}
// Test regular text input (not a command)
func TestToolLogger_RegularInput(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "This is a normal message",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil result for regular input, got %v", result)
}
}
// Test complete session flow
func TestToolLogger_FullSession(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Simulate a full session
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Several tool calls
tools := []string{"Read", "Glob", "Grep", "Bash"}
for _, tool := range tools {
_, err := harness.Emit(extensions.ToolCallEvent{
ToolName: tool,
Input: "{}",
})
if err != nil {
t.Fatalf("error for tool %s: %v", tool, err)
}
_, err = harness.Emit(extensions.ToolResultEvent{
ToolName: tool,
Content: "result",
IsError: false,
})
if err != nil {
t.Fatalf("error for tool result %s: %v", tool, err)
}
}
// User issues a command
_, err = harness.Emit(extensions.InputEvent{Text: "!time", Source: "cli"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the !time command was handled
if len(harness.Context().GetPrintInfos()) != 1 {
t.Errorf("expected 1 PrintInfo call, got %d", len(harness.Context().GetPrintInfos()))
}
}
// Test that the extension handles file write errors gracefully
func TestToolLogger_FileError(t *testing.T) {
// This test verifies the extension doesn't panic when file operations fail
// Since we can't easily mock os.OpenFile, we rely on the extension code
// properly checking for errors (which it does)
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Just verify the handlers run without panicking
_, err := harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// Test concurrent tool calls (race condition check)
func TestToolLogger_ConcurrentToolCalls(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Run multiple tool calls concurrently
done := make(chan bool, 10)
for i := range 10 {
go func(index int) {
defer func() { done <- true }()
toolName := "Tool" + string(rune('0'+index))
_, err := harness.Emit(extensions.ToolCallEvent{
ToolName: toolName,
Input: "{}",
})
if err != nil {
t.Errorf("error in goroutine %d: %v", index, err)
}
}(i)
}
// Wait for all goroutines
for range 10 {
<-done
}
}
// Test the actual log file is created and written to
func TestToolLogger_LogFile(t *testing.T) {
logFile := "/tmp/kit-tool-log.txt"
// Clean up before test
_ = os.Remove(logFile)
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Emit events
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
_, _ = harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
_, _ = harness.Emit(extensions.ToolResultEvent{ToolName: "Read", Content: "data", IsError: false})
// Note: Since the extension writes to a real file and the test harness
// mocks the context, the file writes actually happen. Let's verify.
// Give it a moment for file operations
if _, err := os.Stat(logFile); err == nil {
// File exists - read and verify content
content, err := os.ReadFile(logFile)
if err != nil {
t.Logf("Could not read log file: %v", err)
} else {
contentStr := string(content)
if !strings.Contains(contentStr, "SESSION_START") {
t.Error("log file should contain SESSION_START")
}
if !strings.Contains(contentStr, "CALL tool=Read") {
t.Error("log file should contain CALL tool=Read")
}
if !strings.Contains(contentStr, "RESULT tool=Read") {
t.Error("log file should contain RESULT tool=Read")
}
}
} else {
t.Log("Note: Log file not created - this is expected since the extension writes directly to disk")
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ func Init(api ext.API) {
DisplayName: "File",
BorderColor: "#89b4fa", // Catppuccin blue
RenderHeader: func(toolArgs string, width int) string {
var args map[string]interface{}
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
@@ -72,7 +72,7 @@ func Init(api ext.API) {
Background: "#1e1e2e", // Dark background
BorderColor: "#a6e3a1", // Catppuccin green
RenderHeader: func(toolArgs string, width int) string {
var args map[string]interface{}
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
+10 -4
View File
@@ -4,11 +4,11 @@ go 1.26.0
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.1
charm.land/bubbletea/v2 v2.0.2
charm.land/fantasy v0.11.1
charm.land/lipgloss/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.1
github.com/alecthomas/chroma/v2 v2.23.1
github.com/aymanbagabas/go-udiff v0.4.0
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/log v0.4.2
github.com/mark3labs/mcp-go v0.44.1
@@ -20,6 +20,7 @@ require (
)
require (
charm.land/huh/v2 v2.0.3 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
@@ -45,6 +46,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
@@ -53,7 +55,9 @@ require (
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -61,6 +65,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/coder/acp-go-sdk v0.6.3 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
@@ -84,6 +89,7 @@ require (
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.3.0 // indirect
github.com/muesli/mango-pflag v0.2.0 // indirect
@@ -138,6 +144,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
+20
View File
@@ -2,10 +2,16 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk=
charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
@@ -66,12 +72,16 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
@@ -98,8 +108,12 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 h1:bloHJLweYZeIkBVgi8AF94DrTdx3eoEB57VOpFuFi3U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -123,6 +137,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
@@ -198,6 +214,8 @@ github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjc
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
@@ -300,6 +318,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+3 -3
View File
@@ -49,12 +49,12 @@ func NewOAuthClient() *OAuthClient {
}
}
// GeneratePKCE generates a cryptographically secure PKCE verifier and challenge pair
// generatePKCE generates a cryptographically secure PKCE verifier and challenge pair
// for the OAuth 2.0 PKCE flow. The verifier is a random 32-byte string encoded as
// base64url, and the challenge is the SHA256 hash of the verifier, also base64url encoded.
// Returns the verifier (to be stored securely), challenge (to be sent with auth request),
// and any error encountered during generation.
func GeneratePKCE() (verifier, challenge string, err error) {
func generatePKCE() (verifier, challenge string, err error) {
// Generate 32 bytes of random data
verifierBytes := make([]byte, 32)
if _, err := rand.Read(verifierBytes); err != nil {
@@ -76,7 +76,7 @@ func GeneratePKCE() (verifier, challenge string, err error) {
// and PKCE challenge. Returns an AuthData structure containing the URL for user
// authentication and the PKCE verifier for the subsequent code exchange.
func (c *OAuthClient) GetAuthorizationURL() (*AuthData, error) {
verifier, challenge, err := GeneratePKCE()
verifier, challenge, err := generatePKCE()
if err != nil {
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
}
+2 -2
View File
@@ -71,5 +71,5 @@ func DetectMediaType(data []byte) string {
// ErrNoImage is returned when the clipboard does not contain image data.
var ErrNoImage = fmt.Errorf("no image data on clipboard")
// ErrNoClipboardTool is returned when no suitable clipboard tool is found.
var ErrNoClipboardTool = fmt.Errorf("no clipboard tool available (install xclip, wl-paste, or use macOS)")
// errNoClipboardTool is returned when no suitable clipboard tool is found.
var errNoClipboardTool = fmt.Errorf("no clipboard tool available (install xclip, wl-paste, or use macOS)")
+2 -3
View File
@@ -7,9 +7,8 @@ import (
)
// ReadImage reads image data from the system clipboard on macOS.
// It uses osascript to check if the clipboard contains an image and then
// reads the data using a temporary approach. If the clipboard contains
// an image, it writes it to stdout as PNG data.
// It uses osascript to check if the clipboard contains an image via
// NSPasteboard and writes it to stdout as PNG data.
func ReadImage() (*ImageData, error) {
// Use osascript to write clipboard image to stdout via a pipe.
// The script checks if the clipboard has a «class PNGf» item.
+1 -1
View File
@@ -41,7 +41,7 @@ func ReadImage() (*ImageData, error) {
return nil, ErrNoImage
}
return nil, ErrNoClipboardTool
return nil, errNoClipboardTool
}
// readWithXclip reads image data using xclip.
+1 -1
View File
@@ -5,5 +5,5 @@ package clipboard
// ReadImage reads image data from the system clipboard on Windows.
// Windows clipboard image support is not yet implemented.
func ReadImage() (*ImageData, error) {
return nil, ErrNoClipboardTool
return nil, errNoClipboardTool
}
+3 -3
View File
@@ -19,8 +19,8 @@ import (
// Token estimation
// ---------------------------------------------------------------------------
// EstimateTokens provides a rough token count (~4 chars per token).
func EstimateTokens(text string) int {
// estimateTokens provides a rough token count (~4 chars per token).
func estimateTokens(text string) int {
return len(text) / 4
}
@@ -40,7 +40,7 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
total := 0
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
total += EstimateTokens(tp.Text)
total += estimateTokens(tp.Text)
}
}
return total
+2 -2
View File
@@ -36,9 +36,9 @@ func TestEstimateTokens(t *testing.T) {
{"hello world", 2}, // 11 / 4 = 2
}
for _, tt := range tests {
got := EstimateTokens(tt.text)
got := estimateTokens(tt.text)
if got != tt.want {
t.Errorf("EstimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
t.Errorf("estimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
}
}
}
+49 -37
View File
@@ -105,42 +105,56 @@ type AdaptiveColor struct {
Dark string `json:"dark,omitempty" yaml:"dark,omitempty"`
}
// MarkdownThemeConfig defines color overrides for markdown rendering and
// syntax highlighting.
type MarkdownThemeConfig struct {
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
Heading AdaptiveColor `json:"heading,omitzero" yaml:"heading,omitempty"`
Emph AdaptiveColor `json:"emph,omitzero" yaml:"emph,omitempty"`
Strong AdaptiveColor `json:"strong,omitzero" yaml:"strong,omitempty"`
Link AdaptiveColor `json:"link,omitzero" yaml:"link,omitempty"`
Code AdaptiveColor `json:"code,omitzero" yaml:"code,omitempty"`
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
Keyword AdaptiveColor `json:"keyword,omitzero" yaml:"keyword,omitempty"`
String AdaptiveColor `json:"string,omitzero" yaml:"string,omitempty"`
Number AdaptiveColor `json:"number,omitzero" yaml:"number,omitempty"`
Comment AdaptiveColor `json:"comment,omitzero" yaml:"comment,omitempty"`
}
// Theme defines the color scheme for the application UI with adaptive colors
// that support both light and dark modes.
type Theme struct {
Primary AdaptiveColor `json:"primary" yaml:"primary"`
Secondary AdaptiveColor `json:"secondary" yaml:"secondary"`
Success AdaptiveColor `json:"success" yaml:"success"`
Warning AdaptiveColor `json:"warning" yaml:"warning"`
Error AdaptiveColor `json:"error" yaml:"error"`
Info AdaptiveColor `json:"info" yaml:"info"`
Text AdaptiveColor `json:"text" yaml:"text"`
Muted AdaptiveColor `json:"muted" yaml:"muted"`
VeryMuted AdaptiveColor `json:"very-muted" yaml:"very-muted"`
Background AdaptiveColor `json:"background" yaml:"background"`
Border AdaptiveColor `json:"border" yaml:"border"`
MutedBorder AdaptiveColor `json:"muted-border" yaml:"muted-border"`
System AdaptiveColor `json:"system" yaml:"system"`
Tool AdaptiveColor `json:"tool" yaml:"tool"`
Accent AdaptiveColor `json:"accent" yaml:"accent"`
Highlight AdaptiveColor `json:"highlight" yaml:"highlight"`
}
Primary AdaptiveColor `json:"primary,omitzero" yaml:"primary,omitempty"`
Secondary AdaptiveColor `json:"secondary,omitzero" yaml:"secondary,omitempty"`
Success AdaptiveColor `json:"success,omitzero" yaml:"success,omitempty"`
Warning AdaptiveColor `json:"warning,omitzero" yaml:"warning,omitempty"`
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
Info AdaptiveColor `json:"info,omitzero" yaml:"info,omitempty"`
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
VeryMuted AdaptiveColor `json:"very-muted,omitzero" yaml:"very-muted,omitempty"`
Background AdaptiveColor `json:"background,omitzero" yaml:"background,omitempty"`
Border AdaptiveColor `json:"border,omitzero" yaml:"border,omitempty"`
MutedBorder AdaptiveColor `json:"muted-border,omitzero" yaml:"muted-border,omitempty"`
System AdaptiveColor `json:"system,omitzero" yaml:"system,omitempty"`
Tool AdaptiveColor `json:"tool,omitzero" yaml:"tool,omitempty"`
Accent AdaptiveColor `json:"accent,omitzero" yaml:"accent,omitempty"`
Highlight AdaptiveColor `json:"highlight,omitzero" yaml:"highlight,omitempty"`
// MarkdownTheme defines the color scheme for markdown rendering with syntax
// highlighting support and adaptive colors for light and dark modes.
type MarkdownTheme struct {
Text AdaptiveColor `json:"text" yaml:"text"`
Muted AdaptiveColor `json:"muted" yaml:"muted"`
Heading AdaptiveColor `json:"heading" yaml:"heading"`
Emph AdaptiveColor `json:"emph" yaml:"emph"`
Strong AdaptiveColor `json:"strong" yaml:"strong"`
Link AdaptiveColor `json:"link" yaml:"link"`
Code AdaptiveColor `json:"code" yaml:"code"`
Error AdaptiveColor `json:"error" yaml:"error"`
Keyword AdaptiveColor `json:"keyword" yaml:"keyword"`
String AdaptiveColor `json:"string" yaml:"string"`
Number AdaptiveColor `json:"number" yaml:"number"`
Comment AdaptiveColor `json:"comment" yaml:"comment"`
// Diff block backgrounds
DiffInsertBg AdaptiveColor `json:"diff-insert-bg,omitzero" yaml:"diff-insert-bg,omitempty"`
DiffDeleteBg AdaptiveColor `json:"diff-delete-bg,omitzero" yaml:"diff-delete-bg,omitempty"`
DiffEqualBg AdaptiveColor `json:"diff-equal-bg,omitzero" yaml:"diff-equal-bg,omitempty"`
DiffMissingBg AdaptiveColor `json:"diff-missing-bg,omitzero" yaml:"diff-missing-bg,omitempty"`
// Code/output block backgrounds
CodeBg AdaptiveColor `json:"code-bg,omitzero" yaml:"code-bg,omitempty"`
GutterBg AdaptiveColor `json:"gutter-bg,omitzero" yaml:"gutter-bg,omitempty"`
WriteBg AdaptiveColor `json:"write-bg,omitzero" yaml:"write-bg,omitempty"`
// Markdown rendering and syntax highlighting
Markdown MarkdownThemeConfig `json:"markdown,omitzero" yaml:"markdown,omitempty"`
}
// Config represents the complete application configuration including MCP servers,
@@ -157,7 +171,6 @@ type Config struct {
ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"`
Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"`
Theme any `json:"theme" yaml:"theme"`
MarkdownTheme any `json:"markdown-theme" yaml:"markdown-theme"`
// Model generation parameters
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
@@ -373,11 +386,10 @@ func FilepathOr[T any](key string, value *T) error {
fmt.Fprintf(os.Stderr, "%q", err)
os.Exit(1)
}
if filepath.Ext(absPath) == ".json" {
switch filepath.Ext(absPath) {
case ".json":
return json.Unmarshal(b, value)
}
if filepath.Ext(absPath) == ".yaml" {
case ".yaml", ".yml":
return yaml.Unmarshal(b, value)
}
}
+110
View File
@@ -174,6 +174,22 @@ type Context struct {
// }
PromptInput func(PromptInputConfig) PromptInputResult
// PromptMultiSelect shows a multi-selection list to the user, allowing
// them to toggle options with spacebar and confirm with enter. In
// non-interactive mode, returns all options as selected.
//
// Example:
//
// result := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{
// Message: "Select extensions to install:",
// Options: []string{"git", "todo", "weather"},
// DefaultSelected: []int{0, 1, 2}, // All selected by default
// })
// if !result.Cancelled {
// fmt.Println("Selected:", result.Values)
// }
PromptMultiSelect func(PromptMultiSelectConfig) PromptMultiSelectResult
// ShowOverlay displays a modal overlay dialog that blocks until the
// user dismisses it or selects an action. The overlay renders as a
// centered (or anchored) bordered box over the TUI. Returns a
@@ -469,6 +485,36 @@ type Context struct {
// ctx.RenderMessage("build-status", "All 42 tests passed.")
RenderMessage func(rendererName string, content string)
// RegisterTheme adds a named theme to the runtime theme registry.
// If a theme with the same name already exists it is replaced.
// The theme becomes available via /theme and ctx.SetTheme().
//
// Example:
//
// ctx.RegisterTheme("neon", ext.ThemeColorConfig{
// Primary: ext.ThemeColor{Dark: "#FF00FF"},
// Secondary: ext.ThemeColor{Dark: "#00FFFF"},
// Success: ext.ThemeColor{Dark: "#00FF00"},
// Warning: ext.ThemeColor{Dark: "#FFFF00"},
// Error: ext.ThemeColor{Dark: "#FF0000"},
// Info: ext.ThemeColor{Dark: "#00FFFF"},
// Text: ext.ThemeColor{Dark: "#FFFFFF"},
// Background: ext.ThemeColor{Dark: "#000000"},
// })
RegisterTheme func(name string, config ThemeColorConfig)
// SetTheme switches the active color theme by name. The name must
// match a built-in theme, a user/project theme file, or a theme
// registered via RegisterTheme. Returns an error if not found.
//
// Example:
//
// err := ctx.SetTheme("neon")
SetTheme func(name string) error
// ListThemes returns the names of all available themes.
ListThemes func() []string
// ReloadExtensions hot-reloads all extensions from disk. Existing
// extensions receive a SessionShutdown event, then new code is loaded
// and receives a SessionStart event. Event handlers, commands,
@@ -1000,6 +1046,29 @@ type PromptInputResult struct {
Cancelled bool
}
// PromptMultiSelectConfig configures a multi-selection prompt that allows
// the user to toggle multiple options and confirm their selection.
type PromptMultiSelectConfig struct {
// Message is the question or instruction displayed to the user.
Message string
// Options is the list of choices the user can select from.
Options []string
// DefaultSelected contains indices of options that should be
// pre-selected when the prompt appears. If nil, all options are selected.
DefaultSelected []int
}
// PromptMultiSelectResult is the response from a multi-selection prompt.
type PromptMultiSelectResult struct {
// Values contains the text of selected options.
Values []string
// Indices contains the zero-based indices of selected options.
Indices []int
// Cancelled is true if the user dismissed the prompt (ESC) or
// the prompt was unavailable (non-interactive mode).
Cancelled bool
}
// ---------------------------------------------------------------------------
// Header/Footer types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
@@ -1684,3 +1753,44 @@ type BeforeCompactResult struct {
}
func (BeforeCompactResult) isResult() {}
// ---------------------------------------------------------------------------
// Theme types (exposed to Yaegi — concrete structs, string hex colors)
// ---------------------------------------------------------------------------
// ThemeColor is an adaptive color pair with light and dark hex values.
// Either field may be empty to inherit from the default theme.
type ThemeColor struct {
Light string
Dark string
}
// ThemeColorConfig defines a complete color theme that extensions can register
// programmatically via ctx.RegisterTheme(). Uses plain hex strings (not
// color.Color) so the type is safe to pass across the Yaegi boundary.
type ThemeColorConfig struct {
Primary ThemeColor
Secondary ThemeColor
Success ThemeColor
Warning ThemeColor
Error ThemeColor
Info ThemeColor
Text ThemeColor
Muted ThemeColor
VeryMuted ThemeColor
Background ThemeColor
Border ThemeColor
MutedBorder ThemeColor
System ThemeColor
Tool ThemeColor
Accent ThemeColor
Highlight ThemeColor
// Markdown/syntax highlighting overrides.
MdHeading ThemeColor
MdLink ThemeColor
MdKeyword ThemeColor
MdString ThemeColor
MdNumber ThemeColor
MdComment ThemeColor
}
+537
View File
@@ -0,0 +1,537 @@
package extensions
import (
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// InstallScope defines where a package should be installed.
type InstallScope string
const (
ScopeGlobal InstallScope = "global"
ScopeProject InstallScope = "project"
)
// GitSource represents a parsed git repository URL.
type GitSource struct {
Repo string // Clone URL (e.g., https://github.com/user/repo.git)
Host string // Host (e.g., github.com)
Path string // Path (e.g., user/repo)
Ref string // Optional ref (tag, branch, commit)
Pinned bool // Whether a specific ref is pinned
}
// String returns the canonical string representation.
func (g GitSource) String() string {
if g.Pinned {
return fmt.Sprintf("git:%s/%s@%s", g.Host, g.Path, g.Ref)
}
return fmt.Sprintf("git:%s/%s", g.Host, g.Path)
}
// Identity returns a normalized identity string for deduplication.
func (g GitSource) Identity() string {
return fmt.Sprintf("%s/%s", g.Host, g.Path)
}
// ParseGitSource parses a git source string into a GitSource.
// Supports formats like:
// - git:github.com/user/repo
// - git:github.com/user/repo@v1.0.0
// - https://github.com/user/repo
// - https://github.com/user/repo@v1.0.0
// - ssh://git@github.com/user/repo
// - git@github.com:user/repo
// - github.com/user/repo (shorthand, defaults to https)
func ParseGitSource(source string) (*GitSource, error) {
source = strings.TrimSpace(source)
// Check for @ref suffix
ref := ""
pinned := false
if atIdx := strings.LastIndex(source, "@"); atIdx > 0 {
// Make sure it's not part of the protocol (e.g., @ in ssh://git@)
after := source[atIdx+1:]
if !strings.Contains(after, "/") && !strings.Contains(after, ":") {
ref = after
pinned = true
source = source[:atIdx]
}
}
// Handle git: prefix
source, _ = strings.CutPrefix(source, "git:")
var repo, host, path string
// Handle explicit URLs
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
u, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
host = u.Host
path = strings.TrimPrefix(u.Path, "/")
path, _ = strings.CutSuffix(path, ".git")
repo = source
if !strings.HasSuffix(repo, ".git") {
repo += ".git"
}
} else if strings.HasPrefix(source, "ssh://") {
u, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid SSH URL: %w", err)
}
host = u.Host
path = strings.TrimPrefix(u.Path, "/")
path, _ = strings.CutSuffix(path, ".git")
repo = source
} else if strings.HasPrefix(source, "git@") {
// SSH shorthand: git@github.com:user/repo
parts := strings.SplitN(source, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid SSH shorthand format")
}
host = strings.TrimPrefix(parts[0], "git@")
path = parts[1]
path, _ = strings.CutSuffix(path, ".git")
repo = source
} else if strings.HasPrefix(source, "github.com/") || strings.HasPrefix(source, "gitlab.com/") || strings.HasPrefix(source, "bitbucket.org/") {
// Shorthand for known hosts: host/path
parts := strings.SplitN(source, "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid shorthand format, expected host/path")
}
host = parts[0]
path = parts[1]
repo = fmt.Sprintf("https://%s/%s.git", host, path)
} else if strings.HasPrefix(source, ".") || strings.HasPrefix(source, "/") || strings.HasPrefix(source, "~") {
// Local paths are not supported
return nil, fmt.Errorf("local paths not supported, use explicit extension path with -e flag")
} else {
// Generic shorthand: host/user/repo (3+ path segments)
parts := strings.Split(source, "/")
if len(parts) >= 3 {
host = parts[0]
path = strings.Join(parts[1:], "/")
repo = fmt.Sprintf("https://%s/%s.git", host, path)
} else {
return nil, fmt.Errorf("unrecognized source format: %s", source)
}
}
return &GitSource{
Repo: repo,
Host: host,
Path: path,
Ref: ref,
Pinned: pinned,
}, nil
}
// Installer handles installing, updating, and removing git-based extensions.
type Installer struct {
// Global packages root: $XDG_DATA_HOME/kit/git/ (default ~/.local/share/kit/git/)
globalGitRoot string
// Project packages root: .kit/git/
projectGitRoot string
}
// NewInstaller creates a new Installer.
func NewInstaller(projectDir string) *Installer {
return &Installer{
globalGitRoot: globalGitInstallRoot(),
projectGitRoot: filepath.Join(projectDir, ".kit", "git"),
}
}
// Install clones a git repository to the appropriate scope.
func (i *Installer) Install(source *GitSource, scope InstallScope) error {
targetDir := i.getInstallPath(source, scope)
// Check if already installed
if _, err := os.Stat(targetDir); err == nil {
return fmt.Errorf("extension already installed at %s", targetDir)
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetDir), 0755); err != nil {
return fmt.Errorf("creating parent directory: %w", err)
}
// Clone the repository
cmd := exec.Command("git", "clone", "--depth=1", source.Repo, targetDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git clone failed: %w\n%s", err, string(output))
}
// Checkout specific ref if pinned
if source.Pinned && source.Ref != "" {
checkoutCmd := exec.Command("git", "checkout", source.Ref)
checkoutCmd.Dir = targetDir
if output, err := checkoutCmd.CombinedOutput(); err != nil {
// Clean up on failed checkout
_ = os.RemoveAll(targetDir)
return fmt.Errorf("git checkout failed: %w\n%s", err, string(output))
}
}
// Validate that the package contains valid extensions
if err := i.validatePackage(targetDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("validation failed: %w", err)
}
// Add to manifest
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: source.Ref,
Pinned: source.Pinned,
Scope: scope,
Installed: time.Now(),
}
if err := i.addToManifest(entry, scope); err != nil {
// Don't fail the install, just log the error
// The package is installed, manifest update failed
return fmt.Errorf("installed but failed to update manifest: %w", err)
}
return nil
}
// Uninstall removes an installed package.
func (i *Installer) Uninstall(source *GitSource, scope InstallScope) error {
targetDir := i.getInstallPath(source, scope)
if _, err := os.Stat(targetDir); err != nil {
return fmt.Errorf("extension not found at %s", targetDir)
}
// Remove the directory
if err := os.RemoveAll(targetDir); err != nil {
return fmt.Errorf("removing extension directory: %w", err)
}
// Remove from manifest
if err := i.removeFromManifest(source.Identity(), scope); err != nil {
return fmt.Errorf("removed but failed to update manifest: %w", err)
}
return nil
}
// Update fetches and resets a git package to the latest.
// For pinned packages, this does nothing.
func (i *Installer) Update(source *GitSource, scope InstallScope) error {
if source.Pinned {
return nil // Don't update pinned packages
}
targetDir := i.getInstallPath(source, scope)
if _, err := os.Stat(targetDir); err != nil {
return i.Install(source, scope)
}
// Fetch latest
fetchCmd := exec.Command("git", "fetch", "--prune", "origin")
fetchCmd.Dir = targetDir
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch failed: %w\n%s", err, string(output))
}
// Reset to tracking branch or origin/HEAD
resetCmd := exec.Command("git", "reset", "--hard", "@{upstream}")
resetCmd.Dir = targetDir
if _, err := resetCmd.CombinedOutput(); err != nil {
// Try alternative: set HEAD and reset to origin/HEAD
_ = exec.Command("git", "remote", "set-head", "origin", "-a").Run()
resetCmd = exec.Command("git", "reset", "--hard", "origin/HEAD")
resetCmd.Dir = targetDir
if output, err := resetCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git reset failed: %w\n%s", err, string(output))
}
}
// Clean untracked files
cleanCmd := exec.Command("git", "clean", "-fdx")
cleanCmd.Dir = targetDir
_ = cleanCmd.Run() // Ignore errors - clean is best effort
// Update manifest timestamp
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: "",
Pinned: false,
Scope: scope,
Installed: time.Now(),
Updated: time.Now(),
}
_ = i.addToManifest(entry, scope) // Best effort - don't fail update if manifest fails
return nil
}
// getInstallPath returns the target directory for a source.
func (i *Installer) getInstallPath(source *GitSource, scope InstallScope) string {
root := i.globalGitRoot
if scope == ScopeProject {
root = i.projectGitRoot
}
return filepath.Join(root, source.Host, source.Path)
}
// validatePackage checks that the cloned repo contains valid .go extension files.
func (i *Installer) validatePackage(dir string) error {
// Find all .go files in the directory
var goFiles []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") {
goFiles = append(goFiles, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walking directory: %w", err)
}
if len(goFiles) == 0 {
return fmt.Errorf("no .go files found in package")
}
// Try to load the first .go file to validate it's a valid extension
// We don't fail if validation fails - the extension might be fine but
// have dependencies that aren't available during install time
_, err = loadSingleExtension(goFiles[0])
if err != nil {
// Log but don't fail - the extension might need runtime deps
// User can use `kit extensions validate` to check later
return nil
}
return nil
}
// addToManifest adds an entry to the manifest.
func (i *Installer) addToManifest(entry ManifestEntry, scope InstallScope) error {
manifest, err := i.loadManifest(scope)
if err != nil {
return err
}
// Remove any existing entry with same identity
identity := entry.Host + "/" + entry.Path
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Host+"/"+p.Path != identity {
filtered = append(filtered, p)
}
}
filtered = append(filtered, entry)
manifest.Packages = filtered
return i.saveManifest(manifest, scope)
}
// removeFromManifest removes an entry from the manifest by identity.
func (i *Installer) removeFromManifest(identity string, scope InstallScope) error {
manifest, err := i.loadManifest(scope)
if err != nil {
return err
}
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Host+"/"+p.Path != identity {
filtered = append(filtered, p)
}
}
manifest.Packages = filtered
return i.saveManifest(manifest, scope)
}
// loadManifest loads the manifest for the given scope.
func (i *Installer) loadManifest(scope InstallScope) (*Manifest, error) {
path := i.manifestPath(scope)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Manifest{Packages: []ManifestEntry{}}, nil
}
return nil, err
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &manifest, nil
}
// saveManifest saves the manifest for the given scope.
func (i *Installer) saveManifest(manifest *Manifest, scope InstallScope) error {
path := i.manifestPath(scope)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// manifestPath returns the path to the manifest file.
func (i *Installer) manifestPath(scope InstallScope) string {
if scope == ScopeProject {
return filepath.Join(i.projectGitRoot, "packages.json")
}
return filepath.Join(i.globalGitRoot, "packages.json")
}
// globalGitInstallRoot returns the global git install root.
func globalGitInstallRoot() string {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "kit", "git")
}
// GetInstalledPackages returns all installed packages from both scopes.
func (i *Installer) GetInstalledPackages() ([]ManifestEntry, error) {
var all []ManifestEntry
global, err := i.loadManifest(ScopeGlobal)
if err != nil {
return nil, fmt.Errorf("loading global manifest: %w", err)
}
all = append(all, global.Packages...)
project, err := i.loadManifest(ScopeProject)
if err != nil {
return nil, fmt.Errorf("loading project manifest: %w", err)
}
all = append(all, project.Packages...)
return all, nil
}
// IsInstalled checks if a package is installed in either scope.
// Returns (scope, true) if installed, ("", false) otherwise.
func (i *Installer) IsInstalled(source *GitSource) (InstallScope, bool) {
globalPath := i.getInstallPath(source, ScopeGlobal)
if _, err := os.Stat(globalPath); err == nil {
return ScopeGlobal, true
}
projectPath := i.getInstallPath(source, ScopeProject)
if _, err := os.Stat(projectPath); err == nil {
return ScopeProject, true
}
return "", false
}
// PreviewExtensions clones a repo to a temporary directory and scans for extensions.
// Returns the preview list and the temp directory path (caller should clean up).
func (i *Installer) PreviewExtensions(source *GitSource) ([]ExtensionPreview, string, error) {
// Create temp directory
tempDir, err := os.MkdirTemp("", "kit-install-preview-*")
if err != nil {
return nil, "", fmt.Errorf("creating temp directory: %w", err)
}
// Clone to temp
cloneDir := filepath.Join(tempDir, "repo")
cmd := exec.Command("git", "clone", "--depth=1", source.Repo, cloneDir)
if output, err := cmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("git clone failed: %w\n%s", err, string(output))
}
// Checkout specific ref if pinned
if source.Pinned && source.Ref != "" {
checkoutCmd := exec.Command("git", "checkout", source.Ref)
checkoutCmd.Dir = cloneDir
if output, err := checkoutCmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("git checkout failed: %w\n%s", err, string(output))
}
}
// Scan for extensions
previews, err := ScanForExtensions(cloneDir)
if err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("scanning extensions: %w", err)
}
return previews, tempDir, nil
}
// InstallWithInclude clones a repo and installs only the specified extensions.
// includePaths are relative paths like "./git/main.go" - if empty, installs all.
func (i *Installer) InstallWithInclude(source *GitSource, scope InstallScope, includePaths []string) error {
// First, do a regular install
if err := i.Install(source, scope); err != nil {
return err
}
// If specific includes were requested, update the manifest
if len(includePaths) > 0 {
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: source.Ref,
Pinned: source.Pinned,
Scope: scope,
Include: includePaths,
}
if err := addEntryToManifest(entry, scope); err != nil {
return fmt.Errorf("updating manifest with includes: %w", err)
}
}
return nil
}
// CleanupTempDir removes a temporary directory used for preview.
func CleanupTempDir(tempDir string) {
if tempDir != "" {
_ = os.RemoveAll(tempDir)
}
}
+392
View File
@@ -0,0 +1,392 @@
package extensions
import (
"os"
"path/filepath"
"testing"
)
func TestParseGitSource(t *testing.T) {
tests := []struct {
name string
source string
wantRepo string
wantHost string
wantPath string
wantRef string
wantPinned bool
wantErr bool
}{
{
name: "github shorthand",
source: "github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "github shorthand with version",
source: "github.com/user/repo@v1.0.0",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "v1.0.0",
wantPinned: true,
},
{
name: "git prefix shorthand",
source: "git:github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "https URL",
source: "https://github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "https URL with .git suffix",
source: "https://github.com/user/repo.git",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "ssh shorthand",
source: "git@github.com:user/repo",
wantRepo: "git@github.com:user/repo",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "ssh URL",
source: "ssh://git@github.com/user/repo",
wantRepo: "ssh://git@github.com/user/repo",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "gitlab shorthand",
source: "gitlab.com/user/repo",
wantRepo: "https://gitlab.com/user/repo.git",
wantHost: "gitlab.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "bitbucket shorthand",
source: "bitbucket.org/user/repo",
wantRepo: "https://bitbucket.org/user/repo.git",
wantHost: "bitbucket.org",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "generic host",
source: "gitea.example.com/user/repo",
wantRepo: "https://gitea.example.com/user/repo.git",
wantHost: "gitea.example.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "with branch ref",
source: "github.com/user/repo@main",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "main",
wantPinned: true,
},
{
name: "with commit ref",
source: "github.com/user/repo@abc1234",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "abc1234",
wantPinned: true,
},
{
name: "local path should error",
source: "./local/path",
wantErr: true,
},
{
name: "absolute path should error",
source: "/absolute/path",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseGitSource(tt.source)
if (err != nil) != tt.wantErr {
t.Errorf("ParseGitSource() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
if got.Repo != tt.wantRepo {
t.Errorf("ParseGitSource() Repo = %v, want %v", got.Repo, tt.wantRepo)
}
if got.Host != tt.wantHost {
t.Errorf("ParseGitSource() Host = %v, want %v", got.Host, tt.wantHost)
}
if got.Path != tt.wantPath {
t.Errorf("ParseGitSource() Path = %v, want %v", got.Path, tt.wantPath)
}
if got.Ref != tt.wantRef {
t.Errorf("ParseGitSource() Ref = %v, want %v", got.Ref, tt.wantRef)
}
if got.Pinned != tt.wantPinned {
t.Errorf("ParseGitSource() Pinned = %v, want %v", got.Pinned, tt.wantPinned)
}
})
}
}
func TestGitSourceIdentity(t *testing.T) {
source := &GitSource{
Host: "github.com",
Path: "user/repo",
}
if got := source.Identity(); got != "github.com/user/repo" {
t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo")
}
}
func TestGitSourceString(t *testing.T) {
tests := []struct {
name string
source GitSource
want string
}{
{
name: "unpinned",
source: GitSource{
Host: "github.com",
Path: "user/repo",
Pinned: false,
},
want: "git:github.com/user/repo",
},
{
name: "pinned",
source: GitSource{
Host: "github.com",
Path: "user/repo",
Ref: "v1.0.0",
Pinned: true,
},
want: "git:github.com/user/repo@v1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.source.String(); got != tt.want {
t.Errorf("String() = %v, want %v", got, tt.want)
}
})
}
}
func TestInstallerGetInstallPath(t *testing.T) {
tempDir := t.TempDir()
installer := NewInstaller(tempDir)
source := &GitSource{
Host: "github.com",
Path: "user/repo",
}
// Test global scope
globalPath := installer.getInstallPath(source, ScopeGlobal)
if !filepath.IsAbs(globalPath) {
t.Error("Global install path should be absolute")
}
// Test project scope
projectPath := installer.getInstallPath(source, ScopeProject)
expectedProjectPath := filepath.Join(tempDir, ".kit", "git", "github.com", "user", "repo")
if projectPath != expectedProjectPath {
t.Errorf("Project path = %v, want %v", projectPath, expectedProjectPath)
}
}
func TestManifestEntryIdentity(t *testing.T) {
entry := ManifestEntry{
Host: "github.com",
Path: "user/repo",
}
if got := entry.Identity(); got != "github.com/user/repo" {
t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo")
}
}
func TestLoadAndSaveManifest(t *testing.T) {
tempDir := t.TempDir()
manifestPath := filepath.Join(tempDir, "packages.json")
// Test loading non-existent manifest
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected empty packages, got %d", len(manifest.Packages))
}
// Create a manifest
manifest = &Manifest{
Packages: []ManifestEntry{
{
Source: "git:github.com/user/repo",
Repo: "https://github.com/user/repo.git",
Host: "github.com",
Path: "user/repo",
Pinned: false,
Scope: ScopeGlobal,
},
},
}
// Save it
err = saveManifestToPath(manifest, manifestPath)
if err != nil {
t.Fatalf("saveManifestToPath() error = %v", err)
}
// Load it back
loaded, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(loaded.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(loaded.Packages))
}
if loaded.Packages[0].Host != "github.com" {
t.Errorf("Expected host github.com, got %s", loaded.Packages[0].Host)
}
}
func TestAddAndRemoveFromManifest(t *testing.T) {
tempDir := t.TempDir()
// Set up environment for manifest path
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// The manifest path when XDG_DATA_HOME is set
manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json")
// Add an entry
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Verify it was added
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(manifest.Packages))
}
// Remove it
err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal)
if err != nil {
t.Fatalf("removeEntryFromManifest() error = %v", err)
}
// Verify it was removed
manifest, err = loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected 0 packages, got %d", len(manifest.Packages))
}
}
func TestFindInManifest(t *testing.T) {
tempDir := t.TempDir()
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// Add an entry to global manifest
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Find it
found, scope, err := FindInManifest("github.com/user/repo")
if err != nil {
t.Fatalf("FindInManifest() error = %v", err)
}
if found == nil {
t.Fatal("Expected to find entry, got nil")
}
if scope != ScopeGlobal {
t.Errorf("Expected scope global, got %s", scope)
}
// Try to find non-existent
notFound, _, err := FindInManifest("github.com/other/repo")
if err != nil {
t.Fatalf("FindInManifest() error = %v", err)
}
if notFound != nil {
t.Error("Expected nil for non-existent entry")
}
}
+225
View File
@@ -71,12 +71,24 @@ func discoverExtensionPaths(extraPaths []string) []string {
add(p)
}
// Global installed git packages: $XDG_DATA_HOME/kit/git/
globalGitDir := globalGitInstallRoot()
for _, p := range findExtensionsInGitPackages(globalGitDir) {
add(p)
}
// Project-local extensions: .kit/extensions/
localDir := filepath.Join(".kit", "extensions")
for _, p := range findExtensionsInDir(localDir) {
add(p)
}
// Project-local installed git packages: .kit/git/
projectGitDir := filepath.Join(".kit", "git")
for _, p := range findExtensionsInGitPackages(projectGitDir) {
add(p)
}
// Explicit paths (highest precedence)
for _, p := range extraPaths {
info, err := os.Stat(p)
@@ -123,6 +135,219 @@ func findExtensionsInDir(dir string) []string {
return results
}
// findExtensionsInRepo scans a git repository for extensions using opinionated conventions.
// Extensions are ONLY recognized in:
// 1. Root-level *.go files
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
// 3. Files in any top-level ext/ directory
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
//
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
func findExtensionsInRepo(repoPath string) []string {
var results []string
multiFileDirs := make(map[string]bool)
_ = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(repoPath, path)
relPath = filepath.ToSlash(relPath)
// Skip directories we know don't contain extensions
if info.IsDir() {
switch info.Name() {
case ".git", ".github", "node_modules", "vendor", "dist", "build":
return filepath.SkipDir
}
// Skip internal code directories
if strings.HasPrefix(relPath, "internal/") ||
strings.HasPrefix(relPath, "cmd/") ||
strings.HasPrefix(relPath, "pkg/") ||
strings.HasPrefix(relPath, "test/") ||
strings.HasPrefix(relPath, "tests/") {
return filepath.SkipDir
}
// Root directory - scan it
if relPath == "." {
return nil
}
base := info.Name()
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
if !isExtDir && !isExamplesSubdir {
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if relPath == base { // Top-level directory
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
}
return filepath.SkipDir
}
// Check for main.go
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
return nil
}
// It's a file
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
if info.Name() == "main.go" {
return nil
}
parentDir := filepath.Dir(relPath)
if parentDir == "." {
// Root-level .go file - valid extension
results = append(results, path)
return nil
}
// Must be in valid extension directory
isValidExtDir := false
if strings.HasPrefix(parentDir, "examples/extensions/") ||
parentDir == "examples/extensions" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
parentDir == "examples/ext" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "ext/") ||
parentDir == "ext" {
isValidExtDir = true
} else if strings.Contains(parentDir, "-extensions/") ||
strings.HasSuffix(parentDir, "-extensions") {
isValidExtDir = true
} else if strings.Contains(parentDir, "-ext/") ||
strings.HasSuffix(parentDir, "-ext") {
isValidExtDir = true
}
if !isValidExtDir {
return nil
}
results = append(results, path)
return nil
})
return results
}
// Each git package is stored at <gitRoot>/<host>/<owner>/<repo>/ and can contain
// .go files or a main.go in subdirectories.
// If a package has a manifest with Include field, only those paths are loaded.
func findExtensionsInGitPackages(gitRoot string) []string {
info, err := os.Stat(gitRoot)
if err != nil || !info.IsDir() {
return nil
}
var results []string
// Load the manifest if it exists
manifestPath := filepath.Join(gitRoot, "packages.json")
manifest, _ := loadManifestFromPath(manifestPath)
// Build a map of package identity -> include list
includeMap := make(map[string][]string)
if manifest != nil {
for _, entry := range manifest.Packages {
if len(entry.Include) > 0 {
identity := fmt.Sprintf("%s/%s", entry.Host, entry.Path)
includeMap[identity] = entry.Include
}
}
}
// Walk through host directories (e.g., github.com/)
hosts, err := os.ReadDir(gitRoot)
if err != nil {
return nil
}
for _, host := range hosts {
if !host.IsDir() {
continue
}
hostPath := filepath.Join(gitRoot, host.Name())
// Walk through owner directories (e.g., github.com/user/)
owners, err := os.ReadDir(hostPath)
if err != nil {
continue
}
for _, owner := range owners {
if !owner.IsDir() {
continue
}
ownerPath := filepath.Join(hostPath, owner.Name())
// Walk through repo directories (e.g., github.com/user/repo/)
repos, err := os.ReadDir(ownerPath)
if err != nil {
continue
}
for _, repo := range repos {
if !repo.IsDir() {
continue
}
repoPath := filepath.Join(ownerPath, repo.Name())
// Check if there's an include filter for this package
identity := fmt.Sprintf("%s/%s/%s", host.Name(), owner.Name(), repo.Name())
includes, hasFilter := includeMap[identity]
if hasFilter {
// Only include specific paths
for _, include := range includes {
// Convert relative path to absolute
include = strings.TrimPrefix(include, "./")
fullPath := filepath.Join(repoPath, filepath.FromSlash(include))
if _, err := os.Stat(fullPath); err == nil {
results = append(results, fullPath)
}
}
} else {
// Find all extensions within this repo using convention-based scanning
results = append(results, findExtensionsInRepo(repoPath)...)
}
}
}
}
return results
}
// globalExtensionsDir returns the global extensions directory, respecting
// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/extensions.
func globalExtensionsDir() string {
+9
View File
@@ -304,6 +304,15 @@ func Init(api ext.API) {
func TestLoadExtensions_SkipsBadFiles(t *testing.T) {
dir := t.TempDir()
// Isolate from host environment so globally-installed extensions
// are not discovered alongside the test fixtures.
isolated := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, "config"))
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, "data"))
origWd, _ := os.Getwd()
_ = os.Chdir(isolated)
t.Cleanup(func() { _ = os.Chdir(origWd) })
// Good extension
good := `package main
import "kit/ext"
+398
View File
@@ -0,0 +1,398 @@
package extensions
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Manifest tracks installed git packages.
type Manifest struct {
Packages []ManifestEntry `json:"packages"`
}
// ManifestEntry represents a single installed package.
type ManifestEntry struct {
// Source is the canonical string representation (e.g., "git:github.com/user/repo@v1.0.0")
Source string `json:"source"`
// Repo is the clone URL
Repo string `json:"repo"`
// Host is the git host (e.g., github.com)
Host string `json:"host"`
// Path is the path on the host (e.g., user/repo)
Path string `json:"path"`
// Ref is the optional pinned ref (tag/branch/commit)
Ref string `json:"ref,omitempty"`
// Pinned indicates if the ref is pinned
Pinned bool `json:"pinned"`
// Scope is where the package is installed (global or project)
Scope InstallScope `json:"scope"`
// Installed is when the package was first installed
Installed time.Time `json:"installed"`
// Updated is when the package was last updated (only for unpinned, zero time means never updated)
Updated time.Time `json:"updated,omitzero"`
// Include is a list of relative paths to extensions that should be loaded.
// If empty, all extensions in the package are loaded.
// Paths are relative to the package root (e.g., "./git/main.go", "./weather.go")
Include []string `json:"include,omitempty"`
}
// Identity returns the normalized identity for deduplication.
func (e ManifestEntry) Identity() string {
return fmt.Sprintf("%s/%s", e.Host, e.Path)
}
// loadManifest loads the manifest from the given scope.
func loadManifestFromScope(scope InstallScope) (*Manifest, error) {
path := manifestPathForScope(scope)
return loadManifestFromPath(path)
}
// loadManifestFromPath loads a manifest from a specific file path.
func loadManifestFromPath(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Manifest{Packages: []ManifestEntry{}}, nil
}
return nil, fmt.Errorf("reading manifest: %w", err)
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &manifest, nil
}
// saveManifestToScope saves the manifest to the given scope.
func saveManifestToScope(manifest *Manifest, scope InstallScope) error {
path := manifestPathForScope(scope)
return saveManifestToPath(manifest, path)
}
// saveManifestToPath saves a manifest to a specific file path.
func saveManifestToPath(manifest *Manifest, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// manifestPathForScope returns the manifest file path for a scope.
func manifestPathForScope(scope InstallScope) string {
if scope == ScopeProject {
return filepath.Join(".kit", "git", "packages.json")
}
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "kit", "git", "packages.json")
}
// GetGlobalManifest returns the global manifest.
func GetGlobalManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeGlobal)
}
// GetProjectManifest returns the project manifest.
func GetProjectManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeProject)
}
// addEntryToManifest adds or replaces an entry in the manifest for a scope.
func addEntryToManifest(entry ManifestEntry, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
// Remove any existing entry with same identity
identity := entry.Identity()
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
filtered = append(filtered, entry)
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// removeEntryFromManifest removes an entry by identity from the manifest for a scope.
func removeEntryFromManifest(identity string, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// FindInManifest finds an entry by identity in either global or project manifest.
// Returns the entry and its scope, or nil if not found.
func FindInManifest(identity string) (*ManifestEntry, InstallScope, error) {
global, err := loadManifestFromScope(ScopeGlobal)
if err != nil {
return nil, "", fmt.Errorf("loading global manifest: %w", err)
}
for _, p := range global.Packages {
if p.Identity() == identity {
return &p, ScopeGlobal, nil
}
}
project, err := loadManifestFromScope(ScopeProject)
if err != nil {
return nil, "", fmt.Errorf("loading project manifest: %w", err)
}
for _, p := range project.Packages {
if p.Identity() == identity {
return &p, ScopeProject, nil
}
}
return nil, "", nil
}
// ExtensionPreview represents a discovered extension in a package before installation.
type ExtensionPreview struct {
// Path is the relative path from the package root (e.g., "./git/main.go")
Path string `json:"path"`
// Name is a display name for the extension (derived from path or metadata)
Name string `json:"name"`
// Description is an optional description (could be extracted from comments)
Description string `json:"description,omitempty"`
// IsMain indicates if this is a main.go in a subdirectory
IsMain bool `json:"is_main"`
}
// ScanForExtensions discovers all extensions in a directory using opinionated conventions.
// Extensions are ONLY recognized in these specific locations:
// 1. Root-level *.go files
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
// 3. Files in any top-level ext/ directory
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
//
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return nil, fmt.Errorf("not a directory: %s", dir)
}
var previews []ExtensionPreview
multiFileDirs := make(map[string]bool)
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(dir, path)
relPath = filepath.ToSlash(relPath)
// Skip directories we know don't contain extensions
if info.IsDir() {
// Never scan these directories
switch info.Name() {
case ".git", ".github", "node_modules", "vendor", "dist", "build":
return filepath.SkipDir
}
// Skip internal code directories
if strings.HasPrefix(relPath, "internal/") ||
strings.HasPrefix(relPath, "cmd/") ||
strings.HasPrefix(relPath, "pkg/") ||
strings.HasPrefix(relPath, "test/") ||
strings.HasPrefix(relPath, "tests/") {
return filepath.SkipDir
}
// Root directory - scan it
if relPath == "." {
return nil
}
// Check if this directory is an extension location by name
// Pattern: must be named "extensions", "ext", or end with those
base := info.Name()
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
// Or check if it's a subdirectory of examples/ that might contain extensions
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
if !isExtDir && !isExamplesSubdir {
// Check for main.go before skipping
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
// This is a package with main.go at root level
if relPath == base { // Top-level directory
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
// Inside a valid extensions directory
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
}
// Not an extension location
return filepath.SkipDir
}
// Check for main.go in this directory
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
// Scan this extensions directory
return nil
}
// It's a file - check if it's a valid extension
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
if info.Name() == "main.go" {
return nil // Already handled above
}
// Check if parent is a valid extension location
parentDir := filepath.Dir(relPath)
if parentDir == "." {
// Root-level .go file - valid extension
previews = append(previews, ExtensionPreview{
Path: "./" + relPath,
Name: deriveExtensionName(relPath, false),
IsMain: false,
})
return nil
}
// Check if we're in a valid extension directory
// Valid locations are:
// - examples/extensions/*
// - examples/ext/*
// - ext/* (top-level)
// - Any *-extensions/* or *-ext/* directory
isValidExtDir := false
if strings.HasPrefix(parentDir, "examples/extensions/") ||
parentDir == "examples/extensions" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
parentDir == "examples/ext" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "ext/") ||
parentDir == "ext" {
isValidExtDir = true
} else if strings.Contains(parentDir, "-extensions/") ||
strings.HasSuffix(parentDir, "-extensions") {
isValidExtDir = true
} else if strings.Contains(parentDir, "-ext/") ||
strings.HasSuffix(parentDir, "-ext") {
isValidExtDir = true
}
if !isValidExtDir {
return nil
}
previews = append(previews, ExtensionPreview{
Path: "./" + relPath,
Name: deriveExtensionName(relPath, false),
IsMain: false,
})
return nil
})
if err != nil {
return nil, err
}
return previews, nil
}
// deriveExtensionName creates a display name from a file path.
func deriveExtensionName(relPath string, isMain bool) string {
// Convert path to a readable name
// e.g., "git/main.go" -> "Git Extension"
// e.g., "weather.go" -> "Weather"
dir := filepath.Dir(relPath)
base := filepath.Base(relPath)
if isMain && dir != "." {
// Use immediate parent directory name for main.go files
name := filepath.Base(dir)
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, "-", " ")
return cases.Title(language.English).String(name) + " Extension"
}
// Use filename without extension
name := strings.TrimSuffix(base, ".go")
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, "-", " ")
return cases.Title(language.English).String(name)
}
+12 -6
View File
@@ -90,12 +90,14 @@ func Symbols() interp.Exports {
"EditorConfig": reflect.ValueOf((*EditorConfig)(nil)),
// Prompt types
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
"PromptMultiSelectConfig": reflect.ValueOf((*PromptMultiSelectConfig)(nil)),
"PromptMultiSelectResult": reflect.ValueOf((*PromptMultiSelectResult)(nil)),
// Context filtering types
"ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),
@@ -117,6 +119,10 @@ func Symbols() interp.Exports {
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
// Theme types
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
+169
View File
@@ -0,0 +1,169 @@
package extensions
// NewTestAPI creates an API object wired for testing.
// This is used by the test harness to load extensions and verify behavior.
// The registration functions wire handlers directly to the provided extension.
func NewTestAPI(ext *LoadedExtension) API {
reg := func(event EventType, fn HandlerFunc) {
ext.Handlers[event] = append(ext.Handlers[event], fn)
}
return API{
onToolCall: func(h func(ToolCallEvent, Context) *ToolCallResult) {
reg(ToolCall, func(e Event, c Context) Result {
r := h(e.(ToolCallEvent), c)
if r == nil {
return nil
}
return *r
})
},
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
reg(ToolExecutionStart, func(e Event, c Context) Result {
h(e.(ToolExecutionStartEvent), c)
return nil
})
},
onToolExecEnd: func(h func(ToolExecutionEndEvent, Context)) {
reg(ToolExecutionEnd, func(e Event, c Context) Result {
h(e.(ToolExecutionEndEvent), c)
return nil
})
},
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
reg(ToolResult, func(e Event, c Context) Result {
r := h(e.(ToolResultEvent), c)
if r == nil {
return nil
}
return *r
})
},
onInput: func(h func(InputEvent, Context) *InputResult) {
reg(Input, func(e Event, c Context) Result {
r := h(e.(InputEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeAgentStart: func(h func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) {
reg(BeforeAgentStart, func(e Event, c Context) Result {
r := h(e.(BeforeAgentStartEvent), c)
if r == nil {
return nil
}
return *r
})
},
onAgentStart: func(h func(AgentStartEvent, Context)) {
reg(AgentStart, func(e Event, c Context) Result {
h(e.(AgentStartEvent), c)
return nil
})
},
onAgentEnd: func(h func(AgentEndEvent, Context)) {
reg(AgentEnd, func(e Event, c Context) Result {
h(e.(AgentEndEvent), c)
return nil
})
},
onMessageStart: func(h func(MessageStartEvent, Context)) {
reg(MessageStart, func(e Event, c Context) Result {
h(e.(MessageStartEvent), c)
return nil
})
},
onMessageUpdate: func(h func(MessageUpdateEvent, Context)) {
reg(MessageUpdate, func(e Event, c Context) Result {
h(e.(MessageUpdateEvent), c)
return nil
})
},
onMessageEnd: func(h func(MessageEndEvent, Context)) {
reg(MessageEnd, func(e Event, c Context) Result {
h(e.(MessageEndEvent), c)
return nil
})
},
onSessionStart: func(h func(SessionStartEvent, Context)) {
reg(SessionStart, func(e Event, c Context) Result {
h(e.(SessionStartEvent), c)
return nil
})
},
onSessionShutdown: func(h func(SessionShutdownEvent, Context)) {
reg(SessionShutdown, func(e Event, c Context) Result {
h(e.(SessionShutdownEvent), c)
return nil
})
},
onModelChange: func(h func(ModelChangeEvent, Context)) {
reg(ModelChange, func(e Event, c Context) Result {
h(e.(ModelChangeEvent), c)
return nil
})
},
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
reg(ContextPrepare, func(e Event, c Context) Result {
r := h(e.(ContextPrepareEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
reg(BeforeFork, func(e Event, c Context) Result {
r := h(e.(BeforeForkEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
r := h(e.(BeforeSessionSwitchEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
reg(BeforeCompact, func(e Event, c Context) Result {
r := h(e.(BeforeCompactEvent), c)
if r == nil {
return nil
}
return *r
})
},
registerToolFn: func(tool ToolDef) {
ext.Tools = append(ext.Tools, tool)
},
registerCmdFn: func(cmd CommandDef) {
ext.Commands = append(ext.Commands, cmd)
},
registerToolRendererFn: func(config ToolRenderConfig) {
ext.ToolRenderers = append(ext.ToolRenderers, config)
},
onCustomEvent: func(name string, handler func(string)) {
if ext.CustomEventHandlers == nil {
ext.CustomEventHandlers = make(map[string][]func(string))
}
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
},
registerOption: func(opt OptionDef) {
ext.Options = append(ext.Options, opt)
},
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
},
registerMessageRendererFn: func(config MessageRendererConfig) {
ext.MessageRenderers = append(ext.MessageRenderers, config)
},
}
}
-25
View File
@@ -166,28 +166,3 @@ func (p *ProviderPool) Close() {
}
p.mu.Unlock()
}
// Stats returns current pool statistics.
func (p *ProviderPool) Stats() PoolStats {
p.mu.RLock()
defer p.mu.RUnlock()
stats := PoolStats{
TotalProviders: len(p.providers),
}
for _, pp := range p.providers {
if pp.refs > 0 {
stats.ActiveProviders++
} else {
stats.IdleProviders++
}
}
return stats
}
// PoolStats contains provider pool statistics.
type PoolStats struct {
TotalProviders int
ActiveProviders int
IdleProviders int
}
+4 -14
View File
@@ -49,7 +49,7 @@ func resolveModelAlias(provider, modelName string) string {
}
if resolved, exists := aliasMap[modelName]; exists {
if _, err := registry.ValidateModel(provider, resolved); err == nil {
if registry.LookupModel(provider, resolved) != nil {
return resolved
}
}
@@ -73,8 +73,8 @@ func ThinkingLevels() []ThinkingLevel {
return []ThinkingLevel{ThinkingOff, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
}
// ThinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
func ThinkingBudgetTokens(level ThinkingLevel) int64 {
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
func thinkingBudgetTokens(level ThinkingLevel) int64 {
switch level {
case ThinkingMinimal:
return 1024
@@ -162,16 +162,6 @@ func ParseModelString(modelString string) (provider, model string, err error) {
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
}
// Legacy colon-separated format
if strings.Contains(modelString, ":") {
parts := strings.SplitN(modelString, ":", 2)
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
fmt.Fprintf(os.Stderr, "Warning: model format %q uses deprecated colon separator. Use %s/%s instead.\n",
modelString, parts[0], parts[1])
return parts[0], parts[1], nil
}
}
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
}
@@ -489,7 +479,7 @@ func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fan
return nil
}
budget := ThinkingBudgetTokens(config.ThinkingLevel)
budget := thinkingBudgetTokens(config.ThinkingLevel)
if budget == 0 {
return nil
}
+3 -19
View File
@@ -147,24 +147,8 @@ func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo {
return &modelInfo
}
// ValidateModel validates if a model exists and returns detailed information.
// Deprecated: Use LookupModel instead — it returns nil for unknown models
// rather than an error, letting the provider API be the authority.
func (r *ModelsRegistry) ValidateModel(provider, modelID string) (*ModelInfo, error) {
if info := r.LookupModel(provider, modelID); info != nil {
return info, nil
}
providerInfo, exists := r.providers[provider]
if !exists {
return nil, fmt.Errorf("unsupported provider: %s", provider)
}
return nil, fmt.Errorf("model %s not found for provider %s", modelID, providerInfo.ID)
}
// GetRequiredEnvVars returns the required environment variables for a provider.
func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
// getRequiredEnvVars returns the required environment variables for a provider.
func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
providerInfo, exists := r.providers[provider]
if !exists {
return nil, fmt.Errorf("unsupported provider: %s", provider)
@@ -194,7 +178,7 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
}
}
envVars, err := r.GetRequiredEnvVars(provider)
envVars, err := r.getRequiredEnvVars(provider)
if err != nil {
// Unknown provider — nothing to validate
return nil
-11
View File
@@ -144,17 +144,6 @@ func NewMessageEntry(parentID string, msg message.Message) (*MessageEntry, error
}, nil
}
// NewMessageEntryFromRaw creates a MessageEntry with pre-marshaled parts.
func NewMessageEntryFromRaw(parentID, role string, parts json.RawMessage, model, provider string) *MessageEntry {
return &MessageEntry{
Entry: NewEntry(EntryTypeMessage, parentID),
Role: role,
Parts: parts,
Model: model,
Provider: provider,
}
}
// NewModelChangeEntry creates a ModelChangeEntry.
func NewModelChangeEntry(parentID, provider, modelID string) *ModelChangeEntry {
return &ModelChangeEntry{
-24
View File
@@ -253,27 +253,3 @@ func extractTextPreview(partsJSON json.RawMessage) string {
func DeleteSession(path string) error {
return os.Remove(path)
}
// ListChildSessions returns all sessions that have the given session ID as
// their parent. This is useful for finding subagent sessions spawned from
// a parent session. Results are sorted by creation time (newest first).
func ListChildSessions(parentID string) ([]SessionInfo, error) {
if parentID == "" {
return nil, nil
}
allSessions, err := ListAllSessions()
if err != nil {
return nil, err
}
var children []SessionInfo
for _, s := range allSessions {
if s.ParentSessionID == parentID {
children = append(children, s)
}
}
// Already sorted by modification time from ListAllSessions
return children, nil
}
+17 -1
View File
@@ -11,6 +11,7 @@ type blockRenderer struct {
align *lipgloss.Position
borderColor *color.Color
background *color.Color
foreground *color.Color
fullWidth bool
noBorder bool
paddingTop int
@@ -123,6 +124,15 @@ func WithBackground(c color.Color) renderingOption {
}
}
// WithForeground returns a renderingOption that overrides the default text
// foreground color (theme.Text) for the block. Useful for muted or
// de-emphasized content blocks.
func WithForeground(c color.Color) renderingOption {
return func(br *blockRenderer) {
br.foreground = &c
}
}
// WithWidth returns a renderingOption that sets a specific width for the block
// in characters. This overrides the default container width and allows precise
// control over the block's horizontal dimensions.
@@ -167,13 +177,19 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
theme := GetTheme()
// Resolve foreground color: caller override or theme default.
fgColor := theme.Text
if renderer.foreground != nil {
fgColor = *renderer.foreground
}
// Single-pass render: padding, border, and foreground in one style.
style := lipgloss.NewStyle().
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
Foreground(theme.Text)
Foreground(fgColor)
if hasBorder {
style = style.BorderStyle(lipgloss.ThickBorder())
+40 -2
View File
@@ -560,9 +560,10 @@ func TestStreamComponent_SpinnerTick_AdvancesFrame(t *testing.T) {
// Start spinning first.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
initialFrame := c.spinnerFrame
gen := c.spinnerGeneration
// Send a tick.
_, cmd := c.Update(streamSpinnerTickMsg{})
// Send a tick with the current generation.
_, cmd := c.Update(streamSpinnerTickMsg{generation: gen})
if c.spinnerFrame != initialFrame+1 {
t.Fatalf("expected spinnerFrame=%d, got %d", initialFrame+1, c.spinnerFrame)
@@ -583,3 +584,40 @@ func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning(t *testing.T)
t.Fatal("expected no tick reschedule when not spinning")
}
}
// TestStreamComponent_StaleTick_Discarded verifies that a tick from a previous
// spinner generation is silently discarded, preventing duplicate concurrent
// tick loops that would double the animation speed.
func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
c := newTestStream()
// Start spinner → generation 1.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
staleGen := c.spinnerGeneration
// Stop spinner → generation bumped to 2.
c = sendStreamMsg(c, app.SpinnerEvent{Show: false})
// Restart spinner → generation bumped to 3.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
currentGen := c.spinnerGeneration
frameBefore := c.spinnerFrame
// Simulate a stale tick from the first spinner session arriving.
_, cmd := c.Update(streamSpinnerTickMsg{generation: staleGen})
if c.spinnerFrame != frameBefore {
t.Fatalf("stale tick should not advance frame: expected %d, got %d", frameBefore, c.spinnerFrame)
}
if cmd != nil {
t.Fatal("stale tick should not reschedule")
}
// A tick from the current generation should still work.
_, cmd = c.Update(streamSpinnerTickMsg{generation: currentGen})
if c.spinnerFrame != frameBefore+1 {
t.Fatalf("current-gen tick should advance frame: expected %d, got %d", frameBefore+1, c.spinnerFrame)
}
if cmd == nil {
t.Fatal("current-gen tick should reschedule")
}
}
+2 -9
View File
@@ -36,7 +36,7 @@ func NewCLI(debug bool, compact bool) (*CLI, error) {
if compact {
cli.renderer = NewCompactRenderer(cli.width, debug)
} else {
cli.renderer = NewMessageRenderer(cli.width, debug)
cli.renderer = newMessageRenderer(cli.width, debug)
}
return cli, nil
@@ -108,13 +108,6 @@ func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error
return nil
}
// DisplayToolCallMessage is a no-op retained for backward compatibility. Tool
// calls are now rendered as part of the unified tool block in DisplayToolMessage,
// which combines the invocation header with the execution result.
func (c *CLI) DisplayToolCallMessage(toolName, toolArgs string) {
// No-op: unified tool blocks are rendered in DisplayToolMessage.
}
// DisplayToolMessage renders and displays the complete result of a tool execution,
// including the tool name, arguments, and result. The isError parameter determines
// whether the result should be displayed as an error or success message.
@@ -141,7 +134,7 @@ func (c *CLI) DisplayInfo(message string) {
func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
theme := GetTheme()
var borderClr = lipgloss.Color("#89b4fa")
borderClr := theme.Info
if borderColor != "" {
borderClr = lipgloss.Color(borderColor)
}
+18
View File
@@ -94,6 +94,24 @@ var SlashCommands = []SlashCommand{
return matches
},
},
{
Name: "/theme",
Description: "Switch color theme (e.g. /theme catppuccin)",
Category: "System",
Complete: func(prefix string) []string {
names := ListThemes()
if prefix == "" {
return names
}
var matches []string
for _, n := range names {
if strings.HasPrefix(n, strings.ToLower(prefix)) {
matches = append(matches, n)
}
}
return matches
},
},
{
Name: "/quit",
Description: "Exit the application",
+16 -11
View File
@@ -44,15 +44,20 @@ func (r *CompactRenderer) SetWidth(width int) {
// and metadata.
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Secondary).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true).Render("User")
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
// Convert single newlines to paragraph breaks so they survive glamour's
// markdown rendering (glamour treats single \n as a soft break).
content = strings.ReplaceAll(content, "\n", "\n\n")
// Format content for user messages (preserve formatting, no truncation)
compactContent := r.formatUserAssistantContent(content)
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var compactContent string
if strings.Contains(content, "`") {
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
compactContent = r.formatUserAssistantContent(mdContent)
compactContent = removeBlankLines(compactContent)
} else {
compactContent = content
}
// Handle multi-line content
lines := strings.Split(compactContent, "\n")
@@ -170,7 +175,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params — check extension renderer first.
paramBudget := max(r.width-10-len(displayName), 20)
@@ -235,8 +240,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
// formatted to fit on a single line for minimal space usage.
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.System).Render("*")
label := lipgloss.NewStyle().Foreground(theme.System).Bold(true).Render("System")
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("")
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
compactContent := r.formatCompactContent(content)
+66 -29
View File
@@ -39,9 +39,26 @@ func SetTheme(theme Theme) {
currentTheme = theme
}
// MarkdownThemeColors defines colors for markdown rendering and syntax highlighting.
type MarkdownThemeColors struct {
Text color.Color
Muted color.Color
Heading color.Color
Emph color.Color
Strong color.Color
Link color.Color
Code color.Color
Error color.Color
Keyword color.Color
String color.Color
Number color.Color
Comment color.Color
}
// Theme defines a comprehensive color scheme for the application's UI, supporting
// both light and dark terminal modes through adaptive colors. It includes semantic
// colors for different message types and UI elements, based on the Catppuccin color palette.
// both light and dark terminal modes through adaptive colors. Inspired by the
// Knight Rider KITT aesthetic — scanner reds, amber dashboard glows, and dark
// cockpit tones.
type Theme struct {
Primary color.Color
Secondary color.Color
@@ -70,40 +87,60 @@ type Theme struct {
CodeBg color.Color // Background for code blocks (Read tool)
GutterBg color.Color // Line-number gutter background
WriteBg color.Color // Green-tinted bg for Write tool content
// Markdown rendering and syntax highlighting colors
Markdown MarkdownThemeColors
}
// DefaultTheme creates and returns the default KIT theme based on the Catppuccin
// Mocha (dark) and Latte (light) color palettes. This theme provides a cohesive,
// pleasant visual experience with carefully selected colors for different UI elements.
// DefaultTheme creates and returns the default KIT theme inspired by the
// Knight Rider KITT aesthetic — scanner reds, amber dashboard glows, and a
// dark cockpit. No blues or bright greens; everything stays in the warm
// red/amber/gray family of KITT's instrument panel.
func DefaultTheme() Theme {
return Theme{
Primary: AdaptiveColor("#8839ef", "#cba6f7"), // Latte/Mocha Mauve
Secondary: AdaptiveColor("#04a5e5", "#89dceb"), // Latte/Mocha Sky
Success: AdaptiveColor("#40a02b", "#a6e3a1"), // Latte/Mocha Green
Warning: AdaptiveColor("#df8e1d", "#f9e2af"), // Latte/Mocha Yellow
Error: AdaptiveColor("#d20f39", "#f38ba8"), // Latte/Mocha Red
Info: AdaptiveColor("#1e66f5", "#89b4fa"), // Latte/Mocha Blue
Text: AdaptiveColor("#4c4f69", "#cdd6f4"), // Latte/Mocha Text
Muted: AdaptiveColor("#6c6f85", "#a6adc8"), // Latte/Mocha Subtext 0
VeryMuted: AdaptiveColor("#9ca0b0", "#6c7086"), // Latte/Mocha Overlay 0
Background: AdaptiveColor("#eff1f5", "#1e1e2e"), // Latte/Mocha Base
Border: AdaptiveColor("#acb0be", "#585b70"), // Latte/Mocha Surface 2
MutedBorder: AdaptiveColor("#ccd0da", "#313244"), // Latte/Mocha Surface 0
System: AdaptiveColor("#179299", "#94e2d5"), // Latte/Mocha Teal
Tool: AdaptiveColor("#fe640b", "#fab387"), // Latte/Mocha Peach
Accent: AdaptiveColor("#ea76cb", "#f5c2e7"), // Latte/Mocha Pink
Highlight: AdaptiveColor("#e6e9ef", "#181825"), // Latte Mantle / Mocha Mantle
Primary: AdaptiveColor("#CC1100", "#FF2200"), // KITT scanner red
Secondary: AdaptiveColor("#CC6600", "#FF8800"), // Amber dashboard glow
Success: AdaptiveColor("#998800", "#CCAA00"), // Warm gold — system OK
Warning: AdaptiveColor("#CC8800", "#FFB800"), // Amber caution light
Error: AdaptiveColor("#CC0000", "#FF3333"), // Alert red
Info: AdaptiveColor("#BB6600", "#DD8833"), // Warm amber readout
Text: AdaptiveColor("#1A1A1A", "#E0E0E0"), // Console text
Muted: AdaptiveColor("#707070", "#808080"), // Dimmed readout
VeryMuted: AdaptiveColor("#A0A0A0", "#505050"), // Inactive element
Background: AdaptiveColor("#F0F0F0", "#0D0D0D"), // Cockpit interior
Border: AdaptiveColor("#B0B0B0", "#3A3A3A"), // Panel edge
MutedBorder: AdaptiveColor("#D0D0D0", "#222222"), // Subtle divider
System: AdaptiveColor("#CC6600", "#FF8800"), // Amber system status
Tool: AdaptiveColor("#CC6600", "#FF8800"), // Amber instrument
Accent: AdaptiveColor("#DD2222", "#FF4444"), // Secondary scanner glow
Highlight: AdaptiveColor("#FFF0F0", "#1A1010"), // Red-tinted mantle
// Diff backgrounds — subtle tinted variants of the base palette
DiffInsertBg: AdaptiveColor("#d5f0d5", "#1a3a2a"), // Green tint
DiffDeleteBg: AdaptiveColor("#f5d5d5", "#3a1a2a"), // Red tint
DiffEqualBg: AdaptiveColor("#eceef3", "#232336"), // Neutral
DiffMissingBg: AdaptiveColor("#e4e6eb", "#1a1a2e"), // Darker neutral
// Diff backgrounds
DiffInsertBg: AdaptiveColor("#F0E8D0", "#2A2410"), // Warm amber tint (added)
DiffDeleteBg: AdaptiveColor("#F5D5D5", "#2E1A1A"), // Red tint (removed)
DiffEqualBg: AdaptiveColor("#E8E8E8", "#161616"), // Neutral
DiffMissingBg: AdaptiveColor("#E0E0E0", "#111111"), // Darker neutral
// Code & output backgrounds
CodeBg: AdaptiveColor("#eceef3", "#232336"), // Matches DiffEqualBg
GutterBg: AdaptiveColor("#e4e6eb", "#1a1a2e"), // Slightly darker
WriteBg: AdaptiveColor("#d5f0d5", "#1a3a2a"), // Matches DiffInsertBg (green tint)
CodeBg: AdaptiveColor("#E8E8E8", "#161616"), // Matches DiffEqualBg
GutterBg: AdaptiveColor("#E0E0E0", "#111111"), // Slightly darker
WriteBg: AdaptiveColor("#F0E8D0", "#2A2410"), // Warm amber tint
// Markdown & syntax highlighting — all warm tones
Markdown: MarkdownThemeColors{
Text: AdaptiveColor("#1A1A1A", "#E0E0E0"), // Console text
Muted: AdaptiveColor("#707070", "#808080"), // Dimmed readout
Heading: AdaptiveColor("#CC1100", "#FF4444"), // Scanner red accent
Emph: AdaptiveColor("#CC8800", "#FFB800"), // Amber emphasis
Strong: AdaptiveColor("#1A1A1A", "#E0E0E0"), // Bright text
Link: AdaptiveColor("#CC4400", "#FF7744"), // Warm orange link
Code: AdaptiveColor("#333333", "#CCCCCC"), // Inline code
Error: AdaptiveColor("#CC0000", "#FF3333"), // Alert red
Keyword: AdaptiveColor("#CC3300", "#FF6644"), // Orange-red keyword
String: AdaptiveColor("#BB7700", "#DDAA33"), // Amber string
Number: AdaptiveColor("#CC8800", "#FFB800"), // Amber number
Comment: AdaptiveColor("#909090", "#606060"), // Dark gray comment
},
}
}
+3 -3
View File
@@ -51,8 +51,8 @@ func CreateUsageTracker(modelString, providerAPIKey string) *UsageTracker {
}
registry := models.GetGlobalRegistry()
modelInfo, err := registry.ValidateModel(provider, model)
if err != nil {
modelInfo := registry.LookupModel(provider, model)
if modelInfo == nil {
return nil
}
@@ -94,7 +94,7 @@ func SetupCLI(opts *CLISetupOptions) (*CLI, error) {
// Skip usage tracking for ollama as it's not in models.dev
if provider != "ollama" {
registry := models.GetGlobalRegistry()
if modelInfo, err := registry.ValidateModel(provider, model); err == nil {
if modelInfo := registry.LookupModel(provider, model); modelInfo != nil {
// Check if OAuth credentials are being used for Anthropic models
isOAuth := false
if provider == "anthropic" {
+83 -31
View File
@@ -89,18 +89,19 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
// Style the textarea to match huh theme
// Style the textarea using theme colors.
theme := GetTheme()
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
styles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
styles.Focused.Prompt = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
@@ -376,9 +377,11 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
func (s *InputComponent) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
Foreground(theme.Text).
MarginBottom(1).
PaddingLeft(3)
@@ -388,7 +391,7 @@ func (s *InputComponent) View() tea.View {
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(lipgloss.Color("39")).
BorderForeground(theme.Primary).
PaddingLeft(2). // match message block paddingLeft
Width(s.width - 1) // full width minus left border
@@ -405,7 +408,7 @@ func (s *InputComponent) View() tea.View {
// Show image attachment indicator when images are pending.
if len(s.pendingImages) > 0 {
imgStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("39")).
Foreground(theme.Secondary).
PaddingLeft(3)
label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages))
@@ -415,11 +418,22 @@ func (s *InputComponent) View() tea.View {
if !s.hideHint {
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Foreground(theme.VeryMuted).
MarginTop(1).
PaddingLeft(3)
hint := "enter submit • ctrl+j / alt+enter new line • ctrl+v paste image"
// Adapt hint text to available width (accounting for left padding of 3).
var hint string
availableHintWidth := s.width - 3
if availableHintWidth >= 67 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
} else if availableHintWidth >= 40 {
hint = "↵ submit • ctrl+j newline • ctrl+v image"
} else if availableHintWidth >= 20 {
hint = "↵ submit • ctrl+j"
} else {
hint = "↵ submit"
}
view.WriteString("\n")
view.WriteString(helpStyle.Render(hint))
}
@@ -429,13 +443,18 @@ func (s *InputComponent) View() tea.View {
// renderPopup renders the autocomplete popup for slash command suggestions.
func (s *InputComponent) renderPopup() string {
theme := GetTheme()
popupWidth := max(s.width-4, 20)
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("236")).
BorderForeground(theme.MutedBorder).
Padding(1, 2).
Width(s.width - 4).
Width(popupWidth).
MarginLeft(0)
// Inner content width: popup minus border (2) and horizontal padding (4).
innerWidth := max(popupWidth-6, 10)
var items []string
visibleItems := min(len(s.filtered), s.popupHeight)
@@ -451,56 +470,89 @@ func (s *InputComponent) renderPopup() string {
var indicator string
if i == s.selected {
indicator = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render("> ")
indicator = lipgloss.NewStyle().Foreground(theme.Primary).Render("> ")
} else {
indicator = " "
}
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
nameStyle := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(theme.Muted)
if i == s.selected {
nameStyle = nameStyle.Foreground(lipgloss.Color("87"))
descStyle = descStyle.Foreground(lipgloss.Color("250"))
nameStyle = nameStyle.Foreground(theme.Primary)
descStyle = descStyle.Foreground(theme.Text)
}
if s.fileMode {
// File mode: use full width for the path, show description
// (e.g. "directory") inline after a gap.
maxNameLen := s.width - 24
maxNameLen := max(innerWidth-16, 8)
displayName := sc.Name
if len(displayName) > maxNameLen && maxNameLen > 3 {
displayName = displayName[:maxNameLen-3] + "..."
}
name := nameStyle.Render(displayName)
if sc.Description != "" {
if sc.Description != "" && innerWidth > 30 {
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
} else {
items = append(items, indicator+name)
}
} else {
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
// Line layout: indicator(2) + name(nameWidth-2 visual) + desc.
if innerWidth < 20 {
// Very narrow: show truncated name only, no fixed column.
displayName := sc.Name
maxName := max(innerWidth-2, 3)
if len(displayName) > maxName {
displayName = displayName[:maxName-1] + "…"
}
items = append(items, indicator+nameStyle.Render(displayName))
} else {
nameWidth := 15
if innerWidth < 25 {
nameWidth = max(innerWidth*2/5+1, 8)
}
maxNameChars := nameWidth - 2
displayName := sc.Name
if len(displayName) > maxNameChars {
displayName = displayName[:maxNameChars-1] + "…"
}
name := nameStyle.Width(maxNameChars).Render(displayName)
desc := sc.Description
maxDescLen := s.width - nameWidth - 14
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
// Description gets remaining space.
maxDescLen := max(innerWidth-nameWidth, 0)
desc := sc.Description
if maxDescLen < 4 {
items = append(items, indicator+name)
} else {
if len(desc) > maxDescLen {
desc = desc[:maxDescLen-3] + "..."
}
items = append(items, indicator+name+descStyle.Render(desc))
}
}
items = append(items, indicator+name+descStyle.Render(desc))
}
}
if startIdx > 0 {
items = append([]string{lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Render(" ↑ more above")}, items...)
items = append([]string{lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↑ more above")}, items...)
}
if endIdx < len(s.filtered) {
items = append(items, lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Render(" ↓ more below"))
items = append(items, lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↓ more below"))
}
content := strings.Join(items, "\n")
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Italic(true).
Render("↑↓ navigate • tab complete • ↵ select • esc dismiss")
// Adapt footer text to available width.
var footerText string
if innerWidth >= 50 {
footerText = "↑↓ navigate • tab complete • ↵ select • esc dismiss"
} else if innerWidth >= 30 {
footerText = "↑↓ nav • tab • ↵ select • esc"
} else {
footerText = "↑↓ tab ↵ esc"
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Italic(true).
Render(footerText)
return popupStyle.Render(content + "\n\n" + footer)
}
+57 -117
View File
@@ -3,8 +3,7 @@ package ui
import (
"encoding/json"
"fmt"
"os"
"os/user"
"regexp"
"sort"
"strings"
"time"
@@ -12,6 +11,9 @@ import (
"charm.land/lipgloss/v2"
)
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// MessageType represents different categories of messages displayed in the UI,
// each with distinct visual styling and formatting rules.
type MessageType int
@@ -154,25 +156,10 @@ type MessageRenderer struct {
getToolRenderer func(toolName string) *ToolRendererData
}
// getSystemUsername returns the current system username, fallback to "User"
func getSystemUsername() string {
if currentUser, err := user.Current(); err == nil && currentUser.Username != "" {
return currentUser.Username
}
// Fallback to environment variable
if username := os.Getenv("USER"); username != "" {
return username
}
if username := os.Getenv("USERNAME"); username != "" {
return username
}
return "User"
}
// NewMessageRenderer creates and initializes a new MessageRenderer with the specified
// newMessageRenderer creates and initializes a new MessageRenderer with the specified
// terminal width and debug mode setting. The width parameter determines line wrapping
// and layout calculations.
func NewMessageRenderer(width int, debug bool) *MessageRenderer {
func newMessageRenderer(width int, debug bool) *MessageRenderer {
return &MessageRenderer{
width: width,
debug: debug,
@@ -189,31 +176,30 @@ func (r *MessageRenderer) SetWidth(width int) {
// formatting, including the system username, timestamp, and markdown-rendered content.
// The message is displayed with a colored right border for visual distinction.
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
// Format timestamp and username
timeStr := timestamp.Local().Format("15:04")
username := getSystemUsername()
// Convert single newlines to paragraph breaks so they survive glamour's
// markdown rendering (glamour treats single \n as a soft break).
content = strings.ReplaceAll(content, "\n", "\n\n")
theme := getTheme()
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var messageContent string
if strings.Contains(content, "`") {
// Glamour treats single \n as a soft break, so convert to paragraph
// breaks and collapse the resulting blank lines after rendering.
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
messageContent = r.renderMarkdown(mdContent, r.width-8)
messageContent = removeBlankLines(messageContent)
} else {
messageContent = content
}
// Create info line
info := fmt.Sprintf(" %s (%s)", username, timeStr)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the block renderer — left border with Primary color, no background.
// Left border with Blue color for user messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Primary),
WithBorderColor(theme.Info),
WithMarginBottom(1),
)
@@ -230,14 +216,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
// are displayed with a special "Finished without output" message. The message features
// a colored left border for visual distinction.
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
// Format timestamp and model info with better defaults
timeStr := timestamp.Local().Format("15:04")
if modelName == "" {
modelName = "Assistant"
}
// Handle empty content with better styling
theme := getTheme()
var messageContent string
if strings.TrimSpace(content) == "" {
messageContent = lipgloss.NewStyle().
@@ -246,21 +226,16 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
Align(lipgloss.Center).
Render("Finished without output")
} else {
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
messageContent = r.renderMarkdown(content, r.width-8)
}
// Create info line
info := fmt.Sprintf(" %s (%s)", modelName, timeStr)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer — no borders for agent messages.
// Left border with Primary (Mauve) color for assistant messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithNoBorder(),
WithBorderColor(theme.Primary),
WithMarginBottom(1),
)
@@ -276,35 +251,24 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
// and informational notifications. These messages are displayed with a distinctive system
// color border and "KIT System" label to differentiate them from user and AI content.
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Handle empty content with better styling
theme := getTheme()
var messageContent string
if strings.TrimSpace(content) == "" {
messageContent = lipgloss.NewStyle().
Italic(true).
Foreground(theme.Muted).
Align(lipgloss.Center).
Render("No content available")
messageContent = "No content available"
} else if strings.Contains(content, "`") {
messageContent = r.renderMarkdown(content, r.width-8)
} else {
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
messageContent = content
}
// Create info line
info := fmt.Sprintf(" KIT System (%s)", timeStr)
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.System),
WithNoBorder(),
WithForeground(theme.Muted),
WithMarginBottom(1),
)
@@ -322,29 +286,22 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
// Create the main message style with border using tool color
theme := getTheme()
style := baseStyle.
Width(r.width - 3). // Account for left margin
Width(r.width - 3).
BorderLeft(true).
Foreground(theme.Muted).
BorderForeground(theme.Tool).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1).
MarginLeft(2). // Add left margin like other messages
MarginBottom(1) // Add bottom margin
MarginLeft(2).
MarginBottom(1)
// Format timestamp
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
// Create header with debug icon
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔍 Debug Output")
// Process and format the message content
// Split into lines and format each one
lines := strings.Split(message, "\n")
var formattedLines []string
for _, line := range lines {
@@ -357,17 +314,9 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
Foreground(theme.Muted).
Render(strings.Join(formattedLines, "\n"))
// Create info line
info := baseStyle.
Width(r.width - 5). // Account for margins and padding
Foreground(theme.Muted).
Render(fmt.Sprintf(" KIT (%s)", timeStr))
// Combine all parts
fullContent := lipgloss.JoinVertical(lipgloss.Left,
header,
content,
info,
)
return UIMessage{
@@ -382,7 +331,6 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
// Create the main message style with border using tool color
theme := getTheme()
style := baseStyle.
Width(r.width - 1).
@@ -392,16 +340,11 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1)
// Format timestamp
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
// Create header with debug icon
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔧 Debug Configuration")
// Format configuration settings
var configLines []string
for key, value := range config {
if value != nil {
@@ -413,18 +356,10 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
Foreground(theme.Muted).
Render(strings.Join(configLines, "\n"))
// Create info line
info := baseStyle.
Width(r.width - 1).
Foreground(theme.Muted).
Render(fmt.Sprintf(" KIT (%s)", timeStr))
// Combine parts
parts := []string{header}
if len(configLines) > 0 {
parts = append(parts, configContent)
}
parts = append(parts, info)
rendered := style.Render(
lipgloss.JoinVertical(lipgloss.Left, parts...),
@@ -442,26 +377,15 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
// bold text to ensure visibility. Error messages include timestamp information and
// are displayed with an error-colored border for immediate recognition.
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Format error content
theme := getTheme()
errorContent := lipgloss.NewStyle().
Foreground(theme.Error).
Bold(true).
Render(errorMsg)
// Create info line
info := fmt.Sprintf(" Error (%s)", timeStr)
// Combine content and info
fullContent := errorContent + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
errorContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Error),
@@ -559,7 +483,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params with width budget for the header line.
// Check extension renderer for custom header params first.
@@ -710,3 +634,19 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
rendered := toMarkdown(content, width)
return strings.TrimSuffix(rendered, "\n")
}
// removeBlankLines removes lines that are visually blank from rendered output.
// Glamour wraps every character (including padding spaces) with ANSI color
// codes, so we must strip escape sequences before checking whether a line is
// empty. This collapses paragraph spacing so user messages render without
// extra vertical gaps.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
filtered := lines[:0]
for _, line := range lines {
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
filtered = append(filtered, line)
}
}
return strings.Join(filtered, "\n")
}
+331 -130
View File
@@ -372,11 +372,9 @@ type AppModel struct {
appCtrl AppController
// input is the child input component (slash commands + autocomplete).
// Placeholder until InputComponent is implemented in TAS-15.
input inputComponentIface
// stream is the child streaming display component (spinner + streaming text).
// Placeholder until StreamComponent is implemented in TAS-16.
stream streamComponentIface
// renderer renders completed messages for tea.Println output. It is either
@@ -396,6 +394,20 @@ type AppModel struct {
// the input and move to scrollback when the agent picks them up.
queuedMessages []string
// pendingUserPrints holds user messages that have been consumed from the
// queue but not yet printed to scrollback. They are deferred until
// SpinnerEvent{Show: true} so the previous assistant response can be
// flushed first, preserving chronological order.
pendingUserPrints []string
// scrollbackBuf collects rendered content during a single Update() call.
// All print helpers append here instead of returning tea.Println directly.
// The buffer is drained into a single atomic tea.Println at the end of
// each Update call via drainScrollback(). If the stream component has
// unflushed content, it is automatically prepended so that new messages
// always appear below the previous assistant response.
scrollbackBuf []string
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
canceling bool
@@ -579,7 +591,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
cr.getToolRenderer = opts.GetToolRenderer
rdr = cr
} else {
mr := NewMessageRenderer(width, false)
mr := newMessageRenderer(width, false)
mr.getToolRenderer = opts.GetToolRenderer
rdr = mr
}
@@ -829,7 +841,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.setModel != nil {
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(msg.ModelString); err != nil {
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err)))
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
} else {
// Update display state directly — we cannot use
// NotifyModelChanged (prog.Send) from inside Update()
@@ -839,7 +851,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.providerName = parts[0]
m.modelName = parts[1]
}
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString)))
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
if m.emitModelChange != nil {
emit := m.emitModelChange
newModel := msg.ModelString
@@ -848,6 +860,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case ModelSelectorCancelledMsg:
@@ -1018,6 +1031,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleSlashCommand(sc); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1031,16 +1045,25 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/model":
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/thinking":
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/theme":
if cmd := m.handleThemeCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
@@ -1091,15 +1114,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if qLen > 0 {
// Queued: anchor the message text above the input with a
// "queued" badge. It will be printed to scrollback when
// the agent picks it up (on QueueUpdatedEvent).
// the agent picks it up (via SpinnerEvent).
m.queuedMessages = append(m.queuedMessages, displayText)
m.distributeHeight()
} else {
// Started immediately: print to scrollback now.
cmds = append(cmds, m.printUserMessage(displayText))
// Started immediately. Flush any leftover stream content
// from the previous step first, then print the user
// message — combined via the scrollback buffer so
// scrollback stays in chronological order.
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
m.flushStreamAndPendingUserMessages()
}
} else {
cmds = append(cmds, m.printUserMessage(displayText))
m.printUserMessage(displayText)
}
if m.state != stateWorking {
m.state = stateWorking
@@ -1107,10 +1134,22 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Shell command (! / !!) ───────────────────────────────────────────────
case shellCommandMsg:
// Show spinner while the shell command runs.
m.state = stateWorking
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
cmds = append(cmds, cmd)
}
// Execute the shell command asynchronously so the TUI stays responsive.
cmds = append(cmds, m.executeShellCommand(msg))
case shellCommandResultMsg:
// Stop spinner now that the command has finished.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
cmds = append(cmds, cmd)
}
m.state = stateInput
cmds = append(cmds, m.handleShellCommandResult(msg))
// ── App layer events ─────────────────────────────────────────────────────
@@ -1119,10 +1158,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// SpinnerEvent{Show: true} means a new agent step has started (either
// freshly or from the queue after a previous step completed). Flush
// any leftover stream content from the previous step to scrollback
// before starting the new one. This deferred flush avoids shrinking
// the view at step-completion time (which leaves blank lines).
// before starting the new one, followed by any pending user messages
// from the queue. Everything goes through the scrollback buffer to
// guarantee chronological ordering.
if msg.Show {
cmds = append(cmds, m.flushStreamContent())
m.flushStreamAndPendingUserMessages()
m.state = stateWorking
m.distributeHeight()
}
@@ -1148,7 +1188,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// always completes before tool calls fire). The tool call itself is
// NOT printed here — a unified block (header + result) will be
// rendered when the ToolResultEvent arrives.
cmds = append(cmds, m.flushStreamContent())
m.flushStreamContent()
case app.ToolExecutionEvent:
// Pass to stream component for execution spinner display.
@@ -1158,8 +1198,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case app.ToolResultEvent:
// Print tool result immediately to scrollback.
cmds = append(cmds, m.printToolResult(msg))
// Buffer tool result for scrollback.
m.printToolResult(msg)
// Start spinner again while waiting for the next LLM response.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
@@ -1179,7 +1219,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// In non-streaming mode (no stream content accumulated), print the text.
hasStreamContent := m.stream != nil && m.stream.GetRenderedContent() != ""
if !hasStreamContent && msg.Content != "" {
cmds = append(cmds, m.printAssistantMessage(msg.Content))
m.printAssistantMessage(msg.Content)
if m.stream != nil {
m.stream.Reset()
}
@@ -1189,13 +1229,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Informational — no action needed by parent.
case app.QueueUpdatedEvent:
// drainQueue popped item(s) from the queue. Move consumed messages
// from the anchored display to scrollback (they are now being processed
// or about to be).
// drainQueue popped item(s) from the queue. Move consumed
// messages to pendingUserPrints — they will be printed to
// scrollback in the next SpinnerEvent{Show: true} after the
// previous assistant response is flushed.
for len(m.queuedMessages) > msg.Length {
text := m.queuedMessages[0]
m.queuedMessages = m.queuedMessages[1:]
cmds = append(cmds, m.printUserMessage(text))
m.pendingUserPrints = append(m.pendingUserPrints, text)
}
m.distributeHeight()
@@ -1232,7 +1273,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
if msg.Err != nil {
cmds = append(cmds, m.printErrorResponse(msg))
m.printErrorResponse(msg)
}
m.state = stateInput
m.canceling = false
@@ -1242,14 +1283,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stream.Reset()
}
m.state = stateInput
cmds = append(cmds, m.printCompactResult(msg))
m.printCompactResult(msg)
case app.CompactErrorEvent:
if m.stream != nil {
m.stream.Reset()
}
m.state = stateInput
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err)))
m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err))
case app.ModelChangedEvent:
// Extension changed the model — update display name in status bar
@@ -1357,17 +1398,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case extensionCmdResultMsg:
// Async extension slash command completed. Render output/error.
if msg.err != nil {
cmds = append(cmds, m.printSystemMessage(
fmt.Sprintf("Command %s error: %v", msg.name, msg.err)))
m.printSystemMessage(fmt.Sprintf("Command %s error: %v", msg.name, msg.err))
} else if msg.output != "" {
cmds = append(cmds, m.printSystemMessage(msg.output))
m.printSystemMessage(msg.output)
}
case beforeSessionSwitchResultMsg:
// Async before-session-switch hook completed. Proceed with the
// session reset if the hook did not cancel.
if msg.cancelled {
cmds = append(cmds, m.printSystemMessage(msg.reason))
m.printSystemMessage(msg.reason)
} else {
cmds = append(cmds, m.performNewSession())
}
@@ -1376,7 +1416,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Async before-fork hook completed. Proceed with the fork if the
// hook did not cancel.
if msg.cancelled {
cmds = append(cmds, m.printSystemMessage(msg.reason))
m.printSystemMessage(msg.reason)
} else {
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
}
@@ -1385,15 +1425,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Extension output — route through styled renderers when a level is set.
switch msg.Level {
case "info":
cmds = append(cmds, m.printSystemMessage(msg.Text))
m.printSystemMessage(msg.Text)
case "error":
cmds = append(cmds, m.printErrorResponse(app.StepErrorEvent{
m.printErrorResponse(app.StepErrorEvent{
Err: fmt.Errorf("%s", msg.Text),
}))
})
case "block":
cmds = append(cmds, m.printExtensionBlock(msg))
m.printExtensionBlock(msg)
default:
cmds = append(cmds, tea.Println(msg.Text))
m.appendScrollback(msg.Text)
}
default:
@@ -1408,6 +1448,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1510,8 +1551,9 @@ func (m *AppModel) renderStream() string {
// Show canceling warning if set.
if m.canceling {
theme := GetTheme()
warning := lipgloss.NewStyle().
Foreground(lipgloss.Color("214")).
Foreground(theme.Warning).
Bold(true).
Render(" ⚠ Press ESC again to cancel")
return lipgloss.JoinVertical(lipgloss.Left,
@@ -1581,10 +1623,31 @@ func (m *AppModel) renderStatusBar() string {
rightSide := strings.Join(rightParts, " ")
// Fill the gap between left+middle and right with spaces.
usedWidth := lipgloss.Width(leftSide) + lipgloss.Width(middleSide) + lipgloss.Width(rightSide)
gap := max(m.width-usedWidth, 1)
// Progressive truncation to keep the status bar on one line.
// When content exceeds terminal width, drop sections in order:
// middle (extensions/thinking) → usage stats → model label → right side.
leftW := lipgloss.Width(leftSide)
middleW := lipgloss.Width(middleSide)
rightW := lipgloss.Width(rightSide)
// Need at least 1 space gap between left+middle and right.
if leftW+middleW+rightW+1 > m.width {
// Drop middle section first (extensions/thinking status).
middleSide = ""
middleW = 0
}
if leftW+rightW+1 > m.width && len(rightParts) > 1 {
// Drop usage stats, keep model label.
rightSide = rightParts[0]
rightW = lipgloss.Width(rightSide)
}
if leftW+rightW+1 > m.width {
// Drop right side entirely.
rightSide = ""
rightW = 0
}
gap := max(m.width-leftW-middleW-rightW, 1)
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
}
@@ -1753,30 +1816,28 @@ func (m *AppModel) renderQueuedMessages() string {
// Print helpers — emit content to scrollback via tea.Println
// --------------------------------------------------------------------------
// printUserMessage renders a user message and emits it above the BT region.
func (m *AppModel) printUserMessage(text string) tea.Cmd {
return tea.Println(m.renderer.RenderUserMessage(text, time.Now()).Content)
// printUserMessage renders a user message into the scrollback buffer.
func (m *AppModel) printUserMessage(text string) {
m.appendScrollback(m.renderer.RenderUserMessage(text, time.Now()).Content)
}
// printAssistantMessage renders an assistant message and emits it above the BT region.
func (m *AppModel) printAssistantMessage(text string) tea.Cmd {
if text == "" {
return nil
// printAssistantMessage renders an assistant message into the scrollback buffer.
func (m *AppModel) printAssistantMessage(text string) {
if text != "" {
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
}
return tea.Println(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
}
// printToolResult renders a tool result message and emits it above the BT region.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) tea.Cmd {
return tea.Println(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
// printToolResult renders a tool result message into the scrollback buffer.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
m.appendScrollback(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
}
// printErrorResponse renders an error message and emits it above the BT region.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd {
if evt.Err == nil {
return nil
// printErrorResponse renders an error message into the scrollback buffer.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
if evt.Err != nil {
m.appendScrollback(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
}
return tea.Println(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
}
// --------------------------------------------------------------------------
@@ -1791,17 +1852,19 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/quit":
return tea.Quit
case "/help":
return m.printHelpMessage()
m.printHelpMessage()
case "/tools":
return m.printToolsMessage()
m.printToolsMessage()
case "/servers":
return m.printServersMessage()
m.printServersMessage()
case "/usage":
return m.printUsageMessage()
m.printUsageMessage()
case "/reset-usage":
return m.printResetUsage()
m.printResetUsage()
case "/model":
return m.handleModelCommand("")
case "/theme":
return m.handleThemeCommand("")
case "/thinking":
return m.handleThinkingCommand("")
case "/compact":
@@ -1810,14 +1873,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
return m.printSystemMessage("Conversation cleared. Starting fresh.")
m.printSystemMessage("Conversation cleared. Starting fresh.")
case "/clear-queue":
if m.appCtrl != nil {
m.appCtrl.ClearQueue()
}
m.queuedMessages = m.queuedMessages[:0]
m.distributeHeight()
return nil
case "/tree":
return m.handleTreeCommand()
@@ -1831,22 +1893,23 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
return m.handleSessionInfoCommand()
default:
return m.printSystemMessage(fmt.Sprintf("Unknown command: %s", sc.Name))
m.printSystemMessage(fmt.Sprintf("Unknown command: %s", sc.Name))
}
return nil
}
// printSystemMessage renders a system-level message and emits it above the BT region.
func (m *AppModel) printSystemMessage(text string) tea.Cmd {
return tea.Println(m.renderer.RenderSystemMessage(text, time.Now()).Content)
// printSystemMessage renders a system-level message into the scrollback buffer.
func (m *AppModel) printSystemMessage(text string) {
m.appendScrollback(m.renderer.RenderSystemMessage(text, time.Now()).Content)
}
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle, then emits it to scrollback.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) tea.Cmd {
// caller-chosen border color and optional subtitle into the scrollback buffer.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
theme := GetTheme()
// Resolve border color: use the extension's hex value, fall back to theme accent.
var borderClr = lipgloss.Color("#89b4fa") // default blue
// Resolve border color: use the extension's hex value, fall back to theme info.
borderClr := theme.Info
if evt.BorderColor != "" {
borderClr = lipgloss.Color(evt.BorderColor)
}
@@ -1865,7 +1928,7 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) tea.Cmd {
WithBorderColor(borderClr),
WithMarginBottom(1),
)
return tea.Println(rendered)
m.appendScrollback(rendered)
}
// handleExtensionCommand checks if the submitted text matches an extension-
@@ -1916,7 +1979,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
}
// printHelpMessage renders the help text listing all available slash commands.
func (m *AppModel) printHelpMessage() tea.Cmd {
func (m *AppModel) printHelpMessage() {
help := "## Available Commands\n\n" +
"**Info:**\n" +
"- `/help`: Show this help message\n" +
@@ -1966,11 +2029,11 @@ func (m *AppModel) printHelpMessage() tea.Cmd {
"- `Ctrl+C`: Exit at any time\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
"You can also just type your message to chat with the AI assistant."
return m.printSystemMessage(help)
m.printSystemMessage(help)
}
// printToolsMessage renders the list of available tools.
func (m *AppModel) printToolsMessage() tea.Cmd {
func (m *AppModel) printToolsMessage() {
var content string
content = "## Available Tools\n\n"
if len(m.toolNames) == 0 {
@@ -1980,11 +2043,11 @@ func (m *AppModel) printToolsMessage() tea.Cmd {
content += fmt.Sprintf("%d. `%s`\n", i+1, tool)
}
}
return m.printSystemMessage(content)
m.printSystemMessage(content)
}
// printServersMessage renders the list of configured MCP servers.
func (m *AppModel) printServersMessage() tea.Cmd {
func (m *AppModel) printServersMessage() {
var content string
content = "## Configured MCP Servers\n\n"
if len(m.serverNames) == 0 {
@@ -1994,13 +2057,14 @@ func (m *AppModel) printServersMessage() tea.Cmd {
content += fmt.Sprintf("%d. `%s`\n", i+1, server)
}
}
return m.printSystemMessage(content)
m.printSystemMessage(content)
}
// printUsageMessage renders token usage statistics.
func (m *AppModel) printUsageMessage() tea.Cmd {
func (m *AppModel) printUsageMessage() {
if m.usageTracker == nil {
return m.printSystemMessage("Usage tracking is not available for this model.")
m.printSystemMessage("Usage tracking is not available for this model.")
return
}
sessionStats := m.usageTracker.GetSessionStats()
@@ -2014,16 +2078,17 @@ func (m *AppModel) printUsageMessage() tea.Cmd {
content += fmt.Sprintf("**Session Total:** %d input + %d output tokens = $%.6f (%d requests)\n",
sessionStats.TotalInputTokens, sessionStats.TotalOutputTokens, sessionStats.TotalCost, sessionStats.RequestCount)
return m.printSystemMessage(content)
m.printSystemMessage(content)
}
// printResetUsage resets usage statistics and prints a confirmation.
func (m *AppModel) printResetUsage() tea.Cmd {
func (m *AppModel) printResetUsage() {
if m.usageTracker == nil {
return m.printSystemMessage("Usage tracking is not available for this model.")
m.printSystemMessage("Usage tracking is not available for this model.")
return
}
m.usageTracker.Reset()
return m.printSystemMessage("Usage statistics have been reset.")
m.printSystemMessage("Usage statistics have been reset.")
}
// handleCompactCommand starts an async compaction. It returns a tea.Cmd that
@@ -2033,23 +2098,26 @@ func (m *AppModel) printResetUsage() tea.Cmd {
// prompt (e.g. "Focus on the API design decisions").
func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
if m.appCtrl == nil {
return m.printSystemMessage("Compaction is not available.")
m.printSystemMessage("Compaction is not available.")
return nil
}
if err := m.appCtrl.CompactConversation(customInstructions); err != nil {
return m.printSystemMessage(fmt.Sprintf("Cannot compact: %v", err))
m.printSystemMessage(fmt.Sprintf("Cannot compact: %v", err))
return nil
}
// Transition to working state so the spinner shows while compaction runs.
m.state = stateWorking
m.printSystemMessage("Compacting conversation...")
var spinnerCmd tea.Cmd
if m.stream != nil {
_, spinnerCmd = m.stream.Update(app.SpinnerEvent{Show: true})
}
return tea.Batch(m.printSystemMessage("Compacting conversation..."), spinnerCmd)
return spinnerCmd
}
// printCompactResult renders the compaction summary in a styled block with
// a distinct border color and a stats subtitle.
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
// a distinct border color and a stats subtitle into the scrollback buffer.
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
theme := GetTheme()
saved := evt.OriginalTokens - evt.CompactedTokens
@@ -2071,32 +2139,89 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
WithBorderColor(theme.Secondary),
WithMarginBottom(1),
)
return tea.Println(rendered)
m.appendScrollback(rendered)
}
// flushStreamContent gets the rendered content from the stream component,
// emits it above the BT region via tea.Println, and resets the stream. This
// is called before printing tool calls (streaming completes before tools fire)
// and on step completion.
//
// After flushing, a ClearScreen is issued to force a full terminal redraw.
// When
// the stream content is moved to scrollback the view height shrinks, and
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
// below the managed region. ClearScreen ensures a clean redraw.
func (m *AppModel) flushStreamContent() tea.Cmd {
// flushStreamContent moves rendered content from the stream component into the
// scrollback buffer and resets the stream. Called before tool calls (streaming
// completes before tools fire). The actual tea.Println is deferred to
// drainScrollback() at the end of the Update cycle.
func (m *AppModel) flushStreamContent() {
if m.stream == nil {
return nil
return
}
content := m.stream.GetRenderedContent()
if content == "" {
return nil
return
}
m.stream.Reset()
return tea.Sequence(
tea.Println(content),
func() tea.Msg { return tea.ClearScreen() },
)
m.appendScrollback(content)
}
// flushStreamAndPendingUserMessages moves the previous assistant response and
// any pending queued user messages into the scrollback buffer. Called from
// SpinnerEvent{Show: true} where all previous stream chunks are guaranteed to
// have been processed. The actual tea.Println is deferred to drainScrollback().
func (m *AppModel) flushStreamAndPendingUserMessages() {
// 1. Flush previous stream content (assistant response).
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
m.appendScrollback(content)
}
}
// 2. Render pending user messages from the queue.
for _, text := range m.pendingUserPrints {
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
m.appendScrollback(rendered)
}
m.pendingUserPrints = nil
}
// appendScrollback adds rendered content to the scrollback buffer. The content
// will be emitted via tea.Println when drainScrollback is called at the end of
// the current Update cycle.
func (m *AppModel) appendScrollback(content string) {
if content != "" {
m.scrollbackBuf = append(m.scrollbackBuf, content)
}
}
// drainScrollback flushes the scrollback buffer into a single tea.Println. If
// the stream component has unflushed content, it is automatically prepended so
// that new messages always appear below the previous assistant response. When
// stream content is flushed a ClearScreen follows to clean up orphaned terminal
// rows left after the view height shrinks. Returns nil if there is nothing to
// print.
func (m *AppModel) drainScrollback() tea.Cmd {
if len(m.scrollbackBuf) == 0 {
return nil
}
var parts []string
needsClear := false
// Auto-flush any stream content so it appears before new messages.
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
needsClear = true
}
}
parts = append(parts, m.scrollbackBuf...)
m.scrollbackBuf = m.scrollbackBuf[:0]
printCmd := tea.Println(strings.Join(parts, "\n"))
if needsClear {
return tea.Sequence(
printCmd,
func() tea.Msg { return tea.ClearScreen() },
)
}
return printCmd
}
// distributeHeight recalculates child component heights after a window resize,
@@ -2109,7 +2234,7 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
// stream region = total - header - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) - footer
// separator = 1 line
// above widgets = measured dynamically
// queued msgs = ~5 lines per message (padding + text + badge + padding)
// queued msgs = measured dynamically via lipgloss.Height()
// input region = measured dynamically via lipgloss.Height()
// below widgets = measured dynamically
// status bar = 1 line (always present)
@@ -2125,8 +2250,12 @@ func (m *AppModel) distributeHeight() {
if vis.HideStatusBar {
statusBarLines = 0
}
const linesPerQueuedMsg = 5
queuedLines := len(m.queuedMessages) * linesPerQueuedMsg
// Measure actual queued message height instead of using a fixed estimate,
// since text wrapping at different widths changes the rendered line count.
var queuedLines int
if queuedView := m.renderQueuedMessages(); queuedView != "" {
queuedLines = lipgloss.Height(queuedView)
}
// Propagate hint visibility before measuring input height.
if ic, ok := m.input.(*InputComponent); ok {
@@ -2173,6 +2302,17 @@ func (m *AppModel) distributeHeight() {
}
}
// clamp constrains v to the range [lo, hi].
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// repeatRune returns a string consisting of n repetitions of r.
func repeatRune(r rune, n int) string {
if n <= 0 {
@@ -2242,7 +2382,8 @@ func remapKey(name string) (tea.KeyPressMsg, bool) {
// to that model directly.
func (m *AppModel) handleModelCommand(args string) tea.Cmd {
if m.setModel == nil {
return m.printSystemMessage("Model switching is not available.")
m.printSystemMessage("Model switching is not available.")
return nil
}
if args == "" {
@@ -2256,7 +2397,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
// Direct model switch with the provided model string.
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(args); err != nil {
return m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
return nil
}
// Update display state directly (cannot use prog.Send from Update).
@@ -2273,7 +2415,50 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
go emit(newModel, prev, "user")
}
return m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
return nil
}
// --------------------------------------------------------------------------
// Theme command handler
// --------------------------------------------------------------------------
// handleThemeCommand switches the active color theme. With no arguments it
// lists available themes and highlights the active one. With a name argument
// (e.g. "/theme catppuccin") it switches immediately.
func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
if args == "" {
// List available themes.
names := ListThemes()
active := ActiveThemeName()
var lines []string
lines = append(lines, "Available themes:")
for _, name := range names {
if name == active {
lines = append(lines, fmt.Sprintf(" * %s (active)", name))
} else {
lines = append(lines, fmt.Sprintf(" %s", name))
}
}
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("User themes: %s", userThemesDir()))
if pdir := projectThemesDir(); pdir != "" {
lines = append(lines, fmt.Sprintf("Project themes: %s", pdir))
} else {
lines = append(lines, "Project themes: .kit/themes/ (not found)")
}
m.printSystemMessage(strings.Join(lines, "\n"))
return nil
}
if err := ApplyTheme(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Theme error: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Switched to theme: %s", args))
return nil
}
// --------------------------------------------------------------------------
@@ -2285,7 +2470,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
// minimal, low, medium, high) it switches to that level.
func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
if !m.isReasoningModel {
return m.printSystemMessage("Current model does not support thinking/reasoning.")
m.printSystemMessage("Current model does not support thinking/reasoning.")
return nil
}
if args == "" {
@@ -2300,13 +2486,15 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
lines = append(lines, fmt.Sprintf("%s%s — %s", marker, l, models.ThinkingLevelDescription(l)))
}
header := fmt.Sprintf("Current thinking level: %s\n\nAvailable levels:", m.thinkingLevel)
return m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
return nil
}
// Parse and validate the level.
level := models.ParseThinkingLevel(args)
if string(level) != strings.ToLower(args) {
return m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
return nil
}
// Apply the change.
@@ -2316,7 +2504,8 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
_ = m.setThinkingLevel(string(level))
}()
}
return m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
return nil
}
// --------------------------------------------------------------------------
@@ -2327,10 +2516,12 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
func (m *AppModel) handleTreeCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
return nil
}
if ts.EntryCount() == 0 {
return m.printSystemMessage("No entries in session yet.")
m.printSystemMessage("No entries in session yet.")
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
@@ -2343,10 +2534,12 @@ func (m *AppModel) handleTreeCommand() tea.Cmd {
func (m *AppModel) handleForkCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
return nil
}
if ts.EntryCount() == 0 {
return m.printSystemMessage("No entries to fork from.")
m.printSystemMessage("No entries to fork from.")
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
@@ -2384,14 +2577,16 @@ func (m *AppModel) performNewSession() tea.Cmd {
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
return m.printSystemMessage("Conversation cleared. Starting fresh.")
m.printSystemMessage("Conversation cleared. Starting fresh.")
return nil
}
ts.ResetLeaf()
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
return m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
return nil
}
// performFork performs the actual tree branch. Called either directly (when no
@@ -2399,7 +2594,8 @@ func (m *AppModel) performNewSession() tea.Cmd {
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active.")
m.printSystemMessage("No tree session active.")
return nil
}
_ = ts.Branch(targetID)
@@ -2413,7 +2609,7 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
}
return m.printSystemMessage(
m.printSystemMessage(
fmt.Sprintf("Navigated to branch point. %s",
func() string {
if isUser {
@@ -2421,29 +2617,34 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
return "Continue from this point."
}()))
return nil
}
// handleNameCommand sets a display name for the current session.
func (m *AppModel) handleNameCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active.")
m.printSystemMessage("No tree session active.")
return nil
}
// For now, prompt user to provide name via input. We print instructions
// and the next non-command input starting with "name:" will be captured.
// TODO: inline input dialog.
currentName := ts.GetSessionName()
if currentName != "" {
return m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
return nil
}
return m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
return nil
}
// handleSessionInfoCommand shows session statistics.
func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active.")
m.printSystemMessage("No tree session active.")
return nil
}
header := ts.GetHeader()
@@ -2468,7 +2669,8 @@ func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
info += fmt.Sprintf("- **Name:** %s\n", name)
}
return m.printSystemMessage(info)
m.printSystemMessage(info)
return nil
}
// --------------------------------------------------------------------------
@@ -2779,8 +2981,7 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
WithMarginBottom(1),
)
var cmds []tea.Cmd
cmds = append(cmds, tea.Println(rendered))
m.appendScrollback(rendered)
// For ! (included in context): inject the command output into the
// conversation as a user message so the LLM can reference it on the
@@ -2800,5 +3001,5 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
m.appCtrl.AddContextMessage(contextMsg)
}
return tea.Batch(cmds...)
return nil
}
+50 -6
View File
@@ -208,9 +208,20 @@ func (ms *ModelSelectorComponent) View() tea.View {
// Header.
b.WriteString(headerStyle.Render("Model Selector"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
// Adapt help text to terminal width.
if ms.width >= 56 {
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
} else if ms.width >= 35 {
b.WriteString(helpStyle.Render("↑↓ move ↵ select esc type"))
} else {
b.WriteString(helpStyle.Render("↑↓ ↵ esc"))
}
b.WriteString("\n")
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
if ms.width >= 48 {
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
} else {
b.WriteString(infoStyle.Render("Models with API keys"))
}
b.WriteString("\n")
// Search input.
@@ -281,9 +292,9 @@ func (ms *ModelSelectorComponent) IsActive() bool {
// --- Internal helpers ---
func (ms *ModelSelectorComponent) visibleHeight() int {
// Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7
h := max(ms.height-7, 5)
return h
// Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7.
// Minimum 3 entries so the selector is still usable on short terminals.
return max(ms.height-7, 3)
}
func (ms *ModelSelectorComponent) rebuildFiltered() {
@@ -396,8 +407,37 @@ func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) s
// Active model checkmark.
var active string
activeWidth := 0
if entry.Provider+"/"+entry.ModelID == ms.currentModel {
active = lipgloss.NewStyle().Foreground(theme.Success).Render(" \u2713")
activeWidth = 2 // " ✓"
}
// Truncate model ID and provider tag to fit terminal width.
// Layout: cursor(3) + model + " " + provider + active.
// Use rune length for display-width accuracy (the "…" suffix is 1 rune / 1 column).
const cursorWidth = 3
available := max(ms.width-cursorWidth-activeWidth-1, 10) // 1 for space between model and provider
provDisplayLen := len([]rune(providerStr))
modelDisplayLen := len([]rune(modelStr))
if modelDisplayLen+1+provDisplayLen > available {
// Prioritize model name — truncate it, but keep provider visible.
maxModel := max(available-provDisplayLen-1, 6)
if maxModel < modelDisplayLen {
if maxModel > 3 {
runes := []rune(modelStr)
modelStr = string(runes[:maxModel-1]) + "…"
} else {
runes := []rune(modelStr)
modelStr = string(runes[:maxModel])
}
}
// If provider itself is too long, drop it.
modelDisplayLen = len([]rune(modelStr))
if modelDisplayLen+1+provDisplayLen > available {
providerStr = ""
}
}
// Style the model ID.
@@ -409,5 +449,9 @@ func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) s
// Style the provider tag.
providerStyle := lipgloss.NewStyle().Foreground(theme.Muted)
return cursor + modelStyle.Render(modelStr) + " " + providerStyle.Render(providerStr) + active
result := cursor + modelStyle.Render(modelStr)
if providerStr != "" {
result += " " + providerStyle.Render(providerStr)
}
return result + active
}
+15 -7
View File
@@ -116,7 +116,7 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
appCtrl: ctrl,
stream: stream,
input: input,
renderer: NewMessageRenderer(80, false),
renderer: newMessageRenderer(80, false),
compactMode: false,
modelName: "test-model",
width: 80,
@@ -405,14 +405,16 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
}
// TestQueuedMessages_poppedOnQueueUpdated verifies that QueueUpdatedEvent pops
// consumed messages from queuedMessages and prints them to scrollback.
// consumed messages from queuedMessages and moves them to pendingUserPrints.
// The actual printing is deferred to SpinnerEvent{Show: true} to preserve
// chronological order with the preceding assistant response.
func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.queuedMessages = []string{"first", "second", "third"}
// Simulate drainQueue popping one item (length goes from 3 to 2).
_, cmd := m.Update(app.QueueUpdatedEvent{Length: 2})
m = sendMsg(m, app.QueueUpdatedEvent{Length: 2})
if len(m.queuedMessages) != 2 {
t.Fatalf("expected 2 queued messages after pop, got %d", len(m.queuedMessages))
@@ -420,14 +422,17 @@ func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
if m.queuedMessages[0] != "second" {
t.Fatalf("expected first remaining message 'second', got %q", m.queuedMessages[0])
}
// Should produce a cmd (tea.Println for the popped user message).
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) for popped message")
// Popped message should be deferred to pendingUserPrints.
if len(m.pendingUserPrints) != 1 {
t.Fatalf("expected 1 pending user print, got %d", len(m.pendingUserPrints))
}
if m.pendingUserPrints[0] != "first" {
t.Fatalf("expected pending message 'first', got %q", m.pendingUserPrints[0])
}
}
// TestQueuedMessages_allPoppedOnDrain verifies that QueueUpdatedEvent with
// Length=0 pops all remaining queued messages.
// Length=0 pops all remaining queued messages into pendingUserPrints.
func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
@@ -438,6 +443,9 @@ func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
if len(m.queuedMessages) != 0 {
t.Fatalf("expected 0 queued messages after drain, got %d", len(m.queuedMessages))
}
if len(m.pendingUserPrints) != 2 {
t.Fatalf("expected 2 pending user prints, got %d", len(m.pendingUserPrints))
}
}
// --------------------------------------------------------------------------
+28 -26
View File
@@ -135,31 +135,24 @@ func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd)
func (o *overlayDialog) Render() string {
theme := GetTheme()
// Calculate dialog dimensions.
// Calculate dialog dimensions, clamped to terminal bounds.
termW := max(o.width, 10)
termH := max(o.height, 5)
dw := o.dialogWidth
if dw == 0 {
dw = o.width * 60 / 100
}
if dw < 30 {
dw = 30
}
if dw > o.width-4 {
dw = o.width - 4
dw = termW * 60 / 100
}
dw = clamp(dw, min(24, termW), termW-2)
mh := o.maxHeight
if mh == 0 {
mh = o.height * 80 / 100
}
if mh < 8 {
mh = 8
}
if mh > o.height-2 {
mh = o.height - 2
mh = termH * 80 / 100
}
mh = clamp(mh, min(6, termH), termH)
// Inner width accounts for border (2) + horizontal padding (2 left + 1 right).
innerWidth := max(dw-5, 10)
innerWidth := max(dw-5, 6)
// Render body text (potentially as markdown).
bodyText := o.content
@@ -249,7 +242,7 @@ func (o *overlayDialog) Render() string {
innerContent := strings.Join(parts, "\n")
// Resolve border color.
borderClr := lipgloss.Color("#89b4fa") // default blue
borderClr := theme.Info
if o.borderColor != "" {
borderClr = lipgloss.Color(o.borderColor)
}
@@ -268,18 +261,27 @@ func (o *overlayDialog) Render() string {
dialog := dialogStyle.Render(innerContent)
// Key hints below the dialog.
// Key hints below the dialog, adapted to width.
var hints []string
if scrollable {
hints = append(hints, "↑/↓ scroll")
}
if len(o.actions) > 0 {
hints = append(hints, "←/→ switch")
hints = append(hints, "Enter select")
if termW >= 50 {
if scrollable {
hints = append(hints, "↑/↓ scroll")
}
if len(o.actions) > 0 {
hints = append(hints, "←/→ switch")
hints = append(hints, "Enter select")
} else {
hints = append(hints, "Enter dismiss")
}
hints = append(hints, "Esc cancel")
} else {
hints = append(hints, "Enter dismiss")
if len(o.actions) > 0 {
hints = append(hints, "↵ select")
} else {
hints = append(hints, "↵ ok")
}
hints = append(hints, "esc")
}
hints = append(hints, "Esc cancel")
hintText := lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" " + strings.Join(hints, " "))
+1 -1
View File
@@ -83,7 +83,7 @@ func newInputPrompt(message, placeholder, defaultValue string, width, height int
// Prevent Enter from inserting a newline — we intercept it for submit.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
)
if defaultValue != "" {
+23 -18
View File
@@ -42,18 +42,19 @@ func NewSlashCommandInput(width int, title string) *SlashCommandInput {
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
// Style the textarea to match huh theme
// Style the textarea using theme colors.
theme := GetTheme()
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
styles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
styles.Focused.Prompt = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
@@ -178,9 +179,11 @@ func (s *SlashCommandInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (s *SlashCommandInput) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
Foreground(theme.Text).
MarginBottom(1).
PaddingLeft(3)
@@ -191,7 +194,7 @@ func (s *SlashCommandInput) View() tea.View {
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(lipgloss.Color("39")).
BorderForeground(theme.Primary).
PaddingLeft(2). // match message block paddingLeft
Width(s.width - 1) // full width minus left border
@@ -223,11 +226,11 @@ func (s *SlashCommandInput) View() tea.View {
// Add help text at bottom (unless hidden by extension).
if !s.hideHint {
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Foreground(theme.VeryMuted).
MarginTop(1).
PaddingLeft(3)
helpText := "enter submit • ctrl+j / alt+enter new line"
helpText := "enter submit • ctrl+j / shift+enter new line"
view.WriteString("\n")
view.WriteString(helpStyle.Render(helpText))
@@ -240,10 +243,12 @@ func (s *SlashCommandInput) View() tea.View {
// renderPopup renders the autocomplete popup
func (s *SlashCommandInput) renderPopup() string {
theme := GetTheme()
// Popup styling
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("236")).
BorderForeground(theme.MutedBorder).
Padding(1, 2).
Width(s.width - 4). // Account for container padding
MarginLeft(0) // No extra margin needed due to container padding
@@ -268,7 +273,7 @@ func (s *SlashCommandInput) renderPopup() string {
var indicator string
if i == s.selected {
indicator = lipgloss.NewStyle().
Foreground(lipgloss.Color("39")).
Foreground(theme.Primary).
Render("> ")
} else {
indicator = " "
@@ -276,16 +281,16 @@ func (s *SlashCommandInput) renderPopup() string {
// Format item
nameStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("39")).
Foreground(theme.Secondary).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("243"))
Foreground(theme.Muted)
// Highlight selected item
if i == s.selected {
nameStyle = nameStyle.Foreground(lipgloss.Color("87"))
descStyle = descStyle.Foreground(lipgloss.Color("250"))
nameStyle = nameStyle.Foreground(theme.Primary)
descStyle = descStyle.Foreground(theme.Text)
}
// Format with proper spacing
@@ -305,11 +310,11 @@ func (s *SlashCommandInput) renderPopup() string {
// Add scroll indicators if needed
if startIdx > 0 {
scrollUpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
scrollUpStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
items = append([]string{scrollUpStyle.Render(" ↑ more above")}, items...)
}
if endIdx < len(s.filtered) {
scrollDownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
scrollDownStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
items = append(items, scrollDownStyle.Render(" ↓ more below"))
}
// Join items
@@ -317,7 +322,7 @@ func (s *SlashCommandInput) renderPopup() string {
// Add footer hint
footerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("238")).
Foreground(theme.VeryMuted).
Italic(true)
footer := footerStyle.Render("↑↓ navigate • tab complete • ↵ select • esc dismiss")
+105 -28
View File
@@ -1,6 +1,7 @@
package ui
import (
"fmt"
"strings"
"time"
@@ -58,14 +59,20 @@ func knightRiderFrames() []string {
}
// streamSpinnerTickMsg is the internal tick message that drives the KITT-style
// spinner animation inside StreamComponent.
type streamSpinnerTickMsg struct{}
// spinner animation inside StreamComponent. The generation field ties each tick
// to the spinner session that created it so that stale ticks from a previous
// start/stop cycle are silently discarded instead of creating a second
// concurrent tick loop (which doubles the animation speed).
type streamSpinnerTickMsg struct {
generation uint64
}
// streamSpinnerTickCmd returns a tea.Cmd that fires streamSpinnerTickMsg at the
// KITT animation frame rate (14 fps).
func streamSpinnerTickCmd() tea.Cmd {
// KITT animation frame rate (14 fps). The generation parameter is embedded in
// the message so the receiver can verify it matches the current spinner session.
func streamSpinnerTickCmd(generation uint64) tea.Cmd {
return tea.Tick(time.Second/14, func(_ time.Time) tea.Msg {
return streamSpinnerTickMsg{}
return streamSpinnerTickMsg{generation: generation}
})
}
@@ -127,6 +134,15 @@ type StreamComponent struct {
// remains visible alongside streaming text until Reset().
spinning bool
// spinnerGeneration is incremented each time a new spinner tick loop
// is started. Tick messages carry the generation they were created for;
// if a tick's generation doesn't match the current one, it is a stale
// tick from a previous start/stop cycle and is silently discarded.
// This prevents multiple concurrent tick loops from accumulating when
// the spinner is rapidly stopped and restarted (e.g. SpinnerEvent
// hide → ToolExecutionEvent start before the old tick fires).
spinnerGeneration uint64
// spinnerFrames are the pre-rendered KITT animation frames.
spinnerFrames []string
@@ -165,9 +181,15 @@ type StreamComponent struct {
// the cache.
renderDirty bool
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
// reasoningStartTime records when the first reasoning chunk was received.
reasoningStartTime time.Time
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// messageRenderer renders assistant messages in standard mode.
messageRenderer *MessageRenderer
@@ -200,7 +222,7 @@ func NewStreamComponent(compactMode bool, width int, modelName string) *StreamCo
spinnerFrames: knightRiderFrames(),
compactMode: compactMode,
modelName: modelName,
messageRenderer: NewMessageRenderer(width, false),
messageRenderer: newMessageRenderer(width, false),
compactRenderer: NewCompactRenderer(width, false),
width: width,
}
@@ -226,6 +248,7 @@ func (s *StreamComponent) SetHeight(h int) {
func (s *StreamComponent) Reset() {
s.phase = streamPhaseIdle
s.spinning = false
s.spinnerGeneration++ // invalidate any in-flight tick commands
s.spinnerFrame = 0
s.activeTools = nil
s.streamContent.Reset()
@@ -236,6 +259,8 @@ func (s *StreamComponent) Reset() {
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
}
// GetRenderedContent returns the rendered assistant message from the accumulated
@@ -304,11 +329,15 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.renderDirty = true
case streamSpinnerTickMsg:
if s.spinning {
// Only continue the tick loop if this tick belongs to the current
// spinner session. Stale ticks from a previous start/stop cycle
// are silently dropped, preventing duplicate concurrent tick loops
// that would double (or worse) the animation speed.
if s.spinning && msg.generation == s.spinnerGeneration {
s.spinnerFrame++
return s, streamSpinnerTickCmd()
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
// Spinning stopped; let the tick loop die naturally.
// Spinning stopped or generation mismatch; let the tick loop die.
// ── App-layer events ──────────────────────────────────────────────────
@@ -316,13 +345,17 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Show && !s.spinning {
s.phase = streamPhaseActive
s.spinning = true
s.spinnerGeneration++ // new session; invalidate any stale ticks
s.spinnerFrame = 0
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
return s, streamSpinnerTickCmd()
return s, streamSpinnerTickCmd(s.spinnerGeneration)
} else if !msg.Show && s.spinning {
s.spinning = false
// Bump generation so any in-flight tick from this session is
// discarded if spinning is restarted before it fires.
s.spinnerGeneration++
}
case streamFlushTickMsg:
@@ -334,6 +367,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
if s.reasoningStartTime.IsZero() {
s.reasoningStartTime = time.Now()
}
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
@@ -345,6 +381,10 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
// Freeze reasoning duration on transition from reasoning to streaming.
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
s.reasoningDuration = time.Since(s.reasoningStartTime)
}
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
s.flushPending = true
@@ -360,7 +400,8 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !s.spinning {
s.phase = streamPhaseActive
s.spinning = true
return s, streamSpinnerTickCmd()
s.spinnerGeneration++ // new session; invalidate stale ticks
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
} else {
// Tool finished — remove from active list but keep spinning if others remain.
@@ -432,29 +473,65 @@ func (s *StreamComponent) render() string {
return content
}
// renderReasoningBlock renders the reasoning/thinking content. When thinking
// is visible, the full reasoning text is shown in muted italic style. When
// collapsed, a "Thinking..." label is shown instead.
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
// box. When collapsed, shows the last 10 lines with a truncation hint. When
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
theme := GetTheme()
maxWidth := max(s.width-4, 20)
if !s.thinkingVisible {
// Show collapsed "Thinking..." label.
return lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render("Thinking...")
}
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
// Render full reasoning text in muted italic style.
style := lipgloss.NewStyle().
contentStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true)
// Wrap to terminal width.
maxWidth := max(s.width-4, 20) // leave some margin
styled := style.Width(maxWidth).Render(reasoning)
return styled
var parts []string
// When collapsed and content exceeds 10 lines, show only the last 10
// with a truncation hint (matching iteratr's thinking block pattern).
const maxCollapsedLines = 10
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
hidden := len(lines) - maxCollapsedLines
hintStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Italic(true)
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
lines = lines[len(lines)-maxCollapsedLines:]
}
// Render reasoning text.
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
// Duration footer.
var duration time.Duration
if s.reasoningDuration > 0 {
duration = s.reasoningDuration
} else if !s.reasoningStartTime.IsZero() {
duration = time.Since(s.reasoningStartTime)
}
if duration > 0 {
var durationStr string
if duration < time.Second {
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ") +
lipgloss.NewStyle().Foreground(theme.Info).Render(durationStr)
parts = append(parts, footer)
}
innerContent := strings.Join(parts, "\n")
// Wrap in box with surface background for visual distinction.
boxStyle := lipgloss.NewStyle().
Background(theme.MutedBorder). // Surface0 (#313244)
PaddingLeft(1).
Width(maxWidth + 2).
MarginBottom(1)
return boxStyle.Render(innerContent)
}
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
+83 -124
View File
@@ -1,11 +1,12 @@
package ui
import (
"fmt"
"image/color"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/mark3labs/kit/internal/config"
"github.com/spf13/viper"
)
// uintPtr returns a pointer to u. Used by ansi.StyleConfig fields.
@@ -20,6 +21,18 @@ func BaseStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
// colorHex converts a color.Color to a hex string suitable for ansi.StyleConfig.
func colorHex(c color.Color) string {
r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
// colorHexPtr returns a pointer to the hex string of a color.Color.
func colorHexPtr(c color.Color) *string {
s := colorHex(c)
return &s
}
// GetMarkdownRenderer creates and returns a configured glamour.TermRenderer for
// rendering markdown content with syntax highlighting and proper formatting. The
// renderer is customized with our theme colors and adapted to the specified width.
@@ -31,169 +44,119 @@ func GetMarkdownRenderer(width int) *glamour.TermRenderer {
return r
}
// colorScheme holds resolved color values for markdown rendering.
type colorScheme struct {
text string
muted string
heading string
emph string
strong string
link string
code string
err string
keyword string
str string
number string
comment string
}
// resolveColorScheme determines the color palette based on user config and background.
func resolveColorScheme() colorScheme {
var mdTheme config.MarkdownTheme
err := config.FilepathOr("markdown-theme", &mdTheme)
fromConfig := err == nil && viper.InConfig("markdown-theme")
if fromConfig && IsDarkBackground() {
return colorScheme{
text: mdTheme.Text.Light, muted: mdTheme.Muted.Light,
heading: mdTheme.Heading.Light, emph: mdTheme.Emph.Light,
strong: mdTheme.Strong.Light, link: mdTheme.Link.Light,
code: mdTheme.Code.Light, err: mdTheme.Error.Light,
keyword: mdTheme.Keyword.Light, str: mdTheme.String.Light,
number: mdTheme.Number.Light, comment: mdTheme.Comment.Light,
}
}
if fromConfig {
return colorScheme{
text: mdTheme.Text.Dark, muted: mdTheme.Muted.Dark,
heading: mdTheme.Heading.Dark, emph: mdTheme.Emph.Dark,
strong: mdTheme.Strong.Dark, link: mdTheme.Link.Dark,
code: mdTheme.Code.Dark, err: mdTheme.Error.Dark,
keyword: mdTheme.Keyword.Dark, str: mdTheme.String.Dark,
number: mdTheme.Number.Dark, comment: mdTheme.Comment.Dark,
}
}
if IsDarkBackground() {
return colorScheme{
text: "#F9FAFB", muted: "#9CA3AF",
heading: "#22D3EE", emph: "#FDE047",
strong: "#F9FAFB", link: "#60A5FA",
code: "#D1D5DB", err: "#F87171",
keyword: "#C084FC", str: "#34D399",
number: "#FBBF24", comment: "#9CA3AF",
}
}
return colorScheme{
text: "#1F2937", muted: "#6B7280",
heading: "#0891B2", emph: "#D97706",
strong: "#1F2937", link: "#2563EB",
code: "#374151", err: "#DC2626",
keyword: "#7C3AED", str: "#059669",
number: "#D97706", comment: "#6B7280",
}
}
// generateMarkdownStyleConfig creates an ansi.StyleConfig for markdown rendering.
// generateMarkdownStyleConfig creates an ansi.StyleConfig from the active theme.
func generateMarkdownStyleConfig() ansi.StyleConfig {
cs := resolveColorScheme()
md := GetTheme().Markdown
text := colorHexPtr(md.Text)
muted := colorHexPtr(md.Muted)
heading := colorHexPtr(md.Heading)
emph := colorHexPtr(md.Emph)
strong := colorHexPtr(md.Strong)
link := colorHexPtr(md.Link)
code := colorHexPtr(md.Code)
errClr := colorHexPtr(md.Error)
keyword := colorHexPtr(md.Keyword)
str := colorHexPtr(md.String)
number := colorHexPtr(md.Number)
comment := colorHexPtr(md.Comment)
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "",
BlockSuffix: "",
Color: &cs.text,
Color: text,
},
Margin: uintPtr(0), // Remove margin to prevent spacing
Margin: uintPtr(0),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: &cs.muted,
Color: muted,
Italic: new(true),
Prefix: "┃ ",
},
Indent: uintPtr(1),
},
List: ansi.StyleList{
LevelIndent: 0, // Remove list indentation
LevelIndent: 0,
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: &cs.text,
Color: text,
},
},
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: &cs.heading,
Color: heading,
Bold: new(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "# ",
Color: &cs.heading,
Color: heading,
Bold: new(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: &cs.heading,
Color: heading,
Bold: new(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: &cs.heading,
Color: heading,
Bold: new(true),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: &cs.heading,
Color: heading,
Bold: new(true),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: &cs.heading,
Color: heading,
Bold: new(true),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: &cs.heading,
Color: heading,
Bold: new(true),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: new(true),
Color: &cs.muted,
Color: muted,
},
Emph: ansi.StylePrimitive{
Color: &cs.emph,
Color: emph,
Italic: new(true),
},
Strong: ansi.StylePrimitive{
Bold: new(true),
Color: &cs.strong,
Color: strong,
},
HorizontalRule: ansi.StylePrimitive{
Color: &cs.muted,
Color: muted,
Format: "\n─────────────────────────────────────────\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
Color: &cs.text,
Color: text,
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
Color: &cs.text,
Color: text,
},
Task: ansi.StyleTask{
StylePrimitive: ansi.StylePrimitive{},
@@ -201,25 +164,25 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: &cs.link,
Color: link,
Underline: new(true),
},
LinkText: ansi.StylePrimitive{
Color: &cs.link,
Color: link,
Bold: new(true),
},
Image: ansi.StylePrimitive{
Color: &cs.link,
Color: link,
Underline: new(true),
Format: "🖼 {{.text}}",
},
ImageText: ansi.StylePrimitive{
Color: &cs.link,
Color: link,
Format: "{{.text}}",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: &cs.code,
Color: code,
Prefix: "",
Suffix: "",
},
@@ -228,50 +191,46 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "",
Color: &cs.code,
Color: code,
},
Margin: uintPtr(0), // Remove margin
Margin: uintPtr(0),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{Color: &cs.text},
Error: ansi.StylePrimitive{Color: &cs.err},
Comment: ansi.StylePrimitive{Color: &cs.comment},
CommentPreproc: ansi.StylePrimitive{Color: &cs.keyword},
Keyword: ansi.StylePrimitive{Color: &cs.keyword},
KeywordReserved: ansi.StylePrimitive{
Color: &cs.keyword,
},
KeywordNamespace: ansi.StylePrimitive{
Color: &cs.keyword,
},
KeywordType: ansi.StylePrimitive{Color: &cs.keyword},
Operator: ansi.StylePrimitive{Color: &cs.text},
Punctuation: ansi.StylePrimitive{Color: &cs.text},
Name: ansi.StylePrimitive{Color: &cs.text},
NameBuiltin: ansi.StylePrimitive{Color: &cs.text},
NameTag: ansi.StylePrimitive{Color: &cs.keyword},
NameAttribute: ansi.StylePrimitive{Color: &cs.text},
NameClass: ansi.StylePrimitive{Color: &cs.keyword},
NameConstant: ansi.StylePrimitive{Color: &cs.text},
NameDecorator: ansi.StylePrimitive{Color: &cs.text},
NameFunction: ansi.StylePrimitive{Color: &cs.text},
LiteralNumber: ansi.StylePrimitive{Color: &cs.number},
LiteralString: ansi.StylePrimitive{Color: &cs.str},
Text: ansi.StylePrimitive{Color: text},
Error: ansi.StylePrimitive{Color: errClr},
Comment: ansi.StylePrimitive{Color: comment},
CommentPreproc: ansi.StylePrimitive{Color: keyword},
Keyword: ansi.StylePrimitive{Color: keyword},
KeywordReserved: ansi.StylePrimitive{Color: keyword},
KeywordNamespace: ansi.StylePrimitive{Color: keyword},
KeywordType: ansi.StylePrimitive{Color: keyword},
Operator: ansi.StylePrimitive{Color: text},
Punctuation: ansi.StylePrimitive{Color: text},
Name: ansi.StylePrimitive{Color: text},
NameBuiltin: ansi.StylePrimitive{Color: text},
NameTag: ansi.StylePrimitive{Color: keyword},
NameAttribute: ansi.StylePrimitive{Color: text},
NameClass: ansi.StylePrimitive{Color: keyword},
NameConstant: ansi.StylePrimitive{Color: text},
NameDecorator: ansi.StylePrimitive{Color: text},
NameFunction: ansi.StylePrimitive{Color: text},
LiteralNumber: ansi.StylePrimitive{Color: number},
LiteralString: ansi.StylePrimitive{Color: str},
LiteralStringEscape: ansi.StylePrimitive{
Color: &cs.keyword,
Color: keyword,
},
GenericDeleted: ansi.StylePrimitive{Color: &cs.err},
GenericDeleted: ansi.StylePrimitive{Color: errClr},
GenericEmph: ansi.StylePrimitive{
Color: &cs.emph,
Color: emph,
Italic: new(true),
},
GenericInserted: ansi.StylePrimitive{Color: &cs.str},
GenericInserted: ansi.StylePrimitive{Color: str},
GenericStrong: ansi.StylePrimitive{
Color: &cs.strong,
Color: strong,
Bold: new(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: &cs.heading,
Color: heading,
},
},
},
@@ -288,14 +247,14 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n ",
Color: &cs.link,
Color: link,
},
Text: ansi.StylePrimitive{
Color: &cs.text,
Color: text,
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: &cs.text,
Color: text,
},
},
}
+637
View File
@@ -0,0 +1,637 @@
package ui
import (
"encoding/json"
"fmt"
"image/color"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// ThemeEntry is a named, loadable theme — either built-in or discovered from disk.
type ThemeEntry struct {
Name string // Display name (filename stem or preset name)
Source string // "builtin" or absolute file path
theme Theme // Resolved theme (lazy-loaded for file-based)
loaded bool
}
// Theme returns the resolved ui.Theme, loading from disk on first access.
func (e *ThemeEntry) Theme() (Theme, error) {
if e.loaded {
return e.theme, nil
}
if e.Source == "builtin" {
// Already set at registration time.
return e.theme, nil
}
t, err := loadThemeFile(e.Source)
if err != nil {
return Theme{}, fmt.Errorf("loading theme %q: %w", e.Name, err)
}
e.theme = t
e.loaded = true
return e.theme, nil
}
// ---------------------------------------------------------------------------
// Built-in presets
// ---------------------------------------------------------------------------
// builtinThemes returns the set of themes shipped with Kit.
// makeTheme builds a full Theme from a compact palette spec. Fields left as
// zero color.Color inherit from the KITT default theme, keeping the preset
// definitions focused on what differs.
type presetColors struct {
primary, secondary, success, warning, error_, info [2]string // [light, dark]
text, muted, veryMuted, background, border, mutedBorder [2]string
system, tool, accent, highlight [2]string
mdKeyword, mdString, mdNumber, mdComment, mdHeading, mdLink [2]string
}
func makeTheme(p presetColors) Theme {
ac := func(pair [2]string) color.Color { return AdaptiveColor(pair[0], pair[1]) }
def := DefaultTheme()
acOr := func(pair [2]string, fb color.Color) color.Color {
if pair[0] == "" && pair[1] == "" {
return fb
}
return ac(pair)
}
t := Theme{
Primary: ac(p.primary),
Secondary: acOr(p.secondary, ac(p.primary)),
Success: ac(p.success),
Warning: ac(p.warning),
Error: ac(p.error_),
Info: ac(p.info),
Text: ac(p.text),
Muted: acOr(p.muted, def.Muted),
VeryMuted: acOr(p.veryMuted, def.VeryMuted),
Background: ac(p.background),
Border: acOr(p.border, def.Border),
MutedBorder: acOr(p.mutedBorder, def.MutedBorder),
System: acOr(p.system, ac(p.info)),
Tool: acOr(p.tool, ac(p.warning)),
Accent: acOr(p.accent, ac(p.primary)),
Highlight: acOr(p.highlight, def.Highlight),
}
// Derive diff/code backgrounds from the base background.
t.DiffInsertBg = def.DiffInsertBg
t.DiffDeleteBg = def.DiffDeleteBg
t.DiffEqualBg = def.DiffEqualBg
t.DiffMissingBg = def.DiffMissingBg
t.CodeBg = def.CodeBg
t.GutterBg = def.GutterBg
t.WriteBg = def.WriteBg
// Markdown colors.
t.Markdown = MarkdownThemeColors{
Text: t.Text,
Muted: t.Muted,
Heading: acOr(p.mdHeading, t.Primary),
Emph: t.Warning,
Strong: t.Text,
Link: acOr(p.mdLink, t.Info),
Code: t.Muted,
Error: t.Error,
Keyword: acOr(p.mdKeyword, t.Primary),
String: acOr(p.mdString, t.Success),
Number: acOr(p.mdNumber, t.Warning),
Comment: acOr(p.mdComment, t.VeryMuted),
}
return t
}
// builtinThemes returns the set of themes shipped with Kit.
// Inspired by the OpenCode theme collection.
func builtinThemes() map[string]Theme {
return map[string]Theme{
"kitt": DefaultTheme(),
"catppuccin": makeTheme(presetColors{
primary: [2]string{"#8839ef", "#cba6f7"}, secondary: [2]string{"#04a5e5", "#89dceb"},
success: [2]string{"#40a02b", "#a6e3a1"}, warning: [2]string{"#df8e1d", "#f9e2af"},
error_: [2]string{"#d20f39", "#f38ba8"}, info: [2]string{"#1e66f5", "#89b4fa"},
text: [2]string{"#4c4f69", "#cdd6f4"}, muted: [2]string{"#6c6f85", "#a6adc8"},
veryMuted: [2]string{"#9ca0b0", "#6c7086"}, background: [2]string{"#eff1f5", "#1e1e2e"},
border: [2]string{"#acb0be", "#585b70"}, mutedBorder: [2]string{"#ccd0da", "#313244"},
system: [2]string{"#179299", "#94e2d5"}, tool: [2]string{"#fe640b", "#fab387"},
accent: [2]string{"#ea76cb", "#f5c2e7"}, highlight: [2]string{"#e6e9ef", "#181825"},
mdKeyword: [2]string{"#8839ef", "#cba6f7"}, mdString: [2]string{"#40a02b", "#a6e3a1"},
mdNumber: [2]string{"#fe640b", "#fab387"}, mdComment: [2]string{"#9ca0b0", "#6c7086"},
}),
"dracula": makeTheme(presetColors{
primary: [2]string{"#7c6bf5", "#bd93f9"}, secondary: [2]string{"#d16090", "#ff79c6"},
success: [2]string{"#2fbf71", "#50fa7b"}, warning: [2]string{"#f7a14d", "#ffb86c"},
error_: [2]string{"#d9536f", "#ff5555"}, info: [2]string{"#1d7fc5", "#8be9fd"},
text: [2]string{"#1f1f2f", "#f8f8f2"}, background: [2]string{"#f8f8f2", "#1d1e28"},
accent: [2]string{"#d16090", "#ff79c6"},
mdKeyword: [2]string{"#7c6bf5", "#bd93f9"}, mdString: [2]string{"#2fbf71", "#50fa7b"},
mdComment: [2]string{"#6272a4", "#6272a4"},
}),
"tokyonight": makeTheme(presetColors{
primary: [2]string{"#2e7de9", "#7aa2f7"}, secondary: [2]string{"#b15c00", "#ff9e64"},
success: [2]string{"#587539", "#9ece6a"}, warning: [2]string{"#8c6c3e", "#e0af68"},
error_: [2]string{"#c94060", "#f7768e"}, info: [2]string{"#007197", "#7dcfff"},
text: [2]string{"#273153", "#c0caf5"}, background: [2]string{"#e1e2e7", "#1a1b26"},
mdKeyword: [2]string{"#2e7de9", "#7aa2f7"}, mdString: [2]string{"#587539", "#9ece6a"},
mdComment: [2]string{"#848cb5", "#565f89"},
}),
"nord": makeTheme(presetColors{
primary: [2]string{"#5e81ac", "#88c0d0"}, secondary: [2]string{"#bf616a", "#d57780"},
success: [2]string{"#8fbcbb", "#a3be8c"}, warning: [2]string{"#d08770", "#d08770"},
error_: [2]string{"#bf616a", "#bf616a"}, info: [2]string{"#81a1c1", "#81a1c1"},
text: [2]string{"#2e3440", "#e5e9f0"}, background: [2]string{"#eceff4", "#2e3440"},
mdKeyword: [2]string{"#5e81ac", "#81a1c1"}, mdString: [2]string{"#8fbcbb", "#a3be8c"},
mdComment: [2]string{"#616e88", "#616e88"},
}),
"gruvbox": makeTheme(presetColors{
primary: [2]string{"#076678", "#83a598"}, secondary: [2]string{"#9d0006", "#fb4934"},
success: [2]string{"#79740e", "#b8bb26"}, warning: [2]string{"#b57614", "#fabd2f"},
error_: [2]string{"#9d0006", "#fb4934"}, info: [2]string{"#8f3f71", "#d3869b"},
text: [2]string{"#3c3836", "#ebdbb2"}, background: [2]string{"#fbf1c7", "#282828"},
mdKeyword: [2]string{"#9d0006", "#fb4934"}, mdString: [2]string{"#79740e", "#b8bb26"},
mdComment: [2]string{"#928374", "#928374"},
}),
"monokai": makeTheme(presetColors{
primary: [2]string{"#bf7bff", "#ae81ff"}, secondary: [2]string{"#d9487c", "#f92672"},
success: [2]string{"#4fb54b", "#a6e22e"}, warning: [2]string{"#f1a948", "#fd971f"},
error_: [2]string{"#e54b4b", "#f92672"}, info: [2]string{"#2d9ad7", "#66d9ef"},
text: [2]string{"#292318", "#f8f8f2"}, background: [2]string{"#fdf8ec", "#272822"},
mdKeyword: [2]string{"#d9487c", "#f92672"}, mdString: [2]string{"#4fb54b", "#a6e22e"},
mdComment: [2]string{"#888888", "#75715e"},
}),
"solarized": makeTheme(presetColors{
primary: [2]string{"#268bd2", "#6c71c4"}, secondary: [2]string{"#d33682", "#d33682"},
success: [2]string{"#859900", "#859900"}, warning: [2]string{"#b58900", "#b58900"},
error_: [2]string{"#dc322f", "#dc322f"}, info: [2]string{"#2aa198", "#2aa198"},
text: [2]string{"#586e75", "#93a1a1"}, background: [2]string{"#fdf6e3", "#002b36"},
mdKeyword: [2]string{"#268bd2", "#6c71c4"}, mdString: [2]string{"#859900", "#859900"},
mdComment: [2]string{"#93a1a1", "#586e75"},
}),
"github": makeTheme(presetColors{
primary: [2]string{"#0969da", "#58a6ff"}, secondary: [2]string{"#1b7c83", "#39c5cf"},
success: [2]string{"#1a7f37", "#3fb950"}, warning: [2]string{"#9a6700", "#e3b341"},
error_: [2]string{"#cf222e", "#f85149"}, info: [2]string{"#bc4c00", "#d29922"},
text: [2]string{"#24292f", "#c9d1d9"}, background: [2]string{"#ffffff", "#0d1117"},
mdKeyword: [2]string{"#0969da", "#58a6ff"}, mdString: [2]string{"#1a7f37", "#3fb950"},
mdComment: [2]string{"#6e7781", "#8b949e"},
}),
"one-dark": makeTheme(presetColors{
primary: [2]string{"#4078f2", "#61afef"}, secondary: [2]string{"#0184bc", "#56b6c2"},
success: [2]string{"#50a14f", "#98c379"}, warning: [2]string{"#c18401", "#e5c07b"},
error_: [2]string{"#e45649", "#e06c75"}, info: [2]string{"#986801", "#d19a66"},
text: [2]string{"#383a42", "#abb2bf"}, background: [2]string{"#fafafa", "#282c34"},
mdKeyword: [2]string{"#a626a4", "#c678dd"}, mdString: [2]string{"#50a14f", "#98c379"},
mdComment: [2]string{"#a0a1a7", "#5c6370"},
}),
"rose-pine": makeTheme(presetColors{
primary: [2]string{"#31748f", "#9ccfd8"}, secondary: [2]string{"#d7827e", "#ebbcba"},
success: [2]string{"#286983", "#31748f"}, warning: [2]string{"#ea9d34", "#f6c177"},
error_: [2]string{"#b4637a", "#eb6f92"}, info: [2]string{"#56949f", "#9ccfd8"},
text: [2]string{"#575279", "#e0def4"}, background: [2]string{"#faf4ed", "#191724"},
mdKeyword: [2]string{"#31748f", "#9ccfd8"}, mdString: [2]string{"#ea9d34", "#f6c177"},
mdComment: [2]string{"#9893a5", "#6e6a86"},
}),
"ayu": makeTheme(presetColors{
primary: [2]string{"#4aa8c8", "#3fb7e3"}, secondary: [2]string{"#ef7d71", "#f2856f"},
success: [2]string{"#5fb978", "#78d05c"}, warning: [2]string{"#ea9f41", "#e4a75c"},
error_: [2]string{"#e6656a", "#f58572"}, info: [2]string{"#2f9bce", "#66c6f1"},
text: [2]string{"#4f5964", "#d6dae0"}, background: [2]string{"#fdfaf4", "#0f1419"},
mdKeyword: [2]string{"#4aa8c8", "#3fb7e3"}, mdString: [2]string{"#5fb978", "#78d05c"},
mdComment: [2]string{"#abb0b6", "#5c6773"},
}),
"material": makeTheme(presetColors{
primary: [2]string{"#6182b8", "#82aaff"}, secondary: [2]string{"#39adb5", "#89ddff"},
success: [2]string{"#91b859", "#c3e88d"}, warning: [2]string{"#ffb300", "#ffcb6b"},
error_: [2]string{"#e53935", "#f07178"}, info: [2]string{"#f4511e", "#ffcb6b"},
text: [2]string{"#263238", "#eeffff"}, background: [2]string{"#fafafa", "#263238"},
mdKeyword: [2]string{"#6182b8", "#82aaff"}, mdString: [2]string{"#91b859", "#c3e88d"},
mdComment: [2]string{"#aabfc5", "#546e7a"},
}),
"everforest": makeTheme(presetColors{
primary: [2]string{"#8da101", "#a7c080"}, secondary: [2]string{"#df69ba", "#d699b6"},
success: [2]string{"#8da101", "#a7c080"}, warning: [2]string{"#f57d26", "#e69875"},
error_: [2]string{"#f85552", "#e67e80"}, info: [2]string{"#35a77c", "#83c092"},
text: [2]string{"#5c6a72", "#d3c6aa"}, background: [2]string{"#fdf6e3", "#2d353b"},
mdKeyword: [2]string{"#8da101", "#a7c080"}, mdString: [2]string{"#35a77c", "#83c092"},
mdComment: [2]string{"#939b84", "#859289"},
}),
"kanagawa": makeTheme(presetColors{
primary: [2]string{"#2D4F67", "#7E9CD8"}, secondary: [2]string{"#D27E99", "#D27E99"},
success: [2]string{"#98BB6C", "#98BB6C"}, warning: [2]string{"#D7A657", "#D7A657"},
error_: [2]string{"#E82424", "#E82424"}, info: [2]string{"#76946A", "#76946A"},
text: [2]string{"#54433A", "#DCD7BA"}, background: [2]string{"#F2E9DE", "#1F1F28"},
mdKeyword: [2]string{"#2D4F67", "#7E9CD8"}, mdString: [2]string{"#98BB6C", "#98BB6C"},
mdComment: [2]string{"#A09D98", "#727169"},
}),
"amoled": makeTheme(presetColors{
primary: [2]string{"#6200ff", "#b388ff"}, secondary: [2]string{"#ff0080", "#ff4081"},
success: [2]string{"#00e676", "#00ff88"}, warning: [2]string{"#ffab00", "#ffea00"},
error_: [2]string{"#ff1744", "#ff1744"}, info: [2]string{"#00b0ff", "#18ffff"},
text: [2]string{"#0a0a0a", "#ffffff"}, background: [2]string{"#f0f0f0", "#000000"},
mdKeyword: [2]string{"#6200ff", "#b388ff"}, mdString: [2]string{"#00e676", "#00ff88"},
mdComment: [2]string{"#757575", "#424242"},
}),
"synthwave": makeTheme(presetColors{
primary: [2]string{"#00bcd4", "#36f9f6"}, secondary: [2]string{"#9c27b0", "#b084eb"},
success: [2]string{"#4caf50", "#72f1b8"}, warning: [2]string{"#ff9800", "#fede5d"},
error_: [2]string{"#f44336", "#fe4450"}, info: [2]string{"#ff5722", "#ff8b39"},
text: [2]string{"#262335", "#ffffff"}, background: [2]string{"#fafafa", "#262335"},
mdKeyword: [2]string{"#9c27b0", "#b084eb"}, mdString: [2]string{"#4caf50", "#72f1b8"},
mdComment: [2]string{"#848bbd", "#848bbd"},
}),
"vesper": makeTheme(presetColors{
primary: [2]string{"#FFC799", "#FFC799"}, secondary: [2]string{"#B30000", "#FF8080"},
success: [2]string{"#99FFE4", "#99FFE4"}, warning: [2]string{"#FFC799", "#FFC799"},
error_: [2]string{"#FF8080", "#FF8080"}, info: [2]string{"#FFC799", "#FFC799"},
text: [2]string{"#1a1a1a", "#FFF"}, background: [2]string{"#F0F0F0", "#101010"},
mdKeyword: [2]string{"#FFC799", "#FFC799"}, mdString: [2]string{"#99FFE4", "#99FFE4"},
mdComment: [2]string{"#7a7a7a", "#505050"},
}),
"flexoki": makeTheme(presetColors{
primary: [2]string{"#205EA6", "#DA702C"}, secondary: [2]string{"#BC5215", "#8B7EC8"},
success: [2]string{"#66800B", "#879A39"}, warning: [2]string{"#BC5215", "#DA702C"},
error_: [2]string{"#AF3029", "#D14D41"}, info: [2]string{"#24837B", "#3AA99F"},
text: [2]string{"#100F0F", "#CECDC3"}, background: [2]string{"#FFFCF0", "#100F0F"},
mdKeyword: [2]string{"#205EA6", "#DA702C"}, mdString: [2]string{"#66800B", "#879A39"},
mdComment: [2]string{"#878580", "#878580"},
}),
"matrix": makeTheme(presetColors{
primary: [2]string{"#1cc24b", "#2eff6a"}, secondary: [2]string{"#c770ff", "#c770ff"},
success: [2]string{"#1cc24b", "#62ff94"}, warning: [2]string{"#e6ff57", "#e6ff57"},
error_: [2]string{"#ff4b4b", "#ff4b4b"}, info: [2]string{"#30b3ff", "#30b3ff"},
text: [2]string{"#203022", "#62ff94"}, background: [2]string{"#eef3ea", "#0a0e0a"},
mdKeyword: [2]string{"#1cc24b", "#2eff6a"}, mdString: [2]string{"#1cc24b", "#62ff94"},
mdComment: [2]string{"#5a7a5e", "#3a5a3e"},
}),
"vercel": makeTheme(presetColors{
primary: [2]string{"#0070F3", "#0070F3"}, secondary: [2]string{"#8E4EC6", "#8E4EC6"},
success: [2]string{"#388E3C", "#46A758"}, warning: [2]string{"#FF9500", "#FFB224"},
error_: [2]string{"#DC3545", "#E5484D"}, info: [2]string{"#0070F3", "#52A8FF"},
text: [2]string{"#171717", "#EDEDED"}, background: [2]string{"#FFFFFF", "#000000"},
mdKeyword: [2]string{"#0070F3", "#0070F3"}, mdString: [2]string{"#388E3C", "#46A758"},
mdComment: [2]string{"#6B6B6B", "#666666"},
}),
"zenburn": makeTheme(presetColors{
primary: [2]string{"#5f7f8f", "#8cd0d3"}, secondary: [2]string{"#5f8f8f", "#93e0e3"},
success: [2]string{"#5f8f5f", "#7f9f7f"}, warning: [2]string{"#8f8f5f", "#f0dfaf"},
error_: [2]string{"#8f5f5f", "#cc9393"}, info: [2]string{"#8f7f5f", "#dfaf8f"},
text: [2]string{"#3f3f3f", "#dcdccc"}, background: [2]string{"#ffffef", "#3f3f3f"},
mdKeyword: [2]string{"#5f7f8f", "#8cd0d3"}, mdString: [2]string{"#5f8f5f", "#cc9393"},
mdComment: [2]string{"#7f7f7f", "#7f9f7f"},
}),
}
}
// ---------------------------------------------------------------------------
// Theme registry (global)
// ---------------------------------------------------------------------------
var themeRegistry []ThemeEntry
// initThemeRegistry populates the registry from built-ins, user themes, and
// project-local themes. Later sources override earlier ones with the same name:
// 1. Built-in presets
// 2. User themes (~/.config/kit/themes/)
// 3. Project-local (.kit/themes/ in the working directory)
func initThemeRegistry() {
themeRegistry = nil
// 1. Built-in presets.
for name, t := range builtinThemes() {
themeRegistry = append(themeRegistry, ThemeEntry{
Name: name,
Source: "builtin",
theme: t,
loaded: true,
})
}
// 2. User themes from ~/.config/kit/themes/
scanThemesDir(userThemesDir())
// 3. Project-local themes from .kit/themes/
scanThemesDir(projectThemesDir())
sortRegistry()
}
// scanThemesDir adds all .yml/.yaml/.json theme files from dir to the registry.
// Files override any existing entry with the same stem name.
func scanThemesDir(dir string) {
if dir == "" {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext != ".yml" && ext != ".yaml" && ext != ".json" {
continue
}
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
removeFromRegistry(name)
themeRegistry = append(themeRegistry, ThemeEntry{
Name: name,
Source: filepath.Join(dir, entry.Name()),
})
}
}
func sortRegistry() {
sort.Slice(themeRegistry, func(i, j int) bool {
return themeRegistry[i].Name < themeRegistry[j].Name
})
}
func removeFromRegistry(name string) {
for i := range themeRegistry {
if themeRegistry[i].Name == name {
themeRegistry = append(themeRegistry[:i], themeRegistry[i+1:]...)
return
}
}
}
// userThemesDir returns ~/.config/kit/themes, creating it if needed.
func userThemesDir() string {
cfgDir, err := os.UserConfigDir()
if err != nil {
return ""
}
dir := filepath.Join(cfgDir, "kit", "themes")
_ = os.MkdirAll(dir, 0o755)
return dir
}
// projectThemesDir returns .kit/themes/ relative to the working directory.
// Returns "" if the directory doesn't exist (does NOT create it).
func projectThemesDir() string {
dir := filepath.Join(".kit", "themes")
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return ""
}
abs, err := filepath.Abs(dir)
if err != nil {
return dir
}
return abs
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
// ListThemes returns the names of all available themes (built-in + user).
func ListThemes() []string {
if themeRegistry == nil {
initThemeRegistry()
}
names := make([]string, len(themeRegistry))
for i := range themeRegistry {
names[i] = themeRegistry[i].Name
}
return names
}
// LoadThemeByName looks up a theme by name, loads it if needed, and returns it.
func LoadThemeByName(name string) (Theme, error) {
if themeRegistry == nil {
initThemeRegistry()
}
for i := range themeRegistry {
if themeRegistry[i].Name == name {
return themeRegistry[i].Theme()
}
}
return Theme{}, fmt.Errorf("theme %q not found", name)
}
// ApplyTheme loads a theme by name and sets it as the active global theme.
func ApplyTheme(name string) error {
t, err := LoadThemeByName(name)
if err != nil {
return err
}
SetTheme(t)
return nil
}
// RefreshThemeRegistry re-scans the themes directory. Call after the user
// drops a new file into ~/.config/kit/themes/.
func RefreshThemeRegistry() {
initThemeRegistry()
}
// RegisterThemeFromConfig adds a theme to the runtime registry from an
// extension's ThemeColorConfig (string hex pairs). Replaces any existing
// entry with the same name. The theme is immediately available via
// ListThemes, LoadThemeByName, and ApplyTheme.
func RegisterThemeFromConfig(name string, primary, secondary, success, warning, error_, info, text, muted, veryMuted, background, border, mutedBorder, system, tool, accent, highlight, mdHeading, mdLink, mdKeyword, mdString, mdNumber, mdComment [2]string) {
if themeRegistry == nil {
initThemeRegistry()
}
t := makeTheme(presetColors{
primary: primary, secondary: secondary,
success: success, warning: warning,
error_: error_, info: info,
text: text, muted: muted,
veryMuted: veryMuted, background: background,
border: border, mutedBorder: mutedBorder,
system: system, tool: tool,
accent: accent, highlight: highlight,
mdHeading: mdHeading, mdLink: mdLink,
mdKeyword: mdKeyword, mdString: mdString,
mdNumber: mdNumber, mdComment: mdComment,
})
removeFromRegistry(name)
themeRegistry = append(themeRegistry, ThemeEntry{
Name: name,
Source: "extension",
theme: t,
loaded: true,
})
sortRegistry()
}
// ActiveThemeName returns the name of the currently active theme by comparing
// against known entries. Returns "custom" if no match is found.
func ActiveThemeName() string {
if themeRegistry == nil {
initThemeRegistry()
}
current := GetTheme()
for _, e := range themeRegistry {
if !e.loaded {
continue
}
if e.theme.Primary == current.Primary &&
e.theme.Secondary == current.Secondary &&
e.theme.Error == current.Error &&
e.theme.Text == current.Text {
return e.Name
}
}
return "custom"
}
// ---------------------------------------------------------------------------
// File loading
// ---------------------------------------------------------------------------
// themeFileConfig mirrors config.Theme for unmarshaling theme files.
// Uses the same adaptive color structure.
type themeFileConfig struct {
Primary adaptiveColorPair `json:"primary,omitzero" yaml:"primary,omitempty"`
Secondary adaptiveColorPair `json:"secondary,omitzero" yaml:"secondary,omitempty"`
Success adaptiveColorPair `json:"success,omitzero" yaml:"success,omitempty"`
Warning adaptiveColorPair `json:"warning,omitzero" yaml:"warning,omitempty"`
Error adaptiveColorPair `json:"error,omitzero" yaml:"error,omitempty"`
Info adaptiveColorPair `json:"info,omitzero" yaml:"info,omitempty"`
Text adaptiveColorPair `json:"text,omitzero" yaml:"text,omitempty"`
Muted adaptiveColorPair `json:"muted,omitzero" yaml:"muted,omitempty"`
VeryMuted adaptiveColorPair `json:"very-muted,omitzero" yaml:"very-muted,omitempty"`
Background adaptiveColorPair `json:"background,omitzero" yaml:"background,omitempty"`
Border adaptiveColorPair `json:"border,omitzero" yaml:"border,omitempty"`
MutedBorder adaptiveColorPair `json:"muted-border,omitzero" yaml:"muted-border,omitempty"`
System adaptiveColorPair `json:"system,omitzero" yaml:"system,omitempty"`
Tool adaptiveColorPair `json:"tool,omitzero" yaml:"tool,omitempty"`
Accent adaptiveColorPair `json:"accent,omitzero" yaml:"accent,omitempty"`
Highlight adaptiveColorPair `json:"highlight,omitzero" yaml:"highlight,omitempty"`
DiffInsertBg adaptiveColorPair `json:"diff-insert-bg,omitzero" yaml:"diff-insert-bg,omitempty"`
DiffDeleteBg adaptiveColorPair `json:"diff-delete-bg,omitzero" yaml:"diff-delete-bg,omitempty"`
DiffEqualBg adaptiveColorPair `json:"diff-equal-bg,omitzero" yaml:"diff-equal-bg,omitempty"`
DiffMissingBg adaptiveColorPair `json:"diff-missing-bg,omitzero" yaml:"diff-missing-bg,omitempty"`
CodeBg adaptiveColorPair `json:"code-bg,omitzero" yaml:"code-bg,omitempty"`
GutterBg adaptiveColorPair `json:"gutter-bg,omitzero" yaml:"gutter-bg,omitempty"`
WriteBg adaptiveColorPair `json:"write-bg,omitzero" yaml:"write-bg,omitempty"`
Markdown struct {
Text adaptiveColorPair `json:"text,omitzero" yaml:"text,omitempty"`
Muted adaptiveColorPair `json:"muted,omitzero" yaml:"muted,omitempty"`
Heading adaptiveColorPair `json:"heading,omitzero" yaml:"heading,omitempty"`
Emph adaptiveColorPair `json:"emph,omitzero" yaml:"emph,omitempty"`
Strong adaptiveColorPair `json:"strong,omitzero" yaml:"strong,omitempty"`
Link adaptiveColorPair `json:"link,omitzero" yaml:"link,omitempty"`
Code adaptiveColorPair `json:"code,omitzero" yaml:"code,omitempty"`
Error adaptiveColorPair `json:"error,omitzero" yaml:"error,omitempty"`
Keyword adaptiveColorPair `json:"keyword,omitzero" yaml:"keyword,omitempty"`
String adaptiveColorPair `json:"string,omitzero" yaml:"string,omitempty"`
Number adaptiveColorPair `json:"number,omitzero" yaml:"number,omitempty"`
Comment adaptiveColorPair `json:"comment,omitzero" yaml:"comment,omitempty"`
} `json:"markdown,omitzero" yaml:"markdown,omitempty"`
}
type adaptiveColorPair struct {
Light string `json:"light,omitempty" yaml:"light,omitempty"`
Dark string `json:"dark,omitempty" yaml:"dark,omitempty"`
}
// resolve converts an adaptiveColorPair to a resolved color.Color,
// falling back to fallback when both Light and Dark are empty.
func (a adaptiveColorPair) resolve(fallback color.Color) color.Color {
if a.Light == "" && a.Dark == "" {
return fallback
}
return AdaptiveColor(a.Light, a.Dark)
}
func loadThemeFile(path string) (Theme, error) {
data, err := os.ReadFile(path)
if err != nil {
return Theme{}, err
}
var cfg themeFileConfig
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".json":
err = json.Unmarshal(data, &cfg)
case ".yaml", ".yml":
err = yaml.Unmarshal(data, &cfg)
default:
return Theme{}, fmt.Errorf("unsupported theme file format: %s", ext)
}
if err != nil {
return Theme{}, err
}
return fileConfigToTheme(cfg), nil
}
func fileConfigToTheme(cfg themeFileConfig) Theme {
def := DefaultTheme()
return Theme{
Primary: cfg.Primary.resolve(def.Primary),
Secondary: cfg.Secondary.resolve(def.Secondary),
Success: cfg.Success.resolve(def.Success),
Warning: cfg.Warning.resolve(def.Warning),
Error: cfg.Error.resolve(def.Error),
Info: cfg.Info.resolve(def.Info),
Text: cfg.Text.resolve(def.Text),
Muted: cfg.Muted.resolve(def.Muted),
VeryMuted: cfg.VeryMuted.resolve(def.VeryMuted),
Background: cfg.Background.resolve(def.Background),
Border: cfg.Border.resolve(def.Border),
MutedBorder: cfg.MutedBorder.resolve(def.MutedBorder),
System: cfg.System.resolve(def.System),
Tool: cfg.Tool.resolve(def.Tool),
Accent: cfg.Accent.resolve(def.Accent),
Highlight: cfg.Highlight.resolve(def.Highlight),
DiffInsertBg: cfg.DiffInsertBg.resolve(def.DiffInsertBg),
DiffDeleteBg: cfg.DiffDeleteBg.resolve(def.DiffDeleteBg),
DiffEqualBg: cfg.DiffEqualBg.resolve(def.DiffEqualBg),
DiffMissingBg: cfg.DiffMissingBg.resolve(def.DiffMissingBg),
CodeBg: cfg.CodeBg.resolve(def.CodeBg),
GutterBg: cfg.GutterBg.resolve(def.GutterBg),
WriteBg: cfg.WriteBg.resolve(def.WriteBg),
Markdown: MarkdownThemeColors{
Text: cfg.Markdown.Text.resolve(def.Markdown.Text),
Muted: cfg.Markdown.Muted.resolve(def.Markdown.Muted),
Heading: cfg.Markdown.Heading.resolve(def.Markdown.Heading),
Emph: cfg.Markdown.Emph.resolve(def.Markdown.Emph),
Strong: cfg.Markdown.Strong.resolve(def.Markdown.Strong),
Link: cfg.Markdown.Link.resolve(def.Markdown.Link),
Code: cfg.Markdown.Code.resolve(def.Markdown.Code),
Error: cfg.Markdown.Error.resolve(def.Markdown.Error),
Keyword: cfg.Markdown.Keyword.resolve(def.Markdown.Keyword),
String: cfg.Markdown.String.resolve(def.Markdown.String),
Number: cfg.Markdown.Number.resolve(def.Markdown.Number),
Comment: cfg.Markdown.Comment.resolve(def.Markdown.Comment),
},
}
}
+10 -7
View File
@@ -28,11 +28,12 @@ func NewToolApprovalInput(toolName, toolArgs string, width int) *ToolApprovalInp
ta.SetHeight(4) // Default to 3 lines like huh
ta.Focus()
// Style the textarea to match huh theme
// Style the textarea using theme colors.
theme := GetTheme()
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
styles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
styles.Focused.Prompt = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
@@ -87,9 +88,11 @@ func (t *ToolApprovalInput) View() tea.View {
}
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
Foreground(theme.Text).
MarginBottom(1).
PaddingLeft(3)
@@ -100,19 +103,19 @@ func (t *ToolApprovalInput) View() tea.View {
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(lipgloss.Color("39")).
BorderForeground(theme.Primary).
PaddingLeft(2). // match message block paddingLeft
Width(t.width - 1) // full width minus left border
// Style for the currently selected/highlighted option
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("42")). // Bright green
Foreground(theme.Success).
Bold(true).
Underline(true)
// Style for the unselected/unhighlighted option
unselectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")) // Dark gray
Foreground(theme.VeryMuted)
// Build the view
var view strings.Builder
+17 -8
View File
@@ -217,7 +217,14 @@ func (ts *TreeSelectorComponent) View() tea.View {
// Header.
b.WriteString(headerStyle.Render("Session Tree"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter"))
// Adapt help text to terminal width.
if ts.width >= 70 {
b.WriteString(helpStyle.Render("↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter"))
} else if ts.width >= 45 {
b.WriteString(helpStyle.Render("↑↓ move ↵ select esc cancel ^O filter"))
} else {
b.WriteString(helpStyle.Render("↑↓ ↵ esc ^O"))
}
b.WriteString("\n")
if ts.search != "" {
@@ -269,9 +276,10 @@ func (ts *TreeSelectorComponent) IsActive() bool {
// --- Internal helpers ---
func (ts *TreeSelectorComponent) visibleHeight() int {
// Reserve lines for header(3) + search(1) + separator(1) + footer(2).
h := max(ts.height/2-7, 5)
return h
// Chrome: header(1) + help(1) + separator(1) + entries + separator(1) + footer(1) = 5 fixed.
// Optional search line adds 1 more. Use 7 as a safe estimate.
const chromeLines = 7
return max(ts.height-chromeLines, 3)
}
func (ts *TreeSelectorComponent) rebuildFlatList() {
@@ -389,7 +397,7 @@ func (ts *TreeSelectorComponent) passesFilter(node *session.TreeNode) bool {
func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool) string {
theme := GetTheme()
maxWidth := ts.width - 4
maxWidth := max(ts.width-4, 10)
// Cursor indicator.
var cursor string
@@ -401,9 +409,10 @@ func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool
// Role-colored content.
text := ts.entryDisplayText(node.Entry)
if len(text) > maxWidth-len(node.Prefix)-10 {
trimLen := maxWidth - len(node.Prefix) - 13
if trimLen > 0 && trimLen < len(text) {
available := maxWidth - len(node.Prefix) - 10
if available > 3 && len(text) > available {
trimLen := max(available-3, 1)
if trimLen < len(text) {
text = text[:trimLen] + "..."
}
}
+371
View File
@@ -0,0 +1,371 @@
# Testing Kit Extensions
The `github.com/mark3labs/kit/pkg/extensions/test` package provides utilities for testing Kit extensions using standard Go testing patterns.
## Overview
Extension tests run outside the Yaegi interpreter but load your extension code into an isolated interpreter instance. This allows you to:
- Test event handlers without running the full Kit TUI
- Verify that your extension registers tools/commands correctly
- Assert that context methods (Print, SetWidget, etc.) are called as expected
- Test blocking and non-blocking event handling
## Installation
The test package is part of the Kit codebase. Import it in your extension tests:
```go
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
```
## Basic Usage
### Testing an Extension File
```go
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
func TestMyExtension(t *testing.T) {
// Create a test harness
harness := test.New(t)
// Load your extension
harness.LoadFile("my-ext.go")
// Emit events and verify behavior
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the extension printed something
test.AssertPrinted(t, harness, "session started")
}
```
### Testing Inline Extension Code
For quick tests, you can load extension source directly:
```go
func TestToolBlocking(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "dangerous" {
return &ext.ToolCallResult{Block: true, Reason: "not allowed"}
}
return nil
})
}
`
harness := test.New(t)
harness.LoadString(src, "test-ext.go")
// Test the tool is blocked
result, _ := harness.Emit(extensions.ToolCallEvent{
ToolName: "dangerous",
Input: "{}",
})
test.AssertBlocked(t, result, "not allowed")
}
```
## Common Testing Patterns
### Testing Tool Registration
```go
func TestToolRegistration(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Verify the tool was registered
test.AssertToolRegistered(t, harness, "my_tool")
// Or inspect tools directly
tools := harness.RegisteredTools()
for _, tool := range tools {
if tool.Name == "my_tool" {
t.Logf("Tool description: %s", tool.Description)
}
}
}
```
### Testing Command Registration
```go
func TestCommandRegistration(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
test.AssertCommandRegistered(t, harness, "mycommand")
}
```
### Testing Widgets
```go
func TestWidgetBehavior(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Trigger the event that creates the widget
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
// Verify the widget was set
test.AssertWidgetSet(t, harness, "my-widget")
// Verify specific widget content
test.AssertWidgetText(t, harness, "my-widget", "Expected Text")
// Or verify partial content
test.AssertWidgetTextContains(t, harness, "my-widget", "partial")
}
```
### Testing Input Handling
```go
func TestInputHandling(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test that the extension handles certain input
result, _ := harness.Emit(extensions.InputEvent{
Text: "secret password",
Source: "cli",
})
test.AssertInputHandled(t, result, "handled")
}
```
### Testing Print Functions
```go
func TestPrintOutput(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
_, _ = harness.Emit(extensions.ToolCallEvent{
ToolName: "test",
Input: "{}",
})
// Assert exact match
test.AssertPrinted(t, harness, "exact output")
// Or partial match
test.AssertPrintedContains(t, harness, "partial")
// Assert info/error messages
test.AssertPrintInfo(t, harness, "info message")
test.AssertPrintError(t, harness, "error message")
}
```
### Testing Status Bar
```go
func TestStatusBar(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
_, _ = harness.Emit(extensions.AgentEndEvent{})
test.AssertStatusSet(t, harness, "myext:status")
test.AssertStatusText(t, harness, "myext:status", "Ready")
}
```
### Testing Prompt Results
Configure the mock context to return specific prompt results:
```go
func TestWithPrompts(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Configure prompt results before emitting events
harness.Context().SetPromptSelectResult(extensions.PromptSelectResult{
Value: "option1",
Index: 0,
Cancelled: false,
})
// Now when your extension calls ctx.PromptSelect(), it will get this result
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
}
```
## Available Assertions
The test package provides these assertion helpers:
**Event Results:**
- `AssertNotBlocked(t, result)` - Verify tool was not blocked
- `AssertBlocked(t, result, reason)` - Verify tool was blocked with reason
- `AssertInputHandled(t, result, action)` - Verify input was handled
- `AssertInputTransformed(t, result, text)` - Verify input transformation
**Context Interactions:**
- `AssertPrinted(t, harness, text)` - Verify exact print output
- `AssertPrintedContains(t, harness, substring)` - Verify partial print output
- `AssertPrintInfo(t, harness, text)` - Verify PrintInfo was called
- `AssertPrintError(t, harness, text)` - Verify PrintError was called
- `AssertWidgetSet(t, harness, id)` - Verify widget was set
- `AssertWidgetNotSet(t, harness, id)` - Verify widget was not set
- `AssertWidgetText(t, harness, id, text)` - Verify widget content
- `AssertWidgetTextContains(t, harness, id, substring)` - Verify widget contains text
- `AssertHeaderSet(t, harness)` - Verify header was set
- `AssertFooterSet(t, harness)` - Verify footer was set
- `AssertStatusSet(t, harness, key)` - Verify status was set
- `AssertStatusText(t, harness, key, text)` - Verify status text
**Registration:**
- `AssertToolRegistered(t, harness, name)` - Verify tool registration
- `AssertCommandRegistered(t, harness, name)` - Verify command registration
- `AssertHasHandlers(t, harness, eventType)` - Verify handlers exist
- `AssertNoHandlers(t, harness, eventType)` - Verify no handlers
**Messaging:**
- `AssertMessageSent(t, harness, text)` - Verify SendMessage was called
- `AssertCancelAndSend(t, harness, text)` - Verify CancelAndSend was called
## Advanced Usage
### Accessing the Mock Context
For custom assertions, access the mock context directly:
```go
func TestCustomAssertion(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
// Get all recorded prints
prints := harness.Context().GetPrints()
// Check widget directly
widget, ok := harness.Context().GetWidget("my-widget")
if ok && widget.Style.BorderColor == "#ff0000" {
t.Log("Widget has red border")
}
// Check options
optionValue := harness.Context().GetOption("my-option")
}
```
### Testing Multiple Extensions
Each harness is isolated:
```go
func TestExtensionIsolation(t *testing.T) {
// These run in completely separate interpreters
harness1 := test.New(t)
harness1.LoadFile("ext1.go")
harness2 := test.New(t)
harness2.LoadFile("ext2.go")
// Events to one don't affect the other
}
```
### Direct Result Extraction
When you need to inspect result details:
```go
result, _ := harness.Emit(extensions.ToolCallEvent{...})
tcr := test.GetToolCallResult(result)
if tcr != nil {
t.Logf("Block: %v, Reason: %s", tcr.Block, tcr.Reason)
}
```
## Best Practices
1. **Test one behavior per test** - Keep tests focused and readable
2. **Use inline source for simple tests** - LoadString is great for isolated tests
3. **Use LoadFile for integration tests** - Tests the actual extension file
4. **Assert on context calls** - Verify your extension interacts with the context correctly
5. **Test both positive and negative cases** - Verify tools are blocked AND allowed appropriately
6. **Test all event handlers** - Make sure all registered handlers work correctly
## Limitations
The test harness has these limitations:
1. **No TUI rendering** - Widgets are recorded but not rendered visually
2. **Prompts return configured values** - You must pre-configure prompt results in tests
3. **Subagents don't spawn real processes** - SpawnSubagent returns nil/empty results
4. **LLM completions are mocked** - Complete returns empty responses
5. **Some context methods are no-ops** - Exit, SetActiveTools, etc. don't have side effects
These limitations are intentional - the test harness focuses on testing extension logic, not the full Kit runtime.
## Example: Complete Extension Test
Here's a complete example testing a realistic extension:
```go
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
// Test that the extension properly blocks dangerous tools
func TestSafetyExtension_BlocksDangerousTools(t *testing.T) {
harness := test.New(t)
harness.LoadFile("safety-ext.go")
// Verify it handles tool calls
test.AssertHasHandlers(t, harness, extensions.ToolCall)
// Test allowed tool
result, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "read", Input: "{}"})
test.AssertNotBlocked(t, result)
// Test blocked tool
result, _ = harness.Emit(extensions.ToolCallEvent{ToolName: "rm", Input: "{}"})
test.AssertBlocked(t, result, "safety block")
test.AssertPrintError(t, harness, "Tool rm is blocked")
}
// Test that the extension shows status on agent completion
func TestSafetyExtension_ShowsStatus(t *testing.T) {
harness := test.New(t)
harness.LoadFile("safety-ext.go")
_, _ = harness.Emit(extensions.AgentEndEvent{})
test.AssertWidgetSet(t, harness, "safety-widget")
test.AssertWidgetTextContains(t, harness, "safety-widget", "Safe")
}
```
+297
View File
@@ -0,0 +1,297 @@
package test
import (
"slices"
"strings"
"testing"
"github.com/mark3labs/kit/internal/extensions"
)
// AssertNotBlocked fails the test if the tool call result indicates the tool was blocked.
func AssertNotBlocked(t *testing.T, result extensions.Result) {
t.Helper()
if result == nil {
return
}
if tcr, ok := result.(extensions.ToolCallResult); ok {
if tcr.Block {
t.Errorf("expected tool to not be blocked, but it was blocked with reason: %q", tcr.Reason)
}
}
}
// AssertBlocked fails the test if the tool call result does not indicate the tool was blocked.
func AssertBlocked(t *testing.T, result extensions.Result, expectedReason string) {
t.Helper()
if result == nil {
t.Error("expected tool to be blocked, but result was nil")
return
}
tcr, ok := result.(extensions.ToolCallResult)
if !ok {
t.Errorf("expected ToolCallResult, got %T", result)
return
}
if !tcr.Block {
t.Error("expected tool to be blocked, but it was not blocked")
return
}
if expectedReason != "" && tcr.Reason != expectedReason {
t.Errorf("expected block reason %q, got %q", expectedReason, tcr.Reason)
}
}
// AssertInputHandled fails the test if the input result does not indicate the input was handled.
func AssertInputHandled(t *testing.T, result extensions.Result, expectedAction string) {
t.Helper()
if result == nil {
t.Error("expected input to be handled, but result was nil")
return
}
ir, ok := result.(extensions.InputResult)
if !ok {
t.Errorf("expected InputResult, got %T", result)
return
}
if ir.Action != expectedAction {
t.Errorf("expected action %q, got %q", expectedAction, ir.Action)
}
}
// AssertInputTransformed fails the test if the input was not transformed to the expected text.
func AssertInputTransformed(t *testing.T, result extensions.Result, expectedText string) {
t.Helper()
if result == nil {
t.Errorf("expected input to be transformed to %q, but result was nil", expectedText)
return
}
ir, ok := result.(extensions.InputResult)
if !ok {
t.Errorf("expected InputResult, got %T", result)
return
}
if ir.Action != "transform" {
t.Errorf("expected action 'transform', got %q", ir.Action)
}
if ir.Text != expectedText {
t.Errorf("expected transformed text %q, got %q", expectedText, ir.Text)
}
}
// AssertPrinted fails the test if the expected text was not printed.
func AssertPrinted(t *testing.T, harness *Harness, expected string) {
t.Helper()
prints := harness.Context().GetPrints()
if slices.Contains(prints, expected) {
return
}
t.Errorf("expected text %q to be printed, but it was not found in prints: %v", expected, prints)
}
// AssertPrintedContains fails the test if no printed text contains the expected substring.
func AssertPrintedContains(t *testing.T, harness *Harness, substring string) {
t.Helper()
prints := harness.Context().GetPrints()
for _, p := range prints {
if strings.Contains(p, substring) {
return
}
}
t.Errorf("expected printed text to contain %q, but it was not found in prints: %v", substring, prints)
}
// AssertPrintInfo fails the test if the expected info message was not printed.
func AssertPrintInfo(t *testing.T, harness *Harness, expected string) {
t.Helper()
infos := harness.Context().GetPrintInfos()
if slices.Contains(infos, expected) {
return
}
t.Errorf("expected info message %q, but it was not found in PrintInfos: %v", expected, infos)
}
// AssertPrintError fails the test if the expected error message was not printed.
func AssertPrintError(t *testing.T, harness *Harness, expected string) {
t.Helper()
errors := harness.Context().GetPrintErrors()
if slices.Contains(errors, expected) {
return
}
t.Errorf("expected error message %q, but it was not found in PrintErrors: %v", expected, errors)
}
// AssertWidgetSet fails the test if the widget with the given ID was not set.
func AssertWidgetSet(t *testing.T, harness *Harness, id string) {
t.Helper()
if !harness.Context().HasWidget(id) {
t.Errorf("expected widget %q to be set, but it was not", id)
}
}
// AssertWidgetNotSet fails the test if the widget with the given ID was set.
func AssertWidgetNotSet(t *testing.T, harness *Harness, id string) {
t.Helper()
if harness.Context().HasWidget(id) {
t.Errorf("expected widget %q to not be set, but it was", id)
}
}
// AssertWidgetText fails the test if the widget with the given ID does not have the expected text.
func AssertWidgetText(t *testing.T, harness *Harness, id string, expected string) {
t.Helper()
widget, ok := harness.Context().GetWidget(id)
if !ok {
t.Errorf("expected widget %q to be set, but it was not", id)
return
}
if widget.Content.Text != expected {
t.Errorf("expected widget %q to have text %q, got %q", id, expected, widget.Content.Text)
}
}
// AssertWidgetTextContains fails the test if the widget text does not contain the expected substring.
func AssertWidgetTextContains(t *testing.T, harness *Harness, id string, substring string) {
t.Helper()
widget, ok := harness.Context().GetWidget(id)
if !ok {
t.Errorf("expected widget %q to be set, but it was not", id)
return
}
if !strings.Contains(widget.Content.Text, substring) {
t.Errorf("expected widget %q text to contain %q, but got %q", id, substring, widget.Content.Text)
}
}
// AssertHeaderSet fails the test if no header was set.
func AssertHeaderSet(t *testing.T, harness *Harness) {
t.Helper()
if harness.Context().GetHeader() == nil {
t.Error("expected header to be set, but it was not")
}
}
// AssertFooterSet fails the test if no footer was set.
func AssertFooterSet(t *testing.T, harness *Harness) {
t.Helper()
if harness.Context().GetFooter() == nil {
t.Error("expected footer to be set, but it was not")
}
}
// AssertStatusSet fails the test if the status with the given key was not set.
func AssertStatusSet(t *testing.T, harness *Harness, key string) {
t.Helper()
_, ok := harness.Context().GetStatus(key)
if !ok {
t.Errorf("expected status %q to be set, but it was not", key)
}
}
// AssertStatusText fails the test if the status with the given key does not have the expected text.
func AssertStatusText(t *testing.T, harness *Harness, key string, expected string) {
t.Helper()
status, ok := harness.Context().GetStatus(key)
if !ok {
t.Errorf("expected status %q to be set, but it was not", key)
return
}
if status.Text != expected {
t.Errorf("expected status %q to have text %q, got %q", key, expected, status.Text)
}
}
// AssertHasHandlers fails the test if no handlers are registered for the given event type.
func AssertHasHandlers(t *testing.T, harness *Harness, eventType extensions.EventType) {
t.Helper()
if !harness.HasHandlers(eventType) {
t.Errorf("expected handlers for event type %q, but none were registered", eventType)
}
}
// AssertNoHandlers fails the test if any handlers are registered for the given event type.
func AssertNoHandlers(t *testing.T, harness *Harness, eventType extensions.EventType) {
t.Helper()
if harness.HasHandlers(eventType) {
t.Errorf("expected no handlers for event type %q, but some were registered", eventType)
}
}
// AssertToolRegistered fails the test if the tool with the given name was not registered.
func AssertToolRegistered(t *testing.T, harness *Harness, toolName string) {
t.Helper()
tools := harness.RegisteredTools()
for _, tool := range tools {
if tool.Name == toolName {
return
}
}
t.Errorf("expected tool %q to be registered, but it was not found in %v", toolName, tools)
}
// AssertCommandRegistered fails the test if the command with the given name was not registered.
func AssertCommandRegistered(t *testing.T, harness *Harness, cmdName string) {
t.Helper()
cmds := harness.RegisteredCommands()
for _, cmd := range cmds {
if cmd.Name == cmdName {
return
}
}
t.Errorf("expected command %q to be registered, but it was not found in %v", cmdName, cmds)
}
// AssertMessageSent fails the test if the expected message was not sent.
func AssertMessageSent(t *testing.T, harness *Harness, expected string) {
t.Helper()
ctx := harness.Context()
if slices.Contains(ctx.Messages, expected) {
return
}
t.Errorf("expected message %q to be sent, but it was not found in messages: %v", expected, ctx.Messages)
}
// AssertCancelAndSend fails the test if the expected text was not sent via CancelAndSend.
func AssertCancelAndSend(t *testing.T, harness *Harness, expected string) {
t.Helper()
ctx := harness.Context()
if slices.Contains(ctx.CancelSends, expected) {
return
}
t.Errorf("expected CancelAndSend with %q, but it was not found: %v", expected, ctx.CancelSends)
}
// Helper functions
// GetToolCallResult extracts a ToolCallResult from a Result, or nil if not applicable.
func GetToolCallResult(result extensions.Result) *extensions.ToolCallResult {
if result == nil {
return nil
}
if tcr, ok := result.(extensions.ToolCallResult); ok {
return &tcr
}
return nil
}
// GetInputResult extracts an InputResult from a Result, or nil if not applicable.
func GetInputResult(result extensions.Result) *extensions.InputResult {
if result == nil {
return nil
}
if ir, ok := result.(extensions.InputResult); ok {
return &ir
}
return nil
}
// GetToolResultResult extracts a ToolResultResult from a Result, or nil if not applicable.
func GetToolResultResult(result extensions.Result) *extensions.ToolResultResult {
if result == nil {
return nil
}
if trr, ok := result.(extensions.ToolResultResult); ok {
return &trr
}
return nil
}
+232
View File
@@ -0,0 +1,232 @@
// Package test provides utilities for testing Kit extensions.
//
// This package allows extension authors to write standard Go tests that load
// and exercise their extensions in a controlled environment. Extensions are
// loaded into a Yaegi interpreter with all Kit API symbols available.
//
// Basic usage:
//
// package main
//
// import (
// "testing"
// "github.com/mark3labs/kit/pkg/extensions/test"
// )
//
// func TestMyExtension(t *testing.T) {
// // Create a test harness
// harness := test.New(t)
//
// // Load your extension file
// ext := harness.LoadFile("my-ext.go")
//
// // Emit events and check results
// result := harness.Emit(test.ToolCallEvent{
// ToolName: "my_tool",
// Input: `{"key": "value"}`,
// })
//
// // Use assertion helpers
// test.AssertNotBlocked(t, result)
// test.AssertPrinted(t, harness, "expected output")
// }
//
// The harness provides a mock Context that records all interactions,
// allowing you to verify that your extension called SetWidget, Print, etc.
package test
import (
"os"
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
"github.com/traefik/yaegi/stdlib/unrestricted"
)
// Harness provides a testing environment for Kit extensions.
// It loads extensions into an isolated Yaegi interpreter and provides
// methods to emit events and verify extension behavior.
type Harness struct {
t *testing.T
runner *extensions.Runner
context *MockContext
extPath string
}
// New creates a new test harness for the given test.
// The harness must be used within a single test function.
func New(t *testing.T) *Harness {
return &Harness{
t: t,
context: NewMockContext(),
}
}
// LoadFile loads an extension from a file path.
// The extension is evaluated in a fresh Yaegi interpreter with all
// Kit API symbols available. The Init function is called automatically.
//
// Returns the loaded extension or fails the test on error.
func (h *Harness) LoadFile(path string) *extensions.LoadedExtension {
h.t.Helper()
// Verify file exists
if _, err := os.Stat(path); err != nil {
h.t.Fatalf("extension file not found: %s: %v", path, err)
}
// Read extension source
src, err := os.ReadFile(path)
if err != nil {
h.t.Fatalf("failed to read extension file: %v", err)
}
return h.loadSource(string(src), path)
}
// LoadString loads an extension from a source string.
// Useful for inline extension tests. The path is used for error reporting.
func (h *Harness) LoadString(src string, path string) *extensions.LoadedExtension {
h.t.Helper()
return h.loadSource(src, path)
}
// loadSource is the internal implementation that loads extension source
// into a Yaegi interpreter.
func (h *Harness) loadSource(src string, path string) *extensions.LoadedExtension {
h.t.Helper()
// Create a fresh interpreter
i := interp.New(interp.Options{})
// Expose Go stdlib
if err := i.Use(stdlib.Symbols); err != nil {
h.t.Fatalf("failed to load stdlib symbols: %v", err)
}
if err := i.Use(unrestricted.Symbols); err != nil {
h.t.Fatalf("failed to load unrestricted symbols: %v", err)
}
// Expose Kit extension API symbols
if err := i.Use(extensions.Symbols()); err != nil {
h.t.Fatalf("failed to load extension symbols: %v", err)
}
// Evaluate the extension source
if _, err := i.Eval(src); err != nil {
h.t.Fatalf("failed to evaluate extension source: %v", err)
}
// Extract the Init function
initVal, err := i.Eval("Init")
if err != nil {
h.t.Fatalf("extension has no Init function: %v", err)
}
initFn, ok := initVal.Interface().(func(extensions.API))
if !ok {
h.t.Fatalf("Init has wrong signature (want func(ext.API), got %T)", initVal.Interface())
}
// Create the extension struct
ext := &extensions.LoadedExtension{
Path: path,
Handlers: make(map[extensions.EventType][]extensions.HandlerFunc),
}
// Create the API object using the test helper
api := extensions.NewTestAPI(ext)
// Call Init to register handlers
initFn(api)
// Create runner with the loaded extension
h.runner = extensions.NewRunner([]extensions.LoadedExtension{*ext})
h.extPath = path
// Wire the mock context
h.runner.SetContext(h.context.ToContext())
return ext
}
// Emit sends an event to the loaded extension(s) and returns the result.
// Events are dispatched in order and blocking results stop propagation.
func (h *Harness) Emit(event extensions.Event) (extensions.Result, error) {
h.t.Helper()
if h.runner == nil {
h.t.Fatal("no extension loaded, call LoadFile() or LoadString() first")
}
return h.runner.Emit(event)
}
// EmitJSON is a convenience method for emitting a ToolCallEvent with JSON input.
func (h *Harness) EmitJSON(toolName string, input string) (*extensions.ToolCallResult, error) {
h.t.Helper()
result, err := h.Emit(extensions.ToolCallEvent{
ToolName: toolName,
Input: input,
})
if err != nil {
return nil, err
}
if result == nil {
return nil, nil
}
tcr, ok := result.(extensions.ToolCallResult)
if !ok {
h.t.Fatalf("expected ToolCallResult, got %T", result)
}
return &tcr, nil
}
// Context returns the mock context for inspection.
// Use this to verify Print calls, widget settings, etc.
func (h *Harness) Context() *MockContext {
return h.context
}
// Runner returns the underlying runner for advanced use cases.
func (h *Harness) Runner() *extensions.Runner {
return h.runner
}
// HasHandlers reports whether any handlers are registered for the given event type.
func (h *Harness) HasHandlers(eventType extensions.EventType) bool {
if h.runner == nil {
return false
}
return h.runner.HasHandlers(eventType)
}
// RegisteredTools returns all tools registered by the extension.
func (h *Harness) RegisteredTools() []extensions.ToolDef {
if h.runner == nil {
return nil
}
return h.runner.RegisteredTools()
}
// RegisteredCommands returns all commands registered by the extension.
func (h *Harness) RegisteredCommands() []extensions.CommandDef {
if h.runner == nil {
return nil
}
return h.runner.RegisteredCommands()
}
// MustLoad is like LoadFile but fails the test immediately on error.
// It returns the harness for chaining.
func (h *Harness) MustLoad(path string) *Harness {
h.t.Helper()
h.LoadFile(path)
return h
}
+568
View File
@@ -0,0 +1,568 @@
package test
import (
"testing"
"github.com/mark3labs/kit/internal/extensions"
)
// Test harness with a simple extension
func TestHarness_LoadString(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.Print("session started")
})
}
`
harness := New(t)
harness.LoadString(src, "test-ext.go")
// Emit session start event
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the extension printed something
prints := harness.Context().GetPrints()
if len(prints) != 1 || prints[0] != "session started" {
t.Errorf("expected ['session started'], got %v", prints)
}
}
func TestHarness_ToolCallBlocking(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "banned" {
return &ext.ToolCallResult{Block: true, Reason: "tool is banned"}
}
return nil
})
}
`
harness := New(t)
harness.LoadString(src, "blocker.go")
// Test blocked tool
result, err := harness.Emit(extensions.ToolCallEvent{ToolName: "banned", Input: "{}"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
AssertBlocked(t, result, "tool is banned")
// Test allowed tool
result2, err := harness.Emit(extensions.ToolCallEvent{ToolName: "allowed", Input: "{}"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result2 != nil {
t.Errorf("expected nil result for allowed tool, got %v", result2)
}
}
func TestHarness_ToolRegistration(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.RegisterTool(ext.ToolDef{
Name: "my_tool",
Description: "does stuff",
Parameters: "{}",
Execute: func(input string) (string, error) {
return "result: " + input, nil
},
})
}
`
harness := New(t)
harness.LoadString(src, "tool-ext.go")
tools := harness.RegisteredTools()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
if tools[0].Name != "my_tool" {
t.Errorf("expected tool name 'my_tool', got %q", tools[0].Name)
}
AssertToolRegistered(t, harness, "my_tool")
}
func TestHarness_CommandRegistration(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "hello",
Description: "says hello",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.Print("Hello, " + args)
return "greeting sent", nil
},
})
}
`
harness := New(t)
harness.LoadString(src, "cmd-ext.go")
cmds := harness.RegisteredCommands()
if len(cmds) != 1 {
t.Fatalf("expected 1 command, got %d", len(cmds))
}
if cmds[0].Name != "hello" {
t.Errorf("expected command name 'hello', got %q", cmds[0].Name)
}
AssertCommandRegistered(t, harness, "hello")
}
func TestHarness_WidgetSetting(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetWidget(ext.WidgetConfig{
ID: "my-widget",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: "Hello, World!"},
Style: ext.WidgetStyle{BorderColor: "#ff0000"},
})
})
}
`
harness := New(t)
harness.LoadString(src, "widget-ext.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
AssertWidgetSet(t, harness, "my-widget")
AssertWidgetText(t, harness, "my-widget", "Hello, World!")
// Also verify directly
widget, ok := harness.Context().GetWidget("my-widget")
if !ok {
t.Error("expected widget 'my-widget' to exist")
}
if widget.Style.BorderColor != "#ff0000" {
t.Errorf("expected border color '#ff0000', got %q", widget.Style.BorderColor)
}
}
func TestHarness_FooterSetting(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Status: OK"},
Style: ext.WidgetStyle{BorderColor: "#00ff00"},
})
})
}
`
harness := New(t)
harness.LoadString(src, "footer-ext.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
AssertFooterSet(t, harness)
footer := harness.Context().GetFooter()
if footer == nil {
t.Fatal("expected footer to be set")
}
if footer.Content.Text != "Status: OK" {
t.Errorf("expected footer text 'Status: OK', got %q", footer.Content.Text)
}
}
func TestHarness_PrintInfoAndError(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.PrintInfo("Information message")
ctx.PrintError("Error message")
})
}
`
harness := New(t)
harness.LoadString(src, "print-ext.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
AssertPrintInfo(t, harness, "Information message")
AssertPrintError(t, harness, "Error message")
}
func TestHarness_EmitJSON(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "test_tool" {
return &ext.ToolCallResult{Block: true, Reason: "blocked"}
}
return nil
})
}
`
harness := New(t)
harness.LoadString(src, "json-ext.go")
result, err := harness.EmitJSON("test_tool", `{"key": "value"}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if !result.Block {
t.Error("expected Block=true")
}
}
func TestHarness_HasHandlers(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnToolCall(func(_ ext.ToolCallEvent, _ ext.Context) *ext.ToolCallResult {
return nil
})
api.OnSessionStart(func(_ ext.SessionStartEvent, _ ext.Context) {
})
}
`
harness := New(t)
harness.LoadString(src, "handlers-ext.go")
AssertHasHandlers(t, harness, extensions.ToolCall)
AssertHasHandlers(t, harness, extensions.SessionStart)
AssertNoHandlers(t, harness, extensions.AgentEnd)
}
func TestHarness_MultipleExtensions(t *testing.T) {
ext1 := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.Print("extension 1")
})
}
`
ext2 := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.Print("extension 2")
})
}
`
// Load first extension
harness1 := New(t)
harness1.LoadString(ext1, "ext1.go")
// Load second extension
harness2 := New(t)
harness2.LoadString(ext2, "ext2.go")
// Verify they are isolated
_, _ = harness1.Emit(extensions.SessionStartEvent{SessionID: "test1"})
_, _ = harness2.Emit(extensions.SessionStartEvent{SessionID: "test2"})
prints1 := harness1.Context().GetPrints()
prints2 := harness2.Context().GetPrints()
if len(prints1) != 1 || prints1[0] != "extension 1" {
t.Errorf("ext1 prints: expected ['extension 1'], got %v", prints1)
}
if len(prints2) != 1 || prints2[0] != "extension 2" {
t.Errorf("ext2 prints: expected ['extension 2'], got %v", prints2)
}
}
func TestHarness_InputHandling(t *testing.T) {
src := `package main
import (
"kit/ext"
"strings"
)
func Init(api ext.API) {
api.OnInput(func(ie ext.InputEvent, ctx ext.Context) *ext.InputResult {
if strings.Contains(ie.Text, "secret") {
return &ext.InputResult{Action: "handled"}
}
return nil
})
}
`
harness := New(t)
harness.LoadString(src, "input-ext.go")
// Test handled input
result, err := harness.Emit(extensions.InputEvent{Text: "my secret password", Source: "cli"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
AssertInputHandled(t, result, "handled")
// Test unhandled input
result2, err := harness.Emit(extensions.InputEvent{Text: "normal input", Source: "cli"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result2 != nil {
t.Errorf("expected nil result for unhandled input, got %v", result2)
}
}
func TestHarness_StatusSetting(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetStatus("myext:status", "Ready", 50)
})
}
`
harness := New(t)
harness.LoadString(src, "status-ext.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
AssertStatusSet(t, harness, "myext:status")
AssertStatusText(t, harness, "myext:status", "Ready")
}
func TestHarness_LoadFile_NotFound(t *testing.T) {
// Test that loading a nonexistent file fails the test
// We create a mock testing.T to capture the failure
mockT := &testing.T{}
harness := New(mockT)
// Just verify the harness was created successfully
_ = harness.Context().GetPrints()
// The actual behavior (Fatalf on missing file) is tested implicitly
// whenever LoadFile is used in other tests
}
// MockContext tests
func TestMockContext_Prompts(t *testing.T) {
ctx := NewMockContext()
// Configure results
ctx.SetPromptSelectResult(extensions.PromptSelectResult{Value: "option1", Index: 0, Cancelled: false})
ctx.SetPromptConfirmResult(extensions.PromptConfirmResult{Value: true, Cancelled: false})
ctx.SetPromptInputResult(extensions.PromptInputResult{Value: "input text", Cancelled: false})
extCtx := ctx.ToContext()
// Test prompts return configured results
selectResult := extCtx.PromptSelect(extensions.PromptSelectConfig{Message: "test", Options: []string{"a", "b"}})
if selectResult.Value != "option1" {
t.Errorf("expected 'option1', got %q", selectResult.Value)
}
confirmResult := extCtx.PromptConfirm(extensions.PromptConfirmConfig{Message: "test"})
if !confirmResult.Value {
t.Error("expected true")
}
inputResult := extCtx.PromptInput(extensions.PromptInputConfig{Message: "test"})
if inputResult.Value != "input text" {
t.Errorf("expected 'input text', got %q", inputResult.Value)
}
}
func TestMockContext_Options(t *testing.T) {
ctx := NewMockContext()
extCtx := ctx.ToContext()
// Initially empty
if extCtx.GetOption("key") != "" {
t.Error("expected empty option")
}
// Set option
extCtx.SetOption("key", "value")
if extCtx.GetOption("key") != "value" {
t.Errorf("expected 'value', got %q", extCtx.GetOption("key"))
}
}
// Assertion helper tests
func TestAssertPrintedContains(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.Print("This is a long message with some content")
})
}
`
harness := New(t)
harness.LoadString(src, "print-ext.go")
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
AssertPrintedContains(t, harness, "long message")
AssertPrintedContains(t, harness, "some content")
}
func TestAssertWidgetTextContains(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetWidget(ext.WidgetConfig{
ID: "status",
Content: ext.WidgetContent{Text: "Build: passing, Tests: 42/42"},
})
})
}
`
harness := New(t)
harness.LoadString(src, "widget-ext.go")
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
AssertWidgetTextContains(t, harness, "status", "Build: passing")
AssertWidgetTextContains(t, harness, "status", "42/42")
}
// Test that shows how to test a realistic extension pattern
func TestExample_RealisticExtension(t *testing.T) {
// This is an example of a realistic extension that:
// 1. Blocks dangerous tools
// 2. Shows a status widget
// 3. Logs tool calls
src := `package main
import "kit/ext"
var blockedTools = []string{"rm", "del", "remove"}
func Init(api ext.API) {
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
// Check if tool is blocked
for _, blocked := range blockedTools {
if tc.ToolName == blocked {
ctx.PrintError("Tool " + tc.ToolName + " is blocked for safety")
return &ext.ToolCallResult{Block: true, Reason: "safety block"}
}
}
// Log the tool call
ctx.SetStatus("tool-logger:last", tc.ToolName, 10)
return nil
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetWidget(ext.WidgetConfig{
ID: "safety-status",
Content: ext.WidgetContent{Text: "Safety: Active"},
Style: ext.WidgetStyle{BorderColor: "#00ff00"},
})
})
}
`
harness := New(t)
harness.LoadString(src, "safety-ext.go")
// Verify handlers are registered
AssertHasHandlers(t, harness, extensions.ToolCall)
AssertHasHandlers(t, harness, extensions.SessionStart)
// Test session start
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify widget was set
AssertWidgetSet(t, harness, "safety-status")
AssertWidgetText(t, harness, "safety-status", "Safety: Active")
// Test allowed tool
result, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "read", Input: "{}"})
AssertNotBlocked(t, result)
// Verify status was updated
AssertStatusSet(t, harness, "tool-logger:last")
AssertStatusText(t, harness, "tool-logger:last", "read")
// Test blocked tool
result2, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "rm", Input: `{"file": "test.txt"}`})
AssertBlocked(t, result2, "safety block")
AssertPrintError(t, harness, "Tool rm is blocked for safety")
}
+460
View File
@@ -0,0 +1,460 @@
package test
import (
"sync"
"github.com/mark3labs/kit/internal/extensions"
)
// MockContext records all interactions with the extension context.
// It provides a Context object that captures Print calls, widget settings,
// and other context operations for verification in tests.
type MockContext struct {
mu sync.RWMutex
// Recorded calls
Prints []string
PrintInfos []string
PrintErrors []string
PrintBlocks []extensions.PrintBlockOpts
Messages []string
CancelSends []string
// Widget state
Widgets map[string]extensions.WidgetConfig
RemovedIDs []string
Header *extensions.HeaderFooterConfig
Footer *extensions.HeaderFooterConfig
HeaderRemoved bool
FooterRemoved bool
// Context properties
SessionID string
CWD string
Model string
Interactive bool
// UI visibility
UIVisibility *extensions.UIVisibility
// Status entries
StatusEntries map[string]extensions.StatusBarEntry
RemovedStatus []string
// Editor
EditorConfig *extensions.EditorConfig
EditorReset bool
EditorTexts []string
// Options
Options map[string]string
// Prompt results (configurable for testing)
PromptSelectResult extensions.PromptSelectResult
PromptConfirmResult extensions.PromptConfirmResult
PromptInputResult extensions.PromptInputResult
PromptMultiSelectResult extensions.PromptMultiSelectResult
// Overlay
Overlays []extensions.OverlayConfig
}
// StatusBarEntry represents a recorded status bar entry
type StatusBarEntry struct {
Key string
Text string
Priority int
}
// NewMockContext creates a new mock context with default values.
func NewMockContext() *MockContext {
return &MockContext{
Prints: make([]string, 0),
PrintInfos: make([]string, 0),
PrintErrors: make([]string, 0),
PrintBlocks: make([]extensions.PrintBlockOpts, 0),
Messages: make([]string, 0),
CancelSends: make([]string, 0),
Widgets: make(map[string]extensions.WidgetConfig),
RemovedIDs: make([]string, 0),
StatusEntries: make(map[string]extensions.StatusBarEntry),
RemovedStatus: make([]string, 0),
EditorTexts: make([]string, 0),
Options: make(map[string]string),
Overlays: make([]extensions.OverlayConfig, 0),
Interactive: true,
SessionID: "test-session",
CWD: "/test",
Model: "test-model",
}
}
// ToContext returns a extensions.Context wired to record all interactions.
func (m *MockContext) ToContext() extensions.Context {
return extensions.Context{
SessionID: m.SessionID,
CWD: m.CWD,
Model: m.Model,
Interactive: m.Interactive,
Print: m.recordPrint,
PrintInfo: m.recordPrintInfo,
PrintError: m.recordPrintError,
PrintBlock: m.recordPrintBlock,
SendMessage: m.recordSendMessage,
CancelAndSend: m.recordCancelAndSend,
SetWidget: m.recordSetWidget,
RemoveWidget: m.recordRemoveWidget,
SetHeader: m.recordSetHeader,
RemoveHeader: m.recordRemoveHeader,
SetFooter: m.recordSetFooter,
RemoveFooter: m.recordRemoveFooter,
PromptSelect: m.recordPromptSelect,
PromptConfirm: m.recordPromptConfirm,
PromptInput: m.recordPromptInput,
PromptMultiSelect: m.recordPromptMultiSelect,
SetEditor: m.recordSetEditor,
ResetEditor: m.recordResetEditor,
SetEditorText: m.recordSetEditorText,
SetUIVisibility: m.recordUIVisibility,
GetContextStats: m.getContextStats,
GetMessages: m.getMessages,
GetSessionPath: m.getSessionPath,
AppendEntry: m.appendEntry,
GetEntries: m.getEntries,
SetStatus: m.recordSetStatus,
RemoveStatus: m.recordRemoveStatus,
GetOption: m.getOption,
SetOption: m.setOption,
SetModel: m.setModel,
GetAllTools: m.getAllTools,
SetActiveTools: m.setActiveTools,
Exit: m.exit,
Complete: m.complete,
SuspendTUI: m.suspendTUI,
RenderMessage: m.renderMessage,
RegisterTheme: m.registerTheme,
SetTheme: m.setTheme,
ListThemes: m.listThemes,
ReloadExtensions: m.reloadExtensions,
SpawnSubagent: m.spawnSubagent,
ShowOverlay: m.showOverlay,
}
}
// Record methods
func (m *MockContext) recordPrint(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.Prints = append(m.Prints, text)
}
func (m *MockContext) recordPrintInfo(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.PrintInfos = append(m.PrintInfos, text)
}
func (m *MockContext) recordPrintError(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.PrintErrors = append(m.PrintErrors, text)
}
func (m *MockContext) recordPrintBlock(opts extensions.PrintBlockOpts) {
m.mu.Lock()
defer m.mu.Unlock()
m.PrintBlocks = append(m.PrintBlocks, opts)
}
func (m *MockContext) recordSendMessage(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.Messages = append(m.Messages, text)
}
func (m *MockContext) recordCancelAndSend(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.CancelSends = append(m.CancelSends, text)
}
func (m *MockContext) recordSetWidget(config extensions.WidgetConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.Widgets[config.ID] = config
}
func (m *MockContext) recordRemoveWidget(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.Widgets, id)
m.RemovedIDs = append(m.RemovedIDs, id)
}
func (m *MockContext) recordSetHeader(config extensions.HeaderFooterConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.Header = &config
}
func (m *MockContext) recordRemoveHeader() {
m.mu.Lock()
defer m.mu.Unlock()
m.Header = nil
m.HeaderRemoved = true
}
func (m *MockContext) recordSetFooter(config extensions.HeaderFooterConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.Footer = &config
}
func (m *MockContext) recordRemoveFooter() {
m.mu.Lock()
defer m.mu.Unlock()
m.Footer = nil
m.FooterRemoved = true
}
func (m *MockContext) recordSetStatus(key string, text string, priority int) {
m.mu.Lock()
defer m.mu.Unlock()
m.StatusEntries[key] = extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
}
}
func (m *MockContext) recordRemoveStatus(key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.StatusEntries, key)
m.RemovedStatus = append(m.RemovedStatus, key)
}
func (m *MockContext) recordSetEditor(config extensions.EditorConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.EditorConfig = &config
}
func (m *MockContext) recordResetEditor() {
m.mu.Lock()
defer m.mu.Unlock()
m.EditorReset = true
m.EditorConfig = nil
}
func (m *MockContext) recordSetEditorText(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.EditorTexts = append(m.EditorTexts, text)
}
func (m *MockContext) recordUIVisibility(vis extensions.UIVisibility) {
m.mu.Lock()
defer m.mu.Unlock()
m.UIVisibility = &vis
}
func (m *MockContext) recordPromptSelect(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
// Return the configured result (tests can set this)
return m.PromptSelectResult
}
func (m *MockContext) recordPromptConfirm(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
return m.PromptConfirmResult
}
func (m *MockContext) recordPromptInput(config extensions.PromptInputConfig) extensions.PromptInputResult {
return m.PromptInputResult
}
func (m *MockContext) recordPromptMultiSelect(config extensions.PromptMultiSelectConfig) extensions.PromptMultiSelectResult {
return m.PromptMultiSelectResult
}
func (m *MockContext) showOverlay(config extensions.OverlayConfig) extensions.OverlayResult {
m.mu.Lock()
defer m.mu.Unlock()
m.Overlays = append(m.Overlays, config)
return extensions.OverlayResult{Cancelled: true} // Default to cancelled for tests
}
// Stub methods that do nothing or return defaults
func (m *MockContext) getContextStats() extensions.ContextStats {
return extensions.ContextStats{
EstimatedTokens: 1000,
ContextLimit: 200000,
UsagePercent: 0.5,
MessageCount: 10,
}
}
func (m *MockContext) getMessages() []extensions.SessionMessage {
return nil
}
func (m *MockContext) getSessionPath() string {
return ""
}
func (m *MockContext) appendEntry(entryType string, data string) (string, error) {
return "", nil
}
func (m *MockContext) getEntries(entryType string) []extensions.ExtensionEntry {
return nil
}
func (m *MockContext) getOption(name string) string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.Options[name]
}
func (m *MockContext) setOption(name string, value string) {
m.mu.Lock()
defer m.mu.Unlock()
m.Options[name] = value
}
func (m *MockContext) setModel(modelString string) error {
return nil
}
func (m *MockContext) getAllTools() []extensions.ToolInfo {
return nil
}
func (m *MockContext) setActiveTools(names []string) {}
func (m *MockContext) exit() {}
func (m *MockContext) complete(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return extensions.CompleteResponse{}, nil
}
func (m *MockContext) suspendTUI(callback func()) error {
callback()
return nil
}
func (m *MockContext) renderMessage(rendererName string, content string) {}
func (m *MockContext) registerTheme(name string, config extensions.ThemeColorConfig) {}
func (m *MockContext) setTheme(name string) error {
return nil
}
func (m *MockContext) listThemes() []string {
return nil
}
func (m *MockContext) reloadExtensions() error {
return nil
}
func (m *MockContext) spawnSubagent(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
return nil, nil, nil
}
// Accessor methods for verification
// GetPrints returns all recorded Print calls.
func (m *MockContext) GetPrints() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.Prints))
copy(result, m.Prints)
return result
}
// GetPrintInfos returns all recorded PrintInfo calls.
func (m *MockContext) GetPrintInfos() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.PrintInfos))
copy(result, m.PrintInfos)
return result
}
// GetPrintErrors returns all recorded PrintError calls.
func (m *MockContext) GetPrintErrors() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.PrintErrors))
copy(result, m.PrintErrors)
return result
}
// GetWidget returns a recorded widget by ID.
func (m *MockContext) GetWidget(id string) (extensions.WidgetConfig, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
w, ok := m.Widgets[id]
return w, ok
}
// HasWidget reports whether a widget with the given ID was set.
func (m *MockContext) HasWidget(id string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.Widgets[id]
return ok
}
// GetHeader returns the recorded header configuration.
func (m *MockContext) GetHeader() *extensions.HeaderFooterConfig {
m.mu.RLock()
defer m.mu.RUnlock()
return m.Header
}
// GetFooter returns the recorded footer configuration.
func (m *MockContext) GetFooter() *extensions.HeaderFooterConfig {
m.mu.RLock()
defer m.mu.RUnlock()
return m.Footer
}
// GetStatus returns a recorded status entry by key.
func (m *MockContext) GetStatus(key string) (extensions.StatusBarEntry, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.StatusEntries[key]
return s, ok
}
// SetPromptSelectResult configures the result returned by PromptSelect.
func (m *MockContext) SetPromptSelectResult(result extensions.PromptSelectResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptSelectResult = result
}
// SetPromptConfirmResult configures the result returned by PromptConfirm.
func (m *MockContext) SetPromptConfirmResult(result extensions.PromptConfirmResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptConfirmResult = result
}
// SetPromptInputResult configures the result returned by PromptInput.
func (m *MockContext) SetPromptInputResult(result extensions.PromptInputResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptInputResult = result
}
// SetPromptMultiSelectResult configures the result returned by PromptMultiSelect.
func (m *MockContext) SetPromptMultiSelectResult(result extensions.PromptMultiSelectResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptMultiSelectResult = result
}
+33 -5
View File
@@ -1300,10 +1300,17 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Fall back to parent's model.
// Resolve model: fall back to parent's model, and inherit the parent's
// provider when only a bare model name is given (e.g. "claude-haiku"
// instead of "anthropic/claude-haiku"). This avoids provider guessing.
model := cfg.Model
if model == "" {
model = m.modelString
} else if !strings.Contains(model, "/") {
// Bare model name — prepend parent's provider.
if parts := strings.SplitN(m.modelString, "/", 2); len(parts) == 2 {
model = parts[0] + "/" + model
}
}
// Default system prompt.
@@ -1318,15 +1325,33 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
tools = SubagentTools()
}
// Create child Kit instance.
child, err := New(ctx, &Options{
// Create child Kit instance. If the requested model fails (bad name,
// unsupported provider, etc.), fall back to the parent's model so the
// agent gets a useful error message instead of a hard failure.
childOpts := &Options{
Model: model,
SystemPrompt: systemPrompt,
Tools: tools,
NoSession: cfg.NoSession,
Quiet: true,
})
if err != nil {
}
child, err := New(ctx, childOpts)
if err != nil && model != m.modelString {
// Model-specific failure — retry with parent's model.
childOpts.Model = m.modelString
child, err = New(ctx, childOpts)
if err != nil {
return &SubagentResult{
Error: fmt.Errorf("failed to create subagent: %w", err),
Elapsed: time.Since(start),
}, err
}
// Prepend a note so the agent knows which model is actually running.
cfg.Prompt = fmt.Sprintf(
"[Note: requested model %q was not available, using %s instead.]\n\n%s",
model, m.modelString, cfg.Prompt,
)
} else if err != nil {
return &SubagentResult{
Error: fmt.Errorf("failed to create subagent: %w", err),
Elapsed: time.Since(start),
@@ -1383,6 +1408,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
Model: model,
SystemPrompt: systemPrompt,
Timeout: timeout,
OnEvent: func(e Event) {
m.events.emit(e)
},
})
if result == nil {
return &core.SubagentSpawnResult{Error: err}, err
-15
View File
@@ -1,15 +0,0 @@
{
"version": 1,
"skills": {
"btca-cli": {
"source": "davis7dotsh/better-context",
"sourceType": "github",
"computedHash": "99bc5301f4f839a6f3be99d98955f32f1cd576c218731fa05fa54a003bd20e9b"
},
"kit-extensions": {
"source": "mark3labs/kit",
"sourceType": "github",
"computedHash": "9347a88bec46dd52727a672b6c8d058955f9f50dfe98708e0c63b85e0779ba96"
}
}
}
+372 -4
View File
@@ -5,7 +5,9 @@ description: Guide for creating Kit extensions. Use when the user asks to build,
# Kit Extensions Development Guide
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, register and switch color themes, and more.
Extensions can be distributed via git repositories using `kit install`. Repos can contain single extensions or collections of multiple extensions.
## Extension Structure
@@ -414,6 +416,17 @@ result := ctx.PromptInput(ext.PromptInputConfig{
if !result.Cancelled {
// result.Value string
}
// Multi-select (toggle with spacebar, confirm with enter)
result := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{
Message: "Select extensions to install:",
Options: []string{"git", "todo", "weather"},
DefaultSelected: []int{0, 1, 2}, // pre-selected indices; nil = all selected
})
if !result.Cancelled {
// result.Values []string — selected option texts
// result.Indices []int — selected option indices
}
```
### Overlay Dialogs
@@ -529,6 +542,64 @@ ctx.SuspendTUI(func() {
})
```
### Themes
Register, switch, and list color themes at runtime:
```go
// Register a custom theme (empty fields inherit from default).
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
})
// Switch to a theme by name (built-in, file-based, or extension-registered).
err := ctx.SetTheme("neon")
// List all available theme names.
names := ctx.ListThemes() // []string
```
**ThemeColorConfig fields:**
| Field | Description |
|-------|-------------|
| `Primary` | Main brand/accent color |
| `Secondary` | Secondary accent |
| `Success` | Success states |
| `Warning` | Warning states |
| `Error` | Error/critical states |
| `Info` | Informational states |
| `Text` | Primary text |
| `Muted` | Dimmed text |
| `VeryMuted` | Very dimmed text |
| `Background` | Base background |
| `Border` | Panel borders |
| `MutedBorder` | Subtle dividers |
| `System` | System messages |
| `Tool` | Tool-related elements |
| `Accent` | Secondary highlight |
| `Highlight` | Highlighted regions |
| `MdHeading` | Markdown headings |
| `MdLink` | Markdown links |
| `MdKeyword` | Syntax: keywords |
| `MdString` | Syntax: strings |
| `MdNumber` | Syntax: numbers |
| `MdComment` | Syntax: comments |
Each field is an `ext.ThemeColor` with `Light` and `Dark` hex strings. Kit ships 22 built-in themes: `kitt`, `catppuccin`, `dracula`, `tokyonight`, `nord`, `gruvbox`, `monokai`, `solarized`, `github`, `one-dark`, `rose-pine`, `ayu`, `material`, `everforest`, `kanagawa`, `amoled`, `synthwave`, `vesper`, `flexoki`, `matrix`, `vercel`, `zenburn`.
Users can also drop `.yml`/`.yaml`/`.json` theme files in `~/.config/kit/themes/` (global) or `.kit/themes/` (project-local). Extension-registered themes take highest precedence.
### Application Control
```go
@@ -736,20 +807,164 @@ api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
})
```
### Pattern: Custom Theme with Slash Command
Register a theme and provide a slash command shortcut to activate it:
```go
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
})
})
api.RegisterCommand(ext.CommandDef{
Name: "neon",
Description: "Switch to the neon cyberpunk theme",
Execute: func(args string, ctx ext.Context) (string, error) {
if err := ctx.SetTheme("neon"); err != nil {
return "", err
}
return "Neon theme activated!", nil
},
})
```
### Pattern: Spawning Kit as a Sub-Agent
Extensions can spawn Kit as a subprocess for delegation:
Use `ctx.SpawnSubagent` to spawn an isolated child Kit instance. The subagent runs as a subprocess with `--json --no-extensions` flags, ensuring isolation.
**Blocking mode** — waits for completion:
```go
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: "Analyze the test files and summarize coverage",
Model: "anthropic/claude-haiku-3-5-20241022", // empty = parent's model
SystemPrompt: "You are a test analysis expert.",
Timeout: 2 * time.Minute, // 0 = 5 minute default
Blocking: true,
})
if err != nil {
ctx.PrintError("spawn failed: " + err.Error())
return
}
if result.Error != nil {
ctx.PrintError("subagent failed: " + result.Error.Error())
return
}
ctx.PrintInfo("Result:\n" + result.Response)
// result.Elapsed, result.ExitCode, result.SessionID
// result.Usage.InputTokens, result.Usage.OutputTokens (if available)
```
**Background mode** — returns immediately with a handle:
```go
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: "Write unit tests for UserService",
OnOutput: func(chunk string) {
// Live stderr streaming (progress, tool calls, etc.)
},
OnEvent: func(event ext.SubagentEvent) {
// Real-time events: "text", "reasoning", "tool_call",
// "tool_result", "tool_execution_start", "tool_execution_end",
// "turn_start", "turn_end"
// event.Type, event.Content, event.ToolName, event.ToolArgs, etc.
},
OnComplete: func(result ext.SubagentResult) {
ctx.SendMessage("Subagent finished:\n" + result.Response)
},
})
// handle.Kill() — terminate the subagent
// handle.Wait() — block until completion, returns SubagentResult
// <-handle.Done() — channel that closes on completion
```
**SubagentConfig fields:**
| Field | Type | Description |
|-------|------|-------------|
| `Prompt` | string | Task instruction (required) |
| `Model` | string | Override model ("provider/model"), empty = parent's |
| `SystemPrompt` | string | Custom system prompt, empty = default |
| `Timeout` | time.Duration | Execution limit, 0 = 5 minutes |
| `Blocking` | bool | Wait for completion vs return handle |
| `NoSession` | bool | Don't persist subagent session file |
| `ParentSessionID` | string | Link to parent session (optional) |
| `OnOutput` | func(string) | Stderr streaming callback |
| `OnEvent` | func(SubagentEvent) | Real-time event callback |
| `OnComplete` | func(SubagentResult) | Completion callback |
**SubagentResult fields:**
| Field | Type | Description |
|-------|------|-------------|
| `Response` | string | Final text response |
| `Error` | error | Non-nil on failure |
| `ExitCode` | int | Process exit code (0 = success) |
| `Elapsed` | time.Duration | Total execution time |
| `Usage` | *SubagentUsage | Token usage (InputTokens, OutputTokens) |
| `SessionID` | string | Subagent's session ID (if persisted) |
You can also spawn Kit as a raw subprocess for simpler cases:
```bash
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
---
## Testing Extensions
Kit provides a testing package to help you write unit tests for your extensions:
```go
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
func TestMyExtension(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test event handlers
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify behavior with assertions
test.AssertPrinted(t, harness, "session started")
test.AssertWidgetSet(t, harness, "my-widget")
// Test tool blocking
result, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "dangerous"})
test.AssertBlocked(t, result, "not allowed")
}
```
**Key testing patterns:**
- Load extensions with `LoadFile()` or `LoadString()` for inline code
- Emit events with `Emit()` to trigger handlers
- Verify with 25+ assertion helpers: `AssertWidgetSet()`, `AssertToolRegistered()`, `AssertPrintInfo()`, etc.
- Mock prompts by setting results on `harness.Context().SetPromptSelectResult()`
- Test multiple scenarios per extension with isolated harness instances
See `examples/extensions/tool-logger_test.go` for a complete example with 14 test cases.
### CLI Testing Commands
```bash
# Validate syntax of all discovered extensions
kit extensions validate
@@ -772,6 +987,157 @@ kit extensions init
---
## Distributing Extensions via Git Repositories
Extensions can be distributed and installed from git repositories using `kit install`. This enables sharing extensions with others and maintaining versioned collections.
### Repository Structure
Extensions support two organization patterns within a repo:
**Single-file extensions** (simple, standalone):
```
my-extension-repo/
├── weather.go # Single extension file
├── todo.go # Another extension
└── README.md # Installation and usage docs
```
**Multi-file extensions** (with `main.go` entry point):
```
my-extension-repo/
├── git-tools/
│ ├── main.go # Entry point
│ ├── helpers.go # Supporting code
│ └── config.go # Configuration
├── todo/
│ ├── main.go # Entry point
│ └── storage.go # Storage logic
└── README.md
```
**Hybrid approach** (single files + subdirectories with main.go):
```
my-extensions/
├── weather.go # Single file extension
├── calculator.go # Single file extension
├── git-tools/
│ ├── main.go # Multi-file extension
│ └── utils.go
└── README.md
```
### Installing from Git
Users install extensions using the `kit install` command:
```bash
# Install from GitHub (latest)
kit install github.com/user/repo
# Pin to a specific version/tag
kit install github.com/user/repo@v1.0.0
kit install github.com/user/repo@main
kit install github.com/user/repo@abc1234
# Install locally in project (./.kit/git/)
kit install github.com/user/repo --local
# Interactive selection for repos with multiple extensions
kit install github.com/user/collection --select
```
Supported URL formats:
- `github.com/user/repo` — Shorthand (defaults to HTTPS)
- `git:github.com/user/repo` — Git prefix format
- `https://github.com/user/repo` — HTTPS URL
- `ssh://git@github.com/user/repo` — SSH URL
- `git@github.com:user/repo` — SSH shorthand
### Managing Installed Extensions
```bash
# Update an installed extension (skips pinned versions)
kit install github.com/user/repo --update
# Remove an installed extension
kit install github.com/user/repo --uninstall
# List all loaded extensions
kit extensions list
# Validate all extensions
kit extensions validate
```
### Extension Selection
For repos containing multiple extensions, users can select which to install:
```bash
# Interactive selection
kit install github.com/user/collection --select
```
This prompts the user to choose which extensions to install. Selected extensions are recorded in the manifest, and only those are loaded at runtime (others in the repo are ignored).
### README Template for Extension Repos
Include this in your extension repo's README.md:
```markdown
# My Kit Extensions
A collection of extensions for [Kit](https://github.com/mark3labs/kit).
## Installation
### Install all extensions
\`\`\`bash
kit install github.com/username/repo
\`\`\`
### Install specific extensions
\`\`\`bash
kit install github.com/username/repo --select
\`\`\`
### Install locally in a project
\`\`\`bash
kit install github.com/username/repo --local
\`\`\`
## Extensions
### Extension Name
Description of what it does.
- **Path**: `./ext-name/main.go` or `./ext-name.go`
- **Commands**: `/command-name`
- **Tools**: `tool_name`
## Requirements
- Kit vX.Y.Z+
- Any other dependencies
## Update
\`\`\`bash
kit install github.com/username/repo --update
\`\`\`
```
### Storage Locations
Installed extensions are stored at:
- **Global**: `~/.local/share/kit/git/<host>/<owner>/<repo>/`
- **Project-local**: `./.kit/git/<host>/<owner>/<repo>/`
- **Manifest**: `packages.json` in respective directories
---
## Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
@@ -850,4 +1216,6 @@ func applyMode(ctx ext.Context, active bool, tools []string) {
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
- [`pkg/extensions/test/`](https://github.com/mark3labs/kit/tree/main/pkg/extensions/test) — Testing package with harness, mocks, and assertions
- [`examples/extensions/tool-logger_test.go`](https://github.com/mark3labs/kit/blob/main/examples/extensions/tool-logger_test.go) — Complete test example
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
out/
dist/
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
// Bootstraps the Tome documentation shell.
// Configure your site in tome.config.js instead.
import "@tomehq/theme/entry";
+918
View File
@@ -0,0 +1,918 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "www",
"devDependencies": {
"@tomehq/cli": "^0.5.0",
"@tomehq/theme": "^0.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
},
},
},
"packages": {
"@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.7.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA=="],
"@apidevtools/openapi-schemas": ["@apidevtools/openapi-schemas@2.1.0", "", {}, "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="],
"@apidevtools/swagger-methods": ["@apidevtools/swagger-methods@3.0.2", "", {}, "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="],
"@apidevtools/swagger-parser": ["@apidevtools/swagger-parser@10.1.1", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "11.7.2", "@apidevtools/openapi-schemas": "^2.1.0", "@apidevtools/swagger-methods": "^3.0.2", "@jsdevtools/ono": "^7.1.3", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "call-me-maybe": "^1.0.2" }, "peerDependencies": { "openapi-types": ">=7" } }, "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.3", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7" } }, "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.1", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mdx-js/rollup": ["@mdx-js/rollup@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "@rollup/pluginutils": "^5.0.0", "source-map": "^0.7.0", "vfile": "^6.0.0" }, "peerDependencies": { "rollup": ">=2" } }, "sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="],
"@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="],
"@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="],
"@shikijs/twoslash": ["@shikijs/twoslash@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/types": "1.29.2", "twoslash": "^0.2.12" } }, "sha512-2S04ppAEa477tiaLfGEn1QJWbZUmbk8UoPbAEw4PifsrxkBXtAtOflIZJNtuCwz8ptc/TPxy7CO7gW4Uoi6o/g=="],
"@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@tomehq/cli": ["@tomehq/cli@0.5.0", "", { "dependencies": { "@tomehq/core": "0.5.0", "@vitejs/plugin-react": "^4.0.0", "commander": "^12.0.0", "picocolors": "^1.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "vite": "^6.0.0" }, "bin": { "tome": "dist/cli.js" } }, "sha512-fZkEsE5RregWDOBFFMkMPodu30hDp/wK3xIsuknUGABcwGHWu3A+QmY0vvf9HuoGHPjz7mc7bM0dmJB1CO7G/g=="],
"@tomehq/components": ["@tomehq/components@0.5.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-JKEpbGmmas4vms5jVn3t/xphjM+7xaZLMbFhVvze25fOiwNOjBRJ8hOr68aO5vAVDu1oXSC2GOV8LogY4I3sfA=="],
"@tomehq/core": ["@tomehq/core@0.5.0", "", { "dependencies": { "@apidevtools/swagger-parser": "^10.1.1", "@mdx-js/rollup": "^3.0.0", "@modelcontextprotocol/sdk": "^1.12.0", "@shikijs/twoslash": "^1.22.0", "estree-util-visit": "^2.0.0", "glob": "^11.0.0", "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.0", "isomorphic-dompurify": "^3.3.0", "openapi-types": "^12.1.3", "rehype-autolink-headings": "^7.1.0", "rehype-raw": "^7.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "shiki": "^1.22.0", "unified": "^11.0.0", "unist-util-visit": "^5.1.0", "yaml": "^2.8.2", "zod": "^3.23.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0" } }, "sha512-+TRZBnlUzDV89SyrSmrVQFfZDZ76yj6jHaShU/jxsPfXc1o8LqvBbFbRpWYJf8/yCDH3FUee1ZnZHtcq3K3R1w=="],
"@tomehq/theme": ["@tomehq/theme@0.5.0", "", { "dependencies": { "@tomehq/components": "0.5.0", "@tomehq/core": "0.5.0" }, "peerDependencies": { "@docsearch/css": "^3.0.0", "@docsearch/react": "^3.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@docsearch/css", "@docsearch/react"] }, "sha512-YRGJj4Igc2reoP/H6kASrY4wtaWbt3xiwrS5BBpBn9+EO2G0cZb1uLyYxf4lx7yEBy5NXa6jKwrfC6+zWvWQ6A=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="],
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="],
"estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-util-scope": ["estree-util-scope@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ=="],
"estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="],
"estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isomorphic-dompurify": ["isomorphic-dompurify@3.5.1", "", { "dependencies": { "dompurify": "^3.3.3", "jsdom": "^29.0.0" } }, "sha512-LlUCZikq/W6CmSq1UWcFLgS4nhkNBwimmVyNQtdByDffT0yVjKYef9lincZ7/ohBlknPkX1C97BBmjeNuFFR4Q=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"jsdom": ["jsdom@29.0.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.2", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.3", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="],
"micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="],
"micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="],
"micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="],
"micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
"recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="],
"recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="],
"recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="],
"regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
"regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="],
"rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="],
"rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="],
"remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tldts": ["tldts@7.0.26", "", { "dependencies": { "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ=="],
"tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"twoslash": ["twoslash@0.2.12", "", { "dependencies": { "@typescript/vfs": "^1.6.0", "twoslash-protocol": "0.2.12" }, "peerDependencies": { "typescript": "*" } }, "sha512-tEHPASMqi7kqwfJbkk7hc/4EhlrKCSLcur+TcvYki3vhIfaRMXnXjaYFgXpoZRbT6GdprD4tGuVBEmTpUgLBsw=="],
"twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@apidevtools/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
}
}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kit Documentation</title>
<link rel="icon" href="/logo.jpg" type="image/jpeg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./styles/custom.css" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { -webkit-font-smoothing: antialiased; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
</style>
</head>
<body>
<div id="tome-root"></div>
<script type="module" src="./.tome/entry.tsx"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "www",
"private": true,
"type": "module",
"scripts": {
"dev": "tome dev",
"build": "tome build",
"deploy": "tome deploy"
},
"devDependencies": {
"@tomehq/cli": "^0.5.0",
"@tomehq/theme": "^0.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
+95
View File
@@ -0,0 +1,95 @@
---
title: JSON Output
description: Machine-readable JSON output for scripting and automation.
---
# JSON Output
Use the `--json` flag to get structured output for scripting and automation:
```bash
kit "Explain main.go" --json --quiet --no-session
```
## Response format
```json
{
"response": "Final assistant response text",
"model": "anthropic/claude-haiku-3-5-20241022",
"stop_reason": "end_turn",
"session_id": "a1b2c3d4e5f6",
"usage": {
"input_tokens": 1024,
"output_tokens": 512,
"total_tokens": 1536,
"cache_read_tokens": 0,
"cache_creation_tokens": 0
},
"messages": [
{
"role": "assistant",
"parts": [
{"type": "text", "data": "..."},
{"type": "tool_call", "data": {"name": "...", "args": "..."}},
{"type": "tool_result", "data": {"name": "...", "result": "..."}}
]
}
]
}
```
## Fields
### Top-level
| Field | Type | Description |
|-------|------|-------------|
| `response` | string | The final assistant response text |
| `model` | string | The model that was used |
| `stop_reason` | string | Why the model stopped (e.g., `end_turn`) |
| `session_id` | string | Session identifier (omitted in `--no-session` mode) |
| `usage` | object | Token usage statistics |
| `messages` | array | Full conversation history |
### Usage
| Field | Type | Description |
|-------|------|-------------|
| `input_tokens` | int | Tokens sent to the model |
| `output_tokens` | int | Tokens generated by the model |
| `total_tokens` | int | Sum of input and output tokens |
| `cache_read_tokens` | int | Tokens read from prompt cache |
| `cache_creation_tokens` | int | Tokens written to prompt cache |
### Message parts
Each message contains a `parts` array with typed entries:
| Type | Description |
|------|-------------|
| `text` | Assistant text content |
| `tool_call` | Tool invocation with name and args |
| `tool_result` | Tool execution result |
| `reasoning` | Extended thinking content |
| `finish` | End-of-turn marker |
## Parsing in scripts
### bash + jq
```bash
result=$(kit "Count files" --json --quiet --no-session)
response=$(echo "$result" | jq -r '.response')
tokens=$(echo "$result" | jq '.usage.total_tokens')
```
### Go SDK
For Go programs, use the SDK's `PromptResult` method instead of parsing JSON:
```go
result, err := host.PromptResult(ctx, "Count files")
fmt.Println(result.Response)
fmt.Println(result.Usage.TotalTokens)
```
+73
View File
@@ -0,0 +1,73 @@
---
title: Subagents
description: Multi-agent orchestration with Kit subagents.
---
# Subagents
Kit supports multi-agent orchestration through both subprocess spawning and in-process subagents.
## Subprocess pattern
Spawn Kit as a subprocess for isolated agent execution:
```bash
kit "Analyze codebase" \
--json \
--no-session \
--no-extensions \
--quiet \
--model anthropic/claude-haiku-3-5-20241022
```
Key flags for subprocess usage:
| Flag | Purpose |
|------|---------|
| `--quiet` | Stdout only, no TUI |
| `--no-session` | Ephemeral, no persistence |
| `--no-extensions` | Prevent recursive extension loading |
| `--json` | Machine-readable output |
| `--system-prompt` | Custom system prompt (string or file path) |
Positional arguments are the prompt. `@file` arguments attach file content as context.
## Built-in spawn_subagent tool
Kit includes a built-in `spawn_subagent` tool that the LLM can use to delegate tasks to independent child agents:
```
spawn_subagent(
task: "Analyze the test files and summarize coverage",
model: "anthropic/claude-haiku-3-5-20241022", // optional
system_prompt: "You are a test analysis expert.", // optional
timeout_seconds: 300 // optional, max 1800
)
```
Subagents run as separate in-process Kit instances with full tool access (except spawning further subagents, to prevent infinite recursion). They can run in parallel.
## Extension subagents
Extensions can spawn subagents programmatically:
```go
result := ctx.SpawnSubagent(ext.SubagentConfig{
Task: "Review this code for security issues",
Model: "anthropic/claude-sonnet-4-5-20250929",
SystemPrompt: "You are a security auditor.",
})
```
## Go SDK subagents
The SDK provides in-process subagent spawning:
```go
result, err := host.Subagent(ctx, kit.SubagentConfig{
Task: "Summarize the changes in this PR",
Model: "anthropic/claude-haiku-3-5-20241022",
SystemPrompt: "You are a code reviewer.",
Timeout: 5 * time.Minute,
})
```
+74
View File
@@ -0,0 +1,74 @@
---
title: Testing with tmux
description: Test Kit's TUI non-interactively using tmux.
---
# Testing with tmux
Kit's interactive TUI can be tested non-interactively using tmux. This is useful for automated testing, CI pipelines, and extension development.
## Basic pattern
```bash
# Start Kit in a detached tmux session
tmux new-session -d -s kittest -x 120 -y 40 \
"output/kit -e ext.go --no-session 2>kit_stderr.log"
# Wait for startup
sleep 3
# Capture the current screen
tmux capture-pane -t kittest -p
# Send input
tmux send-keys -t kittest '/command' Enter
# Wait for response
sleep 2
# Capture updated screen
tmux capture-pane -t kittest -p
# Cleanup
tmux kill-session -t kittest
```
## Testing extensions
When testing extensions, the pattern is:
1. Build Kit with your changes
2. Start Kit in tmux with the extension loaded
3. Send slash commands or prompts
4. Capture and verify the screen output
5. Check stderr logs for errors
```bash
# Build first
go build -o output/kit ./cmd/kit
# Start with extension
tmux new-session -d -s kittest -x 120 -y 40 \
"output/kit -e examples/extensions/widget-status.go --no-session 2>kit_stderr.log"
sleep 3
# Verify widget appears in screen
tmux capture-pane -t kittest -p | grep "Status"
# Send a slash command
tmux send-keys -t kittest '/stats' Enter
sleep 1
tmux capture-pane -t kittest -p
# Cleanup
tmux kill-session -t kittest
```
## Tips
- Use `-x` and `-y` to set consistent terminal dimensions
- Redirect stderr to a log file (`2>kit.log`) for debugging
- Use `--no-session` to avoid creating session files during tests
- Add sufficient `sleep` between commands for the TUI to render
- Use `grep` on captured pane output to verify specific content
+89
View File
@@ -0,0 +1,89 @@
---
title: Commands
description: Complete reference for all Kit CLI subcommands.
---
# Commands
## Authentication
For OAuth-enabled providers like Anthropic.
```bash
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
```
## Model database
Manage the local model database that maps provider names to API configurations.
```bash
kit models [provider] # List available models (optionally filter by provider)
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models [source] # Update model database
```
The `update-models` command accepts an optional source argument:
- *(none)* — update from [models.dev](https://models.dev)
- A URL — fetch from a custom endpoint
- A file path — load from a local file
- `embedded` — reset to the bundled database
## Extension management
```bash
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
```
### Installing extensions from git
```bash
kit install <git-url> # Install extensions from git repositories
kit install -l <git-url> # Install to project-local .kit/git/ directory
kit install -u <git-url> # Update an already-installed package
kit install --uninstall <pkg> # Remove an installed package
kit install --all # Install all extensions without prompting
```
## Skills
```bash
kit skill # Install the Kit extensions skill via skills.sh
```
## Interactive slash commands
These commands are available inside the Kit TUI during an interactive session:
| Command | Description |
|---------|-------------|
| `/help` | Show available commands |
| `/tools` | List available MCP tools |
| `/servers` | Show connected MCP servers |
| `/model [name]` | Switch model or open model selector |
| `/theme [name]` | Switch color theme or list available themes |
| `/thinking [level]` | Set thinking level (off, minimal, low, medium, high) |
| `/compact [focus]` | Summarize older messages to free context |
| `/clear` | Clear conversation |
| `/clear-queue` | Clear queued messages |
| `/usage` | Show token usage |
| `/reset-usage` | Reset usage statistics |
| `/tree` | Navigate session tree |
| `/fork` | Branch from an earlier message |
| `/new` | Start a new session |
| `/name` | Set session display name |
| `/session` | Show session info |
| `/quit` | Exit Kit |
## ACP server
Run Kit as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server. ACP-compatible clients communicate with Kit over JSON-RPC 2.0 on stdin/stdout.
```bash
kit acp # Start as ACP agent
kit acp --debug # With debug logging to stderr
```
+66
View File
@@ -0,0 +1,66 @@
---
title: Global Flags
description: Complete reference for all Kit CLI flags.
---
# Global Flags
All flags can be passed to the root `kit` command.
## Model and provider
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--model` | `-m` | `anthropic/claude-sonnet-4-5-20250929` | Model to use (provider/model format) |
| `--provider-api-key` | — | — | API key for the provider |
| `--provider-url` | — | — | Base URL for provider API |
| `--tls-skip-verify` | — | `false` | Skip TLS certificate verification |
## Session management
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--session` | `-s` | — | Open specific JSONL session file |
| `--continue` | `-c` | `false` | Resume most recent session for current directory |
| `--resume` | `-r` | `false` | Interactive session picker |
| `--no-session` | — | `false` | Ephemeral mode, no persistence |
## Behavior
These flags control Kit's behavior. When a prompt is passed as a positional argument, Kit runs in non-interactive mode.
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--quiet` | — | `false` | Suppress all output (non-interactive only) |
| `--json` | — | `false` | Output response as JSON (non-interactive only) |
| `--no-exit` | — | `false` | Enter interactive mode after prompt completes |
| `--max-steps` | — | `0` | Maximum agent steps (0 for unlimited) |
| `--stream` | — | `true` | Enable streaming output |
| `--compact` | — | `false` | Enable compact output mode |
| `--auto-compact` | — | `false` | Auto-compact conversation near context limit |
## Extensions
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--extension` | `-e` | — | Load additional extension file(s) (repeatable) |
| `--no-extensions` | — | `false` | Disable all extensions |
## Generation parameters
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--max-tokens` | — | `4096` | Maximum tokens in response |
| `--temperature` | — | `0.7` | Randomness 0.01.0 |
| `--top-p` | — | `0.95` | Nucleus sampling 0.01.0 |
| `--top-k` | — | `40` | Limit top K tokens |
| `--stop-sequences` | — | — | Custom stop sequences (comma-separated) |
| `--thinking-level` | — | `off` | Extended thinking level: off, minimal, low, medium, high |
## System
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--config` | — | `~/.kit.yml` | Config file path |
| `--system-prompt` | — | — | System prompt text or file path |
| `--debug` | — | `false` | Enable debug logging |
+116
View File
@@ -0,0 +1,116 @@
---
title: Configuration
description: Configure Kit using config files, environment variables, and CLI flags.
---
# Configuration
Kit looks for configuration in the following locations, in order of priority:
1. CLI flags
2. Environment variables (with `KIT_` prefix)
3. `./.kit.yml` / `./.kit.yaml` / `./.kit.json` (project-local)
4. `~/.kit.yml` / `~/.kit.yaml` / `~/.kit.json` (global)
## Basic configuration
Create `~/.kit.yml`:
```yaml
model: anthropic/claude-sonnet-4-5-20250929
max-tokens: 4096
temperature: 0.7
stream: true
```
## All configuration keys
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `model` | string | `anthropic/claude-sonnet-4-5-20250929` | Model to use (provider/model format) |
| `max-tokens` | int | `4096` | Maximum tokens in response |
| `temperature` | float | `0.7` | Randomness 0.01.0 |
| `top-p` | float | `0.95` | Nucleus sampling 0.01.0 |
| `top-k` | int | `40` | Limit top K tokens |
| `stream` | bool | `true` | Enable streaming output |
| `debug` | bool | `false` | Enable debug logging |
| `compact` | bool | `false` | Enable compact output mode |
| `system-prompt` | string | — | System prompt text or file path |
| `max-steps` | int | `0` | Maximum agent steps (0 = unlimited) |
| `thinking-level` | string | `off` | Extended thinking: off, minimal, low, medium, high |
| `provider-api-key` | string | — | API key for the provider |
| `provider-url` | string | — | Base URL for provider API |
| `tls-skip-verify` | bool | `false` | Skip TLS certificate verification |
| `stop-sequences` | list | — | Custom stop sequences |
| `theme` | object or string | — | UI theme ([inline overrides or file path](/themes)) |
## Environment variables
Any configuration key can be set via environment variable with the `KIT_` prefix. Hyphens become underscores:
```bash
export KIT_MODEL="openai/gpt-4o"
export KIT_MAX_TOKENS="8192"
export KIT_TEMPERATURE="0.5"
```
Provider API keys use their own environment variables:
```bash
export ANTHROPIC_API_KEY="sk-..."
export OPENAI_API_KEY="sk-..."
export GOOGLE_API_KEY="..."
```
## MCP server configuration
Add external MCP servers to your `.kit.yml`:
```yaml
mcpServers:
filesystem:
type: local
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
environment:
LOG_LEVEL: "info"
allowedTools: ["read_file", "write_file"]
excludedTools: ["delete_file"]
search:
type: remote
url: "https://mcp.example.com/search"
```
### MCP server fields
| Field | Type | Description |
|-------|------|-------------|
| `type` | string | `local` (stdio) or `remote` (streamable HTTP) |
| `command` | list | Command and args for local servers |
| `environment` | map | Environment variables for the server process |
| `url` | string | URL for remote servers |
| `allowedTools` | list | Whitelist of tool names to expose |
| `excludedTools` | list | Blacklist of tool names to hide |
A legacy format with `transport`, `args`, `env`, and `headers` fields is also supported.
## Theme configuration
Set theme colors inline or reference an external file:
```yaml
# Inline partial overrides (unspecified fields inherit from default)
theme:
primary:
light: "#8839ef"
dark: "#cba6f7"
error:
dark: "#FF0000"
```
```yaml
# Reference external theme file
theme: "./themes/my-custom-theme.yml"
```
See [Themes](/themes) for the full theme file format, built-in themes, and the extension theme API.
+84
View File
@@ -0,0 +1,84 @@
---
title: Development
description: Build, test, and contribute to Kit.
---
# Development
## Build and test
```bash
# Build
go build -o output/kit ./cmd/kit
# Run all tests
go test -race ./...
# Run a specific test
go test -race ./cmd -run TestScriptExecution
# Lint
go vet ./...
# Format
go fmt ./...
```
## Project structure
```
cmd/kit/ - CLI entry point (main.go)
cmd/ - CLI command implementations (root, auth, models, etc.)
pkg/kit/ - Go SDK for embedding Kit
internal/app/ - Application orchestrator (agent loop, message store, queue)
internal/agent/ - Agent execution and tool dispatch
internal/auth/ - OAuth authentication and credential storage
internal/acpserver/ - ACP (Agent Client Protocol) server
internal/clipboard/ - Cross-platform clipboard operations
internal/compaction/ - Conversation compaction and summarization
internal/config/ - Configuration management
internal/core/ - Built-in tools (bash, read, write, edit, grep, find, ls)
internal/extensions/ - Yaegi extension system
internal/kitsetup/ - Initial setup wizard
internal/message/ - Message content types and structured content blocks
internal/models/ - Provider and model management
internal/session/ - Session persistence (tree-based JSONL)
internal/skills/ - Skill loading and system prompt composition
internal/tools/ - MCP tool integration
internal/ui/ - Bubble Tea TUI components
examples/extensions/ - Example extension files
npm/ - NPM package wrapper for distribution
```
## Architecture overview
Kit is built around a few key architectural patterns:
### Multi-provider LLM support
The `llm.Provider` interface abstracts different LLM providers. Each provider implements message formatting, tool calling, and streaming for its specific API.
### MCP client-server model
External tools are integrated via the Model Context Protocol (MCP). Kit acts as an MCP client, connecting to MCP servers configured in `.kit.yml`.
### Extension system
Extensions are Go source files interpreted at runtime by Yaegi. The `internal/extensions/` package manages loading, symbol export, and lifecycle dispatch. See the [Extension System](/extensions/overview) docs for details.
### TUI architecture
The interactive terminal UI is built with [Bubble Tea v2](https://github.com/charmbracelet/bubbletea), using a parent-child model where `AppModel` manages child components (`InputComponent`, `StreamComponent`, etc.).
### Decoupling pattern
`cmd/root.go` contains converter functions (e.g., `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types. The UI never imports the extensions package directly.
## Contributing
Contributions are welcome! Please see the [contribution guide](https://github.com/mark3labs/kit/blob/master/contribute/contribute.md) for guidelines.
## Community
- [Discord](https://discord.gg/RqSS2NQVsY)
- [GitHub Issues](https://github.com/mark3labs/kit/issues)
+283
View File
@@ -0,0 +1,283 @@
---
title: Capabilities
description: All extension capabilities — lifecycle events, tools, commands, widgets, and more.
---
# Extension Capabilities
## Lifecycle events
Extensions can hook into 18 lifecycle events:
| Event | Description |
|-------|-------------|
| `OnSessionStart` | Session initialized |
| `OnSessionShutdown` | Session ending |
| `OnBeforeAgentStart` | Before the agent loop begins |
| `OnAgentStart` | Agent loop started |
| `OnAgentEnd` | Agent loop completed |
| `OnToolCall` | Tool call requested by the model |
| `OnToolExecutionStart` | Tool execution beginning |
| `OnToolExecutionEnd` | Tool execution completed |
| `OnToolResult` | Tool result returned |
| `OnInput` | User input received |
| `OnMessageStart` | Assistant message started |
| `OnMessageUpdate` | Streaming text chunk received |
| `OnMessageEnd` | Assistant message completed |
| `OnModelChange` | Model switched |
| `OnContextPrepare` | Context being assembled for the model |
| `OnBeforeFork` | Before forking a conversation branch |
| `OnBeforeSessionSwitch` | Before switching sessions |
| `OnBeforeCompact` | Before conversation compaction |
### Example
```go
api.OnToolCall(func(event ext.ToolCallEvent, ctx ext.Context) {
ctx.PrintInfo("Calling tool: " + event.Name)
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
ctx.PrintInfo("Agent finished")
})
```
## Tools
Register custom tools that the LLM can invoke:
```go
api.RegisterTool(ext.ToolDef{
Name: "weather",
Description: "Get current weather for a location",
Parameters: map[string]ext.ParameterDef{
"city": {Type: "string", Description: "City name", Required: true},
},
Handler: func(ctx ext.Context, params map[string]any) (string, error) {
city := params["city"].(string)
return "Sunny, 72°F in " + city, nil
},
})
```
## Commands
Register slash commands that users can invoke directly:
```go
api.RegisterCommand(ext.CommandDef{
Name: "stats",
Description: "Show context statistics",
Handler: func(ctx ext.Context, args string) {
stats := ctx.GetContextStats()
ctx.PrintInfo(fmt.Sprintf("Tokens: %d", stats.TotalTokens))
},
})
```
## Widgets
Add persistent status displays above or below the input area:
```go
ctx.SetWidget(ext.WidgetConfig{
ID: "token-count",
Position: "bottom",
Content: ext.WidgetContent{Text: "Tokens: 1,234"},
})
// Update later
ctx.SetWidget(ext.WidgetConfig{
ID: "token-count",
Position: "bottom",
Content: ext.WidgetContent{Text: "Tokens: 2,456"},
})
// Remove
ctx.RemoveWidget("token-count")
```
## Headers and footers
Persistent content above and below the conversation:
```go
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Project: my-app | Branch: main"},
})
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Plan Mode (read-only)"},
})
```
## Status bar
Custom status bar entries:
```go
ctx.SetStatus("mode", "Planning")
ctx.RemoveStatus("mode")
```
## Shortcuts
Global keyboard shortcuts:
```go
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+t",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handle shortcut
})
```
## Overlays
Modal dialogs with markdown content:
```go
ctx.ShowOverlay(ext.OverlayConfig{
Title: "Help",
Content: "# Keyboard Shortcuts\n\n- **ctrl+t** — Toggle plan mode\n- **ctrl+s** — Save session",
})
```
## Tool renderers
Customize how specific tool calls are displayed in the TUI:
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
Render: func(name, args, result string, isError bool) string {
return "$ " + args + "\n" + result
},
})
```
## Message renderers
Custom rendering for assistant messages:
```go
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "custom",
Render: func(content string) string {
return ">> " + content
},
})
```
## Editor interceptors
Handle key events and wrap the editor's rendering:
```go
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
if key == "escape" {
return ext.EditorKeyAction{Handled: true}
}
return ext.EditorKeyAction{Handled: false}
},
})
```
## Interactive prompts
Select, confirm, input, and multi-select dialogs:
```go
// Single select
response := ctx.PromptSelect(ext.PromptSelectConfig{
Title: "Choose a model",
Options: []string{"claude-sonnet", "gpt-4o", "llama3"},
})
// Confirm
confirmed := ctx.PromptConfirm(ext.PromptConfirmConfig{
Title: "Delete this file?",
})
// Text input
name := ctx.PromptInput(ext.PromptInputConfig{
Title: "Enter project name",
Placeholder: "my-project",
})
```
## Options
Register configurable extension options:
```go
api.RegisterOption(ext.OptionDef{
Name: "auto-commit",
Description: "Automatically commit on shutdown",
DefaultValue: "false",
})
```
## Subagents
Spawn in-process child Kit instances:
```go
result := ctx.SpawnSubagent(ext.SubagentConfig{
Task: "Analyze the test files and summarize coverage",
Model: "anthropic/claude-haiku-3-5-20241022",
SystemPrompt: "You are a test analysis expert.",
})
```
## LLM completion
Make direct model calls without going through the agent loop:
```go
response := ctx.Complete(ext.CompleteRequest{
Prompt: "Summarize this in one sentence: " + content,
})
```
## Themes
Register and switch color themes at runtime:
```go
// Register a custom theme
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
})
// Switch to it
ctx.SetTheme("neon")
// List all available themes
names := ctx.ListThemes()
```
See [Themes](/themes) for the full theme file format, built-in themes, and color reference.
## Custom events
Inter-extension communication:
```go
// Emit
ctx.EmitCustomEvent("my-extension:data-ready", payload)
// Listen
api.OnCustomEvent("my-extension:data-ready", func(data any, ctx ext.Context) {
// handle event
})
```
+76
View File
@@ -0,0 +1,76 @@
---
title: Examples
description: Catalog of example extensions included with Kit.
---
# Extension Examples
Kit ships with a rich set of example extensions in the `examples/extensions/` directory. These serve as both documentation and starting points for your own extensions.
## UI and display
| Extension | Description |
|-----------|-------------|
| [`minimal.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/minimal.go) | Clean UI with custom footer |
| [`branded-output.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/branded-output.go) | Branded output rendering |
| [`header-footer-demo.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/header-footer-demo.go) | Custom headers and footers |
| [`widget-status.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/widget-status.go) | Persistent status widgets |
| [`overlay-demo.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/overlay-demo.go) | Modal dialogs |
| [`tool-renderer-demo.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/tool-renderer-demo.go) | Custom tool call rendering |
| [`custom-editor-demo.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/custom-editor-demo.go) | Vim-like modal editor |
| [`pirate.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/pirate.go) | Pirate-themed personality |
## Workflow and automation
| Extension | Description |
|-----------|-------------|
| [`auto-commit.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/auto-commit.go) | Auto-commit changes on shutdown |
| [`plan-mode.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/plan-mode.go) | Read-only planning mode |
| [`permission-gate.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/permission-gate.go) | Permission gating for destructive tools |
| [`confirm-destructive.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/confirm-destructive.go) | Confirm destructive operations |
| [`protected-paths.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/protected-paths.go) | Path protection for sensitive files |
| [`project-rules.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/project-rules.go) | Project-specific rules injection |
| [`compact-notify.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/compact-notify.go) | Notification on conversation compaction |
## Interactive features
| Extension | Description |
|-----------|-------------|
| [`prompt-demo.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/prompt-demo.go) | Interactive prompts (select/confirm/input) |
| [`bookmark.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/bookmark.go) | Bookmark conversations |
| [`inline-bash.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/inline-bash.go) | Inline bash execution |
| [`interactive-shell.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/interactive-shell.go) | Interactive shell integration |
| [`notify.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/notify.go) | Desktop notifications |
## Agent and context
| Extension | Description |
|-----------|-------------|
| [`tool-logger.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/tool-logger.go) | Log all tool calls |
| [`context-inject.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/context-inject.go) | Inject context into conversations |
| [`summarize.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/summarize.go) | Conversation summarization |
| [`lsp-diagnostics.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/lsp-diagnostics.go) | LSP diagnostic integration |
## Multi-agent
| Extension | Description |
|-----------|-------------|
| [`kit-kit.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/kit-kit.go) | Kit-in-Kit sub-agent spawning |
| [`subagent-widget.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/subagent-widget.go) | Multi-agent orchestration with status widget |
| [`subagent-test.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/subagent-test.go) | Subagent testing utilities |
## Development
| Extension | Description |
|-----------|-------------|
| [`dev-reload.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/dev-reload.go) | Development live-reload |
| [`tool-logger_test.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/tool-logger_test.go) | Example extension tests (see [Testing](/extensions/testing)) |
| [`extension_test_template.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/extension_test_template.go) | Copy-and-paste test template for your extensions |
## Subdirectory extensions
| Directory | Description |
|-----------|-------------|
| [`kit-kit-agents/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/kit-kit-agents) | Multi-agent orchestration example |
| [`kit-telegram/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/kit-telegram) | Telegram bot integration |
| [`status-tools/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/status-tools) | Status bar tool examples |
+119
View File
@@ -0,0 +1,119 @@
---
title: Loading Extensions
description: How Kit discovers and loads extensions.
---
# Loading Extensions
## Auto-discovery
Kit automatically discovers and loads extensions from these paths, in order:
| Path | Scope |
|------|-------|
| `~/.config/kit/extensions/*.go` | Global single files |
| `~/.config/kit/extensions/*/main.go` | Global subdirectory extensions |
| `.kit/extensions/*.go` | Project-local single files |
| `.kit/extensions/*/main.go` | Project-local subdirectory extensions |
| `~/.local/share/kit/git/` | Global git-installed packages |
| `.kit/git/` | Project-local git-installed packages |
## Explicit loading
Load extensions by path using the `-e` flag:
```bash
kit -e path/to/extension.go
```
Load multiple extensions:
```bash
kit -e ext1.go -e ext2.go
```
## Disabling extensions
Disable all auto-discovered extensions:
```bash
kit --no-extensions
```
You can combine `--no-extensions` with `-e` to load only specific extensions:
```bash
kit --no-extensions -e my-extension.go
```
## Installing from git
Install extensions from git repositories using `kit install`:
```bash
# Install globally (to ~/.local/share/kit/git/)
kit install https://github.com/user/my-kit-extension.git
# Install project-locally (to .kit/git/)
kit install -l https://github.com/user/my-kit-extension.git
# Update an installed package
kit install -u https://github.com/user/my-kit-extension.git
# Remove
kit install --uninstall my-kit-extension
```
## Extension structure
### Single-file extensions
A single `.go` file with an `Init` function:
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
// register handlers, tools, commands, etc.
}
```
The `//go:build ignore` directive prevents the Go toolchain from trying to compile the file as part of a normal build.
### Subdirectory extensions
For more complex extensions, create a directory with a `main.go` entry point:
```
.kit/extensions/my-extension/
├── main.go # Must contain Init(api ext.API)
├── helpers.go # Additional source files
└── config.go
```
### Package-level state
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
```go
package main
import "kit/ext"
var callCount int
func Init(api ext.API) {
api.OnToolCall(func(_ ext.ToolCallEvent, ctx ext.Context) {
callCount++
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{
Text: fmt.Sprintf("Tools called: %d", callCount),
},
})
})
}
```
+73
View File
@@ -0,0 +1,73 @@
---
title: Extension System
description: Overview of Kit's Go-based extension system.
---
# Extension System
Extensions are Go source files interpreted at runtime via [Yaegi](https://github.com/traefik/yaegi). They can add custom tools, slash commands, widgets, keyboard shortcuts, and intercept lifecycle events — all without recompiling Kit.
## Minimal extension
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Custom Footer"},
})
})
}
```
Run it with:
```bash
kit -e examples/extensions/minimal.go
```
## How extensions work
1. Kit discovers extension files from [auto-discovery paths](/extensions/loading) or explicit `-e` flags
2. Each `.go` file is loaded into a Yaegi interpreter with access to the `kit/ext` package
3. Kit calls the `Init(api ext.API)` function in each extension
4. The extension registers callbacks, tools, commands, and UI components via the `api` and `ctx` objects
## Key concepts
### The `API` object
Passed to `Init()`, the `API` object is used to register lifecycle event handlers and static components:
- **Lifecycle handlers**`api.OnSessionStart(...)`, `api.OnToolCall(...)`, etc.
- **Tools**`api.RegisterTool(ext.ToolDef{...})`
- **Commands**`api.RegisterCommand(ext.CommandDef{...})`
- **Shortcuts**`api.RegisterShortcut(ext.ShortcutDef{...}, handler)`
- **Tool renderers**`api.RegisterToolRenderer(ext.ToolRenderConfig{...})`
- **Message renderers**`api.RegisterMessageRenderer(ext.MessageRendererConfig{...})`
- **Options**`api.RegisterOption(ext.OptionDef{...})`
### The `Context` object
Passed to event handlers, the `Context` object provides runtime access to Kit's state and UI:
- **Output**`ctx.Print(...)`, `ctx.PrintInfo(...)`, `ctx.PrintError(...)`
- **UI components**`ctx.SetWidget(...)`, `ctx.SetHeader(...)`, `ctx.SetFooter(...)`, `ctx.SetStatus(...)`
- **Editor**`ctx.SetEditor(...)`, `ctx.ResetEditor()`
- **Prompts**`ctx.PromptSelect(...)`, `ctx.PromptConfirm(...)`, `ctx.PromptInput(...)`
- **Overlays**`ctx.ShowOverlay(...)`
- **Messages**`ctx.SendMessage(...)`, `ctx.GetMessages()`
- **Model**`ctx.SetModel(...)`, `ctx.GetAvailableModels()`
- **Tools**`ctx.GetAllTools()`, `ctx.SetActiveTools(...)`
- **Context stats**`ctx.GetContextStats()`
- **Session data**`ctx.AppendEntry(...)`, `ctx.GetEntries(...)`
- **Subagents**`ctx.SpawnSubagent(...)`
- **LLM completion**`ctx.Complete(...)`
- **Custom events**`ctx.EmitCustomEvent(...)`
See [Capabilities](/extensions/capabilities) for full details on each component type, and [Testing](/extensions/testing) for writing tests for your extensions.
+448
View File
@@ -0,0 +1,448 @@
---
title: Testing Extensions
description: Write unit tests for your Kit extensions using the test package.
---
# Testing Extensions
Kit provides a testing package (`github.com/mark3labs/kit/pkg/extensions/test`) that enables you to write unit tests for your extensions. Tests run outside the Yaegi interpreter but load your extension code into an isolated interpreter instance, allowing you to verify behavior without running the full Kit TUI.
## Overview
Extension tests allow you to:
- Test event handlers without running the interactive TUI
- Verify tool/command registration
- Assert that context methods (Print, SetWidget, etc.) are called correctly
- Test blocking and non-blocking event handling
- Simulate user input and tool calls
- Verify widget, header, footer, and status bar updates
## Installation
The test package is part of the Kit codebase. Import it in your extension tests:
```go
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
```
## Basic Usage
### Testing an Extension File
Create a test file alongside your extension (e.g., `my-ext_test.go`):
```go
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
func TestMyExtension(t *testing.T) {
// Create a test harness
harness := test.New(t)
// Load your extension
harness.LoadFile("my-ext.go")
// Emit events and check results
result, err := harness.Emit(extensions.ToolCallEvent{
ToolName: "my_tool",
Input: `{"key": "value"}`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Use assertion helpers
test.AssertNotBlocked(t, result)
test.AssertPrinted(t, harness, "expected output")
}
```
### Testing Inline Extension Code
For quick tests or edge cases, you can load extension source directly:
```go
func TestToolBlocking(t *testing.T) {
src := `package main
import "kit/ext"
func Init(api ext.API) {
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "dangerous" {
return &ext.ToolCallResult{Block: true, Reason: "not allowed"}
}
return nil
})
}
`
harness := test.New(t)
harness.LoadString(src, "test-ext.go")
// Test the tool is blocked
result, _ := harness.Emit(extensions.ToolCallEvent{
ToolName: "dangerous",
Input: "{}",
})
test.AssertBlocked(t, result, "not allowed")
}
```
## Common Testing Patterns
### Testing Handler Registration
Verify your extension registers the expected handlers:
```go
func TestHandlers(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
test.AssertHasHandlers(t, harness, extensions.ToolCall)
test.AssertHasHandlers(t, harness, extensions.SessionStart)
test.AssertNoHandlers(t, harness, extensions.AgentEnd) // Verify no unexpected handlers
}
```
### Testing Tool Registration
```go
func TestTools(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Verify a specific tool is registered
test.AssertToolRegistered(t, harness, "my_tool")
// Or inspect all tools
tools := harness.RegisteredTools()
for _, tool := range tools {
t.Logf("Tool: %s - %s", tool.Name, tool.Description)
}
}
```
### Testing Commands
```go
func TestCommands(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
test.AssertCommandRegistered(t, harness, "mycommand")
}
```
### Testing Widgets
```go
func TestWidgets(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Trigger event that creates the widget
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
// Verify widget was set
test.AssertWidgetSet(t, harness, "my-widget")
test.AssertWidgetText(t, harness, "my-widget", "Expected Text")
test.AssertWidgetTextContains(t, harness, "my-widget", "partial")
// Check widget properties directly
widget, ok := harness.Context().GetWidget("my-widget")
if ok {
t.Logf("Border color: %s", widget.Style.BorderColor)
}
}
```
### Testing Input Handling
```go
func TestInput(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
result, _ := harness.Emit(extensions.InputEvent{
Text: "!mycommand",
Source: "cli",
})
test.AssertInputHandled(t, result, "handled")
}
```
### Testing Headers and Footers
```go
func TestHeaderFooter(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
test.AssertHeaderSet(t, harness)
test.AssertFooterSet(t, harness)
// Inspect content
header := harness.Context().GetHeader()
if header != nil {
t.Logf("Header text: %s", header.Content.Text)
}
}
```
### Testing Status Bar
```go
func TestStatus(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
_, _ = harness.Emit(extensions.AgentEndEvent{})
test.AssertStatusSet(t, harness, "myext:status")
test.AssertStatusText(t, harness, "myext:status", "Ready")
}
```
### Testing Print Output
```go
func TestOutput(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
_, _ = harness.Emit(extensions.ToolCallEvent{ToolName: "test"})
// Exact match
test.AssertPrinted(t, harness, "exact output")
// Partial match
test.AssertPrintedContains(t, harness, "partial")
// Styled output
test.AssertPrintInfo(t, harness, "info message")
test.AssertPrintError(t, harness, "error message")
}
```
### Testing with Prompts
Configure mock prompt results for testing interactive behavior:
```go
func TestWithPrompts(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Configure what prompts should return
harness.Context().SetPromptSelectResult(extensions.PromptSelectResult{
Value: "option1",
Index: 0,
Cancelled: false,
})
harness.Context().SetPromptConfirmResult(extensions.PromptConfirmResult{
Value: true,
Cancelled: false,
})
// Now when your extension calls ctx.PromptSelect(), it gets this result
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
}
```
### Testing Complete Session Flow
```go
func TestFullSession(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Simulate a complete session
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
_, _ = harness.Emit(extensions.BeforeAgentStartEvent{})
_, _ = harness.Emit(extensions.AgentStartEvent{})
// Multiple tool calls
tools := []string{"Read", "Grep", "Bash"}
for _, tool := range tools {
_, _ = harness.Emit(extensions.ToolCallEvent{ToolName: tool})
_, _ = harness.Emit(extensions.ToolResultEvent{ToolName: tool})
}
_, _ = harness.Emit(extensions.AgentEndEvent{})
_, _ = harness.Emit(extensions.SessionShutdownEvent{})
// Verify final state
test.AssertWidgetTextContains(t, harness, "status", "Complete")
}
```
## Available Assertions
The test package provides these assertion helpers:
### Event Results
| Function | Description |
|----------|-------------|
| `AssertNotBlocked(t, result)` | Verify tool was not blocked |
| `AssertBlocked(t, result, reason)` | Verify tool was blocked with reason |
| `AssertInputHandled(t, result, action)` | Verify input was handled |
| `AssertInputTransformed(t, result, text)` | Verify input was transformed |
### Context Interactions
| Function | Description |
|----------|-------------|
| `AssertPrinted(t, harness, text)` | Verify exact print output |
| `AssertPrintedContains(t, harness, substring)` | Verify partial print output |
| `AssertPrintInfo(t, harness, text)` | Verify PrintInfo was called |
| `AssertPrintError(t, harness, text)` | Verify PrintError was called |
| `AssertWidgetSet(t, harness, id)` | Verify widget was set |
| `AssertWidgetNotSet(t, harness, id)` | Verify widget was not set |
| `AssertWidgetText(t, harness, id, text)` | Verify widget content |
| `AssertWidgetTextContains(t, harness, id, substring)` | Verify widget contains text |
| `AssertHeaderSet(t, harness)` | Verify header was set |
| `AssertFooterSet(t, harness)` | Verify footer was set |
| `AssertStatusSet(t, harness, key)` | Verify status was set |
| `AssertStatusText(t, harness, key, text)` | Verify status text |
### Registration
| Function | Description |
|----------|-------------|
| `AssertToolRegistered(t, harness, name)` | Verify tool registration |
| `AssertCommandRegistered(t, harness, name)` | Verify command registration |
| `AssertHasHandlers(t, harness, eventType)` | Verify handlers exist |
| `AssertNoHandlers(t, harness, eventType)` | Verify no handlers |
### Messaging
| Function | Description |
|----------|-------------|
| `AssertMessageSent(t, harness, text)` | Verify SendMessage was called |
| `AssertCancelAndSend(t, harness, text)` | Verify CancelAndSend was called |
## Helper Functions
For custom assertions, extract result details:
```go
result, _ := harness.Emit(extensions.ToolCallEvent{...})
tcr := test.GetToolCallResult(result)
if tcr != nil {
t.Logf("Block: %v, Reason: %s", tcr.Block, tcr.Reason)
}
ir := test.GetInputResult(result)
trr := test.GetToolResultResult(result)
```
## Advanced Usage
### Accessing the Mock Context
For custom verification:
```go
ctx := harness.Context()
// Get all recorded prints
prints := ctx.GetPrints()
// Check options
value := ctx.GetOption("my-option")
// Verify widget properties
widget, ok := ctx.GetWidget("my-widget")
if ok && widget.Style.BorderColor == "#ff0000" {
t.Log("Widget has red border")
}
// Check status entries
status, ok := ctx.GetStatus("myext:status")
```
### Testing Multiple Extensions
Each harness is isolated:
```go
harness1 := test.New(t)
harness1.LoadFile("ext1.go")
harness2 := test.New(t)
harness2.LoadFile("ext2.go")
// Events to one don't affect the other
```
### Running Tests
Run all tests in your extension directory:
```bash
cd examples/extensions
go test -v
```
Run with race detector:
```bash
go test -race -v
```
Run a specific test:
```bash
go test -v -run TestMyExtension
```
## Best Practices
1. **Test one behavior per test** — Keep tests focused and readable
2. **Use inline source for edge cases**`LoadString()` is great for testing specific scenarios
3. **Use `LoadFile()` for integration tests** — Tests the actual extension file
4. **Assert on context calls** — Verify your extension interacts with the context correctly
5. **Test both positive and negative cases** — Verify tools are blocked AND allowed appropriately
6. **Test all event handlers** — Make sure all registered handlers work correctly
7. **Use descriptive test names**`TestExtension_BlocksDangerousTools` is clearer than `Test1`
## Limitations
The test harness has these intentional limitations:
- **No TUI rendering** — Widgets are recorded but not rendered visually
- **Prompts return configured values** — Pre-configure prompt results in tests
- **Subagents don't spawn real processes**`SpawnSubagent()` returns nil/empty results
- **LLM completions are mocked**`Complete()` returns empty responses
- **Some context methods are no-ops**`Exit()`, `SetActiveTools()`, etc. don't have side effects
These limitations focus testing on extension logic rather than the full Kit runtime.
## Complete Example
See `examples/extensions/tool-logger_test.go` for a complete example with 14 tests covering:
- Handler registration
- Tool call and result handling
- Session lifecycle events
- Input commands (`!time`, `!status`)
- Unknown command handling
- Concurrent operations (race condition check)
- Real file logging verification
+33
View File
@@ -0,0 +1,33 @@
---
title: Kit
description: Kit is a powerful, extensible AI coding agent CLI with multi-provider support, built-in tools, and a rich extension system.
toc: false
---
<div style="text-align: center; margin: 2rem 0;">
<img src="/logo.jpg" alt="KIT" style="max-width: 400px; width: 100%; margin: 0 auto; display: block;" />
</div>
A powerful, extensible AI coding agent CLI with multi-provider support, built-in tools, and a rich extension system.
## Features
- **Multi-Provider LLM Support** — Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools** — bash, read, write, edit, grep, find, ls, spawn_subagent with no MCP overhead
- **MCP Integration** — Connect external MCP servers for expanded capabilities
- **Extension System** — Write custom tools, commands, widgets, and UI modifications in Go
- **Interactive TUI** — Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management** — Tree-based conversation history with branching support
- **Non-Interactive Mode** — Script-friendly positional args with JSON output
- **ACP Server** — Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK** — Embed Kit in your own applications
## Quick links
| Resource | Description |
|----------|-------------|
| [Installation](/installation) | Get Kit up and running |
| [Quick Start](/quick-start) | Your first Kit session |
| [Configuration](/configuration) | Customize Kit for your workflow |
| [Extensions](/extensions/overview) | Build custom tools and UI components |
| [Go SDK](/sdk/overview) | Embed Kit in your applications |
+65
View File
@@ -0,0 +1,65 @@
---
title: Installation
description: Install Kit using npm, bun, pnpm, Go, or build from source.
---
# Installation
## Using npm / bun / pnpm
```bash
npm install -g @mark3labs/kit
```
```bash
bun install -g @mark3labs/kit
```
```bash
pnpm install -g @mark3labs/kit
```
## Using Go
```bash
go install github.com/mark3labs/kit/cmd/kit@latest
```
## Building from source
```bash
git clone https://github.com/mark3labs/kit.git
cd kit
go build -o kit ./cmd/kit
```
## Verifying the installation
After installing, verify Kit is available:
```bash
kit --help
```
## Setting up a provider
Kit needs at least one LLM provider configured. Set an API key for your preferred provider:
```bash
# Anthropic (default provider)
export ANTHROPIC_API_KEY="sk-..."
# OpenAI
export OPENAI_API_KEY="sk-..."
# Google Gemini
export GOOGLE_API_KEY="..."
```
For OAuth-enabled providers like Anthropic, you can also authenticate interactively:
```bash
kit auth login anthropic
```
See [Providers](/providers) for the full list of supported providers and their configuration.
+117
View File
@@ -0,0 +1,117 @@
---
title: Providers
description: Supported LLM providers and model configuration.
---
# Providers
Kit supports a wide range of LLM providers through a unified `provider/model` string format.
## Supported providers
| Provider | Prefix | Description |
|----------|--------|-------------|
| **Anthropic** | `anthropic/` | Claude models (native, prompt caching, OAuth) |
| **OpenAI** | `openai/` | GPT models |
| **Google** | `google/` or `gemini/` | Gemini models |
| **Ollama** | `ollama/` | Local models |
| **Azure OpenAI** | `azure/` | Azure-hosted OpenAI |
| **AWS Bedrock** | `bedrock/` | Bedrock models |
| **Google Vertex** | `google-vertex-anthropic/` | Claude on Vertex AI |
| **OpenRouter** | `openrouter/` | Multi-provider router |
| **Vercel AI** | `vercel/` | Vercel AI SDK models |
| **Auto-routed** | any | Any provider from the models.dev database |
## Model string format
```bash
provider/model # Standard format
anthropic/claude-sonnet-4-5-20250929
openai/gpt-4o
ollama/llama3
google/gemini-2.0-flash-exp
```
## Model aliases
Kit provides aliases for commonly used models:
```bash
claude-opus-latest → claude-opus-4-20250514
claude-sonnet-latest → claude-sonnet-4-5-20250929
claude-4-opus-latest → claude-opus-4-20250514
claude-4-sonnet-latest → claude-sonnet-4-5-20250929
claude-3-7-sonnet-latest → claude-3-7-sonnet-20250219
claude-3-5-sonnet-latest → claude-3-5-sonnet-20241022
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
claude-3-opus-latest → claude-3-opus-20240229
```
## Specifying a model
Via CLI flag:
```bash
kit --model openai/gpt-4o
kit -m ollama/llama3
```
Via config file:
```yaml
model: anthropic/claude-sonnet-4-5-20250929
```
Via environment variable:
```bash
export KIT_MODEL="google/gemini-2.0-flash-exp"
```
## Authentication
### API keys
Set the appropriate environment variable for your provider:
```bash
export ANTHROPIC_API_KEY="sk-..."
export OPENAI_API_KEY="sk-..."
export GOOGLE_API_KEY="..."
```
Or pass it directly:
```bash
kit --provider-api-key "sk-..." --model openai/gpt-4o
```
### OAuth
For providers that support OAuth (e.g., Anthropic):
```bash
kit auth login anthropic # Start OAuth flow
kit auth status # Check authentication status
kit auth logout anthropic # Remove credentials
```
### Custom provider URL
For self-hosted or proxy endpoints:
```bash
kit --provider-url "https://my-proxy.example.com/v1" --model openai/gpt-4o
```
## Model database
Kit ships with a local model database that maps provider names to API configurations. You can manage it with:
```bash
kit models # List available models
kit models openai # Filter by provider
kit models --all # Show all providers
kit update-models # Update from models.dev
kit update-models embedded # Reset to bundled database
```
+86
View File
@@ -0,0 +1,86 @@
---
title: Quick Start
description: Get up and running with Kit in minutes.
---
# Quick Start
## Basic usage
Start an interactive session:
```bash
kit
```
Run a one-off prompt:
```bash
kit "List files in src/"
```
Attach files as context using the `@` prefix:
```bash
kit @main.go @test.go "Review these files"
```
Use a specific model:
```bash
kit --model anthropic/claude-sonnet-4-5-20250929
```
## Non-interactive mode
Kit can run as a non-interactive tool for scripting and automation.
Get JSON output:
```bash
kit "Explain main.go" --json
```
Quiet mode (final response only, no TUI):
```bash
kit "Run tests" --quiet
```
Ephemeral mode (no session file created):
```bash
kit "Quick question" --no-session
```
## Resuming sessions
Continue the most recent session for the current directory:
```bash
kit --continue
# or
kit -c
```
Pick from previous sessions interactively:
```bash
kit --resume
# or
kit -r
```
## ACP server mode
Kit can run as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server, enabling ACP-compatible clients (such as [OpenCode](https://github.com/sst/opencode)) to drive Kit as a remote coding agent over stdio:
```bash
# Start Kit as an ACP server (JSON-RPC 2.0 on stdin/stdout)
kit acp
# With debug logging to stderr
kit acp --debug
```
The ACP server exposes Kit's full capabilities — LLM execution, tool calls (bash, read, write, edit, grep, etc.), and session persistence — over the standard ACP protocol.
+115
View File
@@ -0,0 +1,115 @@
---
title: Callbacks
description: Monitor tool calls and streaming output with the Kit Go SDK.
---
# Callbacks
## PromptWithCallbacks
The `PromptWithCallbacks` method provides real-time visibility into tool calls and streaming output:
```go
response, err := host.PromptWithCallbacks(
ctx,
"List files in current directory",
func(name, args string) {
// Called when the model invokes a tool
fmt.Println("Calling tool:", name)
},
func(name, args, result string, isError bool) {
// Called when a tool returns its result
if isError {
fmt.Println("Tool failed:", name)
}
},
func(chunk string) {
// Called for each streaming text chunk
fmt.Print(chunk)
},
)
```
### Callback signatures
| Callback | Signature | When |
|----------|-----------|------|
| `onToolCall` | `func(name, args string)` | Model requests a tool call |
| `onToolResult` | `func(name, args, result string, isError bool)` | Tool execution completes |
| `onStreaming` | `func(chunk string)` | Streaming text chunk received |
Any callback can be `nil` if you don't need it:
```go
// Only care about streaming output
response, err := host.PromptWithCallbacks(ctx, "Hello", nil, nil, func(chunk string) {
fmt.Print(chunk)
})
```
## Event-based monitoring
For more granular control, use the event subscription API:
```go
// Subscribe returns an unsubscribe function
unsub := host.OnToolCall(func(event kit.ToolCallEvent) {
fmt.Printf("Tool: %s, Args: %s\n", event.Name, event.Args)
})
defer unsub()
unsub2 := host.OnToolResult(func(event kit.ToolResultEvent) {
fmt.Printf("Result: %s (error: %v)\n", event.Name, event.IsError)
})
defer unsub2()
unsub3 := host.OnStreaming(func(event kit.MessageUpdateEvent) {
fmt.Print(event.Chunk)
})
defer unsub3()
unsub4 := host.OnResponse(func(event kit.ResponseEvent) {
fmt.Println("Final response received")
})
defer unsub4()
unsub5 := host.OnTurnStart(func(event kit.TurnStartEvent) {
fmt.Println("Turn started")
})
defer unsub5()
unsub6 := host.OnTurnEnd(func(event kit.TurnEndEvent) {
fmt.Println("Turn ended")
})
defer unsub6()
```
## Hook system
Hooks allow you to intercept and modify behavior. Unlike events, hooks can modify or cancel operations:
```go
// Intercept tool calls before execution
host.OnBeforeToolCall(0, func(ctx context.Context, name string, args string) (string, error) {
if name == "bash" {
log.Println("Bash command:", args)
}
return args, nil // return modified args or error to cancel
})
// Process results after tool execution
host.OnAfterToolResult(0, func(ctx context.Context, name string, result string) (string, error) {
return result, nil
})
// Before/after each agent turn
host.OnBeforeTurn(0, func(ctx context.Context) error {
return nil
})
host.OnAfterTurn(0, func(ctx context.Context) error {
return nil
})
```
The first argument is a priority (lower = runs first).
+64
View File
@@ -0,0 +1,64 @@
---
title: SDK Options
description: Configuration options for the Kit Go SDK.
---
# SDK Options
Pass an `Options` struct to `kit.New()` to configure the Kit instance.
## Full options reference
```go
host, err := kit.New(ctx, &kit.Options{
// Model
Model: "ollama/llama3",
SystemPrompt: "You are a helpful bot",
ConfigFile: "/path/to/config.yml",
// Behavior
MaxSteps: 10,
Streaming: true,
Quiet: true,
Debug: true,
// Session
SessionPath: "./session.jsonl",
SessionDir: "/custom/sessions/",
Continue: true,
NoSession: true,
// Tools
Tools: []kit.Tool{...}, // Replace default tool set entirely
ExtraTools: []kit.Tool{...}, // Add tools alongside defaults
// Compaction
AutoCompact: true,
// Skills
Skills: []string{"/path/to/skill.md"},
SkillsDir: "/path/to/skills/",
})
```
## Options fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `Model` | `string` | config default | Model string (provider/model format) |
| `SystemPrompt` | `string` | — | System prompt text or file path |
| `ConfigFile` | `string` | `~/.kit.yml` | Path to config file |
| `MaxSteps` | `int` | `0` | Max agent steps (0 = unlimited) |
| `Streaming` | `bool` | `true` | Enable streaming output |
| `Quiet` | `bool` | `false` | Suppress output |
| `Debug` | `bool` | `false` | Enable debug logging |
| `SessionPath` | `string` | — | Open a specific session file |
| `SessionDir` | `string` | — | Base directory for session discovery |
| `Continue` | `bool` | `false` | Resume most recent session |
| `NoSession` | `bool` | `false` | Ephemeral mode (no persistence) |
| `Tools` | `[]Tool` | — | Replace the entire default tool set |
| `ExtraTools` | `[]Tool` | — | Additional tools alongside core/MCP/extension tools |
| `AutoCompact` | `bool` | `false` | Auto-compact when near context limit |
| `CompactionOptions` | `*CompactionOptions` | — | Configuration for auto-compaction |
| `Skills` | `[]string` | — | Explicit skill files/dirs to load |
| `SkillsDir` | `string` | — | Override default skills directory |
+114
View File
@@ -0,0 +1,114 @@
---
title: Go SDK
description: Embed Kit in your Go applications.
---
# Go SDK
The `pkg/kit` package lets you embed Kit as a library in your Go applications.
## Installation
```bash
go get github.com/mark3labs/kit/pkg/kit
```
## Basic usage
```go
package main
import (
"context"
"log"
kit "github.com/mark3labs/kit/pkg/kit"
)
func main() {
ctx := context.Background()
// Create Kit instance with default configuration
host, err := kit.New(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer host.Close()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
if err != nil {
log.Fatal(err)
}
println(response)
}
```
## Multi-turn conversations
Conversations retain context automatically across calls:
```go
host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
// response: "Your name is Alice"
```
## Additional prompt methods
The SDK provides several prompt variants:
| Method | Description |
|--------|-------------|
| `Prompt(ctx, message)` | Simple prompt, returns response string |
| `PromptWithCallbacks(ctx, message, ...)` | With tool call and streaming callbacks |
| `PromptWithOptions(ctx, message, opts)` | With per-call options |
| `PromptResult(ctx, message)` | Returns full `TurnResult` with usage stats |
| `PromptResultWithFiles(ctx, message, files)` | Multimodal with file attachments |
| `Steer(ctx, instruction)` | System-level steering without user message |
| `FollowUp(ctx, text)` | Continue without new user input |
## Event system
Subscribe to events for monitoring:
```go
unsubscribe := host.OnToolCall(func(event kit.ToolCallEvent) {
fmt.Println("Tool called:", event.Name)
})
defer unsubscribe()
host.OnToolResult(func(event kit.ToolResultEvent) {
fmt.Println("Tool result:", event.Name)
})
host.OnStreaming(func(event kit.MessageUpdateEvent) {
fmt.Print(event.Chunk)
})
```
## Model management
Switch models at runtime:
```go
host.SetModel(ctx, "openai/gpt-4o")
info := host.GetModelInfo()
models := host.GetAvailableModels()
```
## Context and compaction
Monitor and manage context usage:
```go
tokens := host.EstimateContextTokens()
stats := host.GetContextStats()
if host.ShouldCompact() {
result, err := host.Compact(ctx, nil, "")
}
```
See [Options](/sdk/options), [Callbacks](/sdk/callbacks), and [Sessions](/sdk/sessions) for more details.
+90
View File
@@ -0,0 +1,90 @@
---
title: SDK Sessions
description: Session management in the Kit Go SDK.
---
# SDK Sessions
## Automatic persistence
By default, Kit automatically persists sessions to JSONL files. Multi-turn conversations retain context across calls:
```go
host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
// response: "Your name is Alice"
```
## Accessing session info
```go
// Get the current session file path
path := host.GetSessionPath()
// Get the session ID
id := host.GetSessionID()
// Get the current model string
model := host.GetModelString()
```
## Configuring sessions via Options
Session behavior is configured at initialization:
```go
// Open a specific session file
host, _ := kit.New(ctx, &kit.Options{
SessionPath: "./my-session.jsonl",
})
// Resume the most recent session for the current directory
host, _ := kit.New(ctx, &kit.Options{
Continue: true,
})
// Ephemeral mode (no file persistence)
host, _ := kit.New(ctx, &kit.Options{
NoSession: true,
})
// Custom session directory
host, _ := kit.New(ctx, &kit.Options{
SessionDir: "/custom/sessions/",
})
```
## Clearing history
Clear the in-memory conversation history (does not delete the session file):
```go
host.ClearSession()
```
## Tree-based sessions
Kit's session model is tree-based, supporting branching. You can branch from any entry to explore alternate conversation paths:
```go
// Access the tree session manager
ts := host.GetTreeSession()
// Branch from a specific entry
err := host.Branch("entry-id-123")
```
## Listing and managing sessions
Package-level functions for session discovery:
```go
// List sessions for a specific directory
sessions := kit.ListSessions("/home/user/project")
// List all sessions across all directories
all := kit.ListAllSessions()
// Delete a session file
kit.DeleteSession("/path/to/session.jsonl")
```
+57
View File
@@ -0,0 +1,57 @@
---
title: Session Management
description: How Kit persists and manages conversation sessions.
---
# Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
## Session storage
Sessions are stored as JSONL (JSON Lines) files:
```
~/.kit/sessions/<cwd-path>/<timestamp>_<id>.jsonl
```
Path separators in the working directory are replaced with `--`. For example, `/home/user/project` becomes `home--user--project`.
Each line in the session file is a JSON entry representing a message, tool call, model change, or extension data. The tree structure allows branching from any message to explore alternate paths.
## Resuming sessions
### Continue most recent
Resume the most recent session for the current directory:
```bash
kit --continue
kit -c
```
### Interactive picker
Choose from previous sessions interactively:
```bash
kit --resume
kit -r
```
### Open a specific session
```bash
kit --session path/to/session.jsonl
kit -s path/to/session.jsonl
```
## Ephemeral mode
Run without creating a session file:
```bash
kit --no-session
```
This is useful for one-off prompts, scripting, and subagent patterns where persistence isn't needed.
+279
View File
@@ -0,0 +1,279 @@
---
title: Themes
description: Customize Kit's appearance with built-in themes, custom theme files, and the extension theme API.
---
# Themes
Kit ships with 22 built-in color themes and supports custom themes via YAML/JSON files or the extension API. Themes control all UI colors: input borders, popups, system messages, markdown rendering, syntax highlighting, and diff displays.
## Quick start
Switch themes at runtime with the `/theme` command:
```
/theme dracula
/theme catppuccin
/theme kitt
```
Run `/theme` with no arguments to list all available themes.
## Built-in themes
| Theme | Style |
|-------|-------|
| `kitt` | KITT-inspired reds and ambers (default) |
| `catppuccin` | Soothing pastels (Mocha/Latte) |
| `dracula` | Purple and cyan dark theme |
| `tokyonight` | Cool blues with warm accents |
| `nord` | Arctic, north-bluish palette |
| `gruvbox` | Retro groove colors |
| `monokai` | Classic syntax theme |
| `solarized` | Precision colors for machines and people |
| `github` | GitHub's light and dark palettes |
| `one-dark` | Atom One Dark |
| `rose-pine` | Soho vibes with muted tones |
| `ayu` | Simple with bright colors |
| `material` | Material Design palette |
| `everforest` | Green-focused comfortable theme |
| `kanagawa` | Dark theme inspired by Katsushika Hokusai |
| `amoled` | Pure black background, vivid accents |
| `synthwave` | Retro neon glows |
| `vesper` | Warm minimalist dark theme |
| `flexoki` | Inky reading palette |
| `matrix` | Green-on-black terminal aesthetic |
| `vercel` | Clean monochrome with blue accents |
| `zenburn` | Low-contrast, warm dark theme |
All themes support both light and dark terminal modes via adaptive colors.
## Custom theme files
Create a `.yml`, `.yaml`, or `.json` file with color definitions. Kit discovers themes from two directories:
| Location | Scope | Precedence |
|----------|-------|------------|
| `~/.config/kit/themes/` | User (global) | Overrides built-ins |
| `.kit/themes/` | Project-local | Overrides user and built-ins |
### Theme file format
A theme file defines adaptive color pairs with `light` and `dark` hex values. Any field left empty inherits from the default KITT theme.
```yaml
# ~/.config/kit/themes/my-theme.yml
# Core semantic colors
primary:
light: "#8839ef"
dark: "#cba6f7"
secondary:
light: "#04a5e5"
dark: "#89dceb"
success:
light: "#40a02b"
dark: "#a6e3a1"
warning:
light: "#df8e1d"
dark: "#f9e2af"
error:
light: "#d20f39"
dark: "#f38ba8"
info:
light: "#1e66f5"
dark: "#89b4fa"
# Text and chrome
text:
light: "#4c4f69"
dark: "#cdd6f4"
muted:
light: "#6c6f85"
dark: "#a6adc8"
very-muted:
light: "#9ca0b0"
dark: "#6c7086"
background:
light: "#eff1f5"
dark: "#1e1e2e"
border:
light: "#acb0be"
dark: "#585b70"
muted-border:
light: "#ccd0da"
dark: "#313244"
# Semantic roles
system:
light: "#179299"
dark: "#94e2d5"
tool:
light: "#fe640b"
dark: "#fab387"
accent:
light: "#ea76cb"
dark: "#f5c2e7"
highlight:
light: "#e6e9ef"
dark: "#181825"
# Diff backgrounds
diff-insert-bg:
light: "#d5f0d5"
dark: "#1a3a2a"
diff-delete-bg:
light: "#f5d5d5"
dark: "#3a1a2a"
diff-equal-bg:
light: "#eceef3"
dark: "#232336"
diff-missing-bg:
light: "#e4e6eb"
dark: "#1a1a2e"
# Code block backgrounds
code-bg:
light: "#eceef3"
dark: "#232336"
gutter-bg:
light: "#e4e6eb"
dark: "#1a1a2e"
write-bg:
light: "#d5f0d5"
dark: "#1a3a2a"
# Markdown and syntax highlighting
markdown:
heading:
light: "#1e66f5"
dark: "#89b4fa"
link:
light: "#1e66f5"
dark: "#89b4fa"
keyword:
light: "#8839ef"
dark: "#cba6f7"
string:
light: "#40a02b"
dark: "#a6e3a1"
number:
light: "#fe640b"
dark: "#fab387"
comment:
light: "#9ca0b0"
dark: "#6c7086"
```
### Partial themes
You only need to define the colors you want to change. Unspecified fields fall back to the default theme:
```yaml
# Just override the primary and accent colors
primary:
dark: "#FF00FF"
accent:
dark: "#00FFFF"
```
### Distributing themes
- **Personal**: Drop a file in `~/.config/kit/themes/`
- **Team/project**: Drop a file in `.kit/themes/` and commit it to version control
- **Override built-in**: Name your file the same as a built-in (e.g., `dracula.yml`) and it takes precedence
## Config file theme
You can also set theme colors directly in `.kit.yml`:
```yaml
theme:
primary:
light: "#8839ef"
dark: "#cba6f7"
error:
dark: "#FF0000"
```
Or reference an external theme file:
```yaml
theme: "./themes/my-custom-theme.yml"
```
## Extension theme API
Extensions can register and switch themes programmatically at runtime.
### Registering a theme
```go
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
})
})
```
### Switching themes
```go
err := ctx.SetTheme("dracula")
```
### Listing available themes
```go
names := ctx.ListThemes()
```
### ThemeColorConfig fields
| Field | Description |
|-------|-------------|
| `Primary` | Main brand/accent color |
| `Secondary` | Secondary accent |
| `Success` | Success states |
| `Warning` | Warning states |
| `Error` | Error/critical states |
| `Info` | Informational states |
| `Text` | Primary text |
| `Muted` | Dimmed text |
| `VeryMuted` | Very dimmed text |
| `Background` | Base background |
| `Border` | Panel borders |
| `MutedBorder` | Subtle dividers |
| `System` | System messages |
| `Tool` | Tool-related elements |
| `Accent` | Secondary highlight |
| `Highlight` | Highlighted regions |
| `MdHeading` | Markdown headings |
| `MdLink` | Markdown links |
| `MdKeyword` | Syntax: keywords |
| `MdString` | Syntax: strings |
| `MdNumber` | Syntax: numbers |
| `MdComment` | Syntax: comments |
Each field is an `ext.ThemeColor` with `Light` and `Dark` hex strings. Empty fields inherit from the default theme.
## Precedence order
When multiple sources define the same theme name, later sources win:
1. Built-in presets (lowest)
2. User themes (`~/.config/kit/themes/`)
3. Project-local themes (`.kit/themes/`)
4. Extension-registered themes (highest)
Theme changes via `/theme` or `ctx.SetTheme()` take effect immediately on all UI elements, including previously rendered messages.
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

+30
View File
@@ -0,0 +1,30 @@
:root,
:root[data-theme="dark"],
:root[data-theme="light"],
html,
html[data-theme="dark"],
#tome-root,
#tome-root *,
[data-theme],
body,
div {
--bg: #08080a !important;
--sf: #0e0e12 !important;
--sfH: #141418 !important;
--bd: #1a1a22 !important;
--tx: #e8e0e0 !important;
--tx2: #8a8090 !important;
--txM: #6a6070 !important;
--ac: #e03030 !important;
--acD: rgba(224, 48, 48, 0.12) !important;
--acT: #ff4444 !important;
--cdBg: #0a0a0e !important;
--cdTx: #c8a0a0 !important;
--sbBg: #0a0a0d !important;
--hdBg: rgba(8, 8, 10, 0.92) !important;
}
h1, h2, h3, h4, h5, h6 {
color: #e8e0e0 !important;
font-style: normal !important;
}

Some files were not shown because too many files have changed in this diff Show More