Compare commits

...

58 Commits

Author SHA1 Message Date
Ed Zynda 66f2ba543b refactor: align message styling with iteratr conventions
Swap user/assistant border colors (user=blue, assistant=mauve), remove
per-message timestamps and username labels, simplify system messages to
borderless muted text with diamond prefix, change tool name color from
peach to blue, and redesign thinking blocks with surface background,
line truncation, and duration footer.
2026-03-17 15:11:33 +03:00
Ed Zynda 6dd052b990 fix: improve input keybindings, user message rendering, and scrollback ordering
- Change newline keybinding from alt+enter to shift+enter across all
  input components (main input, slash command input, prompt overlay)
- Skip markdown rendering for plain-text user messages so newlines are
  preserved without extra paragraph spacing from glamour
- Fix scrollback ordering: defer queued user message printing to
  SpinnerEvent where previous stream content is guaranteed complete,
  combining flush + user message into a single tea.Println call
2026-03-17 14:23:16 +03:00
Ed Zynda ef8628eecc fix: forward subagent events to parent event bus in core spawn_subagent tool
The spawner closure in generate() called m.Subagent() without setting
OnEvent, so child events (tool calls, text streaming, reasoning deltas)
were silently discarded. Wire OnEvent to re-emit on the parent's bus,
matching the behavior already present in the extension SpawnSubagent path.
2026-03-17 13:03:41 +03:00
Ed Zynda 3167222b72 fix: gracefully recover from bad model names in subagents
If the requested model fails (bad name, unsupported provider), fall
back to the parent's model instead of returning a hard error. The
original prompt is prepended with a note so the agent knows which
model is actually running and can adjust future calls.
2026-03-16 13:43:52 +03:00
Ed Zynda e3b37191b1 fix: inherit parent provider for bare model names in subagents
When spawn_subagent is called with a model name like 'claude-haiku'
(no provider prefix), prepend the parent's provider instead of letting
ParseModelString guess. Only full 'provider/model' strings bypass this.
2026-03-16 13:41:02 +03:00
Ed Zynda 41d5f5e0fb feat: add OnEvent callback for real-time subagent event streaming
Add SubagentEvent type to extension API and OnEvent field to
SubagentConfig so extensions can watch subagent tool calls, text
chunks, reasoning deltas, and turn lifecycle events in real time.

The SDK's Kit.Subagent() already had OnEvent via kit.SubagentConfig.
This wires it through to the extension layer with a concrete
SubagentEvent struct (Yaegi-safe) and bridges SDK events to it
in both cmd/root.go and the ACP server.
2026-03-16 13:06:53 +03:00
Ed Zynda 3ad0b3616d fix: surface SubagentSessionID in ToolResultMetadata
The subagent_session_id was already attached to the fantasy response
metadata by internal/core/subagent.go but ToolResultMetadata had no
field for it, so json.Unmarshal silently dropped it. Add the field
so SDK consumers can detect subagent tools and load their sessions.
2026-03-16 13:01:34 +03:00
Ed Zynda 8831b49b51 feat: in-process subagents replace subprocess spawning
Subagents now run as child Kit instances in the same process instead of
spawning a kit binary subprocess. This removes the binary dependency,
eliminates JSON serialization overhead, and enables SDK-only consumers
to use subagents without installing the kit CLI.

- Add Kit.Subagent() method for in-process subagent execution
- Add SubagentConfig/SubagentResult types to the SDK
- Add context-based SubagentSpawnFunc injection so core spawn_subagent
  tool calls back to Kit.Subagent() without an import cycle
- Add SubagentTools() bundle (all core tools minus spawn_subagent)
- Add viperInitMu for thread-safe concurrent kit.New() calls
- Wire extension ctx.SpawnSubagent and ACP server to use in-process
- Child Kit gets parent's model as fallback, in-memory or persisted
  session, and no extensions (preventing recursive loading)
2026-03-16 11:39:59 +03:00
Ed Zynda c94edc929b feat: add rich tool metadata to SDK and extension events (Gaps 1-8)
Thread ToolCallID, ToolKind, ParsedArgs, FileDiff metadata, StopReason,
SessionID, and StructuredMessages across the SDK event bus, extension
wrapper, app bridge, hooks, and ACP server layers.

- Gap 1: ToolCallID from Fantasy's ToolCallContent threaded end-to-end
- Gap 2: ToolKind via static lookup (execute/edit/read/search/agent)
- Gap 3+4: FileDiffInfo with DiffBlocks via fantasy.ToolResponse.Metadata
- Gap 5: StopReason from Fantasy FinishReason on TurnEndEvent/TurnResult
- Gap 6: Subagent sessions now opt-out (NoSession); SessionID in JSON output
- Gap 7: GetStructuredMessages() returns typed ContentParts
- Gap 8: ParsedArgs map[string]any on tool events for convenience

Edit/write tools attach structured diff metadata. ACP server uses real
ToolCallIDs. Extension and SDK events kept in sync with matching fields.
2026-03-16 11:10:05 +03:00
Ed Zynda e49194a0d4 fix(acp): wire extension context so extensions work in ACP mode
Extensions were loaded but non-functional in ACP because
SetExtensionContext was never called. Wire a headless context with
no-op TUI stubs, functional data/model/tool APIs, and emit
SessionStart so extension lifecycle hooks fire during ACP sessions.
2026-03-15 15:29:08 +03:00
Ed Zynda 46b1acf444 fix 2026-03-15 15:10:02 +03:00
Ed Zynda 6a6d201a50 add LSP diagnostics example extension
Adds an extension that starts language servers on demand and surfaces
diagnostics after file edits, following crush's LSP integration pattern.
Hooks into the edit tool lifecycle to diff pre/post diagnostics, display
a persistent widget, and expose lsp_diagnostics/lsp_hover tools plus
/lsp and /lsp-check slash commands.
2026-03-15 14:29:27 +03:00
Ed Zynda 930cbcb4f2 fix: use full GitHub URLs for file references in kit-extensions skill 2026-03-15 13:01:05 +03:00
Ed Zynda 12e1ef2036 skills 2026-03-15 12:55:47 +03:00
Ed Zynda a05da5f3ab fix(auth): support OAuth credentials in ACP mode and auto-refresh tokens
Remove the early ValidateEnvironment gate from CreateProvider that only
checked env vars and --provider-api-key, blocking stored OAuth credentials
from working. Each provider creation function already handles its own auth
resolution with clear error messages.

Update ValidateEnvironment to also check stored Anthropic credentials so
the model selector UI correctly shows Anthropic models for OAuth users.

Add automatic token refresh in oauthTransport so long-lived ACP sessions
survive token renewals. Surface actionable auth error messages in ACP
session creation.

Fix pre-existing staticcheck SA5011 warnings in test files.
2026-03-15 12:38:23 +03:00
Ed Zynda fefbf19b42 fix(acp): default mcpServers to empty array for clients that omit it 2026-03-15 11:57:30 +03:00
Ed Zynda 93905d4d77 fix(acp): remove startup message from stdio output 2026-03-15 11:38:31 +03:00
Ed Zynda 7268ccdf4d perf(ui): throttle stream rendering with chunk coalescing and render cache
Streaming chunks now accumulate in a pending buffer and flush on a 16ms
tick (~60fps) instead of triggering a full markdown re-render on every
chunk. Between flushes, View() returns a cached string — no markdown
parsing, no lipgloss styling, no terminal escape sequence churn. This is
especially impactful for inline rendering (no alt screen) where each
frame requires cursor repositioning across the full view height.
2026-03-15 11:36:04 +03:00
Ed Zynda 9f59fa42dc fix: resolve golangci-lint issues
- Use strings.Cut instead of strings.Index (modernize)
- Remove unused session registry methods (load, remove)
2026-03-14 17:30:36 +03:00
Ed Zynda 8af7ca8455 refactor(ui): simplify tool names in spinner display
Show 'Subagent' instead of 'spawn_subagent' and remove 'Executing' prefix
for cleaner parallel tool status display.
2026-03-14 17:25:40 +03:00
Ed Zynda 424847f0db feat: enable parallel tool execution with multi-tool status display
- Mark read-only core tools as parallel-safe (read, grep, find, ls)
- Mark spawn_subagent as parallel-safe for concurrent task delegation
- Update UI to track multiple active tools during parallel execution
- Display 'Running: tool1, tool2, ...' in spinner for concurrent tools
- Add test for parallel tool execution scenarios

Fantasy already supports parallel execution via ToolInfo.Parallel field.
Tools marked parallel run concurrently (up to 5 at a time).
2026-03-14 17:24:20 +03:00
Ed Zynda 4c126ca41b feat(ui): show clean summary for subagent results instead of raw output
- Add custom renderer for spawn_subagent tool showing status + 3-line preview
- Pass toolArgs through ToolExecutionEvent to show task in spinner
- Display 'Subagent: <task>' during execution instead of generic message
- Compact mode shows concise one-line status summary
2026-03-14 17:04:50 +03:00
Ed Zynda 4bdc4f75cc chore: remove openspec directory 2026-03-09 23:10:15 +03:00
Ed Zynda bbd8975ca0 feat: add first-class subagent support for task delegation
Implement 4-phase subagent system enabling LLM and extensions to spawn,
manage, and orchestrate child Kit instances for parallel task execution.

- Phase 1: SDK API with SpawnSubagent() for extensions
- Phase 2: spawn_subagent core tool for LLM usage
- Phase 3: Session hierarchy with ParentSessionID tracking
- Phase 4: Provider pooling for concurrent model access

New files:
- internal/extensions/subagent.go: SpawnSubagent implementation
- internal/core/subagent.go: Core tool definition
- internal/models/pool.go: Provider pool for concurrency
- examples/extensions/subagent-test.go: Test extension
- openspec/subagent-support.md: Design specification
2026-03-09 23:07:27 +03:00
Ed Zynda e613a07773 feat: add ACP server mode (kit acp)
Implement Agent Client Protocol server allowing ACP-compatible clients
(e.g. OpenCode) to drive Kit as a remote coding agent over stdio.

- internal/acpserver/agent.go: acp.Agent implementation bridging Kit's
  LLM execution, tool system, and event bus to ACP session updates
- internal/acpserver/session.go: session registry mapping ACP sessions
  to persisted Kit JSONL tree sessions
- cmd/acp.go: cobra subcommand wiring stdio JSON-RPC connection
- Add acp-go-sdk dependency, update README with ACP docs
2026-03-09 21:41:10 +03:00
Ed Zynda 1d3b4f8d56 feat: add skill subcommand to install kit-extensions skill via skills.sh 2026-03-09 14:24:09 +03:00
Ed Zynda 118af2e152 fix: clear conflicting temperature/top_p for Anthropic API
Anthropic rejects requests with both temperature and top_p set.
When both are configured (typically from defaults), clear top_p
so temperature takes precedence.
2026-03-09 10:26:41 +03:00
Ed Zynda c46687fc44 fix: pass image file parts through Fantasy agent's Files field
splitPromptAndHistory was extracting only text from the last user
message, discarding FilePart data (clipboard images). The fix extracts
both text and file parts, passing files via AgentStreamCall.Files and
AgentCall.Files so Fantasy includes them in the API request.

Also preserves file parts when BeforeTurn hooks or skill expansion
replace the user message text in runTurn.
2026-03-09 10:26:31 +03:00
Ed Zynda aeaa5368af fix: use max() builtin to satisfy modernize lint 2026-03-08 11:43:37 +03:00
Ed Zynda 4966c0ca2a feat: add clipboard image paste support (Ctrl+V)
Add multimodal image support so users can paste clipboard images into
prompts alongside text. Images are read from the system clipboard via
platform-specific tools and sent as fantasy.FilePart to the LLM API.

- New internal/clipboard package with platform-specific image readers:
  Linux: xclip (X11) with wl-paste (Wayland) fallback
  macOS: osascript with AppKit NSPasteboard
  Magic byte detection for PNG/JPEG/GIF/WebP/BMP/TIFF
- New ImageContent type in message model with full serialization and
  Fantasy bridge support (ImageContent <-> fantasy.FilePart)
- InputComponent handles Ctrl+V (paste image), Ctrl+U (clear images),
  shows attachment indicator, and carries images through submitMsg
- App layer queue upgraded from []string to []queueItem to carry files
  alongside prompts through the drain loop
- Kit SDK gains PromptResultWithFiles() for multimodal user messages
- AppController interface extended with RunWithFiles()
2026-03-08 11:37:21 +03:00
Ed Zynda f3ea18ae3a feat: add thinking model support with configurable reasoning levels
Add extended thinking/reasoning support for Anthropic and OpenAI models:

- ThinkingLevel type (off/minimal/low/medium/high) with token budgets
- Stream reasoning deltas via OnReasoningDelta through SDK→TUI event pipeline
- Render thinking blocks in StreamComponent (muted italic, collapsible)
- ctrl+t toggles thinking visibility, shift+tab cycles thinking level
- /thinking slash command with tab-completion for level names
- --thinking-level CLI flag and config file support
- Map ThinkingLevel to OpenAI ReasoningEffort for Responses API
- Auto-bump Anthropic max_tokens when thinking budget exceeds it
- Fix ResponseCompleteEvent prematurely resetting stream in streaming mode
- Status bar displays current thinking level
2026-03-07 21:27:46 +03:00
Ed Zynda 24ea2c94e3 feat: add OpenAI Responses API support for codex/gpt-5/o3/o4 models
Enable fantasy's Responses API path (WithUseResponsesAPI) for the OpenAI
provider so that models like gpt-5.3-codex, codex-mini-latest, o3, o4-mini,
and other Responses-only models work correctly.

- Enable WithUseResponsesAPI on both createOpenAIProvider and
  createAutoRoutedOpenAIProvider
- Build provider options for reasoning models (reasoning_summary, encrypted
  reasoning content) matching crush's coordinator behaviour
- Thread ProviderOptions from provider creation through to the fantasy agent
  in NewAgent, SetModel, and the SDK Complete path
- Pass generation parameters (Temperature, MaxTokens, TopP, TopK) to the
  fantasy agent for all providers (previously only Ollama)
- Fix extension tool schema for Responses API: parse Parameters JSON Schema
  string into fantasy ToolInfo format, ensure Required is never nil (OpenAI
  rejects null, expects empty array)
2026-03-07 11:03:10 +03:00
Ed Zynda 4577d218d3 feat: add /model slash command with interactive fuzzy-finding selector
Add /model command that allows switching LLM models mid-session.
When invoked without arguments, opens a full-screen selector overlay
showing only models with configured API keys, with inline fuzzy search,
cursor navigation, and current model indicator. When invoked with an
argument (e.g. /model anthropic/claude-haiku-4-5), switches directly.

Also upgrades all Go dependencies to latest versions.
2026-03-06 18:50:32 +03:00
Ed Zynda bd48457b27 fix: resolve golangci-lint modernize and staticcheck warnings 2026-03-06 15:40:29 +03:00
Ed Zynda 84298a0743 fix: add 20-line display truncation for shell command output
Match the tool result renderer behavior — show first 20 lines
with a '...(N more lines)' hint. Full output still goes to
context (with TruncateTail limits) for ! commands.
2026-03-05 19:31:22 +03:00
Ed Zynda 393074447b fix: truncate shell command output in TUI using same limits as core bash tool 2026-03-05 19:24:49 +03:00
Ed Zynda 879723fe90 feat: add ! and !! shell command prefixes (matching pi behavior)
! runs a shell command with output included in LLM context.
!! runs a shell command with output excluded from LLM context.
Adds AddContextMessage to AppController for injecting messages
without triggering an LLM turn.
2026-03-05 19:17:41 +03:00
Ed Zynda 57250a3a3d refactor: remove --prompt flag, positional args are the only way
Drop the --prompt/-p flag entirely. Non-interactive mode is now
triggered by passing positional arguments:

  kit "Explain this"
  kit @file.go "Review this" --json
  kit @a.go @b.go --quiet

Updated extension examples (kit-kit.go, subagent-widget.go) to pass
the prompt as a positional arg. Updated AGENTS.md and README.md.
2026-03-05 19:03:47 +03:00
Ed Zynda 7e1686e572 feat: positional args as primary non-interactive mode, hide --prompt
Positional args are now the main way to run non-interactive mode:

  kit "Explain this codebase"
  kit @code.ts @test.ts "Review these files"
  kit @go.mod "What module?" --quiet

--prompt is hidden but still works for subprocess compat (extensions
spawn kit with --prompt internally). Updated --quiet/--json/--no-exit
error messages to reference the new positional arg pattern.
2026-03-05 19:00:51 +03:00
Ed Zynda 4a8b10cde7 feat: support Pi-style positional @file args
Enables: kit @code.ts @test.ts "Review these files"

Positional args starting with @ are treated as file attachments —
their content is read and prepended to the prompt. Remaining
positional args are joined as the prompt text. Works alongside
--prompt flag (files prepended, extra text appended).
2026-03-05 18:57:00 +03:00
Ed Zynda cc5611eff7 feat: support @file references in non-interactive mode (--prompt) 2026-03-05 18:54:17 +03:00
Ed Zynda 51c70b63a7 feat: add @file autocomplete and context attachment
Type @ in the input to trigger a fuzzy file picker popup. Files are
discovered via git ls-files (with os.ReadDir fallback), scored by
fuzzy match, and displayed in the existing autocomplete popup.

Tab/Enter inserts the selected path; directories keep the popup open
for drilling. On submit, @file tokens are expanded into XML-wrapped
file content before being sent to the agent. No CWD restriction —
supports ~/, ../, and absolute paths.
2026-03-05 18:46:25 +03:00
Ed Zynda c9ee80d98a fix: run before-hook callbacks in goroutines to prevent TUI deadlock
Before-hook callbacks (OnBeforeSessionSwitch, OnBeforeFork) were called
synchronously inside BubbleTea's Update(), so extensions that used
blocking prompts (ctx.PromptConfirm) would deadlock — the channel read
waited for Update() to process the PromptRequestEvent, but Update()
was blocked on that same channel read.

Run hooks in dedicated goroutines and deliver results via SendEvent,
matching the pattern already used by extension slash commands.
2026-03-05 10:34:17 +03:00
Ed Zynda 3ecedcbc2d docs: add comprehensive README with CLI reference, extensions, SDK, and configuration guide 2026-03-03 18:33:42 +03:00
Ed Zynda dbfa410fc1 fix: use strings.Builder instead of string += in loops 2026-03-02 20:25:07 +03:00
Ed Zynda 512ecb92dc cleanup 2026-03-02 20:05:37 +03:00
Ed Zynda aede76d807 feat: add TUI suspend, custom message rendering, and extension hot-reload
- ctx.SuspendTUI(callback): releases terminal for interactive subprocesses
  (vim, shell, htop), automatically restores TUI when callback returns.
  Uses BubbleTea v2 ReleaseTerminal/RestoreTerminal.

- api.RegisterMessageRenderer(config) + ctx.RenderMessage(name, content):
  named render functions for branded/styled extension output. Renderers
  receive content and terminal width, return ANSI-styled strings.

- ctx.ReloadExtensions(): hot-reloads all extensions from disk. Emits
  SessionShutdown to old extensions, reloads source, emits SessionStart
  to new. Event handlers, commands, renderers, shortcuts update immediately.
  TUI command list refreshes via WidgetUpdateEvent. Extension tools are
  NOT updated (baked into agent at creation, documented limitation).

New example extensions: interactive-shell.go, branded-output.go, dev-reload.go
2026-03-02 19:32:19 +03:00
Ed Zynda 9e1df38836 feat: add keyboard shortcuts, tool context, and ToolCallEvent source field
- RegisterShortcut(ShortcutDef, handler) for global keyboard shortcuts
  that fire across all non-modal app states (after ctrl+c, before
  component dispatch). Handlers run in goroutines for safe blocking calls.
- ToolContext with IsCancelled/OnProgress for rich tool execution;
  ExecuteWithContext on ToolDef takes priority over simple Execute.
- Source field on ToolCallEvent (currently "llm", forward-compatible
  with future user-initiated tool calls).
- Fix missing //go:build ignore on context-inject.go.
- Update plan-mode.go to register ctrl+alt+p shortcut.
2026-03-02 19:04:37 +03:00
Ed Zynda 8f5efee837 feat: add session before-hooks (OnBeforeFork, OnBeforeSessionSwitch) and compaction event (OnBeforeCompact)
Add three new extension events that allow extensions to gate destructive
session operations and compaction:

- OnBeforeFork: fires before branching in the tree selector; handler can
  cancel with reason (e.g. dirty-repo guard)
- OnBeforeSessionSwitch: fires before /new resets the session branch;
  handler can cancel with reason
- OnBeforeCompact: fires before context compaction (auto or manual);
  handler receives token stats and IsAutomatic flag, can cancel

Includes SDK hook registry (beforeCompact), extension bridge, UI
callbacks threaded through AppModelOptions, and two example extensions:
- confirm-destructive.go: git dirty check + fork confirmation
- compact-notify.go: compaction notification + auto-compact gating
2026-03-02 16:35:00 +03:00
Ed Zynda a392d3e572 feat: add OnContextPrepare event for context window filtering and injection
Extensions can now register an OnContextPrepare handler that fires after
the context window is built from the session tree and before messages are
sent to the LLM. Handlers receive ContextMessage entries with positional
indices and can filter, reorder, or inject messages. Original messages
referenced by index preserve tool calls, reasoning, and other complex
parts. New context-inject example extension demonstrates injecting a
local .kit/context.md file as an ephemeral system message every turn.
2026-03-02 15:56:08 +03:00
Ed Zynda c40dc2f4fb feat: add argument tab-completion for extension slash commands
Extensions can now provide a Complete function on CommandDef that supplies
argument suggestions. When the user types a command name followed by a space,
the input popup switches to argument-completion mode, calling Complete with
the partial text and displaying matching suggestions.
2026-03-02 15:37:52 +03:00
Ed Zynda 37e82781b1 feat: add OnModelChange event and ctx.Exit(); remove Gap/Pi references from comments 2026-03-02 14:49:51 +03:00
Ed Zynda 23c16bb197 feat: add tool mgmt, model mgmt, options, event bus, LLM completion, steer mode, and 10 example extensions
Phase 2+3 extension API additions:
- Tool management: GetAllTools, SetActiveTools (plan-mode support)
- Model management: SetModel, GetAvailableModels, ModelChangedEvent
- Extension options: RegisterOption, GetOption, SetOption (env/config/default)
- Inter-extension event bus: OnCustomEvent, EmitCustomEvent
- Direct LLM completion: ctx.Complete with streaming/blocking modes
- Steer delivery mode: CancelAndSend for interrupt-and-redirect

