mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e613a07773 | |||
| 1d3b4f8d56 | |||
| 118af2e152 | |||
| c46687fc44 | |||
| aeaa5368af | |||
| 4966c0ca2a | |||
| f3ea18ae3a | |||
| 24ea2c94e3 | |||
| 4577d218d3 | |||
| bd48457b27 | |||
| 84298a0743 | |||
| 393074447b | |||
| 879723fe90 | |||
| 57250a3a3d | |||
| 7e1686e572 | |||
| 4a8b10cde7 | |||
| cc5611eff7 | |||
| 51c70b63a7 | |||
| c9ee80d98a | |||
| 3ecedcbc2d | |||
| dbfa410fc1 | |||
| 512ecb92dc | |||
| aede76d807 | |||
| 9e1df38836 | |||
| 8f5efee837 | |||
| a392d3e572 | |||
| c40dc2f4fb | |||
| 37e82781b1 | |||
| 23c16bb197 | |||
| 9449f1fcdf | |||
| dc59cfc81e | |||
| 8407d924b9 | |||
| 91474af503 | |||
| e252791b3a | |||
| 1880523422 | |||
| eeecd5a843 | |||
| 7747fc2033 | |||
| 864230bd0a | |||
| 0de0040e63 | |||
| 98efaae960 | |||
| 53ae47a1bd | |||
| 584b215803 | |||
| 3009b5530b | |||
| 1309c4bd12 | |||
| 2a829fb98f | |||
| ad07086900 | |||
| 596eeede2f | |||
| 879ec65609 | |||
| 2fce8731e1 |
@@ -39,12 +39,42 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: npm
|
||||
run: |
|
||||
TAG=${{ inputs.tag || github.ref_name }}
|
||||
VERSION=${TAG#v}
|
||||
echo "Setting npm version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [goreleaser, npm-publish]
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') && (needs.npm-publish.result == 'success') }}
|
||||
steps:
|
||||
- name: Send Discord Notification
|
||||
if: success()
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.RELEASES_WEBHOOK }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}
|
||||
TAG_NAME: ${{ inputs.tag || github.ref_name }}
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
curl -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
@@ -73,29 +103,3 @@ jobs:
|
||||
}]
|
||||
}" \
|
||||
$DISCORD_WEBHOOK
|
||||
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: npm
|
||||
run: |
|
||||
TAG=${{ inputs.tag || github.ref_name }}
|
||||
VERSION=${TAG#v}
|
||||
echo "Setting npm version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -39,6 +39,53 @@ Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
- Multi-provider LLM support via `llm.Provider` interface
|
||||
- MCP client-server for tool integration
|
||||
- Builtin servers: bash, fetch, todo, fs
|
||||
- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors
|
||||
- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel` → `InputComponent`, `StreamComponent`, etc.)
|
||||
- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Yaegi (Extension Interpreter) Gotchas
|
||||
- **No interfaces across boundary**: All extension-facing API types must be concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
|
||||
- **Function field bug**: Named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
|
||||
```go
|
||||
// WRONG: ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
|
||||
// RIGHT: ctx.SetEditor(ext.EditorConfig{HandleKey: func(k, t string) ext.EditorKeyAction { return myHandler(k, t) }})
|
||||
```
|
||||
- **Symbol exports**: Every new type exposed to extensions must be added to `internal/extensions/symbols.go`
|
||||
|
||||
### BubbleTea Integration
|
||||
- **No `prog.Send()` from inside `Update()`**: Calling `prog.Send()` synchronously within a BubbleTea `Update()` handler deadlocks the event loop. Use `go appInstance.NotifyWidgetUpdate()` (async goroutine) instead.
|
||||
- **Height measurement**: `distributeHeight()` in `model.go` must measure using the same render path as `View()`. If an interceptor wraps rendering, measure with the wrapper too, or layout will mismatch.
|
||||
- **Channel-based prompts**: Extension prompt calls (PromptSelect, etc.) block on a `chan PromptResponse`. Extension slash commands run in dedicated goroutines (not `tea.Cmd`) to avoid stalling BubbleTea's Cmd scheduler.
|
||||
|
||||
### Extension State Management
|
||||
- **Thread-safe maps on Runner**: Widget/header/footer/editor state lives on the Runner with `sync.RWMutex`, queried by UI via callbacks
|
||||
- **Context function fields**: The `Context` struct uses function fields (`Print func(string)`, `SetWidget func(WidgetConfig)`) wired by closures in `cmd/root.go`
|
||||
- **Package-level vars in extensions**: Yaegi supports package-level variables captured in closures — this is how extensions maintain state across event callbacks
|
||||
|
||||
### Unicode in Widget Text
|
||||
- Widget content renders through `lipgloss.Style.Render()` which preserves ANSI escape codes
|
||||
- Use rune-based width calculations (`len([]rune(s))`) not byte length (`len(s)`) when aligning box-drawing characters or multi-byte symbols
|
||||
|
||||
## Testing
|
||||
|
||||
### Interactive TUI Testing with tmux
|
||||
Use tmux to test Kit interactively without blocking the agent:
|
||||
```bash
|
||||
tmux new-session -d -s kittest -x 120 -y 40 "output/kit -e examples/extensions/my-ext.go --no-session 2>kit_stderr.log"
|
||||
sleep 3
|
||||
tmux capture-pane -t kittest -p # read screen
|
||||
tmux send-keys -t kittest '/command' Enter # send input
|
||||
tmux kill-session -t kittest # cleanup
|
||||
```
|
||||
|
||||
### Non-Interactive Kit (Subprocess Spawning)
|
||||
Extensions can spawn Kit as a subprocess for sub-agent patterns:
|
||||
```bash
|
||||
kit --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model "question"
|
||||
```
|
||||
Positional args are the prompt. `@file` args attach file content. Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
|
||||
|
||||
## External Repo Research
|
||||
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
|
||||
|
||||
+13
-1
@@ -64,8 +64,20 @@
|
||||
"name": "yaegi",
|
||||
"url": "https://github.com/traefik/yaegi",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "acp-go-sdk",
|
||||
"url": "https://github.com/coder/acp-go-sdk",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "opencode",
|
||||
"url": "https://github.com/anomalyco/opencode",
|
||||
"branch": "dev"
|
||||
}
|
||||
],
|
||||
"model": "claude-haiku-4-5",
|
||||
"provider": "opencode"
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
"github.com/mark3labs/kit/internal/acpserver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var acpCmd = &cobra.Command{
|
||||
Use: "acp",
|
||||
Short: "Start Kit as an ACP agent server",
|
||||
Long: `Start Kit as an ACP (Agent Client Protocol) agent server.
|
||||
|
||||
Communicates over stdio (stdin/stdout) using JSON-RPC 2.0 with
|
||||
newline-delimited JSON, compatible with OpenCode and other ACP clients.
|
||||
|
||||
The server exposes Kit's LLM execution, tool system, and session
|
||||
management via the Agent Client Protocol. Sessions are persisted
|
||||
to Kit's standard JSONL session files.`,
|
||||
RunE: runACP,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(acpCmd)
|
||||
}
|
||||
|
||||
func runACP(cmd *cobra.Command, _ []string) error {
|
||||
// Create the ACP agent implementation.
|
||||
agent := acpserver.NewAgent()
|
||||
defer agent.Close()
|
||||
|
||||
// Create the stdio connection. The SDK reads JSON-RPC from stdin and
|
||||
// writes responses to stdout.
|
||||
conn := acp.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
|
||||
|
||||
// Wire the connection back to the agent so it can send session updates.
|
||||
agent.SetAgentConnection(conn)
|
||||
|
||||
// Enable debug logging to stderr if requested.
|
||||
if debugMode {
|
||||
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})))
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "kit: ACP server ready on stdio")
|
||||
|
||||
// Wait for either the client to disconnect or a signal.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-conn.Done():
|
||||
fmt.Fprintln(os.Stderr, "kit: ACP client disconnected")
|
||||
case sig := <-sigCh:
|
||||
fmt.Fprintf(os.Stderr, "kit: received %s, shutting down\n", sig)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+731
-93
File diff suppressed because it is too large
Load Diff
+9
-14
@@ -3,7 +3,6 @@ package cmd
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
@@ -12,9 +11,9 @@ import (
|
||||
)
|
||||
|
||||
// CollectAgentMetadata extracts model display info and tool/server name lists
|
||||
// from the agent, used to populate app.Options and UI setup.
|
||||
// from the Kit instance, used to populate app.Options and UI setup.
|
||||
// It also returns the number of MCP tools and extension tools separately.
|
||||
func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) {
|
||||
func CollectAgentMetadata(k *kit.Kit, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) {
|
||||
modelString := viper.GetString("model")
|
||||
provider, modelName, _ = kit.ParseModelString(modelString)
|
||||
if modelName == "" {
|
||||
@@ -25,13 +24,9 @@ func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (prov
|
||||
serverNames = append(serverNames, name)
|
||||
}
|
||||
|
||||
for _, tool := range mcpAgent.GetTools() {
|
||||
info := tool.Info()
|
||||
toolNames = append(toolNames, info.Name)
|
||||
}
|
||||
|
||||
mcpToolCount = mcpAgent.GetMCPToolCount()
|
||||
extensionToolCount = mcpAgent.GetExtensionToolCount()
|
||||
toolNames = k.GetToolNames()
|
||||
mcpToolCount = k.GetMCPToolCount()
|
||||
extensionToolCount = k.GetExtensionToolCount()
|
||||
|
||||
return provider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount
|
||||
}
|
||||
@@ -52,7 +47,7 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to
|
||||
|
||||
// DisplayDebugConfig builds and displays the debug configuration map through
|
||||
// the CLI for non-interactive mode.
|
||||
func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Config, provider string) {
|
||||
func DisplayDebugConfig(cli *ui.CLI, k *kit.Kit, mcpConfig *config.Config, provider string) {
|
||||
if quietFlag || cli == nil || !viper.GetBool("debug") {
|
||||
return
|
||||
}
|
||||
@@ -89,7 +84,7 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co
|
||||
if len(mcpConfig.MCPServers) > 0 {
|
||||
mcpServers := make(map[string]any)
|
||||
loadedServerSet := make(map[string]bool)
|
||||
for _, name := range mcpAgent.GetLoadedServerNames() {
|
||||
for _, name := range k.GetLoadedServerNames() {
|
||||
loadedServerSet[name] = true
|
||||
}
|
||||
|
||||
@@ -130,8 +125,8 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co
|
||||
|
||||
// SetupCLIForNonInteractive creates the CLI display layer for non-interactive
|
||||
// mode (--prompt). Returns nil when quiet mode is active.
|
||||
func SetupCLIForNonInteractive(mcpAgent *agent.Agent) (*ui.CLI, error) {
|
||||
agentAdapter := &agentUIAdapter{agent: mcpAgent}
|
||||
func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) {
|
||||
agentAdapter := &kitUIAdapter{kit: k}
|
||||
return ui.SetupCLI(&ui.CLISetupOptions{
|
||||
Agent: agentAdapter,
|
||||
ModelString: viper.GetString("model"),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// skillCmd installs the kit-extensions skill via the skills.sh CLI (npx skills).
|
||||
// This teaches AI agents how to create Kit extensions with full knowledge of
|
||||
// the extension API, lifecycle events, widgets, tools, commands, and Yaegi constraints.
|
||||
var skillCmd = &cobra.Command{
|
||||
Use: "skill",
|
||||
Short: "Install the Kit extensions skill via skills.sh",
|
||||
Long: `Install the kit-extensions skill that teaches AI agents how to create
|
||||
Kit extensions. Uses the skills.sh CLI (npx skills) to install the skill
|
||||
from the Kit repository.
|
||||
|
||||
The skill provides comprehensive documentation of Kit's extension API including
|
||||
lifecycle events, custom tools, slash commands, widgets, editor interceptors,
|
||||
tool renderers, and critical Yaegi interpreter constraints.
|
||||
|
||||
Example:
|
||||
kit skill`,
|
||||
RunE: runSkill,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(skillCmd)
|
||||
}
|
||||
|
||||
func runSkill(_ *cobra.Command, _ []string) error {
|
||||
npx, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
return fmt.Errorf("npx not found in PATH — install Node.js to use this command: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"skills",
|
||||
"add",
|
||||
"mark3labs/kit",
|
||||
"--skill",
|
||||
"kit-extensions",
|
||||
}
|
||||
|
||||
cmd := exec.Command(npx, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("skills install failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init automatically commits staged changes when the session shuts down,
|
||||
// using the last assistant message as the commit message.
|
||||
//
|
||||
// Only commits if:
|
||||
// - There are staged changes (git diff --cached is non-empty)
|
||||
// - There is at least one assistant message to use as commit message
|
||||
//
|
||||
// The commit message is derived from the last assistant response, trimmed
|
||||
// to the first paragraph (max 72 chars for the subject line).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/auto-commit.go
|
||||
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
|
||||
if err == nil {
|
||||
return // exit code 0 means no staged changes
|
||||
}
|
||||
|
||||
// Get the last assistant message.
|
||||
msgs := ctx.GetMessages()
|
||||
var lastAssistant string
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "assistant" {
|
||||
lastAssistant = msgs[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastAssistant == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Build commit message: first paragraph, subject line max 72 chars.
|
||||
subject := firstParagraph(lastAssistant)
|
||||
if len(subject) > 72 {
|
||||
subject = subject[:69] + "..."
|
||||
}
|
||||
|
||||
// Commit.
|
||||
cmd := exec.Command("git", "commit", "-m", subject)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.PrintError("Auto-commit failed: " + string(output))
|
||||
return
|
||||
}
|
||||
ctx.PrintInfo("Auto-committed: " + subject)
|
||||
})
|
||||
}
|
||||
|
||||
// firstParagraph returns the first non-empty paragraph of text.
|
||||
func firstParagraph(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
// Split on double newlines (paragraph breaks).
|
||||
parts := strings.SplitN(text, "\n\n", 2)
|
||||
line := strings.TrimSpace(parts[0])
|
||||
// Collapse to single line.
|
||||
line = strings.ReplaceAll(line, "\n", " ")
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds bookmark commands for marking and recalling important points in
|
||||
// a conversation. Bookmarks are persisted in the session tree and survive
|
||||
// restarts.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /bookmark <label> — bookmark the current point with a label
|
||||
// /bookmarks — list all bookmarks in this session
|
||||
//
|
||||
// Usage: kit -e examples/extensions/bookmark.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmark",
|
||||
Description: "Bookmark the current point in the conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
label := strings.TrimSpace(args)
|
||||
if label == "" {
|
||||
label = time.Now().Format("15:04:05")
|
||||
}
|
||||
|
||||
// Count existing messages to record position.
|
||||
msgs := ctx.GetMessages()
|
||||
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"label": label,
|
||||
"messages": len(msgs),
|
||||
})
|
||||
|
||||
_, err := ctx.AppendEntry("bookmark", string(data))
|
||||
if err != nil {
|
||||
ctx.PrintError("Failed to save bookmark: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs)))
|
||||
return "", nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest existing bookmark labels so the user can quickly
|
||||
// re-bookmark at the same label.
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
if label == "" || seen[label] {
|
||||
continue
|
||||
}
|
||||
if prefix == "" || strings.HasPrefix(strings.ToLower(label), strings.ToLower(prefix)) {
|
||||
labels = append(labels, label)
|
||||
seen[label] = true
|
||||
}
|
||||
}
|
||||
return labels
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmarks",
|
||||
Description: "List all bookmarks in this session",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No bookmarks yet. Use /bookmark <label> to create one.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
msgCount, _ := data["messages"].(float64)
|
||||
lines = append(lines, fmt.Sprintf(" %d. %s (msg %d, %s)",
|
||||
i+1, label, int(msgCount), e.Timestamp[:19]))
|
||||
}
|
||||
|
||||
ctx.PrintInfo("Bookmarks:\n" + strings.Join(lines, "\n"))
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build ignore
|
||||
|
||||
// branded-output.go — Custom Message Rendering example extension for Kit.
|
||||
//
|
||||
// Demonstrates api.RegisterMessageRenderer() and ctx.RenderMessage() which
|
||||
// let extensions define reusable visual styles for output. Each renderer has
|
||||
// a name and a render function that receives content and terminal width.
|
||||
//
|
||||
// This extension registers three renderers:
|
||||
// "success" — green-bordered block for success messages
|
||||
// "warning" — yellow-bordered block for warnings
|
||||
// "metric" — compact key=value display for metrics
|
||||
//
|
||||
// Commands:
|
||||
// /demo-render — shows all three renderers in action
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register a "success" renderer — green-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[32m┌%s┐\033[0m\n \033[32m│\033[0m \033[1;32m%s\033[0m\n \033[32m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "warning" renderer — yellow-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "warning",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[33m┌%s┐\033[0m\n \033[33m│\033[0m \033[1;33m%s\033[0m\n \033[33m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "metric" renderer — compact label: value format.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "metric",
|
||||
Render: func(content string, width int) string {
|
||||
return fmt.Sprintf(" \033[36m▸\033[0m %s", content)
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-render",
|
||||
Description: "Demonstrate custom message renderers",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.RenderMessage("success", "All 42 tests passed in 3.2s")
|
||||
ctx.RenderMessage("warning", "3 deprecation warnings detected")
|
||||
ctx.RenderMessage("metric", fmt.Sprintf("build_time=%.1fs tests=42 coverage=87%% timestamp=%s",
|
||||
3.2, time.Now().Format("15:04:05")))
|
||||
|
||||
return "Rendered three message styles.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers a before-compact hook that notifies the user when
|
||||
// compaction is about to happen and optionally blocks automatic compaction.
|
||||
//
|
||||
// When automatic compaction is triggered (via --auto-compact), the extension
|
||||
// asks for user confirmation. Manual /compact commands are always allowed.
|
||||
//
|
||||
// This demonstrates the OnBeforeCompact event which allows extensions to
|
||||
// inspect context usage stats and gate the compaction process.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/compact-notify.go --auto-compact
|
||||
func Init(api ext.API) {
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
pct := int(e.UsagePercent * 100)
|
||||
summary := fmt.Sprintf("Context: %dk/%dk tokens (%d%%), %d messages",
|
||||
e.EstimatedTokens/1000, e.ContextLimit/1000, pct, e.MessageCount)
|
||||
|
||||
if e.IsAutomatic {
|
||||
// Auto-compaction: ask user first.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Auto-compaction triggered.\n" + summary,
|
||||
BorderColor: "#f9e2af",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Allow automatic compaction?",
|
||||
DefaultValue: true,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: "Auto-compaction skipped by user.",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Manual /compact: just notify.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Compacting conversation...\n" + summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
}
|
||||
|
||||
return nil // allow compaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers before-hooks for destructive session operations:
|
||||
// - Forks: Asks for confirmation before branching to a different tree node.
|
||||
// - New sessions: Checks for uncommitted git changes and warns before
|
||||
// starting a new branch if the working tree is dirty.
|
||||
//
|
||||
// This demonstrates the OnBeforeFork and OnBeforeSessionSwitch events
|
||||
// which allow extensions to cancel session lifecycle operations.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/confirm-destructive.go --continue
|
||||
func Init(api ext.API) {
|
||||
// Gate /new command: warn if there are uncommitted git changes.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
if !isGitDirty() {
|
||||
return nil // clean repo, allow switch
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Working tree has uncommitted changes. Start new session anyway?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeSessionSwitchResult{
|
||||
Cancel: true,
|
||||
Reason: "Session switch cancelled: uncommitted git changes.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
|
||||
// Gate fork: ask for confirmation before branching.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
msg := "Branch to this point in the conversation?"
|
||||
if e.IsUserMessage && e.UserText != "" {
|
||||
// Show a preview of the user message being forked to.
|
||||
preview := e.UserText
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:77] + "..."
|
||||
}
|
||||
msg = "Fork and edit: " + preview + "\n\nContinue?"
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: msg,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeForkResult{
|
||||
Cancel: true,
|
||||
Reason: "Fork cancelled by user.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
}
|
||||
|
||||
// isGitDirty returns true if the git working tree has uncommitted changes.
|
||||
func isGitDirty() bool {
|
||||
out, err := exec.Command("git", "status", "--porcelain").Output()
|
||||
if err != nil {
|
||||
return false // not a git repo or git not available
|
||||
}
|
||||
return len(strings.TrimSpace(string(out))) > 0
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build ignore
|
||||
|
||||
// context-inject.go — Injects context from a local file into every LLM turn.
|
||||
//
|
||||
// Reads a context file (default: .kit/context.md) and prepends it as a system
|
||||
// message to every LLM context window via OnContextPrepare. This is useful for
|
||||
// injecting project-specific knowledge, coding standards, or RAG results that
|
||||
// should always be visible to the model — without cluttering the session history.
|
||||
//
|
||||
// The injected message does NOT persist in the session tree (it's ephemeral,
|
||||
// added at query time only). This means:
|
||||
// - Changing the context file immediately affects future turns
|
||||
// - No session bloat from repeated context injection
|
||||
// - The model always sees the latest version of the context
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// KIT_OPT_CONTEXT_FILE — path to context file (default: .kit/context.md)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/context-inject.go
|
||||
// echo "Always use error wrapping with fmt.Errorf" > .kit/context.md
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "context-file",
|
||||
Description: "Path to the context file to inject into every turn",
|
||||
Default: ".kit/context.md",
|
||||
})
|
||||
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
path := ctx.GetOption("context-file")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// File doesn't exist or can't be read — skip silently.
|
||||
return nil
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend a system message with the context file contents.
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1,
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("[Project Context from %s]\n\n%s", path, content),
|
||||
}
|
||||
|
||||
msgs := make([]ext.ContextMessage, 0, len(e.Messages)+1)
|
||||
msgs = append(msgs, injected)
|
||||
msgs = append(msgs, e.Messages...)
|
||||
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "context",
|
||||
Description: "Show or edit the injected context file path",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
path := ctx.GetOption("context-file")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Context file: %s (not found or unreadable)", path), nil
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
preview := strings.Join(lines, "\n")
|
||||
if len(lines) > 10 {
|
||||
preview = strings.Join(lines[:10], "\n") + "\n..."
|
||||
}
|
||||
return fmt.Sprintf("Context file: %s (%d lines)\n\n%s", path, len(lines), preview), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// vimActive tracks whether the vim interceptor is installed at all.
|
||||
// normalMode tracks whether we are in normal mode (true) or insert mode (false).
|
||||
var vimActive bool
|
||||
var normalMode bool
|
||||
|
||||
// Init demonstrates the editor interceptor system. Extensions can intercept
|
||||
// key events before they reach the built-in editor and wrap the editor's
|
||||
// rendered output. This example implements a simple vim-like modal editor
|
||||
// with normal/insert mode switching.
|
||||
//
|
||||
// Slash commands:
|
||||
// - /vim — toggle vim mode on/off
|
||||
// - /vim-info — show current editor mode
|
||||
func Init(api ext.API) {
|
||||
// /vim — toggle the vim interceptor on/off.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "vim",
|
||||
Description: "Toggle vim-like modal editing",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if vimActive {
|
||||
// Turn off vim mode entirely.
|
||||
vimActive = false
|
||||
normalMode = false
|
||||
ctx.ResetEditor()
|
||||
return "Vim mode OFF. Default editor restored.", nil
|
||||
}
|
||||
// Turn on vim mode, start in normal mode.
|
||||
vimActive = true
|
||||
normalMode = true
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
|
||||
return handleVimKey(key, currentText)
|
||||
},
|
||||
Render: func(width int, defaultContent string) string {
|
||||
return renderVimMode(width, defaultContent)
|
||||
},
|
||||
})
|
||||
return "Vim mode ON (NORMAL). Press 'i' to insert, Esc to return to normal, h/j/k/l to navigate.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /vim-info — show the current editor mode.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "vim-info",
|
||||
Description: "Show current vim mode",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if !vimActive {
|
||||
return "Vim mode is OFF (default editor).", nil
|
||||
}
|
||||
if normalMode {
|
||||
return "Vim mode ON — NORMAL mode", nil
|
||||
}
|
||||
return "Vim mode ON — INSERT mode (Esc to return to normal)", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleVimKey processes keys for both normal and insert modes.
|
||||
// The interceptor stays active in both modes so Esc can switch back.
|
||||
func handleVimKey(key string, currentText string) ext.EditorKeyAction {
|
||||
if !normalMode {
|
||||
// ── Insert mode: pass everything through except Esc ──
|
||||
if key == "esc" {
|
||||
normalMode = true
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
|
||||
// ── Normal mode ──
|
||||
switch key {
|
||||
// Navigation: remap hjkl to arrow keys.
|
||||
case "h":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "left"}
|
||||
case "j":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "down"}
|
||||
case "k":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}
|
||||
case "l":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "right"}
|
||||
|
||||
// Mode switching.
|
||||
case "i":
|
||||
normalMode = false
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
|
||||
// Editing shortcuts.
|
||||
case "x":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "delete"}
|
||||
case "0":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "home"}
|
||||
case "$":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "end"}
|
||||
|
||||
// Submission.
|
||||
case "enter":
|
||||
if strings.TrimSpace(currentText) != "" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeySubmit}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
|
||||
// Block most printable keys in normal mode.
|
||||
default:
|
||||
// Let control sequences and special keys through (e.g., ctrl+c).
|
||||
if len(key) > 1 && key != "space" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
}
|
||||
}
|
||||
|
||||
// renderVimMode wraps the default editor rendering with a mode indicator.
|
||||
func renderVimMode(width int, defaultContent string) string {
|
||||
mode := "-- NORMAL --"
|
||||
if !normalMode {
|
||||
mode = "-- INSERT --"
|
||||
}
|
||||
|
||||
indicator := fmt.Sprintf(" %s", mode)
|
||||
padding := width - len(indicator)
|
||||
if padding > 0 {
|
||||
indicator += strings.Repeat(" ", padding)
|
||||
}
|
||||
|
||||
return indicator + "\n" + defaultContent
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
// dev-reload.go — Extension Hot-Reload example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.ReloadExtensions() which hot-reloads all extensions
|
||||
// from disk without restarting Kit. This is invaluable during extension
|
||||
// development: edit your extension source, then type /reload to pick up
|
||||
// changes immediately.
|
||||
//
|
||||
// Event handlers, slash commands, tool renderers, message renderers, and
|
||||
// keyboard shortcuts update immediately. Extension-defined tools are NOT
|
||||
// updated (they are baked into the agent at creation time and require a
|
||||
// restart).
|
||||
//
|
||||
// Commands:
|
||||
// /reload — hot-reload all extensions from disk
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
var loadedAt string
|
||||
|
||||
func Init(api ext.API) {
|
||||
loadedAt = time.Now().Format("15:04:05")
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "reload",
|
||||
Description: "Hot-reload all extensions from disk",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.Print("Reloading extensions...")
|
||||
err := ctx.ReloadExtensions()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reload failed: %w", err)
|
||||
}
|
||||
return "Extensions reloaded successfully.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "load-time",
|
||||
Description: "Show when this extension was loaded",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
return fmt.Sprintf("This extension was loaded at %s", loadedAt), nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print(fmt.Sprintf("[dev-reload] Extension loaded at %s", loadedAt))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the custom header/footer system. The header shows
|
||||
// project context (branch, CWD) and the footer shows a running summary
|
||||
// of agent activity. Slash commands toggle them on/off.
|
||||
func Init(api ext.API) {
|
||||
var turnCount int
|
||||
var lastResponse string
|
||||
|
||||
// Show a custom header with project context when the session starts.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
|
||||
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Ready | 0 turns",
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Update footer after each agent turn with activity summary.
|
||||
api.OnAgentEnd(func(ae ext.AgentEndEvent, ctx ext.Context) {
|
||||
turnCount++
|
||||
lastResponse = ae.Response
|
||||
if len(lastResponse) > 60 {
|
||||
lastResponse = lastResponse[:57] + "..."
|
||||
}
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Turns: %d | Last: %s | %s",
|
||||
turnCount, ae.StopReason, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// /header-off — remove the custom header.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "header-off",
|
||||
Description: "Remove the custom header",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.RemoveHeader()
|
||||
return "Header removed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /header-on — restore the custom header.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "header-on",
|
||||
Description: "Restore the custom header",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
|
||||
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
return "Header restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /footer-off — remove the custom footer.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "footer-off",
|
||||
Description: "Remove the custom footer",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.RemoveFooter()
|
||||
return "Footer removed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /footer-on — restore the custom footer.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "footer-on",
|
||||
Description: "Restore the custom footer",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Turns: %d | %s", turnCount, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
return "Footer restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Clean up on shutdown.
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveHeader()
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init expands inline bash expressions in user prompts before they reach the
|
||||
// LLM. Text like !{git branch --show-current} is replaced with the command's
|
||||
// stdout.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "Fix the tests on !{git branch --show-current}"
|
||||
// → "Fix the tests on main"
|
||||
//
|
||||
// "The current directory is !{pwd}"
|
||||
// → "The current directory is /home/user/project"
|
||||
//
|
||||
// Usage: kit -e examples/extensions/inline-bash.go
|
||||
func Init(api ext.API) {
|
||||
// Matches !{...} with non-greedy content.
|
||||
re := regexp.MustCompile(`!\{([^}]+)\}`)
|
||||
|
||||
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
if !re.MatchString(ev.Text) {
|
||||
return nil
|
||||
}
|
||||
|
||||
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
|
||||
// Extract the command between !{ and }.
|
||||
cmd := re.FindStringSubmatch(match)[1]
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
out, err := exec.Command("bash", "-c", cmd).Output()
|
||||
if err != nil {
|
||||
return match // keep original on error
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
})
|
||||
|
||||
return &ext.InputResult{
|
||||
Action: "transform",
|
||||
Text: expanded,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build ignore
|
||||
|
||||
// interactive-shell.go — TUI Suspend example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.SuspendTUI() which temporarily releases the terminal
|
||||
// from the TUI so interactive subprocesses can run with full terminal
|
||||
// control. The TUI is automatically restored when the callback returns.
|
||||
//
|
||||
// Commands:
|
||||
// /edit <file> — opens $EDITOR (or vi) to edit a file
|
||||
// /shell — drops into an interactive shell session
|
||||
// /run <cmd> — runs a command with full terminal I/O (no TUI capture)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "edit",
|
||||
Description: "Open $EDITOR to edit a file (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
file := strings.TrimSpace(args)
|
||||
if file == "" {
|
||||
return "", fmt.Errorf("usage: /edit <file>")
|
||||
}
|
||||
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Opening %s in %s...", file, editor))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(editor, file)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("editor session failed: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Finished editing %s", file), nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest files in the current directory.
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var results []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
results = append(results, name)
|
||||
}
|
||||
}
|
||||
return results
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "shell",
|
||||
Description: "Drop into an interactive shell (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Starting %s... (type 'exit' to return to Kit)", shell))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shell session failed: %w", err)
|
||||
}
|
||||
|
||||
return "Shell session ended, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command with full terminal I/O (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
cmdStr := strings.TrimSpace(args)
|
||||
if cmdStr == "" {
|
||||
return "", fmt.Errorf("usage: /run <command>")
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Running: %s", cmdStr))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("sh", "-c", cmdStr)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
|
||||
return "Command finished, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: ext-expert
|
||||
description: Kit extensions — tools, events, commands, widgets, editor interceptors
|
||||
tools: read,grep,glob
|
||||
---
|
||||
You are an expert on Kit's extension system. Your job is to research and answer questions about how Kit extensions work.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `internal/extensions/api.go` — Extension API surface, Context struct, all types
|
||||
- `internal/extensions/runner.go` — Event dispatch, extension registry, widget/header/footer storage
|
||||
- `internal/extensions/loader.go` — Yaegi interpreter setup, extension loading
|
||||
- `internal/extensions/symbols.go` — Yaegi symbol exports
|
||||
- `internal/extensions/events.go` — Event type definitions
|
||||
- `examples/extensions/` — Example extensions demonstrating all features
|
||||
|
||||
## Architecture
|
||||
|
||||
Kit extensions are Go files interpreted at runtime by Yaegi. Each extension exports `func Init(api ext.API)` and uses the API to register:
|
||||
|
||||
- **Event handlers**: OnSessionStart, OnToolCall, OnToolResult, OnInput, OnAgentEnd, etc.
|
||||
- **Custom tools**: ToolDef with name, description, JSON Schema parameters, Execute function
|
||||
- **Slash commands**: CommandDef with name, description, Execute function (receives Context)
|
||||
- **Tool renderers**: ToolRenderConfig with custom RenderHeader/RenderBody
|
||||
- **Widgets**: ctx.SetWidget/RemoveWidget for persistent UI elements
|
||||
- **Headers/Footers**: ctx.SetHeader/SetFooter for chrome customization
|
||||
- **Editor interceptors**: ctx.SetEditor for key interception and render wrapping
|
||||
- **Prompts/Overlays**: ctx.PromptSelect/PromptConfirm/PromptInput/ShowOverlay
|
||||
|
||||
## Critical Yaegi Limitations
|
||||
|
||||
- All function fields in structs must be anonymous closures, NOT named function references
|
||||
- No interfaces exported to extensions — only concrete structs
|
||||
- Extensions run in isolated interpreters with stdlib + os/exec access
|
||||
|
||||
When answering, cite specific file paths and line numbers. Provide concrete code examples.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: llm-expert
|
||||
description: Kit LLM system — providers, streaming, agent loop, tool execution
|
||||
tools: read,grep,glob
|
||||
---
|
||||
You are an expert on Kit's LLM integration and agent system. Your job is to research and answer questions about how Kit communicates with language models and runs the agent loop.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `internal/llm/provider.go` — Provider interface definition
|
||||
- `internal/llm/anthropic/` — Anthropic Claude provider
|
||||
- `internal/llm/openai/` — OpenAI-compatible provider (also used for Ollama)
|
||||
- `internal/llm/google/` — Google Gemini provider
|
||||
- `internal/agent/agent.go` — Agent loop: prompt -> LLM -> tool calls -> repeat
|
||||
- `internal/agent/tools.go` — Tool registry, built-in tool definitions
|
||||
- `internal/app/app.go` — App layer: RunOnce, RunOnceWithDisplay, event routing
|
||||
- `pkg/kit/kit.go` — SDK: New(), configuration, extension management
|
||||
|
||||
## Architecture
|
||||
|
||||
Kit supports multiple LLM providers through the `llm.Provider` interface. The model flag format is `provider/model-name` (e.g., `anthropic/claude-sonnet-4-5`).
|
||||
|
||||
The agent loop in `internal/agent/` follows a standard ReAct pattern:
|
||||
1. Send conversation history + system prompt to LLM
|
||||
2. LLM responds with text and/or tool calls
|
||||
3. Execute tool calls (MCP servers + extension tools)
|
||||
4. Append tool results to conversation
|
||||
5. Repeat until LLM produces a final text response (no tool calls)
|
||||
|
||||
Tool execution goes through MCP (Model Context Protocol) client-server architecture. Built-in MCP servers provide bash, file system, fetch, and todo tools.
|
||||
|
||||
The App layer (`internal/app/`) manages the lifecycle: creating the agent, routing events to the UI or CLI renderer, handling cancellation, and coordinating with extensions.
|
||||
|
||||
When answering, cite specific file paths and line numbers. Provide concrete code examples.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: orchestrator
|
||||
description: Kit Kit orchestrator system prompt template
|
||||
---
|
||||
You are Kit Kit, an orchestrator agent with {{EXPERT_COUNT}} domain experts: {{EXPERT_NAMES}}.
|
||||
|
||||
Your role is to coordinate these experts to research Kit's codebase and then synthesize their findings into working implementations.
|
||||
|
||||
## Available Experts
|
||||
|
||||
{{EXPERT_CATALOG}}
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Analyze** the user's request to identify which domains are relevant.
|
||||
2. **Query** the relevant experts IN PARALLEL using the `query_experts` tool. Ask specific, targeted questions.
|
||||
3. **Synthesize** the expert findings into a coherent understanding.
|
||||
4. **Implement** — you are the ONLY agent that writes files. Experts are read-only researchers.
|
||||
|
||||
## Rules
|
||||
|
||||
- ALWAYS query experts before implementing. Never guess about Kit internals.
|
||||
- Ask SPECIFIC questions: "How does SetWidget update the UI?" beats "Tell me about widgets."
|
||||
- Query MULTIPLE experts in a single tool call when the task spans domains (they run in parallel).
|
||||
- If an expert's answer is insufficient, query again with a more targeted question.
|
||||
- Cite the file paths and patterns from expert responses in your implementation.
|
||||
- When writing Kit extensions, remember the Yaegi closure wrapper pattern for all function fields.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: tui-expert
|
||||
description: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
|
||||
tools: read,grep,glob
|
||||
---
|
||||
You are an expert on Kit's terminal user interface. Your job is to research and answer questions about how Kit's TUI works.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `internal/ui/model.go` — AppModel root component, View(), Update(), key handling, layout
|
||||
- `internal/ui/input.go` — InputComponent wrapping textarea + autocomplete
|
||||
- `internal/ui/overlay.go` — Modal overlay dialogs
|
||||
- `internal/ui/prompt.go` — Interactive prompt overlays (select, confirm, input)
|
||||
- `internal/ui/messages.go` — MessageRenderer for streaming messages
|
||||
- `internal/ui/compact_renderer.go` — CompactRenderer for compact mode
|
||||
- `internal/ui/block_renderer.go` — renderContentBlock() with functional options
|
||||
- `internal/ui/theme.go` — Catppuccin-based theming (GetTheme)
|
||||
- `internal/ui/commands.go` — ExtensionCommand type, slash command registry
|
||||
- `internal/ui/model_test.go` — Tests with stubAppController mock
|
||||
|
||||
## Architecture
|
||||
|
||||
Kit uses Bubble Tea v2 for the TUI. The component hierarchy:
|
||||
|
||||
- **AppModel** — root component managing layout, key routing, and child components
|
||||
- **InputComponent** — text area with autocomplete popup
|
||||
- **StreamComponent** — streaming message display
|
||||
- **TreeSelectorComponent** — session/model picker
|
||||
- **promptOverlay** — interactive prompts (select, confirm, input)
|
||||
- **overlayDialog** — modal overlay dialogs
|
||||
|
||||
Layout (top to bottom): header, stream, separator, widgets-above, input, widgets-below, footer, status bar.
|
||||
|
||||
Rendering uses lipgloss for styling with the Catppuccin Mocha color palette. Content blocks use `renderContentBlock()` with functional options for border, padding, background, and alignment.
|
||||
|
||||
Extension widgets integrate via callback functions (getWidgets, getHeader, getFooter) that query the extension runner through the SDK layer, keeping the UI decoupled from extensions.
|
||||
|
||||
When answering, cite specific file paths and line numbers. Provide concrete code examples.
|
||||
@@ -0,0 +1,870 @@
|
||||
//go:build ignore
|
||||
|
||||
// Kit Kit — Meta-agent that builds Kit agents
|
||||
//
|
||||
// A team of domain-specific research experts operate IN PARALLEL to gather
|
||||
// documentation and patterns. The primary agent synthesizes their findings
|
||||
// and WRITES the actual files.
|
||||
//
|
||||
// Each expert runs as a separate `kit` subprocess with a domain-specific
|
||||
// system prompt. Experts are read-only researchers; the primary agent is
|
||||
// the only writer.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /experts — list available experts and their status
|
||||
// /experts-grid N — set dashboard column count (default 3)
|
||||
//
|
||||
// Usage: kit -e examples/extensions/kit-kit.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// kitJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type kitJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type expertDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Tools string
|
||||
System string // system prompt body
|
||||
File string
|
||||
}
|
||||
|
||||
type expertState struct {
|
||||
Def expertDef
|
||||
Status string // "idle", "researching", "done", "error"
|
||||
Question string
|
||||
Elapsed time.Duration
|
||||
LastLine string
|
||||
QueryCount int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *expertState) set(status, question, lastLine string, elapsed time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if status != "" {
|
||||
s.Status = status
|
||||
}
|
||||
if question != "" {
|
||||
s.Question = question
|
||||
}
|
||||
if lastLine != "" {
|
||||
s.LastLine = lastLine
|
||||
}
|
||||
if elapsed > 0 {
|
||||
s.Elapsed = elapsed
|
||||
}
|
||||
}
|
||||
|
||||
func (s *expertState) snapshot() (string, string, string, time.Duration, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.Status, s.Question, s.LastLine, s.Elapsed, s.QueryCount
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
experts = map[string]*expertState{}
|
||||
gridCols = 3
|
||||
latestCtx ext.Context
|
||||
hasCtx bool
|
||||
kitBinary string // resolved path to kit executable
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func displayName(name string) string {
|
||||
parts := strings.Split(name, "-")
|
||||
for i, w := range parts {
|
||||
if len(w) > 0 {
|
||||
parts[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func runeWidth(s string) int {
|
||||
return len([]rune(s))
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max < 4 {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return string(runes[:max-3]) + "..."
|
||||
}
|
||||
|
||||
func pad(s string, width int) string {
|
||||
w := runeWidth(s)
|
||||
if w >= width {
|
||||
return string([]rune(s)[:width])
|
||||
}
|
||||
return s + strings.Repeat(" ", width-w)
|
||||
}
|
||||
|
||||
// parseAgentFile reads a .md file with YAML-like frontmatter.
|
||||
//
|
||||
// ---
|
||||
// name: ext-expert
|
||||
// description: Extensions documentation
|
||||
// tools: read,grep,glob
|
||||
// ---
|
||||
// System prompt body here ...
|
||||
func parseAgentFile(path string) *expertDef {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text := string(raw)
|
||||
|
||||
// Must start with "---\n"
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return nil
|
||||
}
|
||||
rest := text[4:]
|
||||
idx := strings.Index(rest, "\n---\n")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
frontmatter := rest[:idx]
|
||||
body := strings.TrimSpace(rest[idx+5:])
|
||||
|
||||
fm := map[string]string{}
|
||||
for _, line := range strings.Split(frontmatter, "\n") {
|
||||
i := strings.Index(line, ":")
|
||||
if i > 0 {
|
||||
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
if fm["name"] == "" {
|
||||
return nil
|
||||
}
|
||||
return &expertDef{
|
||||
Name: fm["name"],
|
||||
Description: fm["description"],
|
||||
Tools: fm["tools"],
|
||||
System: body,
|
||||
File: path,
|
||||
}
|
||||
}
|
||||
|
||||
func loadExperts(cwd string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
experts = map[string]*expertState{}
|
||||
dir := filepath.Join(cwd, ".kit", "agents", "kit-kit")
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
if e.Name() == "orchestrator.md" {
|
||||
continue
|
||||
}
|
||||
def := parseAgentFile(filepath.Join(dir, e.Name()))
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(def.Name)
|
||||
experts[key] = &expertState{
|
||||
Def: *def,
|
||||
Status: "idle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func expertList() []*expertState {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
list := make([]*expertState, 0, len(experts))
|
||||
for _, s := range experts {
|
||||
list = append(list, s)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func expertNames() string {
|
||||
list := expertList()
|
||||
names := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
names[i] = displayName(s.Def.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget grid rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func renderCard(s *expertState, w int) []string {
|
||||
status, question, lastLine, elapsed, queryCount := s.snapshot()
|
||||
inner := w - 2 // inside the box-drawing borders
|
||||
|
||||
// Name line
|
||||
name := truncate(displayName(s.Def.Name), inner-1)
|
||||
|
||||
// Status line
|
||||
var icon string
|
||||
switch status {
|
||||
case "idle":
|
||||
icon = "○"
|
||||
case "researching":
|
||||
icon = "◉"
|
||||
case "done":
|
||||
icon = "✓"
|
||||
default:
|
||||
icon = "✗"
|
||||
}
|
||||
statusText := icon + " " + status
|
||||
if status != "idle" {
|
||||
statusText += fmt.Sprintf(" %ds", int(elapsed.Seconds()))
|
||||
}
|
||||
if queryCount > 0 {
|
||||
statusText += fmt.Sprintf(" (%d)", queryCount)
|
||||
}
|
||||
statusText = truncate(statusText, inner-1)
|
||||
|
||||
// Work line (question or description)
|
||||
work := question
|
||||
if work == "" {
|
||||
work = s.Def.Description
|
||||
}
|
||||
work = truncate(work, inner-1)
|
||||
|
||||
// Last output line
|
||||
last := lastLine
|
||||
if last == "" {
|
||||
last = "—"
|
||||
}
|
||||
last = truncate(last, inner-1)
|
||||
|
||||
// Build card (use rune width for box-drawing alignment)
|
||||
topBar := "─ " + name + " "
|
||||
if runeWidth(topBar) < inner {
|
||||
topBar += strings.Repeat("─", inner-runeWidth(topBar))
|
||||
}
|
||||
|
||||
return []string{
|
||||
"┌" + truncate(topBar, inner) + "┐",
|
||||
"│ " + pad(statusText, inner-1) + "│",
|
||||
"│ " + pad(work, inner-1) + "│",
|
||||
"│ " + pad(last, inner-1) + "│",
|
||||
"└" + strings.Repeat("─", inner) + "┘",
|
||||
}
|
||||
}
|
||||
|
||||
func buildGrid() string {
|
||||
list := expertList()
|
||||
if len(list) == 0 {
|
||||
return "No experts found. Add agent .md files to .kit/agents/kit-kit/"
|
||||
}
|
||||
|
||||
cols := gridCols
|
||||
if cols > len(list) {
|
||||
cols = len(list)
|
||||
}
|
||||
|
||||
// Card width: aim for ~28 chars per card
|
||||
cardWidth := 28
|
||||
gap := 1
|
||||
|
||||
var lines []string
|
||||
for i := 0; i < len(list); i += cols {
|
||||
end := i + cols
|
||||
if end > len(list) {
|
||||
end = len(list)
|
||||
}
|
||||
row := list[i:end]
|
||||
|
||||
// Render each card in this row
|
||||
cards := make([][]string, len(row))
|
||||
maxHeight := 0
|
||||
for j, s := range row {
|
||||
cards[j] = renderCard(s, cardWidth)
|
||||
if len(cards[j]) > maxHeight {
|
||||
maxHeight = len(cards[j])
|
||||
}
|
||||
}
|
||||
|
||||
// Merge columns line by line
|
||||
for line := 0; line < maxHeight; line++ {
|
||||
var parts []string
|
||||
for _, card := range cards {
|
||||
if line < len(card) {
|
||||
parts = append(parts, card[line])
|
||||
} else {
|
||||
parts = append(parts, strings.Repeat(" ", cardWidth))
|
||||
}
|
||||
}
|
||||
lines = append(lines, strings.Join(parts, strings.Repeat(" ", gap)))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func updateWidget() {
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "kit-kit:grid",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: buildGrid(),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
NoBorder: true,
|
||||
BorderColor: "",
|
||||
},
|
||||
Priority: 10,
|
||||
})
|
||||
}
|
||||
|
||||
func updateFooter() {
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
list := expertList()
|
||||
active := 0
|
||||
done := 0
|
||||
for _, s := range list {
|
||||
st, _, _, _, _ := s.snapshot()
|
||||
switch st {
|
||||
case "researching":
|
||||
active++
|
||||
case "done":
|
||||
done++
|
||||
}
|
||||
}
|
||||
|
||||
var mid string
|
||||
if active > 0 {
|
||||
mid = fmt.Sprintf(" ◉ %d researching", active)
|
||||
} else if done > 0 {
|
||||
mid = fmt.Sprintf(" ✓ %d done", done)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s | Kit Kit%s", ctx.Model, mid)
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kit binary resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func findKitBinary() string {
|
||||
// Try the current process executable first.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
// Fall back to PATH lookup.
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expert query (subprocess)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func queryExpert(name, question string) (output string, exitCode int, elapsed time.Duration) {
|
||||
mu.Lock()
|
||||
state, ok := experts[strings.ToLower(name)]
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Sprintf("Expert %q not found.", name), 1, 0
|
||||
}
|
||||
|
||||
// Mark as researching.
|
||||
state.mu.Lock()
|
||||
if state.Status == "researching" {
|
||||
state.mu.Unlock()
|
||||
return fmt.Sprintf("Expert %q is already researching.", displayName(name)), 1, 0
|
||||
}
|
||||
state.Status = "researching"
|
||||
state.Question = question
|
||||
state.Elapsed = 0
|
||||
state.LastLine = ""
|
||||
state.QueryCount++
|
||||
state.mu.Unlock()
|
||||
updateWidget()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Timer goroutine: update widget every second while researching.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
state.set("", "", "", time.Since(start))
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Write system prompt to temp file.
|
||||
tmpFile, err := os.CreateTemp("", "kit-kit-*.txt")
|
||||
if err != nil {
|
||||
close(done)
|
||||
state.set("error", "", "temp file error: "+err.Error(), time.Since(start))
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
return "Error creating temp file: " + err.Error(), 1, time.Since(start)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString(state.Def.System); err != nil {
|
||||
tmpFile.Close()
|
||||
close(done)
|
||||
state.set("error", "", "write error: "+err.Error(), time.Since(start))
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
return "Error writing system prompt: " + err.Error(), 1, time.Since(start)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Build subprocess arguments. Use --json for structured output parsing.
|
||||
// Don't pass --model; the subprocess inherits the same config/env default.
|
||||
args := []string{
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--system-prompt", tmpFile.Name(),
|
||||
question,
|
||||
}
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
err = cmd.Run()
|
||||
close(done)
|
||||
elapsed = time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
// On error, prefer stderr for the error message; fall back to stdout.
|
||||
errText := strings.TrimSpace(stderrBuf.String())
|
||||
if errText == "" {
|
||||
errText = strings.TrimSpace(stdoutBuf.String())
|
||||
}
|
||||
errLine := errText
|
||||
if idx := strings.Index(errLine, "\n"); idx >= 0 {
|
||||
errLine = errLine[:idx]
|
||||
}
|
||||
state.set("error", "", truncate(strings.TrimSpace(errLine), 80), elapsed)
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
code := 1
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
}
|
||||
return errText, code, elapsed
|
||||
}
|
||||
|
||||
// Parse JSON output from subprocess.
|
||||
var parsed kitJSONOutput
|
||||
result := strings.TrimSpace(stdoutBuf.String())
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err == nil {
|
||||
result = parsed.Response
|
||||
}
|
||||
// else: fall back to raw stdout (e.g. older kit binary without --json)
|
||||
|
||||
// Extract last non-empty line for the card.
|
||||
lines := strings.Split(result, "\n")
|
||||
var lastLine string
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if strings.TrimSpace(lines[i]) != "" {
|
||||
lastLine = lines[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
state.set("done", "", truncate(lastLine, 60), elapsed)
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
|
||||
return result, 0, elapsed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator system prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func buildOrchestratorPrompt(cwd string) string {
|
||||
orchPath := filepath.Join(cwd, ".kit", "agents", "kit-kit", "orchestrator.md")
|
||||
raw, err := os.ReadFile(orchPath)
|
||||
if err != nil {
|
||||
// Fallback: generate a basic orchestrator prompt.
|
||||
return buildDefaultOrchestratorPrompt()
|
||||
}
|
||||
|
||||
text := string(raw)
|
||||
// Strip frontmatter if present.
|
||||
if strings.HasPrefix(text, "---\n") {
|
||||
if idx := strings.Index(text[4:], "\n---\n"); idx >= 0 {
|
||||
text = strings.TrimSpace(text[4+idx+5:])
|
||||
}
|
||||
}
|
||||
|
||||
list := expertList()
|
||||
catalog := buildExpertCatalog(list)
|
||||
names := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
names[i] = displayName(s.Def.Name)
|
||||
}
|
||||
|
||||
text = strings.ReplaceAll(text, "{{EXPERT_COUNT}}", fmt.Sprintf("%d", len(list)))
|
||||
text = strings.ReplaceAll(text, "{{EXPERT_NAMES}}", strings.Join(names, ", "))
|
||||
text = strings.ReplaceAll(text, "{{EXPERT_CATALOG}}", catalog)
|
||||
return text
|
||||
}
|
||||
|
||||
func buildExpertCatalog(list []*expertState) string {
|
||||
var sb strings.Builder
|
||||
for _, s := range list {
|
||||
fmt.Fprintf(&sb, "### %s\n", displayName(s.Def.Name))
|
||||
fmt.Fprintf(&sb, "**Query as:** `%s`\n", s.Def.Name)
|
||||
fmt.Fprintf(&sb, "%s\n\n", s.Def.Description)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildDefaultOrchestratorPrompt() string {
|
||||
list := expertList()
|
||||
names := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
names[i] = displayName(s.Def.Name)
|
||||
}
|
||||
catalog := buildExpertCatalog(list)
|
||||
|
||||
return fmt.Sprintf(`You are Kit Kit, an orchestrator agent with %d domain experts: %s.
|
||||
|
||||
Use the query_experts tool to consult experts IN PARALLEL before writing code.
|
||||
Always query multiple experts at once when the task spans multiple domains.
|
||||
|
||||
## Available Experts
|
||||
|
||||
%s
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Analyze the user's request to identify which domains are relevant.
|
||||
2. Use query_experts to ask specific questions of the relevant experts.
|
||||
3. Synthesize the expert findings into a coherent implementation.
|
||||
4. Write the actual code/files — you are the only agent that writes.
|
||||
|
||||
## Rules
|
||||
|
||||
- ALWAYS query experts before implementing. Never guess.
|
||||
- Ask SPECIFIC questions. "How does X work?" is better than "Tell me about X".
|
||||
- Query multiple experts in a single call when possible (they run in parallel).
|
||||
- If an expert returns insufficient info, query again with a more specific question.
|
||||
`, len(list), strings.Join(names, ", "), catalog)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Init(api ext.API) {
|
||||
kitBinary = findKitBinary()
|
||||
|
||||
// ── Session Start: load experts, show grid ──
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
loadExperts(ctx.CWD)
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
|
||||
names := expertNames()
|
||||
n := len(expertList())
|
||||
if n > 0 {
|
||||
ctx.PrintInfo(fmt.Sprintf(
|
||||
"Kit Kit loaded — %d experts: %s\n\n"+
|
||||
"/experts List experts and status\n"+
|
||||
"/experts-grid N Set grid columns (1-5)\n\n"+
|
||||
"Ask me to build any Kit component!",
|
||||
n, names))
|
||||
} else {
|
||||
ctx.PrintInfo(
|
||||
"Kit Kit loaded — no experts found.\n\n" +
|
||||
"Add agent .md files to .kit/agents/kit-kit/ to get started.\n" +
|
||||
"See examples/extensions/kit-kit-agents/ for samples.")
|
||||
}
|
||||
})
|
||||
|
||||
// ── Before Agent Start: inject orchestrator system prompt ──
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
|
||||
prompt := buildOrchestratorPrompt(ctx.CWD)
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
|
||||
// ── Agent End: update footer ──
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
updateFooter()
|
||||
})
|
||||
|
||||
// ── Session Shutdown: cleanup ──
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveWidget("kit-kit:grid")
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
|
||||
// ── Tool: query_experts ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "query_experts",
|
||||
Description: `Query one or more Kit domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses.
|
||||
|
||||
Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together.
|
||||
|
||||
Available experts are loaded from .kit/agents/kit-kit/*.md at session start. The default set includes:
|
||||
- ext-expert: Kit extensions — tools, events, commands, widgets, editor interceptors
|
||||
- tui-expert: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
|
||||
- llm-expert: Kit LLM system — providers, streaming, agent loop, tool execution
|
||||
|
||||
Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`,
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"description": "Array of expert queries to run in parallel",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expert": {
|
||||
"type": "string",
|
||||
"description": "Expert name (e.g. ext-expert, tui-expert, llm-expert)"
|
||||
},
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "Specific question about what you need to build"
|
||||
}
|
||||
},
|
||||
"required": ["expert", "question"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["queries"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
Queries []struct {
|
||||
Expert string `json:"expert"`
|
||||
Question string `json:"question"`
|
||||
} `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if len(params.Queries) == 0 {
|
||||
return "No queries provided.", nil
|
||||
}
|
||||
|
||||
// Launch all experts in parallel.
|
||||
type result struct {
|
||||
Expert string
|
||||
Question string
|
||||
Output string
|
||||
ExitCode int
|
||||
Elapsed time.Duration
|
||||
}
|
||||
results := make([]result, len(params.Queries))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, q := range params.Queries {
|
||||
wg.Add(1)
|
||||
go func(idx int, expert, question string) {
|
||||
defer wg.Done()
|
||||
out, code, elapsed := queryExpert(expert, question)
|
||||
results[idx] = result{
|
||||
Expert: expert,
|
||||
Question: question,
|
||||
Output: out,
|
||||
ExitCode: code,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
}(i, q.Expert, q.Question)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Build combined response.
|
||||
var sb strings.Builder
|
||||
for _, r := range results {
|
||||
icon := "✓"
|
||||
if r.ExitCode != 0 {
|
||||
icon = "✗"
|
||||
}
|
||||
fmt.Fprintf(&sb, "## [%s] %s (%ds)\n\n",
|
||||
icon, displayName(r.Expert), int(r.Elapsed.Seconds()))
|
||||
|
||||
out := r.Output
|
||||
if len(out) > 12000 {
|
||||
out = out[:12000] + "\n\n... [truncated — ask follow-up for more]"
|
||||
}
|
||||
sb.WriteString(out)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool Renderer: query_experts ──
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "query_experts",
|
||||
DisplayName: "Query Experts",
|
||||
BorderColor: "#89b4fa",
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct {
|
||||
Queries []struct {
|
||||
Expert string `json:"expert"`
|
||||
} `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, len(args.Queries))
|
||||
for i, q := range args.Queries {
|
||||
names[i] = displayName(q.Expert)
|
||||
}
|
||||
header := fmt.Sprintf("%d experts in parallel: %s",
|
||||
len(args.Queries), strings.Join(names, ", "))
|
||||
return truncate(header, width)
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "" // fall back to default
|
||||
}
|
||||
// Show compact summary: extract ## headers with status
|
||||
var lines []string
|
||||
for _, line := range strings.Split(toolResult, "\n") {
|
||||
if strings.HasPrefix(line, "## [") {
|
||||
lines = append(lines, line[3:]) // strip "## "
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, " · ")
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /experts ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "experts",
|
||||
Description: "List available Kit Kit experts and their status",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
|
||||
list := expertList()
|
||||
if len(list) == 0 {
|
||||
return "No experts loaded. Add agent .md files to .kit/agents/kit-kit/", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, s := range list {
|
||||
status, _, _, _, qc := s.snapshot()
|
||||
fmt.Fprintf(&sb, "%s (%s, queries: %d): %s\n",
|
||||
displayName(s.Def.Name), status, qc, s.Def.Description)
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /experts-grid ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "experts-grid",
|
||||
Description: "Set expert grid columns: /experts-grid <1-5>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
|
||||
args = strings.TrimSpace(args)
|
||||
n := 0
|
||||
if _, err := fmt.Sscanf(args, "%d", &n); err != nil || n < 1 || n > 5 {
|
||||
return "Usage: /experts-grid <1-5>", nil
|
||||
}
|
||||
mu.Lock()
|
||||
gridCols = n
|
||||
mu.Unlock()
|
||||
updateWidget()
|
||||
return fmt.Sprintf("Grid set to %d columns.", n), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates a minimal-chrome extension.
|
||||
// Hides the startup banner, status bar, separator, and input hint, replacing
|
||||
// them with a compact footer showing model name and a context usage bar:
|
||||
//
|
||||
// claude-sonnet-4-5-20250929 [###-------] 30% (3.9K/200K tokens)
|
||||
//
|
||||
// Usage: kit -e examples/extensions/minimal.go
|
||||
func Init(api ext.API) {
|
||||
// updateFooter builds the footer text from current context stats.
|
||||
updateFooter := func(ctx ext.Context) {
|
||||
stats := ctx.GetContextStats()
|
||||
pct := stats.UsagePercent * 100
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
filled := int(math.Round(pct)) / 10
|
||||
bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)
|
||||
|
||||
// Format token counts like the built-in status bar (e.g. "3.9K/200K").
|
||||
fmtTokens := func(n int) string {
|
||||
if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fK", float64(n)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s [%s] %d%%", ctx.Model, bar, int(math.Round(pct)))
|
||||
if stats.ContextLimit > 0 {
|
||||
text += fmt.Sprintf(" (%s/%s tokens)",
|
||||
fmtTokens(stats.EstimatedTokens), fmtTokens(stats.ContextLimit))
|
||||
}
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
}
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
// Strip built-in chrome for a minimal look.
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
// Refresh after each agent turn — context usage changes here.
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init sends a desktop notification when the agent finishes responding.
|
||||
// Useful for long-running tasks — get notified without watching the terminal.
|
||||
|
||||
// Supports: Linux (notify-send), macOS (osascript).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/notify.go
|
||||
func Init(api ext.API) {
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
sendNotification("Kit", "Agent finished responding")
|
||||
})
|
||||
}
|
||||
|
||||
func sendNotification(title, body string) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
// Uses notify-send (libnotify) — available on most Linux desktops.
|
||||
_ = exec.Command("notify-send", "-a", "Kit", title, body).Start()
|
||||
case "darwin":
|
||||
// Uses macOS built-in osascript for native notifications.
|
||||
script := `display notification "` + body + `" with title "` + title + `"`
|
||||
_ = exec.Command("osascript", "-e", script).Start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the overlay dialog system. Extensions can show modal
|
||||
// overlay dialogs that block until the user dismisses them or selects an
|
||||
// action. Four slash commands illustrate different overlay use cases.
|
||||
func Init(api ext.API) {
|
||||
// /overlay-info — simple information dialog (no actions, dismissed with Enter or ESC).
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-info",
|
||||
Description: "Show an info overlay dialog",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
content := "This is a simple informational overlay.\n\n" +
|
||||
"Overlays are modal dialogs that appear over the TUI.\n" +
|
||||
"They can display plain text or markdown content.\n\n" +
|
||||
"Press Enter or ESC to dismiss."
|
||||
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Information",
|
||||
Content: ext.WidgetContent{Text: content},
|
||||
Style: ext.OverlayStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Info dialog cancelled.", nil
|
||||
}
|
||||
return "Info dialog dismissed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /overlay-actions — overlay with action buttons.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-actions",
|
||||
Description: "Show an overlay with action buttons",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Deploy to Production?",
|
||||
Content: ext.WidgetContent{
|
||||
Text: "You are about to deploy the following changes:\n\n" +
|
||||
" - Updated API handlers (3 files)\n" +
|
||||
" - New database migration (v42)\n" +
|
||||
" - Config change: increased rate limit\n\n" +
|
||||
"All tests are passing. Last deploy: 2 hours ago.",
|
||||
},
|
||||
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
|
||||
Width: 65,
|
||||
Actions: []string{"Deploy", "Cancel", "Show Diff"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Deployment cancelled (ESC).", nil
|
||||
}
|
||||
return fmt.Sprintf("Selected action: %q (index %d)", result.Action, result.Index), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /overlay-markdown — overlay with markdown content.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-md",
|
||||
Description: "Show an overlay with markdown content",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
md := "## Build Report\n\n" +
|
||||
"| Component | Status | Duration |\n" +
|
||||
"|-----------|--------|----------|\n" +
|
||||
"| Frontend | Pass | 12.3s |\n" +
|
||||
"| Backend | Pass | 8.7s |\n" +
|
||||
"| E2E Tests | Pass | 45.1s |\n\n" +
|
||||
"**Total time:** 66.1s\n\n" +
|
||||
"All checks passed. Ready to merge."
|
||||
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Build Report",
|
||||
Content: ext.WidgetContent{Text: md, Markdown: true},
|
||||
Style: ext.OverlayStyle{BorderColor: "#a6e3a1"},
|
||||
Width: 70,
|
||||
Actions: []string{"Merge", "Close"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Build report closed.", nil
|
||||
}
|
||||
return fmt.Sprintf("Build report action: %q", result.Action), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /overlay-scroll — overlay with long scrollable content.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-scroll",
|
||||
Description: "Show an overlay with scrollable content",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
var lines []string
|
||||
lines = append(lines, "This overlay has a lot of content to demonstrate scrolling.")
|
||||
lines = append(lines, "Use j/k or arrow keys to scroll through the content.")
|
||||
lines = append(lines, "")
|
||||
for i := 1; i <= 50; i++ {
|
||||
lines = append(lines, fmt.Sprintf(" Line %02d: The quick brown fox jumps over the lazy dog.", i))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "End of content. Press Enter to dismiss or ESC to cancel.")
|
||||
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Log Output (50 lines)",
|
||||
Content: ext.WidgetContent{Text: strings.Join(lines, "\n")},
|
||||
Style: ext.OverlayStyle{BorderColor: "#fab387"},
|
||||
MaxHeight: 20,
|
||||
Actions: []string{"OK", "Copy to Clipboard"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Log viewer cancelled.", nil
|
||||
}
|
||||
return fmt.Sprintf("Log viewer action: %q", result.Action), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init intercepts potentially dangerous bash commands and asks the user for
|
||||
// confirmation before allowing execution.
|
||||
//
|
||||
// Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/
|
||||
//
|
||||
// Usage: kit -e examples/extensions/permission-gate.go
|
||||
func Init(api ext.API) {
|
||||
// Patterns that require user confirmation.
|
||||
dangerousPatterns := []string{
|
||||
"rm -rf",
|
||||
"rm -r /",
|
||||
"sudo ",
|
||||
"chmod 777",
|
||||
"chmod -R 777",
|
||||
"mkfs",
|
||||
"dd if=",
|
||||
"> /dev/",
|
||||
":(){ :|:& };:",
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName != "Bash" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the command from the tool input JSON.
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
cmd := strings.ToLower(input.Command)
|
||||
|
||||
// Check for dangerous patterns.
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if strings.Contains(cmd, strings.ToLower(pattern)) {
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Dangerous command detected: " + input.Command + "\n\nAllow execution?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "User denied execution of dangerous command: " + input.Command,
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
// Init injects a pirate persona into the system prompt, causing the LLM to
|
||||
// respond in pirate-speak. Demonstrates OnBeforeAgentStart system prompt
|
||||
// injection.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/pirate.go
|
||||
func Init(api ext.API) {
|
||||
piratePrompt := `
|
||||
You are a pirate! You must:
|
||||
- Start every response with "Ahoy!"
|
||||
- Use pirate slang (ye, matey, arr, landlubber, etc.)
|
||||
- Refer to files as "scrolls" and directories as "treasure chests"
|
||||
- Call errors "cursed mishaps" and bugs "sea monsters"
|
||||
- End responses with a pirate saying
|
||||
|
||||
Despite the pirate persona, your technical advice must remain accurate and helpful.`
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &piratePrompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init implements a plan/explore mode that restricts the agent to read-only
|
||||
// tools. Toggle with /plan (or start in plan mode via KIT_OPT_PLAN=true).
|
||||
|
||||
// In plan mode the agent can only use read, grep, find, and ls — it cannot
|
||||
// write files, run bash, or make edits. This is useful for exploring a
|
||||
// codebase, reviewing architecture, or generating plans before executing.
|
||||
//
|
||||
// The status bar shows the current mode and the system prompt is augmented
|
||||
// with planning instructions when active.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/plan-mode.go
|
||||
//
|
||||
// Start in plan mode: KIT_OPT_PLAN=true kit -e examples/extensions/plan-mode.go
|
||||
func Init(api ext.API) {
|
||||
// Read-only tool set (matches core.ReadOnlyTools).
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
|
||||
var planActive bool
|
||||
|
||||
// Register "plan" option so users can start in plan mode via env/config.
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// ctrl+alt+p — global shortcut to toggle plan mode.
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
// /plan — toggle plan mode on or off.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode (ctrl+alt+p)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Check option at session start to enable plan mode from env/config.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
opt := strings.ToLower(ctx.GetOption("plan"))
|
||||
if opt == "true" || opt == "1" || opt == "yes" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
// Inject planning instructions into the system prompt when active.
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only exploration).
|
||||
You can ONLY read, search, and explore the codebase. You CANNOT write files,
|
||||
run commands, or make edits. Focus on:
|
||||
- Understanding the codebase structure and architecture
|
||||
- Identifying relevant files and patterns
|
||||
- Generating detailed plans and recommendations
|
||||
- Answering questions about how the code works
|
||||
|
||||
When the user is ready to execute, they will exit plan mode with /plan.`
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &prompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, readOnlyTools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(readOnlyTools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON — agent restricted to read-only tools (read, grep, find, ls).\nUse /plan to toggle off.")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF — all tools re-enabled.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init loads project-specific rules from .kit/rules/ into the system prompt.
|
||||
// Each .md file in the rules directory is injected as additional context,
|
||||
// giving projects a way to customise LLM behaviour without editing the
|
||||
// main system prompt.
|
||||
//
|
||||
// Place rule files in:
|
||||
//
|
||||
// .kit/rules/code-style.md
|
||||
// .kit/rules/testing.md
|
||||
// .kit/rules/security.md
|
||||
//
|
||||
// Usage: kit -e examples/extensions/project-rules.go
|
||||
func Init(api ext.API) {
|
||||
var rules string
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
rulesDir := filepath.Join(ctx.CWD, ".kit", "rules")
|
||||
entries, err := os.ReadDir(rulesDir)
|
||||
if err != nil {
|
||||
return // no rules directory, nothing to do
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".txt") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rulesDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content != "" {
|
||||
parts = append(parts, "## "+strings.TrimSuffix(name, filepath.Ext(name))+"\n\n"+content)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rules = "# Project Rules\n\n" + strings.Join(parts, "\n\n---\n\n")
|
||||
ctx.PrintInfo(fmt.Sprintf("[project-rules] Loaded %d rule file(s) from .kit/rules/", len(parts)))
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if rules == "" {
|
||||
return nil
|
||||
}
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &rules,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the interactive prompt system. It registers three slash
|
||||
// commands that show each prompt type (select, confirm, input), plus a
|
||||
// combined workflow command that chains prompts together.
|
||||
func Init(api ext.API) {
|
||||
|
||||
// /demo-select — shows a selection list.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-select",
|
||||
Description: "Demo: pick from a list",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Choose your deployment target:",
|
||||
Options: []string{"local", "staging", "production"},
|
||||
})
|
||||
if result.Cancelled {
|
||||
return "Selection cancelled.", nil
|
||||
}
|
||||
return fmt.Sprintf("Selected: %s (index %d)", result.Value, result.Index), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /demo-confirm — shows a yes/no confirmation.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-confirm",
|
||||
Description: "Demo: yes/no confirmation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Are you sure you want to deploy?",
|
||||
DefaultValue: false,
|
||||
})
|
||||
if result.Cancelled {
|
||||
return "Confirmation cancelled.", nil
|
||||
}
|
||||
if result.Value {
|
||||
return "Confirmed! Deploying...", nil
|
||||
}
|
||||
return "Declined. Deployment aborted.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /demo-input — shows a text input.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-input",
|
||||
Description: "Demo: free-form text input",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Enter the release tag:",
|
||||
Placeholder: "v1.0.0",
|
||||
})
|
||||
if result.Cancelled {
|
||||
return "Input cancelled.", nil
|
||||
}
|
||||
return fmt.Sprintf("Release tag: %s", result.Value), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /demo-workflow — chains multiple prompts into a workflow.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-workflow",
|
||||
Description: "Demo: chained prompt workflow",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
// Step 1: select environment
|
||||
env := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Step 1/3: Select environment:",
|
||||
Options: []string{"development", "staging", "production"},
|
||||
})
|
||||
if env.Cancelled {
|
||||
return "Workflow cancelled at step 1.", nil
|
||||
}
|
||||
|
||||
// Step 2: enter version tag
|
||||
tag := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Step 2/3: Enter the version tag:",
|
||||
Placeholder: "v1.0.0",
|
||||
})
|
||||
if tag.Cancelled {
|
||||
return "Workflow cancelled at step 2.", nil
|
||||
}
|
||||
|
||||
// Step 3: confirm
|
||||
confirm := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: fmt.Sprintf(
|
||||
"Step 3/3: Deploy %s to %s?",
|
||||
tag.Value, env.Value),
|
||||
DefaultValue: false,
|
||||
})
|
||||
if confirm.Cancelled {
|
||||
return "Workflow cancelled at step 3.", nil
|
||||
}
|
||||
if !confirm.Value {
|
||||
return "Deployment declined.", nil
|
||||
}
|
||||
|
||||
var summary strings.Builder
|
||||
summary.WriteString("Deployment summary:\n")
|
||||
fmt.Fprintf(&summary, " Environment: %s\n", env.Value)
|
||||
fmt.Fprintf(&summary, " Version: %s\n", tag.Value)
|
||||
summary.WriteString(" Status: initiated")
|
||||
return summary.String(), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init blocks tool calls that attempt to write, edit, or delete files in
|
||||
// protected paths.
|
||||
//
|
||||
// Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key
|
||||
//
|
||||
// Usage: kit -e examples/extensions/protected-paths.go
|
||||
func Init(api ext.API) {
|
||||
// Tools that modify files.
|
||||
writeTools := map[string]bool{
|
||||
"Write": true,
|
||||
"Edit": true,
|
||||
"Bash": true,
|
||||
}
|
||||
|
||||
// Path patterns to protect (checked against the file_path / filePath field).
|
||||
protectedPatterns := []string{
|
||||
".env",
|
||||
".git/",
|
||||
"secrets/",
|
||||
"credentials",
|
||||
".pem",
|
||||
".key",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
}
|
||||
|
||||
// Bash commands that could modify protected files.
|
||||
bashWritePatterns := []string{
|
||||
"rm ", "mv ", "cp ", "> ",
|
||||
"cat >", "echo >", "tee ",
|
||||
"chmod ", "chown ",
|
||||
}
|
||||
|
||||
isProtected := func(path string) bool {
|
||||
lower := strings.ToLower(path)
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if !writeTools[tc.ToolName] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Write/Edit: check the file_path / filePath field.
|
||||
if tc.ToolName == "Write" || tc.ToolName == "Edit" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Try both naming conventions.
|
||||
filePath, _ := input["file_path"].(string)
|
||||
if filePath == "" {
|
||||
filePath, _ = input["filePath"].(string)
|
||||
}
|
||||
if isProtected(filePath) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: writing to protected path: " + filePath,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Bash: check if the command references protected paths.
|
||||
if tc.ToolName == "Bash" {
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only check bash commands that look like file mutations.
|
||||
isMutation := false
|
||||
for _, pat := range bashWritePatterns {
|
||||
if strings.Contains(input.Command, pat) {
|
||||
isMutation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMutation {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if any protected pattern appears in the command.
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(input.Command, p) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: bash command references protected path (" + p + "): " + input.Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
//go:build ignore
|
||||
|
||||
// Subagent Widget — /sub, /subclear, /subrm, /subcont commands with live widgets
|
||||
//
|
||||
// Each /sub spawns a background Kit subagent as a subprocess with its own
|
||||
// live widget showing status, task, elapsed time, and last output line.
|
||||
// /subcont continues a finished subagent by passing conversation history.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /sub <task> — spawn a new subagent
|
||||
// /subcont <id> <prompt> — continue subagent #<id>'s conversation
|
||||
// /subrm <id> — remove subagent #<id> widget
|
||||
// /subclear — clear all subagent widgets
|
||||
//
|
||||
// The LLM can also use tools: subagent_create, subagent_continue,
|
||||
// subagent_remove, subagent_list.
|
||||
//
|
||||
// Ported from https://github.com/disler/pi-vs-claude-code extensions/subagent-widget.ts
|
||||
//
|
||||
// Usage: kit -e examples/extensions/subagent-widget.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// subJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type subState struct {
|
||||
ID int
|
||||
Status string // "running", "done", "error"
|
||||
Task string
|
||||
Chunks []string // accumulated output chunks
|
||||
Elapsed time.Duration
|
||||
TurnCount int
|
||||
History string // conversation history for /subcont
|
||||
Proc *os.Process // active process for killing
|
||||
Removed bool // set when /subrm or /subclear removes this agent
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *subState) appendChunk(chunk string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Chunks = append(s.Chunks, chunk)
|
||||
}
|
||||
|
||||
func (s *subState) setElapsed(d time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Elapsed = d
|
||||
}
|
||||
|
||||
func (s *subState) setProc(p *os.Process) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Proc = p
|
||||
}
|
||||
|
||||
func (s *subState) snapshot() (int, string, string, string, time.Duration, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
fullText := strings.Join(s.Chunks, "")
|
||||
return s.ID, s.Status, s.Task, fullText, s.Elapsed, s.TurnCount
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
latestCtx ext.Context
|
||||
hasCtx bool
|
||||
agents = map[int]*subState{}
|
||||
nextID = 1
|
||||
kitBinary string
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func findKitBinary() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max < 4 {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return string(runes[:max-3]) + "..."
|
||||
}
|
||||
|
||||
func lastNonEmptyLine(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func updateWidgets() {
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
agentsCopy := make([]*subState, 0, len(agents))
|
||||
for _, s := range agents {
|
||||
agentsCopy = append(agentsCopy, s)
|
||||
}
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
for _, state := range agentsCopy {
|
||||
id, status, task, fullText, elapsed, turnCount := state.snapshot()
|
||||
|
||||
var icon, color string
|
||||
switch status {
|
||||
case "running":
|
||||
icon = "●"
|
||||
color = "#89b4fa" // blue
|
||||
case "done":
|
||||
icon = "✓"
|
||||
color = "#a6e3a1" // green
|
||||
default:
|
||||
icon = "✗"
|
||||
color = "#f38ba8" // red
|
||||
}
|
||||
|
||||
taskPreview := truncate(task, 40)
|
||||
|
||||
turnLabel := ""
|
||||
if turnCount > 1 {
|
||||
turnLabel = fmt.Sprintf(" · Turn %d", turnCount)
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("%s Subagent #%d%s %s (%ds)",
|
||||
icon, id, turnLabel, taskPreview, int(elapsed.Seconds()))
|
||||
|
||||
lastLine := truncate(lastNonEmptyLine(fullText), 80)
|
||||
|
||||
text := header
|
||||
if lastLine != "" {
|
||||
text += "\n " + lastLine
|
||||
}
|
||||
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: fmt.Sprintf("subagent:%d", id),
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: color},
|
||||
Priority: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subprocess spawning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func spawnAgent(state *subState) {
|
||||
prompt := state.Task
|
||||
|
||||
state.mu.Lock()
|
||||
history := state.History
|
||||
state.mu.Unlock()
|
||||
|
||||
if history != "" {
|
||||
prompt = "Previous conversation:\n" + history + "\n\nNew instruction: " + state.Task
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
prompt,
|
||||
}
|
||||
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
state.mu.Lock()
|
||||
state.Status = "error"
|
||||
state.Chunks = append(state.Chunks, "Pipe error: "+err.Error())
|
||||
state.mu.Unlock()
|
||||
updateWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
state.mu.Lock()
|
||||
state.Status = "error"
|
||||
state.Chunks = append(state.Chunks, "Pipe error: "+err.Error())
|
||||
state.mu.Unlock()
|
||||
updateWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
state.mu.Lock()
|
||||
state.Status = "error"
|
||||
state.Chunks = append(state.Chunks, "Start error: "+err.Error())
|
||||
state.mu.Unlock()
|
||||
updateWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
state.setProc(cmd.Process)
|
||||
|
||||
// Timer goroutine: update widget every second with elapsed time.
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-doneCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
state.setElapsed(time.Since(start))
|
||||
updateWidgets()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stderr in background goroutine (live widget updates).
|
||||
var readWg sync.WaitGroup
|
||||
readWg.Add(1)
|
||||
go func() {
|
||||
defer readWg.Done()
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) != "" {
|
||||
state.appendChunk(line + "\n")
|
||||
updateWidgets()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stdout into a separate buffer (JSON output from --json mode).
|
||||
var stdoutBuf strings.Builder
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
// Wait for all pipe readers, then the process.
|
||||
readWg.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
close(doneCh) // stop timer
|
||||
|
||||
// Parse JSON output from --json mode to extract the response.
|
||||
var result string
|
||||
rawStdout := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subJSONOutput
|
||||
if rawStdout != "" && json.Unmarshal([]byte(rawStdout), &parsed) == nil && parsed.Response != "" {
|
||||
result = parsed.Response
|
||||
} else {
|
||||
// Fallback: use raw stdout (e.g. older kit binary without --json).
|
||||
result = rawStdout
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.Elapsed = time.Since(start)
|
||||
state.Proc = nil
|
||||
if waitErr != nil {
|
||||
state.Status = "error"
|
||||
} else {
|
||||
state.Status = "done"
|
||||
}
|
||||
|
||||
// Save history for /subcont continuations (cap at 16 KB).
|
||||
state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n",
|
||||
state.TurnCount, state.Task, result)
|
||||
if len(state.History) > 16000 {
|
||||
state.History = state.History[len(state.History)-16000:]
|
||||
}
|
||||
|
||||
removed := state.Removed
|
||||
id := state.ID
|
||||
elapsed := state.Elapsed
|
||||
turnCount := state.TurnCount
|
||||
task := state.Task
|
||||
state.mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
|
||||
// Don't deliver follow-up for agents removed via /subrm or /subclear.
|
||||
if removed {
|
||||
return
|
||||
}
|
||||
|
||||
// Deliver result as a follow-up message so the LLM can act on it.
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
|
||||
if ok {
|
||||
resultText := result
|
||||
if len(resultText) > 8000 {
|
||||
resultText = resultText[:8000] + "\n\n... [truncated]"
|
||||
}
|
||||
turnSuffix := ""
|
||||
if turnCount > 1 {
|
||||
turnSuffix = fmt.Sprintf(" (Turn %d)", turnCount)
|
||||
}
|
||||
ctx.SendMessage(fmt.Sprintf(
|
||||
"Subagent #%d%s finished \"%s\" in %ds.\n\nResult:\n%s",
|
||||
id, turnSuffix, task, int(elapsed.Seconds()), resultText,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Init(api ext.API) {
|
||||
kitBinary = findKitBinary()
|
||||
|
||||
// ── Session Start: reset state, show help ──
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
// Kill lingering agents from previous session.
|
||||
mu.Lock()
|
||||
for id, state := range agents {
|
||||
state.mu.Lock()
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
}
|
||||
state.mu.Unlock()
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
|
||||
}
|
||||
agents = map[int]*subState{}
|
||||
nextID = 1
|
||||
mu.Unlock()
|
||||
|
||||
ctx.PrintInfo(
|
||||
"Subagent Widget loaded\n\n" +
|
||||
"/sub <task> Spawn a new subagent\n" +
|
||||
"/subcont <id> <prompt> Continue a finished subagent\n" +
|
||||
"/subrm <id> Remove a subagent\n" +
|
||||
"/subclear Clear all subagents\n\n" +
|
||||
"The LLM can also spawn subagents with the subagent_create tool.")
|
||||
})
|
||||
|
||||
// ── Agent End: keep context fresh ──
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// ── Session Shutdown: cleanup ──
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for id, state := range agents {
|
||||
state.mu.Lock()
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
}
|
||||
state.mu.Unlock()
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
|
||||
}
|
||||
agents = map[int]*subState{}
|
||||
})
|
||||
|
||||
// ── Tool: subagent_create ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_create",
|
||||
Description: `Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results are delivered as a follow-up message when the subagent finishes.
|
||||
|
||||
Each subagent runs as a separate Kit subprocess with full tool access. Use this to delegate independent subtasks that can run in parallel with your main work.`,
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The complete task description for the subagent to perform"
|
||||
}
|
||||
},
|
||||
"required": ["task"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(params.Task) == "" {
|
||||
return "", fmt.Errorf("task is required")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
id := nextID
|
||||
nextID++
|
||||
state := &subState{
|
||||
ID: id,
|
||||
Status: "running",
|
||||
Task: params.Task,
|
||||
TurnCount: 1,
|
||||
}
|
||||
agents[id] = state
|
||||
mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Subagent #%d spawned and running in background.", id), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool: subagent_continue ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_continue",
|
||||
Description: `Continue an existing subagent's conversation with a follow-up prompt. The subagent receives its previous conversation history as context. Use this to refine or extend a finished subagent's work.`,
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the subagent to continue"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The follow-up prompt or new instructions"
|
||||
}
|
||||
},
|
||||
"required": ["id", "prompt"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
ID int `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[params.ID]
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
if state.Status == "running" {
|
||||
state.mu.Unlock()
|
||||
return fmt.Sprintf("Error: Subagent #%d is still running.", params.ID), nil
|
||||
}
|
||||
state.Status = "running"
|
||||
state.Task = params.Prompt
|
||||
state.Chunks = nil
|
||||
state.Elapsed = 0
|
||||
state.TurnCount++
|
||||
turn := state.TurnCount
|
||||
state.mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Subagent #%d continuing conversation in background (Turn %d).", params.ID, turn), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool: subagent_remove ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_remove",
|
||||
Description: "Remove a specific subagent. Kills it if currently running.",
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the subagent to remove"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[params.ID]
|
||||
if !ok {
|
||||
mu.Unlock()
|
||||
return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil
|
||||
}
|
||||
delete(agents, params.ID)
|
||||
mu.Unlock()
|
||||
|
||||
state.mu.Lock()
|
||||
state.Removed = true
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
}
|
||||
state.mu.Unlock()
|
||||
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok2 := hasCtx
|
||||
mu.Unlock()
|
||||
if ok2 {
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", params.ID))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Subagent #%d removed.", params.ID), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool: subagent_list ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_list",
|
||||
Description: "List all active and finished subagents with their IDs, tasks, and status.",
|
||||
Parameters: `{"type": "object", "properties": {}}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
mu.Lock()
|
||||
agentsCopy := make([]*subState, 0, len(agents))
|
||||
for _, s := range agents {
|
||||
agentsCopy = append(agentsCopy, s)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
if len(agentsCopy) == 0 {
|
||||
return "No active subagents.", nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagents:\n")
|
||||
for _, s := range agentsCopy {
|
||||
id, status, task, _, _, turnCount := s.snapshot()
|
||||
fmt.Fprintf(&sb, "#%d [%s] (Turn %d) — %s\n",
|
||||
id, strings.ToUpper(status), turnCount, task)
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool Renderers ──
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "subagent_create",
|
||||
DisplayName: "Spawn Subagent",
|
||||
BorderColor: "#89b4fa",
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct {
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncate(args.Task, width)
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
return truncate(toolResult, width)
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "subagent_continue",
|
||||
DisplayName: "Continue Subagent",
|
||||
BorderColor: "#cba6f7",
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct {
|
||||
ID int `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncate(fmt.Sprintf("#%d: %s", args.ID, args.Prompt), width)
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
return truncate(toolResult, width)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /sub <task> ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "sub",
|
||||
Description: "Spawn a subagent with live widget: /sub <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /sub <task>", nil
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
id := nextID
|
||||
nextID++
|
||||
state := &subState{
|
||||
ID: id,
|
||||
Status: "running",
|
||||
Task: task,
|
||||
TurnCount: 1,
|
||||
}
|
||||
agents[id] = state
|
||||
mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Subagent #%d spawned: %s", id, truncate(task, 60)), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /subcont <id> <prompt> ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subcont",
|
||||
Description: "Continue subagent conversation: /subcont <id> <prompt>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
trimmed := strings.TrimSpace(args)
|
||||
spaceIdx := strings.IndexByte(trimmed, ' ')
|
||||
if spaceIdx < 0 {
|
||||
return "Usage: /subcont <id> <prompt>", nil
|
||||
}
|
||||
|
||||
num, err := strconv.Atoi(trimmed[:spaceIdx])
|
||||
if err != nil {
|
||||
return "Usage: /subcont <id> <prompt>", nil
|
||||
}
|
||||
prompt := strings.TrimSpace(trimmed[spaceIdx+1:])
|
||||
if prompt == "" {
|
||||
return "Usage: /subcont <id> <prompt>", nil
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[num]
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Sprintf("No subagent #%d found. Use /sub to create one.", num), nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
if state.Status == "running" {
|
||||
state.mu.Unlock()
|
||||
return fmt.Sprintf("Subagent #%d is still running — wait for it to finish.", num), nil
|
||||
}
|
||||
state.Status = "running"
|
||||
state.Task = prompt
|
||||
state.Chunks = nil
|
||||
state.Elapsed = 0
|
||||
state.TurnCount++
|
||||
turn := state.TurnCount
|
||||
state.mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Continuing subagent #%d (Turn %d): %s", num, turn, truncate(prompt, 50)), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /subrm <id> ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subrm",
|
||||
Description: "Remove a subagent widget: /subrm <id>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
num, err := strconv.Atoi(strings.TrimSpace(args))
|
||||
if err != nil {
|
||||
return "Usage: /subrm <id>", nil
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[num]
|
||||
if !ok {
|
||||
mu.Unlock()
|
||||
return fmt.Sprintf("No subagent #%d found.", num), nil
|
||||
}
|
||||
delete(agents, num)
|
||||
mu.Unlock()
|
||||
|
||||
state.mu.Lock()
|
||||
state.Removed = true
|
||||
killed := false
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
killed = true
|
||||
}
|
||||
state.mu.Unlock()
|
||||
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", num))
|
||||
|
||||
if killed {
|
||||
return fmt.Sprintf("Subagent #%d killed and removed.", num), nil
|
||||
}
|
||||
return fmt.Sprintf("Subagent #%d removed.", num), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /subclear ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subclear",
|
||||
Description: "Clear all subagent widgets",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
agentsCopy := make(map[int]*subState, len(agents))
|
||||
for k, v := range agents {
|
||||
agentsCopy[k] = v
|
||||
}
|
||||
agents = map[int]*subState{}
|
||||
nextID = 1
|
||||
mu.Unlock()
|
||||
|
||||
killed := 0
|
||||
total := len(agentsCopy)
|
||||
for id, state := range agentsCopy {
|
||||
state.mu.Lock()
|
||||
state.Removed = true
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
killed++
|
||||
}
|
||||
state.mu.Unlock()
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return "No subagents to clear.", nil
|
||||
}
|
||||
msg := fmt.Sprintf("Cleared %d subagent", total)
|
||||
if total != 1 {
|
||||
msg += "s"
|
||||
}
|
||||
if killed > 0 {
|
||||
msg += fmt.Sprintf(" (%d killed)", killed)
|
||||
}
|
||||
msg += "."
|
||||
return msg, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds a /summarize command that generates a concise summary of the
|
||||
// current conversation using a direct LLM completion. Demonstrates the
|
||||
// ctx.Complete API.
|
||||
//
|
||||
// The summary is displayed in a styled block and can optionally be saved
|
||||
// to the session via AppendEntry for later retrieval.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/summarize.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summarize",
|
||||
Description: "Summarize the current conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
msgs := ctx.GetMessages()
|
||||
if len(msgs) == 0 {
|
||||
ctx.PrintInfo("Nothing to summarize — no messages yet.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Build a text representation of the conversation.
|
||||
var parts []string
|
||||
for _, m := range msgs {
|
||||
content := m.Content
|
||||
if len(content) > 2000 {
|
||||
content = content[:1997] + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s]: %s", m.Role, content))
|
||||
}
|
||||
conversation := strings.Join(parts, "\n\n")
|
||||
|
||||
ctx.PrintInfo("Generating summary...")
|
||||
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
System: `You are a concise summarization assistant. Summarize the conversation below in 3-5 bullet points. Focus on:
|
||||
- What was discussed or requested
|
||||
- Key decisions or outcomes
|
||||
- Any pending action items
|
||||
|
||||
Be concise. Use plain text, no markdown headers.`,
|
||||
Prompt: conversation,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.PrintError("Summary failed: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
summary := strings.TrimSpace(resp.Text)
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary (%d messages, %d tokens used)", len(msgs), resp.InputTokens+resp.OutputTokens),
|
||||
})
|
||||
|
||||
// Persist the summary in the session for later retrieval.
|
||||
ctx.AppendEntry("summary", summary)
|
||||
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /summaries — list all saved summaries.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summaries",
|
||||
Description: "List saved conversation summaries",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("summary")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No summaries saved yet. Use /summarize to create one.")
|
||||
return "", nil
|
||||
}
|
||||
for i, e := range entries {
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: e.Data,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary #%d (%s)", i+1, e.Timestamp[:19]),
|
||||
})
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the custom tool rendering system. It registers
|
||||
// renderers that override how specific tools display their headers,
|
||||
// result bodies, display names, border colors, and backgrounds.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/tool-renderer-demo.go
|
||||
//
|
||||
// Then ask the agent to read a file or run a bash command to see
|
||||
// the custom rendering in action.
|
||||
func Init(api ext.API) {
|
||||
// Custom renderer for the "read" tool: custom display name,
|
||||
// blue border, compact filename-only header.
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "read",
|
||||
DisplayName: "File",
|
||||
BorderColor: "#89b4fa", // Catppuccin blue
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "" // fall back to default
|
||||
}
|
||||
|
||||
// Show just the filename, not the full path.
|
||||
parts := strings.Split(path, "/")
|
||||
name := parts[len(parts)-1]
|
||||
|
||||
// Include offset/limit if present.
|
||||
var extras []string
|
||||
if offset, ok := args["offset"]; ok {
|
||||
extras = append(extras, fmt.Sprintf("from line %v", offset))
|
||||
}
|
||||
if limit, ok := args["limit"]; ok {
|
||||
extras = append(extras, fmt.Sprintf("max %v lines", limit))
|
||||
}
|
||||
|
||||
result := name
|
||||
if len(extras) > 0 {
|
||||
result += " (" + strings.Join(extras, ", ") + ")"
|
||||
}
|
||||
|
||||
if len(result) > width {
|
||||
return result[:width-3] + "..."
|
||||
}
|
||||
return result
|
||||
},
|
||||
// RenderBody is nil — fall back to the builtin read renderer
|
||||
// which already provides syntax-highlighted code blocks.
|
||||
})
|
||||
|
||||
// Custom renderer for the "bash" tool: renamed to "Shell",
|
||||
// dark background, custom header with $ prefix.
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell",
|
||||
Background: "#1e1e2e", // Dark background
|
||||
BorderColor: "#a6e3a1", // Catppuccin green
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
cmd, _ := args["command"].(string)
|
||||
if cmd == "" {
|
||||
return "" // fall back to default
|
||||
}
|
||||
|
||||
// Show first line of command with a $ prefix.
|
||||
lines := strings.SplitN(cmd, "\n", 2)
|
||||
display := "$ " + lines[0]
|
||||
if len(lines) > 1 {
|
||||
display += " ..."
|
||||
}
|
||||
|
||||
if len(display) > width {
|
||||
return display[:width-3] + "..."
|
||||
}
|
||||
return display
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "" // fall back to default error rendering
|
||||
}
|
||||
|
||||
// Count lines and show a summary at the end.
|
||||
lines := strings.Split(toolResult, "\n")
|
||||
lineCount := len(lines)
|
||||
|
||||
// Show the first few lines of output.
|
||||
maxShow := 10
|
||||
if lineCount <= maxShow {
|
||||
return toolResult
|
||||
}
|
||||
|
||||
shown := strings.Join(lines[:maxShow], "\n")
|
||||
return fmt.Sprintf("%s\n\n[%d lines total, showing first %d]",
|
||||
shown, lineCount, maxShow)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the widget system by showing a persistent status
|
||||
// widget above the input area. The widget updates on each agent turn
|
||||
// to show a running count of tool calls and the last tool used.
|
||||
func Init(api ext.API) {
|
||||
var toolCallCount int
|
||||
var lastToolName string
|
||||
|
||||
// Show initial status widget when session starts.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "widget-status:info",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Session started | CWD: %s | Model: %s", ctx.CWD, ctx.Model),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Update the widget after each tool call with a running count.
|
||||
api.OnToolResult(func(tr ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
toolCallCount++
|
||||
lastToolName = tr.ToolName
|
||||
|
||||
status := "ok"
|
||||
if tr.IsError {
|
||||
status = "error"
|
||||
}
|
||||
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "widget-status:info",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf(
|
||||
"Tools: %d calls | Last: %s (%s) | %s",
|
||||
toolCallCount, lastToolName, status,
|
||||
time.Now().Format("15:04:05"),
|
||||
),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
// "!widget-off" — removes the status widget.
|
||||
// "!widget-on" — restores the status widget.
|
||||
api.OnInput(func(ie ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
switch ie.Text {
|
||||
case "!widget-off":
|
||||
ctx.RemoveWidget("widget-status:info")
|
||||
ctx.PrintInfo("Status widget removed.")
|
||||
return &ext.InputResult{Action: "handled"}
|
||||
|
||||
case "!widget-on":
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "widget-status:info",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Tools: %d calls | %s", toolCallCount, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
ctx.PrintInfo("Status widget restored.")
|
||||
return &ext.InputResult{Action: "handled"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Clean up widget on shutdown.
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveWidget("widget-status:info")
|
||||
})
|
||||
}
|
||||
@@ -4,13 +4,17 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/bubbletea/v2 v2.0.0
|
||||
charm.land/fantasy v0.10.0
|
||||
charm.land/bubbletea/v2 v2.0.1
|
||||
charm.land/fantasy v0.11.1
|
||||
charm.land/lipgloss/v2 v2.0.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/aymanbagabas/go-udiff v0.4.0
|
||||
github.com/charmbracelet/fang v0.4.4
|
||||
github.com/mark3labs/mcp-go v0.44.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/mark3labs/mcp-go v0.44.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
golang.org/x/term v0.40.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -22,24 +26,22 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
|
||||
github.com/aws/smithy-go v1.24.1 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
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
|
||||
@@ -48,21 +50,21 @@ require (
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/log v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect
|
||||
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-20260223200540-d6a276319c45 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260223200540-d6a276319c45 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 // 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
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
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/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
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
@@ -71,14 +73,14 @@ require (
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.11 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.16 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.3 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.12 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.5 // indirect
|
||||
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
|
||||
@@ -97,28 +99,27 @@ require (
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/traefik/yaegi v0.16.1 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.269.0 // indirect
|
||||
google.golang.org/genai v1.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
google.golang.org/genai v1.49.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
|
||||
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8=
|
||||
charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY=
|
||||
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
|
||||
charm.land/bubbletea/v2 v2.0.1/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/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
@@ -32,36 +32,36 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
|
||||
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
||||
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
|
||||
@@ -88,18 +88,18 @@ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0r
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45 h1:t/EWU3ZOrVxmr2d19f+1wnWr92p1O82oOTm7ASxodsA=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/192monmpmRICpSPrFRzkIO+xfhioV6/nwrQdkDTj10=
|
||||
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/slice v0.0.0-20260223200540-d6a276319c45 h1:jgQlAnMmwbjtvd91AzjWWFtwpIZ2P/Nspx5zyrhmPec=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260223200540-d6a276319c45/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
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/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=
|
||||
@@ -114,6 +114,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
|
||||
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -134,8 +136,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -155,8 +157,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -169,12 +171,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/kaptinlin/go-i18n v0.2.11 h1:OayNt8mWt8nDaqAOp09/C1VG9Y5u8LpQnnxbyGARDV4=
|
||||
github.com/kaptinlin/go-i18n v0.2.11/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
|
||||
github.com/kaptinlin/jsonpointer v0.4.16 h1:Ux4w4FY+uLv+K+TxaCJtM/TpPv+1+eS6gH4Z9/uhOuA=
|
||||
github.com/kaptinlin/jsonpointer v0.4.16/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
|
||||
github.com/kaptinlin/jsonschema v0.7.3 h1:kyIydij76ORiSxmfy0xFYy0cOx8MwG6pyyaSoQshsK4=
|
||||
github.com/kaptinlin/jsonschema v0.7.3/go.mod h1:Ys6zr+W6/1330FzZEouFrAYImK+AmYt5HQVTHQQXQo8=
|
||||
github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
|
||||
github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
|
||||
github.com/kaptinlin/jsonschema v0.7.5 h1:jkK4a3NyzNoGlvu12CsL3IcqNMVa5sL51HPVa0nWcPY=
|
||||
github.com/kaptinlin/jsonschema v0.7.5/go.mod h1:3gIWnptl+SWMyfMR2r4TXXd0xsQZ1m50AKrwmcUONSg=
|
||||
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
|
||||
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -187,8 +189,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
|
||||
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8=
|
||||
github.com/mark3labs/mcp-go v0.44.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
@@ -269,28 +271,28 @@ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 h1:w/o339tDd6Qtu3+ytwt+/jon2yjAs3Ot8Xq8pelfhSo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
@@ -308,12 +310,12 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo=
|
||||
google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
|
||||
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package acpserver implements a Kit-backed ACP (Agent Client Protocol) agent.
|
||||
//
|
||||
// It bridges Kit's LLM execution, tool system, and session management to the
|
||||
// ACP protocol over stdio, allowing ACP clients (such as OpenCode) to drive
|
||||
// Kit as a remote coding agent.
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// Version is injected at build time; fallback to "dev".
|
||||
var Version = "dev"
|
||||
|
||||
// Agent implements the acp.Agent interface, delegating to Kit for LLM
|
||||
// execution, tool calls, and session management.
|
||||
type Agent struct {
|
||||
conn *acp.AgentSideConnection
|
||||
registry *sessionRegistry
|
||||
|
||||
// toolCallCounter provides unique IDs for tool calls within a turn.
|
||||
toolCallCounter atomic.Int64
|
||||
}
|
||||
|
||||
// NewAgent creates a new ACP agent backed by Kit.
|
||||
func NewAgent() *Agent {
|
||||
return &Agent{
|
||||
registry: newSessionRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAgentConnection stores the connection so the agent can send session
|
||||
// updates (streaming, tool calls, etc.) back to the ACP client. This follows
|
||||
// the AgentConnAware duck-typing pattern from the SDK.
|
||||
func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) {
|
||||
a.conn = conn
|
||||
}
|
||||
|
||||
// Close shuts down all active sessions.
|
||||
func (a *Agent) Close() {
|
||||
a.registry.closeAll()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acp.Agent interface implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Authenticate handles authentication requests. Kit doesn't require auth for
|
||||
// local stdio usage, so this is a no-op.
|
||||
func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
|
||||
return acp.AuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
// Initialize negotiates capabilities with the ACP client.
|
||||
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
|
||||
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
|
||||
|
||||
return acp.InitializeResponse{
|
||||
ProtocolVersion: acp.ProtocolVersion(1),
|
||||
AgentCapabilities: acp.AgentCapabilities{
|
||||
LoadSession: true,
|
||||
PromptCapabilities: acp.PromptCapabilities{
|
||||
EmbeddedContext: true,
|
||||
Image: true,
|
||||
},
|
||||
},
|
||||
AgentInfo: &acp.Implementation{
|
||||
Name: "Kit",
|
||||
Version: Version,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewSession creates a new Kit session for the given working directory.
|
||||
func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (acp.NewSessionResponse, error) {
|
||||
cwd := params.Cwd
|
||||
if cwd == "" {
|
||||
return acp.NewSessionResponse{}, acp.NewInvalidParams("cwd is required")
|
||||
}
|
||||
|
||||
log.Debug("acp: new_session", "cwd", cwd)
|
||||
|
||||
sess, err := a.registry.create(ctx, cwd)
|
||||
if err != nil {
|
||||
return acp.NewSessionResponse{}, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
return acp.NewSessionResponse{
|
||||
SessionId: acp.SessionId(sess.sessionID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Prompt handles the main agent execution. It subscribes to Kit's event bus,
|
||||
// converts events to ACP session updates, and runs the prompt through Kit's
|
||||
// full turn lifecycle (hooks, LLM, tool calls, persistence).
|
||||
func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams(
|
||||
fmt.Sprintf("session not found: %s", sessionID),
|
||||
)
|
||||
}
|
||||
|
||||
// Extract text from prompt content blocks.
|
||||
promptText := extractPromptText(params.Prompt)
|
||||
if promptText == "" {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
|
||||
}
|
||||
|
||||
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
|
||||
|
||||
// Create a cancellable context for this prompt turn.
|
||||
promptCtx, cancel := context.WithCancel(ctx)
|
||||
sess.setCancel(cancel)
|
||||
defer sess.clearCancel()
|
||||
|
||||
// Subscribe to Kit events and stream them as ACP session updates.
|
||||
unsub := a.subscribeEvents(promptCtx, sess.kit, params.SessionId)
|
||||
defer unsub()
|
||||
|
||||
// Run the prompt through Kit's full turn lifecycle.
|
||||
_, err := sess.kit.PromptResult(promptCtx, promptText)
|
||||
if err != nil {
|
||||
if promptCtx.Err() != nil {
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonCancelled,
|
||||
}, nil
|
||||
}
|
||||
return acp.PromptResponse{}, fmt.Errorf("prompt failed: %w", err)
|
||||
}
|
||||
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonEndTurn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel cancels the ongoing prompt for a session.
|
||||
func (a *Agent) Cancel(_ context.Context, params acp.CancelNotification) error {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return nil // No-op if session doesn't exist.
|
||||
}
|
||||
|
||||
log.Debug("acp: cancel", "session", sessionID)
|
||||
sess.cancelPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionMode is a no-op for now — Kit doesn't have built-in session modes.
|
||||
func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) {
|
||||
return acp.SetSessionModeResponse{}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event streaming: Kit events → ACP SessionUpdate notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subscribeEvents subscribes to Kit's event bus and forwards events as ACP
|
||||
// session update notifications to the client.
|
||||
func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.SessionId) func() {
|
||||
return k.Subscribe(func(e kit.Event) {
|
||||
// Don't send updates after the context is cancelled.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var update *acp.SessionUpdate
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Chunk)
|
||||
update = &u
|
||||
|
||||
case kit.ReasoningDeltaEvent:
|
||||
u := acp.UpdateAgentThoughtText(ev.Delta)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallEvent:
|
||||
tcID := acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Add(1)))
|
||||
u := acp.StartToolCall(tcID, ev.ToolName,
|
||||
acp.WithStartStatus(acp.ToolCallStatusInProgress),
|
||||
acp.WithStartRawInput(parseToolArgs(ev.ToolArgs)),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolResultEvent:
|
||||
tcID := acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Load()))
|
||||
status := acp.ToolCallStatusCompleted
|
||||
if ev.IsError {
|
||||
status = acp.ToolCallStatusFailed
|
||||
}
|
||||
u := acp.UpdateToolCall(tcID,
|
||||
acp.WithUpdateStatus(status),
|
||||
acp.WithUpdateContent([]acp.ToolCallContent{
|
||||
acp.ToolContent(acp.TextBlock(ev.Result)),
|
||||
}),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallContentEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Content)
|
||||
update = &u
|
||||
}
|
||||
|
||||
if update != nil {
|
||||
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
|
||||
SessionId: sessionID,
|
||||
Update: *update,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// extractPromptText extracts the concatenated text content from ACP content
|
||||
// blocks. Non-text blocks are ignored for now.
|
||||
func extractPromptText(blocks []acp.ContentBlock) string {
|
||||
var text string
|
||||
for _, block := range blocks {
|
||||
if block.Text != nil {
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += block.Text.Text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// parseToolArgs attempts to parse a JSON tool args string into a map for
|
||||
// structured display. Falls back to a simple string wrapper.
|
||||
func parseToolArgs(args string) any {
|
||||
if args == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(args), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
return map[string]any{"input": args}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// acpSession maps an ACP session to a Kit instance with its own tree session.
|
||||
type acpSession struct {
|
||||
kit *kit.Kit
|
||||
cancelFn context.CancelFunc // cancels the current prompt
|
||||
cancelMu sync.Mutex
|
||||
cwd string
|
||||
sessionID string // Kit-generated session ID (from JSONL header)
|
||||
}
|
||||
|
||||
// sessionRegistry is a thread-safe registry of ACP session ID → Kit sessions.
|
||||
type sessionRegistry struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*acpSession // ACP session ID → session
|
||||
}
|
||||
|
||||
func newSessionRegistry() *sessionRegistry {
|
||||
return &sessionRegistry{
|
||||
sessions: make(map[string]*acpSession),
|
||||
}
|
||||
}
|
||||
|
||||
// create creates a new Kit instance with a persisted tree session for the
|
||||
// given working directory. The Kit-generated session ID is used as the ACP
|
||||
// session ID so the mapping is 1:1.
|
||||
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
|
||||
kitInstance, err := kit.New(ctx, &kit.Options{
|
||||
SessionDir: cwd,
|
||||
Quiet: true,
|
||||
Streaming: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create kit instance: %w", err)
|
||||
}
|
||||
|
||||
sessionID := kitInstance.GetSessionID()
|
||||
if sessionID == "" {
|
||||
_ = kitInstance.Close()
|
||||
return nil, fmt.Errorf("kit instance has no session ID")
|
||||
}
|
||||
|
||||
sess := &acpSession{
|
||||
kit: kitInstance,
|
||||
cwd: cwd,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.sessions[sessionID] = sess
|
||||
r.mu.Unlock()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// load opens an existing Kit session by scanning for a matching session ID
|
||||
// in the given working directory.
|
||||
func (r *sessionRegistry) load(ctx context.Context, acpSessionID string, cwd string) (*acpSession, error) {
|
||||
// Find the session file by scanning the session directory.
|
||||
sessions, err := kit.ListSessions(cwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
|
||||
var sessionPath string
|
||||
for _, s := range sessions {
|
||||
if s.ID == acpSessionID {
|
||||
sessionPath = s.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionPath == "" {
|
||||
return nil, fmt.Errorf("session not found: %s", acpSessionID)
|
||||
}
|
||||
|
||||
kitInstance, err := kit.New(ctx, &kit.Options{
|
||||
SessionPath: sessionPath,
|
||||
Quiet: true,
|
||||
Streaming: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open kit session: %w", err)
|
||||
}
|
||||
|
||||
sess := &acpSession{
|
||||
kit: kitInstance,
|
||||
cwd: cwd,
|
||||
sessionID: acpSessionID,
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.sessions[acpSessionID] = sess
|
||||
r.mu.Unlock()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// get retrieves a session by ACP session ID.
|
||||
func (r *sessionRegistry) get(sessionID string) (*acpSession, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
s, ok := r.sessions[sessionID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// remove closes and removes a session from the registry.
|
||||
func (r *sessionRegistry) remove(sessionID string) {
|
||||
r.mu.Lock()
|
||||
sess, ok := r.sessions[sessionID]
|
||||
if ok {
|
||||
delete(r.sessions, sessionID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if ok && sess.kit != nil {
|
||||
_ = sess.kit.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// closeAll closes all sessions.
|
||||
func (r *sessionRegistry) closeAll() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id, sess := range r.sessions {
|
||||
if sess.kit != nil {
|
||||
_ = sess.kit.Close()
|
||||
}
|
||||
delete(r.sessions, id)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelPrompt cancels the current prompt for a session, if any.
|
||||
func (s *acpSession) cancelPrompt() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
if s.cancelFn != nil {
|
||||
s.cancelFn()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// setCancel stores a cancel function for the current prompt.
|
||||
func (s *acpSession) setCancel(cancel context.CancelFunc) {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = cancel
|
||||
}
|
||||
|
||||
// clearCancel clears the stored cancel function (called when prompt completes).
|
||||
func (s *acpSession) clearCancel() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
+147
-13
@@ -58,6 +58,9 @@ type StreamingResponseHandler func(content string)
|
||||
// ToolCallContentHandler is a function type for handling content that accompanies tool calls.
|
||||
type ToolCallContentHandler func(content string)
|
||||
|
||||
// ReasoningDeltaHandler is a function type for handling streaming reasoning/thinking deltas.
|
||||
type ReasoningDeltaHandler func(delta string)
|
||||
|
||||
// Agent represents an AI agent with core tool integration using the fantasy library.
|
||||
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
|
||||
// fantasy.AgentTool implementations — no MCP layer, no serialization overhead.
|
||||
@@ -74,6 +77,7 @@ type Agent struct {
|
||||
streamingEnabled bool
|
||||
coreTools []fantasy.AgentTool
|
||||
extraTools []fantasy.AgentTool
|
||||
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
|
||||
}
|
||||
|
||||
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
|
||||
@@ -156,6 +160,27 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
))
|
||||
}
|
||||
|
||||
// Pass provider-specific options (e.g. OpenAI Responses API reasoning settings).
|
||||
if providerResult.ProviderOptions != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions))
|
||||
}
|
||||
|
||||
// Pass generation parameters when available.
|
||||
if agentConfig.ModelConfig != nil {
|
||||
if agentConfig.ModelConfig.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(agentConfig.ModelConfig.MaxTokens)))
|
||||
}
|
||||
if agentConfig.ModelConfig.Temperature != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*agentConfig.ModelConfig.Temperature)))
|
||||
}
|
||||
if agentConfig.ModelConfig.TopP != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopP(float64(*agentConfig.ModelConfig.TopP)))
|
||||
}
|
||||
if agentConfig.ModelConfig.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*agentConfig.ModelConfig.TopK)))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the fantasy agent
|
||||
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
@@ -179,6 +204,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
streamingEnabled: agentConfig.StreamingEnabled,
|
||||
coreTools: coreTools,
|
||||
extraTools: agentConfig.ExtraTools,
|
||||
toolWrapper: agentConfig.ToolWrapper,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -188,7 +214,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
|
||||
onResponse, onToolCallContent, nil)
|
||||
onResponse, onToolCallContent, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
|
||||
@@ -198,11 +224,14 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Fantasy requires the current user input as Prompt, with prior messages as history.
|
||||
// Extract the last user message text as the prompt, and pass everything before it as Messages.
|
||||
prompt, history := splitPromptAndHistory(messages)
|
||||
// Extract the last user message text and files as the prompt, and pass everything
|
||||
// before it as Messages. Files (e.g. clipboard images) are passed via the Files
|
||||
// field so Fantasy includes them in the API request.
|
||||
prompt, files, history := splitPromptAndHistory(messages)
|
||||
|
||||
// Track current tool call info for callbacks
|
||||
var currentToolName string
|
||||
@@ -213,14 +242,26 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Stream is required to observe tool execution in real time. The non-streaming
|
||||
// Generate path is reserved for the simple case with no callbacks at all.
|
||||
hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil ||
|
||||
onToolCallContent != nil || onStreamingResponse != nil
|
||||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
|
||||
|
||||
if a.streamingEnabled || hasCallbacks {
|
||||
// Use fantasy's streaming agent
|
||||
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
|
||||
// Reasoning/thinking streaming callback
|
||||
OnReasoningDelta: func(id, delta string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onReasoningDelta != nil {
|
||||
onReasoningDelta(delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Text streaming callback
|
||||
OnTextDelta: func(id, text string) error {
|
||||
if ctx.Err() != nil {
|
||||
@@ -302,6 +343,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Non-streaming path with no callbacks — use the simpler Generate call.
|
||||
result, err := a.fantasyAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -322,27 +364,32 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// and returns everything before it as conversation history. Fantasy's agent
|
||||
// requires the current turn's input as Prompt (string), with prior messages
|
||||
// passed separately as Messages (history).
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Message) {
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePart, []fantasy.Message) {
|
||||
if len(messages) == 0 {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Walk backwards to find the last user message
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
// Extract text from the user message parts
|
||||
// Extract text and file parts from the user message
|
||||
var prompt string
|
||||
var files []fantasy.FilePart
|
||||
for _, part := range messages[i].Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
prompt = tp.Text
|
||||
break
|
||||
switch p := part.(type) {
|
||||
case fantasy.TextPart:
|
||||
if prompt == "" {
|
||||
prompt = p.Text
|
||||
}
|
||||
case fantasy.FilePart:
|
||||
files = append(files, p)
|
||||
}
|
||||
}
|
||||
// History is everything except this last user message
|
||||
history := make([]fantasy.Message, 0, len(messages)-1)
|
||||
history = append(history, messages[:i]...)
|
||||
history = append(history, messages[i+1:]...)
|
||||
return prompt, history
|
||||
return prompt, files, history
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,11 +397,11 @@ func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Messag
|
||||
last := messages[len(messages)-1]
|
||||
for _, part := range last.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
return tp.Text, messages[:len(messages)-1]
|
||||
return tp.Text, nil, messages[:len(messages)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return "", messages
|
||||
return "", nil, messages
|
||||
}
|
||||
|
||||
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
|
||||
@@ -455,6 +502,11 @@ func (a *Agent) GetTools() []fantasy.AgentTool {
|
||||
return allTools
|
||||
}
|
||||
|
||||
// GetCoreToolCount returns the number of core tools.
|
||||
func (a *Agent) GetCoreToolCount() int {
|
||||
return len(a.coreTools)
|
||||
}
|
||||
|
||||
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
|
||||
func (a *Agent) GetMCPToolCount() int {
|
||||
if a.toolManager == nil {
|
||||
@@ -481,6 +533,88 @@ func (a *Agent) GetLoadedServerNames() []string {
|
||||
return a.toolManager.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
// SetModel swaps the agent's LLM provider to a new model. The existing tools,
|
||||
// system prompt, and configuration are preserved. The old provider is closed
|
||||
// if it has a closer. Returns the previous model string for notification.
|
||||
func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error {
|
||||
providerResult, err := models.CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create model provider: %v", err)
|
||||
}
|
||||
|
||||
// Rebuild tool list (same as NewAgent).
|
||||
allTools := make([]fantasy.AgentTool, len(a.coreTools))
|
||||
copy(allTools, a.coreTools)
|
||||
if a.toolManager != nil {
|
||||
allTools = append(allTools, a.toolManager.GetTools()...)
|
||||
}
|
||||
if len(a.extraTools) > 0 {
|
||||
allTools = append(allTools, a.extraTools...)
|
||||
}
|
||||
if a.toolWrapper != nil {
|
||||
allTools = a.toolWrapper(allTools)
|
||||
}
|
||||
|
||||
// Rebuild fantasy agent options.
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if a.systemPrompt != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
|
||||
}
|
||||
if len(allTools) > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
|
||||
}
|
||||
if a.maxSteps > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithStopConditions(
|
||||
fantasy.StepCountIs(a.maxSteps),
|
||||
))
|
||||
}
|
||||
|
||||
// Pass provider-specific options (e.g. OpenAI Responses API reasoning settings).
|
||||
if providerResult.ProviderOptions != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions))
|
||||
}
|
||||
|
||||
// Pass generation parameters when available.
|
||||
if config.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(config.MaxTokens)))
|
||||
}
|
||||
if config.Temperature != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*config.Temperature)))
|
||||
}
|
||||
if config.TopP != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopP(float64(*config.TopP)))
|
||||
}
|
||||
if config.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*config.TopK)))
|
||||
}
|
||||
|
||||
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Close old provider.
|
||||
if a.providerCloser != nil {
|
||||
_ = a.providerCloser.Close()
|
||||
}
|
||||
|
||||
// Update model info on MCP tool manager.
|
||||
if a.toolManager != nil {
|
||||
a.toolManager.SetModel(providerResult.Model)
|
||||
}
|
||||
|
||||
// Swap fields.
|
||||
a.fantasyAgent = newFantasyAgent
|
||||
a.model = providerResult.Model
|
||||
a.providerCloser = providerResult.Closer
|
||||
|
||||
// Update provider type.
|
||||
if config.ModelString != "" {
|
||||
if p, _, err := models.ParseModelString(config.ModelString); err == nil {
|
||||
a.providerType = p
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetModel returns the underlying fantasy LanguageModel.
|
||||
func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
return a.model
|
||||
|
||||
+223
-17
@@ -13,6 +13,12 @@ import (
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// queueItem holds a prompt and optional image attachments for the execution queue.
|
||||
type queueItem struct {
|
||||
Prompt string
|
||||
Files []fantasy.FilePart
|
||||
}
|
||||
|
||||
// App is the application-layer orchestrator. It owns the agentic loop,
|
||||
// conversation history (via MessageStore), and queue management. It is
|
||||
// designed to be created once per session and reused across multiple prompts.
|
||||
@@ -47,7 +53,7 @@ type App struct {
|
||||
// mu protects busy, queue, and cancelStep.
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
queue []string
|
||||
queue []queueItem
|
||||
|
||||
// wg tracks in-flight goroutines; Close() waits on it.
|
||||
wg sync.WaitGroup
|
||||
@@ -100,6 +106,16 @@ func (a *App) SetProgram(p *tea.Program) {
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) Run(prompt string) int {
|
||||
return a.RunWithFiles(prompt, nil)
|
||||
}
|
||||
|
||||
// RunWithFiles queues a multimodal prompt (text + image files) for execution.
|
||||
// If the app is idle the prompt executes immediately; otherwise it is queued.
|
||||
// Returns the current queue depth (0 = started immediately, >0 = queued).
|
||||
//
|
||||
// Satisfies ui.AppController (via RunWithImages which converts ImageAttachment
|
||||
// to fantasy.FilePart).
|
||||
func (a *App) RunWithFiles(prompt string, files []fantasy.FilePart) int {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
@@ -107,8 +123,10 @@ func (a *App) Run(prompt string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
item := queueItem{Prompt: prompt, Files: files}
|
||||
|
||||
if a.busy {
|
||||
a.queue = append(a.queue, prompt)
|
||||
a.queue = append(a.queue, item)
|
||||
qLen := len(a.queue)
|
||||
a.mu.Unlock()
|
||||
return qLen
|
||||
@@ -117,7 +135,7 @@ func (a *App) Run(prompt string) int {
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(prompt)
|
||||
go a.drainQueue(item)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -141,6 +159,36 @@ func (a *App) QueueLength() int {
|
||||
return len(a.queue)
|
||||
}
|
||||
|
||||
// Steer cancels the current agent step (if running), clears the queue, and
|
||||
// sends a new message that will execute as soon as the current step finishes
|
||||
// cancelling. If the agent is idle, the message executes immediately.
|
||||
// This is the "steer" delivery mode for SendMessage.
|
||||
func (a *App) Steer(prompt string) {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
item := queueItem{Prompt: prompt}
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
return
|
||||
}
|
||||
|
||||
// Agent is busy: clear queue, insert steer message, then cancel.
|
||||
a.queue = []queueItem{item}
|
||||
cancel := a.cancelStep
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
|
||||
// ClearQueue discards all queued prompts. The caller is responsible for
|
||||
// updating any UI state (e.g. queue badge) — ClearQueue does NOT send
|
||||
// events to the program, because it may be called synchronously from
|
||||
@@ -169,6 +217,22 @@ func (a *App) GetTreeSession() *session.TreeManager {
|
||||
return a.opts.TreeSession
|
||||
}
|
||||
|
||||
// AddContextMessage adds a user-role message to the conversation history
|
||||
// without triggering an LLM response. Used by the ! shell command prefix
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
// subsequent turns.
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) AddContextMessage(text string) {
|
||||
msg := fantasy.NewUserMessage(text)
|
||||
a.store.Add(msg)
|
||||
|
||||
// Persist to tree session if active.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
_, _ = ts.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// CompactConversation summarises older messages to free context space. It
|
||||
// returns an error synchronously if compaction cannot start (agent busy or
|
||||
// app closed). The actual compaction runs in a background goroutine and
|
||||
@@ -243,7 +307,7 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, nil)
|
||||
result, err := a.executeStep(stepCtx, prompt, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -254,6 +318,20 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnceResult executes a single agent step synchronously and returns the
|
||||
// full TurnResult without printing anything. This is used by --json mode to
|
||||
// capture structured output for serialization.
|
||||
func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult, error) {
|
||||
stepCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
a.mu.Lock()
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
return a.executeStep(stepCtx, prompt, nil, nil)
|
||||
}
|
||||
|
||||
// RunOnceWithDisplay executes a single agent step synchronously, sending
|
||||
// intermediate display events (spinner, tool calls, streaming chunks, etc.)
|
||||
// to eventFn. This is the non-TUI equivalent of the interactive Run() path —
|
||||
@@ -272,7 +350,7 @@ func (a *App) RunOnceWithDisplay(ctx context.Context, prompt string, eventFn fun
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn)
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -313,15 +391,15 @@ func (a *App) Close() {
|
||||
// Internal: queue drain loop
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// drainQueue runs in a goroutine. It executes the given prompt and then
|
||||
// drainQueue runs in a goroutine. It executes the given item and then
|
||||
// continues draining the queue until it is empty.
|
||||
// Must be called with a.busy == true and a.wg incremented.
|
||||
func (a *App) drainQueue(firstPrompt string) {
|
||||
func (a *App) drainQueue(first queueItem) {
|
||||
defer a.wg.Done()
|
||||
|
||||
prompt := firstPrompt
|
||||
item := first
|
||||
for {
|
||||
a.runPrompt(prompt)
|
||||
a.runQueueItem(item)
|
||||
|
||||
a.mu.Lock()
|
||||
// Stop draining if the app is shutting down.
|
||||
@@ -336,7 +414,7 @@ func (a *App) drainQueue(firstPrompt string) {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
prompt = a.queue[0]
|
||||
item = a.queue[0]
|
||||
a.queue = a.queue[1:]
|
||||
qLen := len(a.queue)
|
||||
a.mu.Unlock()
|
||||
@@ -345,9 +423,9 @@ func (a *App) drainQueue(firstPrompt string) {
|
||||
}
|
||||
}
|
||||
|
||||
// runPrompt executes a single prompt: adds the user message to the store,
|
||||
// runQueueItem executes a single queue item: adds the user message to the store,
|
||||
// runs the agent step, and sends the appropriate event to the program.
|
||||
func (a *App) runPrompt(prompt string) {
|
||||
func (a *App) runQueueItem(item queueItem) {
|
||||
// Create a per-step cancellable context.
|
||||
stepCtx, cancel := context.WithCancel(a.rootCtx)
|
||||
a.mu.Lock()
|
||||
@@ -366,7 +444,7 @@ func (a *App) runPrompt(prompt string) {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn)
|
||||
result, err := a.executeStep(stepCtx, item.Prompt, eventFn, item.Files)
|
||||
if err != nil {
|
||||
if stepCtx.Err() != nil {
|
||||
// Step was cancelled by the user (e.g. double-ESC). Send a
|
||||
@@ -387,9 +465,9 @@ func (a *App) runPrompt(prompt string) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// executeStep runs a single agentic step by delegating to the SDK's
|
||||
// PromptResult(), which handles session persistence, hooks, extension
|
||||
// events, and the generation loop.
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*kit.TurnResult, error) {
|
||||
// PromptResult() (or PromptResultWithFiles for multimodal), which handles
|
||||
// session persistence, hooks, extension events, and the generation loop.
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []fantasy.FilePart) (*kit.TurnResult, error) {
|
||||
// Test hook: bypass SDK entirely.
|
||||
if a.opts.PromptFunc != nil {
|
||||
return a.opts.PromptFunc(ctx, prompt)
|
||||
@@ -409,7 +487,13 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
|
||||
// Show spinner while the agent works.
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
result, err := a.opts.Kit.PromptResult(ctx, prompt)
|
||||
var result *kit.TurnResult
|
||||
var err error
|
||||
if len(files) > 0 {
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, prompt, files)
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResult(ctx, prompt)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -464,6 +548,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
sendFn(ResponseCompleteEvent{Content: ev.Content})
|
||||
case kit.MessageUpdateEvent:
|
||||
sendFn(StreamChunkEvent{Content: ev.Chunk})
|
||||
case kit.ReasoningDeltaEvent:
|
||||
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -474,6 +560,22 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
|
||||
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
|
||||
// non-interactive mode it cancels the root context, stopping any in-flight
|
||||
// step. Safe to call from any goroutine; idempotent.
|
||||
func (a *App) QuitFromExtension() {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(tea.QuitMsg{})
|
||||
return
|
||||
}
|
||||
// Non-interactive: cancel the root context.
|
||||
a.rootCancel()
|
||||
}
|
||||
|
||||
// PrintFromExtension outputs text from an extension to the user. The level
|
||||
// controls styling: "" for plain text, "info" for a system message block,
|
||||
// "error" for an error block. In interactive mode it sends an
|
||||
@@ -491,6 +593,110 @@ func (a *App) PrintFromExtension(level, text string) {
|
||||
fmt.Println(text)
|
||||
}
|
||||
|
||||
// SetEditorTextFromExtension sends an EditorTextSetEvent to the TUI to
|
||||
// pre-fill the input editor. In non-interactive mode this is a no-op.
|
||||
func (a *App) SetEditorTextFromExtension(text string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(EditorTextSetEvent{Text: text})
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
|
||||
// the model name in the status bar and message attribution.
|
||||
func (a *App) NotifyModelChanged(provider, model string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(ModelChangedEvent{ProviderName: provider, ModelName: model})
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
|
||||
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
|
||||
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
|
||||
func (a *App) NotifyWidgetUpdate() {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(WidgetUpdateEvent{})
|
||||
}
|
||||
}
|
||||
|
||||
// SendEvent sends a tea.Msg to the registered program. Safe to call from
|
||||
// any goroutine. No-op when no program is registered.
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) SendEvent(msg tea.Msg) {
|
||||
a.sendEvent(msg)
|
||||
}
|
||||
|
||||
// SendPromptRequest sends a PromptRequestEvent to the TUI so the user can
|
||||
// respond interactively. In non-interactive mode (no program registered) it
|
||||
// immediately responds with a cancelled result via the channel, ensuring the
|
||||
// calling extension goroutine never blocks indefinitely.
|
||||
func (a *App) SendPromptRequest(evt PromptRequestEvent) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(evt)
|
||||
return
|
||||
}
|
||||
// Non-interactive fallback: immediately cancel.
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- PromptResponse{Cancelled: true}
|
||||
}
|
||||
}
|
||||
|
||||
// SendOverlayRequest sends an OverlayRequestEvent to the TUI so the user
|
||||
// can interact with a modal overlay dialog. In non-interactive mode (no
|
||||
// program registered) it immediately responds with a cancelled result via the
|
||||
// channel, ensuring the calling extension goroutine never blocks indefinitely.
|
||||
func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(evt)
|
||||
return
|
||||
}
|
||||
// Non-interactive fallback: immediately cancel.
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- OverlayResponse{Cancelled: true}
|
||||
}
|
||||
}
|
||||
|
||||
// SuspendTUI temporarily releases the terminal from the TUI, runs the
|
||||
// callback (which may spawn interactive subprocesses), and then restores
|
||||
// the TUI. In non-interactive mode (no program registered) the callback
|
||||
// runs directly with no terminal state changes.
|
||||
//
|
||||
// Safe to call from any goroutine (extension command handlers run in
|
||||
// goroutines). Blocks until the callback returns.
|
||||
func (a *App) SuspendTUI(callback func()) error {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog == nil {
|
||||
// Non-interactive: just run the callback directly.
|
||||
callback()
|
||||
return nil
|
||||
}
|
||||
if err := prog.ReleaseTerminal(); err != nil {
|
||||
return fmt.Errorf("release terminal: %w", err)
|
||||
}
|
||||
callback()
|
||||
if err := prog.RestoreTerminal(); err != nil {
|
||||
return fmt.Errorf("restore terminal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintBlockFromExtension outputs a custom styled block from an extension.
|
||||
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -494,7 +494,11 @@ func TestQueueLength_reflects(t *testing.T) {
|
||||
}
|
||||
|
||||
app.mu.Lock()
|
||||
app.queue = append(app.queue, "a", "b", "c")
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "a"},
|
||||
queueItem{Prompt: "b"},
|
||||
queueItem{Prompt: "c"},
|
||||
)
|
||||
app.mu.Unlock()
|
||||
|
||||
if got := app.QueueLength(); got != 3 {
|
||||
|
||||
@@ -9,6 +9,13 @@ type StreamChunkEvent struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
// ReasoningChunkEvent is sent when a streaming reasoning/thinking delta arrives
|
||||
// from the LLM. Thinking content is rendered separately from regular text.
|
||||
type ReasoningChunkEvent struct {
|
||||
// Delta is the incremental reasoning text from the streaming response.
|
||||
Delta string
|
||||
}
|
||||
|
||||
// ToolCallStartedEvent is sent when a tool call has been parsed and is about to execute.
|
||||
// It carries the tool name and its arguments for display purposes.
|
||||
type ToolCallStartedEvent struct {
|
||||
@@ -113,6 +120,28 @@ type CompactErrorEvent struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// ModelChangedEvent is sent when an extension changes the active model via
|
||||
// ctx.SetModel. The TUI updates the model name shown in the status bar and
|
||||
// message attribution.
|
||||
type ModelChangedEvent struct {
|
||||
// ProviderName is the new provider (e.g. "anthropic").
|
||||
ProviderName string
|
||||
// ModelName is the new model ID (e.g. "claude-3-5-haiku-20241022").
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
|
||||
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
|
||||
// from its WidgetProvider on the next render cycle.
|
||||
type WidgetUpdateEvent struct{}
|
||||
|
||||
// EditorTextSetEvent is sent when an extension calls ctx.SetEditorText to
|
||||
// pre-fill the input editor with text. The TUI handles this by setting the
|
||||
// textarea content and moving the cursor to the end.
|
||||
type EditorTextSetEvent struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo,
|
||||
// ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate
|
||||
// renderer and tea.Println (scrollback); the CLI handler uses
|
||||
@@ -132,3 +161,89 @@ type ExtensionPrintEvent struct {
|
||||
// Subtitle is optional muted text below the content for Level="block".
|
||||
Subtitle string
|
||||
}
|
||||
|
||||
// PromptResponse carries the user's answer to an interactive prompt. The TUI
|
||||
// sends exactly one PromptResponse through the channel embedded in
|
||||
// PromptRequestEvent when the user completes or cancels the prompt.
|
||||
type PromptResponse struct {
|
||||
// Value is the response text — the selected option (select), or the
|
||||
// entered text (input). Unused for confirm prompts.
|
||||
Value string
|
||||
// Index is the zero-based index of the selected option (select only).
|
||||
Index int
|
||||
// Confirmed is the boolean answer for confirm prompts.
|
||||
Confirmed bool
|
||||
// Cancelled is true if the user dismissed the prompt (ESC) or the
|
||||
// prompt could not be shown (e.g. app shutting down).
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// PromptRequestEvent is sent when an extension requests an interactive
|
||||
// prompt from the user (select, confirm, or text input). The TUI enters a
|
||||
// modal prompt state, renders the prompt, and sends a single PromptResponse
|
||||
// through ResponseCh when the user completes or cancels.
|
||||
//
|
||||
// The extension goroutine blocks on the read side of ResponseCh until the
|
||||
// TUI sends a response. The channel must have buffer size >= 1.
|
||||
type PromptRequestEvent struct {
|
||||
// PromptType is "select", "confirm", or "input".
|
||||
PromptType string
|
||||
// Message is the question displayed to the user.
|
||||
Message string
|
||||
// Options lists the choices for select prompts.
|
||||
Options []string
|
||||
// Default is the pre-filled value: "true"/"false" for confirm prompts,
|
||||
// or the initial text for input prompts.
|
||||
Default string
|
||||
// Placeholder is the ghost text for input prompts.
|
||||
Placeholder string
|
||||
// ResponseCh receives the user's answer. The TUI must send exactly one
|
||||
// value. The channel must be buffered (cap >= 1) so sending never
|
||||
// blocks inside Update().
|
||||
ResponseCh chan<- PromptResponse
|
||||
}
|
||||
|
||||
// OverlayResponse carries the user's answer to a modal overlay dialog. The
|
||||
// TUI sends exactly one OverlayResponse through the channel embedded in
|
||||
// OverlayRequestEvent when the user completes or cancels the overlay.
|
||||
type OverlayResponse struct {
|
||||
// Action is the text of the selected action button, or "" if no actions
|
||||
// were configured or the dialog was dismissed without selection.
|
||||
Action string
|
||||
// Index is the zero-based index of the selected action, or -1 if no
|
||||
// action was selected.
|
||||
Index int
|
||||
// Cancelled is true if the user dismissed the overlay (ESC) or the
|
||||
// overlay could not be shown (e.g. non-interactive mode).
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// OverlayRequestEvent is sent when an extension requests a modal overlay
|
||||
// dialog. The TUI enters an overlay state, renders the dialog, and sends a
|
||||
// single OverlayResponse through ResponseCh when the user dismisses or
|
||||
// selects an action.
|
||||
//
|
||||
// The extension goroutine blocks on the read side of ResponseCh until the
|
||||
// TUI sends a response. The channel must have buffer size >= 1.
|
||||
type OverlayRequestEvent struct {
|
||||
// Title is displayed at the top of the dialog. Empty means no title.
|
||||
Title string
|
||||
// Content is the text to render inside the dialog body.
|
||||
Content string
|
||||
// Markdown, when true, renders Content as styled markdown.
|
||||
Markdown bool
|
||||
// BorderColor is a hex color for the dialog border. Empty uses default.
|
||||
BorderColor string
|
||||
// Background is a hex color for the dialog background. Empty = none.
|
||||
Background string
|
||||
// Width is the dialog width in columns. 0 = auto (60% of terminal).
|
||||
Width int
|
||||
// MaxHeight limits dialog height. 0 = auto (80% of terminal).
|
||||
MaxHeight int
|
||||
// Anchor is the vertical positioning: "center", "top-center", "bottom-center".
|
||||
Anchor string
|
||||
// Actions lists the action button labels. Empty = simple dismiss dialog.
|
||||
Actions []string
|
||||
// ResponseCh receives the user's response. Must have buffer size >= 1.
|
||||
ResponseCh chan<- OverlayResponse
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package clipboard provides cross-platform clipboard image reading for Kit.
|
||||
//
|
||||
// Terminals cannot paste binary image data via bracketed paste — only text is
|
||||
// supported. To read images we shell out to platform-specific clipboard tools:
|
||||
//
|
||||
// - Linux X11: xclip -selection clipboard -t image/png -o
|
||||
// - Linux Wayland: wl-paste --type image/png
|
||||
// - macOS: osascript + pbpaste (via a helper that reads NSPasteboard)
|
||||
// - Windows/WSL: powershell Get-Clipboard -Format Image (not yet supported)
|
||||
//
|
||||
// The ReadImage function returns the raw image bytes and detected MIME type,
|
||||
// or an error if no image is available on the clipboard.
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ImageData holds the result of a clipboard image read.
|
||||
type ImageData struct {
|
||||
// Data is the raw image bytes (PNG, JPEG, etc.).
|
||||
Data []byte
|
||||
// MediaType is the MIME type (e.g. "image/png", "image/jpeg").
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// DetectMediaType inspects the magic bytes of data to determine the image
|
||||
// MIME type. Returns empty string if the format is not recognized.
|
||||
func DetectMediaType(data []byte) string {
|
||||
if len(data) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
|
||||
return "image/png"
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
// GIF: 47 49 46 38
|
||||
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38 {
|
||||
return "image/gif"
|
||||
}
|
||||
|
||||
// WebP: RIFF....WEBP
|
||||
if len(data) >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
|
||||
return "image/webp"
|
||||
}
|
||||
|
||||
// BMP: 42 4D
|
||||
if data[0] == 0x42 && data[1] == 0x4D {
|
||||
return "image/bmp"
|
||||
}
|
||||
|
||||
// TIFF: 49 49 2A 00 (little-endian) or 4D 4D 00 2A (big-endian)
|
||||
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
|
||||
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
|
||||
return "image/tiff"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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)")
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build darwin
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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.
|
||||
script := `use framework "AppKit"
|
||||
set pb to current application's NSPasteboard's generalPasteboard()
|
||||
set imgData to pb's dataForType:(current application's NSPasteboardTypePNG)
|
||||
if imgData is missing value then
|
||||
set tiffData to pb's dataForType:(current application's NSPasteboardTypeTIFF)
|
||||
if tiffData is missing value then
|
||||
error "No image on clipboard"
|
||||
end if
|
||||
set bitmapRep to current application's NSBitmapImageRep's imageRepWithData:tiffData
|
||||
set imgData to bitmapRep's representationUsingType:(current application's NSPNGFileType) |properties|:(missing value)
|
||||
end if
|
||||
imgData's writeToFile:"/dev/stdout" atomically:false`
|
||||
|
||||
cmd := exec.Command("osascript", "-l", "AppleScript", "-e", script)
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png" // osascript converts to PNG
|
||||
}
|
||||
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
|
||||
package clipboard_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/clipboard"
|
||||
)
|
||||
|
||||
// TestReadImageIntegration tests reading an image from the system clipboard.
|
||||
// Run with: WAYLAND_DISPLAY=wayland-1 go test -tags integration -v -run TestReadImageIntegration ./internal/clipboard/
|
||||
//
|
||||
// Prerequisites: copy an image to the clipboard first, e.g.:
|
||||
//
|
||||
// WAYLAND_DISPLAY=wayland-1 wl-copy --type image/png < ~/Pictures/Screenshots/some_screenshot.png
|
||||
func TestReadImageIntegration(t *testing.T) {
|
||||
if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
|
||||
t.Skip("no display server available (set WAYLAND_DISPLAY or DISPLAY)")
|
||||
}
|
||||
|
||||
img, err := clipboard.ReadImage()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadImage() error: %v", err)
|
||||
}
|
||||
|
||||
if img == nil {
|
||||
t.Fatal("ReadImage() returned nil without error")
|
||||
}
|
||||
|
||||
t.Logf("Image data: %d bytes", len(img.Data))
|
||||
t.Logf("Media type: %s", img.MediaType)
|
||||
|
||||
if len(img.Data) == 0 {
|
||||
t.Fatal("image data is empty")
|
||||
}
|
||||
|
||||
if img.MediaType == "" {
|
||||
t.Fatal("media type is empty")
|
||||
}
|
||||
|
||||
// Verify magic bytes match the declared media type.
|
||||
detected := clipboard.DetectMediaType(img.Data)
|
||||
if detected == "" {
|
||||
t.Fatal("could not detect image format from magic bytes")
|
||||
}
|
||||
t.Logf("Detected format: %s", detected)
|
||||
|
||||
if detected != img.MediaType {
|
||||
t.Errorf("media type mismatch: declared=%s detected=%s", img.MediaType, detected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMediaType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expected string
|
||||
}{
|
||||
{"PNG", []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}, "image/png"},
|
||||
{"JPEG", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49}, "image/jpeg"},
|
||||
{"GIF", []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00, 0x00}, "image/gif"},
|
||||
{"BMP", []byte{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/bmp"},
|
||||
{"WebP", []byte{0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50}, "image/webp"},
|
||||
{"TIFF-LE", []byte{0x49, 0x49, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/tiff"},
|
||||
{"TIFF-BE", []byte{0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/tiff"},
|
||||
{"unknown", []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, ""},
|
||||
{"too short", []byte{0x89, 0x50}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := clipboard.DetectMediaType(tt.data)
|
||||
if got != tt.expected {
|
||||
t.Errorf("DetectMediaType() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build linux
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// ReadImage reads image data from the system clipboard on Linux.
|
||||
// It tries xclip first (X11), then falls back to wl-paste (Wayland).
|
||||
func ReadImage() (*ImageData, error) {
|
||||
// Try xclip first (X11).
|
||||
if path, err := exec.LookPath("xclip"); err == nil {
|
||||
data, err := readWithXclip(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png" // xclip was asked for image/png
|
||||
}
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to wl-paste (Wayland).
|
||||
if path, err := exec.LookPath("wl-paste"); err == nil {
|
||||
data, err := readWithWlPaste(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png"
|
||||
}
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if either tool exists but just had no image.
|
||||
if _, err := exec.LookPath("xclip"); err == nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
if _, err := exec.LookPath("wl-paste"); err == nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
return nil, ErrNoClipboardTool
|
||||
}
|
||||
|
||||
// readWithXclip reads image data using xclip.
|
||||
func readWithXclip(xclipPath string) ([]byte, error) {
|
||||
cmd := exec.Command(xclipPath, "-selection", "clipboard", "-t", "image/png", "-o")
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// readWithWlPaste reads image data using wl-paste.
|
||||
func readWithWlPaste(wlPastePath string) ([]byte, error) {
|
||||
cmd := exec.Command(wlPastePath, "--type", "image/png")
|
||||
return cmd.Output()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package compaction provides context window management with token estimation,
|
||||
// compaction triggers, and LLM-based conversation summarization.
|
||||
//
|
||||
// The algorithm mirrors Pi's approach: preserve a token budget of recent
|
||||
// The algorithm preserves a token budget of recent
|
||||
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
|
||||
// count. Auto-compaction fires when estimated context usage exceeds
|
||||
// contextWindow − ReserveTokens.
|
||||
@@ -50,8 +50,8 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
|
||||
// Auto-compact trigger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ShouldCompact reports whether auto-compaction should fire. It uses Pi's
|
||||
// formula: contextTokens > contextWindow − reserveTokens.
|
||||
// ShouldCompact reports whether auto-compaction should fire.
|
||||
// Formula: contextTokens > contextWindow − reserveTokens.
|
||||
func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens int) bool {
|
||||
if contextWindow <= 0 || reserveTokens <= 0 {
|
||||
return false
|
||||
@@ -72,8 +72,8 @@ type CompactionResult struct {
|
||||
MessagesRemoved int // Number of messages replaced by the summary
|
||||
}
|
||||
|
||||
// CompactionOptions configures compaction behaviour. Pi-style token-based
|
||||
// defaults are applied for zero-value fields.
|
||||
// CompactionOptions configures compaction behaviour. Token-based defaults
|
||||
// are applied for zero-value fields.
|
||||
type CompactionOptions struct {
|
||||
ContextWindow int // Model's context window size (tokens)
|
||||
ReserveTokens int // Tokens to reserve for LLM response, default 16384
|
||||
@@ -81,7 +81,7 @@ type CompactionOptions struct {
|
||||
SummaryPrompt string // Custom summary prompt (empty = use default)
|
||||
}
|
||||
|
||||
// defaults fills zero-value fields with sensible Pi-style defaults.
|
||||
// defaults fills zero-value fields with sensible defaults.
|
||||
func (o *CompactionOptions) defaults() {
|
||||
if o.ReserveTokens <= 0 {
|
||||
o.ReserveTokens = 16384
|
||||
@@ -92,13 +92,13 @@ func (o *CompactionOptions) defaults() {
|
||||
}
|
||||
|
||||
// defaultSystemPrompt is the system prompt sent to the summarisation LLM.
|
||||
// Matches Pi's compaction system prompt.
|
||||
|
||||
const defaultSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
|
||||
|
||||
// defaultSummaryPrompt is the user prompt appended after the serialised
|
||||
// conversation. Matches Pi's initial-compaction format.
|
||||
// conversation.
|
||||
const defaultSummaryPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
@@ -133,7 +133,7 @@ Use this EXACT format:
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cut point (token-based, Pi-style)
|
||||
// Cut point (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// isValidCutPoint returns true if the message at index i is a valid place to
|
||||
@@ -208,11 +208,11 @@ func forceCutPoint(messages []fantasy.Message) int {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message serialisation (Pi-style)
|
||||
// Message serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// roleLabel returns a human-readable label for a fantasy message role,
|
||||
// matching Pi's serialisation format.
|
||||
|
||||
func roleLabel(role fantasy.MessageRole) string {
|
||||
switch role {
|
||||
case fantasy.MessageRoleUser:
|
||||
@@ -230,7 +230,7 @@ func roleLabel(role fantasy.MessageRole) string {
|
||||
|
||||
// serializeMessages converts a slice of fantasy messages into a plain-text
|
||||
// representation suitable for sending to the summarisation LLM. The format
|
||||
// mirrors Pi's compaction serialisation.
|
||||
|
||||
func serializeMessages(messages []fantasy.Message) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range messages {
|
||||
@@ -277,8 +277,8 @@ func Compact(
|
||||
cutPoint := FindCutPoint(messages, opts.KeepRecentTokens)
|
||||
if cutPoint == 0 {
|
||||
// All messages fit within the keep budget. Force a cut that
|
||||
// keeps only the last non-tool message — matching Pi, which
|
||||
// always compacts when the user explicitly requests it.
|
||||
// keeps only the last non-tool message — always compact when
|
||||
// the user explicitly requests it.
|
||||
cutPoint = forceCutPoint(messages)
|
||||
if cutPoint == 0 {
|
||||
return nil, messages, nil
|
||||
@@ -289,7 +289,7 @@ func Compact(
|
||||
recentMessages := messages[cutPoint:]
|
||||
originalTokens := EstimateMessageTokens(messages)
|
||||
|
||||
// Serialise old messages to text, matching Pi's format.
|
||||
// Serialise old messages to text.
|
||||
conversationText := serializeMessages(oldMessages)
|
||||
|
||||
// Build the user-facing prompt: conversation text + summary instructions.
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestEstimateMessageTokens_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShouldCompact (Pi-style: contextTokens > contextWindow - reserveTokens)
|
||||
// ShouldCompact (contextTokens > contextWindow - reserveTokens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShouldCompact(t *testing.T) {
|
||||
@@ -94,7 +94,7 @@ func TestShouldCompact(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FindCutPoint (token-based, Pi-style)
|
||||
// FindCutPoint (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindCutPoint_TokenBased(t *testing.T) {
|
||||
|
||||
@@ -165,6 +165,9 @@ type Config struct {
|
||||
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
|
||||
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
|
||||
|
||||
// Thinking / extended reasoning
|
||||
ThinkingLevel string `json:"thinking-level,omitempty" yaml:"thinking-level,omitempty"`
|
||||
|
||||
// TLS configuration
|
||||
TLSSkipVerify bool `json:"tls-skip-verify,omitempty" yaml:"tls-skip-verify,omitempty"`
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
}
|
||||
|
||||
// Truncate from tail (keep last N lines, most relevant for bash)
|
||||
tr := truncateTail(output, defaultMaxLines, defaultMaxBytes)
|
||||
tr := TruncateTail(output, defaultMaxLines, defaultMaxBytes)
|
||||
|
||||
if exitCode != 0 {
|
||||
return fantasy.NewTextErrorResponse(tr.Content), nil
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package core provides the built-in core tools for KIT's coding agent.
|
||||
// These tools are direct fantasy.AgentTool implementations — no MCP layer,
|
||||
// no JSON-RPC, no serialization overhead. They match the pi coding agent's
|
||||
// core tool set: bash, read, write, edit, grep, find, ls.
|
||||
// no JSON-RPC, no serialization overhead. Core tool set: bash, read, write,
|
||||
// edit, grep, find, ls.
|
||||
package core
|
||||
|
||||
import (
|
||||
@@ -65,7 +65,7 @@ func parseArgs(input string, target any) error {
|
||||
}
|
||||
|
||||
// CodingTools returns the default set of core tools for a coding agent:
|
||||
// bash, read, write, edit. This matches pi's codingTools collection.
|
||||
// bash, read, write, edit.
|
||||
func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewBashTool(opts...),
|
||||
@@ -76,7 +76,7 @@ func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
}
|
||||
|
||||
// ReadOnlyTools returns tools for read-only exploration:
|
||||
// read, grep, find, ls. This matches pi's readOnlyTools collection.
|
||||
// read, grep, find, ls.
|
||||
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewReadTool(opts...),
|
||||
|
||||
@@ -9,6 +9,11 @@ const (
|
||||
defaultMaxLines = 2000
|
||||
defaultMaxBytes = 50 * 1024 // 50KB
|
||||
grepMaxLineLen = 500
|
||||
|
||||
// DefaultMaxLines is the exported default line limit for truncation.
|
||||
DefaultMaxLines = defaultMaxLines
|
||||
// DefaultMaxBytes is the exported default byte limit for truncation.
|
||||
DefaultMaxBytes = defaultMaxBytes
|
||||
)
|
||||
|
||||
// TruncationResult describes how output was truncated.
|
||||
@@ -20,9 +25,9 @@ type TruncationResult struct {
|
||||
Kept int // lines kept after truncation
|
||||
}
|
||||
|
||||
// truncateTail keeps the last maxLines lines and at most maxBytes bytes.
|
||||
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
|
||||
// Used for bash output where the tail is most relevant.
|
||||
func truncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
if maxLines <= 0 {
|
||||
maxLines = defaultMaxLines
|
||||
}
|
||||
|
||||
+1321
-16
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// Package extensions implements a Pi-style in-process extension system for KIT.
|
||||
// Package extensions implements an in-process extension system for KIT.
|
||||
// Extensions are plain Go files loaded at runtime via Yaegi (a Go interpreter).
|
||||
// They register event handlers using an API object, enabling tool interception,
|
||||
// input transformation, and lifecycle observation — all without recompilation.
|
||||
@@ -48,6 +48,26 @@ const (
|
||||
|
||||
// SessionShutdown fires when the application is closing.
|
||||
SessionShutdown EventType = "session_shutdown"
|
||||
|
||||
// ModelChange fires after the active model is changed via ctx.SetModel().
|
||||
ModelChange EventType = "model_change"
|
||||
|
||||
// ContextPrepare fires after context is built from the session tree and
|
||||
// before the messages are sent to the LLM. Handlers can filter, reorder,
|
||||
// or inject messages into the context window.
|
||||
ContextPrepare EventType = "context_prepare"
|
||||
|
||||
// BeforeFork fires before the session tree is branched to a different
|
||||
// entry point. Handlers can cancel the fork by returning Cancel=true.
|
||||
BeforeFork EventType = "before_fork"
|
||||
|
||||
// BeforeSessionSwitch fires before the session is switched to a new
|
||||
// branch (e.g. /new command). Handlers can cancel by returning Cancel=true.
|
||||
BeforeSessionSwitch EventType = "before_session_switch"
|
||||
|
||||
// BeforeCompact fires before context compaction runs. Handlers can
|
||||
// cancel compaction by returning Cancel=true.
|
||||
BeforeCompact EventType = "before_compact"
|
||||
)
|
||||
|
||||
// AllEventTypes returns every supported event type.
|
||||
@@ -57,6 +77,8 @@ func AllEventTypes() []EventType {
|
||||
Input, BeforeAgentStart, AgentStart, AgentEnd,
|
||||
MessageStart, MessageUpdate, MessageEnd,
|
||||
SessionStart, SessionShutdown,
|
||||
ModelChange, ContextPrepare,
|
||||
BeforeFork, BeforeSessionSwitch, BeforeCompact,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import "testing"
|
||||
|
||||
func TestAllEventTypes_Count(t *testing.T) {
|
||||
all := AllEventTypes()
|
||||
if len(all) != 13 {
|
||||
t.Fatalf("expected 13 event types, got %d", len(all))
|
||||
if len(all) != 18 {
|
||||
t.Fatalf("expected 18 event types, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ func TestEventType_TypeMethod(t *testing.T) {
|
||||
{MessageEndEvent{Content: "done"}, MessageEnd},
|
||||
{SessionStartEvent{SessionID: "abc"}, SessionStart},
|
||||
{SessionShutdownEvent{}, SessionShutdown},
|
||||
{ModelChangeEvent{NewModel: "a/b"}, ModelChange},
|
||||
{ContextPrepareEvent{Messages: []ContextMessage{{Index: 0, Role: "user", Content: "hi"}}}, ContextPrepare},
|
||||
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
|
||||
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
|
||||
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -41,7 +41,8 @@ func LoadExtensions(extraPaths []string) ([]LoadedExtension, error) {
|
||||
log.Debug("loaded extension", "path", p,
|
||||
"handlers", countHandlers(ext),
|
||||
"tools", len(ext.Tools),
|
||||
"commands", len(ext.Commands))
|
||||
"commands", len(ext.Commands),
|
||||
"tool_renderers", len(ext.ToolRenderers))
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
@@ -282,12 +283,72 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
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)
|
||||
},
|
||||
registerMessageRendererFn: func(config MessageRendererConfig) {
|
||||
ext.MessageRenderers = append(ext.MessageRenderers, 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})
|
||||
},
|
||||
}
|
||||
|
||||
// Call Init — the extension registers its handlers, tools, commands.
|
||||
|
||||
+492
-16
@@ -2,28 +2,52 @@ package extensions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Runner manages loaded extensions and dispatches events to their handlers
|
||||
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
|
||||
// sequentially. Handlers execute in extension
|
||||
// load order; for cancellable events the first blocking result wins.
|
||||
type Runner struct {
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
mu sync.RWMutex
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
statusEntries map[string]StatusBarEntry // keyed by status key
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
customEditor *EditorConfig // nil = no custom editor interceptor
|
||||
uiVisibility *UIVisibility // nil = show everything (default)
|
||||
disabledTools map[string]bool // nil = all tools enabled
|
||||
customEventSubs map[string][]func(string) // inter-extension event bus
|
||||
optionOverrides map[string]string // runtime option overrides
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ShortcutEntry pairs a shortcut definition with its handler.
|
||||
type ShortcutEntry struct {
|
||||
Def ShortcutDef
|
||||
Handler func(Context)
|
||||
}
|
||||
|
||||
// LoadedExtension represents a single extension that has been discovered,
|
||||
// loaded, and initialised. It holds the registered handlers and any custom
|
||||
// tools or commands the extension provided.
|
||||
// tools, commands, or tool renderers the extension provided.
|
||||
type LoadedExtension struct {
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
MessageRenderers []MessageRendererConfig // named message renderers
|
||||
CustomEventHandlers map[string][]func(string) // inter-extension event bus
|
||||
Options []OptionDef // registered configuration options
|
||||
Shortcuts []ShortcutEntry // global keyboard shortcuts
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner from a set of loaded extensions.
|
||||
@@ -39,6 +63,13 @@ func (r *Runner) SetContext(ctx Context) {
|
||||
r.ctx = ctx
|
||||
}
|
||||
|
||||
// GetContext returns a snapshot of the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// HasHandlers returns true if any loaded extension has at least one handler
|
||||
// registered for the given event type.
|
||||
func (r *Runner) HasHandlers(event EventType) bool {
|
||||
@@ -115,18 +146,457 @@ func (r *Runner) RegisteredCommands() []CommandDef {
|
||||
return cmds
|
||||
}
|
||||
|
||||
// GetContext returns the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// Extensions returns the loaded extensions for inspection (e.g. CLI list).
|
||||
func (r *Runner) Extensions() []LoadedExtension {
|
||||
return r.extensions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetWidget places or updates a persistent widget. The widget is identified
|
||||
// by config.ID; calling SetWidget with the same ID replaces the previous
|
||||
// content. Thread-safe.
|
||||
func (r *Runner) SetWidget(config WidgetConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.widgets == nil {
|
||||
r.widgets = make(map[string]WidgetConfig)
|
||||
}
|
||||
r.widgets[config.ID] = config
|
||||
}
|
||||
|
||||
// RemoveWidget removes a widget by ID. No-op if the ID does not exist.
|
||||
// Thread-safe.
|
||||
func (r *Runner) RemoveWidget(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.widgets, id)
|
||||
}
|
||||
|
||||
// GetWidgets returns all widgets matching the given placement, sorted by
|
||||
// priority (ascending). Thread-safe.
|
||||
func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var result []WidgetConfig
|
||||
for _, w := range r.widgets {
|
||||
if w.Placement == placement {
|
||||
result = append(result, w)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Priority != result[j].Priority {
|
||||
return result[i].Priority < result[j].Priority
|
||||
}
|
||||
return result[i].ID < result[j].ID // stable tie-break
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetStatusEntry places or updates a keyed status bar entry. Thread-safe.
|
||||
func (r *Runner) SetStatusEntry(entry StatusBarEntry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.statusEntries == nil {
|
||||
r.statusEntries = make(map[string]StatusBarEntry)
|
||||
}
|
||||
r.statusEntries[entry.Key] = entry
|
||||
}
|
||||
|
||||
// RemoveStatusEntry removes a status bar entry by key. Thread-safe.
|
||||
func (r *Runner) RemoveStatusEntry(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.statusEntries, key)
|
||||
}
|
||||
|
||||
// GetStatusEntries returns all status bar entries, sorted by priority
|
||||
// (ascending). Thread-safe.
|
||||
func (r *Runner) GetStatusEntries() []StatusBarEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]StatusBarEntry, 0, len(r.statusEntries))
|
||||
for _, e := range r.statusEntries {
|
||||
result = append(result, e)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Priority != result[j].Priority {
|
||||
return result[i].Priority < result[j].Priority
|
||||
}
|
||||
return result[i].Key < result[j].Key
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetHeader places or replaces the custom header. Thread-safe.
|
||||
func (r *Runner) SetHeader(config HeaderFooterConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.header = &config
|
||||
}
|
||||
|
||||
// RemoveHeader removes the custom header. No-op if none is set. Thread-safe.
|
||||
func (r *Runner) RemoveHeader() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.header = nil
|
||||
}
|
||||
|
||||
// GetHeader returns the current custom header, or nil if none is set.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetHeader() *HeaderFooterConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.header == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to avoid races on the caller side.
|
||||
h := *r.header
|
||||
return &h
|
||||
}
|
||||
|
||||
// SetFooter places or replaces the custom footer. Thread-safe.
|
||||
func (r *Runner) SetFooter(config HeaderFooterConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.footer = &config
|
||||
}
|
||||
|
||||
// RemoveFooter removes the custom footer. No-op if none is set. Thread-safe.
|
||||
func (r *Runner) RemoveFooter() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.footer = nil
|
||||
}
|
||||
|
||||
// GetFooter returns the current custom footer, or nil if none is set.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetFooter() *HeaderFooterConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.footer == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to avoid races on the caller side.
|
||||
f := *r.footer
|
||||
return &f
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor interceptor management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetEditor installs an editor interceptor that wraps the built-in input
|
||||
// editor. Only one interceptor is active at a time; calling SetEditor replaces
|
||||
// any previous interceptor. Thread-safe.
|
||||
func (r *Runner) SetEditor(config EditorConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.customEditor = &config
|
||||
}
|
||||
|
||||
// ResetEditor removes the active editor interceptor and restores the default
|
||||
// built-in editor behavior. No-op if no interceptor is set. Thread-safe.
|
||||
func (r *Runner) ResetEditor() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.customEditor = nil
|
||||
}
|
||||
|
||||
// GetEditor returns the current editor interceptor, or nil if none is set.
|
||||
// Thread-safe. Returns a shallow copy — function fields are reference types
|
||||
// so the copy is safe.
|
||||
func (r *Runner) GetEditor() *EditorConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.customEditor == nil {
|
||||
return nil
|
||||
}
|
||||
e := *r.customEditor
|
||||
return &e
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI visibility management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetUIVisibility updates the UI visibility overrides. Thread-safe.
|
||||
func (r *Runner) SetUIVisibility(v UIVisibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.uiVisibility = &v
|
||||
}
|
||||
|
||||
// GetUIVisibility returns the current UI visibility overrides, or nil if
|
||||
// none have been set (meaning show everything). Thread-safe.
|
||||
func (r *Runner) GetUIVisibility() *UIVisibility {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.uiVisibility == nil {
|
||||
return nil
|
||||
}
|
||||
v := *r.uiVisibility
|
||||
return &v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetToolRenderer returns the custom renderer for the named tool, or nil if
|
||||
// no extension registered a renderer for it. If multiple extensions register
|
||||
// renderers for the same tool, the last one (by load order) wins. Thread-safe
|
||||
// (extensions are immutable after loading).
|
||||
func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
|
||||
// Walk extensions in reverse so last-registered wins.
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for j := len(r.extensions[i].ToolRenderers) - 1; j >= 0; j-- {
|
||||
if r.extensions[i].ToolRenderers[j].ToolName == toolName {
|
||||
config := r.extensions[i].ToolRenderers[j]
|
||||
return &config
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetMessageRenderer returns the named message renderer, or nil if no
|
||||
// extension registered a renderer with that name. If multiple extensions
|
||||
// register the same name, the last one (by load order) wins.
|
||||
func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for j := len(r.extensions[i].MessageRenderers) - 1; j >= 0; j-- {
|
||||
if r.extensions[i].MessageRenderers[j].Name == name {
|
||||
config := r.extensions[i].MessageRenderers[j]
|
||||
return &config
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot-reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reload replaces the loaded extensions with a fresh set and clears all
|
||||
// dynamic state (widgets, status, header/footer, editor, visibility,
|
||||
// disabled tools, custom event subscriptions). Option overrides are
|
||||
// preserved across reloads since they represent user intent.
|
||||
//
|
||||
// The caller is responsible for emitting SessionShutdown before calling
|
||||
// Reload and SessionStart after.
|
||||
func (r *Runner) Reload(exts []LoadedExtension) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.extensions = exts
|
||||
r.widgets = nil
|
||||
r.statusEntries = nil
|
||||
r.header = nil
|
||||
r.footer = nil
|
||||
r.customEditor = nil
|
||||
r.uiVisibility = nil
|
||||
r.disabledTools = nil
|
||||
r.customEventSubs = nil
|
||||
// optionOverrides are intentionally preserved.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inter-extension event bus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubscribeCustomEvent registers a handler for a named custom event. Handlers
|
||||
// execute in registration order when EmitCustomEvent is called. Thread-safe.
|
||||
func (r *Runner) SubscribeCustomEvent(name string, handler func(string)) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.customEventSubs == nil {
|
||||
r.customEventSubs = make(map[string][]func(string))
|
||||
}
|
||||
r.customEventSubs[name] = append(r.customEventSubs[name], handler)
|
||||
}
|
||||
|
||||
// EmitCustomEvent dispatches a named event to all subscribed handlers.
|
||||
// Handlers run synchronously in extension load order. Panics are recovered
|
||||
// and logged. Thread-safe.
|
||||
func (r *Runner) EmitCustomEvent(name, data string) {
|
||||
// Collect handlers: extension-registered (Init-time) + dynamic subs.
|
||||
r.mu.RLock()
|
||||
dynamicHandlers := r.customEventSubs[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
safeInvoke := func(h func(string)) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Warn("custom event handler panicked",
|
||||
"event", name,
|
||||
"err", fmt.Sprintf("%v", rec))
|
||||
}
|
||||
}()
|
||||
h(data)
|
||||
}
|
||||
|
||||
// Extension-registered handlers first (in load order).
|
||||
for i := range r.extensions {
|
||||
for _, h := range r.extensions[i].CustomEventHandlers[name] {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
// Then dynamic subscriptions.
|
||||
for _, h := range dynamicHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetActiveTools restricts the tool set to the named tools. All tools not in
|
||||
// the list are disabled. Passing nil or an empty slice re-enables all tools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) SetActiveTools(names []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(names) == 0 {
|
||||
r.disabledTools = nil
|
||||
return
|
||||
}
|
||||
active := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
active[n] = true
|
||||
}
|
||||
r.disabledTools = active // non-nil = only these tools are allowed
|
||||
}
|
||||
|
||||
// IsToolDisabled returns true if the tool has been disabled via SetActiveTools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) IsToolDisabled(toolName string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.disabledTools == nil {
|
||||
return false // no filter = all enabled
|
||||
}
|
||||
return !r.disabledTools[toolName]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetOption resolves a named option value in priority order:
|
||||
// 1. Runtime override (via SetOption)
|
||||
// 2. Environment variable: KIT_OPT_<NAME> (uppercased, dashes → underscores)
|
||||
// 3. Viper config: options.<name>
|
||||
// 4. Default value from RegisterOption
|
||||
//
|
||||
// Returns empty string if the option was never registered.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetOption(name string) string {
|
||||
// 1. Runtime override.
|
||||
r.mu.RLock()
|
||||
if v, ok := r.optionOverrides[name]; ok {
|
||||
r.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// 2. Environment variable: KIT_OPT_<NAME>
|
||||
envKey := "KIT_OPT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
|
||||
if v := os.Getenv(envKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 3. Viper config: options.<name>
|
||||
configKey := "options." + name
|
||||
if v := viper.GetString(configKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 4. Default from registered option defs.
|
||||
for i := range r.extensions {
|
||||
for _, opt := range r.extensions[i].Options {
|
||||
if opt.Name == name {
|
||||
return opt.Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetOption stores a runtime override for a named option. This takes highest
|
||||
// priority over env vars, config, and defaults. Thread-safe.
|
||||
func (r *Runner) SetOption(name, value string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.optionOverrides == nil {
|
||||
r.optionOverrides = make(map[string]string)
|
||||
}
|
||||
r.optionOverrides[name] = value
|
||||
}
|
||||
|
||||
// RegisteredOptions returns all option definitions from all loaded extensions.
|
||||
func (r *Runner) RegisteredOptions() []OptionDef {
|
||||
var opts []OptionDef
|
||||
for i := range r.extensions {
|
||||
opts = append(opts, r.extensions[i].Options...)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetShortcuts returns all registered keyboard shortcuts as a map of
|
||||
// key binding → handler. If multiple extensions register the same key,
|
||||
// the last registration wins. Thread-safe (reads extension list which is
|
||||
// immutable after loading).
|
||||
func (r *Runner) GetShortcuts() map[string]ShortcutEntry {
|
||||
result := make(map[string]ShortcutEntry)
|
||||
for i := range r.extensions {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
result[sc.Def.Key] = sc
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RegisteredShortcuts returns all shortcut definitions from all loaded
|
||||
// extensions. Used for help/listing commands.
|
||||
func (r *Runner) RegisteredShortcuts() []ShortcutDef {
|
||||
var defs []ShortcutDef
|
||||
seen := make(map[string]bool)
|
||||
// Iterate in reverse so last registration for a key wins.
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
if !seen[sc.Def.Key] {
|
||||
seen[sc.Def.Key] = true
|
||||
defs = append(defs, sc.Def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,6 +618,12 @@ func isBlocking(result Result) bool {
|
||||
return r.Block
|
||||
case InputResult:
|
||||
return r.Action == "handled"
|
||||
case BeforeForkResult:
|
||||
return r.Cancel
|
||||
case BeforeSessionSwitchResult:
|
||||
return r.Cancel
|
||||
case BeforeCompactResult:
|
||||
return r.Cancel
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -23,9 +23,93 @@ func Symbols() interp.Exports {
|
||||
"API": reflect.ValueOf((*API)(nil)),
|
||||
"Context": reflect.ValueOf((*Context)(nil)),
|
||||
"ToolDef": reflect.ValueOf((*ToolDef)(nil)),
|
||||
"ToolContext": reflect.ValueOf((*ToolContext)(nil)),
|
||||
"ShortcutDef": reflect.ValueOf((*ShortcutDef)(nil)),
|
||||
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
|
||||
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
|
||||
|
||||
// Session types
|
||||
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
|
||||
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
|
||||
|
||||
// Option types
|
||||
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
|
||||
|
||||
// Model info types
|
||||
"ModelInfoEntry": reflect.ValueOf((*ModelInfoEntry)(nil)),
|
||||
|
||||
// Tool info types
|
||||
"ToolInfo": reflect.ValueOf((*ToolInfo)(nil)),
|
||||
|
||||
// LLM completion types
|
||||
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
|
||||
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
|
||||
|
||||
// Status bar types
|
||||
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
|
||||
|
||||
// Widget types
|
||||
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
|
||||
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
|
||||
"WidgetStyle": reflect.ValueOf((*WidgetStyle)(nil)),
|
||||
"WidgetPlacement": reflect.ValueOf((*WidgetPlacement)(nil)),
|
||||
"WidgetAbove": reflect.ValueOf(WidgetAbove),
|
||||
"WidgetBelow": reflect.ValueOf(WidgetBelow),
|
||||
|
||||
// Header/Footer types
|
||||
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
|
||||
|
||||
// UI visibility
|
||||
"UIVisibility": reflect.ValueOf((*UIVisibility)(nil)),
|
||||
|
||||
// Context stats
|
||||
"ContextStats": reflect.ValueOf((*ContextStats)(nil)),
|
||||
|
||||
// Overlay types
|
||||
"OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)),
|
||||
"OverlayCenter": reflect.ValueOf(OverlayCenter),
|
||||
"OverlayTopCenter": reflect.ValueOf(OverlayTopCenter),
|
||||
"OverlayBottomCenter": reflect.ValueOf(OverlayBottomCenter),
|
||||
"OverlayStyle": reflect.ValueOf((*OverlayStyle)(nil)),
|
||||
"OverlayConfig": reflect.ValueOf((*OverlayConfig)(nil)),
|
||||
"OverlayResult": reflect.ValueOf((*OverlayResult)(nil)),
|
||||
|
||||
// Tool renderer types
|
||||
"ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)),
|
||||
|
||||
// Message renderer types
|
||||
"MessageRendererConfig": reflect.ValueOf((*MessageRendererConfig)(nil)),
|
||||
|
||||
// Editor interceptor types
|
||||
"EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)),
|
||||
"EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough),
|
||||
"EditorKeyConsumed": reflect.ValueOf(EditorKeyConsumed),
|
||||
"EditorKeyRemap": reflect.ValueOf(EditorKeyRemap),
|
||||
"EditorKeySubmit": reflect.ValueOf(EditorKeySubmit),
|
||||
"EditorKeyAction": reflect.ValueOf((*EditorKeyAction)(nil)),
|
||||
"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)),
|
||||
|
||||
// Context filtering types
|
||||
"ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),
|
||||
"ContextPrepareEvent": reflect.ValueOf((*ContextPrepareEvent)(nil)),
|
||||
"ContextPrepareResult": reflect.ValueOf((*ContextPrepareResult)(nil)),
|
||||
|
||||
// Session lifecycle types
|
||||
"BeforeForkEvent": reflect.ValueOf((*BeforeForkEvent)(nil)),
|
||||
"BeforeForkResult": reflect.ValueOf((*BeforeForkResult)(nil)),
|
||||
"BeforeSessionSwitchEvent": reflect.ValueOf((*BeforeSessionSwitchEvent)(nil)),
|
||||
"BeforeSessionSwitchResult": reflect.ValueOf((*BeforeSessionSwitchResult)(nil)),
|
||||
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
|
||||
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
@@ -44,6 +128,7 @@ func Symbols() interp.Exports {
|
||||
"MessageEndEvent": reflect.ValueOf((*MessageEndEvent)(nil)),
|
||||
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
|
||||
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
|
||||
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package extensions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -9,19 +10,17 @@ import (
|
||||
|
||||
// WrapToolsWithExtensions wraps each tool so that ToolCall and ToolResult
|
||||
// events are emitted through the extension runner before and after execution.
|
||||
// This is the Go equivalent of Pi's wrapper.ts pattern.
|
||||
//
|
||||
|
||||
// If the runner has no relevant handlers the original tools are returned
|
||||
// unchanged (zero overhead).
|
||||
func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantasy.AgentTool {
|
||||
if runner == nil {
|
||||
return tools
|
||||
}
|
||||
if !runner.HasHandlers(ToolCall) && !runner.HasHandlers(ToolResult) &&
|
||||
!runner.HasHandlers(ToolExecutionStart) && !runner.HasHandlers(ToolExecutionEnd) {
|
||||
return tools
|
||||
}
|
||||
|
||||
// Always wrap tools through the runner so that SetActiveTools
|
||||
// (disabled-tool checking) and event handlers both work. The
|
||||
// overhead for disabled-tool checking is a single map lookup
|
||||
// per tool call, which is negligible.
|
||||
wrapped := make([]fantasy.AgentTool, len(tools))
|
||||
for i, tool := range tools {
|
||||
wrapped[i] = &wrappedTool{inner: tool, runner: runner}
|
||||
@@ -31,10 +30,12 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas
|
||||
|
||||
// ExtensionToolsAsFantasy converts ToolDef values registered by extensions
|
||||
// into fantasy.AgentTool implementations so the LLM can invoke them.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef) []fantasy.AgentTool {
|
||||
// The runner is optional; if provided, ToolContext.OnProgress routes
|
||||
// progress messages through the runner's Print function.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool {
|
||||
tools := make([]fantasy.AgentTool, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
tools = append(tools, &extensionTool{def: def})
|
||||
tools = append(tools, &extensionTool{def: def, runner: runner})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
@@ -55,12 +56,20 @@ func (w *wrappedTool) SetProviderOptions(o fantasy.ProviderOptions) { w.inner.Se
|
||||
func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
toolName := w.inner.Info().Name
|
||||
|
||||
// 0. Check if tool is disabled via SetActiveTools.
|
||||
if w.runner.IsToolDisabled(toolName) {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
|
||||
fmt.Errorf("tool %q disabled by extension", toolName)
|
||||
}
|
||||
|
||||
// 1. Emit ToolCall — extensions can block execution.
|
||||
if w.runner.HasHandlers(ToolCall) {
|
||||
result, _ := w.runner.Emit(ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
ToolCallID: call.ID,
|
||||
Input: call.Input,
|
||||
Source: "llm",
|
||||
})
|
||||
if r, ok := result.(ToolCallResult); ok && r.Block {
|
||||
reason := r.Reason
|
||||
@@ -112,21 +121,84 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
type extensionTool struct {
|
||||
def ToolDef
|
||||
runner *Runner // optional; enables ToolContext.OnProgress
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
func (t *extensionTool) Info() fantasy.ToolInfo {
|
||||
return fantasy.ToolInfo{
|
||||
info := fantasy.ToolInfo{
|
||||
Name: t.def.Name,
|
||||
Description: t.def.Description,
|
||||
}
|
||||
|
||||
// Parse the extension's JSON Schema and extract the properties map.
|
||||
// Fantasy expects Parameters to contain property definitions directly
|
||||
// (e.g. {"command": {"type":"string"}}) and wraps them into a full
|
||||
// JSON Schema object internally. If the extension provides a full
|
||||
// schema with "type":"object" and "properties", we extract just the
|
||||
// properties. Required fields are also extracted if present.
|
||||
if t.def.Parameters != "" {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal([]byte(t.def.Parameters), &schema); err == nil {
|
||||
if props, ok := schema["properties"].(map[string]any); ok {
|
||||
info.Parameters = props
|
||||
} else {
|
||||
// Schema doesn't have "properties" — use as-is (may be
|
||||
// a flat property map already matching fantasy's format).
|
||||
info.Parameters = schema
|
||||
}
|
||||
// Extract required fields if present.
|
||||
if req, ok := schema["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
info.Required = append(info.Required, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Parameters and Required are never nil — the OpenAI Responses API
|
||||
// rejects tools where these fields serialize to JSON null instead of
|
||||
// empty object/array.
|
||||
if info.Parameters == nil {
|
||||
info.Parameters = map[string]any{}
|
||||
}
|
||||
if info.Required == nil {
|
||||
info.Required = []string{}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
|
||||
func (t *extensionTool) SetProviderOptions(o fantasy.ProviderOptions) { t.providerOptions = o }
|
||||
|
||||
func (t *extensionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result, err := t.def.Execute(call.Input)
|
||||
func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var result string
|
||||
var err error
|
||||
|
||||
if t.def.ExecuteWithContext != nil {
|
||||
tc := ToolContext{
|
||||
IsCancelled: func() bool {
|
||||
return ctx.Err() != nil
|
||||
},
|
||||
OnProgress: func(text string) {
|
||||
if t.runner != nil {
|
||||
t.runner.mu.RLock()
|
||||
printFn := t.runner.ctx.Print
|
||||
t.runner.mu.RUnlock()
|
||||
if printFn != nil {
|
||||
printFn(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
result, err = t.def.ExecuteWithContext(call.Input, tc)
|
||||
} else {
|
||||
result, err = t.def.Execute(call.Input)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), err
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
|
||||
}))
|
||||
tools := []fantasy.AgentTool{newMockTool("test")}
|
||||
result := WrapToolsWithExtensions(tools, r)
|
||||
if result[0] != tools[0] {
|
||||
t.Error("expected original tool when no tool handlers exist")
|
||||
// Tools are always wrapped now (for SetActiveTools support),
|
||||
// but Info() should pass through correctly.
|
||||
if result[0] == tools[0] {
|
||||
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
|
||||
}
|
||||
if result[0].Info().Name != "test" {
|
||||
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +107,22 @@ func TestWrappedTool_NormalExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_SourceField(t *testing.T) {
|
||||
var gotSource string
|
||||
r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{
|
||||
ToolCall: {func(e Event, c Context) Result {
|
||||
gotSource = e.(ToolCallEvent).Source
|
||||
return nil
|
||||
}},
|
||||
}))
|
||||
|
||||
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r)
|
||||
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
|
||||
if gotSource != "llm" {
|
||||
t.Errorf("expected Source='llm', got %q", gotSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_BlockExecution(t *testing.T) {
|
||||
var toolRan bool
|
||||
r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{
|
||||
@@ -181,7 +202,7 @@ func TestExtensionToolsAsFantasy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
@@ -211,7 +232,7 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
@@ -221,9 +242,104 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContext(t *testing.T) {
|
||||
var gotCancelled bool
|
||||
var gotProgress []string
|
||||
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "rich",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
gotCancelled = tc.IsCancelled()
|
||||
tc.OnProgress("step 1")
|
||||
tc.OnProgress("step 2")
|
||||
return "done: " + input, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Without runner, OnProgress is a no-op.
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "done: test" {
|
||||
t.Errorf("expected 'done: test', got %q", resp.Content)
|
||||
}
|
||||
if gotCancelled {
|
||||
t.Error("expected IsCancelled=false for non-cancelled context")
|
||||
}
|
||||
|
||||
// With runner, OnProgress routes through Print.
|
||||
runner := NewRunner(nil)
|
||||
runner.SetContext(Context{
|
||||
Print: func(text string) { gotProgress = append(gotProgress, text) },
|
||||
})
|
||||
defs2 := []ToolDef{
|
||||
{
|
||||
Name: "rich2",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
tc.OnProgress("hello")
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools2 := ExtensionToolsAsFantasy(defs2, runner)
|
||||
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(gotProgress) != 1 || gotProgress[0] != "hello" {
|
||||
t.Errorf("expected [hello], got %v", gotProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
|
||||
// When both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "both",
|
||||
Execute: func(input string) (string, error) { return "simple", nil },
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
return "rich", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "rich" {
|
||||
t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_CancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
var sawCancelled bool
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "checkcancel",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
sawCancelled = tc.IsCancelled()
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
|
||||
if !sawCancelled {
|
||||
t.Error("expected IsCancelled=true for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ProviderOptions(t *testing.T) {
|
||||
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
|
||||
// Initially nil.
|
||||
opts := tools[0].ProviderOptions()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
package kit
|
||||
// Package kitsetup contains agent creation logic used by both the CLI binary
|
||||
// and the SDK's kit.New(). It is internal — external SDK consumers should use
|
||||
// kit.New() which delegates here.
|
||||
package kitsetup
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -76,6 +79,7 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
NumGPU: &numGPU,
|
||||
MainGPU: &mainGPU,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
}
|
||||
|
||||
return cfg, systemPrompt, nil
|
||||
@@ -183,7 +187,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
|
||||
return extensions.WrapToolsWithExtensions(tools, runner)
|
||||
}
|
||||
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools())
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools(), runner)
|
||||
|
||||
return runner, extensionCreationOpts{
|
||||
toolWrapper: wrapper,
|
||||
@@ -58,6 +58,16 @@ type ToolResult struct {
|
||||
|
||||
func (ToolResult) isPart() {}
|
||||
|
||||
// ImageContent holds image data within a message. The data is stored as raw
|
||||
// bytes (not base64-encoded); serialization handles encoding. MediaType is a
|
||||
// MIME type such as "image/png" or "image/jpeg".
|
||||
type ImageContent struct {
|
||||
Data []byte `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
func (ImageContent) isPart() {}
|
||||
|
||||
// Finish marks the end of an assistant turn, carrying the stop reason.
|
||||
type Finish struct {
|
||||
Reason string `json:"reason"` // "end_turn", "tool_use", "max_tokens", etc.
|
||||
@@ -129,6 +139,17 @@ func (m *Message) ToolResults() []ToolResult {
|
||||
return results
|
||||
}
|
||||
|
||||
// Images returns all ImageContent parts from this message.
|
||||
func (m *Message) Images() []ImageContent {
|
||||
var images []ImageContent
|
||||
for _, part := range m.Parts {
|
||||
if ic, ok := part.(ImageContent); ok {
|
||||
images = append(images, ic)
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// Reasoning returns the ReasoningContent if present, or a zero value.
|
||||
func (m *Message) Reasoning() ReasoningContent {
|
||||
for _, part := range m.Parts {
|
||||
@@ -170,6 +191,7 @@ const (
|
||||
toolCallType partType = "tool_call"
|
||||
toolResultType partType = "tool_result"
|
||||
finishType partType = "finish"
|
||||
imageType partType = "image"
|
||||
)
|
||||
|
||||
type partWrapper struct {
|
||||
@@ -194,6 +216,8 @@ func MarshalParts(parts []ContentPart) ([]byte, error) {
|
||||
pt = toolResultType
|
||||
case Finish:
|
||||
pt = finishType
|
||||
case ImageContent:
|
||||
pt = imageType
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown content part type: %T", part)
|
||||
}
|
||||
@@ -247,6 +271,12 @@ func UnmarshalParts(data []byte) ([]ContentPart, error) {
|
||||
return nil, fmt.Errorf("failed to unmarshal finish part: %w", err)
|
||||
}
|
||||
part = p
|
||||
case imageType:
|
||||
var p ImageContent
|
||||
if err := json.Unmarshal(w.Data, &p); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal image part: %w", err)
|
||||
}
|
||||
part = p
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown part type: %s", w.Type)
|
||||
}
|
||||
@@ -323,13 +353,25 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
|
||||
}}
|
||||
|
||||
case RoleUser:
|
||||
var parts []fantasy.MessagePart
|
||||
text := m.Content()
|
||||
if text == "" {
|
||||
if text != "" {
|
||||
parts = append(parts, fantasy.TextPart{Text: text})
|
||||
}
|
||||
for _, part := range m.Parts {
|
||||
if ic, ok := part.(ImageContent); ok {
|
||||
parts = append(parts, fantasy.FilePart{
|
||||
Data: ic.Data,
|
||||
MediaType: ic.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []fantasy.Message{{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
|
||||
Content: parts,
|
||||
}}
|
||||
|
||||
case RoleSystem:
|
||||
@@ -388,6 +430,13 @@ func FromFantasyMessage(msg fantasy.Message) Message {
|
||||
Thinking: p.Text,
|
||||
})
|
||||
}
|
||||
case fantasy.FilePart:
|
||||
if len(p.Data) > 0 {
|
||||
m.Parts = append(m.Parts, ImageContent{
|
||||
Data: p.Data,
|
||||
MediaType: p.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,66 @@ func resolveModelAlias(provider, modelName string) string {
|
||||
return modelName
|
||||
}
|
||||
|
||||
// ThinkingLevel controls extended thinking / reasoning budget for supported models.
|
||||
type ThinkingLevel string
|
||||
|
||||
const (
|
||||
ThinkingOff ThinkingLevel = "off"
|
||||
ThinkingMinimal ThinkingLevel = "minimal"
|
||||
ThinkingLow ThinkingLevel = "low"
|
||||
ThinkingMedium ThinkingLevel = "medium"
|
||||
ThinkingHigh ThinkingLevel = "high"
|
||||
)
|
||||
|
||||
// ThinkingLevels returns the ordered list of available thinking levels for cycling.
|
||||
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 {
|
||||
switch level {
|
||||
case ThinkingMinimal:
|
||||
return 1024
|
||||
case ThinkingLow:
|
||||
return 4096
|
||||
case ThinkingMedium:
|
||||
return 10240
|
||||
case ThinkingHigh:
|
||||
return 20480
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkingLevelDescription returns a human-readable description of a thinking level.
|
||||
func ThinkingLevelDescription(level ThinkingLevel) string {
|
||||
switch level {
|
||||
case ThinkingOff:
|
||||
return "No reasoning"
|
||||
case ThinkingMinimal:
|
||||
return "Very brief reasoning (~1k tokens)"
|
||||
case ThinkingLow:
|
||||
return "Light reasoning (~4k tokens)"
|
||||
case ThinkingMedium:
|
||||
return "Moderate reasoning (~10k tokens)"
|
||||
case ThinkingHigh:
|
||||
return "Deep reasoning (~20k tokens)"
|
||||
default:
|
||||
return "No reasoning"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseThinkingLevel converts a string to a ThinkingLevel, defaulting to ThinkingOff.
|
||||
func ParseThinkingLevel(s string) ThinkingLevel {
|
||||
switch ThinkingLevel(s) {
|
||||
case ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
|
||||
return ThinkingLevel(s)
|
||||
default:
|
||||
return ThinkingOff
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderConfig holds configuration for creating LLM providers.
|
||||
type ProviderConfig struct {
|
||||
ModelString string
|
||||
@@ -71,6 +131,7 @@ type ProviderConfig struct {
|
||||
NumGPU *int32
|
||||
MainGPU *int32
|
||||
TLSSkipVerify bool
|
||||
ThinkingLevel ThinkingLevel
|
||||
}
|
||||
|
||||
// ProviderResult contains the result of provider creation.
|
||||
@@ -82,6 +143,9 @@ type ProviderResult struct {
|
||||
// Closer is an optional cleanup function for providers that hold
|
||||
// resources (e.g. kronk's loaded models). May be nil.
|
||||
Closer io.Closer
|
||||
// ProviderOptions contains provider-specific options to be passed to the
|
||||
// fantasy agent (e.g. OpenAI Responses API reasoning options).
|
||||
ProviderOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
// ParseModelString parses a model string in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5").
|
||||
@@ -256,6 +320,8 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
// createAutoRoutedAnthropicProvider creates an anthropic provider for
|
||||
// third-party providers with anthropic-compatible APIs (e.g. minimax).
|
||||
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
@@ -297,6 +363,7 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
var opts []openai.Option
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
opts = append(opts, openai.WithUseResponsesAPI())
|
||||
|
||||
if config.ProviderURL != "" {
|
||||
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
|
||||
@@ -316,7 +383,9 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
// resolveAPIKey returns the first non-empty API key from the explicit key
|
||||
@@ -347,7 +416,102 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// clearConflictingAnthropicSamplingParams ensures that temperature and top_p are
|
||||
// not both sent to the Anthropic API, which rejects requests containing both.
|
||||
// When both are set (typically from defaults), top_p is cleared so that
|
||||
// temperature takes precedence.
|
||||
func clearConflictingAnthropicSamplingParams(config *ProviderConfig) {
|
||||
if config.Temperature != nil && config.TopP != nil {
|
||||
config.TopP = nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildOpenAIProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// OpenAI Responses API models. For reasoning models it sets reasoning_summary
|
||||
// to "auto", includes encrypted reasoning content, and maps the ThinkingLevel
|
||||
// to an OpenAI ReasoningEffort. For non-responses or non-reasoning models the
|
||||
// returned map is nil (no extra options needed).
|
||||
func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
|
||||
if !openai.IsResponsesModel(modelName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if openai.IsResponsesReasoningModel(modelName) {
|
||||
reasoningSummary := "auto"
|
||||
opts := &openai.ResponsesProviderOptions{
|
||||
ReasoningSummary: &reasoningSummary,
|
||||
Include: []openai.IncludeType{
|
||||
openai.IncludeReasoningEncryptedContent,
|
||||
},
|
||||
}
|
||||
|
||||
// Map ThinkingLevel to OpenAI ReasoningEffort.
|
||||
if effort := thinkingLevelToReasoningEffort(config.ThinkingLevel); effort != nil {
|
||||
opts.ReasoningEffort = effort
|
||||
}
|
||||
|
||||
return fantasy.ProviderOptions{
|
||||
openai.Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// thinkingLevelToReasoningEffort maps a ThinkingLevel to an OpenAI ReasoningEffort.
|
||||
// Returns nil for ThinkingOff (use the model's default).
|
||||
func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort {
|
||||
switch level {
|
||||
case ThinkingMinimal:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortMinimal)
|
||||
case ThinkingLow:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortLow)
|
||||
case ThinkingMedium:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortMedium)
|
||||
case ThinkingHigh:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortHigh)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildAnthropicProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// Anthropic models with extended thinking. When thinking is enabled, it sets
|
||||
// SendReasoning to true and configures the thinking budget. For thinking-off
|
||||
// or non-reasoning models the returned map is nil.
|
||||
//
|
||||
// Anthropic requires max_tokens > thinking.budget_tokens. If the configured
|
||||
// MaxTokens is too low, it is bumped to budget + 4096 to leave room for the
|
||||
// actual response.
|
||||
func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
|
||||
if config.ThinkingLevel == "" || config.ThinkingLevel == ThinkingOff {
|
||||
return nil
|
||||
}
|
||||
|
||||
budget := ThinkingBudgetTokens(config.ThinkingLevel)
|
||||
if budget == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure MaxTokens exceeds the thinking budget (Anthropic requirement).
|
||||
minRequired := int(budget) + 4096
|
||||
if config.MaxTokens < minRequired {
|
||||
config.MaxTokens = minRequired
|
||||
}
|
||||
|
||||
sendReasoning := true
|
||||
opts := &anthropic.ProviderOptions{
|
||||
SendReasoning: &sendReasoning,
|
||||
Thinking: &anthropic.ThinkingProviderOption{
|
||||
BudgetTokens: budget,
|
||||
},
|
||||
}
|
||||
return anthropic.NewProviderOptions(opts)
|
||||
}
|
||||
|
||||
func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey, source, err := auth.GetAnthropicAPIKey(config.ProviderAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -383,10 +547,15 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
// Build provider options for extended thinking (reasoning budget).
|
||||
providerOpts := buildAnthropicProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
projectID := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_PROJECT"),
|
||||
os.Getenv("ANTHROPIC_VERTEX_PROJECT_ID"),
|
||||
@@ -434,6 +603,7 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
var opts []openai.Option
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
opts = append(opts, openai.WithUseResponsesAPI())
|
||||
|
||||
if config.ProviderURL != "" {
|
||||
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
|
||||
@@ -453,7 +623,10 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
// Build provider options for OpenAI Responses API reasoning models.
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// EntryType identifies the kind of entry stored in a JSONL session file.
|
||||
// Following pi's design, sessions are append-only JSONL files where each line
|
||||
// is a typed entry linked by id/parent_id to form a tree structure.
|
||||
// Sessions are append-only JSONL files where each line is a typed entry
|
||||
// linked by id/parent_id to form a tree structure.
|
||||
type EntryType string
|
||||
|
||||
const (
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
EntryTypeBranchSummary EntryType = "branch_summary"
|
||||
EntryTypeLabel EntryType = "label"
|
||||
EntryTypeSessionInfo EntryType = "session_info"
|
||||
EntryTypeExtensionData EntryType = "extension_data"
|
||||
)
|
||||
|
||||
// CurrentVersion is the session format version for JSONL tree sessions.
|
||||
@@ -89,6 +90,14 @@ type SessionInfoEntry struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ExtensionDataEntry stores custom extension data in the session tree.
|
||||
// Extensions use this to persist state that survives across session restarts.
|
||||
type ExtensionDataEntry struct {
|
||||
Entry
|
||||
ExtType string `json:"ext_type"` // Extension-defined type string (e.g. "plan-mode:state")
|
||||
Data string `json:"data"` // Extension-defined data (JSON or plain text)
|
||||
}
|
||||
|
||||
// GenerateEntryID creates a unique entry identifier (16 hex chars).
|
||||
func GenerateEntryID() string {
|
||||
bytes := make([]byte, 8)
|
||||
@@ -177,6 +186,15 @@ func NewSessionInfoEntry(parentID, name string) *SessionInfoEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// NewExtensionDataEntry creates an ExtensionDataEntry.
|
||||
func NewExtensionDataEntry(parentID, extType, data string) *ExtensionDataEntry {
|
||||
return &ExtensionDataEntry{
|
||||
Entry: NewEntry(EntryTypeExtensionData, parentID),
|
||||
ExtType: extType,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSONL marshaling helpers ---
|
||||
|
||||
// MarshalEntry serializes any entry to a JSON line (no trailing newline).
|
||||
@@ -241,6 +259,13 @@ func UnmarshalEntry(data []byte) (any, error) {
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
case EntryTypeExtensionData:
|
||||
var e ExtensionDataEntry
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal extension_data entry: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown entry type: %q", env.Type)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// SessionInfo contains metadata about a discovered session, used for listing
|
||||
// and session picker display. Follows pi's SessionInfo design.
|
||||
// and session picker display.
|
||||
type SessionInfo struct {
|
||||
// Path is the absolute path to the JSONL session file.
|
||||
Path string
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// TreeNode represents a node in the session tree for display purposes.
|
||||
// It mirrors pi's SessionTreeNode design.
|
||||
|
||||
type TreeNode struct {
|
||||
Entry any // the underlying entry (*MessageEntry, *ModelChangeEntry, etc.)
|
||||
ID string // entry ID
|
||||
@@ -25,7 +25,7 @@ type TreeNode struct {
|
||||
}
|
||||
|
||||
// TreeManager manages a tree-structured JSONL session. It is the replacement
|
||||
// for the linear session.Manager, following pi's design decisions:
|
||||
// for the linear session.Manager:
|
||||
//
|
||||
// - JSONL append-only format (one JSON object per line)
|
||||
// - Tree structure via id/parent_id on every entry
|
||||
@@ -283,6 +283,44 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
|
||||
return entry.ID, nil
|
||||
}
|
||||
|
||||
// AppendExtensionData adds an extension data entry to the tree and persists it.
|
||||
// Extensions use this to store custom state that survives across session restarts.
|
||||
func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
entry := NewExtensionDataEntry(tm.leafID, extType, data)
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
}
|
||||
|
||||
// GetExtensionData returns all extension data entries matching the given type,
|
||||
// walking the current branch from root to leaf. If extType is empty, all
|
||||
// extension data entries on the branch are returned.
|
||||
func (tm *TreeManager) GetExtensionData(extType string) []*ExtensionDataEntry {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
if tm.leafID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
branch := tm.getBranchLocked(tm.leafID)
|
||||
var results []*ExtensionDataEntry
|
||||
for _, entry := range branch {
|
||||
if e, ok := entry.(*ExtensionDataEntry); ok {
|
||||
if extType == "" || e.ExtType == extType {
|
||||
results = append(results, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// --- Tree navigation ---
|
||||
|
||||
// Branch moves the leaf pointer to the given entry ID, creating a branch
|
||||
@@ -601,6 +639,8 @@ func (tm *TreeManager) entryID(entry any) string {
|
||||
return e.ID
|
||||
case *SessionInfoEntry:
|
||||
return e.ID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -619,6 +659,8 @@ func (tm *TreeManager) entryParentID(entry any) string {
|
||||
return e.ParentID
|
||||
case *SessionInfoEntry:
|
||||
return e.ParentID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ParentID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -675,7 +717,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
|
||||
// --- Path conventions ---
|
||||
|
||||
// DefaultSessionDir returns the default session storage directory for a cwd.
|
||||
// Following pi's convention: ~/.kit/sessions/--<cwd-path>--/
|
||||
// Convention: ~/.kit/sessions/--<cwd-path>--/
|
||||
func DefaultSessionDir(cwd string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -202,7 +202,7 @@ func LoadSkills(cwd string) ([]*Skill, error) {
|
||||
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
|
||||
// system prompt. Only the name, description, and file location are included;
|
||||
// the agent reads the full skill file on demand using the read tool. This
|
||||
// matches the Pi SDK's formatSkillsForPrompt convention.
|
||||
|
||||
func FormatForPrompt(skills []*Skill) string {
|
||||
if len(skills) == 0 {
|
||||
return ""
|
||||
|
||||
+38
-101
@@ -10,7 +10,7 @@ import (
|
||||
type blockRenderer struct {
|
||||
align *lipgloss.Position
|
||||
borderColor *color.Color
|
||||
bgColor *color.Color
|
||||
background *color.Color
|
||||
fullWidth bool
|
||||
noBorder bool
|
||||
paddingTop int
|
||||
@@ -34,14 +34,6 @@ func WithFullWidth() renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithBackground returns a renderingOption that sets a background color
|
||||
// for the entire block.
|
||||
func WithBackground(c color.Color) renderingOption {
|
||||
return func(br *blockRenderer) {
|
||||
br.bgColor = &c
|
||||
}
|
||||
}
|
||||
|
||||
// WithNoBorder returns a renderingOption that disables all borders on the
|
||||
// block, rendering content with only padding.
|
||||
func WithNoBorder() renderingOption {
|
||||
@@ -122,6 +114,15 @@ func WithPaddingBottom(padding int) renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithBackground returns a renderingOption that sets the background color
|
||||
// for the entire block. The color parameter accepts any color.Color value,
|
||||
// typically a lipgloss hex color (e.g. lipgloss.Color("#1e1e2e")).
|
||||
func WithBackground(c color.Color) renderingOption {
|
||||
return func(br *blockRenderer) {
|
||||
br.background = &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.
|
||||
@@ -165,104 +166,40 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
|
||||
}
|
||||
|
||||
theme := GetTheme()
|
||||
hasBg := renderer.bgColor != nil
|
||||
|
||||
if hasBg {
|
||||
// When a background color is set we use a three-phase render so
|
||||
// the border extends the full block height including padding:
|
||||
// 1. Render content with bg + horizontal padding (no border,
|
||||
// no vertical padding).
|
||||
// 2. Use Place() to add vertical padding with uniform bg fill.
|
||||
// 3. Apply the border to the padded block.
|
||||
// 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)
|
||||
|
||||
// Phase 1 — content with background + horizontal padding.
|
||||
innerStyle := lipgloss.NewStyle().
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
Foreground(theme.Text).
|
||||
Background(*renderer.bgColor)
|
||||
if hasBorder {
|
||||
style = style.BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
if renderer.fullWidth {
|
||||
innerStyle = innerStyle.Width(renderer.width - borderChars)
|
||||
switch borderAlign {
|
||||
case lipgloss.Right:
|
||||
style = style.
|
||||
BorderRight(true).
|
||||
BorderRightForeground(borderColor)
|
||||
default:
|
||||
style = style.
|
||||
BorderLeft(true).
|
||||
BorderLeftForeground(borderColor)
|
||||
}
|
||||
|
||||
content = innerStyle.Render(content)
|
||||
|
||||
// Phase 2 — vertical padding via Place() with bg-filled whitespace.
|
||||
if renderer.paddingTop > 0 || renderer.paddingBottom > 0 {
|
||||
renderedH := lipgloss.Height(content)
|
||||
renderedW := lipgloss.Width(content)
|
||||
totalH := renderedH + renderer.paddingTop + renderer.paddingBottom
|
||||
|
||||
bgStyle := lipgloss.NewStyle().Background(*renderer.bgColor)
|
||||
|
||||
// Determine vertical position so padding distributes correctly.
|
||||
vPos := lipgloss.Center
|
||||
switch {
|
||||
case renderer.paddingTop > 0 && renderer.paddingBottom == 0:
|
||||
vPos = lipgloss.Bottom
|
||||
case renderer.paddingBottom > 0 && renderer.paddingTop == 0:
|
||||
vPos = lipgloss.Top
|
||||
}
|
||||
|
||||
content = lipgloss.Place(
|
||||
renderedW, totalH,
|
||||
lipgloss.Left, vPos,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(bgStyle),
|
||||
)
|
||||
}
|
||||
|
||||
// Phase 3 — apply border to the full-height block.
|
||||
if hasBorder {
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
switch borderAlign {
|
||||
case lipgloss.Right:
|
||||
borderStyle = borderStyle.
|
||||
BorderRight(true).
|
||||
BorderRightForeground(borderColor)
|
||||
default:
|
||||
borderStyle = borderStyle.
|
||||
BorderLeft(true).
|
||||
BorderLeftForeground(borderColor)
|
||||
}
|
||||
|
||||
content = borderStyle.Render(content)
|
||||
}
|
||||
} else {
|
||||
// No background — PaddingTop/PaddingBottom work fine (no visible
|
||||
// banding), so render everything in a single style pass.
|
||||
style := lipgloss.NewStyle().
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
Foreground(theme.Text)
|
||||
|
||||
if hasBorder {
|
||||
style = style.BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
switch borderAlign {
|
||||
case lipgloss.Right:
|
||||
style = style.
|
||||
BorderRight(true).
|
||||
BorderRightForeground(borderColor)
|
||||
default:
|
||||
style = style.
|
||||
BorderLeft(true).
|
||||
BorderLeftForeground(borderColor)
|
||||
}
|
||||
}
|
||||
|
||||
if renderer.fullWidth {
|
||||
style = style.Width(renderer.width - borderChars)
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
}
|
||||
|
||||
if renderer.background != nil {
|
||||
style = style.Background(*renderer.background)
|
||||
}
|
||||
|
||||
if renderer.fullWidth {
|
||||
style = style.Width(renderer.width - borderChars)
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
|
||||
// Add margins
|
||||
if renderer.marginTop > 0 {
|
||||
for range renderer.marginTop {
|
||||
|
||||
+25
-109
@@ -15,15 +15,12 @@ import (
|
||||
// display modes, handles streaming responses, tracks token usage, and manages the
|
||||
// overall conversation flow between the user and AI assistants.
|
||||
type CLI struct {
|
||||
messageRenderer *MessageRenderer
|
||||
compactRenderer *CompactRenderer
|
||||
messageContainer *MessageContainer
|
||||
usageTracker *UsageTracker
|
||||
width int
|
||||
height int
|
||||
compactMode bool
|
||||
debug bool
|
||||
modelName string
|
||||
renderer Renderer
|
||||
usageTracker *UsageTracker
|
||||
width int
|
||||
compactMode bool
|
||||
debug bool
|
||||
modelName string
|
||||
}
|
||||
|
||||
// NewCLI creates and initializes a new CLI instance with the specified display modes.
|
||||
@@ -36,9 +33,11 @@ func NewCLI(debug bool, compact bool) (*CLI, error) {
|
||||
debug: debug,
|
||||
}
|
||||
cli.updateSize()
|
||||
cli.messageRenderer = NewMessageRenderer(cli.width, debug)
|
||||
cli.compactRenderer = NewCompactRenderer(cli.width, debug)
|
||||
cli.messageContainer = NewMessageContainer(cli.width, cli.height-4, compact) // Pass compact mode
|
||||
if compact {
|
||||
cli.renderer = NewCompactRenderer(cli.width, debug)
|
||||
} else {
|
||||
cli.renderer = NewMessageRenderer(cli.width, debug)
|
||||
}
|
||||
|
||||
return cli, nil
|
||||
}
|
||||
@@ -71,9 +70,6 @@ func (c *CLI) GetDebugLogger() *CLIDebugLogger {
|
||||
// This name is displayed in message headers to indicate which model is responding.
|
||||
func (c *CLI) SetModelName(modelName string) {
|
||||
c.modelName = modelName
|
||||
if c.messageContainer != nil {
|
||||
c.messageContainer.SetModelName(modelName)
|
||||
}
|
||||
}
|
||||
|
||||
// ShowSpinner displays an animated spinner while executing the provided action
|
||||
@@ -94,14 +90,7 @@ func (c *CLI) ShowSpinner(action func() error) error {
|
||||
// formatting based on the current display mode (standard or compact). The message
|
||||
// is timestamped and styled according to the active theme.
|
||||
func (c *CLI) DisplayUserMessage(message string) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderUserMessage(message, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderUserMessage(message, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderUserMessage(message, time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayAssistantMessage renders and displays an AI assistant's response message
|
||||
@@ -115,14 +104,7 @@ func (c *CLI) DisplayAssistantMessage(message string) error {
|
||||
// with the specified model name shown in the message header. The message is
|
||||
// formatted according to the current display mode and includes timestamp information.
|
||||
func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderAssistantMessage(message, time.Now(), modelName)
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderAssistantMessage(message, time.Now(), modelName)
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderAssistantMessage(message, time.Now(), modelName).Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,44 +119,21 @@ func (c *CLI) DisplayToolCallMessage(toolName, toolArgs string) {
|
||||
// including the tool name, arguments, and result. The isError parameter determines
|
||||
// whether the result should be displayed as an error or success message.
|
||||
func (c *CLI) DisplayToolMessage(toolName, toolArgs, toolResult string, isError bool) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError)
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError)
|
||||
}
|
||||
|
||||
// Always display immediately - spinner management is handled externally
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderToolMessage(toolName, toolArgs, toolResult, isError).Content)
|
||||
}
|
||||
|
||||
// DisplayError renders and displays an error message with distinctive formatting
|
||||
// to ensure visibility. The error is timestamped and styled according to the
|
||||
// current display mode's error theme.
|
||||
func (c *CLI) DisplayError(err error) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderErrorMessage(err.Error(), time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderErrorMessage(err.Error(), time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderErrorMessage(err.Error(), time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayInfo renders and displays an informational system message. These messages
|
||||
// are typically used for status updates, notifications, or other non-error system
|
||||
// communications to the user.
|
||||
func (c *CLI) DisplayInfo(message string) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderSystemMessage(message, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderSystemMessage(message, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderSystemMessage(message, time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayExtensionBlock renders a custom styled block with the given border
|
||||
@@ -195,7 +154,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
|
||||
|
||||
rendered := renderContentBlock(
|
||||
content,
|
||||
c.messageRenderer.width,
|
||||
c.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderClr),
|
||||
WithMarginBottom(1),
|
||||
@@ -206,14 +165,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
|
||||
// DisplayCancellation displays a system message indicating that the current
|
||||
// AI generation has been cancelled by the user (typically via ESC key).
|
||||
func (c *CLI) DisplayCancellation() {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayDebugMessage renders and displays a debug message if debug mode is enabled.
|
||||
@@ -223,42 +175,14 @@ func (c *CLI) DisplayDebugMessage(message string) {
|
||||
if !c.debug {
|
||||
return
|
||||
}
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderDebugMessage(message, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderDebugMessage(message, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderDebugMessage(message, time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayDebugConfig renders and displays configuration settings in a formatted
|
||||
// debug message. The config parameter should contain key-value pairs representing
|
||||
// configuration options that will be displayed for debugging purposes.
|
||||
func (c *CLI) DisplayDebugConfig(config map[string]any) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderDebugConfigMessage(config, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderDebugConfigMessage(config, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
}
|
||||
|
||||
// displayContainer renders and displays the message container for one-shot
|
||||
// (non-streaming) messages. Output matches the interactive TUI's tea.Println
|
||||
// path — no extra padding or width wrapping is applied so both modes produce
|
||||
// identical visual output.
|
||||
func (c *CLI) displayContainer() {
|
||||
content := c.messageContainer.Render()
|
||||
if content != "" {
|
||||
fmt.Println(content)
|
||||
}
|
||||
|
||||
// Clear messages after display; one-shot messages don't need to persist.
|
||||
c.messageContainer.messages = nil
|
||||
fmt.Println(c.renderer.RenderDebugConfigMessage(config, time.Now()).Content)
|
||||
}
|
||||
|
||||
// UpdateUsageFromResponse records token usage using metadata from the fantasy
|
||||
@@ -309,27 +233,19 @@ func (c *CLI) DisplayUsageAfterResponse() {
|
||||
|
||||
// updateSize updates the CLI size based on terminal dimensions
|
||||
func (c *CLI) updateSize() {
|
||||
width, height, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil {
|
||||
c.width = 80 // Fallback width
|
||||
c.height = 24 // Fallback height
|
||||
c.width = 80 // Fallback width
|
||||
return
|
||||
}
|
||||
|
||||
// Add left and right padding (4 characters total: 2 on each side)
|
||||
paddingTotal := 4
|
||||
c.width = width - paddingTotal
|
||||
c.height = height
|
||||
|
||||
// Update renderers if they exist
|
||||
if c.messageRenderer != nil {
|
||||
c.messageRenderer.SetWidth(c.width)
|
||||
}
|
||||
if c.compactRenderer != nil {
|
||||
c.compactRenderer.SetWidth(c.width)
|
||||
}
|
||||
if c.messageContainer != nil {
|
||||
c.messageContainer.SetSize(c.width, c.height-4)
|
||||
// Update renderer if it exists
|
||||
if c.renderer != nil {
|
||||
c.renderer.SetWidth(c.width)
|
||||
}
|
||||
if c.usageTracker != nil {
|
||||
c.usageTracker.SetWidth(c.width)
|
||||
|
||||
+32
-2
@@ -1,6 +1,11 @@
|
||||
package ui
|
||||
|
||||
import "slices"
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
)
|
||||
|
||||
// SlashCommand represents a user-invokable slash command with its metadata.
|
||||
// Commands can have multiple aliases and are organized by category for better
|
||||
@@ -9,7 +14,8 @@ type SlashCommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Aliases []string
|
||||
Category string // e.g., "Navigation", "System", "Info"
|
||||
Category string // e.g., "Navigation", "System", "Info"
|
||||
Complete func(prefix string) []string // optional argument tab-completion
|
||||
}
|
||||
|
||||
// SlashCommands provides the global registry of all available slash commands
|
||||
@@ -65,6 +71,29 @@ var SlashCommands = []SlashCommand{
|
||||
Category: "System",
|
||||
Aliases: []string{"/co"},
|
||||
},
|
||||
{
|
||||
Name: "/model",
|
||||
Description: "Switch to a different model",
|
||||
Category: "System",
|
||||
Aliases: []string{"/m"},
|
||||
},
|
||||
{
|
||||
Name: "/thinking",
|
||||
Description: "Set thinking/reasoning level (off, minimal, low, medium, high)",
|
||||
Category: "System",
|
||||
Aliases: []string{"/think"},
|
||||
Complete: func(prefix string) []string {
|
||||
levels := models.ThinkingLevels()
|
||||
var matches []string
|
||||
for _, l := range levels {
|
||||
s := string(l)
|
||||
if prefix == "" || strings.HasPrefix(s, strings.ToLower(prefix)) {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "/quit",
|
||||
Description: "Exit the application",
|
||||
@@ -136,6 +165,7 @@ type ExtensionCommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Execute func(args string) (string, error)
|
||||
Complete func(prefix string) []string // optional argument tab-completion
|
||||
}
|
||||
|
||||
// FindExtensionCommand looks up an extension command by name from the given
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
type CompactRenderer struct {
|
||||
width int
|
||||
debug bool
|
||||
|
||||
// getToolRenderer returns extension-provided rendering overrides for a
|
||||
// specific tool. May be nil if no extensions are loaded. Used in
|
||||
// RenderToolMessage to check for custom header/body formatting before
|
||||
// falling back to builtin renderers.
|
||||
getToolRenderer func(toolName string) *ToolRendererData
|
||||
}
|
||||
|
||||
// NewCompactRenderer creates and initializes a new CompactRenderer with the specified
|
||||
@@ -141,6 +147,12 @@ func (r *CompactRenderer) RenderToolCallMessage(toolName, toolArgs string, times
|
||||
func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
|
||||
theme := getTheme()
|
||||
|
||||
// Resolve extension renderer once for all overrides.
|
||||
var extRd *ToolRendererData
|
||||
if r.getToolRenderer != nil {
|
||||
extRd = r.getToolRenderer(toolName)
|
||||
}
|
||||
|
||||
// Status icon
|
||||
var icon string
|
||||
iconColor := theme.Success
|
||||
@@ -152,12 +164,23 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
}
|
||||
|
||||
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
|
||||
|
||||
// Extension can override display name.
|
||||
displayName := toolDisplayName(toolName)
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
|
||||
// Format params
|
||||
// Format params — check extension renderer first.
|
||||
paramBudget := max(r.width-10-len(displayName), 20)
|
||||
params := formatToolParams(toolArgs, paramBudget)
|
||||
var params string
|
||||
if extRd != nil && extRd.RenderHeader != nil {
|
||||
params = extRd.RenderHeader(toolArgs, paramBudget)
|
||||
}
|
||||
if params == "" {
|
||||
params = formatToolParams(toolArgs, paramBudget)
|
||||
}
|
||||
|
||||
// Build header line
|
||||
header := iconStr + " " + nameStr
|
||||
@@ -165,18 +188,28 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
|
||||
}
|
||||
|
||||
// Format body: try tool-specific renderer, then fall back to default
|
||||
// Format body: check extension renderer first, then compact builtin, then default.
|
||||
var body string
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
|
||||
if body == "" {
|
||||
formatted := r.formatToolResult(toolResult)
|
||||
if formatted == "" {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
|
||||
} else {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
|
||||
if extRd != nil && extRd.RenderBody != nil {
|
||||
body = extRd.RenderBody(toolResult, isError, r.width-4)
|
||||
// Apply markdown rendering if requested and body is non-empty.
|
||||
if body != "" && extRd.BodyMarkdown {
|
||||
body = strings.TrimSuffix(toMarkdown(body, r.width-4), "\n")
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
|
||||
} else {
|
||||
// Use compact summary renderers instead of full tool body renderers.
|
||||
body = renderToolBodyCompact(toolName, toolArgs, toolResult, r.width-4)
|
||||
if body == "" {
|
||||
formatted := r.formatToolResult(toolResult)
|
||||
if formatted == "" {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
|
||||
} else {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,70 +476,9 @@ func (r *CompactRenderer) formatToolResult(result string) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatBashOutput formats bash command output by removing stdout/stderr tags and styling appropriately
|
||||
// formatBashOutput formats bash command output by removing stdout/stderr tags
|
||||
// and styling appropriately. Delegates tag parsing to the shared parseBashOutput
|
||||
// helper.
|
||||
func (r *CompactRenderer) formatBashOutput(result string) string {
|
||||
theme := getTheme()
|
||||
|
||||
// Replace tag pairs with styled content
|
||||
var formattedResult strings.Builder
|
||||
remaining := result
|
||||
|
||||
for {
|
||||
// Find stderr tags
|
||||
stderrStart := strings.Index(remaining, "<stderr>")
|
||||
stderrEnd := strings.Index(remaining, "</stderr>")
|
||||
|
||||
// Find stdout tags
|
||||
stdoutStart := strings.Index(remaining, "<stdout>")
|
||||
stdoutEnd := strings.Index(remaining, "</stdout>")
|
||||
|
||||
// Process whichever comes first
|
||||
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
|
||||
(stdoutStart == -1 || stderrStart < stdoutStart) {
|
||||
// Process stderr
|
||||
// Add content before the tag
|
||||
if stderrStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stderrStart])
|
||||
}
|
||||
|
||||
// Extract and style stderr content
|
||||
stderrContent := remaining[stderrStart+8 : stderrEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stderrContent = strings.Trim(stderrContent, "\n")
|
||||
if len(stderrContent) > 0 {
|
||||
// Style stderr content with error color, same as non-compact mode
|
||||
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
|
||||
formattedResult.WriteString(styledContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
|
||||
|
||||
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
|
||||
// Process stdout
|
||||
// Add content before the tag
|
||||
if stdoutStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stdoutStart])
|
||||
}
|
||||
|
||||
// Extract stdout content (no special styling needed)
|
||||
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stdoutContent = strings.Trim(stdoutContent, "\n")
|
||||
if len(stdoutContent) > 0 {
|
||||
formattedResult.WriteString(stdoutContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
|
||||
|
||||
} else {
|
||||
// No more tags, add remaining content
|
||||
formattedResult.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Trim any leading/trailing whitespace from the final result
|
||||
return strings.TrimSpace(formattedResult.String())
|
||||
return parseBashOutput(result, getTheme())
|
||||
}
|
||||
|
||||
@@ -68,14 +68,7 @@ func (l *CLIDebugLogger) LogDebug(message string) {
|
||||
}
|
||||
|
||||
// Use the CLI's debug message rendering
|
||||
var msg UIMessage
|
||||
if l.cli.compactMode {
|
||||
msg = l.cli.compactRenderer.RenderDebugMessage(formattedMessage, time.Now())
|
||||
} else {
|
||||
msg = l.cli.messageRenderer.RenderDebugMessage(formattedMessage, time.Now())
|
||||
}
|
||||
l.cli.messageContainer.AddMessage(msg)
|
||||
l.cli.displayContainer()
|
||||
fmt.Println(l.cli.renderer.RenderDebugMessage(formattedMessage, time.Now()).Content)
|
||||
}
|
||||
|
||||
// IsDebugEnabled checks whether debug logging is currently active. Returns true
|
||||
|
||||
@@ -14,13 +14,6 @@ import (
|
||||
// isDarkBg caches the terminal background detection result at package init.
|
||||
var isDarkBg = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
|
||||
// colorHex returns the hex string representation of a color.Color by
|
||||
// converting its RGBA values.
|
||||
func colorHex(c color.Color) string {
|
||||
r, g, b, _ := c.RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||
}
|
||||
|
||||
// AdaptiveColor picks between a light-mode and dark-mode hex color string
|
||||
// based on the detected terminal background. This replaces the old
|
||||
// lipgloss.AdaptiveColor{Light: ..., Dark: ...} pattern from v1.
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
package ui
|
||||
|
||||
// ImageAttachment holds a clipboard image that will be sent alongside the
|
||||
// user's text prompt to the LLM. The data is raw image bytes; MediaType is
|
||||
// a MIME type like "image/png".
|
||||
type ImageAttachment struct {
|
||||
// Data is the raw image bytes (PNG, JPEG, etc.).
|
||||
Data []byte
|
||||
// MediaType is the MIME type (e.g. "image/png", "image/jpeg").
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// submitMsg is sent by the InputComponent when the user submits a text prompt.
|
||||
// The parent model receives this and calls app.Run(Text) to start agent processing.
|
||||
type submitMsg struct {
|
||||
// Text is the user's input text to send to the agent.
|
||||
Text string
|
||||
// Images holds clipboard image attachments to send alongside the text.
|
||||
// Empty when no images are attached.
|
||||
Images []ImageAttachment
|
||||
}
|
||||
|
||||
// cancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
|
||||
@@ -28,3 +41,33 @@ type TreeNodeSelectedMsg struct {
|
||||
|
||||
// TreeCancelledMsg is sent when the user cancels the tree selector (ESC).
|
||||
type TreeCancelledMsg struct{}
|
||||
|
||||
// shellCommandMsg is sent by the InputComponent when the user submits a
|
||||
// ! or !! prefixed command. The parent model intercepts this to execute
|
||||
// the shell command directly instead of forwarding to the LLM.
|
||||
//
|
||||
// Matching pi's behavior:
|
||||
// - !cmd → run shell command, output INCLUDED in LLM context
|
||||
// - !!cmd → run shell command, output EXCLUDED from LLM context
|
||||
type shellCommandMsg struct {
|
||||
// Command is the shell command to execute (prefix stripped).
|
||||
Command string
|
||||
// ExcludeFromContext is true for !! (output excluded from LLM context),
|
||||
// false for ! (output included in LLM context).
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
|
||||
// shellCommandResultMsg carries the result of a shell command execution
|
||||
// back to the parent model for display.
|
||||
type shellCommandResultMsg struct {
|
||||
// Command is the original shell command that was executed.
|
||||
Command string
|
||||
// Output is the combined stdout/stderr output.
|
||||
Output string
|
||||
// ExitCode is the process exit code (0 = success).
|
||||
ExitCode int
|
||||
// Err is non-nil if the command failed to start or timed out.
|
||||
Err error
|
||||
// ExcludeFromContext mirrors the flag from shellCommandMsg.
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fileTokenPattern matches @file references in user text. Supports:
|
||||
// - @"path with spaces.txt" (quoted)
|
||||
// - @path/to/file.txt (unquoted, no spaces)
|
||||
var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`)
|
||||
|
||||
// ProcessFileAttachments scans the user's input text for @file references,
|
||||
// reads each referenced file, and returns the text with @tokens replaced by
|
||||
// XML-wrapped file content. Non-file @ tokens (like email addresses) are left
|
||||
// unchanged.
|
||||
//
|
||||
// Returns the original text unchanged if no valid @file references are found.
|
||||
func ProcessFileAttachments(text string, cwd string) string {
|
||||
tokens := fileTokenPattern.FindAllString(text, -1)
|
||||
if len(tokens) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
result := text
|
||||
for _, token := range tokens {
|
||||
path := tokenToPath(token)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
absPath, err := resolvePath(path, cwd)
|
||||
if err != nil {
|
||||
// Not a valid file reference — leave the token as-is.
|
||||
// This handles cases like email addresses (@user) gracefully.
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip directories — we only attach file content.
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty files.
|
||||
if info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build the XML-wrapped replacement.
|
||||
wrapped := wrapFileContent(absPath, content)
|
||||
result = strings.Replace(result, token, wrapped, 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// tokenToPath strips the @ prefix and optional quotes from a token,
|
||||
// returning the raw file path. Returns "" for invalid tokens.
|
||||
func tokenToPath(token string) string {
|
||||
if !strings.HasPrefix(token, "@") {
|
||||
return ""
|
||||
}
|
||||
path := token[1:]
|
||||
|
||||
// Strip quotes.
|
||||
if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
|
||||
path = path[1 : len(path)-1]
|
||||
}
|
||||
|
||||
// Reject obviously non-file tokens (e.g. bare @ or @-flags).
|
||||
if path == "" || strings.HasPrefix(path, "-") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// resolvePath resolves a potentially relative file path to an absolute path.
|
||||
// Supports ~/ expansion and relative paths. No CWD restriction — the user
|
||||
// can reference any file they have read access to.
|
||||
func resolvePath(path string, cwd string) (string, error) {
|
||||
// Expand ~/
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot expand ~: %w", err)
|
||||
}
|
||||
path = filepath.Join(home, path[2:])
|
||||
}
|
||||
|
||||
// Resolve relative to cwd.
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
|
||||
// Clean and resolve symlinks for consistent paths.
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Resolve symlinks so the displayed path is canonical.
|
||||
resolved, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
// EvalSymlinks fails if the file doesn't exist — fall back to
|
||||
// the cleaned absolute path and let the caller's Stat handle it.
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// wrapFileContent wraps file content in XML tags for LLM consumption.
|
||||
func wrapFileContent(absPath string, content []byte) string {
|
||||
return fmt.Sprintf("<file path=\"%s\">\n%s\n</file>", absPath, string(content))
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// FileSuggestion represents a single file or directory suggestion for the @
|
||||
// autocomplete popup.
|
||||
type FileSuggestion struct {
|
||||
// RelPath is the path relative to the search base (e.g. "cmd/kit/main.go").
|
||||
RelPath string
|
||||
// IsDir is true when the entry is a directory.
|
||||
IsDir bool
|
||||
// Score is the fuzzy match score (higher is better).
|
||||
Score int
|
||||
}
|
||||
|
||||
// maxFileSuggestions is the maximum number of file suggestions returned.
|
||||
const maxFileSuggestions = 20
|
||||
|
||||
// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol.
|
||||
// It returns:
|
||||
// - hasAt: true if a valid @ trigger was found
|
||||
// - prefix: the text after @ (possibly empty) that the user has typed so far
|
||||
// - startIdx: byte offset of the @ character in the line
|
||||
//
|
||||
// The @ must appear at the start of the line or after whitespace. Quoted paths
|
||||
// are supported: @"path with spaces" — the returned prefix strips quotes.
|
||||
func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, startIdx int) {
|
||||
if cursorCol > len(line) {
|
||||
cursorCol = len(line)
|
||||
}
|
||||
|
||||
// Walk backwards from cursorCol to find the @ character.
|
||||
text := line[:cursorCol]
|
||||
|
||||
// Find the last @ that is preceded by whitespace or is at position 0.
|
||||
atIdx := -1
|
||||
for i := len(text) - 1; i >= 0; i-- {
|
||||
if text[i] == '@' {
|
||||
// Must be at start of line or preceded by whitespace.
|
||||
if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' {
|
||||
atIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// Stop scanning if we hit a space — the @ we want must be in the
|
||||
// current "word".
|
||||
if text[i] == ' ' || text[i] == '\t' {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if atIdx < 0 {
|
||||
return false, "", 0
|
||||
}
|
||||
|
||||
raw := text[atIdx+1:]
|
||||
|
||||
// Handle quoted paths: @"some path" — strip leading quote.
|
||||
if after, found := strings.CutPrefix(raw, `"`); found {
|
||||
raw = strings.TrimSuffix(after, `"`)
|
||||
}
|
||||
|
||||
return true, raw, atIdx
|
||||
}
|
||||
|
||||
// GetFileSuggestions returns file/directory suggestions matching the given
|
||||
// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
|
||||
// falls back to a simple directory walk.
|
||||
//
|
||||
// If prefix contains a path separator the search is scoped to that
|
||||
// subdirectory. For example, prefix "cmd/k" searches inside "cmd/" for
|
||||
// entries matching "k".
|
||||
func GetFileSuggestions(prefix string, cwd string) []FileSuggestion {
|
||||
// Resolve the base directory and filter query from the prefix.
|
||||
baseDir, query := splitPrefixPath(prefix)
|
||||
|
||||
searchDir := cwd
|
||||
if baseDir != "" {
|
||||
candidate := resolveSearchDir(baseDir, cwd)
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
searchDir = candidate
|
||||
} else {
|
||||
return nil // invalid base directory
|
||||
}
|
||||
}
|
||||
|
||||
files := listFiles(searchDir, cwd)
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend baseDir so results display as "cmd/main.go" not just "main.go".
|
||||
if baseDir != "" {
|
||||
for i := range files {
|
||||
files[i].RelPath = baseDir + files[i].RelPath
|
||||
}
|
||||
}
|
||||
|
||||
return fuzzyFilterFiles(files, prefix, query)
|
||||
}
|
||||
|
||||
// splitPrefixPath separates a prefix like "cmd/kit/m" into
|
||||
// baseDir="cmd/kit/" and query="m". If there is no separator the
|
||||
// baseDir is empty and query is the full prefix.
|
||||
func splitPrefixPath(prefix string) (baseDir, query string) {
|
||||
// Handle ~ expansion display (we keep it in the prefix for display
|
||||
// but resolve it when actually searching).
|
||||
idx := strings.LastIndex(prefix, "/")
|
||||
if idx < 0 {
|
||||
return "", prefix
|
||||
}
|
||||
return prefix[:idx+1], prefix[idx+1:]
|
||||
}
|
||||
|
||||
// resolveSearchDir converts a baseDir from the prefix into an absolute path.
|
||||
// Supports ~/, ../, and absolute paths.
|
||||
func resolveSearchDir(baseDir, cwd string) string {
|
||||
// Expand ~/
|
||||
if strings.HasPrefix(baseDir, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, baseDir[2:])
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute paths
|
||||
if filepath.IsAbs(baseDir) {
|
||||
return filepath.Clean(baseDir)
|
||||
}
|
||||
|
||||
// Relative to cwd
|
||||
return filepath.Join(cwd, baseDir)
|
||||
}
|
||||
|
||||
// listFiles returns files and directories within searchDir, relative to that
|
||||
// directory. Uses `git ls-files` when inside a git repo for speed and
|
||||
// .gitignore awareness, otherwise falls back to os.ReadDir.
|
||||
func listFiles(searchDir, cwd string) []FileSuggestion {
|
||||
// Try git ls-files first (fast, respects .gitignore).
|
||||
if files := listFilesGit(searchDir, cwd); files != nil {
|
||||
return files
|
||||
}
|
||||
return listFilesReadDir(searchDir)
|
||||
}
|
||||
|
||||
// listFilesGit uses `git ls-files` and `git ls-files --others --exclude-standard`
|
||||
// to list tracked and untracked-but-not-ignored files.
|
||||
func listFilesGit(searchDir, cwd string) []FileSuggestion {
|
||||
// Check if we're in a git repo.
|
||||
check := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||
check.Dir = cwd
|
||||
if err := check.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var results []FileSuggestion
|
||||
|
||||
// Tracked files.
|
||||
cmd := exec.Command("git", "ls-files")
|
||||
cmd.Dir = searchDir
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Normalize separators.
|
||||
line = filepath.ToSlash(line)
|
||||
addFileEntries(&results, seen, line, searchDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Untracked, non-ignored files.
|
||||
cmd2 := exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
||||
cmd2.Dir = searchDir
|
||||
out2, err := cmd2.Output()
|
||||
if err == nil {
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(string(out2)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = filepath.ToSlash(line)
|
||||
addFileEntries(&results, seen, line, searchDir)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// addFileEntries adds the file and any intermediate directory entries to
|
||||
// results if not already seen. Paths are stored with forward slashes.
|
||||
func addFileEntries(results *[]FileSuggestion, seen map[string]bool, relPath string, searchDir string) {
|
||||
// Add intermediate directories as suggestions (first component only).
|
||||
parts := strings.SplitN(relPath, "/", 2)
|
||||
if len(parts) > 1 {
|
||||
dir := parts[0] + "/"
|
||||
if !seen[dir] {
|
||||
seen[dir] = true
|
||||
*results = append(*results, FileSuggestion{RelPath: dir, IsDir: true})
|
||||
}
|
||||
}
|
||||
|
||||
// Add the file itself.
|
||||
if !seen[relPath] {
|
||||
seen[relPath] = true
|
||||
*results = append(*results, FileSuggestion{RelPath: relPath, IsDir: false})
|
||||
}
|
||||
}
|
||||
|
||||
// listFilesReadDir is the fallback when git is not available. Lists immediate
|
||||
// children of dir via os.ReadDir, skipping hidden dirs and common noise.
|
||||
func listFilesReadDir(dir string) []FileSuggestion {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
skip := map[string]bool{
|
||||
".git": true, "node_modules": true, ".kit": true,
|
||||
"__pycache__": true, ".venv": true, "vendor": true,
|
||||
}
|
||||
|
||||
var results []FileSuggestion
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if skip[name] {
|
||||
continue
|
||||
}
|
||||
// Skip hidden files/dirs (except common config files).
|
||||
if strings.HasPrefix(name, ".") && name != ".env" && name != ".gitignore" {
|
||||
continue
|
||||
}
|
||||
if e.IsDir() {
|
||||
results = append(results, FileSuggestion{RelPath: name + "/", IsDir: true})
|
||||
} else {
|
||||
results = append(results, FileSuggestion{RelPath: name, IsDir: false})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// fuzzyFilterFiles scores and filters file suggestions against the query,
|
||||
// returning the top maxFileSuggestions results sorted by score descending.
|
||||
// Directories are boosted slightly so they appear near the top.
|
||||
func fuzzyFilterFiles(files []FileSuggestion, fullPrefix, query string) []FileSuggestion {
|
||||
if query == "" && fullPrefix == "" {
|
||||
// No filter — return all (capped).
|
||||
if len(files) > maxFileSuggestions {
|
||||
files = files[:maxFileSuggestions]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// When there's a base dir but no query (e.g. "cmd/"), show everything
|
||||
// in that directory.
|
||||
if query == "" {
|
||||
var filtered []FileSuggestion
|
||||
for i := range files {
|
||||
if strings.HasPrefix(files[i].RelPath, fullPrefix) {
|
||||
// Only show direct children of the base directory.
|
||||
rest := files[i].RelPath[len(fullPrefix):]
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, files[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) > maxFileSuggestions {
|
||||
filtered = filtered[:maxFileSuggestions]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
var scored []FileSuggestion
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
for i := range files {
|
||||
path := files[i].RelPath
|
||||
// When we have a fullPrefix with a dir component, only consider
|
||||
// files under that directory.
|
||||
if fullPrefix != query && !strings.HasPrefix(path, fullPrefix[:len(fullPrefix)-len(query)]) {
|
||||
continue
|
||||
}
|
||||
|
||||
score := scoreFilePath(queryLower, path)
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Boost directories so they appear near the top for navigation.
|
||||
if files[i].IsDir {
|
||||
score += 10
|
||||
}
|
||||
|
||||
files[i].Score = score
|
||||
scored = append(scored, files[i])
|
||||
}
|
||||
|
||||
// Sort by score descending.
|
||||
sort.Slice(scored, func(i, j int) bool {
|
||||
return scored[i].Score > scored[j].Score
|
||||
})
|
||||
|
||||
if len(scored) > maxFileSuggestions {
|
||||
scored = scored[:maxFileSuggestions]
|
||||
}
|
||||
return scored
|
||||
}
|
||||
|
||||
// scoreFilePath scores a file path against a fuzzy query. Higher is better.
|
||||
// Returns 0 if there is no match.
|
||||
func scoreFilePath(query, path string) int {
|
||||
pathLower := strings.ToLower(path)
|
||||
baseName := filepath.Base(strings.TrimSuffix(path, "/"))
|
||||
baseNameLower := strings.ToLower(baseName)
|
||||
|
||||
// Exact basename match.
|
||||
if baseNameLower == query {
|
||||
return 1000
|
||||
}
|
||||
|
||||
// Basename starts with query.
|
||||
if strings.HasPrefix(baseNameLower, query) {
|
||||
return 800 - len(baseName) + len(query)
|
||||
}
|
||||
|
||||
// Basename contains query as substring.
|
||||
if strings.Contains(baseNameLower, query) {
|
||||
return 500 - len(baseName) + len(query)
|
||||
}
|
||||
|
||||
// Full path contains query as substring.
|
||||
if strings.Contains(pathLower, query) {
|
||||
return 300 - len(path) + len(query)
|
||||
}
|
||||
|
||||
// Fuzzy character match on basename.
|
||||
if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
|
||||
return score
|
||||
}
|
||||
|
||||
// Fuzzy character match on full path.
|
||||
if score := fuzzyCharMatch(query, pathLower); score > 0 {
|
||||
return score - 50
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a
|
||||
// positive score if all query characters appear in order in the target.
|
||||
func fuzzyCharMatch(query, target string) int {
|
||||
if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) {
|
||||
return 0
|
||||
}
|
||||
|
||||
qRunes := []rune(query)
|
||||
tRunes := []rune(target)
|
||||
qi := 0
|
||||
score := 100
|
||||
consecutive := 0
|
||||
|
||||
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
|
||||
if tRunes[ti] == qRunes[qi] {
|
||||
qi++
|
||||
consecutive++
|
||||
score += consecutive * 5
|
||||
} else {
|
||||
consecutive = 0
|
||||
score -= 2
|
||||
}
|
||||
}
|
||||
|
||||
if qi < len(qRunes) {
|
||||
return 0
|
||||
}
|
||||
return score
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// Renderer is the interface satisfied by both MessageRenderer and
|
||||
// CompactRenderer. It allows model.go and cli.go to call rendering methods
|
||||
// without branching on compact mode.
|
||||
type Renderer interface {
|
||||
RenderUserMessage(content string, timestamp time.Time) UIMessage
|
||||
RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage
|
||||
RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage
|
||||
RenderSystemMessage(content string, timestamp time.Time) UIMessage
|
||||
RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage
|
||||
RenderDebugMessage(message string, timestamp time.Time) UIMessage
|
||||
RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage
|
||||
SetWidth(width int)
|
||||
}
|
||||
|
||||
// Compile-time checks that both renderers satisfy the Renderer interface.
|
||||
var _ Renderer = (*MessageRenderer)(nil)
|
||||
var _ Renderer = (*CompactRenderer)(nil)
|
||||
|
||||
// parseBashOutput parses <stdout>/<stderr> tagged output from bash tool
|
||||
// results, styling stderr with the theme's error color. Returns the
|
||||
// combined, styled output string with tags stripped.
|
||||
//
|
||||
// Shared by both MessageRenderer and CompactRenderer.
|
||||
func parseBashOutput(result string, theme Theme) string {
|
||||
var formattedResult strings.Builder
|
||||
remaining := result
|
||||
|
||||
for {
|
||||
// Find stderr tags
|
||||
stderrStart := strings.Index(remaining, "<stderr>")
|
||||
stderrEnd := strings.Index(remaining, "</stderr>")
|
||||
|
||||
// Find stdout tags
|
||||
stdoutStart := strings.Index(remaining, "<stdout>")
|
||||
stdoutEnd := strings.Index(remaining, "</stdout>")
|
||||
|
||||
// Process whichever comes first
|
||||
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
|
||||
(stdoutStart == -1 || stderrStart < stdoutStart) {
|
||||
// Process stderr
|
||||
if stderrStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stderrStart])
|
||||
}
|
||||
stderrContent := remaining[stderrStart+8 : stderrEnd]
|
||||
stderrContent = strings.Trim(stderrContent, "\n")
|
||||
if len(stderrContent) > 0 {
|
||||
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
|
||||
formattedResult.WriteString(styledContent)
|
||||
}
|
||||
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
|
||||
|
||||
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
|
||||
// Process stdout
|
||||
if stdoutStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stdoutStart])
|
||||
}
|
||||
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
|
||||
stdoutContent = strings.Trim(stdoutContent, "\n")
|
||||
if len(stdoutContent) > 0 {
|
||||
formattedResult.WriteString(stdoutContent)
|
||||
}
|
||||
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
|
||||
|
||||
} else {
|
||||
// No more tags, add remaining content
|
||||
formattedResult.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(formattedResult.String())
|
||||
}
|
||||
+330
-25
@@ -1,12 +1,15 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/clipboard"
|
||||
)
|
||||
|
||||
// InputComponent is the interactive text input field for the parent AppModel.
|
||||
@@ -36,9 +39,41 @@ type InputComponent struct {
|
||||
title string
|
||||
submitNext bool // defer submit one tick so popup dismisses cleanly
|
||||
|
||||
// Argument completion state. When the user types "/cmd " followed by
|
||||
// a partial argument and the command has a Complete function, the popup
|
||||
// switches to argument-completion mode showing suggestions from Complete.
|
||||
argMode bool // true when showing arg completions
|
||||
argCommand string // command prefix for arg mode (e.g. "/bookmark")
|
||||
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
|
||||
|
||||
// File completion state. When the user types @ followed by a partial
|
||||
// file path, the popup shows file/directory suggestions from the cwd.
|
||||
fileMode bool // true when showing @file completions
|
||||
filePrefix string // current text after @ being matched
|
||||
fileAtStartIdx int // byte offset of @ in the textarea value
|
||||
fileSuggestions []FileSuggestion // backing storage for file entries
|
||||
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
|
||||
|
||||
// cwd is the working directory used for @file path resolution and
|
||||
// autocomplete suggestions. Set by the parent via SetCwd.
|
||||
cwd string
|
||||
|
||||
// appCtrl is used for slash commands that mutate app state.
|
||||
// May be nil in tests; nil-safe.
|
||||
appCtrl AppController
|
||||
|
||||
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
|
||||
hideHint bool
|
||||
|
||||
// pendingImages holds clipboard images attached to the next submission.
|
||||
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
|
||||
pendingImages []ImageAttachment
|
||||
}
|
||||
|
||||
// clipboardImageMsg is the result of an async clipboard image read.
|
||||
type clipboardImageMsg struct {
|
||||
image *ImageAttachment
|
||||
err error
|
||||
}
|
||||
|
||||
// NewInputComponent creates a new InputComponent with the given width, title,
|
||||
@@ -80,6 +115,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
|
||||
}
|
||||
}
|
||||
|
||||
// SetCwd sets the working directory used for @file autocomplete suggestions
|
||||
// and path resolution. Should be called by the parent after construction.
|
||||
func (s *InputComponent) SetCwd(cwd string) {
|
||||
s.cwd = cwd
|
||||
}
|
||||
|
||||
// Init implements tea.Model. Starts the cursor blink animation.
|
||||
func (s *InputComponent) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
@@ -109,6 +150,16 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.textarea.SetWidth(msg.Width - 8)
|
||||
return s, nil
|
||||
|
||||
case clipboardImageMsg:
|
||||
if msg.err != nil {
|
||||
// Silently ignore — no image on clipboard or tool unavailable.
|
||||
return s, nil
|
||||
}
|
||||
if msg.image != nil {
|
||||
s.pendingImages = append(s.pendingImages, *msg.image)
|
||||
}
|
||||
return s, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if !s.showPopup {
|
||||
switch msg.String() {
|
||||
@@ -118,6 +169,15 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.textarea.CursorEnd()
|
||||
s.lastValue = ""
|
||||
return s, s.handleSubmit(value)
|
||||
case "ctrl+v":
|
||||
// Try to read an image from the clipboard asynchronously.
|
||||
return s, readClipboardImageCmd()
|
||||
case "ctrl+u":
|
||||
// Clear all pending image attachments.
|
||||
if len(s.pendingImages) > 0 {
|
||||
s.pendingImages = nil
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,17 +198,35 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||
if s.selected < len(s.filtered) {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
if s.fileMode {
|
||||
s.applyFileCompletion(s.selected)
|
||||
} else if s.argMode {
|
||||
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
} else {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
}
|
||||
s.textarea.CursorEnd()
|
||||
}
|
||||
return s, nil
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if s.selected < len(s.filtered) {
|
||||
// Populate textarea with selected command and submit on next tick.
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
if s.fileMode {
|
||||
// Apply file completion but don't submit.
|
||||
s.applyFileCompletion(s.selected)
|
||||
s.textarea.CursorEnd()
|
||||
return s, nil
|
||||
}
|
||||
// Populate textarea with selected item and submit on next tick.
|
||||
if s.argMode {
|
||||
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
|
||||
} else {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
}
|
||||
s.textarea.CursorEnd()
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
@@ -172,12 +250,57 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if value != s.lastValue {
|
||||
s.lastValue = value
|
||||
lines := strings.Split(value, "\n")
|
||||
if len(lines) == 1 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") {
|
||||
s.showPopup = true
|
||||
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
|
||||
s.selected = 0
|
||||
line := lines[len(lines)-1] // current line (last line for multi-line)
|
||||
|
||||
// Check for @file trigger first.
|
||||
cursorCol := len(line) // approximate: cursor is at end after typing
|
||||
if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt && s.cwd != "" {
|
||||
suggestions := GetFileSuggestions(prefix, s.cwd)
|
||||
if len(suggestions) > 0 {
|
||||
s.showPopup = true
|
||||
s.fileMode = true
|
||||
s.argMode = false
|
||||
s.filePrefix = prefix
|
||||
s.fileAtStartIdx = atIdx
|
||||
s.fileSuggestions = suggestions
|
||||
s.fileSynthCmds = make([]SlashCommand, len(suggestions))
|
||||
s.filtered = make([]FuzzyMatch, len(suggestions))
|
||||
for i, fs := range suggestions {
|
||||
name := fs.RelPath
|
||||
desc := ""
|
||||
if fs.IsDir {
|
||||
desc = "directory"
|
||||
}
|
||||
s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
|
||||
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
|
||||
}
|
||||
s.selected = 0
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.fileMode = false
|
||||
}
|
||||
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
|
||||
s.fileMode = false
|
||||
if !strings.Contains(lines[0], " ") {
|
||||
// Command name completion.
|
||||
s.showPopup = true
|
||||
s.argMode = false
|
||||
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
|
||||
s.selected = 0
|
||||
} else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 {
|
||||
// Argument completion for a command with a Complete function.
|
||||
s.showPopup = true
|
||||
// s.argMode, s.argCommand, s.argSynthCmds, s.filtered
|
||||
// are set by completeArgs.
|
||||
s.selected = 0
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.argMode = false
|
||||
}
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.argMode = false
|
||||
s.fileMode = false
|
||||
}
|
||||
}
|
||||
return s, cmd
|
||||
@@ -191,12 +314,34 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// handleSubmit processes the submitted text. Slash commands that affect app
|
||||
// state are executed here; /quit returns tea.Quit; everything else returns a
|
||||
// submitMsg tea.Cmd for the parent to forward to app.Run().
|
||||
//
|
||||
// Shell command prefixes (matching pi's behavior):
|
||||
// - !cmd → execute shell command, output INCLUDED in LLM context
|
||||
// - !!cmd → execute shell command, output EXCLUDED from LLM context
|
||||
func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for shell command prefixes before slash commands. Test !! first
|
||||
// (more specific) to avoid matching the single-! case for double-bang.
|
||||
if strings.HasPrefix(trimmed, "!!") {
|
||||
cmd := strings.TrimSpace(trimmed[2:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: true}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(trimmed, "!") {
|
||||
cmd := strings.TrimSpace(trimmed[1:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve via canonical command lookup so aliases are handled uniformly.
|
||||
// Only /quit and /clear are handled locally — /clear-queue must go
|
||||
// through the parent model so it can update queueCount directly
|
||||
@@ -217,9 +362,12 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
}
|
||||
|
||||
// For all other input (including unrecognised slash commands and regular
|
||||
// prompts) hand off to the parent via submitMsg.
|
||||
// prompts) hand off to the parent via submitMsg. Attach any pending
|
||||
// images and clear them.
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
return func() tea.Msg {
|
||||
return submitMsg{Text: trimmed}
|
||||
return submitMsg{Text: trimmed, Images: images}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,13 +402,27 @@ func (s *InputComponent) View() tea.View {
|
||||
view.WriteString(s.renderPopup())
|
||||
}
|
||||
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
// Show image attachment indicator when images are pending.
|
||||
if len(s.pendingImages) > 0 {
|
||||
imgStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("39")).
|
||||
PaddingLeft(3)
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
|
||||
label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages))
|
||||
view.WriteString("\n")
|
||||
view.WriteString(imgStyle.Render(label))
|
||||
}
|
||||
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
hint := "enter submit • ctrl+j / alt+enter new line • ctrl+v paste image"
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(hint))
|
||||
}
|
||||
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
}
|
||||
@@ -301,16 +463,32 @@ func (s *InputComponent) renderPopup() string {
|
||||
descStyle = descStyle.Foreground(lipgloss.Color("250"))
|
||||
}
|
||||
|
||||
nameWidth := 15
|
||||
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
|
||||
if s.fileMode {
|
||||
// File mode: use full width for the path, show description
|
||||
// (e.g. "directory") inline after a gap.
|
||||
maxNameLen := s.width - 24
|
||||
displayName := sc.Name
|
||||
if len(displayName) > maxNameLen && maxNameLen > 3 {
|
||||
displayName = displayName[:maxNameLen-3] + "..."
|
||||
}
|
||||
name := nameStyle.Render(displayName)
|
||||
if sc.Description != "" {
|
||||
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)
|
||||
|
||||
desc := sc.Description
|
||||
maxDescLen := s.width - nameWidth - 14
|
||||
if len(desc) > maxDescLen && maxDescLen > 3 {
|
||||
desc = desc[:maxDescLen-3] + "..."
|
||||
desc := sc.Description
|
||||
maxDescLen := s.width - nameWidth - 14
|
||||
if len(desc) > maxDescLen && maxDescLen > 3 {
|
||||
desc = desc[:maxDescLen-3] + "..."
|
||||
}
|
||||
|
||||
items = append(items, indicator+name+descStyle.Render(desc))
|
||||
}
|
||||
|
||||
items = append(items, indicator+name+descStyle.Render(desc))
|
||||
}
|
||||
|
||||
if startIdx > 0 {
|
||||
@@ -326,3 +504,130 @@ func (s *InputComponent) renderPopup() string {
|
||||
|
||||
return popupStyle.Render(content + "\n\n" + footer)
|
||||
}
|
||||
|
||||
// completeArgs checks whether the input line matches a command with a Complete
|
||||
// function, calls it, and populates the arg-mode state on success. Returns the
|
||||
// list of suggestions (empty means no completions available).
|
||||
func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
cmdName := parts[0]
|
||||
argPrefix := ""
|
||||
if len(parts) > 1 {
|
||||
argPrefix = parts[1]
|
||||
}
|
||||
|
||||
cmd := s.findCommandWithComplete(cmdName)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
suggestions := cmd.Complete(argPrefix)
|
||||
if len(suggestions) == 0 {
|
||||
s.argMode = false
|
||||
return nil
|
||||
}
|
||||
|
||||
s.argMode = true
|
||||
s.argCommand = cmdName
|
||||
s.argSynthCmds = make([]SlashCommand, len(suggestions))
|
||||
s.filtered = make([]FuzzyMatch, len(suggestions))
|
||||
for i, sug := range suggestions {
|
||||
s.argSynthCmds[i] = SlashCommand{Name: sug}
|
||||
s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]}
|
||||
}
|
||||
return s.filtered
|
||||
}
|
||||
|
||||
// findCommandWithComplete looks up a command by name that has a non-nil
|
||||
// Complete function.
|
||||
func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
|
||||
for i := range s.commands {
|
||||
if s.commands[i].Name == name && s.commands[i].Complete != nil {
|
||||
return &s.commands[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readClipboardImageCmd returns a tea.Cmd that reads an image from the system
|
||||
// clipboard. The result is delivered as a clipboardImageMsg.
|
||||
func readClipboardImageCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
img, err := clipboard.ReadImage()
|
||||
if err != nil {
|
||||
return clipboardImageMsg{err: err}
|
||||
}
|
||||
return clipboardImageMsg{
|
||||
image: &ImageAttachment{
|
||||
Data: img.Data,
|
||||
MediaType: img.MediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPendingImages removes all pending image attachments and returns them.
|
||||
// Used by the parent model when consuming images for submission.
|
||||
func (s *InputComponent) ClearPendingImages() []ImageAttachment {
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
return images
|
||||
}
|
||||
|
||||
// PendingImageCount returns the number of images currently attached.
|
||||
func (s *InputComponent) PendingImageCount() int {
|
||||
return len(s.pendingImages)
|
||||
}
|
||||
|
||||
// applyFileCompletion replaces the @prefix in the textarea with the selected
|
||||
// file suggestion. For directories, it keeps the popup open for further
|
||||
// drilling. For files, it closes the popup and adds a trailing space.
|
||||
func (s *InputComponent) applyFileCompletion(idx int) {
|
||||
if idx >= len(s.fileSuggestions) {
|
||||
return
|
||||
}
|
||||
|
||||
suggestion := s.fileSuggestions[idx]
|
||||
value := s.textarea.Value()
|
||||
|
||||
// Build the replacement text. The @ and everything after it up to the
|
||||
// cursor should be replaced with @<selected path>.
|
||||
// Find the current line's contribution.
|
||||
lines := strings.Split(value, "\n")
|
||||
lastLine := lines[len(lines)-1]
|
||||
|
||||
// Reconstruct: everything before the @ on the last line + @<path>
|
||||
beforeAt := lastLine[:s.fileAtStartIdx]
|
||||
needsQuote := strings.Contains(suggestion.RelPath, " ")
|
||||
|
||||
var replacement string
|
||||
if needsQuote {
|
||||
replacement = `@"` + suggestion.RelPath + `"`
|
||||
} else {
|
||||
replacement = "@" + suggestion.RelPath
|
||||
}
|
||||
|
||||
// For files, add a trailing space. For directories, don't — allow
|
||||
// continued drilling into the directory.
|
||||
if !suggestion.IsDir {
|
||||
replacement += " "
|
||||
}
|
||||
|
||||
newLastLine := beforeAt + replacement
|
||||
|
||||
// Reconstruct the full value with the updated last line.
|
||||
lines[len(lines)-1] = newLastLine
|
||||
newValue := strings.Join(lines, "\n")
|
||||
|
||||
s.textarea.SetValue(newValue)
|
||||
s.textarea.CursorEnd()
|
||||
|
||||
if suggestion.IsDir {
|
||||
// Keep popup open — trigger a refresh for the new directory.
|
||||
s.lastValue = "" // force re-evaluation on next update tick
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.fileMode = false
|
||||
s.selected = 0
|
||||
}
|
||||
}
|
||||
|
||||
+68
-317
@@ -146,6 +146,12 @@ func formatToolParams(toolArgs string, maxWidth int) string {
|
||||
type MessageRenderer struct {
|
||||
width int
|
||||
debug bool
|
||||
|
||||
// getToolRenderer returns extension-provided rendering overrides for a
|
||||
// specific tool. May be nil if no extensions are loaded. Used in
|
||||
// RenderToolMessage to check for custom header/body formatting before
|
||||
// falling back to builtin renderers.
|
||||
getToolRenderer func(toolName string) *ToolRendererData
|
||||
}
|
||||
|
||||
// getSystemUsername returns the current system username, fallback to "User"
|
||||
@@ -193,10 +199,7 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
|
||||
theme := getTheme()
|
||||
|
||||
// Render the message content with the user-message background so that
|
||||
// glamour-rendered markdown inherits the highlight color.
|
||||
bgHex := colorHex(theme.Highlight)
|
||||
messageContent := r.renderMarkdownWithBg(content, r.width-8, bgHex) // Account for padding and borders
|
||||
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" %s (%s)", username, timeStr)
|
||||
@@ -205,13 +208,12 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer
|
||||
// Use the block renderer — left border with Primary color, no background.
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Primary),
|
||||
WithBackground(theme.Highlight),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -527,6 +529,12 @@ func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, times
|
||||
func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
|
||||
theme := getTheme()
|
||||
|
||||
// Resolve extension renderer once for all overrides.
|
||||
var extRd *ToolRendererData
|
||||
if r.getToolRenderer != nil {
|
||||
extRd = r.getToolRenderer(toolName)
|
||||
}
|
||||
|
||||
// --- Header: [icon] [name] [params] ---
|
||||
var icon string
|
||||
borderColor := theme.Success
|
||||
@@ -539,29 +547,55 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
icon = "✓"
|
||||
}
|
||||
|
||||
// Extension can override border color (applies to both success and error).
|
||||
if extRd != nil && extRd.BorderColor != "" {
|
||||
borderColor = lipgloss.Color(extRd.BorderColor)
|
||||
}
|
||||
|
||||
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
|
||||
|
||||
// Extension can override display name.
|
||||
displayName := toolDisplayName(toolName)
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
|
||||
// Format params with width budget for the header line
|
||||
// Format params with width budget for the header line.
|
||||
// Check extension renderer for custom header params first.
|
||||
paramBudget := max(r.width-10-len(displayName), 20)
|
||||
params := formatToolParams(toolArgs, paramBudget)
|
||||
var params string
|
||||
if extRd != nil && extRd.RenderHeader != nil {
|
||||
params = extRd.RenderHeader(toolArgs, paramBudget)
|
||||
}
|
||||
if params == "" {
|
||||
params = formatToolParams(toolArgs, paramBudget)
|
||||
}
|
||||
|
||||
header := iconStr + " " + nameStr
|
||||
if params != "" {
|
||||
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
|
||||
}
|
||||
|
||||
// --- Body: try tool-specific renderer first, then fall back ---
|
||||
// --- Body: check extension renderer first, then builtin, then default ---
|
||||
var body string
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Render(toolResult)
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
|
||||
if body == "" {
|
||||
body = r.formatToolResult(toolName, toolResult, r.width-8)
|
||||
if extRd != nil && extRd.RenderBody != nil {
|
||||
body = extRd.RenderBody(toolResult, isError, r.width-8)
|
||||
// Apply markdown rendering if requested and body is non-empty.
|
||||
if body != "" && extRd.BodyMarkdown {
|
||||
body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n")
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Render(toolResult)
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
|
||||
if body == "" {
|
||||
body = r.formatToolResult(toolName, toolResult, r.width-8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,15 +606,23 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
Render("(no output)")
|
||||
}
|
||||
|
||||
// Combine header + body into a single block
|
||||
// Combine header + body into a single block.
|
||||
fullContent := header + "\n\n" + strings.TrimSuffix(body, "\n")
|
||||
|
||||
// Build rendering options; extension can override background.
|
||||
blockOpts := []renderingOption{
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderColor),
|
||||
WithMarginBottom(1),
|
||||
}
|
||||
if extRd != nil && extRd.Background != "" {
|
||||
blockOpts = append(blockOpts, WithBackground(lipgloss.Color(extRd.Background)))
|
||||
}
|
||||
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderColor),
|
||||
WithMarginBottom(1),
|
||||
blockOpts...,
|
||||
)
|
||||
|
||||
return UIMessage{
|
||||
@@ -653,75 +695,14 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s
|
||||
Render(result)
|
||||
}
|
||||
|
||||
// formatBashOutput formats bash command output with proper section handling
|
||||
// formatBashOutput formats bash command output with proper section handling.
|
||||
// Delegates tag parsing to the shared parseBashOutput helper.
|
||||
func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Replace tag pairs with styled content
|
||||
var formattedResult strings.Builder
|
||||
remaining := result
|
||||
|
||||
for {
|
||||
// Find stderr tags
|
||||
stderrStart := strings.Index(remaining, "<stderr>")
|
||||
stderrEnd := strings.Index(remaining, "</stderr>")
|
||||
|
||||
// Find stdout tags
|
||||
stdoutStart := strings.Index(remaining, "<stdout>")
|
||||
stdoutEnd := strings.Index(remaining, "</stdout>")
|
||||
|
||||
// Process whichever comes first
|
||||
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
|
||||
(stdoutStart == -1 || stderrStart < stdoutStart) {
|
||||
// Process stderr
|
||||
// Add content before the tag
|
||||
if stderrStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stderrStart])
|
||||
}
|
||||
// Extract and style stderr content
|
||||
stderrContent := remaining[stderrStart+8 : stderrEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stderrContent = strings.Trim(stderrContent, "\n")
|
||||
if len(stderrContent) > 0 {
|
||||
styledContent := baseStyle.Foreground(theme.Error).Render(stderrContent)
|
||||
formattedResult.WriteString(styledContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
|
||||
|
||||
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
|
||||
// Process stdout
|
||||
// Add content before the tag
|
||||
if stdoutStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stdoutStart])
|
||||
}
|
||||
|
||||
// Extract stdout content (no special styling needed)
|
||||
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stdoutContent = strings.Trim(stdoutContent, "\n")
|
||||
if len(stdoutContent) > 0 {
|
||||
formattedResult.WriteString(stdoutContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
|
||||
|
||||
} else {
|
||||
// No more tags, add remaining content
|
||||
formattedResult.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Trim any leading/trailing whitespace from the final result
|
||||
finalResult := strings.TrimSpace(formattedResult.String())
|
||||
|
||||
return baseStyle.
|
||||
parsed := parseBashOutput(result, theme)
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(theme.Muted).
|
||||
Render(finalResult)
|
||||
Render(parsed)
|
||||
}
|
||||
|
||||
// renderMarkdown renders markdown content using glamour
|
||||
@@ -729,233 +710,3 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
|
||||
rendered := toMarkdown(content, width)
|
||||
return strings.TrimSuffix(rendered, "\n")
|
||||
}
|
||||
|
||||
// renderMarkdownWithBg renders markdown content using glamour with a background
|
||||
// color applied to every element so the output blends with a colored block.
|
||||
func (r *MessageRenderer) renderMarkdownWithBg(content string, width int, bgHex string) string {
|
||||
rendered := toMarkdownWithBg(content, width, bgHex)
|
||||
return strings.TrimSuffix(rendered, "\n")
|
||||
}
|
||||
|
||||
// MessageContainer manages a collection of UI messages, handling their display,
|
||||
// updates, and layout within the terminal. It supports both standard and compact
|
||||
// display modes and maintains state for streaming message updates.
|
||||
type MessageContainer struct {
|
||||
messages []UIMessage
|
||||
width int
|
||||
height int
|
||||
compactMode bool // Add compact mode flag
|
||||
modelName string // Store current model name
|
||||
wasCleared bool // Track if container was explicitly cleared
|
||||
}
|
||||
|
||||
// NewMessageContainer creates and initializes a new MessageContainer with the
|
||||
// specified dimensions and display mode. The container starts empty and will
|
||||
// display a welcome message until the first message is added.
|
||||
func NewMessageContainer(width, height int, compact bool) *MessageContainer {
|
||||
return &MessageContainer{
|
||||
messages: make([]UIMessage, 0),
|
||||
width: width,
|
||||
height: height,
|
||||
compactMode: compact,
|
||||
}
|
||||
}
|
||||
|
||||
// AddMessage appends a new UIMessage to the container's collection and resets
|
||||
// the cleared state flag. Messages are displayed in the order they were added.
|
||||
func (c *MessageContainer) AddMessage(msg UIMessage) {
|
||||
c.messages = append(c.messages, msg)
|
||||
c.wasCleared = false // Reset the cleared flag when adding messages
|
||||
}
|
||||
|
||||
// SetModelName updates the AI model name used for rendering assistant messages.
|
||||
// This name is displayed in message headers to indicate which model is responding.
|
||||
func (c *MessageContainer) SetModelName(modelName string) {
|
||||
c.modelName = modelName
|
||||
}
|
||||
|
||||
// UpdateLastMessage efficiently updates the content of the most recent message
|
||||
// in the container. This is primarily used for streaming responses where the
|
||||
// assistant's message is progressively built. Only works for assistant messages.
|
||||
func (c *MessageContainer) UpdateLastMessage(content string) {
|
||||
if len(c.messages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
lastIdx := len(c.messages) - 1
|
||||
lastMsg := &c.messages[lastIdx]
|
||||
|
||||
// Only re-render if content actually changed and it's an assistant message
|
||||
if lastMsg.Type == AssistantMessage {
|
||||
// Create appropriate renderer based on compact mode
|
||||
var newMsg UIMessage
|
||||
if c.compactMode {
|
||||
compactRenderer := NewCompactRenderer(c.width, false)
|
||||
newMsg = compactRenderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName)
|
||||
} else {
|
||||
renderer := NewMessageRenderer(c.width, false)
|
||||
newMsg = renderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName)
|
||||
}
|
||||
newMsg.Streaming = lastMsg.Streaming // Preserve streaming state
|
||||
c.messages[lastIdx] = newMsg
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all messages from the container and sets a flag to prevent
|
||||
// showing the welcome screen. Used when starting a fresh conversation.
|
||||
func (c *MessageContainer) Clear() {
|
||||
c.messages = make([]UIMessage, 0)
|
||||
c.wasCleared = true
|
||||
}
|
||||
|
||||
// SetSize updates the container's dimensions, typically called when the terminal
|
||||
// is resized. This affects how messages are wrapped and displayed.
|
||||
func (c *MessageContainer) SetSize(width, height int) {
|
||||
c.width = width
|
||||
c.height = height
|
||||
}
|
||||
|
||||
// Render generates the complete visual representation of all messages in the
|
||||
// container. Returns an empty state display if no messages exist, or formats
|
||||
// all messages according to the current display mode (standard or compact).
|
||||
func (c *MessageContainer) Render() string {
|
||||
if len(c.messages) == 0 {
|
||||
// Don't show welcome box if explicitly cleared
|
||||
if c.wasCleared {
|
||||
return ""
|
||||
}
|
||||
if c.compactMode {
|
||||
return c.renderCompactEmptyState()
|
||||
}
|
||||
return c.renderEmptyState()
|
||||
}
|
||||
|
||||
if c.compactMode {
|
||||
return c.renderCompactMessages()
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
for i, msg := range c.messages {
|
||||
// Center each message horizontally
|
||||
centeredMsg := lipgloss.PlaceHorizontal(
|
||||
c.width,
|
||||
lipgloss.Center,
|
||||
msg.Content,
|
||||
)
|
||||
parts = append(parts, centeredMsg)
|
||||
|
||||
// Add spacing between messages (except after the last one)
|
||||
if i < len(c.messages)-1 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().
|
||||
Width(c.width)
|
||||
|
||||
// No padding needed between messages
|
||||
|
||||
return style.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Top, parts...),
|
||||
)
|
||||
}
|
||||
|
||||
// renderEmptyState renders an enhanced initial empty state
|
||||
func (c *MessageContainer) renderEmptyState() string {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Create a welcome box with border
|
||||
theme := getTheme()
|
||||
welcomeBox := baseStyle.
|
||||
Width(c.width-4).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(theme.System).
|
||||
Padding(2, 4).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
// Main title
|
||||
title := baseStyle.
|
||||
Foreground(theme.System).
|
||||
Bold(true).
|
||||
Render("KIT")
|
||||
|
||||
// Subtitle with better typography
|
||||
subtitle := baseStyle.
|
||||
Foreground(theme.Primary).
|
||||
Bold(true).
|
||||
MarginTop(1).
|
||||
Render("AI Assistant with MCP Tools")
|
||||
|
||||
// Feature highlights
|
||||
features := []string{
|
||||
"Natural language conversations",
|
||||
"Powerful tool integrations",
|
||||
"Multi-provider LLM support",
|
||||
"Usage tracking & analytics",
|
||||
}
|
||||
|
||||
var featureList []string
|
||||
for _, feature := range features {
|
||||
featureList = append(featureList, baseStyle.
|
||||
Foreground(theme.Muted).
|
||||
MarginLeft(2).
|
||||
Render("• "+feature))
|
||||
}
|
||||
|
||||
// Getting started prompt
|
||||
prompt := baseStyle.
|
||||
Foreground(theme.Accent).
|
||||
Italic(true).
|
||||
MarginTop(2).
|
||||
Render("Start by typing your message below or use /help for commands")
|
||||
|
||||
// Combine all elements
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
title,
|
||||
subtitle,
|
||||
"",
|
||||
lipgloss.JoinVertical(lipgloss.Left, featureList...),
|
||||
"",
|
||||
prompt,
|
||||
)
|
||||
|
||||
welcomeContent := welcomeBox.Render(content)
|
||||
|
||||
// Center the welcome box vertically
|
||||
return baseStyle.
|
||||
Width(c.width).
|
||||
Height(c.height).
|
||||
Align(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(welcomeContent)
|
||||
}
|
||||
|
||||
// renderCompactMessages renders messages in compact format
|
||||
func (c *MessageContainer) renderCompactMessages() string {
|
||||
var lines []string
|
||||
|
||||
for _, msg := range c.messages {
|
||||
lines = append(lines, msg.Content)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderCompactEmptyState renders a simple empty state for compact mode
|
||||
func (c *MessageContainer) renderCompactEmptyState() string {
|
||||
theme := getTheme()
|
||||
|
||||
// Simple compact welcome
|
||||
welcome := lipgloss.NewStyle().
|
||||
Foreground(theme.System).
|
||||
Bold(true).
|
||||
Render("KIT - AI Assistant with MCP Tools")
|
||||
|
||||
help := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render("Type your message or /help for commands")
|
||||
|
||||
return fmt.Sprintf("%s\n%s\n\n", welcome, help)
|
||||
}
|
||||
|
||||
+1542
-131
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
)
|
||||
|
||||
// ModelEntry holds display metadata for a single model in the selector.
|
||||
type ModelEntry struct {
|
||||
Provider string
|
||||
ModelID string
|
||||
Name string // human-friendly name (e.g. "Claude Haiku 4.5")
|
||||
ContextLimit int
|
||||
Reasoning bool
|
||||
}
|
||||
|
||||
// ModelSelectedMsg is sent when the user selects a model from the selector.
|
||||
type ModelSelectedMsg struct {
|
||||
ModelString string // "provider/model-id"
|
||||
}
|
||||
|
||||
// ModelSelectorCancelledMsg is sent when the user cancels the selector.
|
||||
type ModelSelectorCancelledMsg struct{}
|
||||
|
||||
// ModelSelectorComponent is a full-screen Bubble Tea component that displays
|
||||
// a filterable list of available models. It follows the same pattern as
|
||||
// TreeSelectorComponent: inline text search, scrolling list, and custom
|
||||
// messages for result delivery.
|
||||
type ModelSelectorComponent struct {
|
||||
allModels []ModelEntry // all available models (pre-sorted)
|
||||
filtered []ModelEntry // subset matching the current search
|
||||
cursor int
|
||||
search string
|
||||
currentModel string // "provider/model" of the active model (for checkmark)
|
||||
width int
|
||||
height int
|
||||
active bool
|
||||
}
|
||||
|
||||
// NewModelSelector creates a model selector populated from the global registry,
|
||||
// filtered to only providers with configured API keys.
|
||||
func NewModelSelector(currentModel string, width, height int) *ModelSelectorComponent {
|
||||
registry := models.GetGlobalRegistry()
|
||||
var allModels []ModelEntry
|
||||
|
||||
for _, providerID := range registry.GetFantasyProviders() {
|
||||
// Only include providers with valid API keys configured.
|
||||
if err := registry.ValidateEnvironment(providerID, ""); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
modelsMap, err := registry.GetModelsForProvider(providerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for modelID, info := range modelsMap {
|
||||
allModels = append(allModels, ModelEntry{
|
||||
Provider: providerID,
|
||||
ModelID: modelID,
|
||||
Name: info.Name,
|
||||
ContextLimit: info.Limit.Context,
|
||||
Reasoning: info.Reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: alphabetically by model ID, grouped by provider.
|
||||
sort.Slice(allModels, func(i, j int) bool {
|
||||
if allModels[i].Provider != allModels[j].Provider {
|
||||
return allModels[i].Provider < allModels[j].Provider
|
||||
}
|
||||
return allModels[i].ModelID < allModels[j].ModelID
|
||||
})
|
||||
|
||||
ms := &ModelSelectorComponent{
|
||||
allModels: allModels,
|
||||
filtered: allModels,
|
||||
currentModel: currentModel,
|
||||
width: width,
|
||||
height: height,
|
||||
active: true,
|
||||
}
|
||||
|
||||
// Position cursor on the current model if found.
|
||||
for i, m := range ms.filtered {
|
||||
if m.Provider+"/"+m.ModelID == currentModel {
|
||||
ms.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ms
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (ms *ModelSelectorComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
ms.width = msg.Width
|
||||
ms.height = msg.Height
|
||||
return ms, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
||||
if ms.cursor > 0 {
|
||||
ms.cursor--
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
if ms.cursor < len(ms.filtered)-1 {
|
||||
ms.cursor++
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))):
|
||||
ms.cursor -= ms.visibleHeight()
|
||||
if ms.cursor < 0 {
|
||||
ms.cursor = 0
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))):
|
||||
ms.cursor += ms.visibleHeight()
|
||||
if ms.cursor >= len(ms.filtered) {
|
||||
ms.cursor = len(ms.filtered) - 1
|
||||
}
|
||||
if ms.cursor < 0 {
|
||||
ms.cursor = 0
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("home"))):
|
||||
ms.cursor = 0
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("end"))):
|
||||
ms.cursor = max(len(ms.filtered)-1, 0)
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if ms.cursor < len(ms.filtered) {
|
||||
entry := ms.filtered[ms.cursor]
|
||||
ms.active = false
|
||||
return ms, func() tea.Msg {
|
||||
return ModelSelectedMsg{
|
||||
ModelString: entry.Provider + "/" + entry.ModelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
if ms.search != "" {
|
||||
ms.search = ""
|
||||
ms.rebuildFiltered()
|
||||
} else {
|
||||
ms.active = false
|
||||
return ms, func() tea.Msg {
|
||||
return ModelSelectorCancelledMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Inline text search.
|
||||
if msg.Text != "" && len(msg.Text) == 1 {
|
||||
ch := msg.Text[0]
|
||||
if ch >= 32 && ch < 127 {
|
||||
ms.search += string(ch)
|
||||
ms.rebuildFiltered()
|
||||
}
|
||||
}
|
||||
if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ms.search) > 0 {
|
||||
ms.search = ms.search[:len(ms.search)-1]
|
||||
ms.rebuildFiltered()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (ms *ModelSelectorComponent) View() tea.View {
|
||||
theme := GetTheme()
|
||||
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(theme.Accent).
|
||||
PaddingLeft(2)
|
||||
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
PaddingLeft(2)
|
||||
|
||||
infoStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Warning).
|
||||
PaddingLeft(2)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header.
|
||||
b.WriteString(headerStyle.Render("Model Selector"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Search input.
|
||||
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(2)
|
||||
if ms.search != "" {
|
||||
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ms.search)))
|
||||
} else {
|
||||
b.WriteString(searchStyle.Render("> "))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(ms.filtered) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
|
||||
if ms.search != "" {
|
||||
b.WriteString(emptyStyle.Render("No models matching \"" + ms.search + "\""))
|
||||
} else {
|
||||
b.WriteString(emptyStyle.Render("No models available (check API keys)"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
// Visible window.
|
||||
visH := ms.visibleHeight()
|
||||
startIdx := 0
|
||||
if ms.cursor >= visH {
|
||||
startIdx = ms.cursor - visH + 1
|
||||
}
|
||||
endIdx := min(startIdx+visH, len(ms.filtered))
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
entry := ms.filtered[i]
|
||||
line := ms.renderEntry(entry, i == ms.cursor)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Footer.
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
|
||||
b.WriteString("\n")
|
||||
|
||||
footerParts := []string{
|
||||
fmt.Sprintf("(%d/%d)", ms.cursor+1, len(ms.filtered)),
|
||||
}
|
||||
if ms.cursor < len(ms.filtered) {
|
||||
entry := ms.filtered[ms.cursor]
|
||||
if entry.Name != "" {
|
||||
footerParts = append(footerParts, fmt.Sprintf("Model Name: %s", entry.Name))
|
||||
}
|
||||
if entry.ContextLimit > 0 {
|
||||
footerParts = append(footerParts, fmt.Sprintf("Context: %dK", entry.ContextLimit/1000))
|
||||
}
|
||||
}
|
||||
|
||||
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
|
||||
b.WriteString(footerStyle.Render(strings.Join(footerParts, " ")))
|
||||
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
// IsActive returns whether the selector is still accepting input.
|
||||
func (ms *ModelSelectorComponent) IsActive() bool {
|
||||
return ms.active
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
|
||||
func (ms *ModelSelectorComponent) rebuildFiltered() {
|
||||
if ms.search == "" {
|
||||
ms.filtered = ms.allModels
|
||||
} else {
|
||||
query := strings.ToLower(ms.search)
|
||||
ms.filtered = ms.filtered[:0]
|
||||
|
||||
type scored struct {
|
||||
entry ModelEntry
|
||||
score int
|
||||
}
|
||||
var matches []scored
|
||||
|
||||
for _, entry := range ms.allModels {
|
||||
s := ms.fuzzyScoreModel(query, entry)
|
||||
if s > 0 {
|
||||
matches = append(matches, scored{entry: entry, score: s})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending, then alphabetically.
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].score != matches[j].score {
|
||||
return matches[i].score > matches[j].score
|
||||
}
|
||||
return matches[i].entry.ModelID < matches[j].entry.ModelID
|
||||
})
|
||||
|
||||
ms.filtered = make([]ModelEntry, len(matches))
|
||||
for i, m := range matches {
|
||||
ms.filtered[i] = m.entry
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp cursor.
|
||||
if ms.cursor >= len(ms.filtered) {
|
||||
ms.cursor = max(len(ms.filtered)-1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzyScoreModel scores a model entry against the search query.
|
||||
func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry) int {
|
||||
modelID := strings.ToLower(entry.ModelID)
|
||||
provider := strings.ToLower(entry.Provider)
|
||||
name := strings.ToLower(entry.Name)
|
||||
combined := provider + "/" + modelID
|
||||
|
||||
// Exact match on combined provider/model.
|
||||
if combined == query {
|
||||
return 1000
|
||||
}
|
||||
|
||||
// Exact match on model ID.
|
||||
if modelID == query {
|
||||
return 950
|
||||
}
|
||||
|
||||
// Prefix match on model ID.
|
||||
if strings.HasPrefix(modelID, query) {
|
||||
return 800 - len(modelID) + len(query)
|
||||
}
|
||||
|
||||
// Prefix match on combined.
|
||||
if strings.HasPrefix(combined, query) {
|
||||
return 750 - len(combined) + len(query)
|
||||
}
|
||||
|
||||
// Contains match on model ID.
|
||||
if strings.Contains(modelID, query) {
|
||||
return 600
|
||||
}
|
||||
|
||||
// Contains match on combined.
|
||||
if strings.Contains(combined, query) {
|
||||
return 550
|
||||
}
|
||||
|
||||
// Contains match on name.
|
||||
if strings.Contains(name, query) {
|
||||
return 400
|
||||
}
|
||||
|
||||
// Character-by-character fuzzy match on model ID.
|
||||
if s := fuzzyCharacterMatch(query, modelID); s > 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
// Fuzzy match on combined.
|
||||
if s := fuzzyCharacterMatch(query, combined); s > 0 {
|
||||
return s - 20
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) string {
|
||||
theme := GetTheme()
|
||||
modelStr := entry.ModelID
|
||||
providerStr := fmt.Sprintf("[%s]", entry.Provider)
|
||||
|
||||
// Cursor indicator.
|
||||
var cursor string
|
||||
if isCursor {
|
||||
cursor = lipgloss.NewStyle().Foreground(theme.Accent).Render("-> ")
|
||||
} else {
|
||||
cursor = " "
|
||||
}
|
||||
|
||||
// Active model checkmark.
|
||||
var active string
|
||||
if entry.Provider+"/"+entry.ModelID == ms.currentModel {
|
||||
active = lipgloss.NewStyle().Foreground(theme.Success).Render(" \u2713")
|
||||
}
|
||||
|
||||
// Style the model ID.
|
||||
modelStyle := lipgloss.NewStyle().Foreground(theme.Text)
|
||||
if isCursor {
|
||||
modelStyle = modelStyle.Bold(true).Foreground(theme.Accent)
|
||||
}
|
||||
|
||||
// Style the provider tag.
|
||||
providerStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
|
||||
return cursor + modelStyle.Render(modelStr) + " " + providerStyle.Render(providerStr) + active
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
@@ -53,6 +54,19 @@ func (s *stubAppController) GetTreeSession() *session.TreeManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAppController) SendEvent(_ tea.Msg) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
func (s *stubAppController) AddContextMessage(_ string) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
func (s *stubAppController) RunWithFiles(prompt string, _ []fantasy.FilePart) int {
|
||||
s.runCalls = append(s.runCalls, prompt)
|
||||
return s.queueLen
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Stub child components
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -75,6 +89,8 @@ func (s *stubStreamComponent) Reset() { s.resetCalled++; s.r
|
||||
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
|
||||
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
|
||||
func (s *stubStreamComponent) SpinnerView() string { return "" }
|
||||
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
|
||||
func (s *stubStreamComponent) HasReasoning() bool { return false }
|
||||
|
||||
// stubInputComponent satisfies inputComponentIface without rendering anything.
|
||||
type stubInputComponent struct {
|
||||
@@ -101,7 +117,6 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
|
||||
stream: stream,
|
||||
input: input,
|
||||
renderer: NewMessageRenderer(80, false),
|
||||
compactRdr: NewCompactRenderer(80, false),
|
||||
compactMode: false,
|
||||
modelName: "test-model",
|
||||
width: 80,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay dialog — modal overlay rendered by AppModel when active
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// overlayResult carries the synchronous outcome of an overlay dialog update.
|
||||
// A non-nil value means the overlay is done (completed or cancelled); nil
|
||||
// means the overlay is still active.
|
||||
type overlayResult struct {
|
||||
completed bool
|
||||
cancelled bool
|
||||
action string
|
||||
index int
|
||||
}
|
||||
|
||||
// overlayDialog holds the state of an active modal overlay dialog. It is
|
||||
// created when an OverlayRequestEvent arrives and destroyed when the user
|
||||
// completes or cancels. The AppModel owns the overlay and routes messages
|
||||
// to it while in stateOverlay.
|
||||
type overlayDialog struct {
|
||||
title string
|
||||
content string
|
||||
markdown bool
|
||||
borderColor string
|
||||
background string
|
||||
actions []string
|
||||
selAction int // selected action index
|
||||
scrollOff int // scroll offset for content body
|
||||
totalLines int // total body lines (computed on render)
|
||||
width int // terminal width
|
||||
height int // terminal height
|
||||
dialogWidth int // configured dialog width (0 = auto)
|
||||
maxHeight int // configured max height (0 = auto)
|
||||
anchor string
|
||||
}
|
||||
|
||||
// newOverlayDialog creates an overlay dialog from an OverlayRequestEvent's
|
||||
// parameters.
|
||||
func newOverlayDialog(title, content string, markdown bool, borderColor, background string, width, maxHeight int, anchor string, actions []string, termWidth, termHeight int) *overlayDialog {
|
||||
return &overlayDialog{
|
||||
title: title,
|
||||
content: content,
|
||||
markdown: markdown,
|
||||
borderColor: borderColor,
|
||||
background: background,
|
||||
actions: actions,
|
||||
dialogWidth: width,
|
||||
maxHeight: maxHeight,
|
||||
anchor: anchor,
|
||||
width: termWidth,
|
||||
height: termHeight,
|
||||
}
|
||||
}
|
||||
|
||||
// Init returns the initial command for the overlay. Currently no-op.
|
||||
func (o *overlayDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the overlay dialog. It returns a non-nil
|
||||
// *overlayResult when the user completes or cancels. The returned tea.Cmd
|
||||
// is always nil (overlays don't produce async commands).
|
||||
func (o *overlayDialog) Update(msg tea.Msg) (*overlayResult, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
o.width = msg.Width
|
||||
o.height = msg.Height
|
||||
return nil, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
return o.handleKey(msg)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
return &overlayResult{cancelled: true}, nil
|
||||
|
||||
case "enter":
|
||||
if len(o.actions) > 0 {
|
||||
action := ""
|
||||
if o.selAction < len(o.actions) {
|
||||
action = o.actions[o.selAction]
|
||||
}
|
||||
return &overlayResult{completed: true, action: action, index: o.selAction}, nil
|
||||
}
|
||||
// No actions — Enter dismisses (not cancelled).
|
||||
return &overlayResult{completed: true, action: "", index: -1}, nil
|
||||
|
||||
// Content scrolling
|
||||
case "up", "k":
|
||||
if o.scrollOff > 0 {
|
||||
o.scrollOff--
|
||||
}
|
||||
case "down", "j":
|
||||
// Clamped in Render; allow incrementing freely.
|
||||
o.scrollOff++
|
||||
case "home", "g":
|
||||
o.scrollOff = 0
|
||||
case "end", "G":
|
||||
// Set to a large value; Render will clamp.
|
||||
o.scrollOff = o.totalLines
|
||||
|
||||
// Action navigation
|
||||
case "left", "h":
|
||||
if len(o.actions) > 0 && o.selAction > 0 {
|
||||
o.selAction--
|
||||
}
|
||||
case "right", "l":
|
||||
if len(o.actions) > 0 && o.selAction < len(o.actions)-1 {
|
||||
o.selAction++
|
||||
}
|
||||
case "tab":
|
||||
if len(o.actions) > 0 {
|
||||
o.selAction = (o.selAction + 1) % len(o.actions)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Render returns the overlay dialog as a styled string for full-view
|
||||
// composition. The dialog is a bordered box centered (or anchored)
|
||||
// horizontally within the terminal width.
|
||||
func (o *overlayDialog) Render() string {
|
||||
theme := GetTheme()
|
||||
|
||||
// Calculate dialog dimensions.
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Inner width accounts for border (2) + horizontal padding (2 left + 1 right).
|
||||
innerWidth := max(dw-5, 10)
|
||||
|
||||
// Render body text (potentially as markdown).
|
||||
bodyText := o.content
|
||||
if o.markdown {
|
||||
bodyText = toMarkdown(bodyText, innerWidth)
|
||||
}
|
||||
bodyText = strings.TrimRight(bodyText, "\n")
|
||||
|
||||
bodyLines := strings.Split(bodyText, "\n")
|
||||
o.totalLines = len(bodyLines)
|
||||
|
||||
// Calculate available height for the scrollable body.
|
||||
// Chrome: border(2) + padTop(1) + padBottom(1) + hintLine(1) = 5
|
||||
chromeLines := 5
|
||||
if o.title != "" {
|
||||
chromeLines += 2 // title line + separator line
|
||||
}
|
||||
if len(o.actions) > 0 {
|
||||
chromeLines += 2 // separator line + action bar
|
||||
}
|
||||
|
||||
maxBodyLines := max(mh-chromeLines, 1)
|
||||
|
||||
scrollable := len(bodyLines) > maxBodyLines
|
||||
if scrollable {
|
||||
// Clamp scroll offset.
|
||||
maxOff := len(bodyLines) - maxBodyLines
|
||||
if o.scrollOff > maxOff {
|
||||
o.scrollOff = maxOff
|
||||
}
|
||||
if o.scrollOff < 0 {
|
||||
o.scrollOff = 0
|
||||
}
|
||||
bodyLines = bodyLines[o.scrollOff : o.scrollOff+maxBodyLines]
|
||||
} else {
|
||||
o.scrollOff = 0
|
||||
}
|
||||
|
||||
// Build the content to render inside the border.
|
||||
var parts []string
|
||||
|
||||
// Title + separator.
|
||||
if o.title != "" {
|
||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Text)
|
||||
parts = append(parts, titleStyle.Render(o.title))
|
||||
parts = append(parts, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(repeatRune('─', innerWidth)))
|
||||
}
|
||||
|
||||
// Body content.
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, strings.Join(bodyLines, "\n"))
|
||||
|
||||
// Scroll indicator.
|
||||
if scrollable {
|
||||
indicator := fmt.Sprintf("(%d–%d of %d lines)",
|
||||
o.scrollOff+1,
|
||||
min(o.scrollOff+maxBodyLines, o.totalLines),
|
||||
o.totalLines)
|
||||
parts = append(parts, lipgloss.NewStyle().
|
||||
Foreground(theme.VeryMuted).
|
||||
Render(indicator))
|
||||
} else {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
|
||||
// Action bar.
|
||||
if len(o.actions) > 0 {
|
||||
parts = append(parts, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(repeatRune('─', innerWidth)))
|
||||
|
||||
var actionParts []string
|
||||
for i, a := range o.actions {
|
||||
if i == o.selAction {
|
||||
actionParts = append(actionParts,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).Render("> "+a))
|
||||
} else {
|
||||
actionParts = append(actionParts,
|
||||
lipgloss.NewStyle().Foreground(theme.Text).Render(" "+a))
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Join(actionParts, " "))
|
||||
}
|
||||
|
||||
innerContent := strings.Join(parts, "\n")
|
||||
|
||||
// Resolve border color.
|
||||
borderClr := lipgloss.Color("#89b4fa") // default blue
|
||||
if o.borderColor != "" {
|
||||
borderClr = lipgloss.Color(o.borderColor)
|
||||
}
|
||||
|
||||
// Build the dialog box style.
|
||||
dialogStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(borderClr).
|
||||
Width(dw-2). // -2 for border chars
|
||||
Padding(1, 1, 1, 2).
|
||||
Foreground(theme.Text)
|
||||
|
||||
if o.background != "" {
|
||||
dialogStyle = dialogStyle.Background(lipgloss.Color(o.background))
|
||||
}
|
||||
|
||||
dialog := dialogStyle.Render(innerContent)
|
||||
|
||||
// Key hints below the dialog.
|
||||
var hints []string
|
||||
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")
|
||||
hintText := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" " + strings.Join(hints, " "))
|
||||
|
||||
full := lipgloss.JoinVertical(lipgloss.Left, dialog, hintText)
|
||||
|
||||
// Center horizontally within the terminal width.
|
||||
centered := lipgloss.PlaceHorizontal(o.width, lipgloss.Center, full)
|
||||
|
||||
// Apply vertical positioning based on anchor.
|
||||
// Calculate how many lines we have and how many we need.
|
||||
contentHeight := lipgloss.Height(centered)
|
||||
if contentHeight < o.height {
|
||||
switch o.anchor {
|
||||
case "top-center":
|
||||
// Add one blank line at top for breathing room.
|
||||
centered = "\n" + centered
|
||||
case "bottom-center":
|
||||
// Pad from the top so the dialog sits near the bottom.
|
||||
topPad := o.height - contentHeight - 1
|
||||
if topPad > 0 {
|
||||
centered = strings.Repeat("\n", topPad) + centered
|
||||
}
|
||||
default: // "center"
|
||||
// Vertically center within available height.
|
||||
topPad := (o.height - contentHeight) / 2
|
||||
if topPad > 0 {
|
||||
centered = strings.Repeat("\n", topPad) + centered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return centered
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt overlay — modal prompt rendered by AppModel when active
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// promptMode indicates the type of interactive prompt being displayed.
|
||||
type promptMode string
|
||||
|
||||
const (
|
||||
promptModeSelect promptMode = "select"
|
||||
promptModeConfirm promptMode = "confirm"
|
||||
promptModeInput promptMode = "input"
|
||||
)
|
||||
|
||||
// promptResult carries the synchronous outcome of a prompt overlay update.
|
||||
// A non-nil value means the prompt is done (completed or cancelled); nil
|
||||
// means the overlay is still active.
|
||||
type promptResult struct {
|
||||
completed bool
|
||||
cancelled bool
|
||||
value string
|
||||
index int
|
||||
confirmed bool
|
||||
}
|
||||
|
||||
// promptOverlay holds the state of an active interactive prompt. It is
|
||||
// created when a PromptRequestEvent arrives and destroyed when the user
|
||||
// completes or cancels. The AppModel owns the overlay and routes messages
|
||||
// to it while in statePrompt.
|
||||
type promptOverlay struct {
|
||||
mode promptMode
|
||||
message string
|
||||
options []string // select: available choices
|
||||
selected int // select: currently highlighted index
|
||||
confirmed bool // confirm: current yes/no value
|
||||
inputTA textarea.Model // input: text editor
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// newSelectPrompt creates a prompt overlay for a selection list.
|
||||
func newSelectPrompt(message string, options []string, width, height int) *promptOverlay {
|
||||
return &promptOverlay{
|
||||
mode: promptModeSelect,
|
||||
message: message,
|
||||
options: options,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// newConfirmPrompt creates a prompt overlay for a yes/no confirmation.
|
||||
func newConfirmPrompt(message string, defaultValue bool, width, height int) *promptOverlay {
|
||||
return &promptOverlay{
|
||||
mode: promptModeConfirm,
|
||||
message: message,
|
||||
confirmed: defaultValue,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// newInputPrompt creates a prompt overlay for free-form text input.
|
||||
func newInputPrompt(message, placeholder, defaultValue string, width, height int) *promptOverlay {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = placeholder
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = ""
|
||||
ta.CharLimit = 1000
|
||||
ta.SetWidth(width - 12) // account for border + padding
|
||||
ta.SetHeight(1)
|
||||
ta.Focus()
|
||||
|
||||
// Prevent Enter from inserting a newline — we intercept it for submit.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
)
|
||||
|
||||
if defaultValue != "" {
|
||||
ta.SetValue(defaultValue)
|
||||
ta.CursorEnd()
|
||||
}
|
||||
|
||||
return &promptOverlay{
|
||||
mode: promptModeInput,
|
||||
message: message,
|
||||
inputTA: ta,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// Init returns the initial command for the prompt overlay. For input mode
|
||||
// this starts the cursor blink animation.
|
||||
func (p *promptOverlay) Init() tea.Cmd {
|
||||
if p.mode == promptModeInput {
|
||||
return textarea.Blink
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the prompt overlay. It returns a non-nil
|
||||
// *promptResult when the user completes or cancels the prompt. The returned
|
||||
// tea.Cmd is for textarea blink ticks (input mode only).
|
||||
func (p *promptOverlay) Update(msg tea.Msg) (*promptResult, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.width = msg.Width
|
||||
p.height = msg.Height
|
||||
if p.mode == promptModeInput {
|
||||
p.inputTA.SetWidth(p.width - 12)
|
||||
}
|
||||
return nil, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch p.mode {
|
||||
case promptModeSelect:
|
||||
return p.updateSelect(msg)
|
||||
case promptModeConfirm:
|
||||
return p.updateConfirm(msg)
|
||||
case promptModeInput:
|
||||
return p.updateInput(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass non-key messages to textarea for blink animation.
|
||||
if p.mode == promptModeInput {
|
||||
var cmd tea.Cmd
|
||||
p.inputTA, cmd = p.inputTA.Update(msg)
|
||||
return nil, cmd
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *promptOverlay) updateSelect(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if p.selected > 0 {
|
||||
p.selected--
|
||||
}
|
||||
case "down", "j":
|
||||
if p.selected < len(p.options)-1 {
|
||||
p.selected++
|
||||
}
|
||||
case "home":
|
||||
p.selected = 0
|
||||
case "end":
|
||||
if len(p.options) > 0 {
|
||||
p.selected = len(p.options) - 1
|
||||
}
|
||||
case "enter":
|
||||
value := ""
|
||||
if p.selected < len(p.options) {
|
||||
value = p.options[p.selected]
|
||||
}
|
||||
return &promptResult{completed: true, value: value, index: p.selected}, nil
|
||||
case "esc":
|
||||
return &promptResult{cancelled: true}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *promptOverlay) updateConfirm(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "left", "h", "y", "Y":
|
||||
p.confirmed = true
|
||||
case "right", "l", "n", "N":
|
||||
p.confirmed = false
|
||||
case "tab":
|
||||
p.confirmed = !p.confirmed
|
||||
case "enter":
|
||||
return &promptResult{completed: true, confirmed: p.confirmed}, nil
|
||||
case "esc":
|
||||
return &promptResult{cancelled: true}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
return &promptResult{completed: true, value: p.inputTA.Value()}, nil
|
||||
case "esc":
|
||||
return &promptResult{cancelled: true}, nil
|
||||
default:
|
||||
// Delegate character input, backspace, cursor movement, etc.
|
||||
var cmd tea.Cmd
|
||||
p.inputTA, cmd = p.inputTA.Update(msg)
|
||||
return nil, cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Render returns the prompt as a styled string for inline composition in the
|
||||
// AppModel layout. The prompt replaces the normal input area (below the
|
||||
// separator and above the status bar) rather than taking over the full screen.
|
||||
func (p *promptOverlay) Render() string {
|
||||
theme := GetTheme()
|
||||
var content string
|
||||
|
||||
switch p.mode {
|
||||
case promptModeSelect:
|
||||
content = p.viewSelect(theme)
|
||||
case promptModeConfirm:
|
||||
content = p.viewConfirm(theme)
|
||||
case promptModeInput:
|
||||
content = p.viewInput(theme)
|
||||
}
|
||||
|
||||
return renderContentBlock(content, p.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Accent),
|
||||
WithPaddingTop(0),
|
||||
WithPaddingBottom(0),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewSelect(theme Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
|
||||
for i, opt := range p.options {
|
||||
if i == p.selected {
|
||||
cursor := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render("> ")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render(opt)
|
||||
lines = append(lines, " "+cursor+label)
|
||||
} else {
|
||||
lines = append(lines, " "+lipgloss.NewStyle().Foreground(theme.Text).Render(opt))
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" up/down navigate Enter select Esc cancel"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewConfirm(theme Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
|
||||
yesStyle := lipgloss.NewStyle().Foreground(theme.Text)
|
||||
noStyle := lipgloss.NewStyle().Foreground(theme.Text)
|
||||
if p.confirmed {
|
||||
yesStyle = yesStyle.Bold(true).Foreground(theme.Accent)
|
||||
} else {
|
||||
noStyle = noStyle.Bold(true).Foreground(theme.Accent)
|
||||
}
|
||||
|
||||
yes := yesStyle.Render("[Yes]")
|
||||
no := noStyle.Render("[No]")
|
||||
lines = append(lines, " "+yes+" "+no)
|
||||
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" left/right switch y/n Enter confirm Esc cancel"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewInput(theme Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, p.inputTA.View())
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" Enter submit Esc cancel"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type SlashCommandInput struct {
|
||||
value string
|
||||
submitNext bool // Flag to submit on next update
|
||||
renderedLines int // Track how many lines were rendered
|
||||
hideHint bool // Suppress the "enter submit · ctrl+j..." hint
|
||||
}
|
||||
|
||||
// NewSlashCommandInput creates and initializes a new slash command input field with
|
||||
@@ -219,17 +220,19 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
s.renderedLines += 1 + popupLines // newline + popup
|
||||
}
|
||||
|
||||
// Add help text at bottom
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
// Add help text at bottom (unless hidden by extension).
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
s.renderedLines += 2 // newline + help text
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
s.renderedLines += 2 // newline + help text
|
||||
}
|
||||
|
||||
// Apply container padding to entire view
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
|
||||
+14
-9
@@ -15,18 +15,20 @@ import (
|
||||
// The KITT-style frames are generated by knightRiderFrames() in stream.go
|
||||
// (same package) and use the active theme colors.
|
||||
type Spinner struct {
|
||||
frames []string
|
||||
fps time.Duration
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
frames []string
|
||||
fps time.Duration
|
||||
done chan struct{}
|
||||
finished chan struct{} // closed by run() after cleanup
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewSpinner creates a new animated KITT-style spinner using theme colors.
|
||||
func NewSpinner() *Spinner {
|
||||
return &Spinner{
|
||||
frames: knightRiderFrames(),
|
||||
fps: time.Second / 14,
|
||||
done: make(chan struct{}),
|
||||
frames: knightRiderFrames(),
|
||||
fps: time.Second / 14,
|
||||
done: make(chan struct{}),
|
||||
finished: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +38,17 @@ func (s *Spinner) Start() {
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop halts the spinner animation and cleans up. This method blocks until
|
||||
// the animation goroutine has exited and the line is cleared.
|
||||
// Stop halts the spinner animation and blocks until the animation goroutine
|
||||
// has exited and the line is cleared. Safe to call multiple times.
|
||||
func (s *Spinner) Stop() {
|
||||
s.once.Do(func() { close(s.done) })
|
||||
<-s.finished
|
||||
}
|
||||
|
||||
// run is the animation loop that renders spinner frames to stderr.
|
||||
func (s *Spinner) run() {
|
||||
defer close(s.finished) // unblock Stop()
|
||||
|
||||
ticker := time.NewTicker(s.fps)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
+77
-4
@@ -121,6 +121,12 @@ type StreamComponent struct {
|
||||
// streamContent accumulates all streaming text chunks.
|
||||
streamContent strings.Builder
|
||||
|
||||
// reasoningContent accumulates reasoning/thinking text chunks.
|
||||
reasoningContent strings.Builder
|
||||
|
||||
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
|
||||
thinkingVisible bool
|
||||
|
||||
// messageRenderer renders assistant messages in standard mode.
|
||||
messageRenderer *MessageRenderer
|
||||
|
||||
@@ -177,6 +183,7 @@ func (s *StreamComponent) Reset() {
|
||||
s.spinnerFrame = 0
|
||||
s.spinnerMsg = ""
|
||||
s.streamContent.Reset()
|
||||
s.reasoningContent.Reset()
|
||||
s.timestamp = time.Time{}
|
||||
}
|
||||
|
||||
@@ -184,11 +191,22 @@ func (s *StreamComponent) Reset() {
|
||||
// streaming text. Returns empty string if no text has been accumulated. Used by
|
||||
// the parent AppModel to flush content via tea.Println() before resetting.
|
||||
func (s *StreamComponent) GetRenderedContent() string {
|
||||
var sections []string
|
||||
|
||||
// Include rendered reasoning block if present.
|
||||
if reasoning := s.reasoningContent.String(); reasoning != "" {
|
||||
sections = append(sections, s.renderReasoningBlock(reasoning))
|
||||
}
|
||||
|
||||
text := s.streamContent.String()
|
||||
if text == "" {
|
||||
if text != "" {
|
||||
sections = append(sections, s.renderStreamingText(text))
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.renderStreamingText(text)
|
||||
return strings.Join(sections, "\n")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -228,8 +246,17 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
return s, streamSpinnerTickCmd()
|
||||
} else if !msg.Show && s.spinning {
|
||||
s.spinning = false
|
||||
}
|
||||
|
||||
case app.ReasoningChunkEvent:
|
||||
s.phase = streamPhaseActive
|
||||
if s.timestamp.IsZero() {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
s.reasoningContent.WriteString(msg.Delta)
|
||||
|
||||
case app.StreamChunkEvent:
|
||||
s.phase = streamPhaseActive
|
||||
if s.timestamp.IsZero() {
|
||||
@@ -271,14 +298,25 @@ func (s *StreamComponent) render() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
// Render reasoning/thinking block above the main text if present.
|
||||
if reasoning := s.reasoningContent.String(); reasoning != "" {
|
||||
sections = append(sections, s.renderReasoningBlock(reasoning))
|
||||
}
|
||||
|
||||
// Render streaming text only. The spinner is rendered in the status bar
|
||||
// by the parent so it never changes the stream region height.
|
||||
text := s.streamContent.String()
|
||||
if text == "" {
|
||||
if text != "" {
|
||||
sections = append(sections, s.renderStreamingText(text))
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := s.renderStreamingText(text)
|
||||
content := strings.Join(sections, "\n")
|
||||
|
||||
// Clamp to height if constrained: keep the last h lines so the most
|
||||
// recent output is always visible.
|
||||
@@ -293,6 +331,41 @@ 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.
|
||||
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
||||
theme := GetTheme()
|
||||
|
||||
if !s.thinkingVisible {
|
||||
// Show collapsed "Thinking..." label.
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Italic(true).
|
||||
Render("Thinking...")
|
||||
}
|
||||
|
||||
// Render full reasoning text in muted italic style.
|
||||
style := 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
|
||||
}
|
||||
|
||||
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
|
||||
func (s *StreamComponent) SetThinkingVisible(visible bool) {
|
||||
s.thinkingVisible = visible
|
||||
}
|
||||
|
||||
// HasReasoning returns true if any reasoning content has been accumulated.
|
||||
func (s *StreamComponent) HasReasoning() bool {
|
||||
return s.reasoningContent.Len() > 0
|
||||
}
|
||||
|
||||
// SpinnerView returns the rendered spinner line for the parent to embed in the
|
||||
// status bar. Returns "" when the spinner is not active.
|
||||
func (s *StreamComponent) SpinnerView() string {
|
||||
|
||||
+9
-45
@@ -23,15 +23,9 @@ func BaseStyle() lipgloss.Style {
|
||||
// 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.
|
||||
// An optional background color hex string (e.g. "#45475a") can be provided so
|
||||
// that the rendered markdown inherits the background color.
|
||||
func GetMarkdownRenderer(width int, bgHex ...string) *glamour.TermRenderer {
|
||||
var bg string
|
||||
if len(bgHex) > 0 {
|
||||
bg = bgHex[0]
|
||||
}
|
||||
func GetMarkdownRenderer(width int) *glamour.TermRenderer {
|
||||
r, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(generateMarkdownStyleConfig(bg)),
|
||||
glamour.WithStyles(generateMarkdownStyleConfig()),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
return r
|
||||
@@ -100,32 +94,15 @@ func resolveColorScheme() colorScheme {
|
||||
}
|
||||
|
||||
// generateMarkdownStyleConfig creates an ansi.StyleConfig for markdown rendering.
|
||||
// An optional background color hex string can be provided; when non-empty it is
|
||||
// applied to the Document, Paragraph, List, and BlockQuote elements so that
|
||||
// glamour-rendered content inherits the background uniformly.
|
||||
func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
|
||||
func generateMarkdownStyleConfig() ansi.StyleConfig {
|
||||
cs := resolveColorScheme()
|
||||
|
||||
// Background color for indent/whitespace tokens inside glamour.
|
||||
// When empty the tokens are transparent.
|
||||
bgColor := ""
|
||||
if len(bgHex) > 0 && bgHex[0] != "" {
|
||||
bgColor = bgHex[0]
|
||||
}
|
||||
|
||||
// Document-level background (propagates to child elements).
|
||||
var docBg *string
|
||||
if bgColor != "" {
|
||||
docBg = &bgColor
|
||||
}
|
||||
|
||||
return ansi.StyleConfig{
|
||||
Document: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockPrefix: "",
|
||||
BlockSuffix: "",
|
||||
Color: &cs.text,
|
||||
BackgroundColor: docBg,
|
||||
BlockPrefix: "",
|
||||
BlockSuffix: "",
|
||||
Color: &cs.text,
|
||||
},
|
||||
Margin: uintPtr(0), // Remove margin to prevent spacing
|
||||
},
|
||||
@@ -135,13 +112,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
|
||||
Italic: new(true),
|
||||
Prefix: "┃ ",
|
||||
},
|
||||
Indent: uintPtr(1),
|
||||
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
|
||||
Indent: uintPtr(1),
|
||||
},
|
||||
List: ansi.StyleList{
|
||||
LevelIndent: 0, // Remove list indentation
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
},
|
||||
@@ -316,13 +291,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
|
||||
Color: &cs.link,
|
||||
},
|
||||
Text: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
BackgroundColor: docBg,
|
||||
Color: &cs.text,
|
||||
},
|
||||
Paragraph: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
BackgroundColor: docBg,
|
||||
Color: &cs.text,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -334,12 +307,3 @@ func toMarkdown(content string, width int) string {
|
||||
rendered, _ := r.Render(content)
|
||||
return rendered
|
||||
}
|
||||
|
||||
// toMarkdownWithBg renders markdown content using glamour with a background
|
||||
// color applied to all elements so the rendered text blends with the block's
|
||||
// background.
|
||||
func toMarkdownWithBg(content string, width int, bgHex string) string {
|
||||
r := GetMarkdownRenderer(width, bgHex)
|
||||
rendered, _ := r.Render(content)
|
||||
return rendered
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
|
||||
if body := renderEditBody(toolArgs, toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "read" || toolName == "ls":
|
||||
case toolName == "ls":
|
||||
if body := renderLsBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "read":
|
||||
if body := renderReadBody(toolArgs, toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
@@ -292,6 +296,35 @@ func renderDiffBlock(before, after string, startLine int, width int) string {
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ls tool — simple list without gutter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderLsBody renders ls output as a plain list with code background and no
|
||||
// line-number gutter.
|
||||
func renderLsBody(toolResult string, width int) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
const indent = " "
|
||||
codeWidth := max(width-len(indent), 20)
|
||||
|
||||
theme := getTheme()
|
||||
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
|
||||
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
styled := codeStyle.Width(codeWidth).Render(line)
|
||||
result = append(result, indent+styled)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read tool — code block with line numbers + syntax highlighting
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -663,3 +696,177 @@ func truncateLine(s string, maxWidth int) string {
|
||||
}
|
||||
return s[:maxWidth-1] + "…"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact tool body renderers — one-line summaries for compact mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderToolBodyCompact returns a brief summary string for tool results in
|
||||
// compact display mode. Returns empty string to fall back to default.
|
||||
func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) string {
|
||||
switch {
|
||||
case toolName == "edit":
|
||||
return renderEditCompact(toolArgs, toolResult)
|
||||
case toolName == "ls":
|
||||
return renderLsCompact(toolResult)
|
||||
case toolName == "read":
|
||||
return renderReadCompact(toolResult)
|
||||
case toolName == "write":
|
||||
return renderWriteCompact(toolArgs)
|
||||
case toolName == "bash" || toolName == "run_shell_cmd" ||
|
||||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
|
||||
return renderBashCompact(toolResult, width)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderReadCompact returns a line-count summary for Read tool output.
|
||||
func renderReadCompact(toolResult string) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
// Count actual code lines (those with "N: " line-number prefix)
|
||||
codeLines := 0
|
||||
for _, line := range lines {
|
||||
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
|
||||
numPart := line[:idx]
|
||||
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
|
||||
codeLines++
|
||||
}
|
||||
}
|
||||
}
|
||||
if codeLines == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d lines", codeLines)
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderEditCompact returns a change-count summary for Edit tool output.
|
||||
func renderEditCompact(toolArgs, toolResult string) string {
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
oldText, _ := args["old_text"].(string)
|
||||
newText, _ := args["new_text"].(string)
|
||||
if oldText == "" && newText == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
oldCount := len(strings.Split(oldText, "\n"))
|
||||
newCount := len(strings.Split(newText, "\n"))
|
||||
|
||||
theme := getTheme()
|
||||
var summary string
|
||||
if oldCount == newCount {
|
||||
summary = fmt.Sprintf("%d lines modified", oldCount)
|
||||
} else {
|
||||
summary = fmt.Sprintf("-%d/+%d lines", oldCount, newCount)
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderWriteCompact returns a line-count summary for Write tool output.
|
||||
func renderWriteCompact(toolArgs string) string {
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
count := len(strings.Split(content, "\n"))
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d lines written", count)
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderLsCompact returns an entry-count summary for Ls tool output.
|
||||
func renderLsCompact(toolResult string) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
entries := strings.Split(content, "\n")
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d entries", len(entries))
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderBashCompact returns the first few lines of bash output as a compact
|
||||
// summary. Shows up to 3 meaningful output lines.
|
||||
func renderBashCompact(toolResult string, width int) string {
|
||||
result := strings.TrimSpace(toolResult)
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(result, "\n")
|
||||
|
||||
// Filter to meaningful output lines (skip STDERR: label, keep exit codes separate)
|
||||
var outputLines []string
|
||||
var exitCode string
|
||||
inStderr := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "STDERR:" {
|
||||
inStderr = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Exit code:") {
|
||||
exitCode = trimmed
|
||||
continue
|
||||
}
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
outputLines = append(outputLines, line)
|
||||
_ = inStderr // stderr lines are included in output
|
||||
}
|
||||
|
||||
if len(outputLines) == 0 {
|
||||
if exitCode != "" {
|
||||
theme := getTheme()
|
||||
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxLines = 3
|
||||
theme := getTheme()
|
||||
|
||||
display := outputLines
|
||||
if len(display) > maxLines {
|
||||
display = display[:maxLines]
|
||||
}
|
||||
|
||||
// Truncate each line to available width
|
||||
lineMax := max(width-4, 20)
|
||||
for i, line := range display {
|
||||
if len(line) > lineMax {
|
||||
display[i] = line[:lineMax-3] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
summary := strings.Join(display, "\n")
|
||||
if len(outputLines) > maxLines {
|
||||
summary += fmt.Sprintf("\n...(%d more lines)", len(outputLines)-maxLines)
|
||||
}
|
||||
if exitCode != "" {
|
||||
summary += "\n" + lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ type FlatNode struct {
|
||||
}
|
||||
|
||||
// TreeSelectorComponent is a Bubble Tea component that renders the session
|
||||
// tree as an ASCII art list with navigation and selection. It follows pi's
|
||||
// tree selector design.
|
||||
// tree as an ASCII art list with navigation and selection.
|
||||
type TreeSelectorComponent struct {
|
||||
tm *session.TreeManager
|
||||
flatNodes []FlatNode
|
||||
|
||||
+41
-3
@@ -23,8 +23,8 @@ func (m *Kit) EstimateContextTokens() int {
|
||||
}
|
||||
|
||||
// ShouldCompact reports whether the conversation is near the model's context
|
||||
// limit and should be compacted. Uses Pi's formula:
|
||||
// contextTokens > contextWindow − reserveTokens.
|
||||
// limit and should be compacted.
|
||||
// Formula: contextTokens > contextWindow − reserveTokens.
|
||||
// Returns false if the model's context limit is unknown.
|
||||
func (m *Kit) ShouldCompact() bool {
|
||||
info := m.GetModelInfo()
|
||||
@@ -43,9 +43,23 @@ func (m *Kit) ShouldCompact() bool {
|
||||
|
||||
// GetContextStats returns current context usage statistics including
|
||||
// estimated token count, context limit, usage percentage, and message count.
|
||||
//
|
||||
// When API-reported token counts are available (after at least one turn),
|
||||
// EstimatedTokens uses the real input token count from the most recent API
|
||||
// response. This is significantly more accurate than the text-based heuristic
|
||||
// because it includes system prompts, tool definitions, and other overhead
|
||||
// that the heuristic cannot account for.
|
||||
func (m *Kit) GetContextStats() ContextStats {
|
||||
messages := m.treeSession.GetFantasyMessages()
|
||||
estimated := compaction.EstimateMessageTokens(messages)
|
||||
|
||||
// Prefer the real API-reported input token count when available.
|
||||
m.lastInputTokensMu.RLock()
|
||||
estimated := m.lastInputTokens
|
||||
m.lastInputTokensMu.RUnlock()
|
||||
if estimated == 0 {
|
||||
// Fall back to heuristic before first turn completes.
|
||||
estimated = compaction.EstimateMessageTokens(messages)
|
||||
}
|
||||
|
||||
stats := ContextStats{
|
||||
EstimatedTokens: estimated,
|
||||
@@ -72,6 +86,12 @@ func (m *Kit) GetContextStats() ContextStats {
|
||||
// After compaction, the tree session is cleared and replaced with the
|
||||
// compacted messages (summary + preserved recent messages).
|
||||
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstructions string) (*CompactionResult, error) {
|
||||
return m.compactInternal(ctx, opts, customInstructions, false)
|
||||
}
|
||||
|
||||
// compactInternal is the shared compaction implementation. The isAutomatic
|
||||
// flag distinguishes auto-triggered compaction from manual /compact.
|
||||
func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
|
||||
if opts == nil {
|
||||
if m.compactionOpts != nil {
|
||||
opts = m.compactionOpts
|
||||
@@ -92,6 +112,24 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
|
||||
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
|
||||
}
|
||||
|
||||
// Run before-compact hook — extensions can cancel compaction.
|
||||
if m.beforeCompact.hasHooks() {
|
||||
stats := m.GetContextStats()
|
||||
if hookResult := m.beforeCompact.run(BeforeCompactHook{
|
||||
EstimatedTokens: stats.EstimatedTokens,
|
||||
ContextLimit: stats.ContextLimit,
|
||||
UsagePercent: stats.UsagePercent,
|
||||
MessageCount: stats.MessageCount,
|
||||
IsAutomatic: isAutomatic,
|
||||
}); hookResult != nil && hookResult.Cancel {
|
||||
reason := hookResult.Reason
|
||||
if reason == "" {
|
||||
reason = "compaction cancelled by extension"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
model := m.agent.GetModel()
|
||||
result, newMessages, err := compaction.Compact(ctx, model, messages, *opts, customInstructions)
|
||||
if err != nil {
|
||||
|
||||
+2
-1
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// defaultSystemPrompt is the built-in system prompt used when no custom
|
||||
// prompt is configured. It describes the available core tools and provides
|
||||
// usage guidelines, matching the Pi SDK's default prompt style.
|
||||
// usage guidelines.
|
||||
const defaultSystemPrompt = `You are an expert coding assistant operating inside kit, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
|
||||
|
||||
Available tools:
|
||||
@@ -45,6 +45,7 @@ func setSDKDefaults() {
|
||||
viper.SetDefault("top-p", 0.95)
|
||||
viper.SetDefault("top-k", 40)
|
||||
viper.SetDefault("stream", true)
|
||||
viper.SetDefault("thinking-level", "off")
|
||||
viper.SetDefault("num-gpu-layers", -1)
|
||||
viper.SetDefault("main-gpu", 0)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ const (
|
||||
EventResponse EventType = "response"
|
||||
// EventCompaction fires after a successful compaction.
|
||||
EventCompaction EventType = "compaction"
|
||||
// EventReasoningDelta fires for each streaming reasoning/thinking chunk.
|
||||
EventReasoningDelta EventType = "reasoning_delta"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -81,6 +83,14 @@ type MessageUpdateEvent struct {
|
||||
// EventType implements Event.
|
||||
func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate }
|
||||
|
||||
// ReasoningDeltaEvent fires for each streaming reasoning/thinking chunk.
|
||||
type ReasoningDeltaEvent struct {
|
||||
Delta string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
func (e ReasoningDeltaEvent) EventType() EventType { return EventReasoningDelta }
|
||||
|
||||
// MessageEndEvent fires when the assistant message is complete.
|
||||
type MessageEndEvent struct {
|
||||
Content string
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package kit
|
||||
|
||||
import "github.com/mark3labs/kit/internal/extensions"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// bridgeExtensions registers extension event handlers as SDK hooks and
|
||||
// subscribes to SDK observation events to forward them to the extension runner.
|
||||
@@ -97,4 +102,82 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Context filtering hook ---
|
||||
// Extension ContextPrepare → SDK ContextPrepare hook.
|
||||
if runner.HasHandlers(extensions.ContextPrepare) {
|
||||
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
|
||||
// Convert fantasy.Message slice to extension ContextMessage slice.
|
||||
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
|
||||
for i, msg := range h.Messages {
|
||||
// Extract text from content parts.
|
||||
var text strings.Builder
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
text.WriteString(tp.Text)
|
||||
}
|
||||
}
|
||||
extMsgs[i] = extensions.ContextMessage{
|
||||
Index: i,
|
||||
Role: string(msg.Role),
|
||||
Content: text.String(),
|
||||
}
|
||||
}
|
||||
|
||||
result, _ := runner.Emit(extensions.ContextPrepareEvent{Messages: extMsgs})
|
||||
r, ok := result.(extensions.ContextPrepareResult)
|
||||
if !ok || r.Messages == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rebuild fantasy.Message slice from extension result.
|
||||
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
|
||||
for _, cm := range r.Messages {
|
||||
if cm.Index >= 0 && cm.Index < len(h.Messages) {
|
||||
// Reuse original message (preserves tool calls, reasoning, etc.)
|
||||
rebuilt = append(rebuilt, h.Messages[cm.Index])
|
||||
} else {
|
||||
// New message injected by extension.
|
||||
role := fantasy.MessageRoleUser
|
||||
switch cm.Role {
|
||||
case "assistant":
|
||||
role = fantasy.MessageRoleAssistant
|
||||
case "system":
|
||||
role = fantasy.MessageRoleSystem
|
||||
case "tool":
|
||||
role = fantasy.MessageRoleTool
|
||||
}
|
||||
rebuilt = append(rebuilt, fantasy.Message{
|
||||
Role: role,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: cm.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &ContextPrepareResult{Messages: rebuilt}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Compaction hook ---
|
||||
// Extension BeforeCompact → SDK BeforeCompact hook.
|
||||
if runner.HasHandlers(extensions.BeforeCompact) {
|
||||
m.OnBeforeCompact(HookPriorityNormal, func(h BeforeCompactHook) *BeforeCompactResult {
|
||||
result, _ := runner.Emit(extensions.BeforeCompactEvent{
|
||||
EstimatedTokens: h.EstimatedTokens,
|
||||
ContextLimit: h.ContextLimit,
|
||||
UsagePercent: h.UsagePercent,
|
||||
MessageCount: h.MessageCount,
|
||||
IsAutomatic: h.IsAutomatic,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeCompactResult); ok && r.Cancel {
|
||||
return &BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: r.Reason,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,43 @@ type AfterTurnHook struct {
|
||||
// AfterTurnResult is a placeholder — after-turn hooks are observation-only.
|
||||
type AfterTurnResult struct{}
|
||||
|
||||
// ContextPrepareHook is the input for hooks that fire after the context window
|
||||
// is assembled from the session tree (including compaction) and before the
|
||||
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
|
||||
type ContextPrepareHook struct {
|
||||
// Messages is the current context as fantasy.Message objects.
|
||||
Messages []fantasy.Message
|
||||
}
|
||||
|
||||
// ContextPrepareResult can replace the context window.
|
||||
type ContextPrepareResult struct {
|
||||
// Messages replaces the entire context window. If nil, the original
|
||||
// messages are used.
|
||||
Messages []fantasy.Message
|
||||
}
|
||||
|
||||
// BeforeCompactHook is the input for hooks that fire before compaction runs.
|
||||
type BeforeCompactHook struct {
|
||||
// EstimatedTokens is the estimated token count of the conversation.
|
||||
EstimatedTokens int
|
||||
// ContextLimit is the model's context window size in tokens.
|
||||
ContextLimit int
|
||||
// UsagePercent is the fraction of context used (0.0–1.0).
|
||||
UsagePercent float64
|
||||
// MessageCount is the number of messages in the conversation.
|
||||
MessageCount int
|
||||
// IsAutomatic is true when compaction was triggered automatically.
|
||||
IsAutomatic bool
|
||||
}
|
||||
|
||||
// BeforeCompactResult controls whether compaction proceeds.
|
||||
type BeforeCompactResult struct {
|
||||
// Cancel, when true, prevents compaction from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation when Cancel is true.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic hook registry with priority ordering
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -181,6 +218,23 @@ func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() {
|
||||
})
|
||||
}
|
||||
|
||||
// OnContextPrepare registers a hook that fires after the context window is
|
||||
// built from the session tree and before messages are sent to the LLM. Return
|
||||
// a non-nil ContextPrepareResult with Messages to replace the entire context.
|
||||
// Hooks execute in priority order; the first non-nil result wins.
|
||||
// Returns an unregister function.
|
||||
func (m *Kit) OnContextPrepare(p HookPriority, h func(ContextPrepareHook) *ContextPrepareResult) func() {
|
||||
return m.contextPrepare.register(p, h)
|
||||
}
|
||||
|
||||
// OnBeforeCompact registers a hook that fires before context compaction runs.
|
||||
// Return a non-nil BeforeCompactResult with Cancel=true to prevent compaction.
|
||||
// Hooks execute in priority order; the first non-nil result wins.
|
||||
// Returns an unregister function.
|
||||
func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeCompactResult) func() {
|
||||
return m.beforeCompact.register(p, h)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool wrapping via hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+822
-32
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -13,6 +14,9 @@ import (
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/kitsetup"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
@@ -47,6 +51,15 @@ type Kit struct {
|
||||
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
|
||||
beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult]
|
||||
afterTurn *hookRegistry[AfterTurnHook, AfterTurnResult]
|
||||
contextPrepare *hookRegistry[ContextPrepareHook, ContextPrepareResult]
|
||||
beforeCompact *hookRegistry[BeforeCompactHook, BeforeCompactResult]
|
||||
|
||||
// lastInputTokens stores the API-reported input token count from the
|
||||
// most recent turn. Used by GetContextStats() to return accurate usage
|
||||
// instead of the text-based heuristic which misses system prompts,
|
||||
// tool definitions, etc.
|
||||
lastInputTokensMu sync.RWMutex
|
||||
lastInputTokens int
|
||||
}
|
||||
|
||||
// Subscribe registers an EventListener that will be called for every lifecycle
|
||||
@@ -57,15 +70,699 @@ func (m *Kit) Subscribe(listener EventListener) func() {
|
||||
}
|
||||
|
||||
// GetExtRunner returns the extension runner (nil if extensions are disabled).
|
||||
//
|
||||
// Deprecated: Use SetExtensionContext and EmitSessionStart instead. GetExtRunner
|
||||
// leaks the internal extensions.Runner type across the SDK boundary.
|
||||
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
|
||||
|
||||
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
|
||||
//
|
||||
// Deprecated: Use GetBufferedDebugMessages instead.
|
||||
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
|
||||
|
||||
// GetAgent returns the underlying agent. Callers that need the raw agent
|
||||
// (e.g. for GetTools(), GetLoadingMessage()) can use this.
|
||||
// GetAgent returns the underlying agent.
|
||||
//
|
||||
// Deprecated: Use GetToolNames, GetLoadingMessage, GetLoadedServerNames,
|
||||
// GetMCPToolCount, GetExtensionToolCount instead.
|
||||
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Narrow accessors — prefer these over GetAgent/GetExtRunner/GetBufferedLogger
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// GetToolNames returns the names of all tools available to the agent.
|
||||
func (m *Kit) GetToolNames() []string {
|
||||
agentTools := m.agent.GetTools()
|
||||
names := make([]string, len(agentTools))
|
||||
for i, t := range agentTools {
|
||||
names[i] = t.Info().Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// GetLoadingMessage returns the agent's startup info message (e.g. GPU
|
||||
// fallback info), or empty string if none.
|
||||
func (m *Kit) GetLoadingMessage() string {
|
||||
return m.agent.GetLoadingMessage()
|
||||
}
|
||||
|
||||
// GetLoadedServerNames returns the names of successfully loaded MCP servers.
|
||||
func (m *Kit) GetLoadedServerNames() []string {
|
||||
return m.agent.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
|
||||
func (m *Kit) GetMCPToolCount() int {
|
||||
return m.agent.GetMCPToolCount()
|
||||
}
|
||||
|
||||
// GetExtensionToolCount returns the number of tools registered by extensions.
|
||||
func (m *Kit) GetExtensionToolCount() int {
|
||||
return m.agent.GetExtensionToolCount()
|
||||
}
|
||||
|
||||
// GetBufferedDebugMessages returns any debug messages that were buffered
|
||||
// during initialization, then clears the buffer. Returns nil if no messages
|
||||
// were buffered or if buffered logging was not configured.
|
||||
func (m *Kit) GetBufferedDebugMessages() []string {
|
||||
if m.bufferedLogger == nil {
|
||||
return nil
|
||||
}
|
||||
return m.bufferedLogger.GetMessages()
|
||||
}
|
||||
|
||||
// SetExtensionContext configures the extension runner with the given context
|
||||
// functions. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionContext(ctx extensions.Context) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionContext returns the current extension runtime context.
|
||||
// Returns a zero Context if extensions are disabled.
|
||||
func (m *Kit) GetExtensionContext() extensions.Context {
|
||||
if m.extRunner != nil {
|
||||
return m.extRunner.GetContext()
|
||||
}
|
||||
return extensions.Context{}
|
||||
}
|
||||
|
||||
// UpdateExtensionContextModel updates the Model field on the extension
|
||||
// context so subsequent event handlers see the new model. This is a
|
||||
// targeted update that avoids replacing the entire Context struct.
|
||||
func (m *Kit) UpdateExtensionContextModel(model string) {
|
||||
if m.extRunner != nil {
|
||||
ctx := m.extRunner.GetContext()
|
||||
ctx.Model = model
|
||||
m.extRunner.SetContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// EmitSessionStart fires the SessionStart event for extensions.
|
||||
// No-op if extensions are disabled or no handlers are registered.
|
||||
func (m *Kit) EmitSessionStart() {
|
||||
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionStart) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{})
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionCommands returns the slash commands registered by extensions.
|
||||
// Returns nil if extensions are disabled or no commands are registered.
|
||||
func (m *Kit) ExtensionCommands() []extensions.CommandDef {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.RegisteredCommands()
|
||||
}
|
||||
|
||||
// SetExtensionWidget places or updates a persistent extension widget.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionWidget(config extensions.WidgetConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetWidget(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionWidget removes a previously placed extension widget by ID.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionWidget(id string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveWidget(id)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionWidgets returns extension widgets matching the given placement.
|
||||
// Returns nil if extensions are disabled or no widgets match.
|
||||
func (m *Kit) GetExtensionWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetWidgets(placement)
|
||||
}
|
||||
|
||||
// SetExtensionHeader places or replaces the custom header from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionHeader(config extensions.HeaderFooterConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetHeader(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionHeader removes the custom extension header.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionHeader() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveHeader()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionHeader returns the current custom header, or nil if none is set.
|
||||
// Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionHeader() *extensions.HeaderFooterConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetHeader()
|
||||
}
|
||||
|
||||
// SetExtensionFooter places or replaces the custom footer from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionFooter(config extensions.HeaderFooterConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetFooter(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionFooter removes the custom extension footer.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionFooter() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveFooter()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionFooter returns the current custom footer, or nil if none is set.
|
||||
// Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionFooter() *extensions.HeaderFooterConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetFooter()
|
||||
}
|
||||
|
||||
// GetExtensionToolRenderer returns the custom renderer for the named tool, or
|
||||
// nil if no extension registered a renderer for it. Returns nil if extensions
|
||||
// are disabled.
|
||||
func (m *Kit) GetExtensionToolRenderer(toolName string) *extensions.ToolRenderConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetToolRenderer(toolName)
|
||||
}
|
||||
|
||||
// SetExtensionEditor installs an editor interceptor from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionEditor(config extensions.EditorConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetEditor(config)
|
||||
}
|
||||
}
|
||||
|
||||
// ResetExtensionEditor removes the active editor interceptor from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) ResetExtensionEditor() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.ResetEditor()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionEditor returns the current editor interceptor, or nil if none
|
||||
// is set. Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionEditor() *extensions.EditorConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetEditor()
|
||||
}
|
||||
|
||||
// SetExtensionUIVisibility stores extension-provided UI visibility overrides.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionUIVisibility(v extensions.UIVisibility) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetUIVisibility(v)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionUIVisibility returns extension-provided UI visibility overrides,
|
||||
// or nil if none have been set. Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionUIVisibility() *extensions.UIVisibility {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetUIVisibility()
|
||||
}
|
||||
|
||||
// GetSessionMessages returns the conversation messages on the current branch
|
||||
// as extension-facing SessionMessage structs, ordered root to leaf.
|
||||
func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
branch := m.treeSession.GetBranch("")
|
||||
var msgs []extensions.SessionMessage
|
||||
for _, entry := range branch {
|
||||
me, ok := entry.(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg, err := me.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Flatten content parts into a single text string.
|
||||
var content strings.Builder
|
||||
for _, p := range msg.Parts {
|
||||
switch pt := p.(type) {
|
||||
case message.TextContent:
|
||||
content.WriteString(pt.Text)
|
||||
case message.ReasoningContent:
|
||||
content.WriteString(pt.Thinking)
|
||||
case message.ToolCall:
|
||||
fmt.Fprintf(&content, "[tool_call: %s(%s)]", pt.Name, pt.Input)
|
||||
case message.ToolResult:
|
||||
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, extensions.SessionMessage{
|
||||
ID: me.ID,
|
||||
ParentID: me.ParentID,
|
||||
Role: string(msg.Role),
|
||||
Content: content.String(),
|
||||
Model: msg.Model,
|
||||
Provider: msg.Provider,
|
||||
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// GetSessionFilePath returns the JSONL file path of the current session.
|
||||
func (m *Kit) GetSessionFilePath() string {
|
||||
if m.treeSession == nil {
|
||||
return ""
|
||||
}
|
||||
return m.treeSession.GetFilePath()
|
||||
}
|
||||
|
||||
// AppendExtensionEntry persists custom extension data in the session tree.
|
||||
func (m *Kit) AppendExtensionEntry(extType, data string) (string, error) {
|
||||
if m.treeSession == nil {
|
||||
return "", fmt.Errorf("no session available")
|
||||
}
|
||||
return m.treeSession.AppendExtensionData(extType, data)
|
||||
}
|
||||
|
||||
// GetExtensionEntries retrieves persisted extension data entries for a type.
|
||||
func (m *Kit) GetExtensionEntries(extType string) []extensions.ExtensionEntry {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
entries := m.treeSession.GetExtensionData(extType)
|
||||
result := make([]extensions.ExtensionEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
result = append(result, extensions.ExtensionEntry{
|
||||
ID: e.ID,
|
||||
EntryType: e.ExtType,
|
||||
Data: e.Data,
|
||||
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionStatus places or updates a keyed status bar entry.
|
||||
func (m *Kit) SetExtensionStatus(entry extensions.StatusBarEntry) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetStatusEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionStatus removes a keyed status bar entry.
|
||||
func (m *Kit) RemoveExtensionStatus(key string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveStatusEntry(key)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionStatusEntries returns all extension status bar entries sorted by priority.
|
||||
func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetStatusEntries()
|
||||
}
|
||||
|
||||
// GetExtensionShortcuts returns a map of key bindings to handler functions
|
||||
// from all loaded extensions. Returns nil if no shortcuts are registered or
|
||||
// extensions are disabled. Handlers are closures that capture the runner's
|
||||
// current context, so they can call Print/SetStatus/etc.
|
||||
func (m *Kit) GetExtensionShortcuts() map[string]func() {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
entries := m.extRunner.GetShortcuts()
|
||||
if entries == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]func(), len(entries))
|
||||
for key, entry := range entries {
|
||||
h := entry.Handler
|
||||
r := m.extRunner
|
||||
result[key] = func() {
|
||||
ctx := r.GetContext()
|
||||
h(ctx)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetExtensionToolInfos returns information about all tools available to the
|
||||
// agent, including enabled/disabled status from SetActiveTools. Each tool is
|
||||
// categorized by source: "core", "mcp", or "extension".
|
||||
func (m *Kit) GetExtensionToolInfos() []extensions.ToolInfo {
|
||||
agentTools := m.agent.GetTools()
|
||||
coreCount := m.agent.GetCoreToolCount()
|
||||
mcpCount := m.agent.GetMCPToolCount()
|
||||
|
||||
result := make([]extensions.ToolInfo, 0, len(agentTools))
|
||||
for i, t := range agentTools {
|
||||
info := t.Info()
|
||||
source := "core"
|
||||
if i >= coreCount && i < coreCount+mcpCount {
|
||||
source = "mcp"
|
||||
} else if i >= coreCount+mcpCount {
|
||||
source = "extension"
|
||||
}
|
||||
enabled := true
|
||||
if m.extRunner != nil && m.extRunner.IsToolDisabled(info.Name) {
|
||||
enabled = false
|
||||
}
|
||||
result = append(result, extensions.ToolInfo{
|
||||
Name: info.Name,
|
||||
Description: info.Description,
|
||||
Source: source,
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionActiveTools restricts the tool set to the named tools. All
|
||||
// other tools are blocked from execution. Pass nil to re-enable all tools.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionActiveTools(names []string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetActiveTools(names)
|
||||
}
|
||||
}
|
||||
|
||||
// SetModel changes the active model at runtime. The existing tools, system
|
||||
// prompt, and session are preserved. The model string should be in
|
||||
// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
|
||||
// Returns an error if the model string is invalid or the provider cannot
|
||||
// be created.
|
||||
func (m *Kit) SetModel(ctx context.Context, modelString string) error {
|
||||
// Validate the model string first.
|
||||
if _, _, err := ParseModelString(modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a provider config from current settings, overriding the model.
|
||||
config := &models.ProviderConfig{
|
||||
ModelString: modelString,
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
}
|
||||
temperature := float32(viper.GetFloat64("temperature"))
|
||||
config.Temperature = &temperature
|
||||
topP := float32(viper.GetFloat64("top-p"))
|
||||
config.TopP = &topP
|
||||
topK := int32(viper.GetInt("top-k"))
|
||||
config.TopK = &topK
|
||||
|
||||
if err := m.agent.SetModel(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.modelString = modelString
|
||||
|
||||
// Update extension context's Model field.
|
||||
if m.extRunner != nil {
|
||||
extCtx := m.extRunner.GetContext()
|
||||
extCtx.Model = modelString
|
||||
m.extRunner.SetContext(extCtx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAvailableModels returns a list of known models from the registry. Each
|
||||
// entry includes provider, model ID, context limit, and whether the model
|
||||
// supports reasoning. This is an advisory list — models not in the registry
|
||||
// can still be used by specifying their provider/model string.
|
||||
func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
|
||||
registry := models.GetGlobalRegistry()
|
||||
var result []extensions.ModelInfoEntry
|
||||
for _, providerID := range registry.GetFantasyProviders() {
|
||||
modelsMap, err := registry.GetModelsForProvider(providerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for modelID, info := range modelsMap {
|
||||
result = append(result, extensions.ModelInfoEntry{
|
||||
Provider: providerID,
|
||||
ModelID: modelID,
|
||||
Name: info.Name,
|
||||
ContextLimit: info.Limit.Context,
|
||||
OutputLimit: info.Limit.Output,
|
||||
Reasoning: info.Reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetExtensionOption resolves a named extension option value.
|
||||
func (m *Kit) GetExtensionOption(name string) string {
|
||||
if m.extRunner == nil {
|
||||
return ""
|
||||
}
|
||||
return m.extRunner.GetOption(name)
|
||||
}
|
||||
|
||||
// SetExtensionOption stores a runtime override for a named extension option.
|
||||
func (m *Kit) SetExtensionOption(name, value string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetOption(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// EmitModelChange fires the ModelChange event for extensions.
|
||||
// No-op if extensions are disabled or no handlers are registered.
|
||||
func (m *Kit) EmitModelChange(newModel, previousModel, source string) {
|
||||
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.ModelChange) {
|
||||
_, _ = m.extRunner.Emit(extensions.ModelChangeEvent{
|
||||
NewModel: newModel,
|
||||
PreviousModel: previousModel,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// EmitExtensionCustomEvent dispatches a named event to all extension handlers.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) EmitExtensionCustomEvent(name, data string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.EmitCustomEvent(name, data)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionMessageRenderer returns the named message renderer, or nil
|
||||
// if no extension registered a renderer with that name.
|
||||
func (m *Kit) GetExtensionMessageRenderer(name string) *extensions.MessageRendererConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetMessageRenderer(name)
|
||||
}
|
||||
|
||||
// ReloadExtensions hot-reloads all extensions from disk. Event handlers,
|
||||
// commands, renderers, and shortcuts update immediately. Extension-defined
|
||||
// tools are NOT updated (they are baked into the agent at creation time).
|
||||
func (m *Kit) ReloadExtensions() error {
|
||||
if m.extRunner == nil {
|
||||
return fmt.Errorf("no extensions loaded")
|
||||
}
|
||||
|
||||
// Emit shutdown to old extensions.
|
||||
if m.extRunner.HasHandlers(extensions.SessionShutdown) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionShutdownEvent{})
|
||||
}
|
||||
|
||||
// Re-load from disk.
|
||||
extraPaths := viper.GetStringSlice("extension")
|
||||
loaded, err := extensions.LoadExtensions(extraPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reloading extensions: %w", err)
|
||||
}
|
||||
|
||||
// Swap extensions on the runner (clears dynamic state).
|
||||
m.extRunner.Reload(loaded)
|
||||
|
||||
// Re-set context and emit SessionStart.
|
||||
ctx := m.extRunner.GetContext()
|
||||
m.extRunner.SetContext(ctx)
|
||||
if m.extRunner.HasHandlers(extensions.SessionStart) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{SessionID: ctx.SessionID})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCompletion makes a standalone LLM completion call for extensions.
|
||||
// When req.Model is empty the current agent model is reused (no provider
|
||||
// creation overhead). When req.Model is set a temporary provider is created,
|
||||
// used, and closed.
|
||||
func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
var (
|
||||
llmModel fantasy.LanguageModel
|
||||
closer func()
|
||||
usedModel string
|
||||
providerOps fantasy.ProviderOptions
|
||||
)
|
||||
|
||||
if req.Model == "" {
|
||||
// Reuse the active agent's model.
|
||||
llmModel = m.agent.GetModel()
|
||||
usedModel = m.modelString
|
||||
closer = func() {} // nothing to clean up
|
||||
} else {
|
||||
// Create a temporary provider for the requested model.
|
||||
config := &models.ProviderConfig{
|
||||
ModelString: req.Model,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
config.MaxTokens = req.MaxTokens
|
||||
}
|
||||
providerResult, err := models.CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("create provider for %q: %w", req.Model, err)
|
||||
}
|
||||
llmModel = providerResult.Model
|
||||
usedModel = req.Model
|
||||
providerOps = providerResult.ProviderOptions
|
||||
closer = func() {
|
||||
if providerResult.Closer != nil {
|
||||
_ = providerResult.Closer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
defer closer()
|
||||
|
||||
// Build fantasy agent options (no tools — just a simple completion).
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if req.System != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(req.System))
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(req.MaxTokens)))
|
||||
}
|
||||
if providerOps != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerOps))
|
||||
}
|
||||
|
||||
completionAgent := fantasy.NewAgent(llmModel, agentOpts...)
|
||||
|
||||
// Convert extension SessionMessage history to fantasy.Message slice.
|
||||
var messages []fantasy.Message
|
||||
for _, sm := range req.Messages {
|
||||
messages = append(messages, fantasy.Message{
|
||||
Role: fantasy.MessageRole(sm.Role),
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: sm.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Streaming path.
|
||||
if req.OnChunk != nil {
|
||||
result, err := completionAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: req.Prompt,
|
||||
Messages: messages,
|
||||
OnTextDelta: func(_, text string) error {
|
||||
req.OnChunk(text)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("streaming completion: %w", err)
|
||||
}
|
||||
return extensions.CompleteResponse{
|
||||
Text: result.Response.Content.Text(),
|
||||
InputTokens: int(result.Response.Usage.InputTokens),
|
||||
OutputTokens: int(result.Response.Usage.OutputTokens),
|
||||
Model: usedModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Non-streaming path.
|
||||
result, err := completionAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: req.Prompt,
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("completion: %w", err)
|
||||
}
|
||||
return extensions.CompleteResponse{
|
||||
Text: result.Response.Content.Text(),
|
||||
InputTokens: int(result.Response.Usage.InputTokens),
|
||||
OutputTokens: int(result.Response.Usage.OutputTokens),
|
||||
Model: usedModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EmitBeforeFork emits a BeforeFork event to extensions and returns
|
||||
// whether the fork was cancelled and the reason. No-op if extensions are
|
||||
// disabled (returns false, "").
|
||||
func (m *Kit) EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string) {
|
||||
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeFork) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := m.extRunner.Emit(extensions.BeforeForkEvent{
|
||||
TargetID: targetID,
|
||||
IsUserMessage: isUserMsg,
|
||||
UserText: userText,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeForkResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
if reason == "" {
|
||||
reason = "Fork cancelled by extension."
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// EmitBeforeSessionSwitch emits a BeforeSessionSwitch event to extensions
|
||||
// and returns whether the switch was cancelled and the reason. No-op if
|
||||
// extensions are disabled (returns false, "").
|
||||
func (m *Kit) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
|
||||
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := m.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
|
||||
Reason: switchReason,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
if reason == "" {
|
||||
reason = "Session switch cancelled by extension."
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// HasExtensions returns true if the extension runner is configured and active.
|
||||
func (m *Kit) HasExtensions() bool {
|
||||
return m.extRunner != nil
|
||||
}
|
||||
|
||||
// Options configures Kit creation with optional overrides for model,
|
||||
// prompts, configuration, and behavior settings. All fields are optional
|
||||
// and will use CLI defaults if not specified.
|
||||
@@ -93,12 +790,25 @@ type Options struct {
|
||||
AutoCompact bool // Auto-compact when near context limit
|
||||
CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults)
|
||||
|
||||
// CLI-specific fields (ignored by programmatic SDK users)
|
||||
MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set)
|
||||
ShowSpinner bool // Show loading spinner for Ollama models
|
||||
SpinnerFunc SpinnerFunc // Spinner implementation (nil = no spinner)
|
||||
UseBufferedLogger bool // Buffer debug messages for later display
|
||||
Debug bool // Enable debug logging
|
||||
// Debug enables debug logging for the SDK.
|
||||
Debug bool
|
||||
|
||||
// CLI is optional CLI-specific configuration. SDK users leave this nil.
|
||||
CLI *CLIOptions
|
||||
}
|
||||
|
||||
// CLIOptions holds fields only relevant to the CLI binary. SDK users should
|
||||
// not need these; they are separated to keep the main Options struct clean.
|
||||
type CLIOptions struct {
|
||||
// MCPConfig is a pre-loaded MCP config. When set, LoadAndValidateConfig
|
||||
// is skipped during Kit creation.
|
||||
MCPConfig *config.Config
|
||||
// ShowSpinner shows a loading spinner for Ollama models.
|
||||
ShowSpinner bool
|
||||
// SpinnerFunc provides the spinner implementation (nil = no spinner).
|
||||
SpinnerFunc SpinnerFunc
|
||||
// UseBufferedLogger buffers debug messages for later display.
|
||||
UseBufferedLogger bool
|
||||
}
|
||||
|
||||
// InitTreeSession creates or opens a tree session based on the given options.
|
||||
@@ -186,8 +896,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
}
|
||||
|
||||
// Always compose the system prompt with runtime context: base prompt +
|
||||
// AGENTS.md context + skills metadata + date/cwd. This matches Pi's
|
||||
// buildSystemPrompt() convention.
|
||||
// AGENTS.md context + skills metadata + date/cwd.
|
||||
{
|
||||
basePrompt := viper.GetString("system-prompt")
|
||||
pb := skills.NewPromptBuilder(basePrompt)
|
||||
@@ -211,8 +920,11 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
viper.Set("system-prompt", pb.Build())
|
||||
}
|
||||
|
||||
// Load MCP configuration. Use pre-loaded config if provided.
|
||||
mcpConfig := opts.MCPConfig
|
||||
// Load MCP configuration. Use pre-loaded config if provided via CLI options.
|
||||
var mcpConfig *config.Config
|
||||
if opts.CLI != nil {
|
||||
mcpConfig = opts.CLI.MCPConfig
|
||||
}
|
||||
if mcpConfig == nil {
|
||||
mcpConfig, err = config.LoadAndValidateConfig()
|
||||
if err != nil {
|
||||
@@ -227,18 +939,25 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
afterToolResult := newHookRegistry[AfterToolResultHook, AfterToolResultResult]()
|
||||
beforeTurn := newHookRegistry[BeforeTurnHook, BeforeTurnResult]()
|
||||
afterTurn := newHookRegistry[AfterTurnHook, AfterTurnResult]()
|
||||
contextPrepare := newHookRegistry[ContextPrepareHook, ContextPrepareResult]()
|
||||
beforeCompact := newHookRegistry[BeforeCompactHook, BeforeCompactResult]()
|
||||
|
||||
// Build agent setup options, pulling CLI-specific fields when available.
|
||||
setupOpts := kitsetup.AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
}
|
||||
if opts.CLI != nil {
|
||||
setupOpts.ShowSpinner = opts.CLI.ShowSpinner
|
||||
setupOpts.SpinnerFunc = opts.CLI.SpinnerFunc
|
||||
setupOpts.UseBufferedLogger = opts.CLI.UseBufferedLogger
|
||||
}
|
||||
|
||||
// Create agent using shared setup with the hook tool wrapper.
|
||||
agentResult, err := SetupAgent(ctx, AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
ShowSpinner: opts.ShowSpinner,
|
||||
SpinnerFunc: opts.SpinnerFunc,
|
||||
UseBufferedLogger: opts.UseBufferedLogger,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
})
|
||||
agentResult, err := kitsetup.SetupAgent(ctx, setupOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,6 +984,8 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
afterToolResult: afterToolResult,
|
||||
beforeTurn: beforeTurn,
|
||||
afterTurn: afterTurn,
|
||||
contextPrepare: contextPrepare,
|
||||
beforeCompact: beforeCompact,
|
||||
}
|
||||
|
||||
// Bridge extension events to SDK hooks.
|
||||
@@ -312,7 +1033,7 @@ func loadContextFiles(cwd string) []*ContextFile {
|
||||
// so, re-reads the skill file, strips its YAML frontmatter, wraps the body in
|
||||
// a <skill> block with baseDir metadata, and appends any trailing user args.
|
||||
// Returns the original text unchanged when the prefix is absent or the skill is
|
||||
// not found. This matches Pi's _expandSkillCommand() convention.
|
||||
// not found.
|
||||
func (m *Kit) expandSkillCommand(prompt string) string {
|
||||
if !strings.HasPrefix(prompt, "/skill:") {
|
||||
return prompt
|
||||
@@ -478,6 +1199,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
|
||||
func(chunk string) {
|
||||
m.events.emit(MessageUpdateEvent{Chunk: chunk})
|
||||
},
|
||||
func(delta string) {
|
||||
m.events.emit(ReasoningDeltaEvent{Delta: delta})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -498,10 +1222,12 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
// <skill> block, and appends any trailing user args.
|
||||
if expanded := m.expandSkillCommand(prompt); expanded != prompt {
|
||||
prompt = expanded
|
||||
// Replace the last user message in preMessages with the expanded text.
|
||||
// Replace the last user message in preMessages with the expanded text,
|
||||
// preserving any file parts (e.g. clipboard images).
|
||||
for i := len(preMessages) - 1; i >= 0; i-- {
|
||||
if preMessages[i].Role == fantasy.MessageRoleUser {
|
||||
preMessages[i] = fantasy.NewUserMessage(expanded)
|
||||
files := extractFileParts(preMessages[i])
|
||||
preMessages[i] = fantasy.NewUserMessage(expanded, files...)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -510,11 +1236,13 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
// Run BeforeTurn hooks — can modify the prompt, inject system/context messages.
|
||||
if m.beforeTurn.hasHooks() {
|
||||
if hookResult := m.beforeTurn.run(BeforeTurnHook{Prompt: prompt}); hookResult != nil {
|
||||
// Override prompt text in the last user message.
|
||||
// Override prompt text in the last user message, preserving
|
||||
// any file parts (e.g. clipboard images).
|
||||
if hookResult.Prompt != nil {
|
||||
for i := len(preMessages) - 1; i >= 0; i-- {
|
||||
if preMessages[i].Role == fantasy.MessageRoleUser {
|
||||
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt)
|
||||
files := extractFileParts(preMessages[i])
|
||||
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt, files...)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -540,11 +1268,19 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
// Auto-compact if enabled and conversation is near the context limit.
|
||||
if m.autoCompact && m.ShouldCompact() {
|
||||
_, _ = m.Compact(ctx, m.compactionOpts, "") // best-effort
|
||||
_, _ = m.compactInternal(ctx, m.compactionOpts, "", true) // best-effort, automatic
|
||||
}
|
||||
|
||||
// Build context from the tree so only the current branch is sent.
|
||||
messages := m.treeSession.GetFantasyMessages()
|
||||
|
||||
// Run ContextPrepare hooks — extensions can filter, reorder, or inject messages.
|
||||
if m.contextPrepare.hasHooks() {
|
||||
if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil {
|
||||
messages = hookResult.Messages
|
||||
}
|
||||
}
|
||||
|
||||
sentCount := len(messages)
|
||||
|
||||
m.events.emit(TurnStartEvent{Prompt: promptLabel})
|
||||
@@ -562,16 +1298,28 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
responseText := result.FinalResponse.Content.Text()
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
|
||||
// Persist new messages (tool calls, tool results, assistant response).
|
||||
// Persist new messages (tool calls, tool results, assistant response)
|
||||
// BEFORE emitting events so that extension handlers calling
|
||||
// GetContextStats() see up-to-date token counts.
|
||||
if len(result.ConversationMessages) > sentCount {
|
||||
for _, msg := range result.ConversationMessages[sentCount:] {
|
||||
_, _ = m.treeSession.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Store the API-reported token count so GetContextStats() matches the
|
||||
// built-in status bar (which uses input + output tokens). The
|
||||
// text-based heuristic misses system prompts, tool definitions, etc.
|
||||
if result.FinalResponse != nil {
|
||||
u := result.FinalResponse.Usage
|
||||
m.lastInputTokensMu.Lock()
|
||||
m.lastInputTokens = int(u.InputTokens) + int(u.OutputTokens)
|
||||
m.lastInputTokensMu.Unlock()
|
||||
}
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
|
||||
// Run AfterTurn hooks.
|
||||
if m.afterTurn.hasHooks() {
|
||||
m.afterTurn.run(AfterTurnHook{Response: responseText})
|
||||
@@ -725,6 +1473,15 @@ func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, er
|
||||
})
|
||||
}
|
||||
|
||||
// PromptResultWithFiles sends a multimodal message (text + images) and returns
|
||||
// the full turn result. The files parameter carries binary file data (e.g.
|
||||
// clipboard images) that are included alongside the text in the user message.
|
||||
func (m *Kit) PromptResultWithFiles(ctx context.Context, message string, files []fantasy.FilePart) (*TurnResult, error) {
|
||||
return m.runTurn(ctx, message, message, []fantasy.Message{
|
||||
fantasy.NewUserMessage(message, files...),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSession resets the tree session's leaf pointer to the root, starting
|
||||
// a fresh conversation branch.
|
||||
func (m *Kit) ClearSession() {
|
||||
@@ -748,11 +1505,44 @@ func (m *Kit) GetModelInfo() *ModelInfo {
|
||||
return LookupModel(provider, modelID)
|
||||
}
|
||||
|
||||
// IsReasoningModel returns true if the current model supports extended thinking / reasoning.
|
||||
func (m *Kit) IsReasoningModel() bool {
|
||||
info := m.GetModelInfo()
|
||||
return info != nil && info.Reasoning
|
||||
}
|
||||
|
||||
// GetThinkingLevel returns the current thinking level.
|
||||
func (m *Kit) GetThinkingLevel() string {
|
||||
return viper.GetString("thinking-level")
|
||||
}
|
||||
|
||||
// SetThinkingLevel changes the thinking level and recreates the agent with
|
||||
// the new thinking budget. Returns an error if provider recreation fails.
|
||||
func (m *Kit) SetThinkingLevel(ctx context.Context, level string) error {
|
||||
viper.Set("thinking-level", level)
|
||||
// Recreate agent with new thinking config by re-running SetModel
|
||||
// with the same model string. SetModel rebuilds the provider and
|
||||
// passes the updated viper config (including thinking-level).
|
||||
return m.SetModel(ctx, m.modelString)
|
||||
}
|
||||
|
||||
// GetTools returns all tools available to the agent (core + MCP + extensions).
|
||||
func (m *Kit) GetTools() []Tool {
|
||||
return m.agent.GetTools()
|
||||
}
|
||||
|
||||
// extractFileParts returns all FilePart entries from a message's Content.
|
||||
// Used to preserve image attachments when replacing user message text.
|
||||
func extractFileParts(msg fantasy.Message) []fantasy.FilePart {
|
||||
var files []fantasy.FilePart
|
||||
for _, part := range msg.Content {
|
||||
if fp, ok := part.(fantasy.FilePart); ok {
|
||||
files = append(files, fp)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// Close cleans up resources including MCP server connections, model resources,
|
||||
// and the tree session file handle. Should be called when the Kit instance is
|
||||
// no longer needed. Returns an error if cleanup fails.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user