mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93895392e6 | |||
| 473070e78b | |||
| 12268a777f | |||
| 351c10d814 | |||
| 9de3843605 | |||
| 1d5473e111 | |||
| b6adcf159e | |||
| b1da4a28e6 | |||
| 95abb6fa6e | |||
| a9970cf346 | |||
| 13060a20f9 | |||
| adf603e944 | |||
| af486133a5 | |||
| a97cd47ced | |||
| 68518a2bdb | |||
| fd61db3e12 | |||
| e49066a119 | |||
| efaff7f44f | |||
| d3c970b607 | |||
| 23254fee64 | |||
| fe072ad2e1 | |||
| 8840cbfabc | |||
| a11b41cda4 | |||
| 8b7be8b735 | |||
| caa6d1c178 | |||
| 001156053d | |||
| 54717e32bc | |||
| 5b214b9fdf | |||
| c5e6ca6e4d | |||
| 419a139137 | |||
| 7b963624c1 | |||
| 66f2ba543b | |||
| 6dd052b990 | |||
| ef8628eecc | |||
| 3167222b72 | |||
| e3b37191b1 |
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -6,9 +6,10 @@ aidocs/
|
||||
*.log
|
||||
/kit
|
||||
.idea
|
||||
test/
|
||||
build/
|
||||
dist/
|
||||
contribute/output/
|
||||
CONTEXT.md
|
||||
output/
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
@@ -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/internal/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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
@@ -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/internal/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
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,7 +41,7 @@ func ReadImage() (*ImageData, error) {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
return nil, ErrNoClipboardTool
|
||||
return nil, errNoClipboardTool
|
||||
}
|
||||
|
||||
// readWithXclip reads image data using xclip.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
# Testing Kit Extensions
|
||||
|
||||
The `github.com/mark3labs/kit/internal/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/internal/extensions/test"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Testing an Extension File
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/mark3labs/kit/internal/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/internal/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")
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/internal/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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, " "))
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/internal/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
|
||||
- [`internal/extensions/test/`](https://github.com/mark3labs/kit/tree/main/internal/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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
// Bootstraps the Tome documentation shell.
|
||||
// Configure your site in tome.config.js instead.
|
||||
import "@tomehq/theme/entry";
|
||||
+918
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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,
|
||||
})
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.0–1.0 |
|
||||
| `--top-p` | — | `0.95` | Nucleus sampling 0.0–1.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 |
|
||||
@@ -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.0–1.0 |
|
||||
| `top-p` | float | `0.95` | Nucleus sampling 0.0–1.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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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/internal/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/internal/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/internal/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
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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
Reference in New Issue
Block a user