New example extensions (10):
- plan-mode.go: read-only exploration with /plan toggle
- summarize.go: conversation summarization via ctx.Complete
- bookmark.go: persistent bookmarks via AppendEntry/GetEntries
- auto-commit.go: auto-commit on exit using last assistant message
- permission-gate.go: confirm dangerous bash commands
- protected-paths.go: block writes to .env, .git/, secrets/
- notify.go: desktop notifications on agent completion
- inline-bash.go: !{cmd} expansion in prompts
- pirate.go: system prompt persona injection
- project-rules.go: load .kit/rules/*.md into system prompt

Always-wrap tools through runner for SetActiveTools disabled-tool checking.
Removed phase1/phase2 test extensions from examples.
2026-03-02 14:31:35 +03:00
Ed Zynda 9449f1fcdf feat: add session management, persistence, editor text, and status bar APIs for extensions
Implement Phase 1 extension API gaps identified in the pi-mono gap analysis:

- Gap 1: Session Management API (GetMessages, GetSessionPath) — read-only
  access to conversation history from extensions
- Gap 2: Session Persistence (AppendEntry, GetEntries) — custom extension
  data survives across session restarts via new ExtensionDataEntry type
- Gap 10: SetEditorText — extensions can pre-fill the input editor
- Gap M3: Keyed Status Bar (SetStatus, RemoveStatus) — multiple extensions
  can place independent entries in the TUI status bar, ordered by priority
2026-03-02 01:33:56 +03:00
Ed Zynda dc59cfc81e feat: add --json output mode for --prompt and update subagent extensions
Add a --json flag that outputs structured JSON (response, model, usage,
messages with typed parts) when used with --prompt. Update kit-kit and
subagent-widget extensions to use --json for cleaner subprocess output
parsing instead of raw text heuristics.
2026-03-01 21:16:34 +03:00
Ed Zynda 8407d924b9 feat: add UIVisibility, GetContextStats APIs and compact tool renderers
- Add ctx.SetUIVisibility() to toggle built-in TUI chrome (startup
  message, status bar, separator, input hint) from extensions
- Add ctx.GetContextStats() returning accurate API-reported token counts
  instead of text-based heuristic; fix event ordering so extension
  handlers see up-to-date conversation state
- Add compact tool body renderers for compact mode: Read/Edit/Write/Ls
  show one-line summaries, Bash shows first 3 lines instead of full
  20-line syntax-highlighted output
- Add minimal.go example extension using UIVisibility + GetContextStats
2026-03-01 15:24:48 +03:00
Ed Zynda 91474af503 fix: remove line-number gutter from ls tool output
Ls output is a plain file list with no line numbers, so the empty
gutter column was wasted space. Give ls its own renderer that shows
a clean list with just the code background.
2026-03-01 13:41:35 +03:00
Ed Zynda e252791b3a ci: move discord notification after both goreleaser and npm publish 2026-03-01 02:15:37 +03:00
103 changed files with 13867 additions and 689 deletions
+853
View File
@@ -0,0 +1,853 @@
---
name: kit-extensions
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
---
# Kit Extensions Development Guide
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
## Extension Structure
Every extension must export a `package main` with an `Init(api ext.API)` function:
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
// Register event handlers, tools, commands, etc.
}
```
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
## Extension Locations
Extensions are auto-loaded from these directories:
- `~/.config/kit/extensions/*.go` (global, single files)
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
- `.kit/extensions/*.go` (project-local, single files)
- `.kit/extensions/*/main.go` (project-local, subdirectories)
Or loaded explicitly:
```bash
kit -e path/to/extension.go
kit --extension path/to/extension.go
```
## Import Path
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
## API Overview
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
---
## Lifecycle Events
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
### Session Events
```go
// Fired when session is loaded/created.
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
// e.SessionID string
})
// Fired when Kit is shutting down. Use for cleanup.
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
// No fields.
})
```
### Agent Turn Events
```go
// Before agent starts processing. Can inject system prompt or text.
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
// e.Prompt string
// Return nil to pass through.
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
return nil
})
// Agent loop has started.
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
// e.Prompt string
})
// Agent finished responding.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// e.Response string
// e.StopReason string — "completed", "cancelled", "error"
})
```
### Tool Events
```go
// Before a tool executes. Can block the call.
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
// e.ToolName string
// e.ToolCallID string
// e.Input string — JSON-encoded parameters
// e.Source string — "llm" or "user"
// Return nil to allow.
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
return nil
})
// Tool execution started (informational only).
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
// e.ToolName string
})
// Tool execution ended (informational only).
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
// e.ToolName string
})
// After a tool returns. Can modify the result.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
// e.ToolName string
// e.Input string
// e.Content string
// e.IsError bool
// Return nil to pass through.
// Return &ext.ToolResultResult{Content: &s} to replace content.
// Return &ext.ToolResultResult{IsError: &b} to change error status.
return nil
})
```
### Input Events
```go
// User submitted input. Can handle or transform it.
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
// e.Text string
// e.Source string — "interactive", "cli", "script", "queue"
// Return nil to pass through to agent.
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
return nil
})
```
### Streaming Events
```go
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
// e.Chunk string — streaming text chunk
})
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
// e.Content string — full message content
})
```
### Model Events
```go
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
// e.NewModel string
// e.PreviousModel string
// e.Source string — "extension" or "user"
})
```
### Context Filtering
```go
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
// e.Messages []ext.ContextMessage
// Each ContextMessage has: Index int, Role string, Content string
// Index -1 means a new injected message (not from session).
// Return nil to pass through.
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
return nil
})
```
### Session Control Events
```go
// Before forking the session tree. Can cancel.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
// e.TargetID string, e.IsUserMessage bool, e.UserText string
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
})
// Before switching/clearing session. Can cancel.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
// e.Reason string — "new" or "clear"
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
})
// Before context compaction. Can cancel.
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
// e.EstimatedTokens, e.ContextLimit int
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
})
```
### Custom Events
```go
// Subscribe to custom events emitted by other extensions.
api.OnCustomEvent("event-name", func(data string) {
// data is arbitrary string payload
})
// Emit from Context:
ctx.EmitCustomEvent("event-name", "payload")
```
---
## Registering Tools
Tools are functions the LLM can invoke:
```go
api.RegisterTool(ext.ToolDef{
Name: "current_time",
Description: "Get the current date and time",
Parameters: `{"type":"object","properties":{}}`,
Execute: func(input string) (string, error) {
return time.Now().Format(time.RFC3339), nil
},
})
```
For long-running tools with cancellation and progress:
```go
api.RegisterTool(ext.ToolDef{
Name: "slow_task",
Description: "A long-running task with progress reporting",
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
for i := 0; i < 10; i++ {
if tc.IsCancelled() {
return "cancelled", nil
}
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
time.Sleep(time.Second)
}
return "done", nil
},
})
```
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
---
## Registering Slash Commands
Commands are user-facing actions invoked with `/name` in the input:
```go
api.RegisterCommand(ext.CommandDef{
Name: "echo",
Description: "Echo back the provided text",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.PrintInfo("You said: " + args)
return "", nil
},
// Optional tab-completion:
Complete: func(prefix string, ctx ext.Context) []string {
return []string{"hello", "world"}
},
})
```
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
---
## Registering Keyboard Shortcuts
```go
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handler runs when shortcut is pressed
})
```
---
## Registering Options
Options are configurable values resolved from env vars, config, or defaults:
```go
api.RegisterOption(ext.OptionDef{
Name: "my-setting",
Description: "Controls something",
Default: "false",
})
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
val := ctx.GetOption("my-setting")
// Set at runtime:
ctx.SetOption("my-setting", "true")
```
---
## Context API Reference
The `ext.Context` struct provides runtime capabilities via function fields.
### Output
```go
ctx.Print("plain text") // plain output
ctx.PrintInfo("styled info block") // bordered info block
ctx.PrintError("styled error block") // red error block
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
Text: "content",
BorderColor: "#a6e3a1",
Subtitle: "my-ext",
})
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
```
### Message Injection
```go
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
```
### Widgets
Persistent UI elements displayed above or below the input area:
```go
ctx.SetWidget(ext.WidgetConfig{
ID: "my-widget",
Placement: ext.WidgetAbove, // or ext.WidgetBelow
Content: ext.WidgetContent{
Text: "Status: Active",
Markdown: false, // set true for markdown rendering
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1", // hex color
NoBorder: false,
},
Priority: 0, // lower values render first
})
ctx.RemoveWidget("my-widget")
```
### Header and Footer
```go
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Header"},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
ctx.RemoveHeader()
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Footer"},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
ctx.RemoveFooter()
```
### Status Bar
```go
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
ctx.RemoveStatus("key")
```
### Interactive Prompts
These block until the user responds (safe in slash commands and goroutines):
```go
// Selection list
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Pick one:",
Options: []string{"Option A", "Option B", "Option C"},
})
if !result.Cancelled {
// result.Value string, result.Index int
}
// Yes/No confirmation
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure?",
DefaultValue: false,
})
if !result.Cancelled {
// result.Value bool
}
// Text input
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter name:",
Placeholder: "my-project",
Default: "",
})
if !result.Cancelled {
// result.Value string
}
```
### Overlay Dialogs
Modal dialogs with optional action buttons:
```go
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Confirmation",
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
Width: 60, // 0 = 60% of terminal width
MaxHeight: 20, // 0 = 80% of terminal height
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
Actions: []string{"Confirm", "Cancel"},
})
if !result.Cancelled {
// result.Action string, result.Index int
}
```
### Editor Interceptor
Wrap the built-in text input with custom key handling and rendering:
```go
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
if key == "ctrl+s" {
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
Render: func(width int, defaultContent string) string {
return "[custom] " + defaultContent
},
})
ctx.ResetEditor() // remove interceptor
ctx.SetEditorText("prefilled") // set editor text content
```
**EditorKeyAction types:**
- `ext.EditorKeyPassthrough` — let the default editor handle the key
- `ext.EditorKeyConsumed` — swallow the key, do nothing
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
### UI Visibility
```go
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
```
### Session Data
```go
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
path := ctx.GetSessionPath() // file path of session JSONL
// Persist custom data in the session tree:
id, err := ctx.AppendEntry("my-type", "data string")
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
```
### Model Management
```go
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
```
### Tool Management
```go
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
ctx.SetActiveTools(nil) // re-enable all tools
```
### LLM Completions
Make standalone LLM calls (bypasses the agent tool loop):
```go
resp, err := ctx.Complete(ext.CompleteRequest{
Model: "", // empty = current model
System: "You are ...", // optional system prompt
Prompt: "Summarize...", // the prompt
MaxTokens: 1000, // 0 = provider default
OnChunk: func(chunk string) { /* streaming */ },
})
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
```
### TUI Suspension
Temporarily release the terminal for interactive subprocesses:
```go
ctx.SuspendTUI(func() {
cmd := exec.Command("vim", "file.go")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
```
### Application Control
```go
ctx.Exit() // graceful shutdown
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
```
### Context Fields
```go
ctx.SessionID // string
ctx.CWD // string — current working directory
ctx.Model // string — active model name
ctx.Interactive // bool — true if running in TUI mode
```
---
## Tool Renderers
Customize how tool calls are displayed in the TUI:
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell", // replaces auto-capitalized name
BorderColor: "#89b4fa",
Background: "",
BodyMarkdown: true, // render body through markdown
RenderHeader: func(toolArgs string, width int) string {
var args struct{ Command string `json:"command"` }
json.Unmarshal([]byte(toolArgs), &args)
return "$ " + args.Command
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "ERROR: " + toolResult
}
return toolResult
},
})
```
## Message Renderers
Define named output styles for `ctx.RenderMessage()`:
```go
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
return " " + content // green checkmark prefix
},
})
// Usage in handlers:
ctx.RenderMessage("success", "All tests passed")
```
---
## Critical Yaegi Constraints
### No Named Function References in Struct Fields
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
```go
// WRONG - will silently return zero values:
func myHandler(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
// CORRECT - use anonymous closure:
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
```
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
### No Interfaces Across the Boundary
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
### Package-Level Variables for State
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
```go
package main
import "kit/ext"
var callCount int
var lastTool string
func Init(api ext.API) {
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
callCount++
lastTool = e.ToolName
return nil
})
}
```
---
## Common Patterns
### Pattern: Tool Call Blocking
Block dangerous operations by intercepting tool calls:
```go
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "bash" {
var input struct{ Command string `json:"command"` }
json.Unmarshal([]byte(tc.Input), &input)
if strings.Contains(input.Command, "rm -rf") {
return &ext.ToolCallResult{
Block: true,
Reason: "Dangerous command blocked",
}
}
}
return nil
})
```
### Pattern: System Prompt Injection
Augment the agent's behavior by injecting instructions:
```go
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
prompt := "Always respond with bullet points."
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
```
### Pattern: Background Processing with SendMessage
Run work in a goroutine and inject results back:
```go
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command in the background",
Execute: func(args string, ctx ext.Context) (string, error) {
go func() {
out, err := exec.Command("sh", "-c", args).CombinedOutput()
if err != nil {
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
return
}
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
}()
return "Running in background...", nil
},
})
```
### Pattern: Ephemeral Context Injection
Inject information into every LLM turn without persisting in session history:
```go
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
data, err := os.ReadFile(".kit/context.md")
if err != nil {
return nil
}
injected := ext.ContextMessage{
Index: -1, // -1 = new message, not from session
Role: "system",
Content: string(data),
}
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
```
### Pattern: Live Widget Updates
Update a widget periodically from a goroutine:
```go
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetWidget(ext.WidgetConfig{
ID: "clock",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
}()
})
```
### Pattern: Spawning Kit as a Sub-Agent
Extensions can spawn Kit as a subprocess for delegation:
```bash
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
---
## Testing Extensions
```bash
# Validate syntax of all discovered extensions
kit extensions validate
# List loaded extensions
kit extensions list
# Run with a specific extension
kit -e path/to/extension.go
# Run with multiple extensions
kit -e ext1.go -e ext2.go
# Disable all extensions
kit --no-extensions
# Generate an example extension scaffold
kit extensions init
```
---
## Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
```go
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
func Init(api ext.API) {
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if strings.ToLower(ctx.GetOption("plan")) == "true" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
Focus on understanding, analysis, and generating plans.`
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
}
func applyMode(ctx ext.Context, active bool, tools []string) {
if active {
ctx.SetActiveTools(tools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON")
} else {
ctx.SetActiveTools(nil)
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF")
}
}
```
## Key Files for Reference
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
+33 -29
View File
@@ -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 }}
+2 -2
View File
@@ -83,9 +83,9 @@ tmux kill-session -t kittest # cleanup
### Non-Interactive Kit (Subprocess Spawning)
Extensions can spawn Kit as a subprocess for sub-agent patterns:
```bash
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
kit --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model "question"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
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)
+514 -1
View File
@@ -13,4 +13,517 @@
# KIT (Knowledge Inference Tool)
TBD
A powerful, extensible AI coding agent CLI with multi-provider support, built-in tools, and a rich extension system.
## Features
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls - no MCP overhead
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK**: Embed Kit in your own applications
## Installation
### Using npm (recommended)
```bash
npm install -g @mark3labs/kit
```
### Using Go
```bash
go install github.com/mark3labs/kit/cmd/kit@latest
```
### Building from source
```bash
git clone https://github.com/mark3labs/kit.git
cd kit
go build -o kit ./cmd/kit
```
## Quick Start
### Basic Usage
```bash
# Start interactive session
kit
# Run a one-off prompt
kit "List files in src/"
# Attach files as context
kit @main.go @test.go "Review these files"
# Continue the most recent session
kit --continue
# Use specific model
kit --model anthropic/claude-sonnet-4-5-20250929
```
### Non-Interactive Mode
```bash
# Get JSON output for scripting
kit "Explain main.go" --json
# Quiet mode (final response only)
kit "Run tests" --quiet
# Ephemeral mode (no session file)
kit "Quick question" --no-session
```
### ACP Server Mode
Kit can run as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server, enabling ACP-compatible clients (such as [OpenCode](https://github.com/sst/opencode)) to drive Kit as a remote coding agent over stdio.
```bash
# Start Kit as an ACP server (communicates via JSON-RPC 2.0 on stdin/stdout)
kit acp
# With debug logging to stderr
kit acp --debug
```
The ACP server exposes Kit's full capabilities — LLM execution, tool calls (bash, read, write, edit, grep, etc.), and session persistence — over the standard ACP protocol. Sessions are persisted to Kit's normal JSONL session files, so they can be resumed later.
## Configuration
Kit looks for configuration in the following locations (in order of priority):
1. CLI flags
2. Environment variables (with `KIT_` prefix)
3. `./.kit.yml` (project-local)
4. `~/.kit.yml` (global)
### Basic Configuration
Create `~/.kit.yml`:
```yaml
model: anthropic/claude-sonnet-4-5-20250929
max-tokens: 4096
temperature: 0.7
stream: true
```
### Environment Variables
```bash
export ANTHROPIC_API_KEY="sk-..."
export OPENAI_API_KEY="sk-..."
export KIT_MODEL="openai/gpt-4o"
```
### MCP Server Configuration
Add external MCP servers to `.kit.yml`:
```yaml
mcpServers:
filesystem:
type: local
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
environment:
LOG_LEVEL: "info"
allowedTools: ["read_file", "write_file"]
search:
type: remote
url: "https://mcp.example.com/search"
```
## CLI Reference
### Global Flags
```bash
# Model and provider
--model, -m Model to use (provider/model format)
--provider-api-key API key for the provider
--provider-url Base URL for provider API
--tls-skip-verify Skip TLS certificate verification
# Session management
--session, -s Open specific JSONL session file
--continue, -c Resume most recent session for current directory
--resume, -r Interactive session picker
--no-session Ephemeral mode, no persistence
# Behavior (non-interactive: pass prompt as positional arg)
--quiet Suppress all output (non-interactive only)
--json Output response as JSON (non-interactive only)
--no-exit Enter interactive mode after prompt completes
--max-steps Maximum agent steps (0 for unlimited)
--stream Enable streaming output (default: true)
--compact Enable compact output mode
--auto-compact Auto-compact conversation near context limit
# Extensions
--extension, -e Load additional extension file(s) (repeatable)
--no-extensions Disable all extensions
# Generation parameters
--max-tokens Maximum tokens in response (default: 4096)
--temperature Randomness 0.0-1.0 (default: 0.7)
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
--top-k Limit top K tokens (default: 40)
--stop-sequences Custom stop sequences (comma-separated)
# System
--config Config file path (default: ~/.kit.yml)
--system-prompt System prompt text or file path
--debug Enable debug logging
```
### Commands
```bash
# Authentication (for OAuth-enabled providers)
kit auth login # Start OAuth flow
kit auth logout # Remove credentials
kit auth status # Check authentication status
# Model database
kit models # List available models
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models # Update local model database from models.dev
# Extension management
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
# ACP server
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
```
## Extension System
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, and intercept lifecycle events.
### Minimal Extension
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Custom Footer"},
})
})
}
```
**Usage:**
```bash
kit -e examples/extensions/minimal.go
```
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnAgentStart, OnAgentEnd, OnToolCall, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
- **Commands**: Register slash commands (e.g., `/mycommand`)
- **Widgets**: Persistent status displays above/below input
- **Shortcuts**: Global keyboard shortcuts
- **Overlays**: Modal dialogs with markdown content
- **Tool Renderers**: Customize how tool calls display
- **Editor Interceptors**: Handle key events and wrap rendering
### Extension Examples
See the `examples/extensions/` directory:
- `minimal.go` - Clean UI with custom footer
- `notify.go` - Desktop notifications
- `widget-status.go` - Persistent status widgets
- `custom-editor-demo.go` - Vim-like modal editor
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `tool-logger.go` - Log all tool calls
- `overlay-demo.go` - Modal dialogs
- `plan-mode.go` - Read-only planning mode
- `subagent-widget.go` - Multi-agent orchestration
- `auto-commit.go` - Auto-commit on shutdown
### Loading Extensions
**Auto-discovery** (loads automatically):
- `./.kit/extensions/*.go` (project-local)
- `~/.config/kit/extensions/*.go` (global)
**Explicit loading**:
```bash
kit -e path/to/extension.go
kit -e ext1.go -e ext2.go # Multiple extensions
```
**Disable auto-load**:
```bash
kit --no-extensions
```
## Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
### Session Locations
- Default: `~/.local/share/kit/sessions/<cwd-hash>/<uuid>.jsonl`
- Each line is a session entry (messages, tool calls, extension data)
- Supports branching from any message to explore alternate paths
### Session Commands
```bash
# Resume most recent session for current directory
kit --continue
kit -c
# Interactive session picker
kit --resume
kit -r
# Open specific session file
kit --session path/to/session.jsonl
kit -s path/to/session.jsonl
# Ephemeral mode (no file persistence)
kit --no-session
```
## Go SDK
Embed Kit in your Go applications:
```go
package main
import (
"context"
"log"
kit "github.com/mark3labs/kit/pkg/kit"
)
func main() {
ctx := context.Background()
// Create Kit instance with default configuration
host, err := kit.New(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer host.Close()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
if err != nil {
log.Fatal(err)
}
println(response)
}
```
### With Options
```go
host, err := kit.New(ctx, &kit.Options{
Model: "ollama/llama3",
SystemPrompt: "You are a helpful bot",
ConfigFile: "/path/to/config.yml",
MaxSteps: 10,
Streaming: true,
Quiet: true,
})
```
### With Callbacks
```go
response, err := host.PromptWithCallbacks(
ctx,
"List files in current directory",
func(name, args string) {
// Tool call started
println("Calling tool:", name)
},
func(name, args, result string, isError bool) {
// Tool call completed
if isError {
println("Tool failed:", name)
}
},
func(chunk string) {
// Streaming text chunk
print(chunk)
},
)
```
### Session Management
```go
host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
host.SaveSession("./session.json")
host.LoadSession("./session.json")
host.ClearSession()
```
## Advanced Usage
### Subagent Pattern
Spawn Kit as a subprocess for multi-agent orchestration:
```bash
kit "Analyze codebase" \
--json \
--no-session \
--no-extensions \
--quiet \
--model anthropic/claude-haiku-3-5-20241022
```
Parse the JSON output:
```json
{
"response": "Final assistant response text",
"model": "anthropic/claude-haiku-3-5-20241022",
"usage": {
"input_tokens": 1024,
"output_tokens": 512,
"total_tokens": 1536
},
"messages": [...]
}
```
### Testing with tmux
Test the TUI non-interactively:
```bash
# Start Kit in detached tmux session
tmux new-session -d -s kittest -x 120 -y 40 \
"kit -e ext.go --no-session 2>kit.log"
# Wait for startup
sleep 3
# Capture screen
tmux capture-pane -t kittest -p
# Send input
tmux send-keys -t kittest '/command' Enter
# Cleanup
tmux kill-session -t kittest
```
## Development
### Build and Test
```bash
# Build
go build -o output/kit ./cmd/kit
# Run tests
go test -race ./...
# Run specific test
go test -race ./cmd -run TestScriptExecution
# Lint
go vet ./...
# Format
go fmt ./...
```
### Project Structure
```
cmd/kit/ - CLI entry point
cmd/ - CLI command implementations
pkg/kit/ - Go SDK
internal/agent/ - Agent loop and tool execution
internal/ui/ - Bubble Tea TUI components
internal/extensions/ - Yaegi extension system
internal/core/ - Built-in tools
internal/tools/ - MCP tool integration
internal/config/ - Configuration management
internal/acpserver/ - ACP (Agent Client Protocol) server
internal/session/ - Session persistence
internal/models/ - Provider and model management
examples/extensions/ - Example extension files
```
## Supported Providers
- **Anthropic** - Claude models (native, prompt caching, OAuth)
- **OpenAI** - GPT models
- **Google** - Gemini models
- **Ollama** - Local models
- **Azure OpenAI** - Azure-hosted OpenAI
- **AWS Bedrock** - Bedrock models
- **Google Vertex** - Claude on Vertex AI
- **OpenRouter** - Multi-provider router
- **Vercel AI** - Vercel AI SDK models
- **Auto-routed** - Any provider from models.dev database
### Model String Format
```bash
provider/model # Standard format
anthropic/claude-sonnet-4-5-20250929
openai/gpt-4o
ollama/llama3
google/gemini-2.0-flash-exp
```
### Model Aliases
```bash
claude-opus-latest → claude-opus-4-20250514
claude-sonnet-latest → claude-sonnet-4-5-20250929
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
```
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
[Apache 2.0](LICENSE)
## Community
- [Discord](https://discord.gg/RqSS2NQVsY)
- [GitHub Issues](https://github.com/mark3labs/kit/issues)
- [Documentation](https://github.com/mark3labs/kit/wiki)
+13 -1
View File
@@ -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"
}
}
+159
View File
@@ -0,0 +1,159 @@
package cmd
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"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. We wrap stdin with a normalizer that
// fills in optional fields the SDK's generated validation requires
// (e.g. mcpServers) so clients that omit them still work.
conn := acp.NewAgentSideConnection(agent, os.Stdout, newACPNormalizer(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,
})))
}
// 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
}
// acpNormalizer wraps an io.Reader carrying newline-delimited JSON-RPC and
// patches incoming messages so that fields the SDK validates as required —
// but that some clients (e.g. Zed) omit — are defaulted. This avoids
// InvalidParams errors without forking the SDK.
type acpNormalizer struct {
scanner *bufio.Scanner
buf bytes.Buffer // leftover bytes from the last normalized line
}
func newACPNormalizer(r io.Reader) *acpNormalizer {
const maxMsg = 10 * 1024 * 1024 // 10 MB, matches SDK buffer
s := bufio.NewScanner(r)
s.Buffer(make([]byte, 0, 1024*1024), maxMsg)
return &acpNormalizer{scanner: s}
}
// Read satisfies io.Reader. It feeds one normalized JSON line (plus newline)
// per underlying scan, buffering across short caller reads.
func (n *acpNormalizer) Read(p []byte) (int, error) {
// Drain any leftover bytes from the previous line first.
if n.buf.Len() > 0 {
return n.buf.Read(p)
}
if !n.scanner.Scan() {
if err := n.scanner.Err(); err != nil {
return 0, err
}
return 0, io.EOF
}
line := n.scanner.Bytes()
normalized := normalizeACPLine(line)
n.buf.Write(normalized)
n.buf.WriteByte('\n')
return n.buf.Read(p)
}
// normalizeACPLine ensures session/new and session/load params contain an
// mcpServers array. Returns the original line unchanged for all other methods.
func normalizeACPLine(line []byte) []byte {
// Quick check: if it already contains mcpServers, nothing to do.
if bytes.Contains(line, []byte(`"mcpServers"`)) {
return line
}
// Only bother parsing if the method could be session/new or session/load.
if !bytes.Contains(line, []byte(`"session/new"`)) &&
!bytes.Contains(line, []byte(`"session/load"`)) {
return line
}
var msg struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
if err := json.Unmarshal(line, &msg); err != nil {
return line
}
if msg.Method != "session/new" && msg.Method != "session/load" {
return line
}
// Patch params to include mcpServers: [].
var params map[string]json.RawMessage
if err := json.Unmarshal(msg.Params, &params); err != nil {
return line
}
if _, ok := params["mcpServers"]; ok {
return line
}
params["mcpServers"] = json.RawMessage(`[]`)
patched, err := json.Marshal(params)
if err != nil {
return line
}
msg.Params = patched
out, err := json.Marshal(msg)
if err != nil {
return line
}
return out
}
+525 -48
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/ui"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
@@ -27,8 +29,9 @@ var (
providerURL string
providerAPIKey string
debugMode bool
promptFlag string
positionalPrompt string // set by processPositionalArgs from CLI positional args
quietFlag bool
jsonFlag bool
noExitFlag bool
maxSteps int
streamFlag bool // Enable streaming output
@@ -49,6 +52,7 @@ var (
topP float32
topK int32
stopSequences []string
thinkingLevel string
// Ollama-specific parameters
numGPU int32
@@ -98,10 +102,16 @@ func (a *kitUIAdapter) GetExtensionToolCount() int {
// an interface to interact with various AI models through a unified interface
// with support for MCP servers and tool integration.
var rootCmd = &cobra.Command{
Use: "kit",
Use: "kit [@file...] [prompt]",
Short: "Chat with AI models through a unified interface",
Long: `KIT (Knowledge Inference Tool) — A lightweight AI agent for coding`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Parse positional args: @-prefixed args are file attachments,
// remaining args form the prompt (like Pi: kit @code.ts "Review this").
if len(args) > 0 {
processPositionalArgs(args)
}
return runKit(context.Background())
},
}
@@ -199,12 +209,13 @@ func init() {
"model to use (format: provider/model)")
rootCmd.PersistentFlags().
BoolVar(&debugMode, "debug", false, "enable debug logging")
rootCmd.PersistentFlags().
StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt")
BoolVar(&quietFlag, "quiet", false, "suppress all output (non-interactive mode only)")
rootCmd.PersistentFlags().
BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)")
BoolVar(&jsonFlag, "json", false, "output response as JSON (non-interactive mode only)")
rootCmd.PersistentFlags().
BoolVar(&noExitFlag, "no-exit", false, "prevent non-interactive mode from exiting, show input prompt instead")
BoolVar(&noExitFlag, "no-exit", false, "enter interactive mode after non-interactive prompt completes")
rootCmd.PersistentFlags().
IntVar(&maxSteps, "max-steps", 0, "maximum number of agent steps (0 for unlimited)")
rootCmd.PersistentFlags().
@@ -237,6 +248,7 @@ func init() {
flags.Float32Var(&topP, "top-p", 0.95, "controls diversity via nucleus sampling (0.0-1.0)")
flags.Int32Var(&topK, "top-k", 40, "controls diversity by limiting top K tokens to sample from")
flags.StringSliceVar(&stopSequences, "stop-sequences", nil, "custom stop sequences (comma-separated)")
flags.StringVar(&thinkingLevel, "thinking-level", "off", "extended thinking level: off, minimal, low, medium, high")
// Ollama-specific parameters
flags.Int32Var(&numGPU, "num-gpu-layers", -1, "number of model layers to offload to GPU for Ollama models (-1 for auto-detect)")
@@ -247,7 +259,6 @@ func init() {
_ = viper.BindPFlag("system-prompt", rootCmd.PersistentFlags().Lookup("system-prompt"))
_ = viper.BindPFlag("model", rootCmd.PersistentFlags().Lookup("model"))
_ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
_ = viper.BindPFlag("prompt", rootCmd.PersistentFlags().Lookup("prompt"))
_ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
_ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact"))
@@ -260,6 +271,7 @@ func init() {
_ = viper.BindPFlag("top-p", rootCmd.PersistentFlags().Lookup("top-p"))
_ = viper.BindPFlag("top-k", rootCmd.PersistentFlags().Lookup("top-k"))
_ = viper.BindPFlag("stop-sequences", rootCmd.PersistentFlags().Lookup("stop-sequences"))
_ = viper.BindPFlag("thinking-level", rootCmd.PersistentFlags().Lookup("thinking-level"))
_ = viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers"))
_ = viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu"))
_ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify"))
@@ -272,6 +284,62 @@ func init() {
rootCmd.AddCommand(authCmd)
}
// processPositionalArgs separates positional CLI arguments into @file
// attachments and prompt text. File content is read and prepended to
// positionalPrompt so the agent receives it. Positional args are the primary
// way to run non-interactive mode:
//
// kit "Explain this codebase"
// kit @code.ts @test.ts "Review these files"
func processPositionalArgs(args []string) {
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
var fileTokens []string
var promptParts []string
for _, arg := range args {
if strings.HasPrefix(arg, "@") && len(arg) > 1 {
fileTokens = append(fileTokens, arg)
} else {
promptParts = append(promptParts, arg)
}
}
// Build file content prefix from @file arguments.
var fileContent strings.Builder
for _, token := range fileTokens {
expanded := ui.ProcessFileAttachments(token, cwd)
if expanded != token {
// File was resolved — add it.
fileContent.WriteString(expanded)
fileContent.WriteString("\n\n")
}
}
// Combine: positional prompt text is appended to any existing --prompt
// value (for backward compat with subprocess invocations).
if len(promptParts) > 0 {
extra := strings.Join(promptParts, " ")
if positionalPrompt != "" {
positionalPrompt = positionalPrompt + " " + extra
} else {
positionalPrompt = extra
}
}
// Prepend file content to the prompt.
if fileContent.Len() > 0 {
if positionalPrompt == "" {
positionalPrompt = strings.TrimSpace(fileContent.String())
} else {
positionalPrompt = strings.TrimSpace(fileContent.String()) + "\n\n" + positionalPrompt
}
}
}
func runKit(ctx context.Context) error {
return runNormalMode(ctx)
}
@@ -291,13 +359,19 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
if len(name) > 0 && name[0] != '/' {
name = "/" + name
}
cmds = append(cmds, ui.ExtensionCommand{
ec := ui.ExtensionCommand{
Name: name,
Description: d.Description,
Execute: func(args string) (string, error) {
return d.Execute(args, k.GetExtensionContext())
},
})
}
if d.Complete != nil {
ec.Complete = func(prefix string) []string {
return d.Complete(prefix, k.GetExtensionContext())
}
}
cmds = append(cmds, ec)
}
return cmds
}
@@ -411,6 +485,27 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
}
}
// uiVisibilityProviderForUI returns a function that converts extension UI
// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if
// extensions are disabled — the UI treats nil as "show everything".
func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
if !k.HasExtensions() {
return nil
}
return func() *ui.UIVisibility {
v := k.GetExtensionUIVisibility()
if v == nil {
return nil
}
return &ui.UIVisibility{
HideStartupMessage: v.HideStartupMessage,
HideStatusBar: v.HideStatusBar,
HideSeparator: v.HideSeparator,
HideInputHint: v.HideInputHint,
}
}
}
// footerProviderForUI returns a function that converts the extension footer
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
// which is safe — the UI treats a nil GetFooter as "no footer".
@@ -432,13 +527,74 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
}
}
// statusBarProviderForUI returns a function that fetches extension status bar
// entries and converts them to ui.StatusBarEntryData for the TUI. Returns nil
// if extensions are disabled, which is safe — the TUI treats a nil
// GetStatusBarEntries as "no extension entries".
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
if !k.HasExtensions() {
return nil
}
return func() []ui.StatusBarEntryData {
entries := k.GetExtensionStatusEntries()
if len(entries) == 0 {
return nil
}
result := make([]ui.StatusBarEntryData, len(entries))
for i, e := range entries {
result[i] = ui.StatusBarEntryData{
Key: e.Key,
Text: e.Text,
Priority: e.Priority,
}
}
return result
}
}
// beforeForkProviderForUI returns a callback that emits a BeforeFork event
// and returns (cancelled, reason). Returns nil if extensions are disabled —
// the UI treats nil as "no hook".
func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, string) {
if !k.HasExtensions() {
return nil
}
return k.EmitBeforeFork
}
// beforeSessionSwitchProviderForUI returns a callback that emits a
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
// if extensions are disabled — the UI treats nil as "no hook".
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
if !k.HasExtensions() {
return nil
}
return k.EmitBeforeSessionSwitch
}
// globalShortcutsProviderForUI returns a callback that queries the extension
// runner for registered keyboard shortcuts. Returns nil if extensions are
// disabled — the UI treats nil as "no shortcuts".
func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
if !k.HasExtensions() {
return nil
}
return k.GetExtensionShortcuts
}
func runNormalMode(ctx context.Context) error {
// Validate flag combinations
if quietFlag && promptFlag == "" {
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
if quietFlag && positionalPrompt == "" {
return fmt.Errorf("--quiet requires a prompt (e.g. kit \"your question\" --quiet)")
}
if noExitFlag && promptFlag == "" {
return fmt.Errorf("--no-exit flag can only be used with --prompt/-p")
if jsonFlag && positionalPrompt == "" {
return fmt.Errorf("--json requires a prompt (e.g. kit \"your question\" --json)")
}
if jsonFlag && noExitFlag {
return fmt.Errorf("--json and --no-exit flags cannot be used together")
}
if noExitFlag && positionalPrompt == "" {
return fmt.Errorf("--no-exit requires a prompt (e.g. kit \"your question\" --no-exit)")
}
// Set up logging
@@ -505,7 +661,7 @@ func runNormalMode(ctx context.Context) error {
// Create CLI for non-interactive mode only.
var cli *ui.CLI
if promptFlag != "" {
if positionalPrompt != "" {
cli, err = SetupCLIForNonInteractive(kitInstance)
if err != nil {
return fmt.Errorf("failed to setup CLI: %v", err)
@@ -550,14 +706,16 @@ func runNormalMode(ctx context.Context) error {
if kitInstance.HasExtensions() {
cwd, _ := os.Getwd()
kitInstance.SetExtensionContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: promptFlag == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CWD: cwd,
Model: modelName,
Interactive: positionalPrompt == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.Steer(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
appInstance.NotifyWidgetUpdate()
@@ -629,6 +787,19 @@ func runNormalMode(ctx context.Context) error {
}
return extensions.PromptInputResult{Value: resp.Value}
},
SetUIVisibility: func(v extensions.UIVisibility) {
kitInstance.SetExtensionUIVisibility(v)
appInstance.NotifyWidgetUpdate()
},
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
return extensions.ContextStats{
EstimatedTokens: s.EstimatedTokens,
ContextLimit: s.ContextLimit,
UsagePercent: s.UsagePercent,
MessageCount: s.MessageCount,
}
},
SetEditor: func(config extensions.EditorConfig) {
kitInstance.SetExtensionEditor(config)
// Use a goroutine for NotifyWidgetUpdate because this may be
@@ -641,6 +812,95 @@ func runNormalMode(ctx context.Context) error {
kitInstance.ResetExtensionEditor()
go appInstance.NotifyWidgetUpdate()
},
GetMessages: func() []extensions.SessionMessage {
return kitInstance.GetSessionMessages()
},
GetSessionPath: func() string {
return kitInstance.GetSessionFilePath()
},
AppendEntry: func(entryType string, data string) (string, error) {
return kitInstance.AppendExtensionEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.GetExtensionEntries(entryType)
},
SetEditorText: func(text string) {
appInstance.SetEditorTextFromExtension(text)
},
SetStatus: func(key string, text string, priority int) {
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
})
appInstance.NotifyWidgetUpdate()
},
RemoveStatus: func(key string) {
kitInstance.RemoveExtensionStatus(key)
appInstance.NotifyWidgetUpdate()
},
GetOption: func(name string) string {
return kitInstance.GetExtensionOption(name)
},
SetOption: func(name string, value string) {
kitInstance.SetExtensionOption(name, value)
},
SetModel: func(modelString string) error {
// Capture previous model for the ModelChange event.
previousModel := kitInstance.GetExtensionContext().Model
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
}
// Notify TUI so it updates model in status bar.
p, m, _ := models.ParseModelString(modelString)
appInstance.NotifyModelChanged(p, m)
// Update the context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
// Fire OnModelChange event to extensions.
kitInstance.EmitModelChange(modelString, previousModel, "extension")
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry {
return kitInstance.GetAvailableModels()
},
EmitCustomEvent: func(name string, data string) {
kitInstance.EmitExtensionCustomEvent(name, data)
},
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
},
SuspendTUI: func(callback func()) error {
return appInstance.SuspendTUI(callback)
},
RenderMessage: func(rendererName, content string) {
renderer := kitInstance.GetExtensionMessageRenderer(rendererName)
if renderer == nil || renderer.Render == nil {
appInstance.PrintFromExtension("", content)
return
}
w, _, _ := term.GetSize(int(os.Stdout.Fd()))
if w == 0 {
w = 80
}
rendered := renderer.Render(content, w)
appInstance.PrintFromExtension("", rendered)
},
ReloadExtensions: func() error {
err := kitInstance.ReloadExtensions()
if err != nil {
return err
}
// Notify TUI that widgets/status/commands may have changed.
appInstance.NotifyWidgetUpdate()
return nil
},
GetAllTools: func() []extensions.ToolInfo {
return kitInstance.GetExtensionToolInfos()
},
SetActiveTools: func(names []string) {
kitInstance.SetExtensionActiveTools(names)
},
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
ch := make(chan app.OverlayResponse, 1)
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
@@ -664,6 +924,42 @@ func runNormalMode(ctx context.Context) error {
Index: resp.Index,
}
},
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
// In-process subagent via SDK.
sdkCfg := kit.SubagentConfig{
Prompt: config.Prompt,
Model: config.Model,
SystemPrompt: config.SystemPrompt,
Timeout: config.Timeout,
NoSession: config.NoSession,
}
// Bridge SDK events to extension SubagentEvents.
if config.OnEvent != nil {
sdkCfg.OnEvent = func(e kit.Event) {
se := sdkEventToSubagentEvent(e)
if se.Type != "" {
config.OnEvent(se)
}
}
}
result, err := kitInstance.Subagent(ctx, sdkCfg)
if result == nil {
return nil, &extensions.SubagentResult{Error: err}, err
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: result.Error,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
if result.Usage != nil {
extResult.Usage = &extensions.SubagentUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
}
}
return nil, extResult, err
},
})
kitInstance.EmitSessionStart()
}
@@ -696,18 +992,49 @@ func runNormalMode(ctx context.Context) error {
getFooter := footerProviderForUI(kitInstance)
getToolRenderer := toolRendererProviderForUI(kitInstance)
getEditorInterceptor := editorInterceptorProviderForUI(kitInstance)
getUIVisibility := uiVisibilityProviderForUI(kitInstance)
getStatusBarEntries := statusBarProviderForUI(kitInstance)
emitBeforeFork := beforeForkProviderForUI(kitInstance)
emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance)
getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance)
getExtensionCommands := func() []ui.ExtensionCommand {
return extensionCommandsForUI(kitInstance)
}
// Build model switching callbacks for the /model command.
setModelForUI := func(modelString string) error {
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
}
// Update the extension context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
// NOTE: We do NOT call appInstance.NotifyModelChanged() here because
// this callback runs synchronously inside BubbleTea's Update(), and
// NotifyModelChanged calls prog.Send() which deadlocks. The UI layer
// updates m.providerName and m.modelName directly after setModel returns.
return nil
}
emitModelChangeForUI := func(newModel, previousModel, source string) {
kitInstance.EmitModelChange(newModel, previousModel, source)
}
// Build thinking level callback.
setThinkingLevelForUI := func(level string) error {
return kitInstance.SetThinkingLevel(context.Background(), level)
}
// Check if running in non-interactive mode
if promptFlag != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
if positionalPrompt != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
}
// Quiet mode is not allowed in interactive mode
if quietFlag {
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
return fmt.Errorf("--quiet requires a prompt")
}
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -720,8 +1047,25 @@ func runNormalMode(ctx context.Context) error {
//
// When --no-exit is set, after the prompt completes the interactive BubbleTea
// TUI is started so the user can continue the conversation.
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
if quiet {
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error) error {
// Expand @file references in the prompt before sending to the agent.
if cwd, err := os.Getwd(); err == nil {
prompt = ui.ProcessFileAttachments(prompt, cwd)
}
if jsonOutput {
// JSON mode: no intermediate display, structured JSON output.
result, err := appInstance.RunOnceResult(ctx, prompt)
if err != nil {
writeJSONError(err)
return err
}
data, err := buildJSONOutput(result, modelName)
if err != nil {
return fmt.Errorf("failed to marshal JSON output: %w", err)
}
fmt.Println(string(data))
} else if quiet {
// Quiet mode: no intermediate display, just print final response.
if err := appInstance.RunOnce(ctx, prompt); err != nil {
return err
@@ -746,12 +1090,93 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// If --no-exit was requested, hand off to the interactive TUI.
if noExit {
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel)
}
return nil
}
// ---------------------------------------------------------------------------
// JSON output helpers (--json mode)
// ---------------------------------------------------------------------------
// buildJSONOutput converts a TurnResult into a structured JSON byte slice
// suitable for machine consumption.
func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
type jsonPart struct {
Type string `json:"type"`
Data any `json:"data"`
}
type jsonMessage struct {
Role string `json:"role"`
Parts []jsonPart `json:"parts"`
}
type jsonUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
}
type jsonEnvelope struct {
Response string `json:"response"`
Model string `json:"model"`
StopReason string `json:"stop_reason,omitempty"`
SessionID string `json:"session_id,omitempty"`
Usage *jsonUsage `json:"usage,omitempty"`
Messages []jsonMessage `json:"messages"`
}
out := jsonEnvelope{
Response: result.Response,
Model: model,
StopReason: result.StopReason,
SessionID: result.SessionID,
}
if result.TotalUsage != nil {
out.Usage = &jsonUsage{
InputTokens: result.TotalUsage.InputTokens,
OutputTokens: result.TotalUsage.OutputTokens,
TotalTokens: result.TotalUsage.TotalTokens,
CacheReadTokens: result.TotalUsage.CacheReadTokens,
CacheCreationTokens: result.TotalUsage.CacheCreationTokens,
}
}
for _, fmsg := range result.Messages {
converted := kit.ConvertFromFantasyMessage(fmsg)
m := jsonMessage{Role: string(converted.Role)}
for _, p := range converted.Parts {
switch c := p.(type) {
case kit.TextContent:
m.Parts = append(m.Parts, jsonPart{Type: "text", Data: c})
case kit.ToolCall:
m.Parts = append(m.Parts, jsonPart{Type: "tool_call", Data: c})
case kit.ToolResult:
m.Parts = append(m.Parts, jsonPart{Type: "tool_result", Data: c})
case kit.ReasoningContent:
m.Parts = append(m.Parts, jsonPart{Type: "reasoning", Data: c})
case kit.Finish:
m.Parts = append(m.Parts, jsonPart{Type: "finish", Data: c})
}
}
out.Messages = append(out.Messages, m)
}
return json.MarshalIndent(out, "", " ")
}
// writeJSONError writes a JSON-formatted error object to stdout so that
// callers using --json always receive parseable output.
func writeJSONError(err error) {
type jsonError struct {
Error string `json:"error"`
}
data, _ := json.MarshalIndent(jsonError{Error: err.Error()}, "", " ")
fmt.Fprintln(os.Stderr, string(data))
}
// runInteractiveModeBubbleTea starts the new unified Bubble Tea interactive TUI.
//
// It:
@@ -763,7 +1188,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -771,26 +1196,39 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
termHeight = 24
}
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
ContextPaths: contextPaths,
SkillItems: skillItems,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
ContextPaths: contextPaths,
SkillItems: skillItems,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
GetUIVisibility: getUIVisibility,
GetStatusBarEntries: getStatusBarEntries,
EmitBeforeFork: emitBeforeFork,
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
GetGlobalShortcuts: getGlobalShortcuts,
GetExtensionCommands: getExtensionCommands,
SetModel: setModel,
EmitModelChange: emitModelChange,
ThinkingLevel: thinkingLevel,
IsReasoningModel: isReasoningModel,
SetThinkingLevel: setThinkingLevel,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
@@ -804,3 +1242,42 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
_, runErr := program.Run()
return runErr
}
// sdkEventToSubagentEvent converts an SDK event to an extension-facing
// SubagentEvent. Returns a zero-value event (Type=="") for events that
// don't map to anything useful.
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
switch ev := e.(type) {
case kit.MessageUpdateEvent:
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
case kit.ReasoningDeltaEvent:
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
case kit.ToolCallEvent:
return extensions.SubagentEvent{
Type: "tool_call", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
}
case kit.ToolExecutionStartEvent:
return extensions.SubagentEvent{
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolExecutionEndEvent:
return extensions.SubagentEvent{
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolResultEvent:
return extensions.SubagentEvent{
Type: "tool_result", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
ToolResult: ev.Result, IsError: ev.IsError,
}
case kit.TurnStartEvent:
return extensions.SubagentEvent{Type: "turn_start"}
case kit.TurnEndEvent:
return extensions.SubagentEvent{Type: "turn_end"}
default:
return extensions.SubagentEvent{}
}
}
+58
View File
@@ -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
}
+71
View File
@@ -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
}
+101
View File
@@ -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
},
})
}
+76
View File
@@ -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
},
})
}
+56
View File
@@ -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
}
+89
View File
@@ -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
},
})
}
+56
View File
@@ -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))
})
}
+52
View File
@@ -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,
}
})
}
+123
View File
@@ -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
},
})
}
+35 -10
View File
@@ -19,6 +19,7 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
@@ -31,6 +32,16 @@ import (
"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
// ---------------------------------------------------------------------------
@@ -474,27 +485,33 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
}
tmpFile.Close()
// Build subprocess arguments. Don't pass --model; the subprocess
// inherits the same config/env and will use the same default.
// Build subprocess arguments. Use --json for structured output parsing.
// Don't pass --model; the subprocess inherits the same config/env default.
args := []string{
"--prompt", question,
"--quiet",
"--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
outBytes, err := cmd.CombinedOutput()
err = cmd.Run()
close(done)
elapsed = time.Since(start)
result := strings.TrimSpace(string(outBytes))
if err != nil {
// Extract a single-line summary for the card (no newlines).
errLine := result
// 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]
}
@@ -505,10 +522,18 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
}
return result, code, elapsed
return errText, code, elapsed
}
// Success — extract last non-empty line for the card.
// 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-- {
File diff suppressed because it is too large Load Diff
+71
View File
@@ -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()
})
}
+34
View File
@@ -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()
}
}
+64
View File
@@ -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
})
}
+28
View File
@@ -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,
}
})
}
+96
View File
@@ -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.")
}
}
+71
View File
@@ -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,
}
})
}
+114
View File
@@ -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
})
}
+164
View File
@@ -0,0 +1,164 @@
//go:build ignore
// Subagent Test Extension — Tests the new first-class subagent API
//
// Commands:
//
// /subtest <task> — spawn a blocking subagent and print result
// /subbg <task> — spawn a background subagent with live output
//
// Usage: kit -e examples/extensions/subagent-test.go
package main
import (
"fmt"
"strings"
"sync"
"time"
"kit/ext"
)
var (
mu sync.Mutex
latestCtx ext.Context
hasCtx bool
)
func Init(api ext.API) {
// Keep context fresh
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
ctx.PrintInfo(
"Subagent Test Extension loaded\n\n" +
"/subtest <task> Spawn blocking subagent\n" +
"/subbg <task> Spawn background subagent\n\n" +
"The LLM can also use the spawn_subagent tool.")
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
})
// Command: /subtest <task> — blocking subagent
api.RegisterCommand(ext.CommandDef{
Name: "subtest",
Description: "Spawn a blocking subagent: /subtest <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: /subtest <task>", nil
}
ctx.PrintInfo(fmt.Sprintf("Spawning blocking subagent for: %s", task))
start := time.Now()
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: task,
Timeout: 2 * time.Minute,
Blocking: true,
})
elapsed := time.Since(start)
if err != nil {
return fmt.Sprintf("Spawn error: %v", err), nil
}
if result == nil {
return "No result returned", nil
}
if result.Error != nil {
return fmt.Sprintf("Subagent failed (exit %d) after %ds: %v\n\nPartial output:\n%s",
result.ExitCode, int(elapsed.Seconds()), result.Error, truncate(result.Response, 2000)), nil
}
response := fmt.Sprintf("Subagent completed in %ds", int(elapsed.Seconds()))
if result.Usage != nil {
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
}
response += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
return response, nil
},
})
// Command: /subbg <task> — background subagent with callbacks
api.RegisterCommand(ext.CommandDef{
Name: "subbg",
Description: "Spawn a background subagent: /subbg <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: /subbg <task>", nil
}
ctx.PrintInfo(fmt.Sprintf("Spawning background subagent for: %s", task))
start := time.Now()
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: task,
Timeout: 2 * time.Minute,
OnOutput: func(chunk string) {
// Live output - could update a widget here
fmt.Print(chunk)
},
OnComplete: func(result ext.SubagentResult) {
elapsed := time.Since(start)
mu.Lock()
c := latestCtx
ok := hasCtx
mu.Unlock()
if !ok {
return
}
if result.Error != nil {
c.SendMessage(fmt.Sprintf("Background subagent failed after %ds: %v",
int(elapsed.Seconds()), result.Error))
return
}
msg := fmt.Sprintf("Background subagent completed in %ds", int(elapsed.Seconds()))
if result.Usage != nil {
msg += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
}
msg += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
c.SendMessage(msg)
},
})
if err != nil {
return fmt.Sprintf("Spawn error: %v", err), nil
}
return fmt.Sprintf("Background subagent spawned (ID: %s). Results will be delivered when complete.", handle.ID), nil
},
})
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n\n... [truncated]"
}
+22 -7
View File
@@ -35,6 +35,11 @@ import (
"kit/ext"
)
// subJSONOutput matches the JSON envelope produced by `kit --json`.
type subJSONOutput struct {
Response string `json:"response"`
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -204,10 +209,10 @@ func spawnAgent(state *subState) {
}
args := []string{
"--prompt", prompt,
"--quiet",
"--json",
"--no-session",
"--no-extensions",
prompt,
}
cmd := exec.Command(kitBinary, args...)
@@ -261,7 +266,7 @@ func spawnAgent(state *subState) {
}
}()
// Read stderr in background goroutine.
// Read stderr in background goroutine (live widget updates).
var readWg sync.WaitGroup
readWg.Add(1)
go func() {
@@ -277,12 +282,12 @@ func spawnAgent(state *subState) {
}
}()
// Read stdout in foreground.
// 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() {
state.appendChunk(scanner.Text() + "\n")
updateWidgets()
stdoutBuf.WriteString(scanner.Text() + "\n")
}
// Wait for all pipe readers, then the process.
@@ -290,6 +295,17 @@ func spawnAgent(state *subState) {
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
@@ -298,7 +314,6 @@ func spawnAgent(state *subState) {
} else {
state.Status = "done"
}
result := strings.Join(state.Chunks, "")
// Save history for /subcont continuations (cap at 16 KB).
state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n",
+93
View File
@@ -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
},
})
}
+40 -39
View File
@@ -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
)
+76 -74
View File
@@ -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=
+260
View File
@@ -0,0 +1,260 @@
// 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 {
log.Error("acp: session creation failed", "cwd", cwd, "error", err)
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(ev.ToolCallID)
if tcID == "" {
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(ev.ToolCallID)
if tcID == "" {
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}
}
+294
View File
@@ -0,0 +1,294 @@
package acpserver
import (
"context"
"fmt"
"strings"
"sync"
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extensions"
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 {
// Provide actionable guidance for provider auth errors, which are
// the most common failure mode when running via ACP.
msg := err.Error()
if strings.Contains(msg, "API key") || strings.Contains(msg, "credentials") || strings.Contains(msg, "OAuth") {
return nil, fmt.Errorf("provider authentication failed: %w — run 'kit auth login <provider>' or set the appropriate environment variable before starting 'kit acp'", err)
}
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")
}
// Wire extension context with headless implementations so extensions
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
// become no-ops or return cancelled; all data/model/tool APIs work
// identically to interactive mode.
if kitInstance.HasExtensions() {
kitInstance.SetExtensionContext(extensions.Context{
SessionID: sessionID,
CWD: cwd,
Model: kitInstance.GetModelString(),
Interactive: false,
// Output — route through structured logger.
Print: func(text string) { log.Debug("extension: print", "text", text) },
PrintInfo: func(text string) { log.Info("extension: info", "text", text) },
PrintError: func(text string) { log.Error("extension: error", "text", text) },
PrintBlock: func(opts extensions.PrintBlockOpts) {
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
},
// Message injection — no-ops for now; ACP clients drive prompts.
SendMessage: func(string) {},
CancelAndSend: func(string) {},
Exit: func() {},
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
SetWidget: func(extensions.WidgetConfig) {},
RemoveWidget: func(string) {},
SetHeader: func(extensions.HeaderFooterConfig) {},
RemoveHeader: func() {},
SetFooter: func(extensions.HeaderFooterConfig) {},
RemoveFooter: func() {},
SetEditor: func(extensions.EditorConfig) {},
ResetEditor: func() {},
SetEditorText: func(string) {},
SetUIVisibility: func(extensions.UIVisibility) {},
SetStatus: func(string, string, int) {},
RemoveStatus: func(string) {},
// Interactive prompts — return cancelled (no user to prompt).
PromptSelect: func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
return extensions.PromptSelectResult{Cancelled: true}
},
PromptConfirm: func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
return extensions.PromptConfirmResult{Cancelled: true}
},
PromptInput: func(extensions.PromptInputConfig) extensions.PromptInputResult {
return extensions.PromptInputResult{Cancelled: true}
},
ShowOverlay: func(extensions.OverlayConfig) extensions.OverlayResult {
return extensions.OverlayResult{Cancelled: true, Index: -1}
},
SuspendTUI: func(callback func()) error { callback(); return nil },
// Data access — delegate to Kit instance.
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
return extensions.ContextStats{
EstimatedTokens: s.EstimatedTokens,
ContextLimit: s.ContextLimit,
UsagePercent: s.UsagePercent,
MessageCount: s.MessageCount,
}
},
GetMessages: func() []extensions.SessionMessage { return kitInstance.GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionFilePath() },
AppendEntry: func(entryType, data string) (string, error) {
return kitInstance.AppendExtensionEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.GetExtensionEntries(entryType)
},
// Options, model, and tool management.
GetOption: func(name string) string { return kitInstance.GetExtensionOption(name) },
SetOption: func(name, value string) { kitInstance.SetExtensionOption(name, value) },
SetModel: func(modelString string) error {
previousModel := kitInstance.GetExtensionContext().Model
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
return err
}
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.EmitModelChange(modelString, previousModel, "extension")
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry { return kitInstance.GetAvailableModels() },
EmitCustomEvent: func(name, data string) { kitInstance.EmitExtensionCustomEvent(name, data) },
GetAllTools: func() []extensions.ToolInfo { return kitInstance.GetExtensionToolInfos() },
SetActiveTools: func(names []string) { kitInstance.SetExtensionActiveTools(names) },
// LLM completions and subagents.
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
},
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
sdkCfg := kit.SubagentConfig{
Prompt: config.Prompt,
Model: config.Model,
SystemPrompt: config.SystemPrompt,
Timeout: config.Timeout,
NoSession: config.NoSession,
}
if config.OnEvent != nil {
sdkCfg.OnEvent = func(e kit.Event) {
se := sdkEventToSubagentEvent(e)
if se.Type != "" {
config.OnEvent(se)
}
}
}
result, err := kitInstance.Subagent(context.Background(), sdkCfg)
if result == nil {
return nil, &extensions.SubagentResult{Error: err}, err
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: result.Error,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
if result.Usage != nil {
extResult.Usage = &extensions.SubagentUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
}
}
return nil, extResult, err
},
// Render — fall back to logging.
RenderMessage: func(name, content string) {
renderer := kitInstance.GetExtensionMessageRenderer(name)
if renderer != nil && renderer.Render != nil {
content = renderer.Render(content, 80)
}
log.Info("extension: message", "renderer", name, "content", content)
},
ReloadExtensions: func() error { return kitInstance.ReloadExtensions() },
})
kitInstance.EmitSessionStart()
}
sess := &acpSession{
kit: kitInstance,
cwd: cwd,
sessionID: sessionID,
}
r.mu.Lock()
r.sessions[sessionID] = 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
}
// 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
}
// sdkEventToSubagentEvent converts an SDK event to an extension SubagentEvent.
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
switch ev := e.(type) {
case kit.MessageUpdateEvent:
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
case kit.ReasoningDeltaEvent:
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
case kit.ToolCallEvent:
return extensions.SubagentEvent{
Type: "tool_call", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
}
case kit.ToolExecutionStartEvent:
return extensions.SubagentEvent{
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolExecutionEndEvent:
return extensions.SubagentEvent{
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolResultEvent:
return extensions.SubagentEvent{
Type: "tool_result", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
ToolResult: ev.Result, IsError: ev.IsError,
}
case kit.TurnStartEvent:
return extensions.SubagentEvent{Type: "turn_start"}
case kit.TurnEndEvent:
return extensions.SubagentEvent{Type: "turn_end"}
default:
return extensions.SubagentEvent{}
}
}
+159 -20
View File
@@ -41,13 +41,15 @@ type AgentConfig struct {
}
// ToolCallHandler is a function type for handling tool calls as they happen.
type ToolCallHandler func(toolName, toolArgs string)
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
// ToolExecutionHandler is a function type for handling tool execution start/end events.
type ToolExecutionHandler func(toolName string, isStarting bool)
type ToolExecutionHandler func(toolCallID, toolName, toolArgs string, isStarting bool)
// ToolResultHandler is a function type for handling tool results.
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
// The metadata parameter carries optional structured data (e.g. file diff
// info) from the tool execution, JSON-encoded. It may be empty.
type ToolResultHandler func(toolCallID, toolName, toolArgs, result, metadata string, isError bool)
// ResponseHandler is a function type for handling LLM responses.
type ResponseHandler func(content string)
@@ -58,6 +60,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 +79,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.
@@ -86,6 +92,8 @@ type GenerateWithLoopResult struct {
Messages []message.Message
// TotalUsage contains aggregate token usage across all steps
TotalUsage fantasy.Usage
// StopReason is the LLM provider's finish reason for the final response.
StopReason string
}
// NewAgent creates a new Agent with core tools and optional MCP tool integration.
@@ -156,6 +164,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 +208,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
streamingEnabled: agentConfig.StreamingEnabled,
coreTools: coreTools,
extraTools: agentConfig.ExtraTools,
toolWrapper: agentConfig.ToolWrapper,
}, nil
}
@@ -188,7 +218,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 +228,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 +246,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 {
@@ -242,12 +287,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Notify about the tool call
if onToolCall != nil {
onToolCall(tc.ToolName, tc.Input)
onToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
}
// Notify tool execution starting
if onToolExecution != nil {
onToolExecution(tc.ToolName, true)
onToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
}
return nil
@@ -260,13 +305,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
}
// Notify tool execution finished
if onToolExecution != nil {
onToolExecution(tr.ToolName, false)
onToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
}
if onToolResult != nil {
// Extract result text and error status
resultText, isError := extractToolResultText(tr)
onToolResult(tr.ToolName, currentToolArgs, resultText, isError)
onToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
}
return nil
@@ -302,6 +347,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 +368,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 +401,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.
@@ -379,6 +430,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
ConversationMessages: allFantasyMessages,
Messages: allMessages,
TotalUsage: result.TotalUsage,
StopReason: string(result.Response.FinishReason),
}
}
@@ -455,6 +507,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 +538,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
+171 -21
View File
@@ -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
}
@@ -448,14 +532,14 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
switch ev := e.(type) {
case kit.ToolCallEvent:
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolExecutionStartEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
case kit.ToolExecutionEndEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, IsStarting: false})
case kit.ToolResultEvent:
sendFn(ToolResultEvent{
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
Result: ev.Result, IsError: ev.IsError,
})
case kit.ToolCallContentEvent:
@@ -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,28 @@ 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).
@@ -547,6 +671,32 @@ func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
}
}
// 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()
+5 -1
View File
@@ -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 {
+32
View File
@@ -9,9 +9,18 @@ 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 {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
// ToolName is the name of the tool being called.
ToolName string
// ToolArgs is the JSON-encoded arguments for the tool call.
@@ -21,14 +30,20 @@ type ToolCallStartedEvent struct {
// ToolExecutionEvent is sent when a tool starts or finishes executing.
// The IsStarting flag distinguishes between the start and end of execution.
type ToolExecutionEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
// ToolName is the name of the tool being executed.
ToolName string
// ToolArgs is the JSON-encoded arguments for the tool call (only set when IsStarting is true).
ToolArgs string
// IsStarting is true when execution is beginning, false when it is complete.
IsStarting bool
}
// ToolResultEvent is sent after a tool execution completes with its result.
type ToolResultEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
// ToolName is the name of the tool that was executed.
ToolName string
// ToolArgs is the JSON-encoded arguments that were passed to the tool.
@@ -113,11 +128,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
+2
View File
@@ -51,6 +51,7 @@ func TestCredentialManager(t *testing.T) {
}
if creds == nil {
t.Fatal("Expected credentials to be returned")
return
}
if creds.APIKey != testAPIKey {
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
@@ -236,6 +237,7 @@ func TestCredentialStorePersistence(t *testing.T) {
}
if creds == nil {
t.Fatal("Expected credentials to persist")
return
}
if creds.APIKey != testAPIKey {
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
+75
View File
@@ -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)")
+45
View File
@@ -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)
}
})
}
}
+57
View File
@@ -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()
}
+9
View File
@@ -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
}
+15 -15
View File
@@ -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.
+2 -2
View File
@@ -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) {
+3
View File
@@ -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"`
}
+1 -1
View File
@@ -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
+21 -3
View File
@@ -76,13 +76,15 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
// If no exact match, try fuzzy matching
if count == 0 {
if idx, matchLen := fuzzyMatch(normalized, normalizedOld); idx >= 0 {
// Apply fuzzy match
// Apply fuzzy match — the matched text is the original content slice
matchedText := normalized[idx : idx+matchLen]
newContent := normalized[:idx] + args.NewText + normalized[idx+matchLen:]
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
diff := generateDiff(absPath, normalized, newContent, idx)
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)), nil
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff))
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, matchedText, args.NewText)), nil
}
return fantasy.NewTextErrorResponse(fmt.Sprintf("old_text not found in %s", args.Path)), nil
}
@@ -100,7 +102,23 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
idx := strings.Index(normalized, normalizedOld)
diff := generateDiff(absPath, normalized, newContent, idx)
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)), nil
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff))
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, normalizedOld, args.NewText)), nil
}
// editDiffMeta builds the structured metadata attached to edit tool responses.
func editDiffMeta(path, oldText, newText string) map[string]any {
return map[string]any{
"file_diffs": []map[string]any{{
"path": path,
"additions": strings.Count(newText, "\n") + 1,
"deletions": strings.Count(oldText, "\n") + 1,
"diff_blocks": []map[string]any{{
"old_text": oldText,
"new_text": newText,
}},
}},
}
}
// fuzzyMatch tries to find old_text with relaxed matching:
+1
View File
@@ -39,6 +39,7 @@ func NewFindTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeFind(ctx, call, cfg.WorkDir)
+1
View File
@@ -59,6 +59,7 @@ func NewGrepTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeGrep(ctx, call, cfg.WorkDir)
+1
View File
@@ -33,6 +33,7 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeLs(ctx, call, cfg.WorkDir)
+1
View File
@@ -38,6 +38,7 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"path"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeRead(ctx, call, cfg.WorkDir)
+171
View File
@@ -0,0 +1,171 @@
package core
import (
"context"
"fmt"
"time"
"charm.land/fantasy"
)
const defaultSubagentTimeout = 5 * time.Minute
const maxSubagentTimeout = 30 * time.Minute
// ---------------------------------------------------------------------------
// Context-based subagent spawner
// ---------------------------------------------------------------------------
// SubagentSpawnResult carries the outcome of an in-process subagent spawn.
type SubagentSpawnResult struct {
Response string
Error error
SessionID string
InputTokens int64
OutputTokens int64
Elapsed time.Duration
}
// SubagentSpawnFunc is a callback that spawns an in-process subagent. The
// parent Kit instance injects this into the context so the core tool can
// call back without importing pkg/kit (which would create a cycle).
type SubagentSpawnFunc func(ctx context.Context, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
type subagentCtxKey struct{}
// WithSubagentSpawner stores a spawn function in the context so that the
// spawn_subagent core tool can create in-process subagents.
func WithSubagentSpawner(ctx context.Context, fn SubagentSpawnFunc) context.Context {
return context.WithValue(ctx, subagentCtxKey{}, fn)
}
// getSubagentSpawner retrieves the spawn function from the context.
func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
if fn, ok := ctx.Value(subagentCtxKey{}).(SubagentSpawnFunc); ok {
return fn
}
return nil
}
// ---------------------------------------------------------------------------
// spawn_subagent tool
// ---------------------------------------------------------------------------
type subagentArgs struct {
Task string `json:"task"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// NewSubagentTool creates the spawn_subagent core tool.
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "spawn_subagent",
Description: `Spawn a subagent to perform a task autonomously.
The subagent runs as a separate in-process Kit instance with full tool access
(except spawning further subagents). Use this to:
- Delegate independent subtasks that can run in parallel
- Perform research or analysis without blocking your main work
- Execute tasks that benefit from a fresh context window
The subagent result is returned when it completes. For long-running tasks,
consider breaking them into smaller focused subtasks.
Example use cases:
- "Research the authentication patterns in this codebase"
- "Write unit tests for the UserService class"
- "Analyze the performance bottlenecks in the database queries"`,
Parameters: map[string]any{
"task": map[string]any{
"type": "string",
"description": "The complete task description for the subagent to perform",
},
"model": map[string]any{
"type": "string",
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
},
"system_prompt": map[string]any{
"type": "string",
"description": "Optional system prompt for domain-specific guidance",
},
"timeout_seconds": map[string]any{
"type": "number",
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
},
},
Required: []string{"task"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeSubagent(ctx, call)
},
}
}
func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args subagentArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("task parameter is required"), nil
}
if args.Task == "" {
return fantasy.NewTextErrorResponse("task parameter is required"), nil
}
// Determine timeout.
timeout := defaultSubagentTimeout
if args.TimeoutSeconds > 0 {
timeout = min(time.Duration(args.TimeoutSeconds)*time.Second, maxSubagentTimeout)
}
// Retrieve in-process spawner from context.
spawner := getSubagentSpawner(ctx)
if spawner == nil {
return fantasy.NewTextErrorResponse(
"Error: subagent spawner not available. " +
"Ensure Kit is initialized with subagent support.",
), fmt.Errorf("no subagent spawner in context")
}
// Spawn in-process subagent.
result, err := spawner(ctx, args.Task, args.Model, args.SystemPrompt, timeout)
if err != nil || result.Error != nil {
spawnErr := err
if spawnErr == nil {
spawnErr = result.Error
}
response := fmt.Sprintf("Subagent failed after %ds.\n\nError: %v",
int(result.Elapsed.Seconds()), spawnErr)
if result.Response != "" {
response += fmt.Sprintf("\n\nPartial output:\n%s", truncateResponse(result.Response, 8000))
}
return fantasy.NewTextErrorResponse(response), nil
}
// Build successful response.
response := fmt.Sprintf("Subagent completed successfully in %ds.", int(result.Elapsed.Seconds()))
if result.InputTokens > 0 || result.OutputTokens > 0 {
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.InputTokens, result.OutputTokens)
}
response += fmt.Sprintf("\n\nResult:\n%s", truncateResponse(result.Response, 12000))
resp := fantasy.NewTextResponse(response)
// Attach subagent session ID as metadata when available.
if result.SessionID != "" {
resp = fantasy.WithResponseMetadata(resp, map[string]any{
"subagent_session_id": result.SessionID,
})
}
return resp, nil
}
// truncateResponse limits the response length to avoid overwhelming context windows.
func truncateResponse(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "\n\n... [truncated — " + fmt.Sprintf("%d", len(s)-maxLen) + " bytes omitted]"
}
+12 -6
View File
@@ -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...),
@@ -86,8 +86,9 @@ func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
}
}
// AllTools returns all available core tools.
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
// SubagentTools returns all core tools except spawn_subagent. This prevents
// infinite recursion when a subagent is itself a Kit instance.
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
NewBashTool(opts...),
NewReadTool(opts...),
@@ -98,3 +99,8 @@ func AllTools(opts ...ToolOption) []fantasy.AgentTool {
NewLsTool(opts...),
}
}
// AllTools returns all available core tools.
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
return append(SubagentTools(opts...), NewSubagentTool(opts...))
}
+7 -2
View File
@@ -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
}
+32 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"charm.land/fantasy"
)
@@ -53,6 +54,14 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
// Read existing content before writing (for diff metadata).
var beforeContent string
isNew := true
if existing, readErr := os.ReadFile(absPath); readErr == nil {
beforeContent = string(existing)
isNew = false
}
// Create parent directories
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -63,5 +72,27 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
return fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path)), nil
resp := fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path))
return fantasy.WithResponseMetadata(resp, writeDiffMeta(absPath, beforeContent, args.Content, isNew)), nil
}
// writeDiffMeta builds the structured metadata attached to write tool responses.
func writeDiffMeta(path, beforeContent, afterContent string, isNew bool) map[string]any {
additions := strings.Count(afterContent, "\n") + 1
deletions := 0
if !isNew {
deletions = strings.Count(beforeContent, "\n") + 1
}
return map[string]any{
"file_diffs": []map[string]any{{
"path": path,
"additions": additions,
"deletions": deletions,
"is_new": isNew,
"diff_blocks": []map[string]any{{
"old_text": beforeContent,
"new_text": afterContent,
}},
}},
}
}
+853 -26
View File
@@ -64,6 +64,19 @@ type Context struct {
// }()
SendMessage func(string)
// CancelAndSend cancels the current agent turn (if running), clears
// the message queue, and sends a new message that executes as soon as
// cancellation completes. If the agent is idle, the message executes
// immediately. This is the "steer" delivery mode.
//
// Use this for directive changes that should interrupt the current
// operation, e.g. switching modes or redirecting the agent.
//
// Example:
//
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
CancelAndSend func(string)
// SetWidget places or updates a persistent widget in the TUI. Widgets
// remain visible across agent turns until explicitly removed. The
// widget is identified by WidgetConfig.ID; calling SetWidget with the
@@ -206,6 +219,438 @@ type Context struct {
// ResetEditor removes the active editor interceptor and restores the
// default built-in editor behavior. No-op if no interceptor is set.
ResetEditor func()
// SetUIVisibility controls which built-in TUI chrome elements are
// visible. By default all elements are shown (zero value = show all).
// Call this during OnSessionStart to configure the initial layout.
//
// Example — minimal chrome:
//
// ctx.SetUIVisibility(ext.UIVisibility{
// HideStartupMessage: true,
// HideStatusBar: true,
// HideSeparator: true,
// HideInputHint: true,
// })
SetUIVisibility func(UIVisibility)
// GetContextStats returns current context-window usage information
// (estimated tokens, context limit, usage percentage, message count).
// Useful for building context meters, auto-compaction triggers, etc.
//
// Example:
//
// stats := ctx.GetContextStats()
// pct := int(stats.UsagePercent * 100)
// fmt.Sprintf("[%s%s] %d%%", strings.Repeat("#", pct/10), strings.Repeat("-", 10-pct/10), pct)
GetContextStats func() ContextStats
// GetMessages returns the conversation messages on the current branch,
// ordered from root to leaf. This is a read-only view; extensions
// cannot modify messages directly.
//
// Example:
//
// msgs := ctx.GetMessages()
// for _, m := range msgs {
// if m.Role == "assistant" {
// lastResponse = m.Content
// }
// }
GetMessages func() []SessionMessage
// GetSessionPath returns the file path of the current session's JSONL
// file. Returns empty string for in-memory (ephemeral) sessions.
GetSessionPath func() string
// AppendEntry persists custom extension data in the session tree.
// The data survives across session restarts and can be retrieved via
// GetEntries. Use entryType to namespace your data (e.g. "myext:state").
//
// Example:
//
// data, _ := json.Marshal(myState)
// ctx.AppendEntry("myext:state", string(data))
AppendEntry func(entryType string, data string) (string, error)
// GetEntries retrieves all persisted extension data entries matching
// the given type on the current branch, ordered root to leaf. Pass
// empty string to retrieve all extension data entries.
//
// Example — restore state on session resume:
//
// entries := ctx.GetEntries("myext:state")
// if len(entries) > 0 {
// last := entries[len(entries)-1]
// json.Unmarshal([]byte(last.Data), &myState)
// }
GetEntries func(entryType string) []ExtensionEntry
// SetEditorText sets the text content of the input editor. This can
// be used to pre-fill the editor with suggested text (e.g. extracted
// questions, handoff prompts). The cursor is moved to the end.
//
// Example:
//
// ctx.SetEditorText("Please review the changes in src/main.go")
SetEditorText func(text string)
// SetStatus places or updates a keyed entry in the TUI status bar.
// Multiple entries from different extensions coexist; each is identified
// by a unique key. Lower priority values render further left.
//
// Example:
//
// ctx.SetStatus("myext:branch", "main", 50)
SetStatus func(key string, text string, priority int)
// RemoveStatus removes a keyed status bar entry. No-op if the key
// does not exist.
RemoveStatus func(key string)
// GetOption returns the value of a named extension option. Options are
// resolved in priority order:
// 1. Runtime override (via SetOption)
// 2. Environment variable: KIT_OPT_<NAME> (uppercase, dashes → underscores)
// 3. Config file: options.<name> in .kit.yml
// 4. Default value registered by the extension
//
// Returns empty string if the option was not registered.
//
// Example:
//
// preset := ctx.GetOption("preset")
// if preset == "fast" {
// ctx.SetModel("anthropic/claude-haiku-3-5-20241022")
// }
GetOption func(name string) string
// SetOption sets a runtime override for a named extension option. This
// takes highest priority over env vars, config, and defaults. Useful for
// persisting user choices during a session.
SetOption func(name string, value string)
// SetModel changes the active LLM model at runtime. The model string
// should be in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
// Existing tools, system prompt, and session are preserved. Returns an
// error if the model string is invalid or the provider cannot be created.
//
// Example:
//
// err := ctx.SetModel("openai/gpt-4o")
// if err != nil {
// ctx.PrintError("Failed to switch model: " + err.Error())
// }
SetModel func(modelString string) error
// GetAvailableModels returns a list of known models from the registry.
// This is an advisory list — models not in the registry can still be
// used by specifying their provider/model string directly.
//
// Example:
//
// models := ctx.GetAvailableModels()
// for _, m := range models {
// fmt.Printf("%s/%s (ctx: %dk)\n", m.Provider, m.ModelID, m.ContextLimit/1000)
// }
GetAvailableModels func() []ModelInfoEntry
// EmitCustomEvent publishes a named event that other extensions can
// subscribe to via api.OnCustomEvent(). Data is an arbitrary string
// (JSON-encode complex payloads). Handlers run synchronously in
// registration order.
//
// Example:
//
// ctx.EmitCustomEvent("plan-mode:toggled", `{"active":true}`)
EmitCustomEvent func(name string, data string)
// GetAllTools returns information about all tools available to the agent,
// including core tools (bash, read, write, etc.), MCP server tools, and
// extension-registered tools. Each entry includes the tool's enabled status.
//
// Example — list read-only tools:
//
// for _, t := range ctx.GetAllTools() {
// if t.Source == "core" && t.Enabled {
// fmt.Println(t.Name, "-", t.Description)
// }
// }
GetAllTools func() []ToolInfo
// SetActiveTools restricts the agent to only the named tools. Tools not
// in the list are blocked from execution (the LLM receives an error if
// it tries to call them). Pass nil or an empty slice to re-enable all
// tools. Tool names are case-sensitive.
//
// Example — plan mode (read-only):
//
// ctx.SetActiveTools([]string{"Read", "Glob", "Grep", "LS"})
SetActiveTools func(names []string)
// Exit triggers a graceful application shutdown. In interactive mode
// this sends a quit signal to the TUI; in non-interactive mode it
// cancels the current operation. Safe to call from any goroutine.
//
// Example:
//
// api.RegisterCommand(ext.CommandDef{
// Name: "quit",
// Description: "Exit the application",
// Execute: func(args string, ctx ext.Context) (string, error) {
// ctx.Exit()
// return "", nil
// },
// })
Exit func()
// Complete makes a standalone LLM completion call, bypassing the agent
// tool loop. Use this for summarisation, question extraction, or any
// sub-task that needs an LLM response without tool access.
//
// If Model is empty the current session model is reused (no extra
// provider creation overhead). Specify a different model string to
// use a cheaper/faster model for the sub-task.
//
// Example — summarise with a fast model:
//
// resp, err := ctx.Complete(ext.CompleteRequest{
// Model: "anthropic/claude-haiku-3-5-20241022",
// System: "You are a concise summarisation assistant.",
// Prompt: "Summarise this conversation:\n" + text,
// })
// if err != nil {
// ctx.PrintError("completion failed: " + err.Error())
// return
// }
// ctx.PrintInfo(resp.Text)
//
// Example — streaming completion:
//
// resp, err := ctx.Complete(ext.CompleteRequest{
// Prompt: "Explain quantum computing",
// OnChunk: func(chunk string) {
// fmt.Print(chunk) // stream to stdout
// },
// })
Complete func(CompleteRequest) (CompleteResponse, error)
// SuspendTUI temporarily releases the terminal from the TUI, runs the
// provided callback (which may spawn interactive processes like vim or
// htop), and then restores the TUI. In non-interactive mode the
// callback runs directly with no terminal changes.
//
// The callback has full access to stdin/stdout/stderr while the TUI is
// suspended. Return from the callback to restore the TUI.
//
// Example — launch $EDITOR:
//
// err := ctx.SuspendTUI(func() {
// editor := os.Getenv("EDITOR")
// if editor == "" { editor = "vim" }
// cmd := exec.Command(editor, "file.go")
// cmd.Stdin = os.Stdin
// cmd.Stdout = os.Stdout
// cmd.Stderr = os.Stderr
// cmd.Run()
// })
SuspendTUI func(callback func()) error
// RenderMessage outputs text using a named message renderer registered
// by an extension via api.RegisterMessageRenderer(). If no renderer
// with the given name exists, the content is printed as plain text.
//
// This allows extensions to define reusable visual styles (borders,
// colors, formatting) for specific message categories and invoke them
// by name at runtime.
//
// Example:
//
// ctx.RenderMessage("build-status", "All 42 tests passed.")
RenderMessage func(rendererName string, content string)
// ReloadExtensions hot-reloads all extensions from disk. Existing
// extensions receive a SessionShutdown event, then new code is loaded
// and receives a SessionStart event. Event handlers, commands,
// renderers, and shortcuts update immediately; extension-defined tools
// are NOT updated (they are baked into the agent at creation time).
//
// After calling ReloadExtensions the calling extension's code has been
// replaced; the caller should return promptly.
//
// Example:
//
// api.RegisterCommand(ext.CommandDef{
// Name: "reload",
// Description: "Hot-reload all extensions",
// Execute: func(args string, ctx ext.Context) (string, error) {
// if err := ctx.ReloadExtensions(); err != nil {
// return "", err
// }
// return "Extensions reloaded", nil
// },
// })
ReloadExtensions func() error
// SpawnSubagent spawns a child Kit instance to perform a task autonomously.
// The subagent runs as a separate subprocess with full tool access but
// isolated session and extensions (--no-session --no-extensions).
//
// When config.Blocking is true, blocks until completion and returns the
// result directly (handle is nil). When false, returns immediately with
// a handle for monitoring/cancellation.
//
// Example — blocking call:
//
// _, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
// Prompt: "Research authentication patterns in this codebase",
// Blocking: true,
// Timeout: 2 * time.Minute,
// })
// if err != nil {
// ctx.PrintError("spawn failed: " + err.Error())
// return
// }
// ctx.PrintInfo("Subagent result:\n" + result.Response)
//
// Example — background spawn with callbacks:
//
// handle, _, _ := ctx.SpawnSubagent(ext.SubagentConfig{
// Prompt: "Write unit tests for UserService",
// OnOutput: func(chunk string) {
// // Live output streaming
// },
// OnComplete: func(result ext.SubagentResult) {
// ctx.SendMessage("Subagent finished:\n" + result.Response)
// },
// })
// // handle.Kill() to cancel, handle.Wait() to block
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
}
// ---------------------------------------------------------------------------
// Session types (exposed to Yaegi — concrete structs for session access)
// ---------------------------------------------------------------------------
// SessionMessage represents a conversation message exposed to extensions.
// This is a simplified, read-only view of the internal message structures.
type SessionMessage struct {
// ID is the unique entry identifier in the session tree.
ID string
// ParentID links this entry to its parent in the tree.
ParentID string
// Role is the message role: "user", "assistant", "tool", or "system".
Role string
// Content is the text content of the message (tool calls and results
// are serialized as text summaries).
Content string
// Model is the model that generated this message (empty for user messages).
Model string
// Provider is the provider used (empty for user messages).
Provider string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
}
// ExtensionEntry represents persisted extension data stored in the session.
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
// it on session resume.
type ExtensionEntry struct {
// ID is the unique entry identifier.
ID string
// EntryType is the extension-defined type string (e.g. "plan-mode:state").
EntryType string
// Data is the extension-defined payload (JSON or plain text).
Data string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
}
// ---------------------------------------------------------------------------
// Context filtering types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ContextMessage represents a single message in the LLM context window.
// Used by OnContextPrepare to let extensions inspect and modify the messages
// that will be sent to the LLM.
type ContextMessage struct {
// Index is the position of this message in the original context array
// (0-based). When returning messages from a ContextPrepareResult,
// messages with Index >= 0 reuse the original fantasy.Message at that
// position (preserving tool calls, reasoning, and other complex parts).
// Set Index to -1 for newly injected messages (created from Role + Content).
Index int
// Role is the message role: "user", "assistant", "system", or "tool".
Role string
// Content is the text content of the message. For assistant messages
// with tool calls, this includes a text summary of the calls.
Content string
}
// ---------------------------------------------------------------------------
// LLM completion types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// CompleteRequest configures a standalone LLM completion call. Extensions use
// this with ctx.Complete() to make direct LLM calls without the agent tool loop.
type CompleteRequest struct {
// Model is the model to use in "provider/model" format (e.g.
// "anthropic/claude-haiku-3-5-20241022"). Empty string uses the current
// session model, avoiding extra provider creation overhead.
Model string
// Prompt is the user input text sent to the model.
Prompt string
// System is an optional system prompt. Empty uses no system prompt.
System string
// Messages is optional conversation history. If provided, Prompt is
// appended as the final user message.
Messages []SessionMessage
// MaxTokens limits the response length (0 = provider default).
MaxTokens int
// OnChunk is called for each streaming text delta. When set, the
// completion is performed in streaming mode. When nil, the call blocks
// until the full response is available.
OnChunk func(chunk string)
}
// CompleteResponse contains the LLM response and usage metadata from a
// standalone completion call.
type CompleteResponse struct {
// Text is the complete response text.
Text string
// InputTokens is the number of tokens in the request.
InputTokens int
// OutputTokens is the number of tokens in the response.
OutputTokens int
// Model is the actual model used (useful when CompleteRequest.Model was empty).
Model string
}
// ---------------------------------------------------------------------------
// Status bar types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// StatusBarEntry represents a keyed entry in the TUI status bar. Extensions
// can set multiple independent entries that render alongside the built-in
// model name and token usage display.
type StatusBarEntry struct {
// Key uniquely identifies this entry (e.g. "myext:git-branch").
Key string
// Text is the rendered content shown in the status bar.
Text string
// Priority controls ordering. Lower values render further left.
// Built-in entries (model, usage) have implicit priority 100-110.
Priority int
}
// PrintBlockOpts configures a custom styled block for PrintBlock.
@@ -233,22 +678,31 @@ type PrintBlockOpts struct {
// register typed event handlers, custom tools, and slash commands.
type API struct {
// Event-specific registration functions (wired by the loader).
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
onToolExecStart func(func(ToolExecutionStartEvent, Context))
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
onInput func(func(InputEvent, Context) *InputResult)
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
onAgentStart func(func(AgentStartEvent, Context))
onAgentEnd func(func(AgentEndEvent, Context))
onMessageStart func(func(MessageStartEvent, Context))
onMessageUpdate func(func(MessageUpdateEvent, Context))
onMessageEnd func(func(MessageEndEvent, Context))
onSessionStart func(func(SessionStartEvent, Context))
onSessionShutdown func(func(SessionShutdownEvent, Context))
registerToolFn func(ToolDef)
registerCmdFn func(CommandDef)
registerToolRendererFn func(ToolRenderConfig)
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
onToolExecStart func(func(ToolExecutionStartEvent, Context))
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
onInput func(func(InputEvent, Context) *InputResult)
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
onAgentStart func(func(AgentStartEvent, Context))
onAgentEnd func(func(AgentEndEvent, Context))
onMessageStart func(func(MessageStartEvent, Context))
onMessageUpdate func(func(MessageUpdateEvent, Context))
onMessageEnd func(func(MessageEndEvent, Context))
onSessionStart func(func(SessionStartEvent, Context))
onSessionShutdown func(func(SessionShutdownEvent, Context))
registerToolFn func(ToolDef)
registerCmdFn func(CommandDef)
registerToolRendererFn func(ToolRenderConfig)
onModelChange func(func(ModelChangeEvent, Context))
onContextPrepare func(func(ContextPrepareEvent, Context) *ContextPrepareResult)
onBeforeFork func(func(BeforeForkEvent, Context) *BeforeForkResult)
onBeforeSessionSwitch func(func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult)
onBeforeCompact func(func(BeforeCompactEvent, Context) *BeforeCompactResult)
onCustomEvent func(name string, handler func(string))
registerOption func(OptionDef)
registerShortcutFn func(ShortcutDef, func(Context))
registerMessageRendererFn func(MessageRendererConfig)
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -319,6 +773,36 @@ func (a *API) OnSessionShutdown(handler func(SessionShutdownEvent, Context)) {
a.onSessionShutdown(handler)
}
// OnModelChange registers a handler that fires after the active model is
// changed via ctx.SetModel(). The handler receives the new and previous model
// strings plus the source of the change.
func (a *API) OnModelChange(handler func(ModelChangeEvent, Context)) {
a.onModelChange(handler)
}
// OnContextPrepare registers a handler that fires after the context window is
// built from the session tree (including compaction) and before the messages
// are sent to the LLM. The handler can inspect the context and return a
// modified message set to filter, reorder, or inject messages.
//
// Return nil to leave the context unchanged. Return a non-nil result with
// a Messages slice to replace the context window entirely. Messages with a
// non-negative Index reuse the original message at that position (preserving
// tool calls, reasoning parts, etc.); messages with Index < 0 are created
// fresh from Role + Content.
//
// Example — inject a RAG context message:
//
// api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
// ragContext := fetchRelevantDocs(e.Messages[len(e.Messages)-1].Content)
// injected := ext.ContextMessage{Index: -1, Role: "system", Content: ragContext}
// msgs := append([]ext.ContextMessage{injected}, e.Messages...)
// return &ext.ContextPrepareResult{Messages: msgs}
// })
func (a *API) OnContextPrepare(handler func(ContextPrepareEvent, Context) *ContextPrepareResult) {
a.onContextPrepare(handler)
}
// RegisterTool adds a custom tool that the LLM can invoke.
func (a *API) RegisterTool(tool ToolDef) {
a.registerToolFn(tool)
@@ -329,6 +813,55 @@ func (a *API) RegisterCommand(cmd CommandDef) {
a.registerCmdFn(cmd)
}
// RegisterOption declares a named configuration option. The option can be set
// via environment variables (KIT_OPT_<NAME>) or config file (options.<name>).
// Multiple extensions can register options with the same name; the last default
// wins.
func (a *API) RegisterOption(opt OptionDef) {
a.registerOption(opt)
}
// RegisterShortcut registers a global keyboard shortcut that fires across
// all app states except modal prompts/overlays. Use modifier combinations
// like "ctrl+p", "alt+t", or "f1" — avoid bare characters that conflict
// with text input. If multiple extensions register the same key, the last
// registration wins. The handler runs in a goroutine so it can call blocking
// APIs like PromptSelect without stalling the TUI event loop.
func (a *API) RegisterShortcut(def ShortcutDef, handler func(Context)) {
if a.registerShortcutFn != nil {
a.registerShortcutFn(def, handler)
}
}
// OnCustomEvent registers a handler for a custom inter-extension event.
// The handler receives the data string published by EmitCustomEvent.
// Multiple handlers can subscribe to the same event name; they execute
// in registration order.
func (a *API) OnCustomEvent(name string, handler func(string)) {
a.onCustomEvent(name, handler)
}
// OnBeforeFork registers a handler that fires before the session tree is
// branched to a different entry point. Return a non-nil BeforeForkResult
// with Cancel=true to prevent the fork.
func (a *API) OnBeforeFork(handler func(BeforeForkEvent, Context) *BeforeForkResult) {
a.onBeforeFork(handler)
}
// OnBeforeSessionSwitch registers a handler that fires before the session
// is switched to a new branch (e.g. /new command). Return a non-nil
// BeforeSessionSwitchResult with Cancel=true to prevent the switch.
func (a *API) OnBeforeSessionSwitch(handler func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
a.onBeforeSessionSwitch(handler)
}
// OnBeforeCompact registers a handler that fires before context compaction
// runs. Return a non-nil BeforeCompactResult with Cancel=true to prevent
// compaction from proceeding.
func (a *API) OnBeforeCompact(handler func(BeforeCompactEvent, Context) *BeforeCompactResult) {
a.onBeforeCompact(handler)
}
// RegisterToolRenderer registers a custom renderer for a specific tool's
// display in the TUI. The renderer controls the header (parameter summary)
// and/or body (result display) of the tool's output block. If multiple
@@ -337,6 +870,17 @@ func (a *API) RegisterToolRenderer(config ToolRenderConfig) {
a.registerToolRendererFn(config)
}
// RegisterMessageRenderer registers a named message renderer that extensions
// can invoke via ctx.RenderMessage(name, content). Use this to define
// reusable visual styles for branded output, progress reports, or custom
// notification formats. If multiple extensions register the same name, the
// last one wins.
func (a *API) RegisterMessageRenderer(config MessageRendererConfig) {
if a.registerMessageRendererFn != nil {
a.registerMessageRendererFn(config)
}
}
// ---------------------------------------------------------------------------
// Widget types (exposed to Yaegi — concrete structs, no interfaces)
// ---------------------------------------------------------------------------
@@ -472,6 +1016,36 @@ type HeaderFooterConfig struct {
Style WidgetStyle
}
// ---------------------------------------------------------------------------
// UI visibility (exposed to Yaegi — concrete struct)
// ---------------------------------------------------------------------------
// UIVisibility controls which built-in TUI chrome elements are visible.
// The zero value shows everything (backward compatible). Extensions call
// ctx.SetUIVisibility to customise the layout — for example, a "minimal"
// theme can hide the startup banner, status bar, and input hint and replace
// them with a single custom footer.
type UIVisibility struct {
HideStartupMessage bool // Hide the "Model loaded..." startup block
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
HideSeparator bool // Hide the "────────" divider between stream and input
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
}
// ---------------------------------------------------------------------------
// Context stats (exposed to Yaegi — concrete struct)
// ---------------------------------------------------------------------------
// ContextStats contains current context-window usage information.
// Extensions can poll this via ctx.GetContextStats() to build usage
// meters, auto-compaction triggers, etc.
type ContextStats struct {
EstimatedTokens int // Estimated token count of the current conversation
ContextLimit int // Model's context window size (tokens), 0 if unknown
UsagePercent float64 // Fraction of context used (0.01.0), 0 if limit unknown
MessageCount int // Number of messages in the conversation
}
// ---------------------------------------------------------------------------
// Overlay types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
@@ -563,16 +1137,73 @@ type OverlayResult struct {
Cancelled bool
}
// ---------------------------------------------------------------------------
// Model info types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ModelInfoEntry represents a known model from the registry. Used by
// GetAvailableModels to let extensions discover which models are available.
type ModelInfoEntry struct {
// Provider is the provider ID (e.g. "anthropic", "openai").
Provider string
// ModelID is the model identifier (e.g. "claude-sonnet-4-5-20250929").
ModelID string
// Name is the human-readable model name.
Name string
// ContextLimit is the maximum context window in tokens (0 if unknown).
ContextLimit int
// OutputLimit is the maximum output tokens (0 if unknown).
OutputLimit int
// Reasoning is true if the model supports extended thinking.
Reasoning bool
}
// ---------------------------------------------------------------------------
// Tool info types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ToolInfo provides read-only information about a tool available to the agent.
// Used by GetAllTools to let extensions inspect and filter the tool set.
type ToolInfo struct {
// Name is the tool's unique identifier.
Name string
// Description is the tool's human-readable description.
Description string
// Source indicates where the tool came from: "core", "mcp", or "extension".
Source string
// Enabled is true if the tool is currently active.
Enabled bool
}
// ---------------------------------------------------------------------------
// ToolDef / CommandDef
// ---------------------------------------------------------------------------
// ToolContext provides runtime context to a tool's ExecuteWithContext handler.
// It allows tools to check for cancellation and report progress while running.
type ToolContext struct {
// IsCancelled returns true when the tool's execution has been cancelled
// (e.g. the user interrupted the agent or the request timed out).
// Long-running tools should poll this periodically and return early.
IsCancelled func() bool
// OnProgress sends a progress message that is displayed in the TUI
// while the tool is executing. Useful for long-running operations
// that want to show incremental status.
OnProgress func(text string)
}
// ToolDef describes a custom tool registered by an extension.
type ToolDef struct {
Name string
Description string
Parameters string // JSON Schema string
Execute func(input string) (string, error)
// Execute is the simple handler — receives JSON input, returns text result.
// Use this for tools that don't need cancellation or progress reporting.
Execute func(input string) (string, error)
// ExecuteWithContext is the rich handler — receives JSON input plus a
// ToolContext that provides cancellation checking and progress reporting.
// If both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
ExecuteWithContext func(input string, tc ToolContext) (string, error)
}
// CommandDef describes a slash command registered by an extension.
@@ -580,6 +1211,74 @@ type CommandDef struct {
Name string
Description string
Execute func(args string, ctx Context) (string, error)
// Complete provides argument tab-completion for this command.
// Called with the partial argument text typed so far; returns
// candidate completions. Nil means no argument completion.
Complete func(prefix string, ctx Context) []string
}
// ---------------------------------------------------------------------------
// Keyboard shortcuts (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ShortcutDef describes a global keyboard shortcut registered by an extension.
// Shortcuts fire across all app states except modal prompts/overlays.
// Use modifier combinations (e.g., "ctrl+p", "alt+t", "f1") — avoid bare
// characters like "a" or "x" which conflict with text input.
type ShortcutDef struct {
// Key is the key binding (e.g., "ctrl+p", "alt+t", "f1", "ctrl+shift+s").
Key string
// Description explains what the shortcut does (shown in /shortcuts help).
Description string
}
// ---------------------------------------------------------------------------
// Custom message rendering (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// MessageRendererConfig provides a named rendering function that extensions
// can invoke via ctx.RenderMessage(name, content). Unlike tool renderers
// (which hook into the automatic tool result display), message renderers are
// invoked explicitly by extension code for branded status updates, progress
// reports, or any custom visual output.
//
// Example:
//
// api.RegisterMessageRenderer(ext.MessageRendererConfig{
// Name: "build-status",
// Render: func(content string, width int) string {
// border := strings.Repeat("─", width-4)
// return "╭" + border + "╮\n│ " + content + "\n╰" + border + "╯"
// },
// })
type MessageRendererConfig struct {
// Name uniquely identifies this renderer. Used by ctx.RenderMessage
// to look it up at call time. Should be namespaced to avoid collisions
// (e.g. "myext:build-status").
Name string
// Render produces the styled output string from raw content. Receives
// the content and the terminal width in columns. Return the final
// ANSI-styled string to print; it will be emitted via tea.Println
// (or plain stdout in non-interactive mode).
Render func(content string, width int) string
}
// ---------------------------------------------------------------------------
// Extension options (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// OptionDef describes a configuration option that an extension can register.
// Options are resolved from env vars, config file, or default value.
type OptionDef struct {
// Name is the option identifier. Used as:
// - Env var: KIT_OPT_<NAME> (uppercased, dashes → underscores)
// - Config key: options.<name> in .kit.yml
Name string
// Description explains what the option controls.
Description string
// Default is the fallback value if not set via env or config.
Default string
}
// ---------------------------------------------------------------------------
@@ -693,8 +1392,7 @@ type EditorKeyAction struct {
// submit) and/or modify the rendered output (add mode indicators, apply visual
// effects).
//
// This follows Pi's extension editor pattern (modal editor, rainbow editor)
// but uses concrete function fields instead of interfaces for Yaegi safety.
// Uses concrete function fields instead of interfaces for Yaegi safety.
//
// IMPORTANT (Yaegi limitation): Function fields MUST be set using anonymous
// function literals (closures), NOT bare function references. Yaegi does not
@@ -734,7 +1432,13 @@ type EditorConfig struct {
type ToolCallEvent struct {
ToolName string
ToolCallID string
Input string // JSON-encoded tool parameters
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
Input string // JSON-encoded tool parameters
ParsedArgs map[string]any // Pre-parsed arguments for convenience (nil on parse failure)
// Source indicates who initiated the tool call.
// Currently always "llm" (all tool calls originate from the LLM agent loop).
// Future user-initiated tool features may set this to "user".
Source string
}
func (e ToolCallEvent) Type() EventType { return ToolCall }
@@ -749,24 +1453,31 @@ func (ToolCallResult) isResult() {}
// ToolExecutionStartEvent fires when a tool begins executing.
type ToolExecutionStartEvent struct {
ToolName string
ToolCallID string
ToolName string
ToolKind string
}
func (e ToolExecutionStartEvent) Type() EventType { return ToolExecutionStart }
// ToolExecutionEndEvent fires when a tool finishes executing.
type ToolExecutionEndEvent struct {
ToolName string
ToolCallID string
ToolName string
ToolKind string
}
func (e ToolExecutionEndEvent) Type() EventType { return ToolExecutionEnd }
// ToolResultEvent fires after tool execution with the output.
type ToolResultEvent struct {
ToolName string
Input string
Content string
IsError bool
ToolCallID string
ToolName string
ToolKind string
Input string
Content string
IsError bool
Metadata string // Optional JSON-encoded structured metadata (e.g. file diffs)
}
func (e ToolResultEvent) Type() EventType { return ToolResult }
@@ -856,4 +1567,120 @@ func (e SessionStartEvent) Type() EventType { return SessionStart }
// SessionShutdownEvent fires when the application is closing.
type SessionShutdownEvent struct{}
// ModelChangeEvent fires after the active model is changed via ctx.SetModel().
type ModelChangeEvent struct {
// NewModel is the model string that was set (e.g. "anthropic/claude-sonnet-4-5-20250929").
NewModel string
// PreviousModel is the model string before the change.
PreviousModel string
// Source indicates what triggered the change: "extension" for ctx.SetModel(),
// "user" for interactive model selection.
Source string
}
func (e SessionShutdownEvent) Type() EventType { return SessionShutdown }
func (e ModelChangeEvent) Type() EventType { return ModelChange }
// ContextPrepareEvent fires after the context window is built from the session
// tree and before the messages are sent to the LLM. Handlers can inspect the
// messages and return a modified set to filter, reorder, or inject context.
type ContextPrepareEvent struct {
// Messages is the current context window that will be sent to the LLM.
// Each ContextMessage includes an Index field that maps back to the
// position in the original message array (for identity-preserving edits).
Messages []ContextMessage
}
func (e ContextPrepareEvent) Type() EventType { return ContextPrepare }
// ContextPrepareResult allows extensions to replace the context window.
// Return nil to leave the context unchanged.
type ContextPrepareResult struct {
// Messages replaces the entire context window. Each entry with a
// non-negative Index reuses the original message at that position
// (preserving tool calls, reasoning, etc.); entries with Index < 0
// are created fresh from Role + Content.
Messages []ContextMessage
}
func (ContextPrepareResult) isResult() {}
// BeforeForkEvent fires before the session tree is branched to a different
// entry point (via the tree selector or /fork command).
type BeforeForkEvent struct {
// TargetID is the session entry ID being branched to.
TargetID string
// IsUserMessage is true if the selected entry is a user message
// (which causes the fork to target the parent entry).
IsUserMessage bool
// UserText is the user message text (non-empty only when IsUserMessage is true).
UserText string
}
func (e BeforeForkEvent) Type() EventType { return BeforeFork }
// BeforeForkResult controls whether the fork proceeds. Return Cancel=true
// with an optional Reason to block the fork.
type BeforeForkResult struct {
// Cancel, when true, prevents the fork from proceeding.
Cancel bool
// Reason is a human-readable explanation shown to the user when
// Cancel is true. Empty string uses a default message.
Reason string
}
func (BeforeForkResult) isResult() {}
// BeforeSessionSwitchEvent fires before the session is switched to a new
// branch (e.g. /new or /clear commands).
type BeforeSessionSwitchEvent struct {
// Reason describes why the switch is happening: "new" for /new command,
// "clear" for /clear command.
Reason string
}
func (e BeforeSessionSwitchEvent) Type() EventType { return BeforeSessionSwitch }
// BeforeSessionSwitchResult controls whether the session switch proceeds.
// Return Cancel=true with an optional Reason to block the switch.
type BeforeSessionSwitchResult struct {
// Cancel, when true, prevents the session switch from proceeding.
Cancel bool
// Reason is a human-readable explanation shown to the user when
// Cancel is true. Empty string uses a default message.
Reason string
}
func (BeforeSessionSwitchResult) isResult() {}
// BeforeCompactEvent fires before context compaction runs. Provides
// information about the current context state to help extensions decide
// whether to allow or block compaction.
type BeforeCompactEvent 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.01.0).
UsagePercent float64
// MessageCount is the number of messages in the conversation.
MessageCount int
// IsAutomatic is true when compaction was triggered automatically
// (as opposed to manual /compact command).
IsAutomatic bool
}
func (e BeforeCompactEvent) Type() EventType { return BeforeCompact }
// BeforeCompactResult controls whether compaction proceeds. Return
// Cancel=true with an optional Reason to block compaction.
type BeforeCompactResult struct {
// Cancel, when true, prevents compaction from proceeding.
Cancel bool
// Reason is a human-readable explanation shown to the user when
// Cancel is true. Empty string uses a default message.
Reason string
}
func (BeforeCompactResult) isResult() {}
+23 -1
View File
@@ -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,
}
}
+7 -2
View File
@@ -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 {
+57
View File
@@ -283,6 +283,48 @@ 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)
},
@@ -292,6 +334,21 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
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.
+333 -20
View File
@@ -2,34 +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
widgets map[string]WidgetConfig // keyed by widget ID
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
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, commands, or tool renderers the extension provided.
type LoadedExtension struct {
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
ToolRenderers []ToolRenderConfig
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.
@@ -45,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 {
@@ -121,13 +146,6 @@ 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
@@ -177,6 +195,45 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
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
// ---------------------------------------------------------------------------
@@ -269,6 +326,29 @@ func (r *Runner) GetEditor() *EditorConfig {
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
// ---------------------------------------------------------------------------
@@ -290,6 +370,233 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
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
// ---------------------------------------------------------------------------
@@ -311,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
}
+377
View File
@@ -0,0 +1,377 @@
// Package extensions provides subagent spawning capabilities for Kit extensions.
package extensions
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
)
// ---------------------------------------------------------------------------
// Subagent types
// ---------------------------------------------------------------------------
// SubagentConfig configures a subagent spawn.
type SubagentConfig struct {
// Prompt is the task/instruction for the subagent (required).
Prompt string
// Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022").
// Empty string uses the parent's current model.
Model string
// SystemPrompt provides domain-specific instructions.
// Empty string uses the default system prompt.
SystemPrompt string
// Timeout limits execution time. Zero means 5 minute default.
Timeout time.Duration
// OnOutput streams stderr output chunks as the subagent runs.
// Called from a goroutine; must be safe for concurrent use.
OnOutput func(chunk string)
// OnEvent receives real-time events from the subagent's execution:
// text chunks, tool calls, tool results, reasoning deltas, etc.
// Called synchronously from the subagent's event loop.
OnEvent func(SubagentEvent)
// OnComplete is called when the subagent finishes (success or error).
// Called from a goroutine; must be safe for concurrent use.
OnComplete func(result SubagentResult)
// Blocking, when true, makes SpawnSubagent wait for completion and
// return the result directly. When false (default), spawns in background
// and returns immediately with a handle.
Blocking bool
// NoSession, when true, runs the subagent without persisting a session
// file. By default (false), subagent sessions are persisted so they can
// be loaded for replay/inspection. Set to true for ephemeral tasks
// where session history is not needed.
NoSession bool
// ParentSessionID links the subagent's session to the parent (optional).
// When set, the subagent's session header includes a parent reference
// so viewers can navigate the session tree.
ParentSessionID string
}
// SubagentEvent carries a real-time event from a running subagent. Extensions
// use the Type field to determine what happened and read the relevant fields.
// This is a concrete struct (not an interface) for Yaegi compatibility.
type SubagentEvent struct {
// Type identifies the event: "text", "reasoning", "tool_call",
// "tool_result", "tool_execution_start", "tool_execution_end",
// "turn_start", "turn_end".
Type string
// Content carries text for "text" and "reasoning" events.
Content string
// ToolCallID is set on tool_call, tool_result, tool_execution_start,
// and tool_execution_end events.
ToolCallID string
// ToolName is set on tool-related events.
ToolName string
// ToolKind is set on tool-related events.
ToolKind string
// ToolArgs is set on tool_call events (JSON-encoded).
ToolArgs string
// ToolResult is set on tool_result events.
ToolResult string
// IsError is set on tool_result events.
IsError bool
}
// SubagentResult contains the outcome of a subagent execution.
type SubagentResult struct {
// Response is the subagent's final text response.
Response string
// Error is set if the subagent failed (nil on success).
Error error
// ExitCode is the subprocess exit code (0 = success).
ExitCode int
// Elapsed is the total execution time.
Elapsed time.Duration
// Usage contains token usage if available.
Usage *SubagentUsage
// SessionID is the subagent's session identifier, if available.
// Populated when the subagent persists its session (requires running
// without --no-session). Empty for ephemeral sessions.
SessionID string
}
// SubagentUsage contains token usage from the subagent's run.
type SubagentUsage struct {
InputTokens int64
OutputTokens int64
}
// SubagentHandle provides control over a running subagent.
type SubagentHandle struct {
// ID is a unique identifier for this subagent instance.
ID string
proc *os.Process
done chan struct{}
result *SubagentResult
mu sync.Mutex
}
// Kill terminates the subagent process.
func (h *SubagentHandle) Kill() error {
h.mu.Lock()
proc := h.proc
h.mu.Unlock()
if proc != nil {
return proc.Kill()
}
return nil
}
// Wait blocks until the subagent completes and returns the result.
func (h *SubagentHandle) Wait() SubagentResult {
<-h.done
h.mu.Lock()
defer h.mu.Unlock()
if h.result != nil {
return *h.result
}
return SubagentResult{Error: fmt.Errorf("subagent completed without result")}
}
// Done returns a channel that closes when the subagent completes.
func (h *SubagentHandle) Done() <-chan struct{} {
return h.done
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// subagentJSONOutput matches the JSON envelope produced by `kit --json`.
type subagentJSONOutput struct {
Response string `json:"response"`
StopReason string `json:"stop_reason,omitempty"`
SessionID string `json:"session_id,omitempty"`
Usage *struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage,omitempty"`
}
var subagentCounter uint64
func generateSubagentID() string {
n := atomic.AddUint64(&subagentCounter, 1)
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
}
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"
}
// ---------------------------------------------------------------------------
// SpawnSubagent implementation
// ---------------------------------------------------------------------------
// SpawnSubagent spawns a child Kit instance to perform a task.
//
// When config.Blocking is true, blocks until completion and returns the result
// directly (handle is nil). When false, returns immediately with a handle for
// monitoring/cancellation.
//
// The subagent runs with --json --no-session --no-extensions flags by default,
// ensuring isolation from the parent's extensions and session state.
func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
if cfg.Prompt == "" {
return nil, nil, fmt.Errorf("prompt is required")
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 5 * time.Minute
}
kitBinary := findKitBinary()
// Build subprocess arguments.
args := []string{
"--json",
"--no-extensions",
}
if cfg.NoSession {
args = append(args, "--no-session")
}
if cfg.Model != "" {
args = append(args, "--model", cfg.Model)
}
// Handle system prompt - write to temp file if provided.
var tmpFile *os.File
if cfg.SystemPrompt != "" {
var err error
tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt")
if err != nil {
return nil, nil, fmt.Errorf("create temp file: %w", err)
}
if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
return nil, nil, fmt.Errorf("write system prompt: %w", err)
}
_ = tmpFile.Close()
args = append(args, "--system-prompt", tmpFile.Name())
}
// Add the prompt as a positional argument.
args = append(args, cfg.Prompt)
// Create command with timeout context.
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, kitBinary, args...)
cmd.Env = os.Environ()
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("stderr pipe: %w", err)
}
handle := &SubagentHandle{
ID: generateSubagentID(),
done: make(chan struct{}),
}
// Start the subprocess.
start := time.Now()
if err := cmd.Start(); err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("start subprocess: %w", err)
}
handle.mu.Lock()
handle.proc = cmd.Process
handle.mu.Unlock()
// Run the subprocess monitoring in a goroutine.
go func() {
defer close(handle.done)
defer cancel()
if tmpFile != nil {
defer func() { _ = os.Remove(tmpFile.Name()) }()
}
var wg sync.WaitGroup
var stdoutBuf strings.Builder
// Read stderr (live output).
wg.Go(func() {
scanner := bufio.NewScanner(stderr)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
if cfg.OnOutput != nil && strings.TrimSpace(line) != "" {
cfg.OnOutput(line + "\n")
}
}
})
// Read stdout (JSON output).
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
stdoutBuf.WriteString(scanner.Text() + "\n")
}
wg.Wait()
waitErr := cmd.Wait()
elapsed := time.Since(start)
// Build result.
result := SubagentResult{Elapsed: elapsed}
if waitErr != nil {
result.Error = waitErr
if exitErr, ok := waitErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = 1
}
}
// Parse JSON output.
raw := strings.TrimSpace(stdoutBuf.String())
var parsed subagentJSONOutput
if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil {
result.Response = parsed.Response
result.SessionID = parsed.SessionID
if parsed.Usage != nil {
result.Usage = &SubagentUsage{
InputTokens: parsed.Usage.InputTokens,
OutputTokens: parsed.Usage.OutputTokens,
}
}
} else {
// Fallback: use raw stdout.
result.Response = raw
}
handle.mu.Lock()
handle.result = &result
handle.proc = nil
handle.mu.Unlock()
if cfg.OnComplete != nil {
cfg.OnComplete(result)
}
}()
if cfg.Blocking {
// Wait for completion and return result directly.
<-handle.done
handle.mu.Lock()
r := handle.result
handle.mu.Unlock()
return nil, r, nil
}
return handle, nil, nil
}
+52
View File
@@ -23,9 +23,31 @@ 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)),
@@ -37,6 +59,12 @@ func Symbols() interp.Exports {
// 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),
@@ -49,6 +77,9 @@ func Symbols() interp.Exports {
// 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),
@@ -66,6 +97,26 @@ func Symbols() interp.Exports {
"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)),
// Subagent types
"SubagentConfig": reflect.ValueOf((*SubagentConfig)(nil)),
"SubagentResult": reflect.ValueOf((*SubagentResult)(nil)),
"SubagentUsage": reflect.ValueOf((*SubagentUsage)(nil)),
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
@@ -84,6 +135,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)),
},
}
}
+128 -18
View File
@@ -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,14 +30,47 @@ 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
}
// coreToolKinds maps built-in tool names to their kind classification.
var coreToolKinds = map[string]string{
"bash": "execute",
"edit": "edit",
"write": "edit",
"read": "read",
"ls": "read",
"grep": "search",
"find": "search",
"spawn_subagent": "agent",
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
// "execute" for unknown tools (including MCP tools).
func toolKindFor(toolName string) string {
if kind, ok := coreToolKinds[toolName]; ok {
return kind
}
return "execute"
}
// parseToolArgsJSON attempts to parse JSON-encoded tool args into a map.
// Returns nil on failure (non-fatal convenience parsing).
func parseToolArgsJSON(input string) map[string]any {
var parsed map[string]any
if json.Unmarshal([]byte(input), &parsed) == nil {
return parsed
}
return nil
}
// ---------------------------------------------------------------------------
// wrappedTool — intercepts tool calls through the extension runner
// ---------------------------------------------------------------------------
@@ -55,12 +87,24 @@ 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)
}
kind := toolKindFor(toolName)
// 1. Emit ToolCall — extensions can block execution.
if w.runner.HasHandlers(ToolCall) {
result, _ := w.runner.Emit(ToolCallEvent{
ToolName: toolName,
ToolCallID: call.ID,
ToolKind: kind,
Input: call.Input,
ParsedArgs: parseToolArgsJSON(call.Input),
Source: "llm",
})
if r, ok := result.(ToolCallResult); ok && r.Block {
reason := r.Reason
@@ -74,7 +118,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
// 2. Emit ToolExecutionStart.
if w.runner.HasHandlers(ToolExecutionStart) {
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolName: toolName})
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
}
// 3. Execute the actual tool.
@@ -82,16 +126,19 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
// 4. Emit ToolExecutionEnd.
if w.runner.HasHandlers(ToolExecutionEnd) {
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolName: toolName})
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
}
// 5. Emit ToolResult — extensions can modify output.
if w.runner.HasHandlers(ToolResult) {
result, _ := w.runner.Emit(ToolResultEvent{
ToolName: toolName,
Input: call.Input,
Content: resp.Content,
IsError: err != nil || resp.IsError,
ToolCallID: call.ID,
ToolName: toolName,
ToolKind: kind,
Input: call.Input,
Content: resp.Content,
IsError: err != nil || resp.IsError,
Metadata: resp.Metadata,
})
if r, ok := result.(ToolResultResult); ok {
if r.Content != nil {
@@ -112,21 +159,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
}
+121 -5
View File
@@ -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()
+2 -1
View File
@@ -79,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
@@ -186,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,
+51 -2
View File
@@ -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,
})
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package models
import (
"context"
"sync"
"time"
"charm.land/fantasy"
)
// ProviderPool manages reusable LLM provider instances to reduce overhead
// when spawning multiple subagents or making repeated completion calls.
type ProviderPool struct {
mu sync.RWMutex
providers map[string]*pooledProvider
ttl time.Duration
closed bool
closeCh chan struct{}
}
type pooledProvider struct {
model fantasy.LanguageModel
closer func() error
providerOpts fantasy.ProviderOptions
created time.Time
lastUsed time.Time
refs int32
}
// DefaultPoolTTL is the default time-to-live for idle pooled providers.
const DefaultPoolTTL = 5 * time.Minute
// globalPool is the singleton provider pool instance.
var globalPool *ProviderPool
var poolOnce sync.Once
// GetGlobalPool returns the singleton provider pool instance.
func GetGlobalPool() *ProviderPool {
poolOnce.Do(func() {
globalPool = NewProviderPool(DefaultPoolTTL)
})
return globalPool
}
// NewProviderPool creates a provider pool with the given TTL for idle providers.
func NewProviderPool(ttl time.Duration) *ProviderPool {
p := &ProviderPool{
providers: make(map[string]*pooledProvider),
ttl: ttl,
closeCh: make(chan struct{}),
}
go p.cleanupLoop()
return p
}
// Get returns a provider for the model string, creating one if needed.
// The returned release function must be called when the provider is no longer
// needed. The provider may be reused by subsequent Get calls.
func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) {
p.mu.Lock()
// Check if we have an existing provider.
if pp, ok := p.providers[modelString]; ok {
pp.refs++
pp.lastUsed = time.Now()
p.mu.Unlock()
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
p.mu.Unlock()
// Create a new provider outside the lock.
config := &ProviderConfig{ModelString: modelString}
result, err := CreateProvider(ctx, config)
if err != nil {
return nil, nil, nil, err
}
p.mu.Lock()
defer p.mu.Unlock()
// Double-check: another goroutine may have created one while we were unlocked.
if pp, ok := p.providers[modelString]; ok {
// Close the one we just created and use the existing one.
if result.Closer != nil {
_ = result.Closer.Close()
}
pp.refs++
pp.lastUsed = time.Now()
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
var closerFn func() error
if result.Closer != nil {
closerFn = result.Closer.Close
}
pp := &pooledProvider{
model: result.Model,
closer: closerFn,
providerOpts: result.ProviderOptions,
created: time.Now(),
lastUsed: time.Now(),
refs: 1,
}
p.providers[modelString] = pp
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
func (p *ProviderPool) release(modelString string) {
p.mu.Lock()
defer p.mu.Unlock()
if pp, ok := p.providers[modelString]; ok {
pp.refs--
pp.lastUsed = time.Now()
}
}
func (p *ProviderPool) cleanupLoop() {
ticker := time.NewTicker(p.ttl / 2)
defer ticker.Stop()
for {
select {
case <-p.closeCh:
return
case <-ticker.C:
p.cleanup()
}
}
}
func (p *ProviderPool) cleanup() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for key, pp := range p.providers {
// Only clean up providers with no active references and past TTL.
if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl {
if pp.closer != nil {
_ = pp.closer()
}
delete(p.providers, key)
}
}
}
// Close shuts down the pool and releases all providers.
func (p *ProviderPool) Close() {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return
}
p.closed = true
close(p.closeCh)
for key, pp := range p.providers {
if pp.closer != nil {
_ = pp.closer()
}
delete(p.providers, key)
}
p.mu.Unlock()
}
// Stats returns current pool statistics.
func (p *ProviderPool) Stats() PoolStats {
p.mu.RLock()
defer p.mu.RUnlock()
stats := PoolStats{
TotalProviders: len(p.providers),
}
for _, pp := range p.providers {
if pp.refs > 0 {
stats.ActiveProviders++
} else {
stats.IdleProviders++
}
}
return stats
}
// PoolStats contains provider pool statistics.
type PoolStats struct {
TotalProviders int
ActiveProviders int
IdleProviders int
}
+194 -8
View File
@@ -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").
@@ -146,10 +210,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
}
}
// Validate environment variables
if err := registry.ValidateEnvironment(provider, config.ProviderAPIKey); err != nil {
return nil, err
}
// NOTE: We intentionally skip registry.ValidateEnvironment() here.
// Each create*Provider function handles its own auth resolution and
// produces provider-specific error messages. The early env-var check
// was too narrow — it didn't account for stored credentials (e.g.
// OAuth tokens from 'kit auth login') and blocked valid auth paths.
// Validate config against known model limits when metadata is available
if modelInfo != nil {
@@ -256,6 +321,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 +364,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 +384,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 +417,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 +548,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 +604,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 +624,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) {
@@ -869,9 +1043,21 @@ type oauthTransport struct {
}
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Resolve the freshest available token. The credential manager
// automatically refreshes tokens nearing expiry (5-minute buffer).
// This keeps long-lived sessions (e.g. ACP) working across token
// renewals. Falls back to the originally-provided token if the
// credential manager is unavailable.
token := t.accessToken
if cm, err := auth.NewCredentialManager(); err == nil {
if fresh, err := cm.GetValidAccessToken(); err == nil && fresh != "" {
token = fresh
}
}
newReq := req.Clone(req.Context())
newReq.Header.Del("x-api-key")
newReq.Header.Set("Authorization", "Bearer "+t.accessToken)
newReq.Header.Set("Authorization", "Bearer "+token)
newReq.Header.Set("anthropic-beta", "oauth-2025-04-20")
newReq.Header.Set("anthropic-version", "2023-06-01")
+1
View File
@@ -78,6 +78,7 @@ func TestCreateOAuthHTTPClient(t *testing.T) {
if client == nil {
t.Fatal("expected non-nil client")
return
}
// Check that the transport is an oauthTransport
+18 -3
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"os"
"strings"
"github.com/mark3labs/kit/internal/auth"
)
//go:embed embedded_models.json
@@ -171,14 +173,27 @@ func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
return providerInfo.Env, nil
}
// ValidateEnvironment checks if required environment variables are set.
// Returns nil for providers not in the registry (unknown providers are
// assumed to handle auth themselves or via --provider-api-key).
// ValidateEnvironment checks if required credentials are available for a
// provider. It checks the explicit API key, stored credentials (for
// providers that support them, such as Anthropic OAuth), and environment
// variables. Returns nil for providers not in the registry (unknown
// providers are assumed to handle auth themselves or via --provider-api-key).
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
if apiKey != "" {
return nil
}
// For anthropic, also check stored credentials (OAuth / API key)
// since auth resolution goes through the credential manager, not
// just environment variables.
if provider == "anthropic" {
if cm, err := auth.NewCredentialManager(); err == nil {
if has, _ := cm.HasAnthropicCredentials(); has {
return nil
}
}
}
envVars, err := r.GetRequiredEnvVars(provider)
if err != nil {
// Unknown provider — nothing to validate
+31 -2
View File
@@ -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.
@@ -37,6 +38,10 @@ type SessionHeader struct {
Timestamp time.Time `json:"timestamp"` // creation time
Cwd string `json:"cwd"` // working directory
ParentSession string `json:"parent_session,omitempty"` // path to parent if forked
// Subagent fields (set when session is created by a subagent)
ParentSessionID string `json:"parent_session_id,omitempty"` // UUID of parent session
SubagentTask string `json:"subagent_task,omitempty"` // original task prompt
}
// Entry is the common structure shared by all tree entries (everything except
@@ -89,6 +94,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 +190,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 +263,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)
}
+33 -1
View File
@@ -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
@@ -29,6 +29,12 @@ type SessionInfo struct {
// ParentSessionPath is the parent session path if this session was forked.
ParentSessionPath string
// ParentSessionID is the UUID of the parent session (for subagent sessions).
ParentSessionID string
// SubagentTask is the original task prompt (for subagent sessions).
SubagentTask string
// Created is when the session was first created.
Created time.Time
@@ -162,6 +168,8 @@ func extractSessionInfo(path string) (*SessionInfo, error) {
info.Created = h.Timestamp
info.Modified = h.Timestamp
info.ParentSessionPath = h.ParentSession
info.ParentSessionID = h.ParentSessionID
info.SubagentTask = h.SubagentTask
continue
}
@@ -245,3 +253,27 @@ func extractTextPreview(partsJSON json.RawMessage) string {
func DeleteSession(path string) error {
return os.Remove(path)
}
// ListChildSessions returns all sessions that have the given session ID as
// their parent. This is useful for finding subagent sessions spawned from
// a parent session. Results are sorted by creation time (newest first).
func ListChildSessions(parentID string) ([]SessionInfo, error) {
if parentID == "" {
return nil, nil
}
allSessions, err := ListAllSessions()
if err != nil {
return nil, err
}
var children []SessionInfo
for _, s := range allSessions {
if s.ParentSessionID == parentID {
children = append(children, s)
}
}
// Already sorted by modification time from ListAllSessions
return children, nil
}
+45 -3
View File
@@ -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 {
+1 -1
View File
@@ -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 ""
+17 -1
View File
@@ -11,6 +11,7 @@ type blockRenderer struct {
align *lipgloss.Position
borderColor *color.Color
background *color.Color
foreground *color.Color
fullWidth bool
noBorder bool
paddingTop int
@@ -123,6 +124,15 @@ func WithBackground(c color.Color) renderingOption {
}
}
// WithForeground returns a renderingOption that overrides the default text
// foreground color (theme.Text) for the block. Useful for muted or
// de-emphasized content blocks.
func WithForeground(c color.Color) renderingOption {
return func(br *blockRenderer) {
br.foreground = &c
}
}
// WithWidth returns a renderingOption that sets a specific width for the block
// in characters. This overrides the default container width and allows precise
// control over the block's horizontal dimensions.
@@ -167,13 +177,19 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
theme := GetTheme()
// Resolve foreground color: caller override or theme default.
fgColor := theme.Text
if renderer.foreground != nil {
fgColor = *renderer.foreground
}
// Single-pass render: padding, border, and foreground in one style.
style := lipgloss.NewStyle().
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
Foreground(theme.Text)
Foreground(fgColor)
if hasBorder {
style = style.BorderStyle(lipgloss.ThickBorder())
+50 -7
View File
@@ -348,6 +348,9 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
// Receive first chunk — spinner should keep running.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
}
@@ -372,6 +375,9 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
c = sendStreamMsg(c, app.StreamChunkEvent{Content: chunk})
}
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
got := c.streamContent.String()
want := "Hello, world!"
if got != want {
@@ -397,8 +403,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
if !strings.Contains(c.spinnerMsg, "exec_tool") {
t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg)
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
}
if cmd == nil {
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
@@ -410,7 +416,11 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c := newTestStream()
// Start spinning first (simulating execution in progress).
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
c.spinnerMsg = "Executing some_tool…"
// Simulate a tool starting
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolName: "some_tool",
IsStarting: true,
})
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolName: "some_tool",
@@ -420,8 +430,41 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
}
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg cleared after tool finished, got %q", c.spinnerMsg)
if len(c.activeTools) != 0 {
t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools)
}
}
// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently.
func TestStreamComponent_ParallelToolExecution(t *testing.T) {
c := newTestStream()
// Start three tools in parallel
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
if len(c.activeTools) != 3 {
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
}
// Check SpinnerView shows all tools
view := c.SpinnerView()
if !strings.Contains(view, "Running:") {
t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view)
}
// Finish one tool
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
if len(c.activeTools) != 2 {
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
}
// Finish remaining tools
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
if len(c.activeTools) != 0 {
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
}
}
@@ -480,8 +523,8 @@ func TestStreamComponent_Reset(t *testing.T) {
if !c.timestamp.IsZero() {
t.Fatal("expected zero timestamp after Reset()")
}
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg empty after Reset(), got %q", c.spinnerMsg)
if len(c.activeTools) != 0 {
t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools)
}
}
+32 -2
View File
@@ -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
+19 -13
View File
@@ -44,15 +44,20 @@ func (r *CompactRenderer) SetWidth(width int) {
// and metadata.
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Secondary).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true).Render("User")
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
// Convert single newlines to paragraph breaks so they survive glamour's
// markdown rendering (glamour treats single \n as a soft break).
content = strings.ReplaceAll(content, "\n", "\n\n")
// Format content for user messages (preserve formatting, no truncation)
compactContent := r.formatUserAssistantContent(content)
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var compactContent string
if strings.Contains(content, "`") {
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
compactContent = r.formatUserAssistantContent(mdContent)
compactContent = removeBlankLines(compactContent)
} else {
compactContent = content
}
// Handle multi-line content
lines := strings.Split(compactContent, "\n")
@@ -170,7 +175,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params — check extension renderer first.
paramBudget := max(r.width-10-len(displayName), 20)
@@ -188,7 +193,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Format body: check extension renderer first, then builtin, then default.
// Format body: check extension renderer first, then compact builtin, then default.
var body string
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-4)
@@ -201,7 +206,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if isError {
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
// 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 == "" {
@@ -234,8 +240,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
// formatted to fit on a single line for minimal space usage.
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.System).Render("*")
label := lipgloss.NewStyle().Foreground(theme.System).Bold(true).Render("System")
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("")
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
compactContent := r.formatCompactContent(content)
+43
View File
@@ -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
}
+129
View File
@@ -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))
}
+388
View File
@@ -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
}
+332 -27
View File
@@ -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,
@@ -54,10 +89,10 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
@@ -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 / shift+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
}
}
+55 -115
View File
@@ -3,8 +3,7 @@ package ui
import (
"encoding/json"
"fmt"
"os"
"os/user"
"regexp"
"sort"
"strings"
"time"
@@ -12,6 +11,9 @@ import (
"charm.land/lipgloss/v2"
)
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// MessageType represents different categories of messages displayed in the UI,
// each with distinct visual styling and formatting rules.
type MessageType int
@@ -154,21 +156,6 @@ type MessageRenderer struct {
getToolRenderer func(toolName string) *ToolRendererData
}
// getSystemUsername returns the current system username, fallback to "User"
func getSystemUsername() string {
if currentUser, err := user.Current(); err == nil && currentUser.Username != "" {
return currentUser.Username
}
// Fallback to environment variable
if username := os.Getenv("USER"); username != "" {
return username
}
if username := os.Getenv("USERNAME"); username != "" {
return username
}
return "User"
}
// NewMessageRenderer creates and initializes a new MessageRenderer with the specified
// terminal width and debug mode setting. The width parameter determines line wrapping
// and layout calculations.
@@ -189,31 +176,30 @@ func (r *MessageRenderer) SetWidth(width int) {
// formatting, including the system username, timestamp, and markdown-rendered content.
// The message is displayed with a colored right border for visual distinction.
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
// Format timestamp and username
timeStr := timestamp.Local().Format("15:04")
username := getSystemUsername()
// Convert single newlines to paragraph breaks so they survive glamour's
// markdown rendering (glamour treats single \n as a soft break).
content = strings.ReplaceAll(content, "\n", "\n\n")
theme := getTheme()
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var messageContent string
if strings.Contains(content, "`") {
// Glamour treats single \n as a soft break, so convert to paragraph
// breaks and collapse the resulting blank lines after rendering.
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
messageContent = r.renderMarkdown(mdContent, r.width-8)
messageContent = removeBlankLines(messageContent)
} else {
messageContent = content
}
// Create info line
info := fmt.Sprintf(" %s (%s)", username, timeStr)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the block renderer — left border with Primary color, no background.
// Left border with Blue color for user messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Primary),
WithBorderColor(theme.Info),
WithMarginBottom(1),
)
@@ -230,14 +216,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
// are displayed with a special "Finished without output" message. The message features
// a colored left border for visual distinction.
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
// Format timestamp and model info with better defaults
timeStr := timestamp.Local().Format("15:04")
if modelName == "" {
modelName = "Assistant"
}
// Handle empty content with better styling
theme := getTheme()
var messageContent string
if strings.TrimSpace(content) == "" {
messageContent = lipgloss.NewStyle().
@@ -246,21 +226,16 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
Align(lipgloss.Center).
Render("Finished without output")
} else {
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
messageContent = r.renderMarkdown(content, r.width-8)
}
// Create info line
info := fmt.Sprintf(" %s (%s)", modelName, timeStr)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer — no borders for agent messages.
// Left border with Primary (Mauve) color for assistant messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithNoBorder(),
WithBorderColor(theme.Primary),
WithMarginBottom(1),
)
@@ -276,35 +251,24 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
// and informational notifications. These messages are displayed with a distinctive system
// color border and "KIT System" label to differentiate them from user and AI content.
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Handle empty content with better styling
theme := getTheme()
var messageContent string
if strings.TrimSpace(content) == "" {
messageContent = lipgloss.NewStyle().
Italic(true).
Foreground(theme.Muted).
Align(lipgloss.Center).
Render("No content available")
messageContent = "No content available"
} else if strings.Contains(content, "`") {
messageContent = r.renderMarkdown(content, r.width-8)
} else {
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
messageContent = content
}
// Create info line
info := fmt.Sprintf(" KIT System (%s)", timeStr)
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
// Combine content and info
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.System),
WithNoBorder(),
WithForeground(theme.Muted),
WithMarginBottom(1),
)
@@ -322,29 +286,22 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
// Create the main message style with border using tool color
theme := getTheme()
style := baseStyle.
Width(r.width - 3). // Account for left margin
Width(r.width - 3).
BorderLeft(true).
Foreground(theme.Muted).
BorderForeground(theme.Tool).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1).
MarginLeft(2). // Add left margin like other messages
MarginBottom(1) // Add bottom margin
MarginLeft(2).
MarginBottom(1)
// Format timestamp
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
// Create header with debug icon
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔍 Debug Output")
// Process and format the message content
// Split into lines and format each one
lines := strings.Split(message, "\n")
var formattedLines []string
for _, line := range lines {
@@ -357,17 +314,9 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
Foreground(theme.Muted).
Render(strings.Join(formattedLines, "\n"))
// Create info line
info := baseStyle.
Width(r.width - 5). // Account for margins and padding
Foreground(theme.Muted).
Render(fmt.Sprintf(" KIT (%s)", timeStr))
// Combine all parts
fullContent := lipgloss.JoinVertical(lipgloss.Left,
header,
content,
info,
)
return UIMessage{
@@ -382,7 +331,6 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
// Create the main message style with border using tool color
theme := getTheme()
style := baseStyle.
Width(r.width - 1).
@@ -392,16 +340,11 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1)
// Format timestamp
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
// Create header with debug icon
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔧 Debug Configuration")
// Format configuration settings
var configLines []string
for key, value := range config {
if value != nil {
@@ -413,18 +356,10 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
Foreground(theme.Muted).
Render(strings.Join(configLines, "\n"))
// Create info line
info := baseStyle.
Width(r.width - 1).
Foreground(theme.Muted).
Render(fmt.Sprintf(" KIT (%s)", timeStr))
// Combine parts
parts := []string{header}
if len(configLines) > 0 {
parts = append(parts, configContent)
}
parts = append(parts, info)
rendered := style.Render(
lipgloss.JoinVertical(lipgloss.Left, parts...),
@@ -442,26 +377,15 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
// bold text to ensure visibility. Error messages include timestamp information and
// are displayed with an error-colored border for immediate recognition.
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Format error content
theme := getTheme()
errorContent := lipgloss.NewStyle().
Foreground(theme.Error).
Bold(true).
Render(errorMsg)
// Create info line
info := fmt.Sprintf(" Error (%s)", timeStr)
// Combine content and info
fullContent := errorContent + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
errorContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Error),
@@ -559,7 +483,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params with width budget for the header line.
// Check extension renderer for custom header params first.
@@ -710,3 +634,19 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
rendered := toMarkdown(content, width)
return strings.TrimSuffix(rendered, "\n")
}
// removeBlankLines removes lines that are visually blank from rendered output.
// Glamour wraps every character (including padding spaces) with ANSI color
// codes, so we must strip escape sequences before checking whether a line is
// empty. This collapses paragraph spacing so user messages render without
// extra vertical gaps.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
filtered := lines[:0]
for _, line := range lines {
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
filtered = append(filtered, line)
}
}
return strings.Join(filtered, "\n")
}
+874 -57
View File
File diff suppressed because it is too large Load Diff
+413
View File
@@ -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
}
+26 -6
View File
@@ -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"
)
@@ -57,6 +58,15 @@ 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
// --------------------------------------------------------------------------
@@ -79,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 {
@@ -393,14 +405,16 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
}
// TestQueuedMessages_poppedOnQueueUpdated verifies that QueueUpdatedEvent pops
// consumed messages from queuedMessages and prints them to scrollback.
// consumed messages from queuedMessages and moves them to pendingUserPrints.
// The actual printing is deferred to SpinnerEvent{Show: true} to preserve
// chronological order with the preceding assistant response.
func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.queuedMessages = []string{"first", "second", "third"}
// Simulate drainQueue popping one item (length goes from 3 to 2).
_, cmd := m.Update(app.QueueUpdatedEvent{Length: 2})
m = sendMsg(m, app.QueueUpdatedEvent{Length: 2})
if len(m.queuedMessages) != 2 {
t.Fatalf("expected 2 queued messages after pop, got %d", len(m.queuedMessages))
@@ -408,14 +422,17 @@ func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
if m.queuedMessages[0] != "second" {
t.Fatalf("expected first remaining message 'second', got %q", m.queuedMessages[0])
}
// Should produce a cmd (tea.Println for the popped user message).
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) for popped message")
// Popped message should be deferred to pendingUserPrints.
if len(m.pendingUserPrints) != 1 {
t.Fatalf("expected 1 pending user print, got %d", len(m.pendingUserPrints))
}
if m.pendingUserPrints[0] != "first" {
t.Fatalf("expected pending message 'first', got %q", m.pendingUserPrints[0])
}
}
// TestQueuedMessages_allPoppedOnDrain verifies that QueueUpdatedEvent with
// Length=0 pops all remaining queued messages.
// Length=0 pops all remaining queued messages into pendingUserPrints.
func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
@@ -426,6 +443,9 @@ func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
if len(m.queuedMessages) != 0 {
t.Fatalf("expected 0 queued messages after drain, got %d", len(m.queuedMessages))
}
if len(m.pendingUserPrints) != 2 {
t.Fatalf("expected 2 pending user prints, got %d", len(m.pendingUserPrints))
}
}
// --------------------------------------------------------------------------
+1 -1
View File
@@ -83,7 +83,7 @@ func newInputPrompt(message, placeholder, defaultValue string, width, height int
// Prevent Enter from inserting a newline — we intercept it for submit.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
)
if defaultValue != "" {
+14 -11
View File
@@ -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
@@ -41,10 +42,10 @@ func NewSlashCommandInput(width int, title string) *SlashCommandInput {
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
@@ -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 / shift+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()))
+277 -18
View File
@@ -1,6 +1,7 @@
package ui
import (
"fmt"
"strings"
"time"
@@ -69,6 +70,25 @@ func streamSpinnerTickCmd() tea.Cmd {
})
}
// streamFlushTickMsg fires when it's time to commit pending chunks to the
// main content builders and trigger a re-render. This coalesces rapid
// streaming chunks into fewer expensive markdown re-renders.
type streamFlushTickMsg struct{}
// streamFlushInterval is the coalescing window for stream chunks. Chunks
// arriving within this window are batched into a single render pass.
// 16ms ≈ 60 fps — fast enough to appear smooth, slow enough to coalesce
// bursts from the LLM provider.
const streamFlushInterval = 16 * time.Millisecond
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
// the coalescing interval.
func streamFlushTickCmd() tea.Cmd {
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
return streamFlushTickMsg{}
})
}
// streamPhase tracks what the StreamComponent is currently displaying.
type streamPhase int
@@ -114,13 +134,47 @@ type StreamComponent struct {
// spinnerFrame is the current frame index.
spinnerFrame int
// spinnerMsg is the label shown next to the KITT animation (e.g.
// "Executing tool_name…"). Empty string means no label.
spinnerMsg string
// activeTools tracks the names of tools currently executing in parallel.
// When multiple tools run concurrently, all are displayed in the spinner.
activeTools []string
// streamContent accumulates all streaming text chunks.
// streamContent holds committed streaming text (flushed from pending).
streamContent strings.Builder
// reasoningContent holds committed reasoning text (flushed from pending).
reasoningContent strings.Builder
// pendingStream accumulates streaming text chunks between flush ticks.
// Chunks are written here immediately on arrival, then moved to
// streamContent when the flush tick fires.
pendingStream strings.Builder
// pendingReasoning accumulates reasoning chunks between flush ticks.
pendingReasoning strings.Builder
// flushPending is true while a flush tick is in-flight. Prevents
// scheduling duplicate ticks when multiple chunks arrive within
// the same coalescing window.
flushPending bool
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
// renderDirty is true when committed content has changed since the
// last render. Set on flush tick; cleared after render() rebuilds
// the cache.
renderDirty bool
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
// reasoningStartTime records when the first reasoning chunk was received.
reasoningStartTime time.Time
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// messageRenderer renders assistant messages in standard mode.
messageRenderer *MessageRenderer
@@ -166,7 +220,12 @@ func (s *StreamComponent) SetHeight(h int) {
if h < 0 {
h = 0
}
s.height = h
if s.height != h {
s.height = h
// Invalidate cache — height clamp affects output.
s.renderCache = ""
s.renderDirty = true
}
}
// Reset clears all accumulated state so the component is ready for the next
@@ -175,20 +234,60 @@ func (s *StreamComponent) Reset() {
s.phase = streamPhaseIdle
s.spinning = false
s.spinnerFrame = 0
s.spinnerMsg = ""
s.activeTools = nil
s.streamContent.Reset()
s.reasoningContent.Reset()
s.pendingStream.Reset()
s.pendingReasoning.Reset()
s.flushPending = false
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
}
// GetRenderedContent returns the rendered assistant message from the accumulated
// streaming text. Returns empty string if no text has been accumulated. Used by
// the parent AppModel to flush content via tea.Println() before resetting.
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
func (s *StreamComponent) GetRenderedContent() string {
// Commit any pending chunks so the final output is complete.
s.commitPending()
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")
}
// commitPending moves any pending chunks to the committed content builders.
// Called before reading content for scrollback output or on flush tick.
func (s *StreamComponent) commitPending() {
if s.pendingStream.Len() > 0 {
s.streamContent.WriteString(s.pendingStream.String())
s.pendingStream.Reset()
s.renderDirty = true
}
if s.pendingReasoning.Len() > 0 {
s.reasoningContent.WriteString(s.pendingReasoning.String())
s.pendingReasoning.Reset()
s.renderDirty = true
}
}
// --------------------------------------------------------------------------
@@ -209,6 +308,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.width = msg.Width
s.messageRenderer.SetWidth(s.width)
s.compactRenderer.SetWidth(s.width)
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
case streamSpinnerTickMsg:
if s.spinning {
@@ -228,6 +330,26 @@ 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 streamFlushTickMsg:
s.flushPending = false
s.commitPending()
case app.ReasoningChunkEvent:
s.phase = streamPhaseActive
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
if s.reasoningStartTime.IsZero() {
s.reasoningStartTime = time.Now()
}
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
}
case app.StreamChunkEvent:
@@ -235,12 +357,21 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
s.streamContent.WriteString(msg.Content)
// Freeze reasoning duration on transition from reasoning to streaming.
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
s.reasoningDuration = time.Since(s.reasoningStartTime)
}
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
}
case app.ToolExecutionEvent:
if msg.IsStarting {
// Show the tool name on the spinner while the tool executes.
s.spinnerMsg = "Executing " + msg.ToolName + "…"
// Add tool to active list for parallel execution display.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = append(s.activeTools, toolDisplay)
s.spinnerFrame = 0
if !s.spinning {
s.phase = streamPhaseActive
@@ -248,8 +379,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamSpinnerTickCmd()
}
} else {
// Tool finished — clear execution label but keep spinning.
s.spinnerMsg = ""
// Tool finished — remove from active list but keep spinning if others remain.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
}
}
@@ -265,20 +397,41 @@ func (s *StreamComponent) View() tea.View {
// Internal rendering
// --------------------------------------------------------------------------
// render builds the full content string for the stream region.
// render builds the full content string for the stream region. Uses a render
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
// is invalidated when committed content changes (flush tick), terminal width
// changes, or height/thinking visibility changes.
func (s *StreamComponent) render() string {
if s.phase == streamPhaseIdle {
return ""
}
// Return cached render if committed content hasn't changed.
if !s.renderDirty {
return s.renderCache
}
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 {
s.renderCache = ""
s.renderDirty = false
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.
@@ -290,9 +443,88 @@ func (s *StreamComponent) render() string {
}
}
s.renderCache = content
s.renderDirty = false
return content
}
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
// box. When collapsed, shows the last 10 lines with a truncation hint. When
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
theme := GetTheme()
maxWidth := max(s.width-4, 20)
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
contentStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true)
var parts []string
// When collapsed and content exceeds 10 lines, show only the last 10
// with a truncation hint (matching iteratr's thinking block pattern).
const maxCollapsedLines = 10
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
hidden := len(lines) - maxCollapsedLines
hintStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Italic(true)
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
lines = lines[len(lines)-maxCollapsedLines:]
}
// Render reasoning text.
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
// Duration footer.
var duration time.Duration
if s.reasoningDuration > 0 {
duration = s.reasoningDuration
} else if !s.reasoningStartTime.IsZero() {
duration = time.Since(s.reasoningStartTime)
}
if duration > 0 {
var durationStr string
if duration < time.Second {
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ") +
lipgloss.NewStyle().Foreground(theme.Info).Render(durationStr)
parts = append(parts, footer)
}
innerContent := strings.Join(parts, "\n")
// Wrap in box with surface background for visual distinction.
boxStyle := lipgloss.NewStyle().
Background(theme.MutedBorder). // Surface0 (#313244)
PaddingLeft(1).
Width(maxWidth + 2).
MarginBottom(1)
return boxStyle.Render(innerContent)
}
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
func (s *StreamComponent) SetThinkingVisible(visible bool) {
if s.thinkingVisible != visible {
s.thinkingVisible = visible
// Invalidate cache — thinking visibility affects rendered output.
s.renderCache = ""
s.renderDirty = true
}
}
// HasReasoning returns true if any reasoning content has been accumulated
// (committed or pending).
func (s *StreamComponent) HasReasoning() bool {
return s.reasoningContent.Len() > 0 || s.pendingReasoning.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 {
@@ -300,14 +532,22 @@ func (s *StreamComponent) SpinnerView() string {
return ""
}
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
if s.spinnerMsg == "" {
if len(s.activeTools) == 0 {
return " " + frame
}
theme := GetTheme()
msgStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Italic(true)
return " " + frame + " " + msgStyle.Render(s.spinnerMsg)
// Format active tools list
var toolsMsg string
if len(s.activeTools) == 1 {
toolsMsg = s.activeTools[0]
} else {
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
}
return " " + frame + " " + msgStyle.Render(toolsMsg)
}
// renderStreamingText renders the accumulated streaming text as a live assistant
@@ -325,3 +565,22 @@ func (s *StreamComponent) renderStreamingText(text string) string {
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
}
// removeFromSlice removes the first occurrence of a string from a slice.
func removeFromSlice(slice []string, s string) []string {
for i, v := range slice {
if v == s {
return append(slice[:i], slice[i+1:]...)
}
}
return slice
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
func formatToolExecutionMessage(toolName, toolArgs string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
return toolName
}
+332 -1
View File
@@ -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
}
@@ -45,6 +49,10 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderBashBody(toolResult, width); body != "" {
return body
}
case toolName == "spawn_subagent":
if body := renderSubagentBody(toolResult, width); body != "" {
return body
}
}
return "" // fall back to default
}
@@ -292,6 +300,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 +700,297 @@ 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)
case toolName == "spawn_subagent":
return renderSubagentCompact(toolResult)
}
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)
}
// ---------------------------------------------------------------------------
// Subagent tool renderers — show only summary, not full output
// ---------------------------------------------------------------------------
// renderSubagentBody renders a clean summary of subagent results.
// Extracts timing/token info and shows only a brief summary instead of raw output.
func renderSubagentBody(toolResult string, width int) string {
theme := getTheme()
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
// Parse the subagent result format:
// "Subagent completed successfully in Xs. (tokens: N in / M out)\n\nResult:\n..."
// or "Subagent failed (exit code X) after Ys.\n\nError: ...\n\nPartial output:\n..."
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
// First line is always the status summary
statusLine := lines[0]
// Build a clean summary
var summary strings.Builder
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
// For successful results, extract a brief preview of the actual result
if strings.Contains(statusLine, "successfully") {
// Find where "Result:" starts and extract a preview
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
resultContent = strings.TrimSpace(resultContent)
if resultContent != "" {
// Show first 3 meaningful lines as preview
preview := extractSubagentPreview(resultContent, 3, width-4)
if preview != "" {
summary.WriteString("\n\n")
summary.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(preview))
}
}
}
}
return summary.String()
}
// extractSubagentPreview extracts the first N non-empty lines from content,
// truncating each line to maxWidth.
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
lines := strings.Split(content, "\n")
var preview []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Truncate long lines
if len(trimmed) > maxWidth {
trimmed = trimmed[:maxWidth-3] + "..."
}
preview = append(preview, trimmed)
if len(preview) >= maxLines {
break
}
}
if len(preview) == 0 {
return ""
}
result := strings.Join(preview, "\n")
// Count remaining lines for "more" indicator
totalLines := 0
for _, line := range lines {
if strings.TrimSpace(line) != "" {
totalLines++
}
}
if totalLines > maxLines {
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
}
return result
}
// renderSubagentCompact returns a brief one-line summary for subagent results.
func renderSubagentCompact(toolResult string) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
theme := getTheme()
// Extract just the first line which contains the status
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
statusLine := lines[0]
// Make it more compact by removing redundant words
statusLine = strings.Replace(statusLine, "Subagent completed successfully in ", "Completed in ", 1)
statusLine = strings.Replace(statusLine, "Subagent failed", "Failed", 1)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(statusLine)
}
+1 -2
View File
@@ -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
+2
View File
@@ -24,6 +24,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
stats := regularTracker.GetLastRequestStats()
if stats == nil {
t.Fatal("Expected stats to be non-nil")
return
}
// Check that costs are calculated for regular API key
@@ -48,6 +49,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
oauthStats := oauthTracker.GetLastRequestStats()
if oauthStats == nil {
t.Fatal("Expected OAuth stats to be non-nil")
return
}
// Check that all costs are $0 for OAuth
+41 -3
View File
@@ -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
View File
@@ -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)
}
+106 -11
View File
@@ -1,6 +1,9 @@
package kit
import "sync"
import (
"encoding/json"
"sync"
)
// ---------------------------------------------------------------------------
// Event types
@@ -34,6 +37,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"
)
// ---------------------------------------------------------------------------
@@ -46,6 +51,54 @@ type Event interface {
EventType() EventType
}
// ---------------------------------------------------------------------------
// Tool kind constants
// ---------------------------------------------------------------------------
// ToolKind constants classify what a tool does, enabling UIs to render
// appropriate visualizations (e.g. diff view for edit tools, command+output
// for execute tools) and file trackers to identify which results contain
// modifications.
const (
ToolKindExecute = "execute" // Shell execution (bash)
ToolKindEdit = "edit" // File modification (edit, write)
ToolKindRead = "read" // File reading (read, ls)
ToolKindSearch = "search" // Content/file search (grep, find)
ToolKindSubagent = "agent" // Subagent spawning (spawn_subagent)
)
// coreToolKinds maps built-in tool names to their kind. MCP and extension
// tools without an entry default to ToolKindExecute.
var coreToolKinds = map[string]string{
"bash": ToolKindExecute,
"edit": ToolKindEdit,
"write": ToolKindEdit,
"read": ToolKindRead,
"ls": ToolKindRead,
"grep": ToolKindSearch,
"find": ToolKindSearch,
"spawn_subagent": ToolKindSubagent,
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
// ToolKindExecute for unknown tools.
func toolKindFor(toolName string) string {
if kind, ok := coreToolKinds[toolName]; ok {
return kind
}
return ToolKindExecute
}
// parseToolArgs attempts to parse a JSON-encoded tool args string into a map.
// Returns nil on failure (non-fatal convenience parsing).
func parseToolArgs(toolArgs string) map[string]any {
var parsed map[string]any
if json.Unmarshal([]byte(toolArgs), &parsed) == nil {
return parsed
}
return nil
}
// ---------------------------------------------------------------------------
// Concrete event structs
// ---------------------------------------------------------------------------
@@ -60,8 +113,9 @@ func (e TurnStartEvent) EventType() EventType { return EventTurnStart }
// TurnEndEvent fires after the agent finishes processing.
type TurnEndEvent struct {
Response string
Error error
Response string
Error error
StopReason string // "end_turn", "max_tokens", "tool_use", "error", etc.
}
// EventType implements Event.
@@ -81,6 +135,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
@@ -91,8 +153,11 @@ func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }
// ToolCallEvent fires when a tool call has been parsed.
type ToolCallEvent struct {
ToolName string
ToolArgs string
ToolCallID string // Stable ID for correlating tool lifecycle events
ToolName string
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
ToolArgs string // JSON-encoded arguments
ParsedArgs map[string]any // Pre-parsed arguments for convenience (nil on parse failure)
}
// EventType implements Event.
@@ -100,7 +165,10 @@ func (e ToolCallEvent) EventType() EventType { return EventToolCall }
// ToolExecutionStartEvent fires when a tool begins executing.
type ToolExecutionStartEvent struct {
ToolName string
ToolCallID string
ToolName string
ToolKind string
ToolArgs string
}
// EventType implements Event.
@@ -108,7 +176,9 @@ func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecuti
// ToolExecutionEndEvent fires when a tool finishes executing.
type ToolExecutionEndEvent struct {
ToolName string
ToolCallID string
ToolName string
ToolKind string
}
// EventType implements Event.
@@ -116,10 +186,35 @@ func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecution
// ToolResultEvent fires after a tool execution completes with its result.
type ToolResultEvent struct {
ToolName string
ToolArgs string
Result string
IsError bool
ToolCallID string
ToolName string
ToolKind string
ToolArgs string
ParsedArgs map[string]any // Pre-parsed arguments for convenience
Result string
IsError bool
Metadata *ToolResultMetadata // Optional structured metadata from tool execution
}
// ToolResultMetadata carries structured data from tool executions.
type ToolResultMetadata struct {
FileDiffs []FileDiffInfo `json:"file_diffs,omitempty"` // Present for edit/write tools
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for spawn_subagent tool
}
// FileDiffInfo describes a file modification from an edit or write tool.
type FileDiffInfo struct {
Path string `json:"path"` // Absolute file path
Additions int `json:"additions"` // Lines added
Deletions int `json:"deletions"` // Lines removed
IsNew bool `json:"is_new,omitempty"` // True if file was created (write only)
DiffBlocks []DiffBlock `json:"diff_blocks,omitempty"`
}
// DiffBlock represents a single old→new text replacement within a file.
type DiffBlock struct {
OldText string `json:"old_text"`
NewText string `json:"new_text"`
}
// EventType implements Event.
+87 -2
View File
@@ -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.
@@ -84,11 +89,13 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
if runner.HasHandlers(extensions.AgentEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnEndEvent); ok {
stopReason := "completed"
stopReason := ev.StopReason
response := ev.Response
if ev.Error != nil {
stopReason = "error"
response = ""
} else if stopReason == "" {
stopReason = "completed"
}
_, _ = runner.Emit(extensions.AgentEndEvent{
Response: response,
@@ -97,4 +104,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
})
}
}
+70 -12
View File
@@ -31,8 +31,9 @@ const (
// BeforeToolCallHook is the input for hooks that fire before a tool executes.
type BeforeToolCallHook struct {
ToolName string
ToolArgs string
ToolCallID string
ToolName string
ToolArgs string
}
// BeforeToolCallResult controls whether the tool call proceeds.
@@ -43,10 +44,11 @@ type BeforeToolCallResult struct {
// AfterToolResultHook is the input for hooks that fire after a tool executes.
type AfterToolResultHook struct {
ToolName string
ToolArgs string
Result string
IsError bool
ToolCallID string
ToolName string
ToolArgs string
Result string
IsError bool
}
// AfterToolResultResult can modify the tool's output before it reaches the LLM.
@@ -76,6 +78,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.01.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 +220,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
// ---------------------------------------------------------------------------
@@ -204,8 +260,9 @@ func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.To
// 1. BeforeToolCall — can block execution.
if h.beforeToolCall.hasHooks() {
if result := h.beforeToolCall.run(BeforeToolCallHook{
ToolName: toolName,
ToolArgs: call.Input,
ToolCallID: call.ID,
ToolName: toolName,
ToolArgs: call.Input,
}); result != nil && result.Block {
reason := result.Reason
if reason == "" {
@@ -222,10 +279,11 @@ func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.To
// 3. AfterToolResult — can modify output.
if h.afterToolResult.hasHooks() {
if result := h.afterToolResult.run(AfterToolResultHook{
ToolName: toolName,
ToolArgs: call.Input,
Result: resp.Content,
IsError: err != nil || resp.IsError,
ToolCallID: call.ID,
ToolName: toolName,
ToolArgs: call.Input,
Result: resp.Content,
IsError: err != nil || resp.IsError,
}); result != nil {
if result.Result != nil {
resp.Content = *result.Result
+5
View File
@@ -24,6 +24,7 @@ func TestHookRegistry_RegisterAndRun(t *testing.T) {
got := hr.run("hello")
if got == nil {
t.Fatal("expected non-nil result")
return
}
if *got != "handled: hello" {
t.Errorf("expected 'handled: hello', got %q", *got)
@@ -51,6 +52,7 @@ func TestHookRegistry_FirstNonNilWins(t *testing.T) {
got := hr.run("test")
if got == nil {
t.Fatal("expected non-nil result")
return
}
if *got != "second: test" {
t.Errorf("expected 'second: test', got %q", *got)
@@ -77,6 +79,7 @@ func TestHookRegistry_PriorityOrdering(t *testing.T) {
got := hr.run("x")
if got == nil {
t.Fatal("expected non-nil result")
return
}
if *got != "high" {
t.Errorf("expected 'high' (priority 0 runs first), got %q", *got)
@@ -441,6 +444,7 @@ func TestBeforeTurnHook_PromptOverride(t *testing.T) {
result := hr.run(BeforeTurnHook{Prompt: "original"})
if result == nil {
t.Fatal("expected non-nil result")
return
}
if result.Prompt == nil || *result.Prompt != "modified prompt" {
t.Errorf("expected prompt override, got %v", result.Prompt)
@@ -462,6 +466,7 @@ func TestBeforeTurnHook_InjectSystemAndContext(t *testing.T) {
result := hr.run(BeforeTurnHook{Prompt: "hello"})
if result == nil {
t.Fatal("expected non-nil result")
return
}
if result.SystemPrompt == nil || *result.SystemPrompt != "be concise" {
t.Errorf("expected system prompt injection")
+860 -23
View File
File diff suppressed because it is too large Load Diff

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