Compare commits

...

268 Commits

Author SHA1 Message Date
Ed Zynda 2de98d32be fix(ui): accurate context token tracking including cache tokens
- Include all token categories in context fill calculation:
  InputTokens + CacheReadTokens + CacheCreationTokens + OutputTokens
- With Anthropic/kimi prompt caching, InputTokens can be near-zero
  while CacheReadTokens holds the bulk of the context
- Include OutputTokens since assistant output becomes context next turn
- Remove max-only guard in SetContextTokens so context shrinks after
  compaction instead of staying stuck at the high-water mark
- Reset context tokens to 0 after compaction in both SDK and UI layers
- Use real API-reported token counts in ShouldCompact() instead of
  the chars/4 text heuristic which misses system prompts and tool defs
2026-04-10 17:05:47 +03:00
Ed Zynda 83127467c5 feat(sdk): add NoExtensions, NoSkills, and NoContextFiles options
- Add NoExtensions bool to Options, OR with viper fallback
- Add NoSkills bool to Options, guards all skill loading
- Add NoContextFiles bool to Options, skips AGENTS.md discovery
- SDK consumers can disable autoloading without touching viper
2026-04-09 17:07:31 +03:00
Ed Zynda e07c94f49d feat(mcp): add dynamic MCP server loading and unloading
- Add AddServer/RemoveServer to MCPToolManager for runtime server management
- Add RemoveConnection to MCPConnectionPool for per-server teardown
- Add AddMCPServer/RemoveMCPServer/ListMCPServers to Agent and SDK Kit
- Lazily create connection pool so AddServer works without prior LoadTools
- Wire onToolsChanged callback to trigger agent tool list rebuild
- Make MCPToolManager.Close nil-safe when pool was never initialized

Tests:
- Integration tests with real stdio MCP server (Python echo server)
- Agent-level tests using mock LLM model (no API key needed)
- Unit tests for error paths, callbacks, idempotency, nil safety
- SDK type surface tests
2026-04-09 13:54:11 +03:00
Ed Zynda b87146a284 feat(sdk): add MCPTokenStoreFactory for custom OAuth token storage
- Add MCPTokenStoreFactory option to kit.Options allowing SDK consumers
  to provide custom token storage backends for remote MCP servers
- Thread TokenStoreFactory through the full chain: kit.Options →
  kitsetup → agent → MCPToolManager → MCPConnectionPool
- Add createTokenStore() helper on connection pool that delegates to the
  factory or falls back to the default FileTokenStore
- Export MCPTokenStore, MCPToken, MCPTokenStoreFactory, and ErrMCPNoToken
  in pkg/kit/types.go following SDK naming conventions
- Default behavior (file-based storage) is preserved when factory is nil
2026-04-09 13:27:40 +03:00
Ed Zynda 186d9f7f44 fix(ui): route raw fmt.Print calls through proper renderers
- event_handler: route default extension print level through DisplayInfo
  instead of bare fmt.Println for consistent styling and timestamps
- factory: remove orphan fmt.Println("") before system messages; the
  renderer already manages its own spacing
- app: PrintFromExtension non-interactive fallback now respects level,
  writing errors/info to stderr with prefix to keep stdout clean
- app: PrintBlockFromExtension non-interactive fallback writes framed
  blocks to stderr instead of raw text to stdout
2026-04-09 13:00:23 +03:00
Ed Zynda 3a8ffc2104 feat(models): add per-model system prompt support
- Add systemPrompt field to GenerationParams and config structs
- On init, replace default system prompt with per-model prompt when
  user hasn't explicitly set one (via flag, config, or SDK option)
- On model switch, detect per-model prompt and compose it with
  AGENTS.md, skills, and date/cwd context
- Fix viper.IsSet bug: BindPFlag causes IsSet to return true for
  unset flags, so compare against defaultSystemPrompt instead
- Agent.SetModel now updates stored system prompt from config
- Export LoadModelSettingsFromConfig, LoadSystemPromptValue, and
  LookupModelForSettings for use by Kit.SetModel
- Add tests for prompt apply, precedence, file path, and
  modelSettings override
2026-04-09 12:35:00 +03:00
Ed Zynda e54570162e feat(models): add per-model generation parameter defaults
- Add modelSettings config section for attaching generation params
  (temperature, topP, topK, frequencyPenalty, presencePenalty,
  maxTokens, stopSequences, thinkingLevel) to any model by
  provider/model key
- Add params field to customModels definitions for inline defaults
- Change BuildProviderConfig and SetModel to use viper.IsSet so
  unset params remain nil, allowing model-level defaults to apply
- Wire ApplyModelSettings into CreateProvider with priority order:
  CLI flags > global config > modelSettings > customModels params
- Add GenerationParams to ModelInfo in the registry
- Update default config template with modelSettings and customModels
  params examples
2026-04-09 12:07:42 +03:00
Ed Zynda 34bb97a40e chore(deps): update dependencies
- bump mcp-go to v0.47.1
- update cloud auth, otel, and various indirect deps
2026-04-08 20:51:59 +03:00
Ed Zynda f5c1a16f8a feat(session): make compaction create new leaf with no parent
Change compaction behavior so the compaction entry has no parent (empty
ParentID), creating a new root for post-compaction history. This ensures
old compacted messages are not traversed when building LLM context.

- Modify AppendCompaction to create entries with empty ParentID
- Update BuildContext to collect kept messages via FirstKeptEntryID
- Update GetContextEntryIDs with same logic
- Add comprehensive tests for compaction behavior
- Add web viewer support for displaying compaction entries
2026-04-08 18:52:44 +03:00
Ed Zynda b29d7d2166 refactor(acpserver): remove redundant thinking tag parsing
Remove dead code now that pkg/kit transparently handles <thinking> and
 tags at the agent layer. The ACP server no longer needs to:

- Track inThinkingTag state across chunks
- Parse and split reasoning/text from MessageUpdateEvent chunks
- Maintain tag format constants

MessageUpdateEvent now contains clean text, and ReasoningDeltaEvent
contains structured reasoning - no duplicate filtering needed.
2026-04-08 16:55:53 +03:00
Ed Zynda 3ea0db69ea fix(ui): wrap user messages to terminal width
- Add width parameter to UserBlock and apply lipgloss.Wrap() before
  passing content to herald Tip alert
- Subtract 4 from width to account for alert bar prefix and margin
- Pass renderer width from RenderUserMessage to UserBlock
- Mirrors the assistant message wrapping added in e33564c
2026-04-08 15:15:27 +03:00
Ed Zynda 4304a5e899 feat(ui): change steer keybind to Ctrl+X s leader key chord
- Replace single Ctrl+S with Ctrl+X leader prefix followed by "s"
- Add leaderKeyActive flag to AppModel for two-key chord state
- Ctrl+X sets the leader flag; next keypress completes or cancels chord
- Update hint text in input component (adjust width thresholds)
- Update /help command output to reflect new keybind
2026-04-08 15:04:48 +03:00
Ed Zynda 4019c1e4f7 fix(ui): remove character limits from all textarea inputs
- Main message input: 5000 -> unlimited
- Prompt dialog input: 1000 -> unlimited
- Tool approval input: 1000 -> unlimited

Setting CharLimit to 0 disables the limit in Bubble Tea's textarea.
2026-04-08 14:23:34 +03:00
Ed Zynda 30ad7c1d0b feat(sdk): persist session messages incrementally per agent step
- Add StepMessagesHandler callback to agent's GenerateWithLoopAndStreaming
  so callers can persist messages as each step completes
- Wire onStepMessages in Kit.generate() to call session.AppendMessage
  for each step's messages immediately on completion
- Track PersistedMessageCount on GenerateWithLoopResult so runTurn
  skips already-persisted messages in post-generation cleanup
- Tool calls are always persisted as assistant+tool pairs (never orphaned)
- Document concurrency and incremental persistence requirements on
  the SessionManager interface for custom implementations
2026-04-08 14:15:05 +03:00
Ed Zynda e33564c569 fix(ui): wrap assistant messages to terminal width
- ToMarkdown() received a width param but never used it
- Apply lipgloss.Wrap() after herald-md render to break long lines
- Preserves ANSI styles/colors through the wrapping pass
- Fixes overflow for all markdown paths: assistant messages, tool
  bodies, and overlay text
2026-04-08 13:34:33 +03:00
Ed Zynda 5ff28445fd fix(ui): truncate queued and steering message blocks to prevent overflow
- Limit each queued/steering block to 3 visible content lines with ellipsis
- Account for soft-wrapping when counting visual lines
- Truncation is visual only; full text is preserved for scrollback
- Add truncateMessageForBlock helper with wrap-aware line counting
- Add 7 unit tests covering short, exact, overflow, wrapping, and mixed cases
2026-04-08 13:24:26 +03:00
Ed Zynda 13d177e5d0 fix(extensions): use structured logging that respects log levels
Switch from standard log.Printf to charmbracelet/log for extension loading
messages. This ensures DEBUG output only appears when explicitly enabled.

- Remove unconditional WARN log for failed extension loads
- Convert DEBUG loaded extension message to structured log.Debug call
2026-04-08 00:39:21 +03:00
Ed Zynda 3ffc995f27 feat(sdk): add NewTool/NewParallelTool for dependency-free custom tools
- Add ToolOutput struct, TextResult/ErrorResult helpers, and
  ToolCallIDFromContext so SDK consumers can create custom tools
  without importing charm.land/fantasy
- Add NewTool (sequential) and NewParallelTool (concurrent) generic
  constructors with automatic JSON schema generation from struct tags
- Remove dead UpdateUsageFromResponse method and fantasy import from
  internal/ui/cli.go
- Update SDK skill, README, and www/ docs with custom tool examples
  and corrected hook signatures
2026-04-07 22:05:42 +03:00
Ed Zynda b2bd016135 fix(tui): redirect log output to file to prevent TUI corruption
- Add tea.LogToFile in runInteractiveModeBubbleTea to send stdlib log
  output to /tmp/kit/kit.log instead of stderr
- Replace charmbracelet/log with stdlib log in extensions loader,
  runner, watcher, prompts loader, and pkg/kit so all log calls go
  through the redirected stdlib logger
- Leave charmbracelet/log in CLI-only commands (install, acp) and
  acpserver where stderr logging is correct
2026-04-07 21:20:04 +03:00
Ed Zynda 812dedaea2 feat(pkg/kit): add SessionManager interface for custom session backends
Add SessionManager interface to allow pluggable session storage backends.
This enables users to implement custom session managers for databases,
cloud storage, or other persistence mechanisms instead of the default
JSONL file-based TreeManager.

Changes:
- Add SessionManager interface with methods for message storage,
  tree navigation, compaction, and extension data
- Add treeManagerAdapter to wrap existing TreeManager for backward compatibility
- Update Kit struct to use SessionManager interface instead of concrete type
- Add SessionManager option to Options struct
- Update all session-related methods to use interface
- Add documentation for custom SessionManager usage

The default behavior is preserved - when no SessionManager is provided,
Kit automatically uses the TreeManager via the adapter.
2026-04-07 17:41:46 +03:00
Ed Zynda f65b6737f2 feat(sdk): add SkipConfig and DisableCoreTools options
Add two new Options fields for programmatic SDK usage:

- SkipConfig: Skip .kit.yml file loading while still using viper defaults
  and environment variables. Useful for fully programmatic configuration.

- DisableCoreTools: Allow creating agents with 0 tools (chat-only mode) or
  with only custom tools. When true and Tools is empty, no tools are loaded.
  When combined with custom Tools, only those tools are loaded.

Updates documentation in README, pkg/kit/README, skills/kit-sdk/SKILL,
and www/pages/sdk/options.
2026-04-07 17:10:58 +03:00
Ed Zynda 5d45aa196b fix(watcher): remove debug logging that corrupts TUI
Remove charmbracelet/log debug statements from the file watcher that
were writing directly to stderr, corrupting the Bubble Tea terminal UI.

- Remove log.Debug calls for directory operations and file changes
- Remove log.Warn for watcher errors (silently ignore instead)
- Remove the charmbracelet/log import entirely
2026-04-07 16:31:29 +03:00
Ed Zynda debb39f56c fix(ui): show MCP tools in /tools and status bar after async loading
Background MCP tool loading (added in 7e54710) caused tools to not appear
in the UI because tool names and counts were captured at startup before
loading completed. This adds:

- MCPToolsReadyEvent and MCPServerLoadedEvent for progress notifications
- Dynamic GetToolNames/GetMCPToolCount callbacks for live updates
- Per-server status messages as each MCP server finishes loading
- Refresh handlers to update /tools output and status bar when ready
2026-04-07 16:29:09 +03:00
Ed Zynda 7ce6f4fd9e fix(watcher): dynamically watch new subdirectories for skill/prompt reload
- Detect new subdirectory creation in the fsnotify event loop and add
  it to the watcher so files created inside trigger reload events
- Handle cp -r case by checking if new directories already contain
  matching files and scheduling an immediate debounced reload
- Add dirContainsMatchingFiles helper method
- Add tests for both new-subdirectory and copy-with-existing-files cases
2026-04-07 15:01:18 +03:00
Ed Zynda c2f2bdb3d3 feat: auto-reload custom prompts and skills on file change
- Add internal/watcher package with general-purpose ContentWatcher
  using fsnotify, configurable file extensions, and debouncing
- Add ContentReloadEvent and App.NotifyContentReload() for TUI signaling
- Add GetPromptTemplates/GetSkillItems callback fields on AppModelOptions
  following the existing GetExtensionCommands lazy-provider pattern
- Add Kit.ReloadSkills() to re-discover skills from disk
- Wire fsnotify watcher for .kit/prompts/, .kit/skills/, .agents/skills/,
  and global config directories, triggering on .md/.txt changes
- TUI refreshes autocomplete entries and skill list on reload
2026-04-07 14:09:59 +03:00
Ed Zynda 201d14804e fix(ui): prevent double-rendered messages after reasoning-only responses
- Always fire onResponse callback even when response text is empty so
  ResponseCompleteEvent reaches the TUI and resets the StreamComponent
- Check for existing StreamingMessageItem in flushStreamAndPendingUserMessages
  before creating a new StyledMessageItem to avoid duplicate content
- Mark trailing StreamingMessageItem complete on StepComplete, StepCancelled,
  and StepError to freeze live timers and prevent dangling streaming state
2026-04-07 13:52:30 +03:00
Ed Zynda 7e54710d4a perf(agent): load MCP tools asynchronously to speed up startup
Load MCP server tools in the background so the UI appears immediately
instead of blocking until all servers connect. The first LLM call
automatically waits for tools to be ready before proceeding.

Key changes:
- NewAgent() starts MCP loading in a background goroutine and returns
  immediately with core/extension tools only
- GenerateWithLoop() calls ensureMCPTools() to lazily wait and rebuild
  the fantasy agent with full tool set before first LLM call
- Parallelize LoadTools() across all configured MCP servers
- Add WaitForMCPTools() and MCPToolsReady() for status checking
- Refactor SetModel/SetExtraTools to use shared rebuildFantasyAgent()
- Expose async MCP status methods in public SDK
2026-04-07 13:36:10 +03:00
Ed Zynda 88870be4d2 feat: add frequency-penalty and presence-penalty parameters
- Add --frequency-penalty and --presence-penalty CLI flags (0.0-2.0)
- Wire through config, viper, ProviderConfig, and fantasy agent options
- Support in config file, env vars (KIT_FREQUENCY_PENALTY), and SDK
- Pass to Ollama via options map (frequency_penalty, presence_penalty)
- Apply on both initial agent creation and runtime model swap
2026-04-06 10:52:33 +03:00
Ed Zynda 46bf809715 chore(models): update embedded models.json from models.dev
- Providers: 97 -> 109 (+12 new)
- Models: 3039 -> 4156 (+1117 new)
- New providers: alibaba-coding-plan, alibaba-coding-plan-cn, clarifai,
  dinference, drun, llmgateway, perplexity-agent, tencent-coding-plan,
  the-grid-ai, xiaomi-token-plan-ams, xiaomi-token-plan-cn,
  xiaomi-token-plan-sgp
2026-04-06 09:50:43 +03:00
Ed Zynda e19e9642a2 feat(session): include system prompt and model in shared sessions
Add SystemPromptEntry type to capture system prompt, model, and provider
when sharing sessions via /share command. The entry is inserted into the
JSONL after the header and displayed in the web viewer as a collapsible
section with a model badge.

- Add SystemPromptEntry with Content, Model, and Provider fields
- Capture current system prompt and model at share time
- Display in web viewer with collapsible UI and model badge
- Update documentation for /share command
2026-04-04 19:33:02 +03:00
Ed Zynda 32675b8b35 chore(deps): update all go module dependencies
- mcp-go v0.46.0 → v0.47.0
- herald v0.11.0 → v0.13.0
- herald-md v0.2.0 → v0.3.0
- smithy-go v1.24.2 → v1.24.3
- otel v1.42.0 → v1.43.0
- googleapis/gax-go v2.20.0 → v2.21.0
- google.golang.org/api v0.273.1 → v0.274.0
- runewidth v0.0.21 → v0.0.22
- azure-sdk-internal v1.11.2 → v1.12.0
- various aws-sdk-go-v2 sub-modules patched
2026-04-04 18:11:56 +03:00
Ed Zynda aecce001ee feat(mcp): add OAuth support for remote MCP servers
- Add MCPAuthHandler interface at SDK level (pkg/kit/) so all consumers
  (CLI, TUI, SDK embedders) control the OAuth UX through one interface
- Default handler opens system browser + local callback server with PKCE
- CLIMCPAuthHandler wraps default with status messages (stderr pre-TUI,
  system messages via TUI event system once running)
- Always enable OAuth on remote transports (streamable HTTP, SSE) when
  handler is configured; harmless for servers that don't need it
- Dynamic client registration when no client ID is pre-configured
- File-based TokenStore persists tokens to ~/.config/.kit/mcp_tokens.json
  keyed by server URL so users don't re-auth on restart
- Catch OAuthAuthorizationRequiredError at connection init (startup) and
  tool execution (mid-session token expiry), run auth flow, retry once
- Fix error wrapping (%v -> %w) in connection pool so errors.As can
  unwrap through the chain to find OAuth errors
- Thread AuthHandler through MCPToolManager -> AgentConfig ->
  AgentCreationOptions -> AgentSetupOptions -> kit.Options
2026-04-04 17:41:57 +03:00
Ed Zynda 32d73171fd fix(extensions): write manifest Include in single pass and preserve on update
- InstallWithInclude wrote manifest twice via two different code paths,
  with the first write missing Include; unify into shared install() method
  that writes the manifest once with all fields including Include
- Update() now reads the existing manifest entry to preserve Include and
  Installed timestamp instead of constructing a fresh entry from scratch
2026-04-04 17:19:00 +03:00
Ed Zynda 265fd2ec0c fix(extensions): skip _test.go files and non-extension examples/ subdirs
- Filter out _test.go files in findExtensionsInDir, findExtensionsInRepo,
  and ScanForExtensions to prevent Yaegi from loading test files
- Narrow examples/ traversal so only recognized extension directories
  (extensions/, ext/, *-ext/, *-extensions/) are scanned, not arbitrary
  subdirs like examples/sdk/ that import pkg/kit
2026-04-04 16:44:13 +03:00
Ed Zynda efebf2eba6 fix(kit-telegram): add typing indicator and config fallback to global path
- Send sendChatAction("typing") every 4s while agent is processing,
  started on AgentStart and stopped on AgentEnd/SessionShutdown
- configPath() now checks project-local .kit/ first, then falls back
  to ~/.config/kit/kit-telegram.json for cross-project portability
2026-04-04 16:33:08 +03:00
Ed Zynda f7b655ae33 feat(extensions): add Abort, IsIdle, Compact, SendMultimodalMessage, GetSessionUsage to Context
- ctx.Abort(): cancel current agent turn and clear queue without
  injecting a new message (App.Abort + App.IsBusy methods)
- ctx.IsIdle(): check whether the agent is currently processing
- ctx.Compact(CompactConfig): trigger async context compaction with
  OnComplete/OnError callbacks (App.CompactAsync method)
- ctx.SendMultimodalMessage(text, []FilePart): send text+image messages
  to the agent, bridging ext.FilePart to fantasy.FilePart via RunWithFiles
- ctx.GetSessionUsage() SessionUsage: expose aggregated session token
  usage and cost from the UsageTracker

New types: CompactConfig, FilePart, SessionUsage
Wired in both context setups in cmd/root.go with nil-guard defaults
in runner.go and Yaegi symbol exports in symbols.go
2026-04-04 15:01:02 +03:00
Ed Zynda 35982b41ad fix(pkg): transparently handle <think> tags in stream
Move reasoning tag detection from the provider and UI layers into the agent layer. This prevents raw XML tags from leaking into text streams while ensuring structured reasoning events are emitted correctly for all callers.
2026-04-03 13:49:12 +03:00
Ed Zynda 788e3b71fd feat(config): per-model baseUrl and apiKey for custom models
- Add `baseUrl` and `apiKey` fields to CustomModelConfig (config and models packages)
- Store them on ModelInfo so they travel through the registry
- createCustomProvider resolves URL/key from model definition first,
  falling back to global --provider-url / --provider-api-key
- Fix registry initialisation: call ReloadGlobalRegistry() in InitConfig()
  so customModels from config are visible on startup (not just at init time)
- Include custom provider in GetLLMProviders() so custom models appear
  in the /model selector
- Hide the built-in custom/custom stub from the selector when user-defined
  custom models are present
2026-04-03 12:37:14 +03:00
Ed Zynda 3496bc2684 feat(ui): add bordered container and improved styling to session selector
- Add full-width bordered container with rounded border and primary color
- Add max height constraint to prevent terminal overflow
- Improve selection highlighting with inverted colors matching PopupList style
- Change cursor indicator from › to > for consistency
- Add separator lines between header, content, and footer
- Add footer showing current filter mode
2026-04-02 17:20:55 +03:00
Ed Zynda 997c7d15ff fix: include pasted images in steering messages
Steering messages (Ctrl+S during agent work) now carry file attachments
just like queued messages do. Previously, pasted images were silently
dropped when steering.

Changes:
- Add SteerMessage struct with Text and Files fields
- Update steer channel from chan string to chan SteerMessage
- Add SteerWithFiles methods through the stack (UI, app, SDK)
- Update PrepareStep to include files in injected user messages
2026-04-02 17:19:34 +03:00
Ed Zynda 83246e47d5 feat(ui): add bordered container and improved styling to tree selector
- Add full-width bordered container with rounded border and primary color
- Add max height constraint to prevent terminal overflow
- Improve selection highlighting with inverted colors matching PopupList style
- Change cursor indicator from › to > for consistency
- Use MutedBorder for tree lines and Success color for active marker
- Update search display format to match PopupList (
2026-04-02 17:18:16 +03:00
Ed Zynda 50e7b78c33 fix(ui): strip herald CodeBlock padding to fix mouse selection off-by-one
Herald's codeBlockWithLineNumbers() hardcodes PaddingTop(1) and
PaddingBottom(1), adding invisible blank lines with background color
above and below the code content. These padding lines occupy line
indices in the rendered item but are visually indistinguishable from
empty space, causing mouse click coordinates to map to the wrong
content line (consistently 1 row off in tool output blocks).

Strip the padding lines after CodeBlock rendering since the Compose
separator above and Figure caption below already provide adequate
visual spacing.
2026-04-02 16:49:44 +03:00
Ed Zynda b937af3056 refactor(ui): use herald Figure component for grep tool output
Add dedicated renderGrepBody function for the grep tool, replacing the
previous behavior of routing it through renderBashBody. The grep tool now:

- Shows a caption with total match count (e.g., '8 matches' or '1 match')
- Displays truncation info when matches exceed maxLsLines
- Uses consistent Figure component styling with ls, read, find, and bash tools
- Uses 'match/matches' terminology appropriate for grep results
2026-04-02 16:12:48 +03:00
Ed Zynda a5e995c750 refactor(ui): use herald Figure component for find tool output
Add dedicated renderFindBody function for the find tool, replacing the
previous behavior of routing it through renderBashBody. The find tool now:

- Shows a caption with total result count (e.g., '12 results')
- Displays truncation info when results exceed maxLsLines
- Uses consistent Figure component styling with ls, read, and bash tools
2026-04-02 16:11:49 +03:00
Ed Zynda e95e08a699 refactor(ui): use herald Figure component for ls tool output
Apply the same Figure component pattern to the ls tool for consistency
with read and bash tools. The caption now appears below the directory
listing and shows the count of hidden entries when truncated.
2026-04-02 16:10:00 +03:00
Ed Zynda bcaf92f62a refactor(ui): use herald Figure component for read and bash tool output
Replace inline truncation hints and exit code labels with herald's
Figure component. Captions now appear below content and show:

- read: filename • lines X-Y of Z • offset=N to continue
- bash: N more lines • exit code N

This provides consistent visual grouping and cleaner metadata
display for tool output blocks.
2026-04-02 16:09:17 +03:00
Ed Zynda ead4afbfe6 fix(subagent): prevent instant failure from already-dead parent contexts
- Replace detachedWithCancel (goroutine-based) with context.WithoutCancel
  + valuesContext; the old goroutine would fire immediately if the parent
  was already cancelled/deadline-exceeded, causing 'failed after 0s'
- Kit.Subagent() pre-flight: if the incoming ctx is already done, reset
  to context.Background() before applying the subagent timeout
- Both Subagent() error paths now return a non-nil *SubagentResult with
  Elapsed set, so the tool response always shows accurate timing
- Narrow viperInitMu scope in Kit.New(): snapshot viper state + call
  BuildProviderConfig under the lock, then release before SetupAgent /
  MCP loading; parallel subagent spawns no longer serialise on viper I/O
- AgentSetupOptions gains ProviderConfig + scalar fields so SetupAgent
  can skip viper reads when a pre-built config is supplied
- Add subagent_test.go covering the fixed context detachment behaviour
2026-04-02 15:54:47 +03:00
Ed Zynda 685aaf207f feat(extensions): add hot-reload with file watching and /reload-ext command
- Add fsnotify-based file watcher that auto-reloads extensions on .go
  file changes in autoloaded dirs with 300ms debounce
- Add /reload-ext built-in command (alias /re) for manual reload
- Add Agent.SetExtraTools() so extension tools update on reload
  instead of being baked in at agent creation time
- Run reload async via tea.Cmd to avoid prog.Send() deadlock when
  extension handlers call ctx.Print() during SessionStart/Shutdown
- Wire watcher lifecycle into cmd/root.go with graceful shutdown
2026-04-02 15:41:54 +03:00
Ed Zynda 76ff6c9639 style(ui): segment KITT scanner LEDs and center logo text
- Break scanner bar into individual LED segments with single-space gaps
- Center KIT text over the scanner bar (13-space indent for all lines)
- Maintain original 46-char total width for the scanner bar
2026-04-02 15:11:01 +03:00
Ed Zynda 1cf24ee5de fix(core): return error when read tool is used on a directory
- Return an error response guiding the agent to use ls instead
- Remove unused readDirectory helper function
2026-04-02 14:45:33 +03:00
Ed Zynda c9637090fa feat(subagent): return early error for invalid model instead of silent fallback
- Add ValidateModelString() to ModelsRegistry for format, provider,
  and model name validation with typo suggestions
- Validate model in Kit.Subagent() before expensive Kit.New() setup
- Remove silent fallback to parent model on creation failure
- Error propagates as tool result so calling agent can self-correct
- Add registry_test.go covering format, provider, and suggestion cases
2026-04-02 14:45:03 +03:00
Ed Zynda 0ff0ff42ab fix(ui): wrap tool error output in caution alert block
Prevent tool error text from spilling into the surrounding layout
by rendering it inside a herald Caution alert container.
2026-04-02 14:39:29 +03:00
Ed Zynda a4fb32ff2b feat(ui): add reusable PopupList and render /model as overlay
- Add PopupList: generic themed popup with fuzzy search, scrolling,
  keyboard navigation, and centered overlay rendering
- Refactor ModelSelectorComponent to delegate to PopupList instead
  of implementing its own full-screen rendering and input handling
- Render /model selector as a centered overlay on top of the chat
  view instead of replacing the entire screen
- PopupList accepts a pluggable FilterFunc for domain-specific
  fuzzy matching (model selector wires its own scoring)
- Add 11 tests for PopupList covering navigation, search, selection,
  cancellation, filtering, rendering, and edge cases
2026-04-02 14:39:21 +03:00
Ed Zynda 7d2f078111 fix(ui): freeze reasoning counter when last token is processed
- Wire fantasy's OnReasoningEnd callback through the full event chain:
  agent → SDK (ReasoningCompleteEvent) → app → TUI
- Freeze reasoning duration in both StreamComponent and
  StreamingMessageItem as soon as reasoning ends, not when the
  next assistant text chunk arrives
- Fix accent color on duration label in render.ReasoningBlock to
  match the live streaming style (VeryMuted prefix + Accent duration)
2026-04-02 14:18:42 +03:00
Ed Zynda b0b66941ab fix(extensions): batch go-edit-lint per turn and fix OnAgentEnd StopReason docs
- Refactor go-edit-lint to collect edited .go files during the agent
  turn via OnToolResult, then run gopls + golangci-lint once in
  OnAgentEnd instead of after every individual edit/write call
- Use ctx.SendMessage() to inject diagnostics as a follow-up prompt
  when issues are found, replacing the old tool-result rewriting
- Show a green 'all clean' block when no issues are detected
- Fix StopReason docs in skills/kit-extensions/SKILL.md: the value is
  'error' on failure, 'completed' when the LLM returns empty, or the
  raw provider value (e.g. 'stop', 'end_turn') passed through — not
  the previously documented 'completed'/'cancelled'/'error' enum
2026-04-02 14:04:41 +03:00
Ed Zynda cbb7387a72 fix(test): add return after t.Fatal to silence SA5011 nil-deref warnings
- internal/ui/model_test.go: bashItem nil check
- pkg/extensions/test/harness_test.go: footer and result nil checks
2026-04-01 21:24:02 +03:00
Ed Zynda 19430b0ecb chore(ui): remove dead toast and clipboard code
Remove 8 unused exports from clipboard package:
- CopyToClipboardWithMessage, IsClipboardSupported
- ToastMsg, ToastType, ToastInfo, ToastSuccess, ToastWarning, ToastError

These were remnants of a toast notification feature that was never
wired up. No callers exist anywhere in the codebase.
2026-04-01 21:11:00 +03:00
Ed Zynda 8e3cfeede5 fix(ui): correct mouse selection Y-offset for reasoning blocks
The getItemAndLineAtY() method was using item.Height() which returns 0
for reasoning blocks (StreamingMessageItem with role='reasoning') because
their render cache is intentionally never populated (they include a live
duration timer).

This caused all items below a reasoning block to have incorrect Y
coordinates — clicking on the reasoning text would highlight the
assistant text below it instead.

Two fixes:
1. getItemAndLineAtY() now uses renderedHeight() which calls Render()
   and counts lines — matching exactly what View() does. This is the
   single source of truth for item height during hit-testing.

2. StreamingMessageItem.Height() now falls back to Render(0) when
   cachedRender is empty, fixing the same issue for other callers
   (GotoBottom, ScrollBy, clampOffset, etc.).
2026-04-01 18:15:04 +03:00
Ed Zynda 4fa5775974 feat(ui): implement character-level mouse text selection and copy
Implement crush-style mouse text selection with character-level precision,
replacing the previously disabled stub implementation.

Architecture:
- New selection package (internal/ui/selection/) handles all coordinate
  math, word boundary detection, and cell-level ANSI text manipulation
- ScrollList upgraded with proper mouse down/drag/up flow supporting
  single click (character drag), double click (word), triple click (line)
- Model.go wires BubbleTea mouse events through to ScrollList with
  proper viewport Y-offset adjustment for the scrollback area

Key features:
- Character-level selection using ultraviolet ScreenBuffer for ANSI-aware
  cell parsing — correctly handles styled text, emoji, CJK wide chars
- Word selection via UAX#29 Unicode segmentation (clipperhouse/uax29)
- Display-width-aware columns via clipperhouse/displaywidth (not bytes)
- Dual clipboard: OSC 52 (remote terminals) + native (atotto/clipboard)
- Multi-click detection with 400ms threshold and 2px tolerance
- Mouse event throttling via existing MouseModeCellMotion
- Selection cleared on any keypress for clean UX

Dependencies (all already indirect in go.mod):
- github.com/charmbracelet/ultraviolet (ScreenBuffer, cell manipulation)
- github.com/charmbracelet/x/ansi (ANSI strip, StringWidth)
- github.com/clipperhouse/displaywidth (grapheme display width)
- github.com/clipperhouse/uax29/v2 (Unicode word segmentation)
2026-04-01 18:05:48 +03:00
Ed Zynda 4e7d823ee4 feat(ui): make /fork create new session file matching Pi behavior
- Add ForkToNewSession method to create new session with history up to target
- Add NewTreeSelectorForFork showing only user messages (flat list)
- Update performFork to create and switch to new session file
- Update /fork command description in docs and help text

Previously /fork just branched within the same session file like /tree.
Now /fork creates a completely new session file with parent_session reference,
matching Pi's behavior exactly.
2026-04-01 16:10:55 +03:00
Ed Zynda 7a16c76adc fix(ui): trim whitespace when loading session messages to prevent empty blocks
When loading session history, some assistant messages contain text parts
with only whitespace (e.g., single space ' '). These were being rendered
as empty message blocks, causing extra vertical spacing in the UI.

Fix by trimming whitespace from message content before checking if it's
non-empty in renderSessionHistory().

Changes:
- Apply strings.TrimSpace() to user message content before rendering
- Apply strings.TrimSpace() to assistant message content before rendering

This prevents empty/whitespace-only message blocks from being added to
the scrollback when resuming sessions.
2026-04-01 15:11:42 +03:00
Ed Zynda 70a21ee73a refactor(ui): extract shared message rendering functions
Extract pure rendering functions into internal/ui/render/blocks.go
to eliminate code duplication between streaming and historical
message rendering paths.

Changes:
- Create render package with UserBlock, AssistantBlock, ReasoningBlock,
  SystemBlock, ErrorBlock, and ToolBlock functions
- Update MessageRenderer methods to use shared render functions
- Update StreamingMessageItem to use shared render functions
- Reduce ~77 lines of duplicated code across message_items.go and messages.go

All existing tests pass, no functional changes.
2026-04-01 14:59:27 +03:00
Ed Zynda 28d2de8f39 Phase 1: Reorganize UI leaf utilities into subpackages
Moved leaf utility files to subpackages for better organization:
- events.go -> core/ (core message types)
- clipboard.go -> clipboard/ (clipboard operations)
- commands.go -> commands/ (slash commands)
- file_processor.go -> fileutil/ (file attachment processing)
- preferences.go -> prefs/ (theme/model preferences)
- enhanced_styles.go, styles.go, themes.go -> style/ (theming system)

Added exports.go to re-export commonly used types for backward
compatibility. External importers can still use ui.XXX without
changes.

All tests pass, basic smoke test successful.
2026-04-01 13:54:10 +03:00
Ed Zynda 7f192ae850 feat(ui): improve slash command popup contrast with full-width backgrounds
- Change border from MutedBorder to Primary for visibility
- Add full-width background styles for all popup items
- Use inverse colors for selected item (primary bg, background fg)
- Add background to scroll indicators and footer
- Add bottom margin for visual depth/shadow effect
2026-04-01 13:35:20 +03:00
Ed Zynda 9f6746ded9 fix(ui): re-enable auto-scroll on new message submission
Auto-scroll was being disabled when users manually scrolled (mouse wheel,
PgUp, etc.) but never re-enabled. Now it reactivates when submitting a
new message so the conversation view jumps to the bottom to show the
latest content.
2026-04-01 13:29:40 +03:00
Ed Zynda 7514d3a0ff chore(deps): update go and npm dependencies
- github.com/indaco/herald v0.10.0 → v0.11.0
- github.com/indaco/herald-md v0.1.0 → v0.2.0
- google.golang.org/api v0.273.0 → v0.273.1
- google.golang.org/genai v1.52.0 → v1.52.1
- google.golang.org/grpc v1.79.3 → v1.80.0
- gonum.org/v1/gonum v0.16.0 → v0.17.0
- add npm and www package-lock.json files
2026-04-01 13:24:36 +03:00
Ed Zynda c83281a52b docs: add feature-request prompt for GitHub feature requests
Add a dedicated /feature-request prompt that guides users through creating
well-formed feature requests using the GitHub feature_request template.

The prompt focuses on:
- Problem-first description
- Clear motivation and use cases
- Optional proposed implementation
- Conventional commit-style titles (feat: ...)

Usage: /feature-request <description of the feature>
2026-04-01 13:22:14 +03:00
Ed Zynda 4515bb92c2 docs: update file-issue prompt to use GitHub issue templates
The file-issue prompt now references the structured GitHub issue templates
(bug_report, feature_request, documentation) and guides users to use the
--template flag with gh issue create for consistent issue formatting.
2026-04-01 13:21:20 +03:00
Ed Zynda e326b84204 chore: add GitHub issue templates and file-issue prompt
Add structured GitHub issue templates for:
- Bug reports (with reproduction steps, code, component)
- Feature requests (with motivation and proposed implementation)
- Documentation issues

Also add a /file-issue kit prompt for quickly filing issues from the TUI.

The templates enforce conventional commit-style titles and include
checklists to ensure issues are well-formed before submission.
2026-04-01 13:20:43 +03:00
Ed Zynda 1b93049b8e fix(ui): remove j/k navigation from fuzzy selectors
Remove 'j' and 'k' keybindings from model, session, and tree selectors
to allow typing those characters for fuzzy filtering. Navigation now
uses only arrow keys (↑/↓) which matches the existing help text.
2026-04-01 13:11:44 +03:00
Ed Zynda 4912449dda fix(ui): render selectors in alt screen buffer
Fix /resume, /model, and /tree selectors to render in the alternate
screen buffer instead of terminal scrollback. All three selector
components now set AltScreen=true on their tea.View returns.
2026-04-01 13:09:23 +03:00
Ed Zynda b70cce4f34 refactor(ui): remove pre-alt-screen dead code and boilerplate
- Remove scrollbackBuf, appendScrollback(), drainScrollback() and all
  call sites — the entire terminal scrollback pipeline was dead code
  since the alt screen migration
- Remove StreamComponent.render(), renderCache, renderDirty,
  scrollbackFlushedLines, viewContent(), and ConsumeOverflow() body —
  rendering is now handled by StreamingMessageItem in the ScrollList
- Remove SetHeight and ConsumeOverflow from streamComponentIface since
  height is managed by ScrollList and overflow is a no-op
- Remove redundant AltScreen/MouseMode/ReportFocus/KeyboardEnhancements
  boilerplate from 6 child View() methods — parent already sets these
- Convert two orphan appendScrollback calls (extension default text,
  shell command output) to proper ScrollList message items
- Update ~30 stale comments referencing tea.Println and scrollback buffer
2026-04-01 01:13:19 +03:00
Ed Zynda 4c566836b2 refactor(ui): move startup banner into ScrollList, fix /resume rendering
- Render ASCII logo and startup info exclusively in the ScrollList
  instead of printing to stdout/terminal scrollback
- Remove PrintStartupInfo() and move kitBanner() to ui.KitBanner()
- Fix separator spacing: use single pre-rendered item with embedded
  blank lines to avoid left-border artifacts on spacing rows
- Rewrite renderSessionHistory() to populate ScrollList with proper
  MessageItems instead of legacy appendScrollback() calls
- Clear m.messages on /clear, /new, and /resume so the ScrollList
  resets correctly when switching sessions
- Add pendingGotoBottom flag to defer scroll-to-bottom until after
  distributeHeight() recalculates the correct viewport height
- Fix pre-existing test failures: initialize scrollList in test helper,
  update 5 tests from tea.Println assertions to ScrollList checks
2026-04-01 00:39:32 +03:00
Ed Zynda bb3261883a Add visual separator after startup info in ScrollList
Added a horizontal rule (────) with blank lines above and below to
visually separate the startup info from the conversation history.

The separator uses theme.Border color and spans 80 characters, providing
a clear visual break between startup messages and the chat content.

This makes it easier to distinguish where the conversation starts when
scrolling back through history.
2026-03-31 19:07:56 +03:00
Ed Zynda 512d0f16ce Show startup info in ScrollList (alt screen mode)
Added AddStartupMessageToScrollList() method that renders startup info
(model, context, skills, extensions, MCP tools) and extension startup
messages as system messages in the ScrollList.

This ensures startup info is visible and scrollable in alt screen mode,
rather than being printed before BubbleTea starts and becoming hidden
when alt screen takes over.

Changes:
- AppModelOptions: Added StartupExtensionMessages field
- AppModel: Store and render startup messages in Init()
- AddStartupMessageToScrollList(): Renders startup info + extension messages
- cmd/root.go: Pass startupExtensionMessages to NewAppModel

The startup info now appears at the top of conversation history and can
be scrolled back to at any time.
2026-03-31 19:03:21 +03:00
Ed Zynda 8159431ce4 Prevent scrolling past bottom of content in ScrollList
Enhanced clampOffset() to detect when the viewport has scrolled past the
bottom of the content (would show empty space) and automatically reposition
to show the last line of content at the bottom of the viewport.

This prevents the 'floating' effect where multiple PgDn or scroll down
operations would push content off the top while showing blank space below.

The clamping logic:
1. Calculates total content height
2. If content fits in viewport, forces position to top
3. Otherwise, checks if remaining content < viewport height
4. If so, repositions to show exactly the last line at viewport bottom

Also updated clampOffset to use rendered height calculation (handles
non-cached items like reasoning blocks) instead of cached Height().
2026-03-31 18:56:18 +03:00
Ed Zynda 9f9f265fb3 Fix autoscroll for streaming messages (iteratr pattern)
Root cause: GotoBottom() was calculating heights using Height() which returns
0 for non-cached items. Reasoning blocks never cache renders due to live
duration updates, causing incorrect scroll calculations during reasoning →
assistant transitions.

Fix: Calculate heights directly from rendered strings instead of relying on
cached Height() values. This ensures accurate scroll positioning for all
message types.

Changes:
- ScrollList.GotoBottom(): Render items and calculate height from string
- ScrollList.AtBottom(): Same pattern for bottom detection
- appendStreamingChunk(): Call GotoBottom() directly for existing messages
- refreshContent(): Remove redundant GotoBottom() (handled by SetItems)

Tested with 'explore this repo' prompt - autoscroll now works correctly
throughout reasoning and assistant streaming phases.
2026-03-31 18:53:18 +03:00
Ed Zynda 9d38349091 fix: resolve all golangci-lint issues
- Use max() instead of if statement for min value
- Use strings.SplitSeq for efficient iteration
- Use range over int instead of explicit loop counter
- Remove unused functions:
  - InputComponent.renderPopup()
  - AppModel.renderStream()
  - AppModel.renderStreamingBashOutput()
  - AppModel.printCompactResult()
2026-03-31 17:49:25 +03:00
Ed Zynda fec8bac800 refactor: remove fallback from flushStreamContent
StreamingMessageItem must exist when flushing - no fallbacks.
2026-03-31 17:45:35 +03:00
Ed Zynda e76f5f3d45 fix: prevent duplicate text when flushing streaming content before tool calls
flushStreamContent() was creating a new StyledMessageItem when tool calls
started, but we already had a StreamingMessageItem with the same content.

Now we:
- Mark the existing StreamingMessageItem as complete
- Only create a new message as fallback if no streaming item exists

This fixes text duplication when assistant messages precede tool calls.
2026-03-31 17:43:50 +03:00
Ed Zynda 1ad493c5c7 feat: cap streaming bash output height and replace with tool result
- Limit streaming bash output to 20 lines max during live display
- Remove streaming bash item when tool completes
- Replace with truncated tool result block
- Expand background color to full terminal width with proper indentation
- Matches renderBashBody styling (lineIndent + width)

This prevents long-running commands from growing the UI forever while
still showing live output up to a reasonable height.
2026-03-31 17:42:32 +03:00
Ed Zynda ea6ddc8792 feat: integrate streaming bash output into ScrollList
- Add StreamingBashOutputItem to message_items.go
- Update ToolOutputEvent handler to append chunks to bash item in ScrollList
- Remove old renderStreamingBashOutput() that broke layout
- Bash output now streams inline with messages instead of separate section
- Auto-scrolls to bottom during streaming
- Marks bash item complete on ToolResultEvent

Fixes layout breaking when bash commands produce streaming output.
2026-03-31 17:38:03 +03:00
Ed Zynda 6d4e8bcec5 feat: add streaming support for compaction summaries
- Add StreamCallback parameter to compaction.Compact() for streaming text deltas
- Update generateSummary() to use fantasy.Agent.Stream() when callback provided
- Fix compactSplitTurn() to stream both history and turn prefix summaries
- Add SDK event subscription in CompactConversation() goroutine
- Update UI to handle streaming compaction like regular assistant messages
- Compaction summaries now stream word-by-word instead of appearing all at once

Fixes issue where compaction would show incomplete context (e.g. only 'nce')
by ensuring both history summary and turn prefix are streamed to the UI.
2026-03-31 17:33:51 +03:00
Ed Zynda e2ed345280 fix: center slash command popup overlay to prevent bottom overflow
- Move popup rendering from inline (below input) to centered overlay
- Add RenderPopupCentered() method to InputComponent
- Implement overlayContent() helper for line-by-line merging
- Popup now appears in center of screen above all content
- Prevents overflow issues when typing / at bottom of terminal
2026-03-31 16:45:57 +03:00
Ed Zynda e542eb797e fix: freeze reasoning duration counter on transition to assistant text
- Detect role transition in appendStreamingChunk (reasoning → assistant)
- Mark reasoning StreamingMessageItem as complete when assistant text starts
- Duration counter now freezes immediately when reasoning ends
- Add live duration counter that updates during reasoning streaming
- Store startTime and finalDuration for proper counter behavior
2026-03-31 16:40:41 +03:00
Ed Zynda e631fc1b17 feat: add live streaming text to ScrollList viewport
- Create StreamingMessageItem that accumulates chunks and re-renders
- Update StreamChunkEvent/ReasoningChunkEvent to append to StreamingMessageItem
- Enable live streaming display within ScrollList (iteratr-style)
- Mark streaming items as complete on ResponseCompleteEvent
- Reasoning and assistant text now stream in real-time in the viewport
2026-03-31 16:35:43 +03:00
Ed Zynda 290c5a4774 chore: disable select/copy functionality but keep plumbing
Disable the mouse selection and keyboard copy features while keeping
all the supporting code infrastructure:

- Comment out MouseClickMsg, MouseMotionMsg, MouseReleaseMsg handlers
- Comment out keyboard shortcuts (c/y keys) for copying
- Keep all ScrollList selection tracking code
- Keep clipboard utilities (clipboard.go)
- Keep highlighting functions in scrolllist.go

This allows the features to be easily re-enabled later while keeping
the codebase clean for now.
2026-03-31 16:29:01 +03:00
Ed Zynda 287d60c31e feat: add visual selection highlighting with theme colors
Implement visual feedback for text selection in the scrollback:

- Add isLineInSelection() to check if a line is within the current selection
- Add applyHighlight() using the theme's Highlight color for selected lines
- Add applyFocusIndicator() using MutedBorder for focused items
- Update View() to apply highlighting during rendering
- Add getItemAndLineAtY() for precise mouse position tracking
- Track both item index and line index within item for selection

Selection highlighting uses the user's selected theme colors for
consistent visual feedback across all themes.
2026-03-31 16:23:46 +03:00
Ed Zynda 3d45d98895 feat: add crush-style copy+paste support
Implement mouse selection and keyboard copy functionality following
crush's patterns:

- Add clipboard.go with dual-write clipboard support (OSC 52 + system)
- Add CopySelection tracking to ScrollList for text selection
- Implement HandleMouseDown/HandleMouseDrag/HandleMouseUp methods
- Add keyboard shortcuts (c/y) for copying messages
- Mouse click+drag to select text, auto-copy on release
- Toast notifications for copy feedback

Note: Full text extraction from selection requires additional work to
properly extract raw text from styled message content.
2026-03-31 16:19:58 +03:00
Ed Zynda db4be4f9a2 feat: implement full alt screen mode with in-memory scrollback
Add ScrollList component for viewport-based message history with lazy
rendering and offset-based scrolling. Implement MessageItem system for
user, assistant, tool, system, and error messages with pre-rendered
styled content from MessageRenderer.

Key changes:
- ScrollList: height-constrained viewport with itemGap support, padding
  to ensure fixed height for sticky bottom layout
- MessageItem implementations with preRendered content from MessageRenderer
- refreshContent() pattern for efficient ScrollList updates
- Mouse wheel scrolling (3 lines per tick) with auto-scroll behavior
- All message types (user, assistant, tool, system, error, extension)
  properly added to in-memory scrollback
- PgUp/PgDn/Alt+Home/Alt+End keybindings for navigation
- Removed tea.Println() calls for alt screen compatibility
- Sticky bottom layout: input, separator, status bar fixed at bottom

Files added:
- internal/ui/scrolllist.go (ScrollList component)
- internal/ui/message_items.go (MessageItem implementations)

Files modified:
- internal/ui/model.go (main integration)
- internal/ui/*.go (alt screen config for components)
2026-03-31 16:12:30 +03:00
Ed Zynda 80093e69ed remove 2026-03-31 15:08:46 +03:00
Ed Zynda ef519ba517 feat(acpserver): implement session/set_model ACP method
Add SetSessionModel method to the ACP agent, allowing clients to change
the active LLM model for a session at runtime. The method looks up the
session in the registry and delegates to kit.SetModel().

Verified with smoke test: session/set_model now returns success instead
of 'Method not found' error.
2026-03-31 15:05:23 +03:00
Ed Zynda d79eb1f0fa refactor(pkg/kit): use fantasy type aliases for LLM types with clean SDK names
Replace concrete LLMMessage/LLMUsage/LLMResponse/LLMFilePart structs with
type aliases to charm.land/fantasy types, exposing them under clean
LLM-prefixed names. This gives SDK consumers full access to rich message
parts (tool calls, reasoning, tool results) without importing fantasy
directly.

Key changes:
- LLM types are now aliases: LLMMessage=fantasy.Message, etc.
- Added aliases for all part types: LLMTextPart, LLMToolCallPart, etc.
- Re-exported constructors: NewLLMUserMessage, NewLLMSystemMessage
- Removed lossy conversion helpers (llm_convert.go, fantasyMsgsToKit)
- Updated all internal packages to use aliases consistently
- Added ACP smoke test script and prompt template
- Fixed lint issues: unused vars, modernize min() usage
2026-03-31 14:26:49 +03:00
Ed Zynda ac8ee6525d refactor(pkg/kit): replace fantasy type aliases with concrete LLM* structs
Remove charm.land/fantasy from the public API surface of pkg/kit by
replacing the four type aliases with concrete Kit-owned structs:

- LLMMessage  {Role LLMMessageRole, Content string}
- LLMUsage    {InputTokens, OutputTokens, TotalTokens, ...}
- LLMResponse {Content, FinishReason, Usage}
- LLMFilePart {Filename, Data []byte, MediaType}

Add LLMMessageRole type with user/assistant/system/tool constants.

Introduce pkg/kit/llm_convert.go as the single boundary layer where
Kit types convert to/from fantasy types internally. All callers in
pkg/kit, pkg/kit/compaction.go, pkg/kit/extensions_bridge.go, and
internal/app/app.go cross through this layer.

ContextPrepareHook.Messages and ContextPrepareResult.Messages change
from []fantasy.Message to []LLMMessage. extensions_bridge.go drops
its fantasy and strings imports entirely.

internal/app/app_test.go switches &fantasy.Usage{} to &kit.LLMUsage{}.

Add seven new tests in types_test.go covering concrete construction,
role constants, JSON snake_case tags, and round-trip conversion.
2026-03-31 13:44:05 +03:00
Ed Zynda e35e8382d6 fix(app): correct drainQueue QueueUpdatedEvent emission
- Remove always-zero queueLen variable: len() was measured after
  clearing the queue, so it was unconditionally 0 and the variable
  was dead code
- Emit QueueUpdatedEvent{Length: 0} explicitly to make intent clear
- Also emit QueueUpdatedEvent when a second batch is pulled mid-loop;
  previously the queue was silently cleared without notifying the UI,
  leaving queuedMessages stuck in the displayed-queued state forever
2026-03-31 13:19:09 +03:00
Ed Zynda fbb3408a25 chore(prompts): add new-prompt template
/new-prompt <description> scaffolds a new .kit/prompts/ template.
Explains the file format, argument substitution syntax, naming
conventions, and writing guidelines.
2026-03-31 13:04:11 +03:00
Ed Zynda 44fed9a647 chore(prompts): add commit-push prompt template
Provides a /commit-push slash command that reviews git status and diff,
stages all changes, writes a conventional commit message, commits, and
pushes to the current branch.
2026-03-31 13:03:14 +03:00
Ed Zynda e7f11487b9 remove CompactRenderer and --compact flag
The compact display mode was purely a UI concern that added complexity
without providing unique value. Anyone wanting compact-style formatting
can implement it as an extension using the Renderer interface.

- Delete internal/ui/compact_renderer.go
- Remove renderToolBodyCompact and all compact tool body renderers from
  tool_renderers.go
- Simplify NewCLI(debug bool) — drop compact parameter
- Simplify NewStreamComponent(width, modelName) — drop compactMode parameter
- Remove CompactMode from AppModelOptions, app.Options, CLISetupOptions
- Remove Compact from internal/config/config.go
- Remove --compact flag, var, and viper binding from cmd/root.go
- Update format.go: remove CompactRenderer interface compile-time check
  and clean up comments
2026-03-31 13:01:30 +03:00
Ed Zynda 054c417603 fix: render reasoning blocks when resuming sessions
When using /resume to resume a session, reasoning/thinking content
was not being displayed even though it was saved in the session file.

Changes:
- Add RenderReasoningBlock to Renderer interface
- Implement RenderReasoningBlock for MessageRenderer with muted italic
  styling matching live streaming output
- Implement RenderReasoningBlock for CompactRenderer with same styling
- Update renderSessionHistory to render reasoning content before
  assistant message text

Fixes: reasoning blocks now populate correctly when resuming sessions
2026-03-31 10:34:10 +03:00
Ed Zynda 94d62a6ef0 Fix ACP thinking tag parsing to handle format
The Qwen model outputs thinking content wrapped in  tags
(not <thinking>). Updated parseThinkingTags to detect and handle
both formats:
- <thinking>...</thinking> (long format)
-   (short format)

Also removed the hasProperReasoningEvents logic that was preventing
thinking tag parsing from working correctly. Now both ReasoningDeltaEvent
(from models with proper reasoning APIs) and thinking tags in text
(from models like Qwen) are handled together, matching the TUI behavior.
2026-03-30 20:38:49 +03:00
Ed Zynda 91e6dfd2c8 Prevent double-sending of thinking content in ACP
Track whether a model sends proper ReasoningDeltaEvent events. If so,
skip parsing <thinking> tags from text to avoid sending reasoning content
twice (once as proper reasoning, once parsed from text).

Also reset the tracking state at the start of each new prompt turn.
2026-03-30 20:33:46 +03:00
Ed Zynda b6a0c4b44c Add thinking tag parsing for ACP
Parse <thinking>...</thinking> tags from models (Qwen, DeepSeek) that
wrap reasoning content in XML-style tags instead of using proper
reasoning events.

When text chunks contain thinking tags:
- Extract content between tags and send as reasoning/thought updates
- Send content outside tags as regular message text
- Track state across chunks to handle streaming properly

This mirrors the TUI's thinking tag parsing behavior.
2026-03-30 20:30:22 +03:00
Ed Zynda 8eb0fa855a Fix ACP file attachment support
- Implement proper handling for all ACP content block types:
  - ContentBlockText: extracts text content
  - ContentBlockImage: decodes base64 to LLMFilePart
  - ContentBlockAudio: decodes base64 to LLMFilePart
  - ContentBlockResource: handles text and binary embedded resources
  - ContentBlockResourceLink: reads files from disk

- Text files are now included inline in the message (not as FilePart)
  to avoid OpenAI API errors. Only binary files (images, audio, PDFs)
  are sent as FilePart attachments.

- Add fallback MIME types when not provided by client
- Add default prompt text when user attaches files without text
- Add comprehensive debug logging for content extraction
- Enable debug logging in ACP command when --debug flag is used
2026-03-30 20:28:14 +03:00
Ed Zynda 3bf696c546 prompts 2026-03-30 18:30:53 +03:00
Ed Zynda 3e461a0539 chore: unignore .kit/prompts directory 2026-03-30 18:30:21 +03:00
Ed Zynda a2ece01ecf ui: stream overflow lines into terminal scrollback buffer
Previously, when streaming text grew taller than the allocated view
height, the top (older) lines were silently discarded by viewContent().
This meant users could not scroll up to see them.

Now, overflow lines are emitted directly via tea.Println so they land
in the terminal's real scrollback buffer — matching the diagram where
completed text lives in the red scrollback region and the green viewable
area always shows the most recent streaming lines + input/footer.

Key changes:
- StreamComponent: add scrollbackFlushedLines counter and ConsumeOverflow()
  method that returns newly overflowed lines and advances the pointer
- StreamComponent.Reset(): zero the counter between steps
- StreamComponent.GetRenderedContent(): skip already-flushed lines so
  the end-of-step flush doesn't re-emit content already in scrollback
- AppModel.Update(): call ConsumeOverflow() each cycle and emit overflow
  directly via tea.Println (not appendScrollback, to avoid triggering
  drainScrollback's auto-flush guard while streaming is active)
- streamComponentIface: add ConsumeOverflow() to interface
- model_test.go: add stub ConsumeOverflow() to test double
- children_test.go: add 7 unit tests covering ConsumeOverflow and the
  updated GetRenderedContent skip-flushed-lines behaviour
2026-03-30 18:22:03 +03:00
Ed Zynda 623c9fb5ad docs(agents): add BTCA configured resources list to AGENTS.md
Enumerate all 14 external repositories configured in btca.config.jsonc
for easy reference when researching dependencies.
2026-03-30 18:20:43 +03:00
Ed Zynda 139506f336 fix(ui): refresh herald typography on theme change
When users run `/theme <name>`, the alert colors (Tip, Note, Warning, etc.)
now update correctly. Previously, MessageRenderer and StreamComponent cached
herald.Typography instances that weren't refreshed after theme changes.

Changes:
- Added UpdateTheme() method to Renderer interface
- Implemented UpdateTheme() for MessageRenderer to recreate herald typography
- Added no-op UpdateTheme() stub for CompactRenderer (fetches colors fresh)
- Implemented UpdateTheme() for StreamComponent reasoning block renderer
- Modified handleThemeCommand() to notify all renderers of theme changes

This ensures newly rendered messages use the current theme's alert colors.
2026-03-30 17:06:06 +03:00
Ed Zynda 6d424554ad Add KIT logo above startup info in TUI
- Display kitBanner() before PrintStartupInfo() when running Kit normally
- The ASCII art banner with KITT scanner lights now appears at the top
  of the screen, before Model, Context, Skills information
- Maintains consistent styling with the existing usage/help screen
2026-03-30 16:57:27 +03:00
Ed Zynda 5a3d3fdd7d fix: properly handle tags from Qwen/DeepSeek models
Models like Qwen and DeepSeek wrap reasoning content in  ...  XML-like
tags within the regular content field. This was causing the reasoning
text to appear twice - once as a reasoning block and once as regular text.

Changes:

1. Provider hooks (providers.go):
   - Extract reasoning from  tags and emit proper reasoning events
   - Use openai provider directly with custom ExtraContentFunc and
     StreamExtraFunc hooks to parse thinking content

2. Stream filtering (stream.go):
   - Filter out all text content between  and  tags at the
     streaming level to prevent duplicate rendering
   - Track state with inThinkTag flag across stream chunks

3. Message conversion (content.go):
   - Strip any remaining  tags from text content when converting
     from fantasy messages

The regex patterns use string concatenation to avoid XML tag corruption:
  regexp.MustCompile( +  +  +  +  +  +  + )

Fixes duplicate reasoning text when using custom provider with models
that wrap thinking in  tags.
2026-03-30 16:31:58 +03:00
Ed Zynda c91225629d fix: handle custom provider model persistence and bare model names
Two related fixes for --provider-url handling:

1. Don't restore custom/* models from preferences without --provider-url
   - When user runs with --provider-url, model defaults to custom/custom
   - If they switch models, custom/custom gets persisted to preferences
   - On next run without --provider-url, restoring custom/custom fails
   - Now we skip restoring custom/* models when no --provider-url is provided

2. Auto-prefix bare model names with custom/ when --provider-url is set
   - Users often provide just the model name (e.g., qwen3.5-35b-a3b)
   - This failed with 'invalid model format' error
   - Now auto-prefixed with custom/ for OpenAI-compatible endpoints
2026-03-30 16:12:16 +03:00
Ed Zynda 5a71cde5ff fix 2026-03-30 16:05:14 +03:00
Ed Zynda 044d3eb206 style: align Read tool gutter styling with Write tool
- Add block-level indentation (2 spaces) to Read tool output
- Configure herald CodeLineNumber style to use GutterBg background
- Match Write tool's gray gutter appearance
2026-03-30 15:49:57 +03:00
Ed Zynda 80f3a642a3 refactor: migrate markdown rendering from glamour to herald-md
- Replace glamour-based markdown rendering with herald/herald-md
- Update go.mod and go.sum with new dependencies
- Refactor styles.go to use Typography cache instead of TermRenderer
- Update enhanced_styles.go for compatibility
- Update btca.config.jsonc configuration
2026-03-30 15:02:01 +03:00
Ed Zynda 26f0969e3e deps: update all dependencies and refactor Read tool rendering
- Update github.com/indaco/herald v0.9.0 -> v0.10.0
- Update charm.land/bubbles/v2 v2.0.0 -> v2.1.0
- Update AWS SDK v2 packages
- Update google.golang.org/genai v1.51.0 -> v1.52.0
- Update various other dependencies

refactor(ui): use herald.CodeBlock for Read tool output

- Replace manual renderCodeBlock() with herald.CodeBlock()
- Add WithCodeLineNumberOffset() support for correct line numbers
- Extract language hint from file extension for syntax highlighting
- Preserve existing syntax highlighting via WithCodeFormatter()
- Remove unused codeLine struct and renderCodeBlock function
2026-03-30 14:51:23 +03:00
Ed Zynda 4af75901b5 test: add generalized smoke and sanity tests for all example extensions
Add two test files that auto-discover and validate every single-file
extension in examples/extensions/:

- all_extensions_load_test.go: Verifies all 32 extensions load into the
  Yaegi interpreter without errors (syntax, imports, Init signature).

- all_extensions_sanity_test.go: Six generalized sanity checks:
  - Lifecycle: SessionStart → SessionShutdown round-trip
  - CommandSanity: non-empty names/descriptions, no spaces/leading slash,
    non-nil Execute, no duplicates
  - ToolSanity: non-empty names/descriptions, at least one executor,
    valid JSON parameters, no duplicates
  - ZeroValueEvents: all 22 event types fired as zero-value structs
  - WidgetSanity: non-empty IDs, consistent keys, valid placements
  - IdempotentLifecycle: repeated SessionStart/SessionShutdown

Shared extensionFiles() helper auto-discovers extensions so new files
are automatically covered.
2026-03-29 15:12:48 +03:00
Ed Zynda 49ff4c0678 fix: /tree and /fork commands lose context due to leaf reset
performFork() called ClearMessages() after Branch(targetID), but
ClearMessages() calls TreeSession.ResetLeaf() which sets leafID back
to empty — immediately undoing the branch. The in-memory message store
was also never reloaded from the tree session after branching, so the
LLM had zero context.

Add ReloadMessagesFromTree() which clears the store and reloads it
from the tree session's current branch without resetting the leaf
pointer. Use it in performFork() instead of ClearMessages().
2026-03-29 15:02:24 +03:00
Ed Zynda b0802a5c32 fix: properly count existing cache blocks to stay under 4-block limit
The issue was that cache control persisted across turns in conversation
history, causing accumulation beyond Anthropic's 4-block limit.

Changes:
- Count existing cache blocks in message history before adding new ones
- Only add new cache blocks up to the 4-block limit
- Remove tool caching (was adding 1 block per turn)
- Skip messages that already have cache control set

Tested with 5 sequential messages - no errors, proper cache metrics.
2026-03-29 14:48:08 +03:00
Ed Zynda dfe65ca227 chore: remove all Crush references from comments
Remove mentions of Crush from:
- cache_control.go
- agent.go (2 references)
- content.go
- tool_renderers.go
- lsp-diagnostics.go (2 references)
2026-03-29 14:43:51 +03:00
Ed Zynda d4ec756ce5 fix: match Crush's cache_control strategy exactly
Crush's proven 4-block strategy:
1. Last system message (if present)
2. Last 2 conversation messages
3. Last tool definition

This stays exactly at Anthropic's 4-block limit without exceeding it.

Previous implementation could exceed the limit in certain edge cases.
Now matches Crush's battle-tested approach.
2026-03-29 14:42:29 +03:00
Ed Zynda 2971e73ee8 fix: limit Anthropic cache_control blocks to maximum 4
Anthropic API enforces a maximum of 4 blocks with cache_control per request.
The previous implementation could exceed this limit when combining:
- System message caching
- Recent message caching
- Tool definition caching

Changes:
- Add explicit cache block counting (max 4)
- Remove tool cache control to stay under limit
- Prioritize: system message first, then recent messages
- Work backwards from end to cache most recent context first

Fixes: bad request error 'A maximum of 4 blocks with cache_control may be provided'
2026-03-29 14:40:44 +03:00
Ed Zynda 5aa6c9e116 chore: fix all golangci-lint v2 issues
- Fix gofmt formatting issues in 7 files
- Replace atomic.AddUint64 with atomic.Uint64 type (modernize)
- Replace for i := 0; i < count; i++ with for i := range count (modernize)
- Replace strings.Split with strings.SplitSeq (modernize)
- Replace deprecated GetFantasyProviders with GetLLMProviders
- Replace deprecated GetFantasyMessages with GetLLMMessages
- Replace deprecated ConvertFromFantasyMessage with ConvertFromLLMMessage
- Replace deprecated FromFantasyMessage with FromLLMMessage
- Replace deprecated ToFantasyMessages with ToLLMMessages
- Remove 2 unused formatToolArgs functions
2026-03-29 14:36:03 +03:00
Ed Zynda bca08476de chore: fix remaining linting issues in caching code
- Use max() built-in instead of if statement (modernize)
- Remove unused buildAnthropicCacheOptions function
- Remove unused anthropic import
2026-03-29 14:32:28 +03:00
Ed Zynda 6a599d86af chore: fix golangci-lint v2 compatibility
- Upgrade golangci-lint to v2.11.4
- Fix errcheck warnings for os.Setenv/os.Unsetenv in tests
- Use maps.Copy instead of manual loop (modernize lint)
- Add maps import for maps.Copy
2026-03-29 14:31:19 +03:00
Ed Zynda fd6f200659 refactor: clean up self-referential comments in caching code
Remove internal monologue comments that don't add value for readers:
- Remove lengthy explanations of type conflicts that are now resolved
- Remove 'NOTE:' and 'TODO:' comments documenting implementation history
- Remove obvious test comments that just restate what the code does
- Keep only meaningful comments that explain design intent

The code is now cleaner and easier to read without the self-referential
commentary that was useful during development but not for maintenance.
2026-03-29 14:28:29 +03:00
Ed Zynda b295a25946 feat: automatic prompt caching for cost reduction
Implements automatic prompt caching to reduce API costs by 60-90% for
repeated prompts with the same context.

Architecture:
- Provider-level caching for OpenAI (PromptCacheKey)
- Message-level caching for Anthropic (avoids type conflicts)
- Model family detection enables caching regardless of provider

Key Changes:
- Add ModelInfo.Family with SupportsCaching() and CacheType() methods
- Add ProviderConfig.DisableCaching for opt-out
- Implement message-level cache control in agent (like Crush)
  - Last system message gets cache control
  - Last 2 messages get cache control
  - Last tool gets cache control
- Auto-disable caching when thinking is enabled (type conflict avoidance)
- Add KIT_DISABLE_CACHE environment variable for global opt-out

Tested with opencode/claude-sonnet-4-6 showing cacheRead/cacheWrite
tokens in debug output, confirming 60-90% cost savings.

Closes cost optimization for multi-turn conversations.
2026-03-29 14:24:07 +03:00
Ed Zynda f0e4e2f757 refactor: remove Fantasy dependency name leakage from public SDK and docs
Rename public SDK symbols to use generic LLM terminology instead of
exposing the internal dependency name (charm.land/fantasy):

Public API renames (with deprecated wrappers for backward compat):
- ConvertToFantasyMessages() → ConvertToLLMMessages()
- ConvertFromFantasyMessage() → ConvertFromLLMMessage()
- GetFantasyProviders() → GetLLMProviders()

New type alias:
- LLMFilePart = fantasy.FilePart (eliminates need for direct fantasy import)
- PromptResultWithFiles() signature now uses LLMFilePart

Internal renames (with deprecated wrappers):
- ModelsRegistry.GetFantasyProviders() → GetLLMProviders()
- TreeManager.GetFantasyMessages() → GetLLMMessages()
- TreeManager.AppendFantasyMessage() → AppendLLMMessage()
- TreeManager.AddFantasyMessages() → AddLLMMessages()
- Message.ToFantasyMessages() → ToLLMMessages()
- FromFantasyMessage() → FromLLMMessage()
- npmToFantasyProvider → npmToLLMProvider
- isProviderFantasySupported() → isProviderLLMSupported()

All internal callers migrated to new names. ~30 comments updated
to remove Fantasy references across pkg/kit/, internal/agent/,
internal/models/, internal/message/, internal/session/.

Documentation updates:
- AGENTS.md: added Public SDK rules section (no dependency leakage,
  naming conventions, deprecation pattern)
- README.md: removed Fantasy references
- pkg/kit/README.md: full rewrite with current API surface
- skills/kit-sdk/SKILL.md: updated examples and type references
- www/pages/providers.md, www/pages/cli/commands.md: updated
2026-03-29 14:01:57 +03:00
Ed Zynda d25249506a docs: update SKILL.md and README for recent SDK changes
- Add StepUsageEvent and SteerConsumedEvent to event types table
- Add new Extension API section documenting kit.Extensions() sub-API
- Add extension_api.go to Key Files reference list
- Fix Close() error handling in README SDK example
2026-03-29 13:33:19 +03:00
Ed Zynda 971521f534 Group Extension* methods behind ExtensionAPI interface
- Create ExtensionAPI interface with all extension-related methods
- Add extensionAPI type that wraps *Kit and implements the interface
- Add Kit.Extensions() method to access the ExtensionAPI
- Remove ~30 Extension* methods from Kit (breaking SDK change)
- Update all internal callers (cmd/, internal/acpserver/) to use Extensions().Method()
- Extensions themselves unaffected (use kit/ext API via Yaegi)

This cleans up the Kit API surface while maintaining full extension functionality.
2026-03-29 13:19:51 +03:00
Ed Zynda 8c00682367 Rename Fantasy* types to LLM* and remove GenerateResult alias
- FantasyMessage -> LLMMessage
- FantasyUsage -> LLMUsage
- FantasyResponse -> LLMResponse
- Remove confusing GenerateResult = TurnResult alias
- Update documentation in SKILL.md
2026-03-29 13:11:55 +03:00
Ed Zynda 58caf155c1 pkg/: internal cleanups - shared iterator, per-instance skill cache, exported EntryID
kit.go
- Extract iterBranchMessages helper to eliminate ~15 lines of duplicated
  branch-fetch/type-assert boilerplate between GetSessionMessages and
  GetStructuredMessages
- Move skillCache from package-level global to per-Kit field; avoids
  cross-contamination when multiple Kit instances exist in same process

skills.go
- Remove globalSkillCache var and skillCache type definition
- Update DiscoverSkillsForExtension and ClearSkillCache to use m.skillCache
- Remove unused sync import

sessions.go
- Use m.treeSession.EntryID instead of local getEntryID duplicate
- Remove local getEntryID function (was missing LabelEntry, SessionInfoEntry,
  CompactionEntry types that internal/session.TreeManager.EntryID handles)

internal/session/tree_manager.go
- Export entryID -> EntryID so pkg/kit can use it directly
- Update all internal callers to use EntryID

config.go
- Add sync comment for defaultSystemPrompt noting it should be kept in sync
  with CLI default in cmd/root.go

hooks_test.go
- Add newEmptyHookedTool helper for tests that need hookedTool with empty
  hook registries
- Update TestHookedTool_Passthrough and TestHookedTool_InfoDelegates to use
  helper (saves ~6 lines of boilerplate each)
- Merge TestHookRegistry_HasHooks into TestHookRegistry_Unregister (was
  testing same behavior, now just one initial state assertion added)

All changes tested with opencode/kimi-k2.5 exploring the repo in tmux.
6 files changed, 69 insertions(+), 98 deletions(-)
2026-03-29 13:00:33 +03:00
Ed Zynda 3f08bf2424 pkg/kit: SDK quality-of-life improvements
Replace var function aliases with proper func wrappers (types.go)
- ParseModelString, CreateProvider, GetGlobalRegistry, LoadSystemPrompt
  were package-level vars, making them reassignable and rendering oddly
  in go doc. Now plain func wrappers with matching signatures.

Fix Subagent() double-error return convention
- Was returning both (*SubagentResult{Error: err}, err) simultaneously.
  Now returns (nil, err) on failure, consistent with Go conventions.
- Removed SubagentResult.Error field; errors come from the error return.
- Updated all call sites in cmd/root.go, internal/acpserver, and kit.go.

Fix NavigateTo/SummarizeBranch/CollapseBranch string-encoded errors
- All three returned "" or an error string instead of error values,
  making it impossible to distinguish success from failure in SummarizeBranch
  (empty string meant both "no content" and "LLM failed").
- NavigateTo: string -> error
- SummarizeBranch: string -> (string, error)
- CollapseBranch: string -> error
- Updated cmd/root.go bridge closures to use err != nil and err.Error().

Remove duplicate GetSessionFilePath (use GetSessionPath)
- GetSessionPath (sessions.go) and GetSessionFilePath (kit.go) were
  identical. Removed GetSessionFilePath; updated cmd/root.go and
  internal/acpserver to call GetSessionPath directly.
2026-03-29 12:51:04 +03:00
Ed Zynda 9fbbab05f6 pkg/: simplify code without altering public API
events.go
- Delete subagentListenerSet (verbatim duplicate of eventBus); reuse
  *eventBus in SubscribeSubagent and getSubagentListenerSet

hooks.go
- Add early-exit in run() when hooks slice is empty, making all
  hasHooks() guard call sites in kit.go and compaction.go redundant

kit.go
- Remove four if m.X.hasHooks() { m.X.run(...) } outer guards
  (beforeTurn, contextPrepare, afterTurn x2); run() now short-circuits
- Replace goto drained with an idiomatic return inside default: branch
- Replace stdlib log.Printf with charmlog.Debug (charmbracelet/log),
  consistent with the rest of the codebase; remove "log" import

config.go
- Collapse single-element configNames := []string{".kit"} loop into a
  direct viper.SetConfigName call (removes slice, for, break, flag)

auth.go
- Fix GetOpenAIAPIKey: it documented OPENAI_API_KEY env var fallback but
  never called os.Getenv; now it does

compaction.go
- Extract persistAndEmitCompaction helper; eliminates duplicated
  AppendCompaction + events.emit block in compactInternal and
  applyCustomCompaction
- Replace fmt.Errorf("%s", reason) with errors.New(reason)
- Name the 16384 magic number as const defaultReserveTokens

skills.go
- Fix broken double-checked lock in DiscoverSkillsForExtension: the
  read-unlock -> write-lock gap had a TOCTOU race; replaced with a
  single write-lock covering the check and load
- Remove dead nil guard in convertSkills (convertSkill never returns nil)
- Rename convertSkills parameter skills->skillList to avoid shadowing
  the skills package import

extensions_bridge.go
- Delete taskMutex struct (sync.Mutex wrapper with map passed as param);
  replace with inline var taskMu sync.Mutex at the use site
- Simplify AgentEnd double-if into a single combined := declaration

template_bridge.go
- Fix RenderTemplate: use varRegex.ReplaceAllStringFunc instead of
  two-pass strings.ReplaceAll; handles arbitrary whitespace in {{var}}
- Remove dead isFlag function and simplify ParseArguments guard
  (the outer !HasPrefix guard made isFlag always return false)
- Cache matchModelPattern compiled regexps in a sync.Map to avoid
  repeated regexp.Compile on hot streaming paths

pkg/extensions/test/mock.go
- Remove dead local StatusBarEntry type (duplicate of extensions type,
  never referenced)
- Change make([]T, 0) to nil for nine slice fields in NewMockContext

pkg/extensions/test/harness.go
- Remove MustLoad (no callers outside the package)
- Remove extPath field (assigned but never read)
- Remove redundant os.Stat in LoadFile (os.ReadFile already errors)

events_test.go
- Add five missing event types to TestEventTypes table
  (Compaction, ReasoningDelta, ToolOutput, StepUsage, SteerConsumed)
- Expand TestEventOrdering from 11 to 16 events with the same types
- Add a got < 0 assertion to TestEventBusConcurrentSubscribeEmit so the
  test can actually fail rather than only logging
2026-03-29 12:39:19 +03:00
Ed Zynda b0991c7aa6 tui: simplify rendering, fix correctness issues, remove dead code
## Dead code removal
- Delete slash_command_input.go (352 lines, never instantiated)
- Remove FormatCompactLine, StyleCompactSymbol/Label/Content from
  enhanced_styles.go (zero call sites)
- Remove getTheme() alias in messages.go; standardize on GetTheme()
  across compact_renderer.go (8 sites) and tool_renderers.go (14 sites)

## BubbleTea correctness
- Fix child model discards: all m.stream.Update() and m.input.Update()
  calls now store the returned model via type-assertion (13 sites)
- Fix Init(): remove vestigial nil guards; StreamComponent.Init() always
  returns nil so only m.input.Init() is needed
- Fix /clear divergence: remove silent InputComponent /clear handler so
  parent AppModel handles it with the proper system message (one path)

## Architecture / maintainability
- Unify slash-command dispatch from two-pass (exact + prefix) to single
  parse: strings.Cut once, GetCommandByName on name, pass args to
  handleSlashCommand(sc, args); eliminates 3 separate dispatch sites
- Add noopCmd package-level var replacing three inline func()tea.Msg{nil}
  sentinel returns
- Remove stale TAS-15/16/17 comments from interface declarations
- Deduplicate headerProviderForUI / footerProviderForUI in cmd/root.go
  into a shared headerFooterProviderForUI helper (removes ~28 duplicated lines)

## Performance
- Cache glamour.TermRenderer keyed by width in styles.go; invalidate on
  theme change — eliminates full goldmark parser re-init every flush tick
- Add styleMarginBottom1 package-level var replacing 9 per-frame
  lipgloss.NewStyle().MarginBottom(1) allocations
- Add layoutDirty flag: replace 9 distributeHeight() calls in Update()
  with m.layoutDirty=true; flush once in View() — guarantees exactly one
  layout measurement per frame instead of N (reduces double-render)
- Add WidgetUpdateEvent coalescing in app.NotifyWidgetUpdate() via
  atomic.Bool + 16ms debounce; prevents fast extension tickers from
  flooding BubbleTea's message queue with redundant re-render triggers

## Concurrency safety
- Convert all NotifyWidgetUpdate() call sites in cmd/root.go to
  go appInstance.NotifyWidgetUpdate() (16 sites) — eliminates deadlock
  risk when called synchronously from inside BubbleTea's Update() handler
2026-03-29 11:34:16 +03:00
Ed Zynda 9c90563765 refactor: simplify code patterns and reduce duplication
- Extract isShellTool() helper in tool_renderers.go to eliminate
  duplicated shell tool matching logic
- Replace bannedCommands slice with compiled regex in bash.go for
  cleaner security validation
- Extract pathSet helper type in loader.go for reusable path
  deduplication
- Consolidate ac()/acOr() helpers in themes.go for better organization
- Total reduction: ~34 lines across 4 files

All tests pass (go test -race ./...) and build succeeds.
2026-03-29 01:18:27 +03:00
Ed Zynda f36166bee5 rename spawn_subagent tool to subagent; remove redundant toolDisplayNames map
Tool rename (breaking change for ToolName string comparisons in event handlers):
- internal/core/subagent.go: Name field 'spawn_subagent' → 'subagent'
- internal/extensions/wrapper.go: update coreToolKinds map key
- pkg/kit/events.go: update coreToolKinds map key and ToolKindSubagent comment
- pkg/kit/extensions_bridge.go: update three ToolName == ... guards
- internal/ui/tool_renderers.go: update two toolName == ... case guards
- internal/ui/stream.go: remove special-case branch (toolName is now already
  'subagent', so the title-case fallback produces 'Subagent' naturally)

Comments/docs updated everywhere (no logic changes):
- internal/core/tools.go, internal/extensions/api.go, events.go
- pkg/kit/kit.go, tools.go
- examples/extensions/subagent-test.go, kit-telegram/main.go
- README.md, skills/kit-sdk/SKILL.md
- www/pages/advanced/subagents.md, extensions/capabilities.md
- www/pages/index.md, sdk/callbacks.md
- www/public/session/index.html (tracked UI asset)

Redundant toolDisplayNames map removed (item #14):
- internal/ui/messages.go: delete the 7-entry map whose every value was
  identical to what the title-case fallback already produced; simplify
  toolDisplayName() to just the fallback
2026-03-29 00:24:18 +03:00
Ed Zynda 879e81f9b5 remove deprecated API methods: GetExtRunner, GetBufferedLogger, GetAgent, PromptWithCallbacks
These methods have been deprecated since the narrow-accessor and event-
subscriber APIs were introduced. No callers exist in this repository.

- pkg/kit/kit.go: remove GetExtRunner(), GetBufferedLogger(), GetAgent(),
  and PromptWithCallbacks(); update Subscribe() doc comment which still
  mentioned PromptWithCallbacks; tighten section header comment
- pkg/kit/README.md: replace PromptWithCallbacks example with the
  OnToolCall/OnToolResult/OnStreaming subscriber pattern; remove method
  from the quick-reference list
- README.md: same example migration in the SDK section
- www/pages/sdk/callbacks.md: remove the PromptWithCallbacks section
  entirely; the event-based monitoring section that followed it is now
  the lead content
- www/pages/sdk/overview.md: remove PromptWithCallbacks row from the
  prompt-variant table
- skills/kit-sdk/SKILL.md: remove the deprecated legacy callback snippet
2026-03-29 00:05:09 +03:00
Ed Zynda 727b42acfe cleanup: remove unused variable, duplicate condition, and reimplemented stdlib helper
- agent: remove unused currentToolName variable and its compiler-suppressor
  '_ = currentToolName'; currentToolArgs is the field actually used by
  OnToolResult callbacks
- tools/connection_pool: collapse double-nested identical if guard into a
  single check (copy-paste artifact)
- tools/mcp_test: replace hand-rolled contains() helper with strings.Contains;
  add 'strings' import and delete the redundant function
2026-03-29 00:00:33 +03:00
Ed Zynda 4830981570 cleanup: fix dead code, logic bug, duplication, and Unicode fuzzy matching
- config: fix tilde path expansion (filepath.Join result was discarded)
- config: remove dead comment '// base := GetConfigPath()'
- auth: extract oauthTokenExpired/oauthTokenNeedsRefresh helpers to
  eliminate copy-paste duplication across AnthropicCredentials and
  OpenAICredentials
- ui/messages: remove dead RenderToolCallMessage on MessageRenderer
  (not part of Renderer interface, never called)
- ui/compact_renderer: remove dead RenderToolCallMessage on CompactRenderer
  (symmetric duplicate, never called)
- ui/enhanced_styles: remove dead CreateGradientText wrapper
  (one-liner over ApplyGradient, never called)
- ui/fuzzy: fix fuzzyCharacterMatch to use rune iteration instead of
  byte indexing (was silently wrong for multi-byte Unicode input)
- ui/file_suggestions: remove duplicate fuzzyCharMatch; call the now-
  correct shared fuzzyCharacterMatch instead; drop unused utf8 import
- app: replace TODO comment with descriptive note (batch file attachment
  limitation is intentional, not a pending action item)
2026-03-28 23:58:14 +03:00
Ed Zynda dcfebafcc5 fix: correct token usage and cost tracking for multi-step tool calls
This commit fixes several issues with token usage tracking:

1. Fix InputTokens-only validation bug - now checks any token field > 0
   to handle OpenAI-compatible providers where cached prompts result in
   InputTokens=0 while OutputTokens>0

2. Remove per-step context token updates from recordStepUsage() - context
   fill is now set once at turn completion via updateUsageFromTurnResult
   using FinalUsage.InputTokens, preventing display jumps during multi-step
   tool calls

3. Track maximum context seen in SetContextTokens() - prevents the status
   bar from showing decreasing token counts when FinalUsage.InputTokens
   reflects only the last step's input

4. Add comprehensive debug logging for token tracking at key points:
   - StepUsageEvent emission
   - recordStepUsage processing
   - updateUsageFromTurnResult processing

5. Update tests to reflect new behavior:
   - TestRecordStepUsage_updatesTracker: no longer expects context updates
   - TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly: verifies
     InputTokens-only tracking

All tests pass. Token tracking now correctly accumulates costs and shows
monotonically increasing context size.
2026-03-28 17:49:31 +03:00
Ed Zynda 1f5c103667 fix: rock-solid token tracking - /new resets usage, remove estimation for costs
- /new command now properly resets usageTracker stats when starting fresh session
- Remove EstimateAndUpdateUsage fallback in updateUsageFromTurnResult()
- Remove EstimateAndUpdateUsage fallback in UpdateUsageFromResponse()
- Only use actual API-reported tokens for cost tracking (following opencode pattern)
- Estimation is inaccurate and should never be used for billing

Fixes issues with kimi-k2.5 and opencode token tracking where:
1. /new didn't reset token count/cost
2. Tokens never updated correctly due to estimation fallback
2026-03-28 12:15:45 +03:00
Ed Zynda 4caa8ba3dc Bridge SDK features to extension system: tree navigation, skills, templates, model resolution
This commit bridges 4 categories of internal SDK capabilities to the extension
system, enabling extensions like pi-prompt-template-model to be built with
minimal custom code.

New Extension APIs:

Tree Navigation (Phase 1):
- GetTreeNode, GetCurrentBranch, GetChildren - Navigate conversation tree
- NavigateTo - Branch/fork to specific entries
- SummarizeBranch - LLM-based branch summarization
- CollapseBranch - Fresh context primitive for context management

Skill Loading (Phase 2):
- LoadSkill, LoadSkillsFromDir - Load skill files with YAML frontmatter
- DiscoverSkills - Auto-discover from standard locations
- InjectSkillAsContext, InjectRawSkillAsContext - Pre-load skills

Template Parsing (Phase 3):
- ParseTemplate, RenderTemplate - {{variable}} substitution
- ParseArguments, SimpleParseArguments - CLI-style arg parsing (, , )
- EvaluateModelConditional, RenderWithModelConditionals - Model conditionals

Model Resolution (Phase 4):
- ResolveModelChain - Fallback chain resolution
- GetModelCapabilities - Query model specs
- CheckModelAvailable, GetCurrentProvider, GetCurrentModelID

Files Modified:
- internal/extensions/api.go - New types and Context methods
- internal/extensions/symbols.go - Export to Yaegi
- internal/extensions/runner.go - No-op stubs
- pkg/kit/sessions.go - Tree navigation bridge
- pkg/kit/skills.go - Skill loading bridge
- pkg/kit/template_bridge.go - NEW - Template & model resolution
- cmd/root.go - Wire to extension Context

Examples Added:
- conversation-manager.go - Tree nav, branch collapse, fresh context loops
- prompt-templates.go - Frontmatter templates with model switching
- bridge_demo.go - All new APIs demonstration

Documentation Updated:
- README.md - New capabilities and examples
- www/pages/extensions/capabilities.md - Full API docs
- www/pages/extensions/examples.md - New example category
- skills/kit-extensions/SKILL.md - Extension developer docs
2026-03-28 12:00:19 +03:00
Ed Zynda 15ef8ad78b fix theming 2026-03-27 23:41:32 +03:00
Ed Zynda 551f2710d9 refactor(ui): trim leading whitespace from thinking content
Use strings.TrimLeft to remove leading spaces, tabs, and newlines
from thinking/reasoning content for cleaner left alignment.
2026-03-27 21:41:16 +03:00
Ed Zynda 67bda5cad5 refactor(ui): color-code thinking block elements
- Thinking content: Italic + Muted color
- 'Thought for' label: VeryMuted color
- Duration (Xms/Xs): Accent color

Creates visual hierarchy: content > label > duration highlight
2026-03-27 21:39:06 +03:00
Ed Zynda 01d7d754ef refactor(ui): apply subdued color to thinking block text
Wrap italic thinking content with VeryMuted foreground color
for secondary visual hierarchy - less prominent than main response.
2026-03-27 21:37:42 +03:00
Ed Zynda c6304f1e92 refactor(ui): use Italic typography for thinking blocks
Change thinking content from H6 to Italic for more subdued,
secondary visual appearance. Makes reasoning text less prominent
than main assistant responses.
2026-03-27 21:36:21 +03:00
Ed Zynda bc3c733ae3 refactor(ui): use H6 instead of blockquote for thinking blocks
Change thinking/reasoning content from Blockquote to H6 (subtitle)
for cleaner visual styling without left border.
2026-03-27 21:34:26 +03:00
Ed Zynda 428ee2b8be refactor(ui): remove indentation from thinking block footer
Remove PaddingLeft(2) from 'Thought for...' duration text
so it aligns without extra indentation.
2026-03-27 21:32:50 +03:00
Ed Zynda eb1d7fd07e fix(ui): set Tip alert label to "You" for user messages 2026-03-27 21:31:05 +03:00
Ed Zynda 1e3e5cafd3 refactor(ui): use herald Tip alert for user messages
Update RenderUserMessage to use r.ty.Tip() for consistent
herald-based styling with green/success color indicator.
2026-03-27 21:30:33 +03:00
Ed Zynda 0b93e58fb9 fix(ui): correct labels for user and info messages
- Change AlertNote label from "You" to "Info" for system/extension messages
- Update RenderUserMessage to use custom styling with "You" label
- This separates user messages ("You") from info messages ("Info")
2026-03-27 21:23:17 +03:00
Ed Zynda 2bb01ed72c refactor(ui): use herald Note alert for system messages
Update RenderSystemMessage to use r.ty.Note() instead of r.ty.P()
for visual consistency with other herald-based message rendering.
This affects extension PrintInfo output and system messages.
2026-03-27 21:21:49 +03:00
Ed Zynda b6ecc36ea1 refactor(ui): improve message spacing and styling consistency
- Add bottom margin to startup header (KVGroup)
- Add bottom margin to thinking/reasoning blocks
- Fix thinking block footer to appear on new line without extra spacing
- Update spawn_subagent tool output to use bash-style formatting
- Add blank line after extension startup messages for visual separation
2026-03-27 21:15:41 +03:00
Ed Zynda d4f27bc912 revert(ui): restore original Read tool renderer without herald
The herald-based CodeBlock implementation didn't match the custom
styling we had for line numbers and gutters. Restoring the original
renderReadBody and renderCodeBlock functions with:
- Custom line number gutter styling
- Chroma syntax highlighting
- Truncation handling with footer preservation
2026-03-27 20:57:34 +03:00
Ed Zynda f12e195390 refactor(ui): replace custom message rendering with herald typography library
- Replace MessageRenderer with herald-based implementation
- Use herald alerts (Note, Tip, Warning, Caution) for message types
- Use blockquote for thinking/reasoning content
- Use KVGroup for startup info display
- Add margin-bottom to all message types for visual separation
- Simplify Read tool with herald CodeBlock and line numbers
- Add detectLanguage helper for syntax highlighting
- Capture extension startup messages and print after startup banner
- Remove ~200 lines of custom rendering code
2026-03-27 20:54:43 +03:00
Ed Zynda b68b3dd0bf Fix usage widget startup visibility and stop-path updates 2026-03-27 18:21:11 +03:00
Ed Zynda 48521bf76d ui: drop unused tool args from spinner label formatter 2026-03-27 17:54:53 +03:00
Ed Zynda 16df3a738c ui: polish stream/tool tracking comments and event-loop notes 2026-03-27 17:51:41 +03:00
Ed Zynda 9d0b8c8cef ui: simplify stream rendering state and harden stream ticks 2026-03-27 17:49:45 +03:00
Ed Zynda d9326fcf21 fix: auto-initialize extension context in kit.New()
Extensions were being loaded automatically by SetupAgent but the context
was never initialized unless the SDK user explicitly called
SetExtensionContext. This left extensions with a zero-value Context where
all function fields are nil.

Now kit.New() automatically calls SetExtensionContext with minimal defaults
(CWD, Model, Interactive=false) when extensions are loaded. SDK users can
still call SetExtensionContext to override with richer implementations
(TUI callbacks, prompts, etc.).

Combined with the normalizeContext() safety net in the runner, extensions
are now guaranteed to work in SDK mode without explicit context wiring.
2026-03-27 15:54:56 +03:00
Ed Zynda 22c479277e fix: normalize nil Context function fields to no-ops in SetContext
Extensions running via the SDK (without a fully-wired SetExtensionContext
call) would panic with 'reflect.Value.Call: call of nil function' when
calling any ctx method like ctx.PrintBlock().

normalizeContext() now replaces every nil function field in Context with
a safe no-op stub before storing it in the runner, so extension handlers
can never crash on a missing callback regardless of how Kit is embedded.
2026-03-27 15:54:54 +03:00
Ed Zynda 8ae204f12f fix: preserve full content in scrollback by separating render cache from viewport
The StreamComponent was truncating content to fit the viewport height before
caching it in renderCache. This caused GetRenderedContent() to return truncated
content when flushing to scrollback.

Changes:
- render() now caches FULL content without height clamping
- New viewContent() helper applies height clamping only for display
- View() calls both: render() for full content, viewContent() for visible slice

This follows the Pi TUI pattern: full buffer in memory, viewport slicing only
at display time. Long assistant messages are now fully preserved in scrollback.
2026-03-27 12:13:04 +03:00
Ed Zynda 8b1665a4ce feat: add multi-edit support to edit tool
Implement multi-edit functionality matching Pi's approach:
- Add 'edits' array parameter for multiple disjoint replacements
- All edits matched against original content (non-incremental)
- Overlap detection prevents conflicting edits
- Duplicate detection ensures unique matches
- Atomic operations: all succeed or none applied
- Detailed error messages with edit indices (edits[0], etc.)
- Fuzzy matching works with multi-edit mode
- Backward compatible with single-edit mode (old_text/new_text)

Changes:
- internal/core/edit.go: Multi-edit logic, validation, overlap detection
- internal/ui/messages.go: Add 'edits' to body keys
- internal/ui/tool_renderers.go: Render multi-edit diffs
- internal/core/edit_test.go: 9 comprehensive multi-edit tests
2026-03-27 10:34:43 +03:00
Ed Zynda 941f1daf0b fix: correct token/cost double-counting in usage tracker
Remove the StepUsageEvent handler from subscribeSDKEvents. It was
calling UpdateUsage() for every individual tool-calling step as it
streamed, then updateUsageFromTurnResult() called UpdateUsage() again
with TotalUsage (fantasy's own aggregate of all steps). A turn with N
tool calls was counting every token N+1 times.

Fix updateUsageFromTurnResult to use a single, clean code path:
- UpdateUsage() called exactly once per turn using TotalUsage
- SetContextTokens() uses FinalUsage.InputTokens only (not +OutputTokens)
  since input tokens of the last call = actual context window fill;
  output tokens are the response length, not context occupancy
- Estimate fallback no longer early-returns before SetContextTokens

Verified with opencode/kimi-k2.5: cost accumulates linearly across
simple and multi-step tool-calling turns with no double-counting.
anthropic/claude-sonnet-4-6 correctly shows $0.00 for OAuth sessions.
2026-03-26 16:46:48 +03:00
Ed Zynda ab7e2bda61 docs: update documentation for recent features
- Remove subagent-monitor.go references (project-local extension)
- Add customModels configuration documentation
- Document Ctrl+S mid-turn steering feature
- Update /new command description to clarify it creates new session file
- Add auto-cleanup documentation for empty sessions
2026-03-26 16:03:12 +03:00
Ed Zynda 741520927c chore: update dependencies and fix test issues
- Update all Go dependencies to latest versions
- Remove internal/app/usage_test.go (import cycle)
- Add sanitizeToolCallID function to fix message tests
- All tests pass with race detection
2026-03-26 15:56:04 +03:00
Ed Zynda 4c1bda9541 feat: auto-cleanup empty sessions on shutdown and /resume
Empty sessions (no messages) are now automatically cleaned up:

1. On shutdown: When kit exits cleanly, if the current session has no
   messages, the session file is deleted.

2. On /resume: When listing sessions for the resume picker, any empty
   session files are deleted and not shown in the list.

This prevents accumulation of orphaned empty session files when users
start sessions but don't send any messages.

Changes:
- internal/session/tree_manager.go: add IsEmpty() helper
- internal/app/app.go: delete empty session on Close()
- internal/session/store.go: filter and delete empty sessions in listSessionsInDir()
2026-03-26 15:46:51 +03:00
Ed Zynda 3b69b13556 feat: make /new create a new session file like Pi
Change /new behavior to match Pi:
- Create a completely new session file instead of just resetting the leaf
- Previous session is closed and saved (accessible via /resume)
- New session starts with 0 entries, 0 messages - clean slate
- Update help text to reflect new behavior

Key fix: SwitchTreeSession now updates the kit SDK's tree session
reference so messages are persisted to the correct file.

Files changed:
- internal/app/app.go: update kit SDK session reference
- internal/ui/model.go: create new session file on /new
- internal/ui/model_test.go: add SwitchTreeSession stub
2026-03-26 15:41:01 +03:00
Ed Zynda 83a959a379 Clean up dead code from OpenAI Codex OAuth implementation
- Remove unused modelFamily variable in createOpenAICodexProvider
- Remove dead spark handling code (spark is rejected early with error)
- Simplify buildCodexProviderOptions to only handle regular codex models
- Remove redundant comments and simplify code structure
- Net reduction: 31 lines of code
2026-03-26 15:22:16 +03:00
Ed Zynda 3491e05e9e Add clear error message for gpt-codex-spark models
Spark models are not accessible via ChatGPT OAuth and return Cloudflare
'Forbidden' errors. Add early detection and helpful error message directing
users to regular Codex models like 'openai/gpt-5.3-codex' instead.
2026-03-26 15:20:34 +03:00
Ed Zynda 0a54a8aa05 Fix OpenAI Codex model family detection for provider options
Different Codex model families use different API formats:
- gpt-codex-spark: uses standard ProviderOptions (not Responses API)
- gpt-codex, gpt-codex-mini: uses ResponsesProviderOptions

- Add detectCodexModelFamily() to determine model family from name
- Use standard ProviderOptions for spark models
- Use ResponsesProviderOptions for regular codex models
- Conditionally use WithUseResponsesAPI() based on model family

Note: gpt-5.3-codex-spark still gets Cloudflare forbidden error,
may need additional headers or different endpoint.
2026-03-26 15:17:30 +03:00
Ed Zynda 3cb3e5dba1 Fix missing system prompt when switching models in interactive mode
When using /model command to switch models, the system prompt was not being
passed to the new provider config. This caused OpenAI Codex to fail with
"Instructions are required" error.

- Load system prompt using config.LoadSystemPrompt() in SetModel
- Pass SystemPrompt to ProviderConfig when building model config
- This ensures Codex OAuth gets the instructions field it requires
2026-03-26 15:06:32 +03:00
Ed Zynda 31966c469f Skip max_output_tokens for OpenAI Codex OAuth provider
The Codex API doesn't support the max_output_tokens parameter, which was causing
"Unsupported parameter: max_output_tokens" errors.

- Add SkipMaxOutputTokens flag to ProviderResult
- Set flag when creating Codex OAuth provider
- Check flag in agent setup to skip WithMaxOutputTokens option
- This matches pi's behavior of not sending max_tokens to Codex API
2026-03-26 15:04:16 +03:00
Ed Zynda f03625d6e5 Upgrade fantasy to v0.17.1 and fix Codex API instructions parameter
- Upgrade charm.land/fantasy from v0.16.0 to v0.17.1
- Add buildCodexProviderOptions() to pass system prompt as 'instructions'
- The Codex API requires instructions as a top-level field, not as system message
- Set Store=false to prevent server-side conversation storage
- Use ResponsesProviderOptions.Instructions for system prompt
2026-03-26 15:00:10 +03:00
Ed Zynda d06641dc0a Fix OpenAI Codex API endpoint and headers
- Change base URL to /backend-api/codex for correct endpoint path
- Add browser-like User-Agent to avoid Cloudflare blocking
- Add Accept, Accept-Language, Cache-Control headers
- Match pi client headers more closely
2026-03-26 14:55:02 +03:00
Ed Zynda bbf1106e27 Add OpenAI ChatGPT/Codex OAuth authentication alongside Anthropic auth
Implements OAuth authentication for OpenAI ChatGPT Plus/Pro (Codex) similar to pi:

- Add OpenAICredentials type with OAuth and API key support
- Add OpenAI OAuth client with correct endpoints (auth.openai.com)
- Implement PKCE-based OAuth flow with local callback server on :1455
- Add login/logout/status commands for openai provider
- Support both ChatGPT/Codex OAuth tokens (chatgpt.com/backend-api) and
  regular OpenAI API keys (api.openai.com)
- Extract and store ChatGPT account ID from JWT token
- Add custom HTTP transport with required Codex headers:
  - chatgpt-account-id, originator, OpenAI-Beta: responses=experimental
- Update provider selection to use correct endpoint based on auth type

Usage:
  kit auth login openai    # OAuth with ChatGPT account
  kit auth logout openai
  kit auth status

The implementation follows the same patterns as the existing Anthropic OAuth
support, with automatic token refresh and secure credential storage in
~/.config/.kit/credentials.json
2026-03-26 14:50:15 +03:00
Ed Zynda babed03a3d feat: show bash command header in streaming output
When displaying streaming bash output, show the initial command as a
muted header ($ <command>) before the output lines. This helps users
understand what command is currently executing.

Changes:
- Add streamingBashCommand field to AppModel
- Extract command from ToolCallStartedEvent for bash tools
- Render $ <command> header in renderStreamingBashOutput
- Clear command on ToolResultEvent when tool completes
- Add tests for command extraction and cleanup
2026-03-26 14:36:39 +03:00
Ed Zynda 1cd074836f docs: document subagent monitoring events and extension
Update all documentation to include the new OnSubagentStart, OnSubagentChunk,
OnSubagentEnd lifecycle events for monitoring subagents spawned by the main agent:

README.md:
- Update lifecycle events list (20 → 23 events)
- Add subagent-monitor.go to examples list

www/pages/extensions/capabilities.md:
- Update event count (20 → 23)
- Add 3 new subagent events to lifecycle table
- Add 'Monitoring subagents spawned by the main agent' section with
  complete event handler documentation and struct definitions

www/pages/extensions/examples.md:
- Add subagent-monitor.go to Multi-agent section
- Add subagent-monitor_test.go to Development section

www/pages/advanced/subagents.md:
- Add 'Monitoring subagents from extensions' section with complete
  code example and event struct documentation
- Cross-reference subagent-monitor.go example

.agents/skills/kit-extensions/SKILL.md:
- Update lifecycle event count (18 → 21)
- Add Subagent Events section with full handler documentation
- Add event struct definitions (SubagentStartEvent, SubagentChunkEvent,
  SubagentEndEvent)
- Add 'Pattern: Monitoring Subagents with Widgets' complete example
  with Yaegi-safe design notes
2026-03-26 13:41:43 +03:00
Ed Zynda ab3ce260c8 feat: add subagent monitoring extension with horizontal widget layout
Add new extension API hooks for tracking spawned subagents:
- OnSubagentStart, OnSubagentChunk, OnSubagentEnd events
- Extensions bridge for forwarding child subagent events

Create subagent-monitor.go extension:
- Displays horizontally-stacked widgets above input box
- Shows real-time scrolling output from each subagent
- Yaegi-safe: no sync.Mutex, no goroutines, nil-guarded context calls
- Race-free design with on-demand elapsed time calculation

Add comprehensive tests:
- SessionStart, SubagentLifecycle, MultipleSubagents, SessionShutdown

Update symbols.go to export new event types for Yaegi interpreter.
2026-03-26 13:38:06 +03:00
Ed Zynda 8e8cc3946d fix: render steering user message immediately on mid-turn SteerConsumedEvent
When a steer message is consumed mid-turn via PrepareStep, no new
SpinnerEvent{Show: true} fires within that turn, so the message was
stuck in pendingUserPrints indefinitely and never rendered.

Branch the SteerConsumedEvent handler on m.state:
- stateWorking (mid-turn): flush live stream content, then print the
  steering user messages to scrollback immediately via drainScrollback.
- idle/post-turn: keep the existing pendingUserPrints deferral so the
  SpinnerEvent{Show: true} for the next turn orders things correctly.
2026-03-26 12:51:44 +03:00
Ed Zynda e18e36625e fix: route opencode models through correct provider API
Models from the opencode provider (like claude-opus-4-6 and gpt-5.3-codex)
have provider overrides in the models database that specify different npm
packages than the provider's default. The code was ignoring these overrides
and routing all models through openaicompat, causing "bad request" errors.

Changes:
- Added Provider field to modelsDBModel to capture model-specific overrides
- Added ProviderNPM field to ModelInfo registry struct
- Updated autoRouteProvider() to check for model-specific provider overrides
- Fixed URL path handling for anthropic provider (strip /v1 suffix to avoid
  double /v1/v1 paths when using third-party anthropic-compatible APIs)

Fixes routing for:
- opencode/claude-opus-4-6 -> @ai-sdk/anthropic
- opencode/gpt-5.3-codex -> @ai-sdk/openai
2026-03-26 12:44:19 +03:00
Ed Zynda be55bc03f1 Add mid-turn steering with Ctrl+S 2026-03-26 12:10:14 +03:00
Ed Zynda 09919b6307 feat: update token usage after each step in multi-step turns
Previously, token usage and costs were only updated at the end of a complete
turn. For long-running multi-step tool-calling conversations, this meant the
status bar showed stale (or zero) costs during the entire interaction.

Now, after each complete step (tool call + result), the usage tracker is
updated with the actual token counts from that step. This provides real-time
cost accumulation visible in the status bar.

Changes:
- Add StepUsageHandler type and onStepUsage parameter to agent
- Emit StepUsageEvent from kit layer after each step completes
- Handle StepUsageEvent in app layer to update UsageTracker
- Add EventStepUsage constant and StepUsageEvent struct to events

The step usage is additive - each step's tokens are added to the running
session totals, just like the final turn usage was before.
2026-03-25 18:17:48 +03:00
Ed Zynda 7a2de4cc3c fix: update token counting when switching models mid-session
When switching models (e.g., via /model command or ctx.SetModel), the usage
tracker now updates its model info to reflect the new model's:
- Pricing for cost calculations
- Context limits for percentage display
- OAuth status (to show bash costs when using OAuth creds)

Previously, token costs and context percentages continued using the old
model's settings after a switch, causing incorrect display for:
- Users switching from paid to free/OAuth models
- Users switching between models with different pricing

Changes:
- Add UpdateModelInfo() method to UsageTracker
- Call UpdateModelInfo() in both SetModel callbacks (extension and UI)
- Add auth import for OAuth detection in root.go
2026-03-25 18:09:36 +03:00
Ed Zynda acd7fd7f45 feat(ui): add line truncation to bash streaming output
Add width and count truncation to renderStreamingBashOutput to prevent
long-running commands from blowing up the TUI layout:

- Per-line width truncation via truncateLine() (ANSI-aware, matches final
  bash tool renderer behavior)
- Display cap at maxBashLines (20) showing the tail (latest output)
- Truncation hint '...(N more lines above)' when lines are hidden

The buffer still accumulates up to 50 lines for context, but only the
last 20 are rendered during streaming. This is consistent with how the
final bash tool result is displayed.
2026-03-25 18:02:50 +03:00
Ed Zynda 3446f38516 feat(ui): add line truncation to Ls tool renderer
Add maxLsLines (20) constant and truncate Ls output in the TUI to
prevent large directory listings from blowing up the layout. Shows a
'...(N more entries)' hint when truncated, consistent with all other
core tool renderers (Edit, Read, Write, Bash, Subagent).
2026-03-25 17:48:37 +03:00
Ed Zynda db4bb19bac fix: derive diff/code bg colors from active theme instead of hardcoding KITT defaults
- makeTheme() and fileConfigToTheme() now compute DiffInsertBg, DiffDeleteBg,
  DiffEqualBg, DiffMissingBg, CodeBg, GutterBg, and WriteBg by blending the
  theme's own Background with its Success/Error colors, so every theme gets
  properly tinted diff backgrounds.
- Added color derivation helpers: parseHexColor, blendHex, deriveDiffBg.
- File-based themes still allow explicit diff color overrides; derived colors
  are used only as fallbacks.
- formatToolParams() now skips body-content keys (content, old_text, new_text,
  etc.) from the header line regardless of value length, preventing raw
  unformatted code from appearing above the formatted body.
2026-03-25 17:41:37 +03:00
Ed Zynda d1cffb85ef fix: prevent bash tool from hanging on long-running/background processes
- Use process group isolation (Setpgid) so the entire process tree is
  killed on timeout/cancellation, not just the direct child
- Set cmd.Cancel to kill the process group (-pgid) with SIGKILL
- Set cmd.WaitDelay (500ms grace period) to force-close pipes when
  grandchild processes hold them open after the direct child exits
- Convert buffered path from cmd.Run() to explicit pipes + cmd.Start()
  + cmd.Wait() so WaitDelay can properly force-close pipe handles
- Reorder streaming path: cmd.Wait() before wg.Wait() so the WaitDelay
  timer starts when the child exits, not after pipes close
- Add mutex for thread-safe chunk collection in streaming mode
- Add comprehensive tests for timeout, background processes, context
  cancellation, and both buffered/streaming paths
2026-03-24 15:13:35 +03:00
Ed Zynda 329cd4ea4a feat: Add custom models via config file
Allow users to define custom models in ~/.kit.yml under the customModels
section. These models are automatically merged into the custom provider.

Example config:
  customModels:
    my-model:
      name: "My Custom Model"
      reasoning: true
      temperature: true
      cost:
        input: 0.002
        output: 0.004
      limit:
        context: 128000
        output: 32000

Usage:
  kit --model custom/my-model "Hello"
  kit --provider-url "http://localhost:8080" --model custom/my-model "Hello"

Note: When --provider-url is specified without --model, kit defaults to
custom/custom. When --provider-url is specified WITH a custom model from
config, that model is used.

Bug fixes:
- Fixed kit.New() re-loading config file and overriding CLI-specified config
- Fixed models command to reload registry for custom models
2026-03-24 14:19:49 +03:00
Ed Zynda 4e779d576f docs: Add custom provider documentation
Update README.md and www/pages/providers.md to document the new
custom/custom model that auto-loads when --provider-url is specified.
2026-03-24 13:38:52 +03:00
Ed Zynda fc054f50e8 Add custom/custom stub model for --provider-url
When users pass --provider-url without --model, automatically default
to custom/custom instead of the saved model preference. This lets users
point kit at any OpenAI-compatible endpoint without needing a provider/model
pair from the database.

The custom/custom model has:
- Zero cost (input/output = 0)
- 262K context window, 65K output limit
- Reasoning and temperature support
- Routes through openaicompat fantasy provider
2026-03-24 13:28:23 +03:00
Ed Zynda d8f1b32885 Remove --skill flag from skill subcommand to install all skills
The skill command now runs 'npx skills add mark3labs/kit' without
filtering to a single skill, installing both available skills:

1. Extensions - creating Kit extensions
2. SDK - building with the Kit Go SDK
2026-03-23 17:54:53 +03:00
Ed Zynda 1e2a3e2589 fix: preserve completed messages in session on ESC cancellation
Previously, pressing ESC twice to cancel rolled back the entire tree
session to the pre-turn state, discarding the user message, completed
tool call/result pairs, and any streamed response. Content that had
already rendered in the TUI would vanish from the session history.

Now the cancellation path uses the same logic as the non-cancellation
error path: the user message (already persisted before generation) and
any completed step messages (fully-paired tool_use + tool_result from
OnStepFinish) are preserved. Only the in-progress pending message or
tool call is discarded.

This ensures that if a message has rendered in the TUI, it stays in
the history and session.
2026-03-23 17:51:22 +03:00
Ed Zynda c7f43917b1 Add kit-sdk skill for building Go applications with the Kit SDK
Comprehensive reference covering the full pkg/kit API surface:
- Core lifecycle (New, Prompt, Close)
- All 8 prompt method variants
- Event system with 14 event types
- Hook system with 6 interceptor types and priorities
- Tool constructors, bundles, and runtime querying
- Session management (5 modes, branching, discovery)
- Model management and registry lookups
- Context estimation and compaction
- In-process subagents with event streaming
- Authentication, skills, config resolution
- 7 common patterns (scripting, daemon, streaming, etc.)
- Re-exported types reference
2026-03-23 17:27:53 +03:00
Ed Zynda 6a8833a7b1 add crypto-monitor SDK example
Background agent that checks BTC/ETH prices every 30 minutes via the
CoinGecko API and sends desktop notifications through notify-send.
Demonstrates long-running autonomous agents with the Kit SDK.
2026-03-23 16:08:25 +03:00
Ed Zynda 82cbf1d457 move SDK examples from pkg/kit/examples to examples/sdk
Relocate SDK usage examples to the top-level examples directory alongside
extension examples for better discoverability. Add README for the SDK
examples directory.
2026-03-23 16:02:54 +03:00
Ed Zynda ab09d5c9e4 docs: update README and www docs for recent features
- Update lifecycle events count from 18 to 20
- Add OnToolOutput event documentation for streaming tool output
- Add OnCustomEvent to lifecycle events list
- Fix go-edit-lint.go reference (it's project-local, not in examples/)
- Add neon-theme.go to examples list
- Add project-local extension example section
2026-03-22 21:11:18 +03:00
Ed Zynda 2347e0e506 fix: uniform background for thinking output blocks
Add Background(theme.MutedBorder) to all text elements in reasoning blocks: contentStyle, hintStyle, and footer styles. Previously these only specified foreground colors, causing them to inherit the terminal's default background instead of matching the box background.
2026-03-22 20:50:14 +03:00
Ed Zynda 3e1c19442b feat(extensions): expose ToolOutputEvent to extensions API
Extensions can now subscribe to streaming tool output events using
OnToolOutput(), giving them the same power as the internal TUI to
observe and react to tool execution in real-time.

Changes:
- Add ToolOutputEvent struct to extensions API
- Add ToolOutput constant to EventType
- Add OnToolOutput() handler registration method
- Add event bridging from kit to extensions runner
- Export ToolOutputEvent in Yaegi symbols
- Add OnToolOutput() to public SDK (pkg/kit)

Example usage in an extension:
  api.OnToolOutput(func(e ext.ToolOutputEvent, ctx ext.Context) {
    ctx.PrintInfo(fmt.Sprintf("%s: %s", e.ToolName, e.Chunk))
  })
2026-03-22 20:28:30 +03:00
Ed Zynda 3fc0ad906e feat(ui): streaming bash output in TUI
Display streaming bash output in the TUI stream region as it arrives.

Changes:
- Add streaming bash output rendering to renderStream()
- Style stdout with CodeBg, stderr with Error color
- Add streamingMu mutex for thread-safe buffer access
- Clear buffers on ToolResultEvent
- Add ToolOutputEvent to event system (pkg/kit, internal/app)
- Add ToolOutputHandler callback in agent
- Implement streaming mode in bash tool with pipes
- Add tests for accumulation and clearing

The streaming output appears in real-time below the LLM streaming text
while bash commands are executing, with proper synchronization to
prevent race conditions between Update and Render methods.
2026-03-22 20:23:19 +03:00
Ed Zynda f373c34f54 ui: remove strikethrough from diff delete lines for better readability 2026-03-22 20:21:39 +03:00
Ed Zynda 1206837af4 fix(go-edit-lint): run golangci-lint against ./... instead of single file
Running golangci-lint on a single file caused false positives due to
missing package context. Now it analyzes the entire package (./...)
while gopls still provides fast, targeted feedback on the edited file.
2026-03-22 20:19:24 +03:00
Ed Zynda f79601feb1 docs: update README and documentation for recent features
- Document model and thinking level persistence across sessions
- Document Pi-style prompt templates with argument substitution
- Document /share command and session viewer
- Document double-ESC cancellation behavior
- Document improved compaction with file tracking
- Add go-edit-lint.go to extension examples list
- Update CLI flags with --prompt-template and --no-prompt-templates
2026-03-22 19:42:30 +03:00
Ed Zynda eb3219e7ca chore: upgrade all dependencies to latest versions
- Go version: 1.26.0 -> 1.26.1 (required by charm.land/fantasy)
- Major updates:
  - charm.land/fantasy v0.11.1 -> v0.16.0
  - charm.land/lipgloss/v2 v2.0.1 -> v2.0.2
  - github.com/charmbracelet/fang v0.4.4 -> v1.0.0
  - github.com/charmbracelet/glamour v0.10.0 -> v1.0.0
  - github.com/charmbracelet/log v0.4.2 -> v1.0.0
  - github.com/mark3labs/mcp-go v0.44.1 -> v0.45.0
- Plus 30+ indirect dependency updates

All tests pass and build succeeds.
2026-03-22 19:39:53 +03:00
Ed Zynda 7e7632ad3c fix(session): use bufio.Reader instead of Scanner to handle long lines
Replace bufio.Scanner with bufio.Reader in OpenTreeSession to avoid
64KB line length limit. Scanner silently truncates long lines which can
corrupt session data. Reader handles arbitrary line lengths properly.
2026-03-22 19:36:31 +03:00
Ed Zynda 0ef46a75f2 feat: add go-edit-lint extension with TUI diagnostics visibility
- Add go-edit-lint extension that runs gopls and golangci-lint on Go file edits
- Show TUI PrintBlock only when diagnostics are found
- Add explicit prompt to LLM to fix issues when found
- Unignore .kit/extensions/ directory in .gitignore
- Color-coded borders: yellow for single tool issues, red for both
2026-03-22 19:31:50 +03:00
Ed Zynda 7f9a9da40a ui: suppress empty assistant responses in TUI
Instead of displaying 'Finished without output' or '(no output)' messages
when the LLM returns empty content, both RenderAssistantMessage functions
now return empty UIMessages with zero height. This removes confusing
placeholder messages from the scrollback.

Changes:
- internal/ui/messages.go: Return empty message when content is whitespace-only
- internal/ui/compact_renderer.go: Same behavior for compact mode
2026-03-22 19:21:12 +03:00
Ed Zynda 7ff9e84894 fix: resolve golangci-lint issues in prompts package
- Fix gofmt formatting in loader.go and loader_test.go
- Modernize strings.Index to strings.Cut (template.go:162,231)
- Use min() builtin instead of manual if check (template.go:273)
2026-03-22 19:11:52 +03:00
Ed Zynda 017eb99d44 feat: add Pi-style prompt templates
Add user-defined prompt templates that expand into full prompts with
shell-style argument substitution.

Features:
- Templates loaded from ~/.kit/prompts/*.md and .kit/prompts/*.md
- YAML frontmatter support for description
- Argument placeholders: $1, $2, $@, $ARGUMENTS, ${@:N}, ${@:N:L}
- Autocomplete integration (templates appear as /name commands)
- CLI flags: --prompt-template and --no-prompt-templates
- First-match-wins collision handling with logged diagnostics

Example template:
---
description: Review code for issues
---
Review the following code for bugs and security issues.
Focus on $1 specifically.

Usage: /review error handling
2026-03-22 19:09:15 +03:00
Ed Zynda 15a1550205 fix: don't show 'Finished without output' for empty assistant messages
When the assistant returns empty or whitespace-only content after a tool
call, the TUI was showing a 'Finished without output' message block. This
was confusing because the tool output was already displayed.

Now we simply skip rendering the assistant message block entirely when
there's no meaningful content to display.

Changes:
- printAssistantMessage: check strings.TrimSpace(text) !=  instead of text !=
- ResponseCompleteEvent handler: same trim check before printing
2026-03-22 18:49:16 +03:00
Ed Zynda 2d14b3461f feat: cancel tool calls with double-ESC without breaking API pairs
When the user presses ESC twice to cancel during a tool call, the entire
turn is now rolled back instead of persisting partial progress. This
ensures that tool_use and tool_result messages are always sent to the API
as matched pairs, avoiding errors from orphaned tool calls.

Changes:
- Save pre-turn leaf ID before appending user messages
- On context cancellation (double-ESC), branch back to pre-turn leaf
- On other errors (API failures), still persist partial progress
- Update app.go comments to reflect new behavior
2026-03-22 18:44:37 +03:00
Ed Zynda b99aafaeaa fix: correct fuzzy match position mapping and diff generation in edit tool
Two bugs fixed in internal/core/edit.go:

1. fuzzyMatch/mapFuzzyIndex returned wrong byte positions when trailing
   whitespace was stripped during normalization. The old rune-counting
   approach assumed 1:1 mapping between original and normalized strings,
   but whitespace trimming changes string length. This caused the
   replacement splice to cut at wrong boundaries, corrupting files.

   Fix: replaced with normalizeWithMap() that builds an explicit
   byte-position mapping during normalization. Also added ambiguity
   guard — multiple fuzzy matches now return no-match instead of
   silently picking the wrong one.

2. generateDiff marked the entire rest of the file as changed.
   The old code used countNewlines(old[changeIdx:]) to compute the
   diff range, which counted ALL newlines from the change point to EOF.

   Fix: replaced hand-rolled diff with udiff.Unified() (already a
   dependency) for correct standard unified diff output.

Added internal/core/edit_test.go with 33 tests covering fuzzyMatch
position mapping, normalizeWithMap correctness, generateDiff output,
and end-to-end executeEdit scenarios including corruption regression
tests.
2026-03-22 18:02:07 +03:00
Ed Zynda a55f6d3d9a feat: improved compaction with split-turn handling, file tracking, and non-destructive persistence
Rework the compaction system with several improvements modelled after
pi's approach:

Compaction engine (internal/compaction):
- Tool result truncation: cap tool result text at 2000 chars during
  serialisation to keep summarisation requests within token budgets
- Serialize tool calls and reasoning parts (previously only text)
- Split turn handling: when a single turn exceeds the keep budget,
  summarise the turn prefix separately and merge with history summary
- Cumulative file tracking: extract read/modified files from tool calls
  (read, write, edit, grep, find, ls) and carry forward across
  compactions via PreviousCompaction parameter
- Add IsSplitTurn, findTurnStart helpers and CutPoint, ReadFiles,
  ModifiedFiles fields to CompactionResult

Session tree (internal/session):
- New CompactionEntry type records summary, first-kept-entry-id, token
  stats, and file lists without deleting old messages
- BuildContext skips entries before the compaction boundary and injects
  the summary as a system message
- GetContextEntryIDs maps fantasy message indices to entry IDs for
  cut-point resolution
- GetLastCompaction retrieves prior file tracking state

Non-destructive compaction (pkg/kit):
- Compact now appends a CompactionEntry instead of clearing and
  rewriting the session — old messages remain on disk for history
- Extension hook (BeforeCompact) can now provide a custom Summary that
  replaces the LLM-generated one, in addition to cancelling

UI (internal/ui):
- Tree selector renders CompactionEntry nodes with info styling

Events & hooks (pkg/kit):
- CompactionEvent includes ReadFiles and ModifiedFiles
- BeforeCompactResult gains Summary field for extension-provided summaries
- Bridge updated to forward custom summaries from extensions
2026-03-22 17:14:50 +03:00
Ed Zynda 027c2de849 fix: detach subagent context from parent deadline
The spawn_subagent tool was inheriting the parent's context deadline,
causing subagents to be killed prematurely (e.g. after ~120s instead of
the intended 5-minute default).

The parent LLM generation loop's context carries its own deadline which,
via Go's context.WithTimeout semantics (takes the minimum of parent
deadline and new timeout), would always win over the subagent's longer
timeout.

Add a detachedContext type that preserves context values (spawner func,
etc.) and propagates parent cancellation (Ctrl-C) but strips the
deadline. Applied only in the internal tool handler (executeSubagent) so
the public Kit.Subagent() SDK method continues to honor caller-provided
context deadlines.
2026-03-22 17:12:40 +03:00
Ed Zynda d24540693c refactor: simplify max line chars calculation with builtin max() 2026-03-22 14:35:26 +03:00
Ed Zynda f7c8e7757b feat: persist model selection and thinking level across sessions
Model and thinking level choices now survive restarts, matching the
existing theme persistence pattern. Selections are saved to
~/.config/kit/preferences.yml and restored on next launch.

Precedence: CLI flag > config file > saved preference > default

Changes:
- Extended preferences struct with model and thinking_level fields
- Refactored preferences.go to shared load/save helpers (DRY)
- Added SaveModelPreference/LoadModelPreference
- Added SaveThinkingLevelPreference/LoadThinkingLevelPreference
- Persist on /model, model selector, /thinking, and Shift+Tab cycle
- Restore at startup in runNormalMode when no explicit flag/config
- Added modelFlagChanged/thinkingFlagChanged to detect explicit flags
- Comprehensive tests for all preference operations
2026-03-22 13:52:06 +03:00
Ed Zynda 0d5374b17b fix: truncate long lines in tool messages instead of wrapping
Use ANSI-aware truncation (charmbracelet/x/ansi) to prevent ugly line
wrapping in all tool renderers (Bash, Read, Write, Ls, Edit, Subagent).

- Replace byte-length padRight/truncateLine with xansi.StringWidth and
  xansi.Truncate which correctly handle ANSI escape codes and wide chars
- Bash: truncate lines to fit panel width instead of allowing 3x wrapping
- Read/Write: truncate syntax-highlighted lines before lipgloss renders
- Ls: truncate entries before styling
- Compact renderers: use ANSI-aware truncation consistently
2026-03-22 13:41:57 +03:00
Ed Zynda 25f17a104d fix: truncate long individual lines to prevent TUI blow-up
A single very long line (e.g. minified JSON, base64 blob) could wrap
into hundreds of visual rows in the TUI even when within the line-count
and byte-count limits.

Core layer (truncate.go):
- Add defaultMaxLineLen (2000 chars) per-line cap
- Apply truncateLongLines() in both TruncateTail and truncateHead
  before line/byte truncation
- Append '[N chars truncated]' marker to capped lines

UI layer:
- Cap lines in renderBashBody() to width*3 chars before rendering
- Cap lines in shell display handler (model.go) similarly

Add comprehensive tests in truncate_test.go.
2026-03-22 13:31:25 +03:00
Ed Zynda 20125f939b feat: add /share command and session viewer
- Add /share slash command that uploads session JSONL to GitHub Gist
  via the gh CLI and prints a shareable viewer URL
- Add session viewer SPA at www/public/session/index.html served at
  go-kit.dev/session/#GIST_ID
- Viewer supports all message types: text, reasoning/thinking blocks,
  tool calls (bash, read, write, edit, grep, find, ls, spawn_subagent),
  images, model changes, branch summaries, and labels
- Tool-specific rendering with syntax highlighting, diffs, collapsible
  output, and status badges
- Also supports ?url= query param for loading from any JSONL URL
- Dark theme matching Kit brand colors
2026-03-22 13:23:44 +03:00
Ed Zynda d3b67ffd14 feat(ui): render session history on /resume and /import
When a user resumes or imports a session, all conversation messages are
now rendered into the TUI scrollback buffer, giving visual context of
the prior conversation. This includes:

- User messages with original text
- Assistant responses with model name from the session
- Tool calls with name, args, and full output/error status

The implementation does a two-pass walk over the session branch:
1. Builds a toolCallID → {name, args} map from assistant messages
2. Renders each message entry using the existing print helpers

Wired into both the /resume session picker (SessionSelectedMsg handler)
and the /import command handler.
2026-03-22 00:57:57 +03:00
Ed Zynda 915dc066dd docs: update documentation for recent features
- Document theme persistence in themes.md and README.md
- Document session commands (/resume, /export, /import, /name) in commands.md and sessions.md
- Document prompt history (up/down arrows) in commands.md
- Document SubscribeSubagent API in sdk/callbacks.md and advanced/subagents.md
2026-03-21 21:15:27 +03:00
Ed Zynda 3b14814740 feat: persist theme selection across sessions
Theme choices via /theme or ctx.SetTheme() were previously lost on
restart. Now the selected theme name is saved to
~/.config/kit/preferences.yml and restored on next launch.

Precedence: .kit.yml theme > preferences.yml > default (kitt).

- Add internal/ui/preferences.go with atomic save/load
- ApplyTheme() now persists; ApplyThemeWithoutSave() for startup
- Fallback to saved preference in cmd/root.go init()
2026-03-21 21:01:25 +03:00
Ed Zynda a1decf9cff feat: add SubscribeSubagent API for per-tool-call event streaming
Add Kit.SubscribeSubagent(toolCallID, listener) which lets SDK consumers
opt into real-time events from LLM-initiated subagents. Listeners are
keyed by the spawn_subagent tool call ID, which is available in the
ToolCallEvent before the subagent starts.

The typical pattern is:

    kit.OnToolCall(func(e kit.ToolCallEvent) {
        if e.ToolName == "spawn_subagent" {
            kit.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
                // real-time subagent events
            })
        }
    })

Implementation:
- Thread toolCallID through SubagentSpawnFunc so generate() knows which
  tool call triggered the spawn
- Add subagentListenerSet (per-tool-call event bus) stored in a sync.Map
  on the Kit struct, keyed by toolCallID
- In generate(), wire OnEvent to dispatch to registered listeners only
  when SubscribeSubagent has been called for that tool call
- Listeners are cleaned up automatically when the subagent completes
- No listeners registered = no OnEvent callback = no overhead (the
  default TUI path)
2026-03-21 20:48:40 +03:00
Ed Zynda ec4ac64343 fix: stop re-emitting subagent events onto parent event bus
The core tool spawner in generate() was unconditionally setting OnEvent
to re-emit every child event onto the parent Kit's event bus. This caused
subagent tool calls, streaming text, reasoning, and responses to surface
in the TUI as if they were the parent's own events.

Remove the OnEvent callback from the core tool spawner. The spawn_subagent
tool is a blocking call that returns a summary result — it doesn't need
real-time event streaming. SDK consumers who need real-time subagent events
can call Kit.Subagent() directly with their own OnEvent callback. The
extension and ACP paths are unaffected as they bridge to their own
callbacks independently.
2026-03-21 20:41:28 +03:00
Ed Zynda a95117686e fix: override SHELL env var to bash in command execution
When the user's login shell is nushell, fish, or another non-bash shell,
the SHELL environment variable leaks through to child processes. This
causes tools like tmux to use the wrong shell for pane commands, leading
to failures (e.g. nushell rejects 'sleep 30' because it requires
'sleep 30sec').

Override SHELL to point to the resolved bash binary path in both the
bash tool (internal/core/bash.go) and the TUI shell command handler
(internal/ui/model.go) so child processes always use bash.
2026-03-21 18:47:16 +03:00
Ed Zynda c0880e1ef6 fix: preserve completed tool calls when cancelling with ESC
When pressing ESC twice to cancel an agent turn, completed tool calls
and their results were being discarded along with the in-progress text.
Only the streaming text should be discarded.

The root cause was a chain of two issues:

1. Agent layer (internal/agent/agent.go): Fantasy's Stream() returns
   nil on error, discarding all accumulated step data. Fixed by tracking
   completed step messages via the OnStepFinish callback and returning
   a partial GenerateWithLoopResult alongside the error.

2. App layer (internal/app/app.go): The in-memory message store was
   never synced from the tree session after cancellation. Fixed by
   reloading the store from the tree session (which the SDK's runTurn
   already persists partial progress to).

The existing partial-persistence code in pkg/kit/kit.go runTurn() was
correct but was dead code because the agent layer always returned nil
on error. It now receives the partial result and persists completed
step messages to the tree session as intended.
2026-03-21 18:32:28 +03:00
Ed Zynda 4e66c0b4f7 feat: add session management features (picker, history, /name, /export, /import)
Fill session management gaps compared to pi:
- TUI session picker (/resume, --resume flag) with search, scope/filter
  toggles, delete, right-aligned metadata, and background-highlighted cursor
- Prompt history navigation via up/down arrows (100-entry ring buffer)
- /name <name> command now functional (was stubbed as not implemented)
- /export [path] exports session JSONL to file
- /import <path> loads session from JSONL file
- Remove deprecated unused App.runQueueItem method
2026-03-20 18:08:48 +03:00
Ed Zynda 131ce8f2cc Fix message batching and cancellation persistence
1. Batch queued messages into single agent turn
   - Add PromptResultWithMessages() to SDK for batch submission
   - Rewrite drainQueue() to collect all queued items and submit together
   - This prevents the agent from processing queued messages sequentially

2. Persist tool messages on cancellation
   - When generation is cancelled (double-ESC), persist any completed
     tool calls and results to the session before returning
   - Prevents the agent from re-doing work when user continues

Both issues caused the agent to lose context:
- Batching: Multiple queued messages now submitted as one turn
- Cancellation: Tool results from cancelled turns are preserved
2026-03-20 17:18:07 +03:00
Ed Zynda 3d0f3358cb feat: Update model aliases for Anthropic, OpenAI, and Google Gemini
Update model aliases to point to latest versions based on models.dev:

Anthropic:
- claude-opus-latest, claude-4-opus-latest -> claude-opus-4-6
- claude-sonnet-latest, claude-4-sonnet-latest -> claude-sonnet-4-6
- Add claude-haiku-latest, claude-4-haiku-latest -> claude-haiku-4-5
- Keep existing 3.x aliases

OpenAI (new):
- o1-latest -> o1, o3-latest -> o3, o4-latest -> o4-mini
- gpt-5-latest, gpt-5-chat-latest -> gpt-5.4
- gpt-4-latest, gpt-4 -> gpt-4o
- gpt-3.5-latest, gpt-3.5 -> gpt-3.5-turbo
- codex-latest -> codex-mini-latest

Google Gemini (new):
- gemini-pro-latest, gemini-pro -> gemini-2.5-pro
- gemini-flash-latest, gemini-flash -> gemini-2.5-flash

Also update README and www documentation to reflect new aliases and
update default model references to use claude-sonnet-latest alias.
2026-03-20 14:01:22 +03:00
Ed Zynda 25da02fa65 docs: add extensions/testing page to navigation 2026-03-20 13:48:21 +03:00
Ed Zynda 4ae03aab7c refactor: move extension testing package to pkg/
Move the extension testing package from internal/extensions/test to
pkg/extensions/test to make it publicly importable by external extension
authors.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Introduce a scrollback buffer: all print helpers now buffer rendered
content via appendScrollback(). At the end of each Update cycle,
drainScrollback() combines everything into a single tea.Println. If
the stream component has unflushed content it is auto-prepended, so
new messages always appear below the previous assistant response.
2026-03-18 14:16:37 +03:00
Ed Zynda 66f2ba543b refactor: align message styling with iteratr conventions
Swap user/assistant border colors (user=blue, assistant=mauve), remove
per-message timestamps and username labels, simplify system messages to
borderless muted text with diamond prefix, change tool name color from
peach to blue, and redesign thinking blocks with surface background,
line truncation, and duration footer.
2026-03-17 15:11:33 +03:00
Ed Zynda 6dd052b990 fix: improve input keybindings, user message rendering, and scrollback ordering
- Change newline keybinding from alt+enter to shift+enter across all
  input components (main input, slash command input, prompt overlay)
- Skip markdown rendering for plain-text user messages so newlines are
  preserved without extra paragraph spacing from glamour
- Fix scrollback ordering: defer queued user message printing to
  SpinnerEvent where previous stream content is guaranteed complete,
  combining flush + user message into a single tea.Println call
2026-03-17 14:23:16 +03:00
Ed Zynda ef8628eecc fix: forward subagent events to parent event bus in core spawn_subagent tool
The spawner closure in generate() called m.Subagent() without setting
OnEvent, so child events (tool calls, text streaming, reasoning deltas)
were silently discarded. Wire OnEvent to re-emit on the parent's bus,
matching the behavior already present in the extension SpawnSubagent path.
2026-03-17 13:03:41 +03:00
Ed Zynda 3167222b72 fix: gracefully recover from bad model names in subagents
If the requested model fails (bad name, unsupported provider), fall
back to the parent's model instead of returning a hard error. The
original prompt is prepended with a note so the agent knows which
model is actually running and can adjust future calls.
2026-03-16 13:43:52 +03:00
Ed Zynda e3b37191b1 fix: inherit parent provider for bare model names in subagents
When spawn_subagent is called with a model name like 'claude-haiku'
(no provider prefix), prepend the parent's provider instead of letting
ParseModelString guess. Only full 'provider/model' strings bypass this.
2026-03-16 13:41:02 +03:00
248 changed files with 52403 additions and 7080 deletions
-64
View File
@@ -1,64 +0,0 @@
---
name: btca-cli
description: Operate the btca CLI for local resources and source-first answers. Use when setting up btca in a project, connecting a provider, adding or managing resources, and asking questions via btca commands. Invoke this skill when the user says "use btca" or needs to do more detailed research on a specific library or framework.
---
# btca CLI
`btca` is a source-first research CLI. It hydrates resources (git, local, npm) into searchable context, then answers questions grounded in those sources. Use configured resources for ongoing work, or one-off anonymous resources directly in `btca ask`.
Full CLI reference: https://docs.btca.dev/guides/cli-reference
Add resources:
```bash
# Git resource
btca add -n svelte-dev https://github.com/sveltejs/svelte.dev
# Local directory
btca add -n my-docs -t local /absolute/path/to/docs
# npm package
btca add npm:@types/node@22.10.1 -n node-types -t npm
```
Verify resources:
```bash
btca resources
```
Ask a question:
```bash
btca ask -r svelte-dev -q "How do I define remote functions?"
```
## Common Tasks
- Ask with multiple resources:
```bash
btca ask -r react -r typescript -q "How do I type useState?"
```
- Ask with anonymous one-off resources (not saved to config):
```bash
# One-off git repo
btca ask -r https://github.com/sveltejs/svelte -q "Where is the implementation of writable stores?"
# One-off npm package
btca ask -r npm:react@19.0.0 -q "How is useTransition exported?"
```
## Config Overview
- Config lives in `btca.config.jsonc` (project) and `~/.config/btca/btca.config.jsonc` (global).
- Project config overrides global and controls provider/model and resources.
## Troubleshooting
- "No resources configured": add resources with `btca add ...` and re-run `btca resources`.
- "Provider not connected": run `btca connect` and follow the prompts.
- "Unknown resource": use `btca resources` for configured names, or pass a valid HTTPS git URL / `npm:<package>` as an anonymous one-off in `btca ask`.
@@ -1,3 +0,0 @@
interface:
display_name: "BTCA CLI"
short_description: "Help with BTCA CLI setup and usage workflows"
-853
View File
@@ -1,853 +0,0 @@
---
name: kit-extensions
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
---
# Kit Extensions Development Guide
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
## Extension Structure
Every extension must export a `package main` with an `Init(api ext.API)` function:
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
// Register event handlers, tools, commands, etc.
}
```
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
## Extension Locations
Extensions are auto-loaded from these directories:
- `~/.config/kit/extensions/*.go` (global, single files)
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
- `.kit/extensions/*.go` (project-local, single files)
- `.kit/extensions/*/main.go` (project-local, subdirectories)
Or loaded explicitly:
```bash
kit -e path/to/extension.go
kit --extension path/to/extension.go
```
## Import Path
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
## API Overview
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
---
## Lifecycle Events
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
### Session Events
```go
// Fired when session is loaded/created.
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
// e.SessionID string
})
// Fired when Kit is shutting down. Use for cleanup.
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
// No fields.
})
```
### Agent Turn Events
```go
// Before agent starts processing. Can inject system prompt or text.
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
// e.Prompt string
// Return nil to pass through.
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
return nil
})
// Agent loop has started.
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
// e.Prompt string
})
// Agent finished responding.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// e.Response string
// e.StopReason string — "completed", "cancelled", "error"
})
```
### Tool Events
```go
// Before a tool executes. Can block the call.
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
// e.ToolName string
// e.ToolCallID string
// e.Input string — JSON-encoded parameters
// e.Source string — "llm" or "user"
// Return nil to allow.
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
return nil
})
// Tool execution started (informational only).
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
// e.ToolName string
})
// Tool execution ended (informational only).
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
// e.ToolName string
})
// After a tool returns. Can modify the result.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
// e.ToolName string
// e.Input string
// e.Content string
// e.IsError bool
// Return nil to pass through.
// Return &ext.ToolResultResult{Content: &s} to replace content.
// Return &ext.ToolResultResult{IsError: &b} to change error status.
return nil
})
```
### Input Events
```go
// User submitted input. Can handle or transform it.
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
// e.Text string
// e.Source string — "interactive", "cli", "script", "queue"
// Return nil to pass through to agent.
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
return nil
})
```
### Streaming Events
```go
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
// e.Chunk string — streaming text chunk
})
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
// e.Content string — full message content
})
```
### Model Events
```go
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
// e.NewModel string
// e.PreviousModel string
// e.Source string — "extension" or "user"
})
```
### Context Filtering
```go
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
// e.Messages []ext.ContextMessage
// Each ContextMessage has: Index int, Role string, Content string
// Index -1 means a new injected message (not from session).
// Return nil to pass through.
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
return nil
})
```
### Session Control Events
```go
// Before forking the session tree. Can cancel.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
// e.TargetID string, e.IsUserMessage bool, e.UserText string
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
})
// Before switching/clearing session. Can cancel.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
// e.Reason string — "new" or "clear"
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
})
// Before context compaction. Can cancel.
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
// e.EstimatedTokens, e.ContextLimit int
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
})
```
### Custom Events
```go
// Subscribe to custom events emitted by other extensions.
api.OnCustomEvent("event-name", func(data string) {
// data is arbitrary string payload
})
// Emit from Context:
ctx.EmitCustomEvent("event-name", "payload")
```
---
## Registering Tools
Tools are functions the LLM can invoke:
```go
api.RegisterTool(ext.ToolDef{
Name: "current_time",
Description: "Get the current date and time",
Parameters: `{"type":"object","properties":{}}`,
Execute: func(input string) (string, error) {
return time.Now().Format(time.RFC3339), nil
},
})
```
For long-running tools with cancellation and progress:
```go
api.RegisterTool(ext.ToolDef{
Name: "slow_task",
Description: "A long-running task with progress reporting",
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
for i := 0; i < 10; i++ {
if tc.IsCancelled() {
return "cancelled", nil
}
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
time.Sleep(time.Second)
}
return "done", nil
},
})
```
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
---
## Registering Slash Commands
Commands are user-facing actions invoked with `/name` in the input:
```go
api.RegisterCommand(ext.CommandDef{
Name: "echo",
Description: "Echo back the provided text",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.PrintInfo("You said: " + args)
return "", nil
},
// Optional tab-completion:
Complete: func(prefix string, ctx ext.Context) []string {
return []string{"hello", "world"}
},
})
```
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
---
## Registering Keyboard Shortcuts
```go
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handler runs when shortcut is pressed
})
```
---
## Registering Options
Options are configurable values resolved from env vars, config, or defaults:
```go
api.RegisterOption(ext.OptionDef{
Name: "my-setting",
Description: "Controls something",
Default: "false",
})
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
val := ctx.GetOption("my-setting")
// Set at runtime:
ctx.SetOption("my-setting", "true")
```
---
## Context API Reference
The `ext.Context` struct provides runtime capabilities via function fields.
### Output
```go
ctx.Print("plain text") // plain output
ctx.PrintInfo("styled info block") // bordered info block
ctx.PrintError("styled error block") // red error block
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
Text: "content",
BorderColor: "#a6e3a1",
Subtitle: "my-ext",
})
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
```
### Message Injection
```go
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
```
### Widgets
Persistent UI elements displayed above or below the input area:
```go
ctx.SetWidget(ext.WidgetConfig{
ID: "my-widget",
Placement: ext.WidgetAbove, // or ext.WidgetBelow
Content: ext.WidgetContent{
Text: "Status: Active",
Markdown: false, // set true for markdown rendering
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1", // hex color
NoBorder: false,
},
Priority: 0, // lower values render first
})
ctx.RemoveWidget("my-widget")
```
### Header and Footer
```go
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Header"},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
ctx.RemoveHeader()
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Footer"},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
ctx.RemoveFooter()
```
### Status Bar
```go
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
ctx.RemoveStatus("key")
```
### Interactive Prompts
These block until the user responds (safe in slash commands and goroutines):
```go
// Selection list
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Pick one:",
Options: []string{"Option A", "Option B", "Option C"},
})
if !result.Cancelled {
// result.Value string, result.Index int
}
// Yes/No confirmation
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure?",
DefaultValue: false,
})
if !result.Cancelled {
// result.Value bool
}
// Text input
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter name:",
Placeholder: "my-project",
Default: "",
})
if !result.Cancelled {
// result.Value string
}
```
### Overlay Dialogs
Modal dialogs with optional action buttons:
```go
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Confirmation",
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
Width: 60, // 0 = 60% of terminal width
MaxHeight: 20, // 0 = 80% of terminal height
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
Actions: []string{"Confirm", "Cancel"},
})
if !result.Cancelled {
// result.Action string, result.Index int
}
```
### Editor Interceptor
Wrap the built-in text input with custom key handling and rendering:
```go
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
if key == "ctrl+s" {
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
Render: func(width int, defaultContent string) string {
return "[custom] " + defaultContent
},
})
ctx.ResetEditor() // remove interceptor
ctx.SetEditorText("prefilled") // set editor text content
```
**EditorKeyAction types:**
- `ext.EditorKeyPassthrough` — let the default editor handle the key
- `ext.EditorKeyConsumed` — swallow the key, do nothing
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
### UI Visibility
```go
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
```
### Session Data
```go
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
path := ctx.GetSessionPath() // file path of session JSONL
// Persist custom data in the session tree:
id, err := ctx.AppendEntry("my-type", "data string")
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
```
### Model Management
```go
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
```
### Tool Management
```go
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
ctx.SetActiveTools(nil) // re-enable all tools
```
### LLM Completions
Make standalone LLM calls (bypasses the agent tool loop):
```go
resp, err := ctx.Complete(ext.CompleteRequest{
Model: "", // empty = current model
System: "You are ...", // optional system prompt
Prompt: "Summarize...", // the prompt
MaxTokens: 1000, // 0 = provider default
OnChunk: func(chunk string) { /* streaming */ },
})
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
```
### TUI Suspension
Temporarily release the terminal for interactive subprocesses:
```go
ctx.SuspendTUI(func() {
cmd := exec.Command("vim", "file.go")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
```
### Application Control
```go
ctx.Exit() // graceful shutdown
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
```
### Context Fields
```go
ctx.SessionID // string
ctx.CWD // string — current working directory
ctx.Model // string — active model name
ctx.Interactive // bool — true if running in TUI mode
```
---
## Tool Renderers
Customize how tool calls are displayed in the TUI:
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell", // replaces auto-capitalized name
BorderColor: "#89b4fa",
Background: "",
BodyMarkdown: true, // render body through markdown
RenderHeader: func(toolArgs string, width int) string {
var args struct{ Command string `json:"command"` }
json.Unmarshal([]byte(toolArgs), &args)
return "$ " + args.Command
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "ERROR: " + toolResult
}
return toolResult
},
})
```
## Message Renderers
Define named output styles for `ctx.RenderMessage()`:
```go
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
return " " + content // green checkmark prefix
},
})
// Usage in handlers:
ctx.RenderMessage("success", "All tests passed")
```
---
## Critical Yaegi Constraints
### No Named Function References in Struct Fields
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
```go
// WRONG - will silently return zero values:
func myHandler(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
// CORRECT - use anonymous closure:
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
```
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
### No Interfaces Across the Boundary
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
### Package-Level Variables for State
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
```go
package main
import "kit/ext"
var callCount int
var lastTool string
func Init(api ext.API) {
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
callCount++
lastTool = e.ToolName
return nil
})
}
```
---
## Common Patterns
### Pattern: Tool Call Blocking
Block dangerous operations by intercepting tool calls:
```go
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "bash" {
var input struct{ Command string `json:"command"` }
json.Unmarshal([]byte(tc.Input), &input)
if strings.Contains(input.Command, "rm -rf") {
return &ext.ToolCallResult{
Block: true,
Reason: "Dangerous command blocked",
}
}
}
return nil
})
```
### Pattern: System Prompt Injection
Augment the agent's behavior by injecting instructions:
```go
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
prompt := "Always respond with bullet points."
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
```
### Pattern: Background Processing with SendMessage
Run work in a goroutine and inject results back:
```go
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command in the background",
Execute: func(args string, ctx ext.Context) (string, error) {
go func() {
out, err := exec.Command("sh", "-c", args).CombinedOutput()
if err != nil {
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
return
}
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
}()
return "Running in background...", nil
},
})
```
### Pattern: Ephemeral Context Injection
Inject information into every LLM turn without persisting in session history:
```go
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
data, err := os.ReadFile(".kit/context.md")
if err != nil {
return nil
}
injected := ext.ContextMessage{
Index: -1, // -1 = new message, not from session
Role: "system",
Content: string(data),
}
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
```
### Pattern: Live Widget Updates
Update a widget periodically from a goroutine:
```go
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetWidget(ext.WidgetConfig{
ID: "clock",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
}()
})
```
### Pattern: Spawning Kit as a Sub-Agent
Extensions can spawn Kit as a subprocess for delegation:
```bash
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
---
## Testing Extensions
```bash
# Validate syntax of all discovered extensions
kit extensions validate
# List loaded extensions
kit extensions list
# Run with a specific extension
kit -e path/to/extension.go
# Run with multiple extensions
kit -e ext1.go -e ext2.go
# Disable all extensions
kit --no-extensions
# Generate an example extension scaffold
kit extensions init
```
---
## Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
```go
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
func Init(api ext.API) {
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if strings.ToLower(ctx.GetOption("plan")) == "true" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
Focus on understanding, analysis, and generating plans.`
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
}
func applyMode(ctx ext.Context, active bool, tools []string) {
if active {
ctx.SetActiveTools(tools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON")
} else {
ctx.SetActiveTools(nil)
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF")
}
}
```
## Key Files for Reference
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
+79
View File
@@ -0,0 +1,79 @@
name: Bug Report
description: Report a bug or issue with Kit
title: "fix: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Bug Description
description: What happened? What did you expect to happen?
placeholder: |
The BorderColor field in ToolRenderConfig is documented but never applied
during tool rendering. I expected the tool block to render with my custom
color, but it uses the default styling instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Provide clear steps to reproduce the issue
placeholder: |
1. Create an extension with `api.RegisterToolRenderer(ext.ToolRenderConfig{...})`
2. Set `BorderColor: "#89b4fa"` in the config
3. Run a tool that uses this renderer
4. Observe the border color is not applied
render: markdown
validations:
required: true
- type: textarea
id: code
attributes:
label: Relevant Code / Configuration
description: Paste any code, configuration, or error messages
placeholder: |
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell",
BorderColor: "#a6e3a1", // This is ignored!
Background: "#1e1e2e", // This is ignored!
})
```
render: go
- type: input
id: component
attributes:
label: Affected Component
description: Which part of Kit is affected?
placeholder: e.g., extensions, ui, tool rendering, session management
- type: input
id: version
attributes:
label: Kit Version
description: What version of Kit are you running?
placeholder: e.g., v0.1.0, commit hash, or "main"
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other context, proposed fixes, or related issues
placeholder: |
The issue appears to be in `internal/ui/messages.go:RenderToolMessage()`
which ignores the BorderColor and Background fields from ToolRendererData.
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've searched existing issues and this hasn't been reported yet
required: true
- label: I've tested with the latest version of Kit
required: false
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Kit Documentation
url: https://github.com/mark3labs/kit/tree/main/www/pages
about: Check the documentation before filing an issue
- name: Extension Examples
url: https://github.com/mark3labs/kit/tree/main/examples/extensions
about: See working extension examples for reference
- name: Discussions
url: https://github.com/mark3labs/kit/discussions
about: For questions, ideas, or general discussion
+40
View File
@@ -0,0 +1,40 @@
name: Documentation Issue
description: Report missing, incorrect, or unclear documentation
title: "docs: "
labels: ["documentation"]
body:
- type: textarea
id: description
attributes:
label: Documentation Issue
description: What's wrong or missing in the documentation?
placeholder: |
The ToolRenderConfig documentation mentions BorderColor and Background fields,
but the code doesn't actually use them. The docs should either be updated
to reflect reality, or the bug should be fixed.
validations:
required: true
- type: input
id: location
attributes:
label: Documentation Location
description: Where is the affected documentation?
placeholder: e.g., README.md, examples/extensions/tool-renderer-demo.go, pkg/kit docs
- type: textarea
id: suggestion
attributes:
label: Suggested Improvement
description: How should the documentation be improved?
placeholder: |
Add a note that BorderColor and Background are not yet implemented,
or fix the bug and document the correct behavior.
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've checked that this documentation issue still exists in the latest version
required: true
@@ -0,0 +1,64 @@
name: Feature Request
description: Suggest a new feature or enhancement for Kit
title: "feat: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: Feature Description
description: What would you like to see added or changed?
placeholder: |
I'd like to be able to customize the border color of tool result blocks
dynamically based on the tool type or result status.
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation / Use Case
description: Why is this feature needed? What problem does it solve?
placeholder: |
When running multiple tools in sequence, it's hard to visually distinguish
between file reads (blue), shell commands (green), and errors (red)
without custom border colors.
validations:
required: true
- type: textarea
id: proposed
attributes:
label: Proposed Implementation
description: How do you think this should work? (optional)
placeholder: |
Extend `ToolRenderConfig` to accept a function that receives the tool
result and returns a color based on the content:
```go
BorderColorFunc: func(result string, isError bool) string {
if isError {
return "#f38ba8"
}
return "#89b4fa"
}
```
render: go
- type: checkboxes
id: alternatives
attributes:
label: Alternatives Considered
options:
- label: I've considered workarounds or alternative approaches
required: false
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've searched existing issues and this hasn't been requested yet
required: true
- label: This feature aligns with Kit's design philosophy (TUI-first, extension-based)
required: false
+32
View File
@@ -0,0 +1,32 @@
name: Build and Deploy Docs to GitHub Pages
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install Dependencies
working-directory: ./www
run: bun install
- name: Build
working-directory: ./www
run: bun run build
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: www/out
branch: gh-pages
+5 -2
View File
@@ -1,14 +1,17 @@
.aider*
.task/
.env
.kit/
.kit/*
!.kit/extensions/
!.kit/prompts/
aidocs/
*.log
/kit
.idea
test/
build/
dist/
contribute/output/
CONTEXT.md
output/
.agents/
skills-lock.json
+268
View File
@@ -0,0 +1,268 @@
//go:build ignore
package main
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"kit/ext"
)
const (
diagnosticsTimeout = 20 * time.Second
maxOutputBytes = 12_000
)
type toolPathInput struct {
Path string `json:"path"`
}
type lintResult struct {
Output string
Err error
}
// Package-level state: set of .go files edited during the current agent turn.
var editedFiles map[string]bool
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint after agent turns that edit Go files")
})
// Track edited .go files — don't lint yet.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
if e.IsError || !isEditOrWrite(e.ToolName) {
return nil
}
absPath, ok := resolveGoFilePath(e.Input, ctx.CWD)
if !ok {
return nil
}
if editedFiles == nil {
editedFiles = make(map[string]bool)
}
editedFiles[absPath] = true
return nil
})
// After the agent turn ends, lint all collected files.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if len(editedFiles) == 0 {
return
}
// Snapshot and reset immediately so the next turn starts clean.
files := editedFiles
editedFiles = nil
// Skip lint on errored turns.
if e.StopReason == "error" {
return
}
// Collect unique directories and file list for gopls.
var allGoplsOutput []string
for absPath := range files {
res := runGopls(ctx.CWD, absPath)
formatted := formatToolResult(res, "")
if formatted != "" {
allGoplsOutput = append(allGoplsOutput, fmt.Sprintf("# %s\n%s", filepath.Base(absPath), formatted))
}
}
lintRes := runGolangCILint(ctx.CWD, "./...")
goplsSection := "No diagnostics."
if len(allGoplsOutput) > 0 {
goplsSection = strings.Join(allGoplsOutput, "\n\n")
}
lintSection := formatToolResult(lintRes, "No lint issues.")
// Build file list for the report header.
var fileNames []string
for absPath := range files {
fileNames = append(fileNames, filepath.Base(absPath))
}
report := fmt.Sprintf(
"<go_diagnostics files=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
strings.Join(fileNames, ", "),
goplsSection,
lintSection,
)
goplsIssues, lintIssues := countIssues(report)
hasIssues := goplsIssues > 0 || lintIssues > 0
if hasIssues {
// Show TUI block so the user sees it too.
var msgLines []string
msgLines = append(msgLines, fmt.Sprintf("Files: %s", strings.Join(fileNames, ", ")))
if goplsIssues > 0 {
msgLines = append(msgLines, fmt.Sprintf("gopls: %d issue(s)", goplsIssues))
}
if lintIssues > 0 {
msgLines = append(msgLines, fmt.Sprintf("golangci-lint: %d issue(s)", lintIssues))
}
borderColor := "#f9e2af" // yellow
if goplsIssues > 0 && lintIssues > 0 {
borderColor = "#f38ba8" // red
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: strings.Join(msgLines, "\n"),
BorderColor: borderColor,
Subtitle: "go-edit-lint",
})
// Inject a follow-up message so the agent fixes the issues.
ctx.SendMessage(report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review and fix the issues above.")
} else {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: fmt.Sprintf("Files: %s\n✓ All clean", strings.Join(fileNames, ", ")),
BorderColor: "#a6e3a1",
Subtitle: "go-edit-lint",
})
}
})
}
func isEditOrWrite(toolName string) bool {
return strings.EqualFold(toolName, "edit") || strings.EqualFold(toolName, "write")
}
func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
var args toolPathInput
if err := json.Unmarshal([]byte(inputJSON), &args); err != nil || args.Path == "" {
return "", false
}
absPath := args.Path
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(cwd, absPath)
}
if strings.ToLower(filepath.Ext(absPath)) != ".go" {
return "", false
}
return absPath, true
}
func runGopls(cwd, absPath string) lintResult {
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "gopls", "check", absPath)
cmd.Dir = cwd
out, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
}
if err != nil {
return lintResult{Output: truncate(string(out), maxOutputBytes), Err: fmt.Errorf("failed to run gopls check: %w", err)}
}
return lintResult{Output: truncate(string(out), maxOutputBytes)}
}
func runGolangCILint(cwd, target string) lintResult {
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
defer cancel()
args := []string{
"run",
target,
"--show-stats=false",
"--output.text.path", "stdout",
"--output.text.colors=false",
"--output.text.print-issued-lines=false",
}
cmd := exec.CommandContext(ctx, "golangci-lint", args...)
cmd.Dir = cwd
out, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
}
trimmed := truncate(string(out), maxOutputBytes)
if err == nil {
return lintResult{Output: trimmed}
}
exitErr, ok := err.(*exec.ExitError)
if ok && exitErr.ExitCode() == 1 {
return lintResult{Output: trimmed}
}
return lintResult{Output: trimmed, Err: fmt.Errorf("failed to run golangci-lint: %w", err)}
}
func formatToolResult(res lintResult, emptyFallback string) string {
var lines []string
if res.Err != nil {
lines = append(lines, "ERROR: "+res.Err.Error())
}
out := strings.TrimSpace(res.Output)
if out == "" {
if res.Err == nil {
if emptyFallback != "" {
lines = append(lines, emptyFallback)
}
}
} else {
lines = append(lines, out)
}
if len(lines) == 0 {
return emptyFallback
}
return strings.Join(lines, "\n")
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n... output truncated ..."
}
func countIssues(report string) (goplsCount, lintCount int) {
goplsStart := strings.Index(report, "[gopls]")
lintStart := strings.Index(report, "[golangci-lint]")
endTag := strings.Index(report, "</go_diagnostics>")
if goplsStart != -1 && lintStart != -1 {
goplsSection := report[goplsStart:lintStart]
for _, line := range strings.Split(goplsSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[gopls]" && line != "No diagnostics." && !strings.HasPrefix(line, "#") {
goplsCount++
}
}
}
if lintStart != -1 && endTag != -1 {
lintSection := report[lintStart:endTag]
for _, line := range strings.Split(lintSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
lintCount++
}
}
}
return goplsCount, lintCount
}
+304
View File
@@ -0,0 +1,304 @@
//go:build ignore
// subagent-monitor — live horizontal widget strip for spawned subagents
//
// Subscribes to subagents spawned by the main Kit agent and displays a
// single widget just above the input box. Each subagent occupies one column
// in a side-by-side horizontal layout. Columns show scrolling real-time
// output as the subagent works. When a subagent finishes its column is
// removed automatically.
//
// Yaegi-safe design notes:
// - No sync.Mutex (Yaegi has reflection issues with sync primitives)
// - No channels in maps (Yaegi panics on range over map[string]chan)
// - All ctx.* calls guarded with nil checks
// - Simple data structures only
package main
import (
"fmt"
"strings"
"time"
"kit/ext"
)
// ---------------------------------------------------------------------------
// Per-subagent state
// ---------------------------------------------------------------------------
type submonEntry struct {
id int
callID string
task string
lines []string
started time.Time
elapsed time.Duration
}
const (
submonColWidth = 34 // visible character width per column
submonMaxLines = 5 // scrolling output lines per column
submonColGap = 2 // spaces between columns
)
// ---------------------------------------------------------------------------
// Package-level state - all simple types
// ---------------------------------------------------------------------------
var (
submonCtx ext.Context
submonHasCtx bool
submonEntries []*submonEntry
submonNextID int
)
func submonInit() {
submonEntries = nil
submonNextID = 1
}
// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------
func submonPad(s string, w int) string {
r := []rune(s)
if len(r) >= w {
return string(r[:w])
}
return s + strings.Repeat(" ", w-len(r))
}
func submonTrunc(s string, w int) string {
r := []rune(s)
if len(r) <= w {
return s
}
if w <= 1 {
return "…"
}
return string(r[:w-1]) + "…"
}
// ---------------------------------------------------------------------------
// Widget rendering
// ---------------------------------------------------------------------------
func submonRenderColumn(e *submonEntry) []string {
var rows []string
// Calculate elapsed time on-demand to avoid race conditions with ticker
elapsed := e.elapsed
if elapsed == 0 && !e.started.IsZero() {
elapsed = time.Since(e.started)
}
secs := int(elapsed.Seconds())
timeStr := fmt.Sprintf("%ds", secs)
taskMax := submonColWidth - len(timeStr) - 3
taskPart := submonTrunc(e.task, taskMax)
header := fmt.Sprintf("#%d %s %s", e.id, taskPart, timeStr)
rows = append(rows, submonPad(header, submonColWidth))
display := e.lines
if len(display) > submonMaxLines {
display = display[len(display)-submonMaxLines:]
}
for _, l := range display {
rows = append(rows, submonPad(" "+submonTrunc(l, submonColWidth-2), submonColWidth))
}
for len(rows) < submonMaxLines+1 {
if len(rows) == 1 && len(e.lines) == 0 {
rows = append(rows, submonPad(" waiting…", submonColWidth))
} else {
rows = append(rows, strings.Repeat(" ", submonColWidth))
}
}
return rows
}
func submonBuildWidget() string {
if len(submonEntries) == 0 {
return ""
}
numCols := len(submonEntries)
numRows := submonMaxLines + 1
cols := make([][]string, numCols)
for i, e := range submonEntries {
rows := submonRenderColumn(e)
col := make([]string, numRows)
for j := 0; j < numRows; j++ {
if j < len(rows) {
col[j] = rows[j]
} else {
col[j] = strings.Repeat(" ", submonColWidth)
}
}
cols[i] = col
}
gap := strings.Repeat(" ", submonColGap)
var sb strings.Builder
for row := 0; row < numRows; row++ {
for ci := range cols {
if ci > 0 {
sb.WriteString(gap)
}
sb.WriteString(cols[ci][row])
}
if row < numRows-1 {
sb.WriteString("\n")
}
}
return sb.String()
}
func submonPushWidget() {
if !submonHasCtx {
return
}
if submonCtx.SetWidget == nil {
return
}
text := submonBuildWidget()
if len(submonEntries) == 0 {
if submonCtx.RemoveWidget != nil {
submonCtx.RemoveWidget("submon")
}
return
}
submonCtx.SetWidget(ext.WidgetConfig{
ID: "submon",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: text},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
Priority: 0,
})
}
func submonAppendLine(e *submonEntry, line string) {
line = strings.TrimRight(line, "\r\n")
if strings.TrimSpace(line) == "" {
return
}
e.lines = append(e.lines, line)
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
func Init(api ext.API) {
submonInit()
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
submonInit()
if ctx.RemoveWidget != nil {
ctx.RemoveWidget("submon")
}
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
})
// ── SubagentStart ────────────────────────────────────────────────────────
api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
id := submonNextID
submonNextID++
entry := &submonEntry{
id: id,
callID: e.ToolCallID,
task: e.Task,
started: time.Now(),
}
submonEntries = append(submonEntries, entry)
submonPushWidget()
})
// ── SubagentChunk ────────────────────────────────────────────────────────
api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
var entry *submonEntry
for _, en := range submonEntries {
if en.callID == e.ToolCallID {
entry = en
break
}
}
if entry == nil {
return
}
switch e.ChunkType {
case "text":
for _, line := range strings.Split(e.Content, "\n") {
submonAppendLine(entry, line)
}
case "tool_call":
submonAppendLine(entry, "→ "+e.ToolName)
case "tool_execution_start":
submonAppendLine(entry, "⚙ "+e.ToolName)
case "tool_result":
if e.IsError {
submonAppendLine(entry, "✗ "+e.ToolName)
} else {
submonAppendLine(entry, "✓ "+e.ToolName)
}
}
submonPushWidget()
})
// ── SubagentEnd ──────────────────────────────────────────────────────────
api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
var entry *submonEntry
for _, en := range submonEntries {
if en.callID == e.ToolCallID {
entry = en
break
}
}
if entry != nil {
entry.elapsed = time.Since(entry.started)
if e.ErrorMsg != "" {
submonAppendLine(entry, "✗ "+submonTrunc(e.ErrorMsg, submonColWidth-2))
}
}
submonPushWidget()
// Remove the entry immediately (no goroutine to avoid races)
newEntries := submonEntries[:0]
for _, en := range submonEntries {
if en.callID != e.ToolCallID {
newEntries = append(newEntries, en)
}
}
submonEntries = newEntries
submonPushWidget()
})
// ── SessionShutdown ──────────────────────────────────────────────────────
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
submonInit()
// Guard ctx access - may be nil during shutdown
if ctx.RemoveWidget != nil {
ctx.RemoveWidget("submon")
}
})
}
+37
View File
@@ -0,0 +1,37 @@
---
description: Run ACP smoke test against opencode/kimi-k2.5 to verify JSON-RPC stdio works
---
Run the ACP smoke test to verify the Kit ACP server works correctly over JSON-RPC stdio with streaming responses.
## Steps
1. Build the kit binary:
```bash
go build -o output/kit ./cmd/kit
```
2. Run the smoke test Python script against opencode/kimi-k2.5:
```bash
python3 scripts/acp_smoke_test.py
```
3. Verify the output shows:
- `session/new` returns a valid `sessionId`
- `session/prompt` streams `agent_thought_chunk` notifications (reasoning)
- `session/prompt` streams `agent_message_chunk` notifications (response)
- Final result has `stopReason: "end_turn"`
- `✓ SMOKE TEST PASSED` at the end
4. If the test fails, check:
- `output/kit` binary exists and is executable
- `OPENCODE_API_KEY` or `OPENCODE_ZEN_API_KEY` environment variable is set
- `scripts/acp_smoke_test.py` exists
- The model `opencode/kimi-k2.5` is available (`kit models opencode | grep kimi-k2.5`)
5. For testing with a different model, edit the script or set the `MODEL` variable:
```bash
MODEL=anthropic/claude-sonnet-4-5 python3 scripts/acp_smoke_test.py
```
The smoke test exercises the full ACP protocol: session lifecycle, streaming notifications, and tool-free prompt completion.
+30
View File
@@ -0,0 +1,30 @@
---
description: Stage, commit, and push changes with an auto-generated conventional commit message
---
Review the current git status and diff, then stage all changes, write a concise conventional commit message, commit, and push to the current branch.
## Steps
1. **Check status**: `git status` — understand what has changed
2. **Review the diff**: `git diff` (and `git diff --cached` if anything is already staged) — read the actual changes
3. **Stage everything**: `git add -A`
4. **Craft the commit message** following Conventional Commits:
- Format: `<type>(<scope>): <short summary>`
- Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `perf`, `build`
- Scope: optional, the subsystem affected (e.g. `ui`, `cmd`, `config`)
- Summary: imperative mood, lowercase, no trailing period, ≤72 chars
- Body: add a blank line then bullet points for non-trivial changes
- Do **not** include "Generated by" or similar noise
5. **Commit**: `git commit -m "<message>"`
6. **Push**: `git push`
## Guidelines
- Read the actual diff — do not guess from filenames alone
- Prefer one well-scoped commit; do not split unless the changes are clearly unrelated
- Keep the subject line under 72 characters
- Use the body to explain *what* and *why*, not *how*
- If there is nothing to commit, say so and stop
$@
+86
View File
@@ -0,0 +1,86 @@
---
description: Create a feature request using the GitHub template
---
Create a feature request for the Kit repository. The user wants to request: $@
## Feature Request Template
This prompt uses the `feature_request` GitHub template which requires:
| Field | Required | Purpose |
|-------|----------|---------|
| **Feature Description** | Yes | What should be added or changed |
| **Motivation / Use Case** | Yes | Why is this needed? What problem does it solve? |
| **Proposed Implementation** | No | How do you think this should work? |
## Steps
1. **Understand the request** from `$@`
- What capability is missing?
- What would the ideal behavior look like?
2. **Ask clarifying questions** if needed:
- "What problem does this solve for you?"
- "How would you expect this to work?"
- "Are there similar features in other tools you use?"
3. **Craft the title** using conventional format:
- `feat: <short description>`
- Lowercase, imperative mood, ≤72 chars
- Good examples:
- `feat: add keyboard shortcut for clearing input`
- `feat: support custom themes per extension`
- `feat: add fuzzy matching to model selector`
- Bad examples:
- `Feature request: can we have...` (too vague)
- `It would be nice if...` (not imperative)
4. **Build the body** with the template fields:
**Feature Description:**
- Clear statement of what to add/change
- Be specific about the behavior
- Include UI/UX details if relevant
**Motivation / Use Case:**
- What problem does this solve?
- Current workaround (if any) and why it's insufficient
- Who benefits from this feature?
**Proposed Implementation** (optional but helpful):
- High-level approach
- API changes if applicable
- Example usage code
5. **Create the issue**:
```bash
gh issue create --template feature_request --title "feat: ..." --body "..."
```
6. **Confirm success**:
- Show the issue URL and number
- Mention it was created with the feature_request template
## Guidelines
- Focus on the *problem* first, then the solution
- Include concrete examples of how the feature would be used
- Consider edge cases and mention them
- If proposing API changes, show before/after code
- Check if similar features exist in related tools (mention them for reference)
- Align with Kit's philosophy: TUI-first, extension-based, keyboard-driven
## Example
User: `/feature-request I want to be able to customize tool border colors dynamically`
You:
1. Title: `feat: dynamic border colors for tool results based on status`
2. Body:
- **Feature Description**: Allow `ToolRenderConfig` to accept a function that determines border color based on tool result content or status, enabling dynamic visual feedback.
- **Motivation**: When running multiple tools, it's hard to distinguish file reads (blue), shell commands (green), and errors (red) without custom colors per result.
- **Proposed Implementation**: Add `BorderColorFunc` callback that receives `(result string, isError bool)` and returns a color string.
3. Execute: `gh issue create --template feature_request --title "feat: ..." --body "..."`
4. Confirm: Created issue #43 using feature_request template
+100
View File
@@ -0,0 +1,100 @@
---
description: File a GitHub issue using the appropriate template
---
File a GitHub issue for the Kit repository. The user wants to create an issue about: $@
## Issue Templates Available
This repository has structured issue templates. You MUST use the appropriate template:
| Type | Template | Use For |
|------|----------|---------|
| `bug` | `bug_report` | Something is broken, not working as expected |
| `feat` | `feature_request` | New feature, enhancement, improvement |
| `docs` | `documentation` | Missing, incorrect, or unclear documentation |
## Steps
1. **Determine the issue type** from `$@`:
- Bug → use `--template bug_report`
- Feature → use `--template feature_request`
- Documentation → use `--template documentation`
2. **Ask clarifying questions** if critical info is missing:
- For bugs: "What were you doing when this happened?" (reproduction steps)
- For features: "What problem does this solve?" (motivation)
- For docs: "Where did you look for this information?" (location)
3. **Craft the title** using conventional format:
- `<type>: <short description>`
- Lowercase, imperative mood, ≤72 chars
- Examples:
- `fix: ToolRenderConfig BorderColor ignored during rendering`
- `feat: add keyboard shortcut for clearing input`
- `docs: clarify extension widget lifecycle`
4. **File the issue** using the template:
```bash
# For bugs
gh issue create --template bug_report --title "fix: ..." --body "..."
# For features
gh issue create --template feature_request --title "feat: ..." --body "..."
# For documentation
gh issue create --template documentation --title "docs: ..." --body "..."
```
The template will guide the user through the required fields. You need to provide:
- **Bug reports**: Description, reproduction steps, expected vs actual behavior
- **Feature requests**: Description, motivation/use case, optional proposed implementation
- **Documentation**: Description, location of docs, suggested improvement
5. **Confirm success** by showing:
- The issue URL
- The issue number
- Which template was used
## Template Field Guide
### Bug Report (`bug_report`)
Required fields in the body:
- **Bug Description** - what happened vs expected
- **Steps to Reproduce** - numbered list to recreate the bug
- **Relevant Code** - code snippets, configuration, error messages
- **Component** - which part of Kit (ui, extensions, session, etc.)
- **Version** - Kit version or commit hash
### Feature Request (`feature_request`)
Required fields in the body:
- **Feature Description** - what to add/change
- **Motivation / Use Case** - why this is needed
- **Proposed Implementation** - how it could work (optional)
### Documentation (`documentation`)
Required fields in the body:
- **Documentation Issue** - what's wrong or missing
- **Documentation Location** - file or URL where docs exist
- **Suggested Improvement** - how to fix the docs
## Guidelines
- ALWAYS use `--template <name>` instead of bare `gh issue create`
- Include file paths and line numbers when you know them
- Use triple backticks for code blocks
- Keep the body factual - avoid speculation unless in "Proposed Fix" section
- If you're unsure about technical details, say so in the issue
- For UI bugs, describe what you see vs what you expect
- For API bugs, include the relevant struct/function names
## Example Usage
User: `/file-issue The ToolRenderConfig BorderColor field is documented but never used in rendering`
You:
1. Determine this is a **bug** (documented field doesn't work)
2. Use `--template bug_report`
3. Gather: reproduction steps (register renderer with BorderColor), expected (custom color), actual (default color)
4. Create issue with title `fix: ToolRenderConfig BorderColor and Background fields are ignored`
5. Confirm: Created issue #42 using bug_report template
+47
View File
@@ -0,0 +1,47 @@
---
description: Scaffold a new prompt template in .kit/prompts/
---
Create a new kit prompt template. The user wants a prompt that does: $@
## What a prompt template is
A prompt template is a `.md` file in `.kit/prompts/` (project-local) or `~/.kit/prompts/` (global).
It becomes a `/slug` slash command in the kit input box — typed as `/filename` with optional arguments.
## File format
```
---
description: One-line description shown in autocomplete
---
Body text of the prompt. Use $@ for all user-supplied arguments,
$1 $2 etc. for positional arguments.
```
- **Filename** → slug: `commit-push.md` becomes `/commit-push`
- **Frontmatter**: only `description` is recognised; keep it under ~80 chars
- **Body**: plain markdown; the full text is submitted as the user's message when the template fires
- **Arguments**: `$@` expands to everything the user typed after the slash command name;
`$1`, `$2` for individual positional args; omit entirely if no arguments are needed
## Steps
1. **Understand the workflow** the user described in `$@` — ask a clarifying question if the intent is ambiguous
2. **Choose a filename**: short, lowercase, hyphen-separated, descriptive (e.g. `code-review.md`)
3. **Write the description**: one sentence, imperative, fits in autocomplete
4. **Draft the body**:
- Open with a single sentence stating the goal
- Use `## Steps` for multi-step workflows; use plain prose for simple prompts
- Be specific: name commands, flags, and file paths where relevant
- End with `$@` on its own line if the user might want to pass context or a hint; omit if the prompt is self-contained
5. **Write the file** to `.kit/prompts/<slug>.md`
6. **Confirm** by showing the final file content and the slash command that activates it
## Guidelines
- Keep prompts action-oriented — they should tell kit *what to do*, not just *what to think about*
- Prefer concrete steps over vague instructions
- A prompt that does one thing well beats one that tries to cover every edge case
- If the workflow already exists as a prompt, suggest extending it instead of duplicating
+70
View File
@@ -0,0 +1,70 @@
---
description: Semantic version tagging workflow - analyzes commits and tags releases
---
# Release Tagging Workflow
Tag a new version of this Go project following semantic versioning.
## Steps
1. **Fetch remote tags**: `git fetch --tags origin`
2. **Find latest version**: `git tag -l | sort -V | tail -5` to see recent tags
3. **Analyze changes since last tag**:
- `git log <latest-tag>..HEAD --oneline` - list commits
- `git diff <latest-tag>..HEAD --stat` - see file stats
- `git diff <latest-tag>..HEAD --name-only` - see changed files
4. **Determine version bump** (Semantic Versioning):
- **MAJOR (X.0.0)**: Breaking API changes, incompatible modifications
- **MINOR (0.X.0)**: New features, backward-compatible additions
- **PATCH (0.0.X)**: Bug fixes, backward-compatible fixes
Look for indicators:
- `feat:` or `feature:` commits → MINOR
- `fix:` or `bugfix:` commits → PATCH
- `breaking:` or `BREAKING CHANGE:` → MAJOR
- Breaking API changes in `pkg/` or public interfaces → MAJOR
- New commands, flags, or features → MINOR
- Documentation-only changes → PATCH (or skip)
5. **Calculate new version**: Increment appropriate segment, reset lower segments to 0
6. **Draft tag message**:
- Summarize key changes from commits
- Group by type (Features, Fixes, Breaking Changes)
- Keep concise but informative
7. **Create annotated tag**: `git tag -a vX.Y.Z -m "vX.Y.Z - <summary>\n\n<detailed list>"`
8. **Push tag**: `git push origin vX.Y.Z`
## Guidelines
- Always fetch remote tags first to avoid conflicts
- Use annotated tags (`-a`) with descriptive messages
- Follow semver strictly - when in doubt, prefer conservative bump (patch over minor)
- For Go projects, changes to `pkg/` or exported APIs warrant careful version consideration
- If no changes since last tag, suggest skipping the release
- Include commit summaries in the tag message body
## Example Tag Message Format
```
v0.30.1 - Bug fixes for model handling and UI improvements
Fixes:
- Properly handle think tags from Qwen/DeepSeek models
- Handle custom provider model persistence and bare model names
Improvements:
- UI style refactoring and cleanup
```
Wait for the user to confirm the version and message before executing tag commands.
---
$@
+45 -19
View File
@@ -1,22 +1,3 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
# KIT Agent Guidelines
## Build/Test Commands
@@ -42,6 +23,33 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors
- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel``InputComponent`, `StreamComponent`, etc.)
- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly
- **Public SDK** (`pkg/kit/`): The public-facing Go SDK for embedding Kit as a library. See rules below.
## Public SDK (`pkg/kit/`) Rules
`pkg/kit/` is the **public API surface** consumed by external Go developers. All exported symbols, types, function names, and godoc comments in this package are part of the SDK contract.
### No Dependency Name Leakage
Internal dependency names (e.g. `charm.land/fantasy`, library-specific jargon) **must not** appear in:
- **Exported function/method names** — use generic terms (`LLM`, `Provider`, `Message`) instead of library names
- **Exported type names** — type aliases should use domain names (e.g. `LLMMessage`, not `FantasyMessage`)
- **Godoc comments** on exported symbols — these are visible in `go doc` output and pkg.go.dev
- **Struct field names and tags** on exported types
Using dependency types directly in **function bodies** (private implementation) is fine — that's invisible to SDK consumers.
### Naming Conventions for SDK Symbols
- Type aliases re-exporting dependency types: use `LLM*` prefix (e.g. `LLMMessage`, `LLMUsage`, `LLMResponse`)
- Conversion helpers: use `ConvertToLLM*` / `ConvertFromLLM*` (not the dependency name)
- Provider queries: use `GetLLMProviders` (not `GetFantasyProviders`)
- When wrapping internal methods, the `pkg/kit/` name should be dependency-agnostic even if the `internal/` method still uses the old name
### Deprecation Pattern
When renaming a public SDK symbol, keep the old name as a deprecated wrapper for one release cycle:
```go
// Deprecated: Use NewName instead.
func OldName() { return NewName() }
```
## Key Patterns
@@ -92,3 +100,21 @@ Positional args are the prompt. `@file` args attach file content. Key flags: `--
- Never guess or manually search the filesystem for external projects
- Example: `btca ask -r https://github.com/user/repo -q "How does X work?"`
- See `.agents/skills/btca-cli/SKILL.md` for full btca usage
## BTCA Configured Resources
The following external repositories are configured in `btca.config.jsonc` for research:
- bubbletea
- lipgloss
- bubbles
- glamour
- fantasy
- catwalk
- crush
- pi
- iteratr
- yaegi
- acp-go-sdk
- opencode
- herald
- herald-md
+80
View File
@@ -0,0 +1,80 @@
# Autoscroll Fix - Final Summary
## Root Cause
The autoscroll was failing for streaming assistant messages due to a bug in how `GotoBottom()` calculated item heights.
### The Problem
1. **Reasoning blocks** (`StreamingMessageItem` with `role="reasoning"`) are **never cached** because they have live duration counters that update every render
2. The `Height()` method returns `0` when `cachedRender == ""`
3. `GotoBottom()` was calling:
```go
itemHeight := item.Height() // Returns 0 for reasoning
if itemHeight == 0 {
item.Render(s.width) // Renders but doesn't cache (reasoning)
itemHeight = item.Height() // Still returns 0!
}
```
4. This caused incorrect scroll position calculations, especially during reasoning → assistant transitions
## The Solution
Changed `GotoBottom()` and `AtBottom()` to calculate height **directly from the rendered string** instead of relying on the cached height:
```go
// OLD: item.Height() which checks cached render
itemHeight := item.Height()
if itemHeight == 0 {
item.Render(s.width)
itemHeight = item.Height() // Still might be 0!
}
// NEW: Calculate from rendered string directly
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
```
This works for **all** items regardless of whether they cache their render or not.
## Files Changed
### `internal/ui/scrolllist.go`
- **`GotoBottom()`**: Calculate height from rendered string (2 loops)
- **`AtBottom()`**: Calculate height from rendered string (1 loop)
### `internal/ui/model.go`
- **`appendStreamingChunk()`**: For existing messages, call `GotoBottom()` directly (iteratr pattern)
- **`refreshContent()`**: Simplified to only call `SetItems()` (removed redundant `GotoBottom()`)
- **Bash streaming handler**: Removed redundant `GotoBottom()` after `refreshContent()`
## Testing Results
✅ **Test prompt**: "explore this repo"
**Before fix**:
- Autoscroll stopped after reasoning block completed
- Viewport stuck showing end of reasoning ("Thought for 203ms")
- Assistant response streamed off-screen below
**After fix**:
- Autoscroll works throughout reasoning block
- Autoscroll continues during reasoning → assistant transition
- Viewport stays at bottom showing latest assistant content
- Final position shows end of response (build commands section)
## Behavior Verified
1. ✅ Streaming text auto-scrolls to bottom
2. ✅ Works across reasoning → assistant transition
3. ✅ Manual scroll up (PgUp) disables autoscroll
4. ✅ Scroll to bottom (Alt+End) re-enables autoscroll
5. ✅ Accurate positioning with no offset errors
## Performance Note
The fix calls `Render()` on all items during `GotoBottom()` calculations. This is acceptable because:
- `Render()` is already optimized with caching for non-reasoning items
- `GotoBottom()` is only called during content updates (not every frame)
- Reasoning blocks need to render anyway for live duration updates
- This matches iteratr's approach of ensuring items are rendered before height calculations
+357 -67
View File
@@ -18,9 +18,12 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
## Features
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls - no MCP overhead
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, subagent - no MCP overhead
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
- **Theming**: 22 built-in color themes (KITT, Catppuccin, Dracula, Nord, etc.) with runtime switching, persistence, and custom theme files
- **Model Persistence**: Model and thinking level selections are automatically saved and restored across sessions
- **Prompt Templates**: Create reusable prompt templates with shell-style argument substitution
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
@@ -29,10 +32,14 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
## Installation
### Using npm (recommended)
### Using npm / bun / pnpm
```bash
npm install -g @mark3labs/kit
# or
bun install -g @mark3labs/kit
# or
pnpm install -g @mark3labs/kit
```
### Using Go
@@ -66,8 +73,11 @@ kit @main.go @test.go "Review these files"
# Continue the most recent session
kit --continue
# Model and thinking level selections are automatically persisted
# across sessions and restored on next launch
# Use specific model
kit --model anthropic/claude-sonnet-4-5-20250929
kit --model anthropic/claude-sonnet-latest
```
### Non-Interactive Mode
@@ -103,15 +113,15 @@ Kit looks for configuration in the following locations (in order of priority):
1. CLI flags
2. Environment variables (with `KIT_` prefix)
3. `./.kit.yml` (project-local)
4. `~/.kit.yml` (global)
3. `./.kit.yml` / `./.kit.yaml` / `./.kit.json` (project-local)
4. `~/.kit.yml` / `~/.kit.yaml` / `~/.kit.json` (global)
### Basic Configuration
Create `~/.kit.yml`:
```yaml
model: anthropic/claude-sonnet-4-5-20250929
model: anthropic/claude-sonnet-latest
max-tokens: 4096
temperature: 0.7
stream: true
@@ -172,6 +182,8 @@ mcpServers:
# Extensions
--extension, -e Load additional extension file(s) (repeatable)
--no-extensions Disable all extensions
--prompt-template Load a specific prompt template by name
--no-prompt-templates Disable prompt template loading
# Generation parameters
--max-tokens Maximum tokens in response (default: 4096)
@@ -179,6 +191,7 @@ mcpServers:
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
--top-k Limit top K tokens (default: 40)
--stop-sequences Custom stop sequences (comma-separated)
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
# System
--config Config file path (default: ~/.kit.yml)
@@ -190,28 +203,63 @@ mcpServers:
```bash
# Authentication (for OAuth-enabled providers)
kit auth login # Start OAuth flow
kit auth logout # Remove credentials
kit auth status # Check authentication status
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
# Model database
kit models # List available models
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models # Update local model database from models.dev
kit models [provider] # List available models (optionally filter by provider)
kit models --all # Show all providers (not just LLM-compatible)
kit update-models [source] # Update model database (from models.dev, URL, file, or 'embedded')
# Extension management
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
kit install <git-url> # Install extensions from git repositories
kit install -l <git-url> # Install to project-local .kit/git/ directory
kit install -u <git-url> # Update an already-installed package
kit install --uninstall <pkg> # Remove an installed package
# Skills
kit skill # Install the Kit extensions skill via skills.sh
# ACP server
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
```
## Themes
Kit ships with 22 built-in color themes that control all UI elements. Switch at runtime:
```
/theme dracula
/theme catppuccin
/theme tokyonight
```
Theme selections are automatically saved and restored on next launch (stored in `~/.config/kit/preferences.yml`). This persistence also applies to **model** and **thinking level** selections — all are saved together and restored on startup.
### Custom themes
Drop a `.yml` file in `~/.config/kit/themes/` (user) or `.kit/themes/` (project):
```yaml
# ~/.config/kit/themes/my-theme.yml
primary:
light: "#8839ef"
dark: "#cba6f7"
success:
light: "#40a02b"
dark: "#a6e3a1"
```
Built-in themes: `kitt`, `catppuccin`, `dracula`, `tokyonight`, `nord`, `gruvbox`, `monokai`, `solarized`, `github`, `one-dark`, `rose-pine`, `ayu`, `material`, `everforest`, `kanagawa`, `amoled`, `synthwave`, `vesper`, `flexoki`, `matrix`, `vercel`, `zenburn`
## Extension System
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, and intercept lifecycle events.
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, themes, and intercept lifecycle events.
### Minimal Extension
@@ -239,37 +287,79 @@ kit -e examples/extensions/minimal.go
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnAgentStart, OnAgentEnd, OnToolCall, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
- **Commands**: Register slash commands (e.g., `/mycommand`)
- **Options**: Register configurable extension options
- **Widgets**: Persistent status displays above/below input
- **Headers/Footers**: Persistent content above/below the conversation
- **Status Bar**: Custom status bar entries
- **Shortcuts**: Global keyboard shortcuts
- **Overlays**: Modal dialogs with markdown content
- **Tool Renderers**: Customize how tool calls display
- **Message Renderers**: Custom rendering for assistant messages
- **Editor Interceptors**: Handle key events and wrap rendering
- **Interactive Prompts**: Select, confirm, input, and multi-select dialogs
- **Subagents**: Spawn in-process child Kit instances
- **LLM Completion**: Direct model calls via `Complete()`
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
**Bridged SDK APIs** (NEW): Extensions can now access internal SDK capabilities:
- **Tree Navigation**: Navigate conversation history (`GetTreeNode`, `GetCurrentBranch`, `NavigateTo`), summarize branches (`SummarizeBranch`), and implement fresh context loops (`CollapseBranch`)
- **Skill Loading**: Dynamically load and inject skills at runtime (`LoadSkill`, `DiscoverSkills`, `InjectSkillAsContext`)
- **Template Parsing**: Parse and render templates with `{{variables}}` (`ParseTemplate`, `RenderTemplate`), parse CLI-style arguments (`ParseArguments`, `SimpleParseArguments`), and evaluate model conditionals (`EvaluateModelConditional`, `RenderWithModelConditionals`)
- **Model Resolution**: Resolve model fallback chains (`ResolveModelChain`), query model capabilities (`GetModelCapabilities`, `CheckModelAvailable`), and extract provider/model ID (`GetCurrentProvider`, `GetCurrentModelID`)
### Extension Examples
See the `examples/extensions/` directory:
- `minimal.go` - Clean UI with custom footer
- `notify.go` - Desktop notifications
- `widget-status.go` - Persistent status widgets
- `custom-editor-demo.go` - Vim-like modal editor
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `tool-logger.go` - Log all tool calls
- `overlay-demo.go` - Modal dialogs
- `plan-mode.go` - Read-only planning mode
- `subagent-widget.go` - Multi-agent orchestration
- `auto-commit.go` - Auto-commit on shutdown
- `bookmark.go` - Bookmark conversations
- `branded-output.go` - Branded output rendering
- `compact-notify.go` - Notification on compaction
- `confirm-destructive.go` - Confirm destructive operations
- `context-inject.go` - Inject context into conversations
- `conversation-manager.go` - **NEW** Tree navigation, branch summarization, and fresh context loops
- `custom-editor-demo.go` - Vim-like modal editor
- `dev-reload.go` - Development live-reload
- `header-footer-demo.go` - Custom headers and footers
- `inline-bash.go` - Inline bash execution
- `interactive-shell.go` - Interactive shell integration
- `kit-kit.go` - Kit-in-Kit (sub-agent spawning)
- `lsp-diagnostics.go` - LSP diagnostic integration
- `notify.go` - Desktop notifications
- `overlay-demo.go` - Modal dialogs
- `permission-gate.go` - Permission gating for tools
- `pirate.go` - Pirate-themed personality
- `plan-mode.go` - Read-only planning mode
- `project-rules.go` - Project-specific rules
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `prompt-templates.go` - **NEW** Frontmatter-driven templates with model switching and skill injection
- `protected-paths.go` - Path protection for sensitive files
- `subagent-widget.go` - Multi-agent orchestration with status widget
- `subagent-test.go` - Subagent testing utilities
- `summarize.go` - Conversation summarization
- `tool-logger.go` - Log all tool calls
- `neon-theme.go` - Custom theme registration and switching
- `tool-renderer-demo.go` - Custom tool call rendering
- `widget-status.go` - Persistent status widgets
Also see `.kit/extensions/go-edit-lint.go` (in this repo) for a project-local extension example that runs gopls and golangci-lint on Go file edits.
### Loading Extensions
**Auto-discovery** (loads automatically):
- `./.kit/extensions/*.go` (project-local)
- `~/.config/kit/extensions/*.go` (global)
- `~/.config/kit/extensions/*.go` (global single files)
- `~/.config/kit/extensions/*/main.go` (global subdirectory extensions)
- `.kit/extensions/*.go` (project-local single files)
- `.kit/extensions/*/main.go` (project-local subdirectory extensions)
- `~/.local/share/kit/git/` (global git-installed packages)
- `.kit/git/` (project-local git-installed packages)
**Explicit loading**:
```bash
@@ -282,13 +372,76 @@ kit -e ext1.go -e ext2.go # Multiple extensions
kit --no-extensions
```
### Testing Extensions
Kit provides a testing package to help you write unit tests for your extensions:
```go
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
func TestMyExtension(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Emit events and verify behavior
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the extension printed something
test.AssertPrinted(t, harness, "session started")
}
```
**Available assertions:**
- `AssertBlocked()`, `AssertNotBlocked()` — Verify tool blocking
- `AssertWidgetSet()`, `AssertWidgetText()` — Verify widget content
- `AssertPrinted()`, `AssertPrintedContains()` — Verify output
- `AssertToolRegistered()`, `AssertCommandRegistered()` — Verify registration
See `examples/extensions/tool-logger_test.go` for a complete example with 14 test cases covering tool calls, input handling, and session lifecycle.
### Prompt Templates
Create reusable prompt templates with shell-style argument substitution. Templates are loaded from `~/.kit/prompts/*.md` and `.kit/prompts/*.md`.
**Example template** (`~/.kit/prompts/review.md`):
```markdown
---
description: Review code for issues
---
Review the following code for bugs and security issues.
Focus on $1 specifically.
```
**Usage:**
```
/review error handling
```
**Argument placeholders:**
- `$1`, `$2`, etc. — Individual arguments
- `$@` or `$ARGUMENTS` — All arguments
- `${@:2}` — Arguments from position 2 onwards
- `${@:1:3}` — 3 arguments starting at position 1
Disable templates with `--no-prompt-templates` or load a specific template with `--prompt-template <name>`.
## Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
### Session Locations
- Default: `~/.local/share/kit/sessions/<cwd-hash>/<uuid>.jsonl`
- Default: `~/.kit/sessions/<cwd-path>/<timestamp>_<id>.jsonl`
- Path separators in the working directory are replaced with `--` (e.g., `/home/user/project` becomes `home--user--project`)
- Each line is a session entry (messages, tool calls, extension data)
- Supports branching from any message to explore alternate paths
@@ -311,6 +464,22 @@ kit -s path/to/session.jsonl
kit --no-session
```
### Interactive Session Commands
During an interactive session, use these slash commands:
| Command | Description |
|---------|-------------|
| `/name [name]` | Set or display the session's display name |
| `/session` | Show session info (path, ID, message count) |
| `/resume` | Open the session picker to switch sessions |
| `/export [path]` | Export session as JSONL (auto-generates path if omitted) |
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
| `/tree` | Navigate the session tree |
| `/fork` | Fork to new session from an earlier message |
| `/new` | Start a fresh session |
## Go SDK
Embed Kit in your Go applications:
@@ -333,7 +502,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
defer host.Close()
defer func() { _ = host.Close() }()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
@@ -355,43 +524,101 @@ host, err := kit.New(ctx, &kit.Options{
MaxSteps: 10,
Streaming: true,
Quiet: true,
// Session options
SessionPath: "./session.jsonl", // Open specific session
Continue: true, // Resume most recent session
NoSession: true, // Ephemeral mode
// Tool options
Tools: []kit.Tool{...}, // Replace default tool set entirely
ExtraTools: []kit.Tool{...}, // Add tools alongside defaults
DisableCoreTools: true, // Use no core tools (0 tools, for chat-only)
// Configuration
SkipConfig: true, // Skip .kit.yml files (viper defaults + env vars still apply)
// Compaction
AutoCompact: true, // Auto-compact near context limit
Debug: true, // Debug logging
})
```
### Custom Tools
Create custom tools with automatic schema generation — no external dependencies needed:
```go
type SearchInput struct {
Query string `json:"query" description:"Search query"`
}
searchTool := kit.NewTool("search", "Search the codebase",
func(ctx context.Context, input SearchInput) (kit.ToolOutput, error) {
return kit.TextResult("Found: ..."), nil
},
)
host, _ := kit.New(ctx, &kit.Options{
ExtraTools: []kit.Tool{searchTool}, // adds alongside built-in tools
})
```
Use `kit.NewParallelTool` for tools safe to run concurrently. See the [SDK docs](/sdk/overview) for full details on struct tags, `ToolOutput` fields, and `ToolCallIDFromContext`.
### With Callbacks
```go
response, err := host.PromptWithCallbacks(
unsub := host.OnToolCall(func(e kit.ToolCallEvent) {
println("Calling tool:", e.ToolName)
})
defer unsub()
unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
if e.IsError {
println("Tool failed:", e.ToolName)
}
})
defer unsub2()
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
print(e.Chunk)
})
defer unsub3()
response, err := host.Prompt(
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
// Multi-turn conversations retain context automatically
host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
host.SaveSession("./session.json")
host.LoadSession("./session.json")
// Sessions are persisted automatically to JSONL files.
// Access session info:
path := host.GetSessionPath()
id := host.GetSessionID()
// Clear conversation history
host.ClearSession()
```
Session persistence is configured via `Options`:
```go
host, _ := kit.New(ctx, &kit.Options{
SessionPath: "./my-session.jsonl", // Open specific session
Continue: true, // Resume most recent session
NoSession: true, // Ephemeral mode
})
```
## Advanced Usage
### Subagent Pattern
@@ -413,12 +640,25 @@ Parse the JSON output:
{
"response": "Final assistant response text",
"model": "anthropic/claude-haiku-3-5-20241022",
"stop_reason": "end_turn",
"session_id": "a1b2c3d4e5f6",
"usage": {
"input_tokens": 1024,
"output_tokens": 512,
"total_tokens": 1536
"total_tokens": 1536,
"cache_read_tokens": 0,
"cache_creation_tokens": 0
},
"messages": [...]
"messages": [
{
"role": "assistant",
"parts": [
{"type": "text", "data": "..."},
{"type": "tool_call", "data": {"name": "...", "args": "..."}},
{"type": "tool_result", "data": {"name": "...", "result": "..."}}
]
}
]
}
```
@@ -468,19 +708,27 @@ go fmt ./...
### Project Structure
```
cmd/kit/ - CLI entry point
cmd/ - CLI command implementations
pkg/kit/ - Go SDK
internal/agent/ - Agent loop and tool execution
internal/ui/ - Bubble Tea TUI components
cmd/kit/ - CLI entry point (main.go)
cmd/ - CLI command implementations (root, auth, models, etc.)
pkg/kit/ - Go SDK for embedding Kit
internal/app/ - Application orchestrator (agent loop, message store, queue)
internal/agent/ - Agent execution and tool dispatch
internal/auth/ - OAuth authentication and credential storage
internal/acpserver/ - ACP (Agent Client Protocol) server
internal/clipboard/ - Cross-platform clipboard operations
internal/compaction/ - Conversation compaction and summarization
internal/config/ - Configuration management
internal/core/ - Built-in tools (bash, read, write, edit, grep, find, ls)
internal/extensions/ - Yaegi extension system
internal/core/ - Built-in tools
internal/tools/ - MCP tool integration
internal/config/ - Configuration management
internal/acpserver/ - ACP (Agent Client Protocol) server
internal/session/ - Session persistence
internal/models/ - Provider and model management
internal/kitsetup/ - Initial setup wizard
internal/message/ - Message content types and structured content blocks
internal/models/ - Provider and model management
internal/session/ - Session persistence (tree-based JSONL)
internal/skills/ - Skill loading and system prompt composition
internal/tools/ - MCP tool integration
internal/ui/ - Bubble Tea TUI components
examples/extensions/ - Example extension files
npm/ - NPM package wrapper for distribution
```
## Supported Providers
@@ -494,13 +742,29 @@ examples/extensions/ - Example extension files
- **Google Vertex** - Claude on Vertex AI
- **OpenRouter** - Multi-provider router
- **Vercel AI** - Vercel AI SDK models
- **Custom** - Any OpenAI-compatible endpoint via `--provider-url`
- **Auto-routed** - Any provider from models.dev database
### Custom Provider
Use `custom/custom` when pointing Kit at any OpenAI-compatible endpoint with `--provider-url`:
```bash
kit --provider-url "http://localhost:8080/v1" "Hello"
```
This automatically defaults to `custom/custom` without needing to specify a model. The custom provider routes through the `openaicompat` provider and supports:
- Zero cost tracking (input/output = 0)
- 262K context window, 65K output limit
- Reasoning and temperature support
- Optional `CUSTOM_API_KEY` environment variable or `--provider-api-key` flag
### Model String Format
```bash
provider/model # Standard format
anthropic/claude-sonnet-4-5-20250929
anthropic/claude-sonnet-latest
openai/gpt-4o
ollama/llama3
google/gemini-2.0-flash-exp
@@ -509,18 +773,44 @@ 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
# Anthropic Claude
claude-opus-latest → claude-opus-4-6
claude-sonnet-latest → claude-sonnet-4-6
claude-haiku-latest → claude-haiku-4-5
claude-4-opus-latest → claude-opus-4-6
claude-4-sonnet-latest → claude-sonnet-4-6
claude-4-haiku-latest → claude-haiku-4-5
claude-3-7-sonnet-latest → claude-3-7-sonnet-20250219
claude-3-5-sonnet-latest → claude-3-5-sonnet-20241022
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
claude-3-opus-latest → claude-3-opus-20240229
# OpenAI GPT
o1-latest → o1
o3-latest → o3
o4-latest → o4-mini
gpt-5-latest → gpt-5.4
gpt-5-chat-latest → gpt-5.4
gpt-4-latest → gpt-4o
gpt-4 → gpt-4o
gpt-3.5-latest → gpt-3.5-turbo
gpt-3.5 → gpt-3.5-turbo
codex-latest → codex-mini-latest
# Google Gemini
gemini-pro-latest → gemini-2.5-pro
gemini-flash-latest → gemini-2.5-flash
gemini-flash → gemini-2.5-flash
gemini-pro → gemini-2.5-pro
```
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Contributions are welcome! Please see the [contribution guide](contribute/contribute.md) for guidelines.
## License
[Apache 2.0](LICENSE)
[MIT](LICENSE)
## Community
+12
View File
@@ -76,6 +76,18 @@
"name": "opencode",
"url": "https://github.com/anomalyco/opencode",
"branch": "dev"
},
{
"type": "git",
"name": "herald",
"url": "https://github.com/indaco/herald",
"branch": "main"
},
{
"type": "git",
"name": "herald-md",
"url": "https://github.com/indaco/herald-md",
"branch": "main"
}
],
"model": "claude-haiku-4-5",
+3
View File
@@ -11,6 +11,7 @@ import (
"os/signal"
"syscall"
"github.com/charmbracelet/log"
acp "github.com/coder/acp-go-sdk"
"github.com/mark3labs/kit/internal/acpserver"
@@ -54,6 +55,8 @@ func runACP(cmd *cobra.Command, _ []string) error {
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
// Also set charmbracelet/log level for acpserver package logging
log.SetLevel(log.DebugLevel)
}
// Wait for either the client to disconnect or a signal.
+325 -28
View File
@@ -1,11 +1,15 @@
package cmd
import (
"bufio"
"context"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"charm.land/huh/v2"
"github.com/mark3labs/kit/internal/auth"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
@@ -14,7 +18,7 @@ import (
// authCmd represents the auth command for managing AI provider authentication.
// This command provides subcommands for login, logout, and status checking
// of authentication credentials for various AI providers, with OAuth support
// for providers like Anthropic.
// for providers like Anthropic and OpenAI.
var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage authentication credentials for AI providers",
@@ -25,9 +29,11 @@ using OAuth flows. Stored credentials take precedence over environment variables
Available providers:
- anthropic: Anthropic Claude API (OAuth)
- openai: OpenAI API (OAuth and API key)
Examples:
kit auth login anthropic
kit auth login openai
kit auth logout anthropic
kit auth status`,
}
@@ -46,9 +52,11 @@ environment variables when making API calls.
Available providers:
- anthropic: Anthropic Claude API (OAuth)
- openai: OpenAI ChatGPT Plus/Pro (Codex OAuth)
Example:
kit auth login anthropic`,
kit auth login anthropic
kit auth login openai`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogin,
}
@@ -61,14 +69,16 @@ var authLogoutCmd = &cobra.Command{
Short: "Remove stored authentication credentials for a provider",
Long: `Remove stored authentication credentials for an AI provider.
This will delete the stored API key for the specified provider. You will need
to use environment variables or command-line flags for authentication after logout.
This will delete the stored API key or OAuth credentials for the specified provider.
You will need to use environment variables or command-line flags for authentication after logout.
Available providers:
- anthropic: Anthropic Claude API
- openai: OpenAI API
Example:
kit auth logout anthropic`,
kit auth logout anthropic
kit auth logout openai`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogout,
}
@@ -101,8 +111,10 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
switch provider {
case "anthropic":
return loginAnthropic()
case "openai":
return loginOpenAI()
default:
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic", provider)
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
}
}
@@ -112,8 +124,10 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
switch provider {
case "anthropic":
return logoutAnthropic()
case "openai":
return logoutOpenAI()
default:
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic", provider)
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
}
}
@@ -157,8 +171,44 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
}
}
// Check OpenAI credentials
fmt.Print("\nOpenAI: ")
if hasOpenAICreds, err := cm.HasOpenAICredentials(); err != nil {
fmt.Printf("Error checking credentials: %v\n", err)
} else if hasOpenAICreds {
if creds, err := cm.GetOpenAICredentials(); err != nil {
fmt.Printf("Error reading credentials: %v\n", err)
} else {
authType := "API Key"
status := "✓ Authenticated"
if creds.Type == "oauth" {
authType = "OAuth (ChatGPT/Codex)"
if creds.IsExpired() {
status = "⚠️ Token expired (will refresh automatically)"
} else if creds.NeedsRefresh() {
status = "⚠️ Token expires soon (will refresh automatically)"
}
}
accountInfo := ""
if creds.Type == "oauth" && creds.AccountID != "" {
accountInfo = fmt.Sprintf(" [%s]", creds.AccountID)
}
fmt.Printf("%s (%s%s, stored %s)\n", status, authType, accountInfo, creds.CreatedAt.Format("2006-01-02 15:04:05"))
}
} else {
fmt.Println("✗ Not authenticated")
// Check if environment variable is set
if os.Getenv("OPENAI_API_KEY") != "" {
fmt.Println(" (OPENAI_API_KEY environment variable is set)")
}
}
fmt.Println("\nTo authenticate with a provider:")
fmt.Println(" kit auth login anthropic")
fmt.Println(" kit auth login openai")
return nil
}
@@ -171,14 +221,15 @@ func loginAnthropic() error {
// Check if already authenticated
if hasAuth, err := cm.HasAnthropicCredentials(); err == nil && hasAuth {
fmt.Print("You are already authenticated with Anthropic. Do you want to re-authenticate? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
var reauth bool
err := huh.NewConfirm().
Title("You are already authenticated with Anthropic").
Description("Do you want to re-authenticate?").
Affirmative("Yes").
Negative("No").
Value(&reauth).
Run()
if err != nil || !reauth {
fmt.Println("Authentication cancelled.")
return nil
}
@@ -204,10 +255,13 @@ func loginAnthropic() error {
// Wait for user to complete OAuth flow
fmt.Println("After authorizing the application, you'll receive an authorization code.")
fmt.Print("Please enter the authorization code: ")
reader := bufio.NewReader(os.Stdin)
code, err := reader.ReadString('\n')
var code string
err = huh.NewInput().
Title("Authorization code").
Description("Paste the code from your browser").
Value(&code).
Run()
if err != nil {
return fmt.Errorf("failed to read authorization code: %w", err)
}
@@ -255,15 +309,15 @@ func logoutAnthropic() error {
}
// Confirm logout
fmt.Print("Are you sure you want to remove your Anthropic credentials? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
var confirm bool
err = huh.NewConfirm().
Title("Remove Anthropic credentials").
Description("Are you sure you want to remove your stored credentials?").
Affirmative("Yes").
Negative("No").
Value(&confirm).
Run()
if err != nil || !confirm {
fmt.Println("Logout cancelled.")
return nil
}
@@ -278,3 +332,246 @@ func logoutAnthropic() error {
return nil
}
func loginOpenAI() error {
cm, err := kit.NewCredentialManager()
if err != nil {
return fmt.Errorf("failed to initialize credential manager: %w", err)
}
// Check if already authenticated
if hasAuth, err := cm.HasOpenAICredentials(); err == nil && hasAuth {
var reauth bool
err := huh.NewConfirm().
Title("You are already authenticated with OpenAI (ChatGPT/Codex)").
Description("Do you want to re-authenticate?").
Affirmative("Yes").
Negative("No").
Value(&reauth).
Run()
if err != nil || !reauth {
fmt.Println("Authentication cancelled.")
return nil
}
}
// Create OAuth client
client := auth.NewOpenAIOAuthClient()
// Generate authorization URL
fmt.Println("🔐 Starting OAuth authentication with OpenAI (ChatGPT/Codex)...")
fmt.Println("This will open your browser to authenticate with your ChatGPT account.")
fmt.Println()
authData, err := client.GetAuthorizationURL()
if err != nil {
return fmt.Errorf("failed to generate authorization URL: %w", err)
}
// Start local callback server
callbackServer, err := startOpenAICallbackServer(authData.State)
if err != nil {
fmt.Printf("⚠️ Could not start local callback server: %v\n", err)
fmt.Println("Falling back to manual code entry.")
}
if callbackServer != nil {
defer callbackServer.Close()
}
// Display URL and try to open browser
fmt.Println("📱 Opening your browser for authentication...")
fmt.Println("If the browser doesn't open automatically, please visit this URL:")
fmt.Printf("\n%s\n\n", authData.URL)
// Try to open browser
auth.TryOpenBrowser(authData.URL)
// Wait for callback or manual input
var code string
if callbackServer != nil {
fmt.Println("Waiting for browser authentication...")
select {
case callbackCode := <-callbackServer.CodeChan:
if callbackCode != "" {
code = callbackCode
fmt.Println("✓ Received authorization code from browser callback.")
}
case <-time.After(2 * time.Minute):
fmt.Println("\n⏱️ Timeout waiting for browser callback.")
callbackServer.Close()
}
}
// If no code from callback, prompt for manual entry
if code == "" {
fmt.Println("\nAfter authorizing, paste the callback URL or authorization code below.")
fmt.Println("(The callback URL will look like: http://localhost:1455/auth/callback?code=...&state=...)")
fmt.Println()
var input string
err = huh.NewInput().
Title("Callback URL or Code").
Description("Paste the full callback URL or just the authorization code").
Value(&input).
Run()
if err != nil {
return fmt.Errorf("failed to read input: %w", err)
}
input = strings.TrimSpace(input)
if input == "" {
return fmt.Errorf("authorization code cannot be empty")
}
// Parse the input (could be full URL or just code)
parsedCode, parsedState := auth.ParseOpenAIAuthorizationInput(input)
if parsedCode == "" {
return fmt.Errorf("could not extract authorization code from input")
}
// Validate state if provided
if parsedState != "" && parsedState != authData.State {
return fmt.Errorf("state mismatch - possible security issue")
}
code = parsedCode
}
// Exchange code for tokens
fmt.Println("\n🔄 Exchanging authorization code for access token...")
creds, err := client.ExchangeCode(code, authData.Verifier)
if err != nil {
return fmt.Errorf("failed to exchange authorization code: %w", err)
}
// Store the credentials
if err := cm.SetOpenAIOAuthCredentials(creds); err != nil {
return fmt.Errorf("failed to store credentials: %w", err)
}
fmt.Println("✅ Successfully authenticated with OpenAI (ChatGPT/Codex)!")
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
fmt.Printf("👤 Account ID: %s\n", creds.AccountID)
fmt.Println("\n🎉 Your OAuth credentials will now be used for OpenAI API calls.")
fmt.Println("💡 You can check your authentication status with: kit auth status")
return nil
}
// callbackServer holds the HTTP server and channel for receiving the OAuth callback
type callbackServer struct {
Server *http.Server
CodeChan chan string
State string
}
// Close shuts down the callback server
func (cs *callbackServer) Close() {
if cs.Server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = cs.Server.Shutdown(ctx)
}
}
// startOpenAICallbackServer starts a local HTTP server to receive the OAuth callback
func startOpenAICallbackServer(expectedState string) (*callbackServer, error) {
codeChan := make(chan string, 1)
mux := http.NewServeMux()
server := &http.Server{
Addr: "127.0.0.1:1455",
Handler: mux,
}
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
// Check state
state := r.URL.Query().Get("state")
if state != expectedState {
http.Error(w, "State mismatch", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "Missing authorization code", http.StatusBadRequest)
return
}
// Send code to channel
select {
case codeChan <- code:
default:
}
// Return success page
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head><title>Authentication Successful</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>✓ Authentication Successful</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>`)
})
// Try to start server
listener, err := net.Listen("tcp", "127.0.0.1:1455")
if err != nil {
return nil, fmt.Errorf("port 1455 not available: %w", err)
}
_ = listener.Close()
go func() {
_ = server.ListenAndServe()
}()
return &callbackServer{
Server: server,
CodeChan: codeChan,
State: expectedState,
}, nil
}
func logoutOpenAI() error {
cm, err := kit.NewCredentialManager()
if err != nil {
return fmt.Errorf("failed to initialize credential manager: %w", err)
}
// Check if authenticated
hasAuth, err := cm.HasOpenAICredentials()
if err != nil {
return fmt.Errorf("failed to check authentication status: %w", err)
}
if !hasAuth {
fmt.Println("You are not currently authenticated with OpenAI.")
return nil
}
// Confirm logout
var confirm bool
err = huh.NewConfirm().
Title("Remove OpenAI credentials").
Description("Are you sure you want to remove your stored credentials?").
Affirmative("Yes").
Negative("No").
Value(&confirm).
Run()
if err != nil || !confirm {
fmt.Println("Logout cancelled.")
return nil
}
// Remove credentials
if err := cm.RemoveOpenAICredentials(); err != nil {
return fmt.Errorf("failed to remove credentials: %w", err)
}
fmt.Println("✓ Successfully logged out from OpenAI!")
fmt.Println("You will need to use environment variables or command-line flags for authentication.")
return nil
}
+225
View File
@@ -0,0 +1,225 @@
package cmd
import (
"fmt"
"os/exec"
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extensions"
"github.com/spf13/cobra"
)
var (
installLocalFlag bool
installUpdateFlag bool
installUninstallFlag bool
installAllFlag bool
)
var installCmd = &cobra.Command{
Use: "install <git-url>",
Short: "Install extensions from git repositories",
Long: `Install extensions from git repositories.
The install command downloads and installs Kit extensions from git repositories.
Extensions are stored in the global extensions directory by default, or in the
project's .kit/git/ directory when using the --local flag.
When a repo contains multiple extensions, an interactive multi-select is shown
so you can choose which to install. Use --all to skip selection and install everything.
Supported URL formats:
- github.com/user/repo (shorthand, defaults to HTTPS)
- git:github.com/user/repo
- https://github.com/user/repo
- ssh://git@github.com/user/repo
- git@github.com:user/repo
You can pin to a specific version, tag, or commit using @:
- github.com/user/repo@v1.0.0
- github.com/user/repo@main
- github.com/user/repo@abc1234
Examples:
kit install github.com/user/my-extension
kit install github.com/user/my-extension@v1.0.0
kit install github.com/user/my-extension --local
kit install github.com/user/collection --all`,
Args: cobra.ExactArgs(1),
RunE: runInstall,
}
func init() {
installCmd.Flags().BoolVarP(&installLocalFlag, "local", "l", false, "Install to project-local .kit/git/ directory")
installCmd.Flags().BoolVarP(&installUpdateFlag, "update", "u", false, "Update an already-installed package")
installCmd.Flags().BoolVar(&installUninstallFlag, "uninstall", false, "Remove an installed package")
installCmd.Flags().BoolVar(&installAllFlag, "all", false, "Install all extensions without prompting")
rootCmd.AddCommand(installCmd)
}
func runInstall(cmd *cobra.Command, args []string) error {
sourceStr := args[0]
// Check that git is available
if _, err := exec.LookPath("git"); err != nil {
return fmt.Errorf("git is not installed or not in PATH")
}
// Parse the source
source, err := extensions.ParseGitSource(sourceStr)
if err != nil {
return fmt.Errorf("invalid source: %w", err)
}
// Determine scope
scope := extensions.ScopeGlobal
if installLocalFlag {
scope = extensions.ScopeProject
}
installer := extensions.NewInstaller(".")
// Handle uninstall
if installUninstallFlag {
return runUninstall(installer, source, scope)
}
// Handle update
if installUpdateFlag {
return runUpdate(installer, source, scope)
}
// Handle install
return runInstallPackage(installer, source, scope)
}
func runInstallPackage(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Check if already installed
existingScope, installed := installer.IsInstalled(source)
if installed {
return fmt.Errorf("extension already installed (scope: %s). Use --update to update or --uninstall to remove", existingScope)
}
// Preview extensions to decide if we need multi-select
previews, tempDir, err := installer.PreviewExtensions(source)
if err != nil {
return fmt.Errorf("previewing extensions: %w", err)
}
defer extensions.CleanupTempDir(tempDir)
if len(previews) == 0 {
return fmt.Errorf("no extensions found in %s", source.String())
}
scopeStr := "globally"
if scope == extensions.ScopeProject {
scopeStr = "locally in .kit/git/"
}
// Single extension or --all flag: install everything directly
if len(previews) == 1 || installAllFlag {
if err := installer.Install(source, scope); err != nil {
return fmt.Errorf("install failed: %w", err)
}
if source.Pinned {
fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr)
} else {
fmt.Printf("Installed %d extension(s) from %s %s\n", len(previews), source.String(), scopeStr)
}
log.Info("extension installed", "source", source.String(), "scope", scope)
return nil
}
// Multiple extensions: show interactive selection
includePaths, err := multiSelectForInstall(previews)
if err != nil {
if err.Error() == "selection cancelled" || err.Error() == "no extensions selected" {
fmt.Println("Install cancelled.")
return nil
}
return fmt.Errorf("selection failed: %w", err)
}
if err := installer.InstallWithInclude(source, scope, includePaths); err != nil {
return fmt.Errorf("install failed: %w", err)
}
fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr)
for _, path := range includePaths {
fmt.Printf(" - %s\n", path)
}
log.Info("extension installed", "source", source.String(), "scope", scope, "selected", len(includePaths))
return nil
}
func runUpdate(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Find the installed package
existingScope, installed := installer.IsInstalled(source)
if !installed {
// Try to find with wildcard (no version)
entry, foundScope, err := extensions.FindInManifest(source.Identity())
if err != nil || entry == nil {
return fmt.Errorf("extension not installed: %s", source.Identity())
}
// Parse the found entry's source
foundSource, err := extensions.ParseGitSource(entry.Source)
if err != nil {
return fmt.Errorf("failed to parse installed source: %w", err)
}
existingScope = foundScope
source = foundSource
}
// Override scope if specified
if installLocalFlag && scope != existingScope {
return fmt.Errorf("extension installed in %s scope, cannot update with --local flag", existingScope)
}
scope = existingScope
// Check if pinned
if source.Pinned {
fmt.Printf("Skipping %s (pinned at %s)\n", source.Identity(), source.Ref)
return nil
}
// Update
if err := installer.Update(source, scope); err != nil {
return fmt.Errorf("update failed: %w", err)
}
fmt.Printf("Updated %s\n", source.Identity())
log.Info("extension updated", "source", source.Identity(), "scope", scope)
return nil
}
func runUninstall(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Find where it's installed (ignore scope flag for uninstall - remove from wherever it exists)
existingScope, installed := installer.IsInstalled(source)
if !installed {
// Try to find in manifests
entry, foundScope, err := extensions.FindInManifest(source.Identity())
if err != nil || entry == nil {
return fmt.Errorf("extension not installed: %s", source.Identity())
}
existingScope = foundScope
// Parse the found entry's source
foundSource, err := extensions.ParseGitSource(entry.Source)
if err != nil {
return fmt.Errorf("failed to parse installed source: %w", err)
}
source = foundSource
}
// Uninstall from the scope where it's installed
if err := installer.Uninstall(source, existingScope); err != nil {
return fmt.Errorf("uninstall failed: %w", err)
}
fmt.Printf("Uninstalled %s from %s scope\n", source.Identity(), existingScope)
log.Info("extension uninstalled", "source", source.Identity(), "scope", existingScope)
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package cmd
import (
"fmt"
"os"
"charm.land/huh/v2"
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extensions"
)
// multiSelectForInstall runs a multi-select prompt for extension selection.
// Returns the selected extension paths, or an error if cancelled.
func multiSelectForInstall(previews []extensions.ExtensionPreview) ([]string, error) {
if len(previews) == 0 {
return nil, fmt.Errorf("no extensions to select")
}
// Non-interactive: select all
if !isInteractive() {
log.Info("Non-interactive mode, selecting all extensions")
paths := make([]string, len(previews))
for i, p := range previews {
paths[i] = p.Path
}
return paths, nil
}
// Single extension: just return it
if len(previews) == 1 {
return []string{previews[0].Path}, nil
}
// Build options for huh MultiSelect
options := make([]huh.Option[string], len(previews))
for i, p := range previews {
label := fmt.Sprintf("%s %s", p.Name, p.Path)
options[i] = huh.NewOption(label, p.Path).Selected(true)
}
var selected []string
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Select extensions to install").
Options(options...).
Value(&selected),
),
)
if err := form.Run(); err != nil {
return nil, fmt.Errorf("selection cancelled")
}
if len(selected) == 0 {
return nil, fmt.Errorf("no extensions selected")
}
return selected, nil
}
// isInteractive checks if the terminal is interactive.
func isInteractive() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
+8 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"sort"
"github.com/mark3labs/kit/internal/models"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
)
@@ -47,11 +48,14 @@ func runModels(_ *cobra.Command, args []string) error {
}
func printAllProviders(showAll bool) error {
// Reload the registry to pick up any custom models from config
models.ReloadGlobalRegistry()
var providerIDs []string
if showAll {
providerIDs = kit.GetSupportedProviders()
} else {
providerIDs = kit.GetFantasyProviders()
providerIDs = kit.GetLLMProviders()
}
sort.Strings(providerIDs)
@@ -98,6 +102,9 @@ func printAllProviders(showAll bool) error {
}
func printProvider(provider string) error {
// Reload the registry to pick up any custom models from config
models.ReloadGlobalRegistry()
m, err := kit.GetModelsForProvider(provider)
if err != nil {
return fmt.Errorf("unknown provider %q. Run 'kit models' to see all providers", provider)
+1044 -212
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -41,7 +41,6 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to
StreamingEnabled: viper.GetBool("stream"),
Quiet: quietFlag,
Debug: viper.GetBool("debug"),
CompactMode: viper.GetBool("compact"),
}
}
@@ -131,7 +130,6 @@ func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) {
Agent: agentAdapter,
ModelString: viper.GetString("model"),
Debug: viper.GetBool("debug"),
Compact: viper.GetBool("compact"),
Quiet: quietFlag,
ShowDebug: false,
ProviderAPIKey: viper.GetString("provider-api-key"),
+12 -12
View File
@@ -8,19 +8,21 @@ import (
"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.
// skillCmd installs Kit skills via the skills.sh CLI (npx skills).
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.
Short: "Install Kit skills via skills.sh",
Long: `Install Kit skills that teach AI agents how to build with Kit.
Uses the skills.sh CLI (npx skills) to install all skills 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.
Two skills are provided:
1. Extensions — creating Kit extensions with full knowledge of the extension
API, lifecycle events, widgets, tools, commands, editor interceptors,
tool renderers, and Yaegi interpreter constraints.
2. SDK — building AI-powered applications with the Kit Go SDK, including
providers, agents, tools, and MCP integration.
Example:
kit skill`,
@@ -41,8 +43,6 @@ func runSkill(_ *cobra.Command, _ []string) error {
"skills",
"add",
"mark3labs/kit",
"--skill",
"kit-extensions",
}
cmd := exec.Command(npx, args...)
+197
View File
@@ -0,0 +1,197 @@
# Kit Extension Examples
A collection of example extensions demonstrating various Kit capabilities. These can be installed individually or as a complete collection.
## Installation
### Install all examples
```bash
kit install github.com/mark3labs/kit/examples/extensions
```
### Install with interactive selection
```bash
kit install github.com/mark3labs/kit/examples/extensions --select
```
### Install locally in your project
```bash
kit install github.com/mark3labs/kit/examples/extensions --local
```
## Extension Index
### Core Concepts
| Extension | Description | Key API |
|-----------|-------------|---------|
| `minimal.go` | Minimal viable extension | Basic `Init()` function |
| `plan-mode.go` | Restrict agent to read-only tools | `OnBeforeAgentStart`, `SetActiveTools` |
| `tool-logger.go` | Log all tool calls to file | `OnToolCall`, `OnToolResult` |
| `notify.go` | Display notifications | `PrintInfo`, `PrintBlock` |
### UI & Widgets
| Extension | Description | Key API |
|-----------|-------------|---------|
| `widget-status.go` | Persistent status widget | `SetWidget`, `RemoveWidget` |
| `header-footer-demo.go` | Custom header/footer | `SetHeader`, `SetFooter` |
| `overlay-demo.go` | Modal overlay dialogs | `ShowOverlay` |
| `compact-notify.go` | Compact mode notifications | `PrintBlock` |
| `branded-output.go` | Custom styled output | `PrintBlock` with colors |
### Input & Editor
| Extension | Description | Key API |
|-----------|-------------|---------|
| `custom-editor-demo.go` | Custom key handling | `SetEditor`, `EditorKeyAction` |
| `pirate.go` | Transform user input | `OnInput`, `InputResult` |
| `interactive-shell.go` | Custom command input | Slash commands with prompts |
| `inline-bash.go` | Execute bash inline | Input handling, `exec` |
### Session & Context
| Extension | Description | Key API |
|-----------|-------------|---------|
| `context-inject.go` | Inject context into prompts | `OnContextPrepare` |
| `bookmark.go` | Bookmark messages | `AppendEntry`, `GetEntries` |
| `project-rules.go` | Project-specific rules | Session data, file reading |
| `protected-paths.go` | Block dangerous operations | `OnToolCall` with blocking |
| `permission-gate.go` | Confirm destructive actions | `OnToolCall` with confirmation |
### Tools & Commands
| Extension | Description | Key API |
|-----------|-------------|---------|
| `auto-commit.go` | Auto-commit changes | Custom tool, git operations |
| `summarize.go` | Summarize conversation | Custom tool with parameters |
| `confirm-destructive.go` | Confirm destructive commands | `OnToolCall` blocking |
| `lsp-diagnostics.go` | LSP integration | Complex extension, external process |
### Subagents & Background Tasks
| Extension | Description | Key API |
|-----------|-------------|---------|
| `kit-kit.go` | Spawn Kit as subagent | Subagent spawning |
| `subagent-test.go` | Test subagent functionality | `SpawnSubagent` |
| `subagent-widget.go` | Widget with subagent updates | Goroutines + widgets |
| `dev-reload.go` | Hot reload extensions | `ReloadExtensions` |
### Integrations
| Extension | Description | Key API |
|-----------|-------------|---------|
| `kit-telegram/` | Telegram relay for remote monitoring & control | `RegisterCommand`, `OnAgentStart/End`, `SetStatus`, `SendMessage` |
### Themes
| Extension | Description | Key API |
|-----------|-------------|---------|
| `neon-theme.go` | Register and switch custom themes | `RegisterTheme`, `SetTheme` |
### Rendering
| Extension | Description | Key API |
|-----------|-------------|---------|
| `tool-renderer-demo.go` | Custom tool output styling | `RegisterToolRenderer` |
| `prompt-demo.go` | Interactive prompts | `PromptSelect`, `PromptConfirm` |
## Extension Details
### minimal.go
The bare minimum extension showing the required structure:
- Package `main`
- Import `kit/ext`
- Export `Init(api ext.API)` function
### plan-mode.go
A complete example demonstrating:
- Slash command (`/plan`)
- Keyboard shortcut (`ctrl+alt+p`)
- Option registration
- Status bar indicators
- System prompt injection
- Tool filtering
### widget-status.go
Shows how to create persistent UI elements:
- Create widgets with `SetWidget`
- Update content dynamically
- Remove when done
- Handle session lifecycle
### context-inject.go
Advanced context manipulation:
- Read project files
- Inject into LLM context
- Filter messages
- Use negative indices for ephemeral content
### lsp-diagnostics.go
Complex real-world example:
- Multi-file extension
- External process management (LSP server)
- File watching
- Diagnostics aggregation
### kit-telegram/
Full-featured Telegram integration:
- Slash command with subcommands and tab completion
- Interactive guided setup flow with prompts
- Background long-polling goroutine
- Progress message rendering edited in place
- Message queue with edit-before-dispatch
- Remote command handling from Telegram
- Status bar and widget updates
- Config persistence with atomic writes
## Multi-File Extension Example
The `kit-kit-agents/` directory demonstrates the multi-file pattern:
```
kit-kit-agents/
├── main.go # Entry point with Init()
├── agent.go # Agent configuration
├── manager.go # Agent lifecycle management
└── README.md # Documentation
```
When the repo is installed, all files in subdirectories with `main.go` are loaded as separate extensions.
## Testing & Validation
After installing, test the extensions:
```bash
# List all loaded extensions
kit extensions list
# Validate all extensions
kit extensions validate
# Run with a specific extension
kit -e ~/.local/share/kit/git/github.com/mark3labs/kit/examples/extensions/plan-mode.go
```
## Creating Your Own
1. Copy `minimal.go` as a starting point
2. Modify the `Init()` function to register your handlers
3. Use the other examples for reference on specific APIs
4. Test with `kit -e your-extension.go`
5. Share by pushing to a git repository!
## Update
To get the latest examples:
```bash
kit install github.com/mark3labs/kit/examples/extensions --update
```
## See Also
- [Kit Extensions Guide](https://github.com/mark3labs/kit/blob/main/.agents/skills/kit-extensions/SKILL.md)
- [API Reference](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go)
- [Example Extensions Source](https://github.com/mark3labs/kit/tree/main/examples/extensions)
@@ -0,0 +1,27 @@
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// TestAllExtensions_Load is a smoke test that verifies every single-file
// example extension in this directory can be loaded by the Yaegi interpreter
// without errors. This catches syntax errors, missing symbols, bad imports,
// and Init signature mismatches.
func TestAllExtensions_Load(t *testing.T) {
files := extensionFiles(t)
for _, file := range files {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile(file)
if ext == nil {
t.Fatalf("%s: extension should not be nil after loading", file)
}
})
}
t.Logf("successfully loaded %d extensions", len(files))
}
@@ -0,0 +1,253 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// extensionFiles returns all single-file extensions in the current directory.
// It skips test files, the test template, and files without an Init function.
func extensionFiles(t *testing.T) []string {
t.Helper()
skip := map[string]bool{
"extension_test_template.go": true,
}
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("failed to read directory: %v", err)
}
var files []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || filepath.Ext(name) != ".go" {
continue
}
if strings.HasSuffix(name, "_test.go") || skip[name] {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("failed to read %s: %v", name, err)
}
if !strings.Contains(string(src), "func Init(") {
continue
}
files = append(files, name)
}
if len(files) == 0 {
t.Fatal("no extensions found — check the directory")
}
return files
}
// TestAllExtensions_Lifecycle verifies that every extension survives a full
// SessionStart → SessionShutdown round-trip without errors.
func TestAllExtensions_Lifecycle(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "smoke-test-session",
})
if err != nil {
t.Fatalf("SessionStart error: %v", err)
}
_, err = harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown error: %v", err)
}
})
}
}
// TestAllExtensions_CommandSanity checks that every registered command has
// a non-empty name, a non-empty description, no spaces in the name, no
// leading slash, a non-nil Execute function, and no duplicate names.
func TestAllExtensions_CommandSanity(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
cmds := harness.RegisteredCommands()
seen := make(map[string]bool)
for _, cmd := range cmds {
if cmd.Name == "" {
t.Error("command has empty name")
}
if strings.Contains(cmd.Name, " ") {
t.Errorf("command %q contains spaces", cmd.Name)
}
if strings.HasPrefix(cmd.Name, "/") {
t.Errorf("command %q has leading slash (framework adds it)", cmd.Name)
}
if cmd.Description == "" {
t.Errorf("command %q has empty description", cmd.Name)
}
if cmd.Execute == nil {
t.Errorf("command %q has nil Execute function", cmd.Name)
}
if seen[cmd.Name] {
t.Errorf("duplicate command name %q", cmd.Name)
}
seen[cmd.Name] = true
}
})
}
}
// TestAllExtensions_ToolSanity checks that every registered tool has a
// non-empty name, a non-empty description, at least one executor, valid
// JSON in its Parameters field, and no duplicate names.
func TestAllExtensions_ToolSanity(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
tools := harness.RegisteredTools()
seen := make(map[string]bool)
for _, tool := range tools {
if tool.Name == "" {
t.Error("tool has empty name")
}
if tool.Description == "" {
t.Errorf("tool %q has empty description", tool.Name)
}
if tool.Execute == nil && tool.ExecuteWithContext == nil {
t.Errorf("tool %q has no executor (both Execute and ExecuteWithContext are nil)", tool.Name)
}
if tool.Parameters != "" && !json.Valid([]byte(tool.Parameters)) {
t.Errorf("tool %q has invalid JSON in Parameters: %s", tool.Name, tool.Parameters)
}
if seen[tool.Name] {
t.Errorf("duplicate tool name %q", tool.Name)
}
seen[tool.Name] = true
}
})
}
}
// TestAllExtensions_ZeroValueEvents fires every event type (as zero-value
// structs) at each extension and verifies no errors are returned. Extensions
// should be resilient to events they don't handle and to events with empty
// fields.
func TestAllExtensions_ZeroValueEvents(t *testing.T) {
// Build the set of zero-value events for every event type.
zeroEvents := []extensions.Event{
extensions.ToolCallEvent{},
extensions.ToolExecutionStartEvent{},
extensions.ToolExecutionEndEvent{},
extensions.ToolOutputEvent{},
extensions.ToolResultEvent{},
extensions.InputEvent{},
extensions.BeforeAgentStartEvent{},
extensions.AgentStartEvent{},
extensions.AgentEndEvent{},
extensions.MessageStartEvent{},
extensions.MessageUpdateEvent{},
extensions.MessageEndEvent{},
extensions.SessionStartEvent{},
extensions.SessionShutdownEvent{},
extensions.ModelChangeEvent{},
extensions.ContextPrepareEvent{},
extensions.BeforeForkEvent{},
extensions.BeforeSessionSwitchEvent{},
extensions.BeforeCompactEvent{},
extensions.SubagentStartEvent{},
extensions.SubagentChunkEvent{},
extensions.SubagentEndEvent{},
}
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
for _, ev := range zeroEvents {
_, err := harness.Emit(ev)
if err != nil {
t.Errorf("event %T returned error: %v", ev, err)
}
}
})
}
}
// TestAllExtensions_WidgetSanity emits SessionStart and then checks that
// any widgets set during initialization have non-empty IDs and valid
// placements.
func TestAllExtensions_WidgetSanity(t *testing.T) {
validPlacements := map[extensions.WidgetPlacement]bool{
"above": true,
"below": true,
}
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
// Trigger SessionStart so extensions that set widgets on init do so.
_, _ = harness.Emit(extensions.SessionStartEvent{
SessionID: "widget-sanity-test",
})
// Widgets is an exported field on MockContext; reads are safe
// here because Emit returned synchronously.
for id, w := range harness.Context().Widgets {
if w.ID == "" {
t.Errorf("widget stored with key %q has empty ID", id)
}
if w.ID != id {
t.Errorf("widget key %q doesn't match widget ID %q", id, w.ID)
}
if !validPlacements[w.Placement] {
t.Errorf("widget %q has invalid placement %q (want \"above\" or \"below\")", id, w.Placement)
}
}
})
}
}
// TestAllExtensions_IdempotentLifecycle verifies that receiving SessionStart
// twice and SessionShutdown twice doesn't cause errors — extensions should
// be defensive about repeated lifecycle events.
func TestAllExtensions_IdempotentLifecycle(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
for i := range 2 {
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "idempotent-test",
})
if err != nil {
t.Fatalf("SessionStart #%d error: %v", i+1, err)
}
}
for i := range 2 {
_, err := harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown #%d error: %v", i+1, err)
}
}
})
}
}
+1 -2
View File
@@ -23,8 +23,7 @@ import (
func Init(api ext.API) {
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
// Check for staged changes.
diff, err := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput()
_ = diff
err := exec.Command("git", "diff", "--cached", "--quiet").Run()
if err == nil {
return // exit code 0 means no staged changes
}
+170
View File
@@ -0,0 +1,170 @@
//go:build ignore
// bridge_demo.go - Demonstrates the new bridged SDK APIs for extensions.
// This extension showcases tree navigation, skill loading, template parsing,
// and model resolution capabilities.
package main
import (
"encoding/json"
"fmt"
"strings"
"kit/ext"
)
var (
discoveredSkills []ext.Skill
currentBranch []ext.TreeNode
)
func Init(api ext.API) {
// Register /tree-info command to demonstrate tree navigation
api.RegisterCommand(ext.CommandDef{
Name: "tree-info",
Description: "Show current conversation tree information",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
info := fmt.Sprintf("Current branch has %d nodes:\n", len(branch))
for i, node := range branch {
info += fmt.Sprintf(" [%d] %s (%s): %s...\n", i, node.Type, node.ID[:8], truncate(node.Content, 40))
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /discover-skills command
api.RegisterCommand(ext.CommandDef{
Name: "discover-skills",
Description: "Discover and list available skills",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.DiscoverSkills()
if result.Error != "" {
return "", fmt.Errorf("discovery failed: %s", result.Error)
}
discoveredSkills = result.Skills
info := fmt.Sprintf("Discovered %d skills:\n", len(result.Skills))
for _, s := range result.Skills {
info += fmt.Sprintf(" - %s: %s\n", s.Name, s.Description)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /parse-template command
api.RegisterCommand(ext.CommandDef{
Name: "parse-template",
Description: "Parse a template and show extracted variables",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "Hello {{name}}, welcome to {{place}}!"
}
tpl := ctx.ParseTemplate("demo", args)
info := fmt.Sprintf("Template: %s\nVariables: %v", tpl.Content, tpl.Variables)
ctx.PrintInfo(info)
return "", nil
},
})
// Register /render-template command
api.RegisterCommand(ext.CommandDef{
Name: "render-template",
Description: "Render a template with variables (usage: /render-template name=John place=Kit)",
Execute: func(args string, ctx ext.Context) (string, error) {
tpl := ctx.ParseTemplate("demo", "Hello {{name}}, welcome to {{place}}!")
vars := ctx.ParseArguments(args, ext.ArgumentPattern{
Flags: map[string]string{"name": "name", "place": "place"},
})
rendered := ctx.RenderTemplate(tpl, vars.Vars)
ctx.PrintInfo("Rendered: " + rendered)
return "", nil
},
})
// Register /check-model command
api.RegisterCommand(ext.CommandDef{
Name: "check-model",
Description: "Check model capabilities and availability",
Execute: func(args string, ctx ext.Context) (string, error) {
model := args
if model == "" {
model = ctx.Model
}
available := ctx.CheckModelAvailable(model)
caps, err := ctx.GetModelCapabilities(model)
info := fmt.Sprintf("Model: %s\n", model)
info += fmt.Sprintf("Available: %v\n", available)
if err == "" {
info += fmt.Sprintf("Provider: %s\n", caps.Provider)
info += fmt.Sprintf("Context Limit: %d\n", caps.ContextLimit)
info += fmt.Sprintf("Reasoning: %v\n", caps.Reasoning)
} else {
info += fmt.Sprintf("Error: %s\n", err)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /resolve-chain command
api.RegisterCommand(ext.CommandDef{
Name: "resolve-chain",
Description: "Resolve a model chain (usage: /resolve-chain claude-opus,gpt-4o,claude-sonnet)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "anthropic/claude-opus-4,anthropic/claude-sonnet-4,openai/gpt-4o"
}
prefs := ctx.SimpleParseArguments(args, 1)
chain := []string{}
if len(prefs) > 1 {
// Split the first arg by comma
for _, p := range strings.Split(prefs[1], ",") {
p = strings.TrimSpace(p)
if p != "" {
chain = append(chain, p)
}
}
}
result := ctx.ResolveModelChain(chain)
info, _ := json.MarshalIndent(result, "", " ")
ctx.PrintInfo("Resolution Result:\n" + string(info))
return "", nil
},
})
// Register /test-conditional command
api.RegisterCommand(ext.CommandDef{
Name: "test-conditional",
Description: "Test model conditional rendering",
Execute: func(args string, ctx ext.Context) (string, error) {
content := `<if-model is="claude-*">This is for Claude models<else>This is for other models</if-model>`
rendered := ctx.RenderWithModelConditionals(content)
ctx.PrintInfo("Input: " + content)
ctx.PrintInfo("Output: " + rendered)
ctx.PrintInfo(fmt.Sprintf("Current model matches 'claude-*': %v", ctx.EvaluateModelConditional("claude-*")))
return "", nil
},
})
// OnSessionStart: discover skills automatically
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
result := ctx.DiscoverSkills()
if result.Error == "" && len(result.Skills) > 0 {
discoveredSkills = result.Skills
ctx.SetStatus("bridge-demo", fmt.Sprintf("%d skills", len(result.Skills)), 50)
}
})
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
+406
View File
@@ -0,0 +1,406 @@
//go:build ignore
// conversation-manager.go - Advanced conversation tree navigation and management.
// This extension demonstrates:
// - Tree navigation (GetTreeNode, GetCurrentBranch, NavigateTo)
// - Branch summarization and collapsing
// - Interactive tree exploration
//
// Commands:
// /tree - Show conversation tree structure
// /branch - Show current branch path
// /goto <entry-id> - Navigate to a specific entry
// /summarize <n> - Summarize last N messages
// /fresh-context - Collapse branch and start fresh
// /loop <n> <prompt> - Execute prompt N times with fresh context each iteration
package main
import (
"fmt"
"strconv"
"strings"
"time"
"kit/ext"
)
var (
loopActive bool
loopCount int
loopCurrent int
loopPrompt string
loopStartNode string
)
func Init(api ext.API) {
// /tree - Show tree structure
api.RegisterCommand(ext.CommandDef{
Name: "tree",
Description: "Show conversation tree structure",
Execute: func(args string, ctx ext.Context) (string, error) {
showTree(ctx)
return "", nil
},
})
// /branch - Show current branch
api.RegisterCommand(ext.CommandDef{
Name: "branch",
Description: "Show current conversation branch",
Execute: func(args string, ctx ext.Context) (string, error) {
showBranch(ctx)
return "", nil
},
})
// /goto - Navigate to entry
api.RegisterCommand(ext.CommandDef{
Name: "goto",
Description: "Navigate to a specific entry ID (usage: /goto <entry-id>)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
ctx.PrintError("Usage: /goto <entry-id>")
return "", nil
}
result := ctx.NavigateTo(args)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Navigation failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo(fmt.Sprintf("Navigated to entry: %s", args))
// Show the node we navigated to
node := ctx.GetTreeNode(args)
if node != nil {
ctx.PrintInfo(fmt.Sprintf("Entry type: %s, Role: %s", node.Type, node.Role))
}
return "", nil
},
})
// /summarize - Summarize recent messages
api.RegisterCommand(ext.CommandDef{
Name: "summarize",
Description: "Summarize last N messages (usage: /summarize [n=5])",
Execute: func(args string, ctx ext.Context) (string, error) {
n := 5
if args != "" {
if parsed, err := strconv.Atoi(args); err == nil && parsed > 0 {
n = parsed
}
}
branch := ctx.GetCurrentBranch()
if len(branch) < 2 {
ctx.PrintError("Not enough messages to summarize")
return "", nil
}
// Find range to summarize
startIdx := len(branch) - n - 1
if startIdx < 0 {
startIdx = 0
}
endIdx := len(branch) - 1
fromID := branch[startIdx].ID
toID := branch[endIdx].ID
ctx.PrintInfo(fmt.Sprintf("Summarizing messages %d to %d...", startIdx, endIdx))
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Summary",
})
return "", nil
},
})
// /fresh-context - Collapse and restart
api.RegisterCommand(ext.CommandDef{
Name: "fresh-context",
Description: "Collapse conversation to summary and start fresh",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
if len(branch) < 3 {
ctx.PrintError("Not enough context to collapse")
return "", nil
}
// Keep first message (system), summarize rest
fromID := branch[1].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo("Generating summary for context collapse...")
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
// Collapse the branch
result := ctx.CollapseBranch(fromID, toID, summary)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo("Context collapsed. Starting fresh with summary.")
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Collapsed Context",
})
// Set a widget showing we're in fresh mode
ctx.SetWidget(ext.WidgetConfig{
ID: "fresh-context",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: "🌱 Fresh Context Mode - Previous conversation collapsed"},
Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
})
return "", nil
},
})
// /loop - Execute with fresh context each iteration
api.RegisterCommand(ext.CommandDef{
Name: "loop",
Description: "Execute prompt N times with fresh context (usage: /loop 5 analyze this code)",
Execute: func(args string, ctx ext.Context) (string, error) {
if loopActive {
ctx.PrintError("Loop already in progress. Wait for completion.")
return "", nil
}
// Parse arguments
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
ctx.PrintError("Usage: /loop <count> <prompt>")
return "", nil
}
count, err := strconv.Atoi(parts[0])
if err != nil || count <= 0 || count > 10 {
ctx.PrintError("Invalid count (must be 1-10)")
return "", nil
}
loopCount = count
loopCurrent = 0
loopPrompt = parts[1]
loopActive = true
// Store current branch position
branch := ctx.GetCurrentBranch()
if len(branch) > 0 {
loopStartNode = branch[len(branch)-1].ID
}
ctx.PrintInfo(fmt.Sprintf("Starting loop: %d iterations", loopCount))
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: 0/%d - %s", loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Start first iteration
executeLoopIteration(ctx)
return "", nil
},
})
// OnAgentEnd handles loop continuation
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if !loopActive {
return
}
loopCurrent++
if loopCurrent >= loopCount {
// Loop complete
loopActive = false
ctx.RemoveWidget("loop-progress")
ctx.PrintInfo(fmt.Sprintf("✅ Loop complete: %d/%d iterations", loopCurrent, loopCount))
// Show final summary
branch := ctx.GetCurrentBranch()
if len(branch) > 0 && loopStartNode != "" {
summary := ctx.SummarizeBranch(loopStartNode, branch[len(branch)-1].ID)
if summary != "" {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Loop Summary",
})
}
}
return
}
// Update progress
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: %d/%d - %s", loopCurrent, loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Collapse previous iteration for fresh context
branch := ctx.GetCurrentBranch()
if len(branch) >= 2 {
// Find the user messages (look for the one before the last assistant message)
// We want to collapse from the user message that started this iteration
// to the last assistant response
var collapseStartIdx = -1
for i := len(branch) - 1; i >= 0; i-- {
if branch[i].Role == "assistant" {
// Found the last assistant message, now find the user message before it
for j := i - 1; j >= 0; j-- {
if branch[j].Role == "user" {
collapseStartIdx = j
break
}
}
break
}
}
if collapseStartIdx >= 0 {
fromID := branch[collapseStartIdx].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo(fmt.Sprintf("Collapsing iteration %d for fresh context...", loopCurrent))
summary := ctx.SummarizeBranch(fromID, toID)
if summary != "" {
result := ctx.CollapseBranch(fromID, toID, summary)
if result.Success {
ctx.PrintInfo("Context collapsed successfully")
} else {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
}
}
}
}
// Small delay to let UI update
time.Sleep(500 * time.Millisecond)
// Trigger next iteration
executeLoopIteration(ctx)
})
}
// showTree displays the conversation tree structure
func showTree(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("Tree is empty")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Conversation Tree (%d nodes):\n\n", len(branch)))
for i, node := range branch {
prefix := " "
if i == len(branch)-1 {
prefix = "▶ " // Current node
} else {
prefix = " "
}
roleIcon := "💬"
switch node.Role {
case "user":
roleIcon = "👤"
case "assistant":
roleIcon = "🤖"
case "system":
roleIcon = "⚙️"
}
content := truncate(node.Content, 50)
if node.Type == "branch_summary" {
roleIcon = "📋"
content = "[Summary] " + truncate(node.Content, 40)
}
output.WriteString(fmt.Sprintf("%s%s %s: %s (%s...)\n", prefix, roleIcon, node.Role, node.ID[:8], content))
// Show children count if any
children := ctx.GetChildren(node.ID)
if len(children) > 0 {
output.WriteString(fmt.Sprintf(" └─ %d branch(es)\n", len(children)))
}
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Tree View",
})
}
// showBranch displays the current branch path
func showBranch(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("No active branch")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Current Branch (%d nodes from root to leaf):\n\n", len(branch)))
for i, node := range branch {
marker := " "
if i == len(branch)-1 {
marker = "▶ " // Current leaf
}
output.WriteString(fmt.Sprintf("%s[%d] %s (%s): %s\n",
marker, i, node.Type, node.ID[:8], truncate(node.Content, 40)))
}
// Show current node details
leaf := branch[len(branch)-1]
output.WriteString(fmt.Sprintf("\nCurrent Leaf:\n"))
output.WriteString(fmt.Sprintf(" ID: %s\n", leaf.ID))
output.WriteString(fmt.Sprintf(" Type: %s\n", leaf.Type))
output.WriteString(fmt.Sprintf(" Role: %s\n", leaf.Role))
output.WriteString(fmt.Sprintf(" Model: %s\n", leaf.Model))
output.WriteString(fmt.Sprintf(" Children: %d\n", len(leaf.Children)))
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#cba6f7",
Subtitle: "conversation-manager · Branch View",
})
}
// executeLoopIteration triggers the next loop iteration
func executeLoopIteration(ctx ext.Context) {
iterationPrompt := fmt.Sprintf("[%d/%d] %s", loopCurrent+1, loopCount, loopPrompt)
ctx.SendMessage(iterationPrompt)
}
// truncate helper
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
+6 -4
View File
@@ -7,10 +7,12 @@
// 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).
// Note: Extensions in autoloaded directories (~/.config/kit/extensions/
// and .kit/extensions/) are automatically reloaded on save. The /reload
// command is useful for extensions loaded via -e from other locations.
//
// Event handlers, slash commands, tool definitions, tool renderers,
// message renderers, and keyboard shortcuts all update immediately.
//
// Commands:
// /reload — hot-reload all extensions from disk
@@ -0,0 +1,170 @@
// Extension Test Template
//
// This is a template for writing tests for your Kit extension.
// Copy this file to your extension directory, rename it to something like
// "my-ext_test.go", and customize it for your extension.
//
// Run tests with: go test -v
//
// IMPORTANT: This file should be in the same directory as your extension
// and use package main, NOT package test.
package main
import (
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// Test that your extension loads without errors
func TestExtension_Loads(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile("my-ext.go") // Change to your extension filename
// Verify the extension was loaded
if ext == nil {
t.Fatal("extension should not be nil")
}
}
// Test your event handlers are registered
func TestExtension_EventHandlers(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Uncomment the handlers your extension uses:
// test.AssertHasHandlers(t, harness, extensions.ToolCall)
// test.AssertHasHandlers(t, harness, extensions.Input)
// test.AssertHasHandlers(t, harness, extensions.SessionStart)
// test.AssertHasHandlers(t, harness, extensions.AgentEnd)
}
// Test tool registration
func TestExtension_Tools(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test that your tools are registered
// test.AssertToolRegistered(t, harness, "my_tool")
// Or test all registered tools
tools := harness.RegisteredTools()
t.Logf("Registered %d tools", len(tools))
for _, tool := range tools {
t.Logf(" - %s: %s", tool.Name, tool.Description)
}
}
// Test command registration
func TestExtension_Commands(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test that your commands are registered
// test.AssertCommandRegistered(t, harness, "mycommand")
// Or test all registered commands
cmds := harness.RegisteredCommands()
t.Logf("Registered %d commands", len(cmds))
for _, cmd := range cmds {
t.Logf(" - %s: %s", cmd.Name, cmd.Description)
}
}
// Test session start behavior
func TestExtension_SessionStart(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Emit session start event
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "test-session",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify expected behavior:
// - Did it print something?
// test.AssertPrinted(t, harness, "expected output")
// - Did it set a widget?
// test.AssertWidgetSet(t, harness, "my-widget")
// test.AssertWidgetText(t, harness, "my-widget", "expected text")
// - Did it set the header/footer?
// test.AssertHeaderSet(t, harness)
// test.AssertFooterSet(t, harness)
// - Did it set a status?
// test.AssertStatusSet(t, harness, "myext:status")
}
// Test tool call handling
func TestExtension_ToolCall(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test a specific tool call
result, err := harness.Emit(extensions.ToolCallEvent{
ToolName: "some_tool",
Input: `{"key": "value"}`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// If your extension blocks certain tools:
// test.AssertNotBlocked(t, result)
// OR
// test.AssertBlocked(t, result, "expected reason")
// Suppress unused variable warning (remove this when using result)
_ = result
// Check for print output
// test.AssertPrinted(t, harness, "expected message")
}
// Test input handling
func TestExtension_InputHandling(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Test input that should be handled
result, err := harness.Emit(extensions.InputEvent{
Text: "test input",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// If your extension handles/transforms input:
// test.AssertInputHandled(t, result, "handled")
// OR
// test.AssertInputTransformed(t, result, "transformed text")
// Suppress unused variable warning (remove this when using result)
_ = result
}
// Test with configured prompt results
func TestExtension_WithPrompts(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
// Configure what prompts should return
harness.Context().SetPromptSelectResult(extensions.PromptSelectResult{
Value: "option1",
Index: 0,
Cancelled: false,
})
// Now when your extension calls ctx.PromptSelect(), it gets the configured result
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
// Verify behavior based on the selected options
}
+111
View File
@@ -0,0 +1,111 @@
# kit-telegram
A Kit extension that relays all Kit agent runs to Telegram and lets approved Telegram users reply back into Kit.
## What it does
- Relays **all Kit runs** to one Telegram chat while connected
- Edits one Telegram progress message in place during a run
- Lets approved Telegram users send normal text replies back into Kit
- Shows `Telegram Connected` or `Telegram Disconnected` in the status bar
- Shows a small spinner animation as `⠋ Telegram Connecting` only while the relay is still connecting
- On startup with an already validated enabled config, sends a short Telegram connection message to confirm the relay is up
## Requirements
- `kit` installed and working
- A Telegram bot token from `@BotFather`
- Either:
- A Telegram chat where you can message the bot, or
- A numeric Telegram chat id you want to enter manually
- For group chats, one or more allowed Telegram user ids
## Quickstart
### 1. Install the extension
```bash
kit install github.com/mark3labs/kit/examples/extensions/kit-telegram
```
Or run directly:
```bash
kit -e path/to/kit-telegram/main.go
```
### 2. Start Kit and connect Telegram
```bash
kit
```
Inside Kit, run:
```
/telegram connect
```
You will be prompted for:
- Bot token from `@BotFather`
- Whether to auto-detect the chat by messaging the bot or enter the chat id manually
- Allowed user ids when needed
### 3. Verify the relay
```
/telegram test
```
Reply in Telegram with the code from the test message.
## Commands
| Command | Description |
|---------|-------------|
| `/telegram` | Human-friendly overview and subcommand list |
| `/telegram status` | Raw deterministic relay state |
| `/telegram test` | Verify outbound and inbound relay |
| `/telegram toggle` | Enable or disable relay without deleting credentials |
| `/telegram logout` | Remove saved credentials and disconnect relay |
| `/telegram connect` | Run the setup flow again |
| `/telegram clear` | Clear Telegram status and working messages from the TUI |
## Remote commands (from Telegram)
| Command | Description |
|---------|-------------|
| `/telegram` | Sends the overview back to Telegram |
| `/telegram status` | Sends the deterministic state report to Telegram |
| `/telegram test` | Sends a reply-code test message from Telegram |
| `/telegram toggle` | Flips the enabled flag |
| `/telegram logout yes` | Logs out (requires `yes` confirmation) |
| `/telegram clear` | Clears the TUI footer and working messages |
## Key APIs Used
- `RegisterCommand` — Slash command with subcommands and tab completion
- `OnSessionStart` / `OnSessionShutdown` — Lifecycle management
- `OnAgentStart` / `OnAgentEnd` — Run tracking and progress rendering
- `OnToolCall` / `OnToolResult` — Action tracking
- `OnMessageEnd` — Capture assistant responses
- `OnInput` — Mirror local messages to Telegram
- `SetStatus` / `RemoveStatus` — Status bar indicators
- `SetWidget` / `RemoveWidget` — Working message display
- `PromptInput` / `PromptSelect` / `PromptConfirm` — Interactive setup flow
- `SendMessage` — Inject Telegram replies as Kit prompts
## Architecture
Single Go file interpreted by Yaegi at runtime. Core components:
- **Telegram Bot API client** — HTTP calls via `net/http` for getMe, getChat, getChatMember, getUpdates (long-polling), sendMessage, editMessageText
- **Config persistence** — JSON file at `.kit/kit-telegram.json` with atomic writes
- **Long-polling goroutine** — Background polling for Telegram updates with warmup poll, retry, and client-side timeouts
- **Message queue** — In-memory FIFO queue for Telegram prompt input with edit-before-dispatch support
- **Progress rendering** — `⏳ elapsed · step N` with action lines, edited in place
- **Final rendering** — `✅/❌ elapsed` with response text, split into chunks for long output
## Debug mode
Set environment variable `KIT_TELEGRAM_DEBUG=1` to enable verbose debug logging.
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -2,9 +2,7 @@
// lsp-diagnostics.go — LSP-powered diagnostics for Kit's edit tool.
//
// Starts language servers on demand and surfaces diagnostics after file edits,
// following the same pattern used by Charm's crush editor:
//
// Starts language servers on demand and surfaces diagnostics after file edits:
// 1. After an edit, notify the LSP server of the file change
// 2. Wait for the server to publish fresh diagnostics
// 3. Append diagnostic output to the edit tool's result
@@ -412,7 +410,7 @@ func (c *lspClient) changeFile(absPath, content string) {
}
// waitForDiagnostics polls until the server publishes new diagnostics or
// the timeout elapses. Mirrors crush's WaitForDiagnostics pattern.
// the timeout elapses.
func (c *lspClient) waitForDiagnostics(timeout time.Duration) {
c.diagMu.Lock()
startVersion := c.diagVersion
+42
View File
@@ -0,0 +1,42 @@
//go:build ignore
package main
import "kit/ext"
// Init registers a "neon" theme and a /neon slash command to apply it.
// Demonstrates how extensions can create and set themes programmatically.
//
// Usage: kit -e examples/extensions/neon-theme.go
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
// Register a cyberpunk neon theme at startup.
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
})
ctx.PrintInfo("Neon theme registered! Use /theme neon to activate.")
})
// Also register a /neon slash command as a shortcut.
api.RegisterCommand(ext.CommandDef{
Name: "neon",
Description: "Switch to the neon cyberpunk theme",
Execute: func(args string, ctx ext.Context) (string, error) {
if err := ctx.SetTheme("neon"); err != nil {
return "", err
}
return "Neon theme activated!", nil
},
})
}
+269
View File
@@ -0,0 +1,269 @@
//go:build ignore
// prompt-templates.go - Frontmatter-driven prompt templates with model switching.
// This extension demonstrates the new bridged SDK APIs:
// - Tree navigation for conversation management
// - Template parsing with {{variable}} substitution
// - Model resolution with fallback chains
// - Skill injection
//
// Usage:
// 1. Create ~/.config/kit/prompts/debug.md with frontmatter:
// ---
// description: Debug Python code
// model: claude-sonnet-4-20250514
// skill: python
// ---
// Help me debug this Python code: {{input}}
//
// 2. In Kit: /debug my_script.py
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"kit/ext"
)
// PromptTemplate represents a loaded template with frontmatter
type PromptTemplate struct {
Name string
Description string
Model string
Skill string
Content string
Variables []string
Path string
}
var (
templates = make(map[string]PromptTemplate)
templateDir string
)
func Init(api ext.API) {
// Determine template directory
home, _ := os.UserHomeDir()
templateDir = filepath.Join(home, ".config", "kit", "prompts")
// Ensure directory exists
os.MkdirAll(templateDir, 0755)
// Register commands
api.RegisterCommand(ext.CommandDef{
Name: "reload-templates",
Description: "Reload prompt templates from disk",
Execute: func(args string, ctx ext.Context) (string, error) {
loadTemplates(ctx)
ctx.PrintInfo(fmt.Sprintf("Loaded %d templates from %s", len(templates), templateDir))
return "", nil
},
})
// Dynamic template commands are registered after loading
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
loadTemplates(ctx)
registerTemplateCommands(api, ctx)
})
}
// loadTemplates discovers and loads all template files
func loadTemplates(ctx ext.Context) {
templates = make(map[string]PromptTemplate)
entries, err := os.ReadDir(templateDir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
path := filepath.Join(templateDir, entry.Name())
tpl, err := loadTemplateFile(path)
if err != nil {
continue
}
name := strings.TrimSuffix(entry.Name(), ".md")
templates[name] = tpl
}
}
// loadTemplateFile parses a template with YAML frontmatter
func loadTemplateFile(path string) (PromptTemplate, error) {
data, err := os.ReadFile(path)
if err != nil {
return PromptTemplate{}, err
}
content := string(data)
tpl := PromptTemplate{Path: path}
// Parse frontmatter
if strings.HasPrefix(content, "---") {
parts := strings.SplitN(content[3:], "---", 2)
if len(parts) == 2 {
frontmatter := strings.TrimSpace(parts[0])
body := strings.TrimSpace(parts[1])
// Simple line-by-line frontmatter parsing
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, ":")
if found {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch key {
case "description":
tpl.Description = value
case "model":
tpl.Model = value
case "skill":
tpl.Skill = value
}
}
}
tpl.Content = body
} else {
tpl.Content = content
}
} else {
tpl.Content = content
}
// Parse {{variables}} using simple string parsing
// (Can't use ctx.ParseTemplate here since we're in Init, not a handler)
var vars []string
for {
start := strings.Index(tpl.Content, "{{")
if start == -1 {
break
}
end := strings.Index(tpl.Content[start:], "}}")
if end == -1 {
break
}
varName := strings.TrimSpace(tpl.Content[start+2 : start+end])
vars = append(vars, varName)
tpl.Content = tpl.Content[:start] + "{{" + varName + "}}" + tpl.Content[start+end+2:]
}
tpl.Variables = vars
return tpl, nil
}
// registerTemplateCommands dynamically registers commands for each template
func registerTemplateCommands(api ext.API, ctx ext.Context) {
for name, tpl := range templates {
// Skip if already registered (we'd need to track this)
tplCopy := tpl // Capture for closure
nameCopy := name
// Build description with metadata
desc := tplCopy.Description
if desc == "" {
desc = fmt.Sprintf("Run %s template", nameCopy)
}
if tplCopy.Model != "" {
desc += fmt.Sprintf(" [%s", tplCopy.Model)
if tplCopy.Skill != "" {
desc += fmt.Sprintf(" +%s", tplCopy.Skill)
}
desc += "]"
}
api.RegisterCommand(ext.CommandDef{
Name: nameCopy,
Description: desc,
Execute: func(args string, ctx ext.Context) (string, error) {
return executeTemplate(ctx, tplCopy, args)
},
})
}
}
// executeTemplate runs a template with the given arguments
func executeTemplate(ctx ext.Context, tpl PromptTemplate, args string) (string, error) {
// Store original model for restoration
originalModel := ctx.Model
// 1. Resolve and switch model if specified
if tpl.Model != "" {
// Parse model chain (comma-separated)
preferences := strings.Split(tpl.Model, ",")
for i := range preferences {
preferences[i] = strings.TrimSpace(preferences[i])
}
result := ctx.ResolveModelChain(preferences)
if result.Error != "" {
ctx.PrintError(fmt.Sprintf("Model resolution failed: %s", result.Error))
// Continue with current model
} else {
ctx.PrintInfo(fmt.Sprintf("Switching to model: %s", result.Model))
if err := ctx.SetModel(result.Model); err != nil {
ctx.PrintError(fmt.Sprintf("Failed to switch model: %s", err.Error()))
}
}
}
// 2. Inject skill if specified
if tpl.Skill != "" {
err := ctx.InjectSkillAsContext(tpl.Skill)
if err != "" {
ctx.PrintError(fmt.Sprintf("Skill injection failed: %s", err))
} else {
ctx.PrintInfo(fmt.Sprintf("Injected skill: %s", tpl.Skill))
}
}
// 3. Parse and render template
parsed := ctx.ParseTemplate(tpl.Name, tpl.Content)
// Build variable map
vars := make(map[string]string)
// Simple argument parsing: first arg is $1 (input), rest is $@
if len(parsed.Variables) > 0 {
argsList := ctx.SimpleParseArguments(args, len(parsed.Variables))
for i, varName := range parsed.Variables {
if i < len(parsed.Variables) && i+1 < len(argsList) {
vars[varName] = argsList[i+1]
}
}
// If single variable, use full args
if len(parsed.Variables) == 1 && vars[parsed.Variables[0]] == "" {
vars[parsed.Variables[0]] = args
}
}
// Render with model conditionals
content := ctx.RenderWithModelConditionals(tpl.Content)
rendered := ctx.RenderTemplate(ext.PromptTemplate{Name: tpl.Name, Content: content, Variables: parsed.Variables}, vars)
// 4. Send the rendered prompt
ctx.SendMessage(rendered)
// 5. Schedule model restoration after turn completes
// We use a goroutine to wait and restore
if tpl.Model != "" && originalModel != "" {
go func() {
// Note: In a real implementation, we'd use OnAgentEnd event
// For now, the user can manually switch back
ctx.SetStatus("template-mode", fmt.Sprintf("Template: %s (model will restore)", tpl.Name), 20)
}()
}
return fmt.Sprintf("Executing template: %s", tpl.Name), nil
}
@@ -0,0 +1,43 @@
//go:build ignore
package main
import (
"fmt"
"kit/ext"
)
// Helper functions for the status-tools extension
// These are used by main.go but kept in a separate file
// to demonstrate the multi-file extension pattern.
// formatMemory converts bytes to human-readable format
func formatMemory(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}
// showMemoryStatus displays memory usage (placeholder)
func showMemoryStatus(ctx ext.Context) {
// This is a placeholder that would show memory stats
// In a real extension, you'd integrate with system metrics
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "Memory status monitoring not yet implemented",
BorderColor: "#f9e2af",
Subtitle: "Memory",
})
}
+49
View File
@@ -0,0 +1,49 @@
//go:build ignore
package main
import (
"fmt"
"time"
"kit/ext"
)
// Init registers the status tools extension.
// This extension provides multiple status-related utilities as a
// multi-file extension example.
func Init(api ext.API) {
// Register a status bar widget that shows time
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetStatus("clock", time.Now().Format("15:04:05"), 5)
}
}()
})
// Register a /status command
api.RegisterCommand(ext.CommandDef{
Name: "status",
Description: "Show system status information",
Execute: func(args string, ctx ext.Context) (string, error) {
stats := ctx.GetContextStats()
info := fmt.Sprintf(
"Model: %s\nTokens: %d/%d (%.1f%%)\nMessages: %d",
ctx.Model,
stats.EstimatedTokens,
stats.ContextLimit,
stats.UsagePercent*100,
stats.MessageCount,
)
ctx.PrintBlock(ext.PrintBlockOpts{
Text: info,
BorderColor: "#89b4fa",
Subtitle: "System Status",
})
return "", nil
},
})
}
@@ -0,0 +1,159 @@
package main
import (
"fmt"
"testing"
"time"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// TestSubagentMonitor_SessionStart verifies OnSessionStart initializes state
// without panicking and properly guards nil ctx calls.
func TestSubagentMonitor_SessionStart(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
// Emit SessionStart - should not panic even with nil ctx functions
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
}
// TestSubagentMonitor_SubagentLifecycle verifies the full subagent lifecycle
// creates entries and emits widget updates.
func TestSubagentMonitor_SubagentLifecycle(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
// Start session
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
// Emit SubagentStart
_, err = harness.Emit(extensions.SubagentStartEvent{
ToolCallID: "call-1",
Task: "test task",
})
if err != nil {
t.Fatalf("SubagentStart should not error: %v", err)
}
// Emit a few chunks
for i := range 3 {
_, err = harness.Emit(extensions.SubagentChunkEvent{
ToolCallID: "call-1",
Task: "test task",
ChunkType: "text",
Content: fmt.Sprintf("line %d", i),
})
if err != nil {
t.Fatalf("SubagentChunk %d should not error: %v", i, err)
}
}
// Emit tool call chunk
_, err = harness.Emit(extensions.SubagentChunkEvent{
ToolCallID: "call-1",
Task: "test task",
ChunkType: "tool_call",
ToolName: "bash",
})
if err != nil {
t.Fatalf("SubagentChunk tool_call should not error: %v", err)
}
// Emit SubagentEnd
_, err = harness.Emit(extensions.SubagentEndEvent{
ToolCallID: "call-1",
Task: "test task",
Response: "done",
})
if err != nil {
t.Fatalf("SubagentEnd should not error: %v", err)
}
// Give time for cleanup goroutine
time.Sleep(100 * time.Millisecond)
}
// TestSubagentMonitor_MultipleSubagents verifies multiple parallel subagents.
func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
// Start 3 subagents
for i := 1; i <= 3; i++ {
_, err := harness.Emit(extensions.SubagentStartEvent{
ToolCallID: fmt.Sprintf("call-%d", i),
Task: fmt.Sprintf("task %d", i),
})
if err != nil {
t.Fatalf("SubagentStart %d should not error: %v", i, err)
}
}
// Emit chunks for each
for i := 1; i <= 3; i++ {
_, err := harness.Emit(extensions.SubagentChunkEvent{
ToolCallID: fmt.Sprintf("call-%d", i),
Task: fmt.Sprintf("task %d", i),
ChunkType: "text",
Content: fmt.Sprintf("output from agent %d", i),
})
if err != nil {
t.Fatalf("SubagentChunk %d should not error: %v", i, err)
}
}
// End all subagents
for i := 1; i <= 3; i++ {
_, err := harness.Emit(extensions.SubagentEndEvent{
ToolCallID: fmt.Sprintf("call-%d", i),
Task: fmt.Sprintf("task %d", i),
Response: "completed",
})
if err != nil {
t.Fatalf("SubagentEnd %d should not error: %v", i, err)
}
}
time.Sleep(100 * time.Millisecond)
}
// TestSubagentMonitor_SessionShutdown verifies shutdown doesn't panic
// even with nil ctx functions.
func TestSubagentMonitor_SessionShutdown(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
// Start then shutdown
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
// Start a subagent
_, err = harness.Emit(extensions.SubagentStartEvent{
ToolCallID: "call-1",
Task: "test task",
})
if err != nil {
t.Fatalf("SubagentStart should not error: %v", err)
}
// Shutdown - should not panic even with active subagent
_, err = harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown should not error: %v", err)
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ func Init(api ext.API) {
"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.")
"The LLM can also use the subagent tool.")
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"os"
"strings"
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// Test that the tool-logger extension loads and registers handlers
func TestToolLogger_Loads(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile("tool-logger.go")
if ext == nil {
t.Fatal("extension should not be nil")
}
// Verify all expected handlers are registered
test.AssertHasHandlers(t, harness, extensions.ToolCall)
test.AssertHasHandlers(t, harness, extensions.ToolResult)
test.AssertHasHandlers(t, harness, extensions.SessionStart)
test.AssertHasHandlers(t, harness, extensions.SessionShutdown)
test.AssertHasHandlers(t, harness, extensions.Input)
}
// Test that tool calls are logged (handlers run without errors)
func TestToolLogger_ToolCall(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Emit a tool call event
result, err := harness.Emit(extensions.ToolCallEvent{
ToolName: "Read",
ToolCallID: "call-123",
Input: `{"file": "test.txt"}`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Tool logger should not block any tools
test.AssertNotBlocked(t, result)
}
// Test that tool results are processed
func TestToolLogger_ToolResult(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
content := "Hello, World!"
result, err := harness.Emit(extensions.ToolResultEvent{
ToolName: "Read",
Content: content,
IsError: false,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Tool logger should not modify results
if result != nil {
t.Error("expected nil result (no modification)")
}
}
// Test that error tool results are handled
func TestToolLogger_ToolResultError(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.ToolResultEvent{
ToolName: "Bash",
Content: "command not found",
IsError: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Error("expected nil result (no modification)")
}
}
// Test session start handler
func TestToolLogger_SessionStart(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "test-session-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Handler should run without errors (logs to file)
// Since file logging happens outside our mock, we just verify no errors
}
// Test session shutdown handler
func TestToolLogger_SessionShutdown(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
_, err := harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// Test the !time command
func TestToolLogger_TimeCommand(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "!time",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
test.AssertInputHandled(t, result, "handled")
// Verify PrintInfo was called with a time message
infos := harness.Context().GetPrintInfos()
found := false
for _, info := range infos {
if strings.Contains(info, "Current time:") {
found = true
break
}
}
if !found {
t.Errorf("expected PrintInfo with 'Current time:', got: %v", infos)
}
}
// Test the !status command
func TestToolLogger_StatusCommand(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "!status",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
test.AssertInputHandled(t, result, "handled")
// Verify PrintBlock was called
blocks := harness.Context().PrintBlocks
if len(blocks) != 1 {
t.Fatalf("expected 1 PrintBlock call, got %d", len(blocks))
}
block := blocks[0]
if block.Subtitle != "tool-logger extension" {
t.Errorf("expected subtitle 'tool-logger extension', got %q", block.Subtitle)
}
if block.BorderColor != "#a6e3a1" {
t.Errorf("expected border color '#a6e3a1', got %q", block.BorderColor)
}
if !strings.Contains(block.Text, "Session active") {
t.Errorf("expected text to contain 'Session active', got %q", block.Text)
}
}
// Test that unknown commands are not handled
func TestToolLogger_UnknownCommand(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "!unknown",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil result for unknown command, got %v", result)
}
// Verify no info/block prints for unknown commands
if len(harness.Context().GetPrintInfos()) != 0 {
t.Error("expected no PrintInfo calls for unknown command")
}
if len(harness.Context().PrintBlocks) != 0 {
t.Error("expected no PrintBlock calls for unknown command")
}
}
// Test regular text input (not a command)
func TestToolLogger_RegularInput(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
result, err := harness.Emit(extensions.InputEvent{
Text: "This is a normal message",
Source: "cli",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil result for regular input, got %v", result)
}
}
// Test complete session flow
func TestToolLogger_FullSession(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Simulate a full session
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Several tool calls
tools := []string{"Read", "Glob", "Grep", "Bash"}
for _, tool := range tools {
_, err := harness.Emit(extensions.ToolCallEvent{
ToolName: tool,
Input: "{}",
})
if err != nil {
t.Fatalf("error for tool %s: %v", tool, err)
}
_, err = harness.Emit(extensions.ToolResultEvent{
ToolName: tool,
Content: "result",
IsError: false,
})
if err != nil {
t.Fatalf("error for tool result %s: %v", tool, err)
}
}
// User issues a command
_, err = harness.Emit(extensions.InputEvent{Text: "!time", Source: "cli"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the !time command was handled
if len(harness.Context().GetPrintInfos()) != 1 {
t.Errorf("expected 1 PrintInfo call, got %d", len(harness.Context().GetPrintInfos()))
}
}
// Test that the extension handles file write errors gracefully
func TestToolLogger_FileError(t *testing.T) {
// This test verifies the extension doesn't panic when file operations fail
// Since we can't easily mock os.OpenFile, we rely on the extension code
// properly checking for errors (which it does)
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Just verify the handlers run without panicking
_, err := harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// Test concurrent tool calls (race condition check)
func TestToolLogger_ConcurrentToolCalls(t *testing.T) {
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Run multiple tool calls concurrently
done := make(chan bool, 10)
for i := range 10 {
go func(index int) {
defer func() { done <- true }()
toolName := "Tool" + string(rune('0'+index))
_, err := harness.Emit(extensions.ToolCallEvent{
ToolName: toolName,
Input: "{}",
})
if err != nil {
t.Errorf("error in goroutine %d: %v", index, err)
}
}(i)
}
// Wait for all goroutines
for range 10 {
<-done
}
}
// Test the actual log file is created and written to
func TestToolLogger_LogFile(t *testing.T) {
logFile := "/tmp/kit-tool-log.txt"
// Clean up before test
_ = os.Remove(logFile)
harness := test.New(t)
harness.LoadFile("tool-logger.go")
// Emit events
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
_, _ = harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
_, _ = harness.Emit(extensions.ToolResultEvent{ToolName: "Read", Content: "data", IsError: false})
// Note: Since the extension writes to a real file and the test harness
// mocks the context, the file writes actually happen. Let's verify.
// Give it a moment for file operations
if _, err := os.Stat(logFile); err == nil {
// File exists - read and verify content
content, err := os.ReadFile(logFile)
if err != nil {
t.Logf("Could not read log file: %v", err)
} else {
contentStr := string(content)
if !strings.Contains(contentStr, "SESSION_START") {
t.Error("log file should contain SESSION_START")
}
if !strings.Contains(contentStr, "CALL tool=Read") {
t.Error("log file should contain CALL tool=Read")
}
if !strings.Contains(contentStr, "RESULT tool=Read") {
t.Error("log file should contain RESULT tool=Read")
}
}
} else {
t.Log("Note: Log file not created - this is expected since the extension writes directly to disk")
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ func Init(api ext.API) {
DisplayName: "File",
BorderColor: "#89b4fa", // Catppuccin blue
RenderHeader: func(toolArgs string, width int) string {
var args map[string]interface{}
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
@@ -72,7 +72,7 @@ func Init(api ext.API) {
Background: "#1e1e2e", // Dark background
BorderColor: "#a6e3a1", // Catppuccin green
RenderHeader: func(toolArgs string, width int) string {
var args map[string]interface{}
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
+45
View File
@@ -0,0 +1,45 @@
# SDK Examples
These examples demonstrate how to use the Kit SDK (`pkg/kit`) to build agents programmatically in Go.
## Examples
### [basic](basic/)
Shows core SDK usage: creating a Kit instance, sending prompts, overriding the model, subscribing to events (tool calls, streaming), and session management.
```bash
go run ./examples/sdk/basic
```
### [scripting](scripting/)
A minimal script-friendly wrapper that takes a prompt from the command line and prints the response — useful for piping and automation.
```bash
go run ./examples/sdk/scripting "Explain what this repo does"
```
### [crypto-monitor](crypto-monitor/)
A background agent that checks Bitcoin and Ethereum prices every 30 minutes and sends desktop notifications via `notify-send` (dbus). Demonstrates using the SDK for a long-running autonomous task with a single tool.
```bash
go run ./examples/sdk/crypto-monitor
# Override the check interval:
CRYPTO_INTERVAL=5m go run ./examples/sdk/crypto-monitor
```
## Getting Started
```go
import kit "github.com/mark3labs/kit/pkg/kit"
host, err := kit.New(ctx, nil) // uses ~/.kit.yml defaults
defer host.Close()
response, err := host.Prompt(ctx, "Hello!")
```
See the [SDK README](../../pkg/kit/README.md) for the full API reference.
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"time"
kit "github.com/mark3labs/kit/pkg/kit"
)
const systemPrompt = `You are a cryptocurrency price monitor. Your job is to:
1. Fetch the current prices of Bitcoin and Ethereum using bash with curl
2. Send a desktop notification with the results using notify-send
To fetch prices, use this CoinGecko API endpoint (no API key needed):
curl -s 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd&include_24hr_change=true'
To send a desktop notification:
notify-send -i dialog-information "Crypto Prices" "BTC: $XX,XXX (+X.X%)\nETH: $X,XXX (+X.X%)"
Include the 24h percentage change in the notification. Use a green arrow (▲) for
positive changes and a red arrow (▼) for negative. Format prices with commas.
If the API call fails, send a notification about the failure instead.
Always complete both steps: fetch then notify. Be concise — no commentary needed.`
func main() {
interval := 30 * time.Minute
if os.Getenv("CRYPTO_INTERVAL") != "" {
d, err := time.ParseDuration(os.Getenv("CRYPTO_INTERVAL"))
if err == nil {
interval = d
}
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
host, err := kit.New(ctx, &kit.Options{
SystemPrompt: systemPrompt,
Tools: []kit.Tool{kit.NewBashTool()},
NoSession: true,
Quiet: true,
})
if err != nil {
log.Fatalf("Failed to create kit instance: %v", err)
}
defer func() { _ = host.Close() }()
fmt.Printf("Crypto price monitor started (every %s)\n", interval)
fmt.Println("Press Ctrl+C to stop")
// Run immediately on startup, then on each tick.
check(ctx, host)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
check(ctx, host)
case <-ctx.Done():
fmt.Println("\nStopping price monitor")
return
}
}
}
func check(ctx context.Context, host *kit.Kit) {
fmt.Printf("[%s] Checking prices...\n", time.Now().Format("15:04:05"))
// Clear session so each check is independent.
host.ClearSession()
_, err := host.Prompt(ctx, "Fetch current Bitcoin and Ethereum prices and send a desktop notification.")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
}
+73 -76
View File
@@ -1,96 +1,96 @@
module github.com/mark3labs/kit
go 1.26.0
go 1.26.1
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.1
charm.land/fantasy v0.11.1
charm.land/lipgloss/v2 v2.0.0
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/fantasy v0.17.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.2
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/charmbracelet/log v0.4.2
github.com/mark3labs/mcp-go v0.44.1
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b
github.com/clipperhouse/displaywidth v0.11.0
github.com/clipperhouse/uax29/v2 v2.7.0
github.com/coder/acp-go-sdk v0.6.3
github.com/fsnotify/fsnotify v1.9.0
github.com/indaco/herald v0.13.0
github.com/indaco/herald-md v0.3.0
github.com/mark3labs/mcp-go v0.47.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
golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
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/atotto/clipboard v0.1.4 // 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
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.3 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // 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-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260406091427-a791e22d5143 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143 // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
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
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.2 // 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.14 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/kaptinlin/go-i18n v0.2.12 // indirect
github.com/kaptinlin/go-i18n v0.3.1 // 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
github.com/kaptinlin/jsonschema v0.7.7 // indirect
github.com/kaptinlin/messageformat-go v0.4.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.3.0 // indirect
github.com/muesli/mango-pflag v0.2.0 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/openai/openai-go/v2 v2.7.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@@ -99,45 +99,42 @@ 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/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
github.com/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // 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.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.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.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.49.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.2 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.275.0 // indirect
google.golang.org/genai v1.52.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/ansi v0.11.6
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.35.0
)
+154 -150
View File
@@ -1,27 +1,29 @@
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/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=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fantasy v0.17.1 h1:SQzfnyJPDuQWt6e//KKmQmEEXdqHMC0IZz10XwkLcEM=
charm.land/fantasy v0.17.1/go.mod h1:FF5ALCCHETacHJPBqU42CtwMInYQ0ul52fdzIHQMbQk=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
@@ -32,74 +34,78 @@ 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.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/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
github.com/aws/smithy-go v1.24.3/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=
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
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-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA=
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-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/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260406091427-a791e22d5143 h1:zmBor0ftFNqVFp9U59ZoEDRUCIYSGOGSIfGGkNZRufs=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260406091427-a791e22d5143/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-20260305213658-fe36e8c10185 h1:bloHJLweYZeIkBVgi8AF94DrTdx3eoEB57VOpFuFi3U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143 h1:aEppolah2k9c0LzKX2fk5ryuyQ0Lq8kCOjkvMw1b8o4=
github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
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=
@@ -108,6 +114,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
@@ -117,12 +125,16 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7X
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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
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=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
@@ -153,51 +165,50 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
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.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=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
github.com/indaco/herald v0.13.0 h1:+xVG9Fx5NpuWhwku/9IlRL6I009NnX4VUGKvlZHTRxU=
github.com/indaco/herald v0.13.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8M=
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
github.com/kaptinlin/go-i18n v0.3.1 h1:plXi3XQE1aYamFi8TU0K6actODmw2+5FSobmhTkfQ/0=
github.com/kaptinlin/go-i18n v0.3.1/go.mod h1:ZRoAHj7elWYamfbv7wev7Ajch6LOzjtBaq8nWe8HIVk=
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/kaptinlin/jsonschema v0.7.7 h1:41BlQJ9dskH0oE5DSzBUrl/w4JQYIr6N6L0B5GNyDoM=
github.com/kaptinlin/jsonschema v0.7.7/go.mod h1:rKjWfyySHSxAD7Li2ctYkPlOu960igoKBvZ2ADRtd5Q=
github.com/kaptinlin/messageformat-go v0.4.19 h1:A5kuuZ1ybXDQ7kD1aoEWGAOemX7hLsMY0yolgSbgpRI=
github.com/kaptinlin/messageformat-go v0.4.19/go.mod h1:utSDTfiXTxl66OC5RIEuObLH7Ue3YjbA2X86SYMBYWg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
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.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=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.47.1 h1:A9sJJ20mscl/ssLYHjodfaoBmq6uuhMG7pAPNYaQymQ=
github.com/mark3labs/mcp-go v0.47.1/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
@@ -206,24 +217,18 @@ github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8p
github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k=
github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -259,63 +264,62 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
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.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.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
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.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=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.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=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk=
google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
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=
+226 -16
View File
@@ -7,8 +7,11 @@ package acpserver
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"sync/atomic"
"github.com/charmbracelet/log"
@@ -20,7 +23,6 @@ import (
// 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
@@ -111,13 +113,20 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
)
}
// Extract text from prompt content blocks.
promptText := extractPromptText(params.Prompt)
if promptText == "" {
// Extract text and file attachments from prompt content blocks.
promptText, files := extractPromptContent(params.Prompt)
if promptText == "" && len(files) == 0 {
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
// If we have files but no text prompt, add a default prompt
// This is required because the underlying LLM library needs a non-empty prompt
// when there are no previous messages in the conversation.
if promptText == "" && len(files) > 0 {
promptText = "Please analyze the attached file."
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText), "files", len(files))
// Create a cancellable context for this prompt turn.
promptCtx, cancel := context.WithCancel(ctx)
@@ -129,7 +138,13 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
defer unsub()
// Run the prompt through Kit's full turn lifecycle.
_, err := sess.kit.PromptResult(promptCtx, promptText)
// Use PromptResultWithFiles when file attachments are present.
var err error
if len(files) > 0 {
_, err = sess.kit.PromptResultWithFiles(promptCtx, promptText, files)
} else {
_, err = sess.kit.PromptResult(promptCtx, promptText)
}
if err != nil {
if promptCtx.Err() != nil {
return acp.PromptResponse{
@@ -162,6 +177,24 @@ func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (
return acp.SetSessionModeResponse{}, nil
}
// SetSessionModel changes the active model for a session.
func (a *Agent) SetSessionModel(ctx context.Context, params acp.SetSessionModelRequest) (acp.SetSessionModelResponse, error) {
sessionID := string(params.SessionId)
sess, ok := a.registry.get(sessionID)
if !ok {
return acp.SetSessionModelResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
}
modelID := string(params.ModelId)
log.Debug("acp: set_session_model", "session", sessionID, "model", modelID)
if err := sess.kit.SetModel(ctx, modelID); err != nil {
return acp.SetSessionModelResponse{}, fmt.Errorf("set model: %w", err)
}
return acp.SetSessionModelResponse{}, nil
}
// ---------------------------------------------------------------------------
// Event streaming: Kit events → ACP SessionUpdate notifications
// ---------------------------------------------------------------------------
@@ -231,19 +264,196 @@ func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.S
// 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"
// extractPromptContent extracts text and file attachments from ACP content blocks.
// It converts supported content blocks (image, audio, resource) to Kit's LLMFilePart.
func extractPromptContent(blocks []acp.ContentBlock) (string, []kit.LLMFilePart) {
var textParts []string
var files []kit.LLMFilePart
log.Debug("acp: extracting content", "blocks", len(blocks))
for i, block := range blocks {
switch {
// Text content
case block.Text != nil:
log.Debug("acp: content block", "index", i, "type", "text", "len", len(block.Text.Text))
textParts = append(textParts, block.Text.Text)
// Image data (base64)
case block.Image != nil:
mimeType := block.Image.MimeType
if mimeType == "" {
mimeType = "image/png" // Default fallback
}
text += block.Text.Text
log.Debug("acp: content block", "index", i, "type", "image", "mime", mimeType, "data_len", len(block.Image.Data))
if data, err := base64.StdEncoding.DecodeString(block.Image.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "image.png",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode image", "error", err)
}
// Audio data (base64)
case block.Audio != nil:
mimeType := block.Audio.MimeType
if mimeType == "" {
mimeType = "audio/wav" // Default fallback
}
log.Debug("acp: content block", "index", i, "type", "audio", "mime", mimeType)
if data, err := base64.StdEncoding.DecodeString(block.Audio.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "audio.wav",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode audio", "error", err)
}
// Embedded resource (text or binary file content)
case block.Resource != nil:
log.Debug("acp: content block", "index", i, "type", "resource")
res := block.Resource.Resource
// Text resource - append as text content with file reference
if res.TextResourceContents != nil {
uri := res.TextResourceContents.Uri
content := res.TextResourceContents.Text
mimeType := "text/plain"
if res.TextResourceContents.MimeType != nil {
mimeType = *res.TextResourceContents.MimeType
}
log.Debug("acp: text resource", "uri", uri, "mime", mimeType, "len", len(content))
// Text files are included as formatted text, NOT as FilePart
// FilePart is for binary files (images, audio, PDFs) only
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, content))
}
// Binary resource (base64 blob) - these become FilePart
if res.BlobResourceContents != nil {
uri := res.BlobResourceContents.Uri
mimeType := "application/octet-stream"
if res.BlobResourceContents.MimeType != nil {
mimeType = *res.BlobResourceContents.MimeType
}
log.Debug("acp: binary resource", "uri", uri, "mime", mimeType, "blob_len", len(res.BlobResourceContents.Blob))
if data, err := base64.StdEncoding.DecodeString(res.BlobResourceContents.Blob); err == nil {
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode binary resource", "error", err)
}
}
// Resource link (file reference without embedded content)
case block.ResourceLink != nil:
uri := block.ResourceLink.Uri
name := block.ResourceLink.Name
log.Debug("acp: content block", "index", i, "type", "resource_link", "uri", uri, "name", name)
// For resource links, we'll try to read the file from disk
// This requires the file URI to be accessible (file:// scheme)
if content, err := readResourceFromURI(uri); err == nil {
// Detect if it's a text file or binary file
mimeType := "text/plain"
if block.ResourceLink.MimeType != nil {
mimeType = *block.ResourceLink.MimeType
}
log.Debug("acp: resource link loaded", "uri", uri, "mime", mimeType, "size", len(content))
// Only create FilePart for binary files (images, audio, PDFs, etc.)
// Text files are included as formatted text in the message
if isTextMimeType(mimeType) || looksLikeText(content) {
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, string(content)))
} else {
// Binary file - create FilePart for models that support it
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: content,
MediaType: mimeType,
})
}
} else {
// If we can't read it, include as a text reference
log.Debug("acp: resource link failed to load", "uri", uri, "error", err)
textParts = append(textParts, fmt.Sprintf("[Referenced file: %s]", uri))
}
default:
log.Debug("acp: content block", "index", i, "type", "unknown/unhandled")
}
}
return text
// Debug log the extracted content
for i, f := range files {
log.Debug("acp: extracted file", "index", i, "filename", f.Filename, "mime", f.MediaType, "size", len(f.Data))
}
return strings.Join(textParts, "\n"), files
}
// isTextMimeType returns true if the MIME type indicates text content.
func isTextMimeType(mimeType string) bool {
return strings.HasPrefix(mimeType, "text/") ||
mimeType == "application/json" ||
mimeType == "application/xml" ||
mimeType == "application/javascript" ||
mimeType == "application/typescript" ||
mimeType == "application/x-sh" ||
mimeType == "application/x-python" ||
mimeType == "application/x-yaml" ||
mimeType == "application/x-toml"
}
// looksLikeText checks if the content appears to be text (not binary).
// It samples the first 512 bytes and checks for null bytes or high
// concentration of non-printable characters.
func looksLikeText(data []byte) bool {
if len(data) == 0 {
return true
}
// Check first 512 bytes (or less if file is smaller)
sampleSize := min(len(data), 512)
sample := data[:sampleSize]
// Count non-printable characters
nonPrintable := 0
for _, b := range sample {
// Null byte indicates binary
if b == 0 {
return false
}
// Count control characters (except common whitespace)
if b < 32 && b != '\n' && b != '\r' && b != '\t' {
nonPrintable++
}
}
// If more than 30% non-printable, consider it binary
return float64(nonPrintable)/float64(sampleSize) < 0.3
}
// extractFilenameFromURI extracts a filename from a file URI or path.
func extractFilenameFromURI(uri string) string {
// Handle file:// URIs
uri = strings.TrimPrefix(uri, "file://")
// Extract basename
if idx := strings.LastIndex(uri, "/"); idx >= 0 {
return uri[idx+1:]
}
return uri
}
// readResourceFromURI attempts to read file content from a file:// URI.
func readResourceFromURI(uri string) ([]byte, error) {
if !strings.HasPrefix(uri, "file://") {
return nil, fmt.Errorf("unsupported URI scheme: %s", uri)
}
path := uri[7:] // Remove file:// prefix
return os.ReadFile(path)
}
// parseToolArgs attempts to parse a JSON tool args string into a map for
+18 -18
View File
@@ -62,8 +62,8 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
// 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{
if kitInstance.Extensions().HasExtensions() {
kitInstance.Extensions().SetContext(extensions.Context{
SessionID: sessionID,
CWD: cwd,
Model: kitInstance.GetModelString(),
@@ -121,31 +121,31 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
MessageCount: s.MessageCount,
}
},
GetMessages: func() []extensions.SessionMessage { return kitInstance.GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionFilePath() },
GetMessages: func() []extensions.SessionMessage { return kitInstance.Extensions().GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionPath() },
AppendEntry: func(entryType, data string) (string, error) {
return kitInstance.AppendExtensionEntry(entryType, data)
return kitInstance.Extensions().AppendEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.GetExtensionEntries(entryType)
return kitInstance.Extensions().GetEntries(entryType)
},
// Options, model, and tool management.
GetOption: func(name string) string { return kitInstance.GetExtensionOption(name) },
SetOption: func(name, value string) { kitInstance.SetExtensionOption(name, value) },
GetOption: func(name string) string { return kitInstance.Extensions().GetOption(name) },
SetOption: func(name, value string) { kitInstance.Extensions().SetOption(name, value) },
SetModel: func(modelString string) error {
previousModel := kitInstance.GetExtensionContext().Model
previousModel := kitInstance.Extensions().GetContext().Model
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
return err
}
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.EmitModelChange(modelString, previousModel, "extension")
kitInstance.Extensions().UpdateContextModel(modelString)
kitInstance.Extensions().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) },
EmitCustomEvent: func(name, data string) { kitInstance.Extensions().EmitCustomEvent(name, data) },
GetAllTools: func() []extensions.ToolInfo { return kitInstance.Extensions().GetToolInfos() },
SetActiveTools: func(names []string) { kitInstance.Extensions().SetActiveTools(names) },
// LLM completions and subagents.
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
@@ -173,7 +173,7 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: result.Error,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
@@ -188,15 +188,15 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
// Render — fall back to logging.
RenderMessage: func(name, content string) {
renderer := kitInstance.GetExtensionMessageRenderer(name)
renderer := kitInstance.Extensions().GetMessageRenderer(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() },
ReloadExtensions: func() error { return kitInstance.Extensions().Reload() },
})
kitInstance.EmitSessionStart()
kitInstance.Extensions().EmitSessionStart()
}
sess := &acpSession{
+473 -136
View File
@@ -25,19 +25,39 @@ type AgentConfig struct {
StreamingEnabled bool
DebugLogger tools.DebugLogger
// AuthHandler handles OAuth authorization for remote MCP servers.
// When set, remote transports are configured with OAuth support.
// If nil, remote MCP servers that require OAuth will fail to connect.
AuthHandler tools.MCPAuthHandler
// TokenStoreFactory, if non-nil, creates a custom token store for each
// remote MCP server's OAuth tokens. When nil, the default file-based
// token store is used.
TokenStoreFactory tools.TokenStoreFactory
// CoreTools overrides the default core tool set. If empty, core.AllTools()
// is used. This allows SDK users to provide a custom tool set (e.g.
// CodingTools or tools with a custom WorkDir).
CoreTools []fantasy.AgentTool
// DisableCoreTools, when true, prevents loading any core tools.
// If both DisableCoreTools is true and CoreTools is empty, the agent
// will have no tools (useful for simple chat completions).
DisableCoreTools bool
// ToolWrapper is an optional function that wraps the combined tool list
// before it is passed to the Fantasy agent. Used by the extensions system
// before it is passed to the LLM agent. Used by the extensions system
// to intercept tool calls/results.
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
// ExtraTools are additional tools to include alongside core and MCP tools.
// Used by extensions to register custom tools.
ExtraTools []fantasy.AgentTool
// OnMCPServerLoaded, if non-nil, is called when each MCP server finishes
// loading (successfully or with error). The callback receives the server
// name, tool count, and any error. Called from the background goroutine.
OnMCPServerLoaded func(serverName string, toolCount int, err error)
}
// ToolCallHandler is a function type for handling tool calls as they happen.
@@ -63,10 +83,38 @@ 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.
// ReasoningCompleteHandler is a function type for handling reasoning/thinking completion.
// Called when the last reasoning token has been processed, before text streaming starts.
type ReasoningCompleteHandler func()
// ToolOutputHandler is a function type for handling streaming tool output chunks.
// Used by tools like bash to stream output as it arrives rather than waiting
// for the command to complete. The isStderr flag indicates if the chunk
// contains stderr output.
// Note: This is an alias for core.ToolOutputCallback to avoid import cycles.
type ToolOutputHandler = core.ToolOutputCallback
// StepMessagesHandler is a function type for persisting messages after each
// complete step in a multi-step agent turn. The handler receives the messages
// produced by the step (typically an assistant message with tool calls followed
// by a tool-role message with results, or a final assistant message with text).
// This enables incremental session persistence so that progress is saved as
// it happens rather than only at the end of the turn.
type StepMessagesHandler func(stepMessages []fantasy.Message)
// StepUsageHandler is a function type for handling token usage after each
// complete step in a multi-step agent turn. This enables real-time cost
// tracking during long-running tool-calling conversations.
type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64)
// Agent represents an AI agent with core tool integration using the LLM library.
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
// fantasy.AgentTool implementations — no MCP layer, no serialization overhead.
// AgentTool implementations — no MCP layer, no serialization overhead.
// Additional tools from external MCP servers can be loaded alongside core tools.
//
// When MCP servers are configured, tool loading happens in the background so the
// agent (and UI) can start immediately. The first LLM call automatically waits
// for MCP tools to finish loading before proceeding.
type Agent struct {
toolManager *tools.MCPToolManager
fantasyAgent fantasy.Agent
@@ -80,6 +128,18 @@ type Agent struct {
coreTools []fantasy.AgentTool
extraTools []fantasy.AgentTool
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
// providerOptions and modelConfig are stored for rebuilding the fantasy
// agent when MCP tools arrive asynchronously or on SetModel.
providerOptions fantasy.ProviderOptions
skipMaxOutputTokens bool
modelConfig *models.ProviderConfig
// mcpReady is closed when background MCP tool loading completes (success
// or failure). nil when no MCP servers are configured.
mcpReady chan struct{}
// mcpErr holds any error from background MCP loading.
mcpErr error
}
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
@@ -88,54 +148,50 @@ type GenerateWithLoopResult struct {
FinalResponse *fantasy.Response
// ConversationMessages contains all messages in the conversation including tool calls and results
ConversationMessages []fantasy.Message
// Messages contains the conversation as custom content blocks (crush-style)
// Messages contains the conversation as custom content blocks
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
// PersistedMessageCount is the number of new messages (beyond the original
// input) that were already persisted incrementally via OnStepMessages during
// generation. The caller should skip these when doing post-generation
// persistence to avoid duplicates.
PersistedMessageCount int
}
// NewAgent creates a new Agent with core tools and optional MCP tool integration.
// Core tools (bash, read, write, edit, grep, find, ls) are always registered.
// External MCP tools are loaded from the config if any MCP servers are configured.
// If MCP servers are configured, their tools are loaded in the background —
// the agent returns immediately and is usable with core tools only. The first
// LLM call (GenerateWithLoop) automatically waits for MCP tools to finish
// loading and rebuilds the agent with the full tool set.
func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
// Create the LLM provider via fantasy
// Create the LLM provider
providerResult, err := models.CreateProvider(ctx, agentConfig.ModelConfig)
if err != nil {
return nil, fmt.Errorf("failed to create model provider: %v", err)
}
// Register core tools (direct fantasy implementations, no MCP overhead).
// Register core tools (direct AgentTool implementations, no MCP overhead).
// Use caller-provided tools if set, otherwise default to all core tools.
coreTools := agentConfig.CoreTools
if len(coreTools) == 0 {
// DisableCoreTools allows explicitly having zero tools (for chat-only mode).
var coreTools []fantasy.AgentTool
if agentConfig.DisableCoreTools && len(agentConfig.CoreTools) == 0 {
// Explicitly zero tools - chat-only mode
coreTools = nil
} else if len(agentConfig.CoreTools) > 0 {
// Custom tools provided - use them
coreTools = agentConfig.CoreTools
} else {
// Default: load all core tools
coreTools = core.AllTools()
}
// Build the combined tool list: core tools + any external MCP tools
// Build the initial tool list: core tools + extension tools (no MCP yet).
allTools := make([]fantasy.AgentTool, len(coreTools))
copy(allTools, coreTools)
// Load external MCP tools if configured
var toolManager *tools.MCPToolManager
if agentConfig.MCPConfig != nil && len(agentConfig.MCPConfig.MCPServers) > 0 {
toolManager = tools.NewMCPToolManager()
toolManager.SetModel(providerResult.Model)
if agentConfig.DebugLogger != nil {
toolManager.SetDebugLogger(agentConfig.DebugLogger)
}
if err := toolManager.LoadTools(ctx, agentConfig.MCPConfig); err != nil {
// MCP tool loading failures are non-fatal; core tools still work
fmt.Printf("Warning: Failed to load MCP tools: %v\n", err)
} else {
mcpTools := toolManager.GetTools()
allTools = append(allTools, mcpTools...)
}
}
// Append any extra tools provided by extensions.
if len(agentConfig.ExtraTools) > 0 {
allTools = append(allTools, agentConfig.ExtraTools...)
@@ -146,7 +202,148 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
allTools = agentConfig.ToolWrapper(allTools)
}
// Build fantasy agent options
// Build agent options
agentOpts := buildAgentOptions(agentConfig, providerResult, allTools)
// Create the agent
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
// Determine provider type from model string
providerType := "default"
if agentConfig.ModelConfig != nil && agentConfig.ModelConfig.ModelString != "" {
if p, _, err := models.ParseModelString(agentConfig.ModelConfig.ModelString); err == nil {
providerType = p
}
}
a := &Agent{
fantasyAgent: fantasyAgent,
model: providerResult.Model,
providerCloser: providerResult.Closer,
maxSteps: agentConfig.MaxSteps,
systemPrompt: agentConfig.SystemPrompt,
loadingMessage: providerResult.Message,
providerType: providerType,
streamingEnabled: agentConfig.StreamingEnabled,
coreTools: coreTools,
extraTools: agentConfig.ExtraTools,
toolWrapper: agentConfig.ToolWrapper,
providerOptions: providerResult.ProviderOptions,
skipMaxOutputTokens: providerResult.SkipMaxOutputTokens,
modelConfig: agentConfig.ModelConfig,
}
// Start MCP tool loading in the background if servers are configured.
// The mcpReady channel is closed when loading completes (success or failure).
if agentConfig.MCPConfig != nil && len(agentConfig.MCPConfig.MCPServers) > 0 {
toolManager := tools.NewMCPToolManager()
toolManager.SetModel(providerResult.Model)
if agentConfig.AuthHandler != nil {
toolManager.SetAuthHandler(agentConfig.AuthHandler)
}
if agentConfig.TokenStoreFactory != nil {
toolManager.SetTokenStoreFactory(agentConfig.TokenStoreFactory)
}
if agentConfig.DebugLogger != nil {
toolManager.SetDebugLogger(agentConfig.DebugLogger)
}
// Set per-server loaded callback if provided.
if agentConfig.OnMCPServerLoaded != nil {
toolManager.SetOnServerLoaded(agentConfig.OnMCPServerLoaded)
}
a.toolManager = toolManager
a.mcpReady = make(chan struct{})
go func() {
defer close(a.mcpReady)
if err := toolManager.LoadTools(ctx, agentConfig.MCPConfig); err != nil {
a.mcpErr = err
fmt.Printf("Warning: Failed to load MCP tools: %v\n", err)
}
}()
}
return a, nil
}
// WaitForMCPTools blocks until background MCP tool loading completes.
// Returns nil if no MCP servers are configured or if loading succeeded.
// Returns the loading error if all servers failed. Safe to call multiple times.
func (a *Agent) WaitForMCPTools() error {
if a.mcpReady == nil {
return nil
}
<-a.mcpReady
return a.mcpErr
}
// MCPToolsReady returns true if MCP tool loading has completed (or was never
// started). This is a non-blocking check useful for UI status display.
func (a *Agent) MCPToolsReady() bool {
if a.mcpReady == nil {
return true
}
select {
case <-a.mcpReady:
return true
default:
return false
}
}
// ensureMCPTools waits for MCP tools to load and rebuilds the fantasy agent
// with the full tool set. Called lazily before the first LLM call.
// This is idempotent — subsequent calls after the first rebuild are no-ops.
func (a *Agent) ensureMCPTools() {
if a.mcpReady == nil {
return
}
<-a.mcpReady
// If there are MCP tools, rebuild the fantasy agent to include them.
if a.toolManager != nil && len(a.toolManager.GetTools()) > 0 {
a.rebuildFantasyAgent()
}
// Nil out the channel so future calls are instant no-ops and we
// don't rebuild again.
a.mcpReady = nil
}
// rebuildFantasyAgent reconstructs the fantasy agent with the current full
// tool set (core + MCP + extension tools). Used after MCP tools arrive
// asynchronously and by SetModel.
func (a *Agent) rebuildFantasyAgent() {
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)
}
providerResult := &models.ProviderResult{
Model: a.model,
ProviderOptions: a.providerOptions,
SkipMaxOutputTokens: a.skipMaxOutputTokens,
}
agentOpts := buildAgentOptions(&AgentConfig{
ModelConfig: a.modelConfig,
SystemPrompt: a.systemPrompt,
MaxSteps: a.maxSteps,
}, providerResult, allTools)
a.fantasyAgent = fantasy.NewAgent(a.model, agentOpts...)
}
// buildAgentOptions constructs the fantasy.AgentOption slice from config,
// provider result, and the combined tool list. Shared by NewAgent,
// rebuildFantasyAgent, and SetModel.
func buildAgentOptions(agentConfig *AgentConfig, providerResult *models.ProviderResult, allTools []fantasy.AgentTool) []fantasy.AgentOption {
var agentOpts []fantasy.AgentOption
if agentConfig.SystemPrompt != "" {
@@ -171,7 +368,8 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
// Pass generation parameters when available.
if agentConfig.ModelConfig != nil {
if agentConfig.ModelConfig.MaxTokens > 0 {
// Skip max_output_tokens for providers that don't support it (e.g., Codex OAuth)
if agentConfig.ModelConfig.MaxTokens > 0 && !providerResult.SkipMaxOutputTokens {
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(agentConfig.ModelConfig.MaxTokens)))
}
if agentConfig.ModelConfig.Temperature != nil {
@@ -183,33 +381,15 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
if agentConfig.ModelConfig.TopK != nil {
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*agentConfig.ModelConfig.TopK)))
}
}
// Create the fantasy agent
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
// Determine provider type from model string
providerType := "default"
if agentConfig.ModelConfig != nil && agentConfig.ModelConfig.ModelString != "" {
if p, _, err := models.ParseModelString(agentConfig.ModelConfig.ModelString); err == nil {
providerType = p
if agentConfig.ModelConfig.FrequencyPenalty != nil {
agentOpts = append(agentOpts, fantasy.WithFrequencyPenalty(float64(*agentConfig.ModelConfig.FrequencyPenalty)))
}
if agentConfig.ModelConfig.PresencePenalty != nil {
agentOpts = append(agentOpts, fantasy.WithPresencePenalty(float64(*agentConfig.ModelConfig.PresencePenalty)))
}
}
return &Agent{
toolManager: toolManager,
fantasyAgent: fantasyAgent,
model: providerResult.Model,
providerCloser: providerResult.Closer,
maxSteps: agentConfig.MaxSteps,
systemPrompt: agentConfig.SystemPrompt,
loadingMessage: providerResult.Message,
providerType: providerType,
streamingEnabled: agentConfig.StreamingEnabled,
coreTools: coreTools,
extraTools: agentConfig.ExtraTools,
toolWrapper: agentConfig.ToolWrapper,
}, nil
return agentOpts
}
// GenerateWithLoop processes messages with a custom loop that displays tool calls in real-time.
@@ -218,39 +398,66 @@ 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, nil)
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil)
}
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
// Fantasy handles the tool call loop internally. We map fantasy's rich callback system
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
// The agent handles the tool call loop internally. We map the rich callback system
// to kit's existing callback interface for UI integration.
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
onStreamingResponse StreamingResponseHandler,
onReasoningDelta ReasoningDeltaHandler,
onReasoningComplete ReasoningCompleteHandler,
onToolOutput ToolOutputHandler,
onStepMessages StepMessagesHandler,
onStepUsage StepUsageHandler,
) (*GenerateWithLoopResult, error) {
// Fantasy requires the current user input as Prompt, with prior messages as history.
// Wait for background MCP tool loading to complete and rebuild the
// fantasy agent with the full tool set. This is a no-op when no MCP
// servers are configured or tools have already been integrated.
a.ensureMCPTools()
// Inject tool output handler into context for use by core tools (e.g., bash).
if onToolOutput != nil {
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
}
// The agent requires the current user input as Prompt, with prior messages as history.
// 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.
// field so the agent includes them in the API request.
prompt, files, history := splitPromptAndHistory(messages)
// Track current tool call info for callbacks
var currentToolName string
// Apply message-level cache control for Anthropic models.
// This avoids type conflicts with provider-level options.
history = applyCacheControlToMessages(history)
// Track current tool call args for callbacks
var currentToolArgs string
// Use the streaming path when streaming is enabled OR when any callbacks are
// provided. Fantasy only exposes tool/step callbacks on AgentStreamCall, so
// provided. The agent only exposes tool/step callbacks on AgentStreamCall, so
// 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 || onReasoningDelta != nil
if a.streamingEnabled || hasCallbacks {
// Use fantasy's streaming agent
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
// Track completed step messages so we can return partial results
// on cancellation. The agent's Stream() discards accumulated steps
// when it returns an error, but the OnStepFinish callback fires
// for every step that completed before the error occurred.
var completedStepMessages []fantasy.Message
// persistedCount tracks how many new messages (beyond the original
// input) were persisted incrementally via onStepMessages, so the
// caller can skip them during post-generation persistence.
var persistedCount int
// Use the streaming agent
streamCall := fantasy.AgentStreamCall{
Prompt: prompt,
Files: files,
Messages: history,
@@ -266,6 +473,17 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
return nil
},
// Reasoning/thinking complete callback
OnReasoningEnd: func(id string, _ fantasy.ReasoningContent) error {
if ctx.Err() != nil {
return ctx.Err()
}
if onReasoningComplete != nil {
onReasoningComplete()
}
return nil
},
// Text streaming callback
OnTextDelta: func(id, text string) error {
if ctx.Err() != nil {
@@ -282,7 +500,6 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
currentToolName = tc.ToolName
currentToolArgs = tc.Input
// Notify about the tool call
@@ -319,6 +536,17 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Step callbacks for content that accompanies tool calls
OnStepFinish: func(step fantasy.StepResult) error {
// Accumulate messages from completed steps so they can be
// persisted even if a later step is cancelled.
completedStepMessages = append(completedStepMessages, step.Messages...)
// Persist step messages incrementally so progress is saved
// as it happens rather than only at the end of the turn.
if onStepMessages != nil && len(step.Messages) > 0 {
onStepMessages(step.Messages)
persistedCount += len(step.Messages)
}
if ctx.Err() != nil {
return ctx.Err()
}
@@ -328,20 +556,94 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if text != "" && len(toolCalls) > 0 && onToolCallContent != nil {
onToolCallContent(text)
}
// Emit step usage for real-time cost tracking
if onStepUsage != nil {
onStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
step.Usage.CacheReadTokens, step.Usage.CacheCreationTokens)
}
return nil
},
})
}
// If a steer channel is attached to the context, wire up a
// PrepareStep function that drains the channel between steps
// and injects pending steer messages as user messages before
// the next LLM call. This enables graceful mid-turn steering
// without cancelling in-progress tool execution.
if steerCh := steerChFromContext(ctx); steerCh != nil {
onConsumed := steerConsumedFromContext(ctx)
streamCall.PrepareStep = func(
stepCtx context.Context,
opts fantasy.PrepareStepFunctionOptions,
) (context.Context, fantasy.PrepareStepResult, error) {
// Drain all pending steer messages (non-blocking).
var steered []SteerMessage
for {
select {
case msg := <-steerCh:
steered = append(steered, msg)
default:
goto done
}
}
done:
result := fantasy.PrepareStepResult{
Model: opts.Model,
Messages: opts.Messages,
}
if len(steered) > 0 {
// Inject each steer message as a user message so the
// LLM sees the redirection on the next step.
for _, sm := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(sm.Text, sm.Files...))
}
// Notify that steer messages were consumed.
if onConsumed != nil {
onConsumed(len(steered))
}
}
// Apply message-level cache control for Anthropic models.
// This avoids type conflicts with provider-level options.
result.Messages = applyCacheControlToMessages(result.Messages)
return stepCtx, result, nil
}
}
result, err := a.fantasyAgent.Stream(ctx, streamCall)
if err != nil {
// On cancellation (or any error), return a partial result
// containing messages from completed steps so the caller can
// persist tool calls and results that finished before the
// cancellation. The original input messages are included so
// the caller sees the full conversation up to the point of
// cancellation.
if len(completedStepMessages) > 0 {
partialMessages := make([]fantasy.Message, 0, len(messages)+len(completedStepMessages))
partialMessages = append(partialMessages, messages...)
partialMessages = append(partialMessages, completedStepMessages...)
return &GenerateWithLoopResult{
ConversationMessages: partialMessages,
PersistedMessageCount: persistedCount,
}, err
}
return nil, err
}
// Fire the response callback for callers that use it (e.g. non-streaming
// callers that still want the final response notification).
if onResponse != nil && result.Response.Content.Text() != "" {
// Fire the response callback so callers (e.g. the TUI) can reset
// streaming state. This must fire even when the response text is
// empty (e.g. reasoning-only responses) so the UI properly resets
// the stream component and avoids duplicate content on the next
// flush.
if onResponse != nil {
onResponse(result.Response.Content.Text())
}
return convertAgentResult(result, messages), nil
r := convertAgentResult(result, messages)
r.PersistedMessageCount = persistedCount
return r, nil
}
// Non-streaming path with no callbacks — use the simpler Generate call.
@@ -354,18 +656,17 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
return nil, err
}
// For non-streaming, fire the response callback with the final text
if onResponse != nil && result.Response.Content.Text() != "" {
// For non-streaming, fire the response callback so callers can reset
// streaming state (see streaming path comment above).
if onResponse != nil {
onResponse(result.Response.Content.Text())
}
_ = currentToolName // satisfy compiler for non-streaming path
return convertAgentResult(result, messages), nil
}
// splitPromptAndHistory extracts the last user message as the prompt string,
// and returns everything before it as conversation history. Fantasy's agent
// and returns everything before it as conversation history. The agent's
// requires the current turn's input as Prompt (string), with prior messages
// passed separately as Messages (history).
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePart, []fantasy.Message) {
@@ -408,8 +709,8 @@ func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePa
return "", nil, messages
}
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
// It builds both the legacy fantasy.Message slice and the new custom content blocks.
// convertAgentResult converts an AgentResult to our GenerateWithLoopResult.
// It builds both the message slice and the new custom content blocks.
func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.Message) *GenerateWithLoopResult {
// Collect all conversation messages: original + all step messages
var allFantasyMessages []fantasy.Message
@@ -422,7 +723,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
// Convert to custom content blocks
var allMessages []message.Message
for _, fm := range allFantasyMessages {
allMessages = append(allMessages, message.FromFantasyMessage(fm))
allMessages = append(allMessages, message.FromLLMMessage(fm))
}
return &GenerateWithLoopResult{
@@ -434,7 +735,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
}
}
// extractToolResultText extracts the text and error status from a fantasy ToolResultContent.
// extractToolResultText extracts the text and error status from a ToolResultContent.
// For core tools, the result is already clean text (no MCP JSON wrapping).
// For MCP tools, it unwraps the MCP content structure.
func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
@@ -447,7 +748,7 @@ func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
return errResult.Error.Error(), true
}
// Get text directly from the Fantasy result type.
// Get text directly from the result type.
if textResult, ok := tr.Result.(fantasy.ToolResultOutputContentText); ok {
// Try to unwrap MCP JSON structure (for external MCP tools).
// Core tools return plain text, so this is a no-op for them.
@@ -525,6 +826,67 @@ func (a *Agent) GetExtensionToolCount() int {
return len(a.extraTools)
}
// SetExtraTools replaces the agent's extra tools (e.g. extension-registered
// tools) and rebuilds the internal agent with the updated tool list. The
// model, system prompt, and all other configuration are preserved.
func (a *Agent) SetExtraTools(extraTools []fantasy.AgentTool) {
a.extraTools = extraTools
a.rebuildFantasyAgent()
}
// AddMCPServer connects to a new MCP server at runtime and makes its tools
// available to the agent. Returns the number of tools loaded.
// If the agent has no tool manager (no MCP servers were configured at init),
// one is created automatically.
func (a *Agent) AddMCPServer(ctx context.Context, name string, cfg config.MCPServerConfig) (int, error) {
// Ensure MCP tools from initial load are settled first.
a.ensureMCPTools()
if a.toolManager == nil {
a.toolManager = tools.NewMCPToolManager()
a.toolManager.SetModel(a.model)
a.toolManager.SetOnToolsChanged(func() {
a.rebuildFantasyAgent()
})
}
count, err := a.toolManager.AddServer(ctx, name, cfg)
if err != nil {
return 0, err
}
// AddServer's onToolsChanged callback triggers rebuildFantasyAgent,
// but only if it was wired. Ensure rebuild happens regardless.
a.rebuildFantasyAgent()
return count, nil
}
// RemoveMCPServer disconnects an MCP server and removes its tools from the agent.
func (a *Agent) RemoveMCPServer(name string) error {
if a.toolManager == nil {
return fmt.Errorf("no MCP servers loaded")
}
// Ensure MCP tools from initial load are settled first.
a.ensureMCPTools()
err := a.toolManager.RemoveServer(name)
if err != nil {
return err
}
// RemoveServer's onToolsChanged callback triggers rebuildFantasyAgent,
// but ensure rebuild happens regardless.
a.rebuildFantasyAgent()
return nil
}
// GetMCPToolManager returns the underlying MCP tool manager.
// Returns nil if no MCP servers have been configured.
func (a *Agent) GetMCPToolManager() *tools.MCPToolManager {
return a.toolManager
}
// GetLoadingMessage returns the loading message from provider creation.
func (a *Agent) GetLoadingMessage() string {
return a.loadingMessage
@@ -538,63 +900,20 @@ 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.
// SetModel swaps the agent's LLM provider to a new model. The existing tools
// and configuration are preserved. When the new model's ProviderConfig carries
// a system prompt (from per-model settings), it replaces the agent's stored
// prompt so the rebuilt fantasy agent uses it. The old provider is closed if
// it has a closer.
func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error {
// Ensure MCP tools are loaded before rebuilding (SetModel may be called
// before the first LLM call).
a.ensureMCPTools()
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()
@@ -606,9 +925,18 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
}
// Swap fields.
a.fantasyAgent = newFantasyAgent
a.model = providerResult.Model
a.providerCloser = providerResult.Closer
a.providerOptions = providerResult.ProviderOptions
a.skipMaxOutputTokens = providerResult.SkipMaxOutputTokens
a.modelConfig = config
// Update system prompt when the config carries one (from per-model
// settings or the global config). This allows model-specific system
// prompts to take effect on model switch.
if config.SystemPrompt != "" {
a.systemPrompt = config.SystemPrompt
}
// Update provider type.
if config.ModelString != "" {
@@ -617,16 +945,25 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
}
}
// Rebuild the fantasy agent with the new model and current tool set.
a.rebuildFantasyAgent()
return nil
}
// GetModel returns the underlying fantasy LanguageModel.
// GetModel returns the underlying LanguageModel.
func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
}
// Close closes the agent and cleans up resources.
// If MCP tools are still loading in the background, Close waits for them
// to finish before closing connections to avoid resource leaks.
func (a *Agent) Close() error {
// Wait for background MCP loading to finish before closing connections.
if a.mcpReady != nil {
<-a.mcpReady
}
var toolErr error
if a.toolManager != nil {
toolErr = a.toolManager.Close()
+242
View File
@@ -0,0 +1,242 @@
package agent
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/config"
)
// mockModel is a minimal LanguageModel that satisfies the interface
// without making real API calls. Used to test tool management wiring.
type mockModel struct{}
func (m *mockModel) Generate(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
return &fantasy.Response{}, nil
}
func (m *mockModel) Stream(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return nil, nil
}
func (m *mockModel) GenerateObject(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
return &fantasy.ObjectResponse{}, nil
}
func (m *mockModel) StreamObject(_ context.Context, _ fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
return nil, nil
}
func (m *mockModel) Provider() string { return "mock" }
func (m *mockModel) Model() string { return "mock-model" }
// testdataDir returns the absolute path to the tools testdata directory.
func testdataDir(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot determine test file path")
}
return filepath.Join(filepath.Dir(file), "..", "tools", "testdata")
}
// echoServerConfig returns an MCPServerConfig for the test echo MCP server.
func echoServerConfig(t *testing.T) config.MCPServerConfig {
t.Helper()
script := filepath.Join(testdataDir(t), "echo_server.py")
if _, err := os.Stat(script); err != nil {
t.Skipf("echo_server.py not found: %v", err)
}
return config.MCPServerConfig{
Command: []string{"python3", script},
}
}
// newTestAgent creates a minimal Agent with a mock model and no core tools,
// suitable for testing MCP server management without an API key.
func newTestAgent() *Agent {
model := &mockModel{}
a := &Agent{
model: model,
coreTools: nil,
extraTools: nil,
maxSteps: 10,
systemPrompt: "test",
fantasyAgent: fantasy.NewAgent(model),
}
return a
}
func TestAgent_AddMCPServer(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
// Initially no MCP tools.
if a.GetMCPToolCount() != 0 {
t.Fatalf("Expected 0 MCP tools initially, got %d", a.GetMCPToolCount())
}
// Add a server.
count, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("AddMCPServer failed: %v", err)
}
if count != 2 {
t.Errorf("Expected 2 tools, got %d", count)
}
// Verify tools are in the agent's tool list.
if a.GetMCPToolCount() != 2 {
t.Errorf("Expected 2 MCP tools, got %d", a.GetMCPToolCount())
}
allTools := a.GetTools()
toolNames := make(map[string]bool)
for _, tool := range allTools {
toolNames[tool.Info().Name] = true
}
if !toolNames["echo__echo"] {
t.Error("Expected tool 'echo__echo' in agent tools")
}
if !toolNames["echo__greet"] {
t.Error("Expected tool 'echo__greet' in agent tools")
}
// Verify loaded server names.
names := a.GetLoadedServerNames()
found := false
for _, n := range names {
if n == "echo" {
found = true
}
}
if !found {
t.Errorf("Expected 'echo' in loaded server names: %v", names)
}
}
func TestAgent_RemoveMCPServer(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
// Add then remove.
_, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("AddMCPServer failed: %v", err)
}
err = a.RemoveMCPServer("echo")
if err != nil {
t.Fatalf("RemoveMCPServer failed: %v", err)
}
// Verify tools removed.
if a.GetMCPToolCount() != 0 {
t.Errorf("Expected 0 MCP tools after removal, got %d", a.GetMCPToolCount())
}
// Verify agent's tool list has no MCP tools.
for _, tool := range a.GetTools() {
if strings.Contains(tool.Info().Name, "echo__") {
t.Errorf("Found leftover tool after removal: %s", tool.Info().Name)
}
}
}
func TestAgent_RemoveMCPServer_NoToolManager(t *testing.T) {
a := newTestAgent()
defer func() { _ = a.Close() }()
err := a.RemoveMCPServer("nonexistent")
if err == nil {
t.Fatal("Expected error when no tool manager exists")
}
if !strings.Contains(err.Error(), "no MCP servers loaded") {
t.Errorf("Expected 'no MCP servers loaded' error, got: %v", err)
}
}
func TestAgent_AddMCPServer_CreatesToolManager(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
// Initially no tool manager.
if a.GetMCPToolManager() != nil {
t.Fatal("Expected nil tool manager initially")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
_, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("AddMCPServer failed: %v", err)
}
// Tool manager should now exist.
if a.GetMCPToolManager() == nil {
t.Fatal("Expected tool manager to be created by AddMCPServer")
}
}
func TestAgent_AddRemoveAdd_MCP(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
// Add → Remove → Add cycle.
_, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("First add failed: %v", err)
}
err = a.RemoveMCPServer("echo")
if err != nil {
t.Fatalf("Remove failed: %v", err)
}
count, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("Re-add failed: %v", err)
}
if count != 2 {
t.Errorf("Expected 2 tools on re-add, got %d", count)
}
if a.GetMCPToolCount() != 2 {
t.Errorf("Expected 2 MCP tools after re-add, got %d", a.GetMCPToolCount())
}
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
)
// cacheControlOptions returns provider options for Anthropic cache control.
// This is used at the message level to avoid type conflicts with provider-level options.
func cacheControlOptions() fantasy.ProviderOptions {
return anthropic.NewProviderCacheControlOptions(&anthropic.ProviderCacheControlOptions{
CacheControl: anthropic.CacheControl{
Type: "ephemeral",
},
})
}
// applyCacheControlToMessages adds cache control to specific messages.
// Anthropic allows max 4 cache blocks per request.
// Counts existing cache blocks and only adds new ones up to the limit.
func applyCacheControlToMessages(messages []fantasy.Message) []fantasy.Message {
if len(messages) == 0 {
return messages
}
// Make a copy to avoid modifying the original slice
result := make([]fantasy.Message, len(messages))
copy(result, messages)
cacheOpts := cacheControlOptions()
maxCacheBlocks := 4
// Helper to check if message already has cache control
hasCache := func(msg fantasy.Message) bool {
if msg.ProviderOptions == nil {
return false
}
if _, ok := msg.ProviderOptions["anthropic"]; ok {
return true
}
return false
}
// Count existing cache blocks
existingCacheCount := 0
for _, msg := range result {
if hasCache(msg) {
existingCacheCount++
}
}
// If we're already at or over the limit, don't add more
if existingCacheCount >= maxCacheBlocks {
return result
}
// How many new cache blocks can we add?
remaining := maxCacheBlocks - existingCacheCount
// First: find and cache the last system message (most important)
lastSystemIdx := -1
for i, msg := range result {
if msg.Role == fantasy.MessageRoleSystem {
lastSystemIdx = i
}
}
if lastSystemIdx >= 0 && remaining > 0 && !hasCache(result[lastSystemIdx]) {
result[lastSystemIdx].ProviderOptions = cacheOpts
remaining--
}
// Second: cache the most recent messages (up to remaining limit)
// Work backwards from the end to prioritize recent context
for i := len(result) - 1; i >= 0 && remaining > 0; i-- {
if hasCache(result[i]) {
continue
}
result[i].ProviderOptions = cacheOpts
remaining--
}
return result
}
+27 -10
View File
@@ -36,13 +36,26 @@ type AgentCreationOptions struct {
SpinnerFunc SpinnerFunc // Function to show spinner (provided by caller)
// DebugLogger is an optional logger for debugging MCP communications
DebugLogger tools.DebugLogger // Optional debug logger
// AuthHandler handles OAuth authorization for remote MCP servers
AuthHandler tools.MCPAuthHandler
// TokenStoreFactory, if non-nil, creates a custom token store for each
// remote MCP server's OAuth tokens. When nil, the default file-based
// token store is used.
TokenStoreFactory tools.TokenStoreFactory
// CoreTools overrides the default core tool set. If empty, core.AllTools()
// is used.
CoreTools []fantasy.AgentTool
// ToolWrapper wraps the combined tool list before Fantasy agent creation.
// DisableCoreTools, when true, prevents loading any core tools.
// If both DisableCoreTools is true and CoreTools is empty, the agent
// will have no tools (useful for simple chat completions).
DisableCoreTools bool
// ToolWrapper wraps the combined tool list before agent creation.
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
// ExtraTools are additional tools to include (e.g. from extensions).
ExtraTools []fantasy.AgentTool
// OnMCPServerLoaded, if non-nil, is called when each MCP server finishes
// loading (successfully or with error). Called from the background goroutine.
OnMCPServerLoaded func(serverName string, toolCount int, err error)
}
// CreateAgent creates an agent with optional spinner for Ollama models.
@@ -50,15 +63,19 @@ type AgentCreationOptions struct {
// Returns the created agent or an error if creation fails.
func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error) {
agentConfig := &AgentConfig{
ModelConfig: opts.ModelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: opts.SystemPrompt,
MaxSteps: opts.MaxSteps,
StreamingEnabled: opts.StreamingEnabled,
DebugLogger: opts.DebugLogger,
CoreTools: opts.CoreTools,
ToolWrapper: opts.ToolWrapper,
ExtraTools: opts.ExtraTools,
ModelConfig: opts.ModelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: opts.SystemPrompt,
MaxSteps: opts.MaxSteps,
StreamingEnabled: opts.StreamingEnabled,
DebugLogger: opts.DebugLogger,
AuthHandler: opts.AuthHandler,
TokenStoreFactory: opts.TokenStoreFactory,
CoreTools: opts.CoreTools,
DisableCoreTools: opts.DisableCoreTools,
ToolWrapper: opts.ToolWrapper,
ExtraTools: opts.ExtraTools,
OnMCPServerLoaded: opts.OnMCPServerLoaded,
}
var agent *Agent
+46
View File
@@ -0,0 +1,46 @@
package agent
import (
"context"
"charm.land/fantasy"
)
// SteerMessage carries a steering prompt and optional file attachments
// (e.g. clipboard images) through the steer channel.
type SteerMessage struct {
Text string
Files []fantasy.FilePart
}
// steerChKey is the context key for the steer channel.
type steerChKey struct{}
// steerConsumedKey is the context key for the steer-consumed callback.
type steerConsumedKey struct{}
// ContextWithSteerCh returns a new context with the steer channel attached.
// The agent's PrepareStep function checks this channel between steps and
// injects any pending steer messages as user messages before the next LLM call.
func ContextWithSteerCh(ctx context.Context, ch <-chan SteerMessage) context.Context {
return context.WithValue(ctx, steerChKey{}, ch)
}
// ContextWithSteerConsumed returns a new context with a callback that fires
// when steer messages are consumed by PrepareStep. The count argument is the
// number of messages injected in this batch.
func ContextWithSteerConsumed(ctx context.Context, fn func(count int)) context.Context {
return context.WithValue(ctx, steerConsumedKey{}, fn)
}
// steerChFromContext extracts the steer channel from the context, or nil.
func steerChFromContext(ctx context.Context) <-chan SteerMessage {
ch, _ := ctx.Value(steerChKey{}).(<-chan SteerMessage)
return ch
}
// steerConsumedFromContext extracts the steer-consumed callback, or nil.
func steerConsumedFromContext(ctx context.Context) func(int) {
fn, _ := ctx.Value(steerConsumedKey{}).(func(int))
return fn
}
+588 -74
View File
@@ -3,7 +3,11 @@ package app
import (
"context"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
@@ -16,7 +20,7 @@ import (
// queueItem holds a prompt and optional image attachments for the execution queue.
type queueItem struct {
Prompt string
Files []fantasy.FilePart
Files []kit.LLMFilePart
}
// App is the application-layer orchestrator. It owns the agentic loop,
@@ -65,11 +69,20 @@ type App struct {
// rootCtx/rootCancel are used to signal shutdown to all goroutines.
rootCtx context.Context
rootCancel context.CancelFunc
// widgetUpdatePending is set to true when a WidgetUpdateEvent has been
// sent to the TUI but not yet consumed by its event loop. While the flag
// is set, subsequent NotifyWidgetUpdate calls are coalesced (dropped) to
// prevent fast extension tickers from flooding the BubbleTea mailbox with
// redundant re-render triggers. The flag is cleared after a short debounce
// (~1 frame) so new updates are always let through once the TUI has had a
// chance to process the pending event.
widgetUpdatePending atomic.Bool
}
// New creates a new App with the provided options and pre-loaded messages.
// initialMessages may be nil or empty for a fresh session.
func New(opts Options, initialMessages []fantasy.Message) *App {
func New(opts Options, initialMessages []kit.LLMMessage) *App {
rootCtx, rootCancel := context.WithCancel(context.Background())
return &App{
opts: opts,
@@ -113,9 +126,8 @@ func (a *App) Run(prompt string) int {
// 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 {
// Satisfies ui.AppController.
func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
a.mu.Lock()
if a.closed {
@@ -150,6 +162,24 @@ func (a *App) CancelCurrentStep() {
cancel()
}
// IsBusy returns true when the agent is currently processing a turn.
func (a *App) IsBusy() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.busy
}
// Abort cancels the current agent step (if running) and clears the queue.
// Unlike InterruptAndSend, no new message is injected — the agent simply
// stops. Safe to call when idle (no-op).
func (a *App) Abort() {
a.mu.Lock()
a.queue = a.queue[:0]
cancel := a.cancelStep
a.mu.Unlock()
cancel()
}
// QueueLength returns the number of prompts currently waiting in the queue.
//
// Satisfies ui.AppController.
@@ -159,11 +189,66 @@ 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) {
// Steer injects a steering message into the currently running agent turn.
// If the agent is in a multi-step tool loop, the message is delivered after
// the current tool execution finishes but before the next LLM call (graceful
// mid-turn injection via Fantasy's PrepareStep). If the agent is streaming
// a text-only response (no pending tool calls), the message waits until the
// response completes and then executes as the next turn.
//
// If the agent is idle, the message starts executing immediately (same as Run).
//
// Returns the number of pending steer/queue items (0 = started immediately,
// >0 = injected/queued). The caller must update UI state based on the return
// value — Steer does NOT send events to the program to avoid deadlocking
// when called from within Update().
//
// Satisfies ui.AppController.
func (a *App) Steer(prompt string) int {
return a.SteerWithFiles(prompt, nil)
}
// SteerWithFiles injects a steering message with optional file attachments
// (e.g. pasted images) into the currently running agent turn. Behaves like
// Steer but includes file parts alongside the text.
//
// Satisfies ui.AppController.
func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return 0
}
if !a.busy {
// Not busy — start immediately, same as RunWithFiles().
item := queueItem{Prompt: prompt, Files: files}
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
return 0
}
a.mu.Unlock()
// Agent is busy — inject via the SDK's steer channel. The message
// will be picked up by PrepareStep between agent steps (after tool
// execution, before next LLM call). If PrepareStep doesn't fire
// (text-only response), drainQueue will pick it up after the turn.
if a.opts.Kit != nil {
a.opts.Kit.InjectSteerWithFiles(prompt, files)
}
return 1
}
// InterruptAndSend 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 hard-cancel delivery mode used by extensions'
// CancelAndSend.
func (a *App) InterruptAndSend(prompt string) {
a.mu.Lock()
if a.closed {
@@ -212,11 +297,42 @@ func (a *App) ClearMessages() {
}
}
// ReloadMessagesFromTree clears the in-memory message store and reloads it
// from the tree session's current branch. Unlike ClearMessages, this does NOT
// reset the tree session's leaf pointer. Used after Branch() to sync the
// store with the new branch position.
func (a *App) ReloadMessagesFromTree() {
a.store.Clear()
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
}
// GetTreeSession returns the tree session manager, or nil if not configured.
func (a *App) GetTreeSession() *session.TreeManager {
return a.opts.TreeSession
}
// SwitchTreeSession replaces the active tree session with a new one and
// reloads the in-memory message store from the new session's messages.
// The old tree session is closed. Used by /resume to switch sessions.
func (a *App) SwitchTreeSession(ts *session.TreeManager) {
// Close old session.
if old := a.opts.TreeSession; old != nil {
_ = old.Close()
}
a.opts.TreeSession = ts
// Also update the kit SDK's tree session so messages are persisted correctly.
if a.opts.Kit != nil {
a.opts.Kit.SetTreeSession(ts)
}
// Reload messages from new session.
a.store.Clear()
if ts != nil {
a.store.Replace(ts.GetLLMMessages())
}
}
// 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
@@ -224,12 +340,12 @@ func (a *App) GetTreeSession() *session.TreeManager {
//
// Satisfies ui.AppController.
func (a *App) AddContextMessage(text string) {
msg := fantasy.NewUserMessage(text)
a.store.Add(msg)
kitMsg := fantasy.NewUserMessage(text)
a.store.Add(kitMsg)
// Persist to tree session if active.
if ts := a.opts.TreeSession; ts != nil {
_, _ = ts.AppendFantasyMessage(msg)
_, _ = ts.AppendLLMMessage(fantasy.NewUserMessage(text))
}
}
@@ -267,6 +383,15 @@ func (a *App) CompactConversation(customInstructions string) error {
a.mu.Unlock()
}()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
if a.program != nil {
a.program.Send(msg)
}
}
unsub := a.subscribeSDKEvents(sendFn, nil)
defer unsub()
result, err := a.opts.Kit.Compact(a.rootCtx, nil, customInstructions)
if err != nil {
a.sendEvent(CompactErrorEvent{Err: err})
@@ -279,7 +404,7 @@ func (a *App) CompactConversation(customInstructions string) error {
// Sync in-memory store with the compacted session.
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetFantasyMessages())
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
a.sendEvent(CompactCompleteEvent{
@@ -292,6 +417,78 @@ func (a *App) CompactConversation(customInstructions string) error {
return nil
}
// CompactAsync is like CompactConversation but calls onComplete/onError
// callbacks instead of sending TUI events. Used by the extension API's
// ctx.Compact() which needs callback-based notification.
func (a *App) CompactAsync(customInstructions string, onComplete func(), onError func(string)) error {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return fmt.Errorf("app is closed")
}
if a.busy {
a.mu.Unlock()
return fmt.Errorf("cannot compact while the agent is working")
}
if a.opts.Kit == nil {
a.mu.Unlock()
return fmt.Errorf("SDK instance not available")
}
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go func() {
defer a.wg.Done()
defer func() {
a.mu.Lock()
a.busy = false
a.mu.Unlock()
}()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
if a.program != nil {
a.program.Send(msg)
}
}
unsub := a.subscribeSDKEvents(sendFn, nil)
defer unsub()
result, err := a.opts.Kit.Compact(a.rootCtx, nil, customInstructions)
if err != nil {
a.sendEvent(CompactErrorEvent{Err: err})
if onError != nil {
onError(err.Error())
}
return
}
if result == nil {
a.sendEvent(CompactErrorEvent{Err: fmt.Errorf("nothing to compact")})
if onError != nil {
onError("nothing to compact")
}
return
}
// Sync in-memory store with the compacted session.
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
a.sendEvent(CompactCompleteEvent{
Summary: result.Summary,
OriginalTokens: result.OriginalTokens,
CompactedTokens: result.CompactedTokens,
MessagesRemoved: result.MessagesRemoved,
})
if onComplete != nil {
onComplete()
}
}()
return nil
}
// --------------------------------------------------------------------------
// Non-interactive execution
// --------------------------------------------------------------------------
@@ -385,47 +582,98 @@ func (a *App) Close() {
// Wait for background goroutines.
a.wg.Wait()
// Clean up empty session file on shutdown.
if ts := a.opts.TreeSession; ts != nil && ts.IsEmpty() {
if path := ts.GetFilePath(); path != "" {
_ = os.Remove(path)
}
}
}
// --------------------------------------------------------------------------
// Internal: queue drain loop
// --------------------------------------------------------------------------
// drainQueue runs in a goroutine. It executes the given item and then
// continues draining the queue until it is empty.
// drainQueue runs in a goroutine. It collects all queued items (including the
// first one) and submits them together as a single batch. This ensures that
// when multiple messages are queued while the agent is working, they are all
// submitted together in one turn rather than sequentially.
// Must be called with a.busy == true and a.wg incremented.
func (a *App) drainQueue(first queueItem) {
defer a.wg.Done()
item := first
for {
a.runQueueItem(item)
// Collect all items to process in this batch
var items []queueItem
items = append(items, first)
// Process batches until no more items are queued
for {
// Drain the queue to collect any pending items
a.mu.Lock()
// Stop draining if the app is shutting down.
if a.closed || a.rootCtx.Err() != nil {
a.busy = false
a.queue = a.queue[:0]
a.mu.Unlock()
return
}
if len(a.queue) == 0 {
a.busy = false
a.mu.Unlock()
return
}
item = a.queue[0]
a.queue = a.queue[1:]
qLen := len(a.queue)
items = append(items, a.queue...)
a.queue = a.queue[:0] // Clear the queue
a.mu.Unlock()
// sendEvent must be called without a.mu held (see sendEvent comment).
a.sendEvent(QueueUpdatedEvent{Length: qLen})
// Notify UI: all queued messages have been consumed into this batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
// Process all collected items as a single batch
a.runQueueBatch(items)
// Drain any unconsumed steer messages from the SDK channel.
// These arrive when the user steered during a text-only response
// (no tool calls, so PrepareStep didn't fire for a second step).
// They go to the front of the queue so they run next.
if a.opts.Kit != nil {
if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 {
a.mu.Lock()
steerItems := make([]queueItem, len(leftover))
for i, sm := range leftover {
steerItems[i] = queueItem{Prompt: sm.Text, Files: sm.Files}
}
a.queue = append(steerItems, a.queue...)
a.mu.Unlock()
// Notify UI about the consumed steer messages.
a.sendEvent(SteerConsumedEvent{})
}
}
// Check if more items were queued while we were processing
a.mu.Lock()
hasMore := len(a.queue) > 0
if hasMore {
// Start a new batch with the newly queued items
items = a.queue
a.queue = a.queue[:0]
}
a.mu.Unlock()
if hasMore {
// Notify UI: these newly queued messages have been consumed into the next batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
}
if !hasMore {
// No more items, we're done
break
}
// Process the new batch
}
// Mark as no longer busy
a.mu.Lock()
a.busy = false
a.mu.Unlock()
}
// 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) runQueueItem(item queueItem) {
// runQueueBatch executes multiple queue items as a single agent turn.
// All items are submitted together, and the agent responds once to the combined context.
func (a *App) runQueueBatch(items []queueItem) {
if len(items) == 0 {
return
}
// Create a per-step cancellable context.
stepCtx, cancel := context.WithCancel(a.rootCtx)
a.mu.Lock()
@@ -444,12 +692,17 @@ func (a *App) runQueueItem(item queueItem) {
}
}
result, err := a.executeStep(stepCtx, item.Prompt, eventFn, item.Files)
// Execute the batch
result, err := a.executeBatch(stepCtx, items, eventFn)
if err != nil {
if stepCtx.Err() != nil {
// Step was cancelled by the user (e.g. double-ESC). Send a
// cancellation event so the TUI can cut off the response
// cleanly without printing an error.
// Step was cancelled by the user (double-ESC). The SDK
// preserves the user message and any completed tool
// call/result pairs; only the in-progress message or tool
// call is discarded. Sync the in-memory store to match.
if ts := a.opts.TreeSession; ts != nil {
a.store.Replace(ts.GetLLMMessages())
}
a.sendEvent(StepCancelledEvent{})
return
}
@@ -467,7 +720,7 @@ func (a *App) runQueueItem(item queueItem) {
// executeStep runs a single agentic step by delegating to the SDK's
// 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) {
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) (*kit.TurnResult, error) {
// Test hook: bypass SDK entirely.
if a.opts.PromptFunc != nil {
return a.opts.PromptFunc(ctx, prompt)
@@ -479,9 +732,10 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
defer unsub()
// Show spinner while the agent works.
@@ -501,15 +755,97 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker.
a.updateUsageFromTurnResult(result, prompt)
// Update usage tracker. If per-step usage was already recorded from
// StepUsageEvent callbacks, avoid double-counting totals.
a.updateUsageFromTurnResult(result, prompt, sawStepUsage.Load())
return result, nil
}
// --------------------------------------------------------------------------
// Internal: event helpers
// --------------------------------------------------------------------------
// executeBatch runs a batch of queue items as a single agent step by delegating
// to the SDK's PromptResultWithMessages(), which handles session persistence,
// hooks, extension events, and the generation loop.
func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(tea.Msg)) (*kit.TurnResult, error) {
// Test hook: bypass SDK entirely (single item only for test compatibility).
if a.opts.PromptFunc != nil {
if len(items) == 1 {
return a.opts.PromptFunc(ctx, items[0].Prompt)
}
// For batch mode with PromptFunc, just use the first item
return a.opts.PromptFunc(ctx, items[0].Prompt)
}
sendFn := func(msg tea.Msg) {
if eventFn != nil {
eventFn(msg)
}
}
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
defer unsub()
// Show spinner while the agent works.
sendFn(SpinnerEvent{Show: true})
// Check if any items have file attachments
hasFiles := false
for _, item := range items {
if len(item.Files) > 0 {
hasFiles = true
break
}
}
var result *kit.TurnResult
var err error
if len(items) == 1 {
// Single item: use the original path for compatibility
item := items[0]
if len(item.Files) > 0 || hasFiles {
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
} else {
result, err = a.opts.Kit.PromptResult(ctx, item.Prompt)
}
} else {
// Multiple items: batch them together
var messages []string
for _, item := range items {
messages = append(messages, item.Prompt)
}
// File attachments are not supported in batch mode; fall back to
// processing only the first item that carries files.
if hasFiles {
// If files exist, fall back to processing just the first item with files
for _, item := range items {
if len(item.Files) > 0 {
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
break
}
}
} else {
result, err = a.opts.Kit.PromptResultWithMessages(ctx, messages)
}
}
if err != nil {
return nil, err
}
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker (using last item's prompt for fallback estimation).
// If per-step usage was already recorded from StepUsageEvent callbacks,
// avoid double-counting totals.
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt, sawStepUsage.Load())
return result, nil
}
// sendEvent sends a tea.Msg to the registered program if one is set.
// Must NOT be called with a.mu held (to avoid deadlock with the program).
@@ -523,9 +859,10 @@ func (a *App) sendEvent(msg tea.Msg) {
}
// subscribeSDKEvents registers temporary SDK event subscribers that convert
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
// unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
// SDK events to tea.Msg events and dispatch them via sendFn. When stepUsageSeen
// is provided, it is set to true after any non-zero StepUsageEvent is observed.
// Returns an unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Bool) func() {
k := a.opts.Kit
var unsubs []func()
@@ -550,6 +887,19 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
sendFn(StreamChunkEvent{Content: ev.Chunk})
case kit.ReasoningDeltaEvent:
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
case kit.ReasoningCompleteEvent:
sendFn(ReasoningCompleteEvent{})
case kit.ToolOutputEvent:
sendFn(ToolOutputEvent{
ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName,
Chunk: ev.Chunk,
IsStderr: ev.IsStderr,
})
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
case kit.StepUsageEvent:
a.recordStepUsage(ev, stepUsageSeen)
}
}))
@@ -580,7 +930,8 @@ func (a *App) QuitFromExtension() {
// controls styling: "" for plain text, "info" for a system message block,
// "error" for an error block. In interactive mode it sends an
// ExtensionPrintEvent through the program so the TUI can render it with the
// appropriate renderer. In non-interactive mode it falls back to stdout.
// appropriate renderer. In non-interactive mode it falls back to stderr with
// a level prefix so errors are distinguishable from plain output.
func (a *App) PrintFromExtension(level, text string) {
a.mu.Lock()
prog := a.program
@@ -589,8 +940,16 @@ func (a *App) PrintFromExtension(level, text string) {
prog.Send(ExtensionPrintEvent{Text: text, Level: level})
return
}
// Non-interactive fallback: write directly to stdout.
fmt.Println(text)
// Non-interactive fallback: write to stderr with a level prefix so that
// errors and info messages are distinguishable from plain output.
switch level {
case "error":
fmt.Fprintf(os.Stderr, "[ERROR] %s\n", text)
case "info":
fmt.Fprintf(os.Stderr, "[INFO] %s\n", text)
default:
fmt.Println(text)
}
}
// SetEditorTextFromExtension sends an EditorTextSetEvent to the TUI to
@@ -618,12 +977,73 @@ func (a *App) NotifyModelChanged(provider, model string) {
// 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).
//
// Coalescing: if a WidgetUpdateEvent is already queued and not yet consumed
// by the TUI event loop, additional calls within the same ~16 ms window are
// dropped. This prevents fast extension tickers from flooding BubbleTea's
// mailbox with redundant re-render triggers.
func (a *App) NotifyWidgetUpdate() {
// Coalesce: only one pending update at a time.
if !a.widgetUpdatePending.CompareAndSwap(false, true) {
return
}
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(WidgetUpdateEvent{})
// Reset the pending flag after a short debounce so subsequent calls
// within the same render cycle are also coalesced, but new updates
// after the cycle are allowed through.
go func() {
time.Sleep(16 * time.Millisecond) // ~1 frame at 60 fps
a.widgetUpdatePending.Store(false)
}()
} else {
// No program registered (non-interactive mode); clear the flag so
// future calls are never permanently blocked.
a.widgetUpdatePending.Store(false)
}
}
// NotifyContentReload sends a ContentReloadEvent to the TUI so it refreshes
// prompt templates and skills from their provider callbacks. Called by file
// watchers when .md/.txt files change in prompt or skill directories.
// In non-interactive mode this is a no-op.
func (a *App) NotifyContentReload() {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(ContentReloadEvent{})
}
}
// NotifyMCPToolsReady sends an MCPToolsReadyEvent to the TUI so it refreshes
// tool names and MCP tool count from provider callbacks. Called when background
// MCP tool loading completes. In non-interactive mode this is a no-op.
func (a *App) NotifyMCPToolsReady() {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(MCPToolsReadyEvent{})
}
}
// NotifyMCPServerLoaded sends an MCPServerLoadedEvent to the TUI so it can
// display a system message when a single MCP server finishes loading. Called
// per server as background MCP tool loading progresses.
func (a *App) NotifyMCPServerLoaded(serverName string, toolCount int, err error) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(MCPServerLoadedEvent{
ServerName: serverName,
ToolCount: toolCount,
Error: err,
})
}
}
@@ -711,37 +1131,131 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
})
return
}
// Non-interactive fallback.
// Non-interactive fallback: render a simple framed block to stderr so
// it is visually distinct from plain stdout output.
if opts.Subtitle != "" {
fmt.Printf("%s\n — %s\n", opts.Text, opts.Subtitle)
fmt.Fprintf(os.Stderr, "--- %s ---\n%s\n", opts.Subtitle, opts.Text)
} else {
fmt.Println(opts.Text)
fmt.Fprintf(os.Stderr, "---\n%s\n---\n", opts.Text)
}
}
// recordStepUsage applies token/cost usage reported for a completed step.
// Step usage events arrive even when a turn is later cancelled, so this keeps
// the usage widget accurate on all stop paths.
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool) {
hasUsage := ev.InputTokens > 0 || ev.OutputTokens > 0 || ev.CacheReadTokens > 0 || ev.CacheWriteTokens > 0
if a.opts.Debug {
log.Printf("[DEBUG] recordStepUsage: hasUsage=%v input=%d output=%d cacheRead=%d cacheWrite=%d",
hasUsage, ev.InputTokens, ev.OutputTokens, ev.CacheReadTokens, ev.CacheWriteTokens)
}
if !hasUsage {
return
}
if stepUsageSeen != nil {
stepUsageSeen.Store(true)
}
if a.opts.UsageTracker == nil {
return
}
a.opts.UsageTracker.UpdateUsage(
int(ev.InputTokens),
int(ev.OutputTokens),
int(ev.CacheReadTokens),
int(ev.CacheWriteTokens),
)
// NOTE: We do NOT call SetContextTokens here. Context fill is set once
// at turn completion via updateUsageFromTurnResult, which sums all token
// categories (Input + CacheRead + CacheCreate + Output) from FinalUsage.
// Per-step context tokens would cause the display to jump around during
// multi-step tool calls.
}
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
// configured UsageTracker. This is the SDK-path equivalent of updateUsage.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
// configured UsageTracker. Called once per turn after the turn completes.
//
// When sawStepUsage is true, totals were already accumulated incrementally via
// StepUsageEvent callbacks; in that case this method only updates context fill.
// Otherwise it falls back to TotalUsage from the API response.
//
// NOTE: We only use ACTUAL token counts from API responses for cost tracking.
// Estimation is never used for costs - only API-reported tokens are accurate.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string, sawStepUsage bool) {
if a.opts.UsageTracker == nil || result == nil {
return
}
if result.TotalUsage != nil {
inputTokens := int(result.TotalUsage.InputTokens)
outputTokens := int(result.TotalUsage.OutputTokens)
if inputTokens > 0 && outputTokens > 0 {
cacheReadTokens := int(result.TotalUsage.CacheReadTokens)
cacheWriteTokens := int(result.TotalUsage.CacheCreationTokens)
a.opts.UsageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
// Debug logging for token tracking
if a.opts.Debug {
if result.TotalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult TotalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
} else {
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
return
log.Printf("[DEBUG] updateUsageFromTurnResult: TotalUsage=nil")
}
if result.FinalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult FinalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.FinalUsage.InputTokens, result.FinalUsage.OutputTokens,
result.FinalUsage.CacheReadTokens, result.FinalUsage.CacheCreationTokens)
} else {
log.Printf("[DEBUG] updateUsageFromTurnResult: FinalUsage=nil")
}
log.Printf("[DEBUG] updateUsageFromTurnResult: sawStepUsage=%v", sawStepUsage)
}
// --- Accumulate cost/token totals for the session ---
// Only use actual API-reported tokens for cost tracking.
// If sawStepUsage is true, totals were already updated via StepUsageEvent.
// Check any token field > 0 (not just InputTokens) because cached prompts
// can result in InputTokens=0 while OutputTokens>0 (OpenAI-compatible behavior).
hasTotalUsage := result.TotalUsage != nil &&
(result.TotalUsage.InputTokens > 0 ||
result.TotalUsage.OutputTokens > 0 ||
result.TotalUsage.CacheReadTokens > 0 ||
result.TotalUsage.CacheCreationTokens > 0)
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: hasTotalUsage=%v", hasTotalUsage)
}
if !sawStepUsage && hasTotalUsage {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: calling UpdateUsage input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
}
a.opts.UsageTracker.UpdateUsage(
int(result.TotalUsage.InputTokens),
int(result.TotalUsage.OutputTokens),
int(result.TotalUsage.CacheReadTokens),
int(result.TotalUsage.CacheCreationTokens),
)
}
// --- Context window fill (drives the % bar) ---
// Calculate context fill from the LAST API call's usage. The context
// window is filled by everything sent to and received from the model:
//
// InputTokens — non-cached input (may be small with prompt caching)
// CacheReadTokens — input tokens served from cache
// CacheCreationTokens — input tokens written to cache this call
// OutputTokens — assistant output (becomes input next turn)
//
// With Anthropic prompt caching, InputTokens can drop to near-zero while
// CacheReadTokens holds the bulk of the context. We must sum all four to
// get the true context window utilization.
//
// We use FinalUsage (last step only), NOT TotalUsage, because TotalUsage
// sums across all tool-calling steps — and each step re-sends the full
// conversation, so TotalUsage massively overstates the actual window fill.
if result.FinalUsage != nil {
if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 {
a.opts.UsageTracker.SetContextTokens(ct)
u := result.FinalUsage
contextFill := int(u.InputTokens) + int(u.CacheReadTokens) + int(u.CacheCreationTokens) + int(u.OutputTokens)
if contextFill > 0 {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: SetContextTokens=%d (Input=%d + CacheRead=%d + CacheCreate=%d + Output=%d)",
contextFill, u.InputTokens, u.CacheReadTokens, u.CacheCreationTokens, u.OutputTokens)
}
a.opts.UsageTracker.SetContextTokens(contextFill)
}
}
}
+197 -36
View File
@@ -14,6 +14,47 @@ import (
// Helpers
// --------------------------------------------------------------------------
type usageUpdaterStub struct {
mu sync.Mutex
updateCalls int
estimateCalls int
contextCalls int
lastUpdateInput int
lastUpdateOutput int
lastUpdateCacheRead int
lastUpdateCacheWrite int
lastContextTokens int
lastEstimateInput string
lastEstimateOutput string
}
func (s *usageUpdaterStub) UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.updateCalls++
s.lastUpdateInput = inputTokens
s.lastUpdateOutput = outputTokens
s.lastUpdateCacheRead = cacheReadTokens
s.lastUpdateCacheWrite = cacheWriteTokens
}
func (s *usageUpdaterStub) EstimateAndUpdateUsage(inputText, outputText string) {
s.mu.Lock()
defer s.mu.Unlock()
s.estimateCalls++
s.lastEstimateInput = inputText
s.lastEstimateOutput = outputText
}
func (s *usageUpdaterStub) SetContextTokens(tokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.contextCalls++
s.lastContextTokens = tokens
}
// turnResult builds a minimal TurnResult with response text t.
func turnResult(t string) *kit.TurnResult {
return &kit.TurnResult{Response: t}
@@ -120,9 +161,8 @@ func TestRun_single(t *testing.T) {
// Run (queued prompts)
// --------------------------------------------------------------------------
// TestRun_queued verifies that a second Run() call while the first is in-flight
// enqueues the prompt rather than spawning a second goroutine, and that the
// queue is drained after the first step completes.
// TestRun_queued verifies that queued prompts are batched together and submitted
// as a single agent turn rather than individually.
func TestRun_queued(t *testing.T) {
gate := make(chan struct{})
callCount := 0
@@ -134,13 +174,7 @@ func TestRun_queued(t *testing.T) {
callCount++
mu.Unlock()
<-gate
return turnResult("first"), nil
},
func(_ context.Context) (*kit.TurnResult, error) {
mu.Lock()
callCount++
mu.Unlock()
return turnResult("second"), nil
return turnResult("batch result"), nil
},
)
app := newTestApp(stub)
@@ -165,11 +199,15 @@ func TestRun_queued(t *testing.T) {
t.Fatal("app did not become idle within 3s after queued runs")
}
// Wait for the goroutine to fully finish (avoid race with queue check)
app.wg.Wait()
mu.Lock()
total := callCount
mu.Unlock()
if total != 2 {
t.Fatalf("expected 2 calls, got %d", total)
// With batching, both prompts should be processed in a single call
if total != 1 {
t.Fatalf("expected 1 batched call, got %d", total)
}
if got := app.QueueLength(); got != 0 {
t.Fatalf("expected empty queue after drain, got %d", got)
@@ -180,31 +218,22 @@ func TestRun_queued(t *testing.T) {
// Queue drain ordering
// --------------------------------------------------------------------------
// TestQueueDrainOrdering verifies that queued prompts are consumed in FIFO order.
// TestQueueDrainOrdering verifies that queued prompts are batched together and
// processed in a single agent turn.
func TestQueueDrainOrdering(t *testing.T) {
gate := make(chan struct{})
var order []string
var receivedPrompt string
var mu sync.Mutex
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
mu.Lock()
order = append(order, "first")
// In test mode with PromptFunc, we receive the first prompt
// but all messages are batched together
receivedPrompt = "batched"
mu.Unlock()
<-gate
return turnResult("first"), nil
},
func(_ context.Context) (*kit.TurnResult, error) {
mu.Lock()
order = append(order, "second")
mu.Unlock()
return turnResult("second"), nil
},
func(_ context.Context) (*kit.TurnResult, error) {
mu.Lock()
order = append(order, "third")
mu.Unlock()
return turnResult("third"), nil
return turnResult("batch result"), nil
},
)
@@ -228,16 +257,12 @@ func TestQueueDrainOrdering(t *testing.T) {
}
mu.Lock()
got := order
got := receivedPrompt
mu.Unlock()
if len(got) != 3 {
t.Fatalf("expected 3 calls, got %d: %v", len(got), got)
}
for i, want := range []string{"first", "second", "third"} {
if got[i] != want {
t.Fatalf("call[%d]: expected %q, got %q", i, want, got[i])
}
// With batching, all 3 prompts should be processed in a single call
if got != "batched" {
t.Fatalf("expected batched processing, got %q", got)
}
}
@@ -505,3 +530,139 @@ func TestQueueLength_reflects(t *testing.T) {
t.Fatalf("expected 3, got %d", got)
}
}
// TestRecordStepUsage_updatesTracker verifies that per-step usage updates are
// recorded immediately for cost tracking. Context tokens are NOT updated here
// (only via updateUsageFromTurnResult) to avoid display jumps during multi-step
// tool calls.
func TestRecordStepUsage_updatesTracker(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.recordStepUsage(kit.StepUsageEvent{
InputTokens: 120,
OutputTokens: 45,
CacheReadTokens: 5,
CacheWriteTokens: 2,
}, nil)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 120 || usage.lastUpdateOutput != 45 || usage.lastUpdateCacheRead != 5 || usage.lastUpdateCacheWrite != 2 {
t.Fatalf("unexpected usage update payload: in=%d out=%d cache_read=%d cache_write=%d",
usage.lastUpdateInput, usage.lastUpdateOutput, usage.lastUpdateCacheRead, usage.lastUpdateCacheWrite)
}
// Context tokens should NOT be updated by recordStepUsage (only by updateUsageFromTurnResult)
if usage.contextCalls != 0 {
t.Fatalf("expected 0 context token updates from recordStepUsage, got %d", usage.contextCalls)
}
}
// TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen ensures we avoid
// double-counting totals once StepUsageEvent-based updates were already applied.
func TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 999,
OutputTokens: 111,
CacheReadTokens: 7,
CacheCreationTokens: 3,
},
FinalUsage: &kit.LLMUsage{InputTokens: 456},
}, "prompt", true)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 0 {
t.Fatalf("expected no total usage update when sawStepUsage=true, got %d", usage.updateCalls)
}
if usage.estimateCalls != 0 {
t.Fatalf("expected no estimate update when sawStepUsage=true, got %d", usage.estimateCalls)
}
// Context tokens should be InputTokens only (456)
if usage.contextCalls != 1 || usage.lastContextTokens != 456 {
t.Fatalf("expected final context tokens=456 (InputTokens only), got calls=%d tokens=%d", usage.contextCalls, usage.lastContextTokens)
}
}
// TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero verifies that usage
// is recorded when InputTokens=0 but OutputTokens>0 (OpenAI-compatible cache behavior).
func TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
// Simulate OpenAI-compatible behavior: all prompt tokens cached, InputTokens=0
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 0, // All cached - subtracted from prompt
OutputTokens: 150, // Actual generated tokens
CacheReadTokens: 500, // Cache hit
CacheCreationTokens: 0,
},
FinalUsage: &kit.LLMUsage{InputTokens: 0, OutputTokens: 150},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call when InputTokens=0 but OutputTokens>0, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 0 || usage.lastUpdateOutput != 150 {
t.Fatalf("expected input=0 output=150, got input=%d output=%d",
usage.lastUpdateInput, usage.lastUpdateOutput)
}
if usage.lastUpdateCacheRead != 500 {
t.Fatalf("expected cache_read=500, got %d", usage.lastUpdateCacheRead)
}
}
// TestUpdateUsageFromTurnResult_contextTokensUsesAllCategories verifies that
// context window fill uses all token categories from the final API call:
// InputTokens + CacheReadTokens + CacheCreationTokens + OutputTokens.
// With Anthropic prompt caching, InputTokens can be near-zero while
// CacheReadTokens holds the bulk of the context.
func TestUpdateUsageFromTurnResult_contextTokensUsesAllCategories(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 3,
OutputTokens: 5,
CacheReadTokens: 0,
CacheCreationTokens: 4317,
},
FinalUsage: &kit.LLMUsage{
InputTokens: 3, // Non-cached input (small with caching)
OutputTokens: 5, // Assistant output
CacheReadTokens: 0, // No cache reads on first call
CacheCreationTokens: 4317, // System prompt + tools written to cache
},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
// Context tokens should be Input + CacheRead + CacheCreate + Output = 4325
expected := 3 + 0 + 4317 + 5
if usage.contextCalls != 1 || usage.lastContextTokens != expected {
t.Fatalf("expected context tokens=%d (all categories), got calls=%d tokens=%d",
expected, usage.contextCalls, usage.lastContextTokens)
}
}
+46 -3
View File
@@ -1,6 +1,6 @@
package app
import "charm.land/fantasy"
import kit "github.com/mark3labs/kit/pkg/kit"
// StreamChunkEvent is sent by the app layer when a streaming text delta arrives
// from the LLM. Each chunk contains an incremental portion of the response.
@@ -16,6 +16,11 @@ type ReasoningChunkEvent struct {
Delta string
}
// ReasoningCompleteEvent is sent when reasoning/thinking is finished, after
// the last reasoning token has been processed. The TUI uses this to freeze
// the reasoning duration counter.
type ReasoningCompleteEvent struct{}
// 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 {
@@ -54,6 +59,19 @@ type ToolResultEvent struct {
IsError bool
}
// ToolOutputEvent is sent when a tool produces streaming output chunks (e.g., bash output).
// This allows the TUI to display tool output as it arrives, before the tool completes.
type ToolOutputEvent struct {
// ToolCallID is the stable identifier for the tool call producing output.
ToolCallID string
// ToolName is the name of the tool producing output.
ToolName string
// Chunk is a piece of the tool's output text.
Chunk string
// IsStderr indicates whether this chunk came from stderr.
IsStderr bool
}
// ToolCallContentEvent is sent when a step includes text content alongside tool calls.
// This allows the TUI to display assistant commentary that accompanies tool usage.
type ToolCallContentEvent struct {
@@ -105,8 +123,8 @@ type SpinnerEvent struct {
// MessageCreatedEvent is sent when a new message is added to the message store.
// This allows the TUI to stay in sync with the conversation history.
type MessageCreatedEvent struct {
// Message is the fantasy message that was added to the store.
Message fantasy.Message
// Message is the message that was added to the store.
Message kit.LLMMessage
}
// CompactCompleteEvent is sent when a /compact operation finishes successfully.
@@ -128,6 +146,12 @@ type CompactErrorEvent struct {
Err error
}
// SteerConsumedEvent is sent when one or more steering messages have been
// consumed — either injected mid-turn via PrepareStep, or drained into the
// queue after a turn completes. The TUI uses this to clear the steering
// badge from the display.
type SteerConsumedEvent struct{}
// 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.
@@ -143,6 +167,25 @@ type ModelChangedEvent struct {
// from its WidgetProvider on the next render cycle.
type WidgetUpdateEvent struct{}
// ContentReloadEvent is sent when prompt templates or skills are reloaded
// from disk (e.g. by a file watcher detecting changes). The TUI refreshes
// its autocomplete entries and internal state from the provider callbacks.
type ContentReloadEvent struct{}
// MCPToolsReadyEvent is sent when background MCP tool loading completes.
// The TUI refreshes its tool names and MCP tool count from provider callbacks
// so that /tools and the startup info bar reflect the loaded MCP tools.
type MCPToolsReadyEvent struct{}
// MCPServerLoadedEvent is sent when a single MCP server finishes loading
// (successfully or with error). The TUI displays a system message so users
// see real-time progress as each server initializes.
type MCPServerLoadedEvent struct {
ServerName string
ToolCount int
Error error // nil on success
}
// 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.
+9 -9
View File
@@ -3,14 +3,14 @@ package app
import (
"sync"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
// MessageStore is a thread-safe in-memory store for the conversation history.
// On-disk persistence is handled by the TreeManager at the app/SDK layer.
type MessageStore struct {
mu sync.RWMutex
messages []fantasy.Message
messages []kit.LLMMessage
}
// NewMessageStore creates an empty MessageStore.
@@ -20,14 +20,14 @@ func NewMessageStore() *MessageStore {
// NewMessageStoreWithMessages creates a MessageStore pre-populated with the
// given messages. This is used when loading an existing session at startup.
func NewMessageStoreWithMessages(msgs []fantasy.Message) *MessageStore {
cp := make([]fantasy.Message, len(msgs))
func NewMessageStoreWithMessages(msgs []kit.LLMMessage) *MessageStore {
cp := make([]kit.LLMMessage, len(msgs))
copy(cp, msgs)
return &MessageStore{messages: cp}
}
// Add appends a single message to the store.
func (s *MessageStore) Add(msg fantasy.Message) {
func (s *MessageStore) Add(msg kit.LLMMessage) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, msg)
@@ -36,22 +36,22 @@ func (s *MessageStore) Add(msg fantasy.Message) {
// Replace replaces the entire message history with the given slice. This is
// used after an agent step returns the full updated conversation (including
// tool calls and results).
func (s *MessageStore) Replace(msgs []fantasy.Message) {
func (s *MessageStore) Replace(msgs []kit.LLMMessage) {
s.mu.Lock()
defer s.mu.Unlock()
cp := make([]fantasy.Message, len(msgs))
cp := make([]kit.LLMMessage, len(msgs))
copy(cp, msgs)
s.messages = cp
}
// GetAll returns a snapshot copy of the current message slice.
// The returned slice is safe to modify without affecting the store.
func (s *MessageStore) GetAll() []fantasy.Message {
func (s *MessageStore) GetAll() []kit.LLMMessage {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make([]fantasy.Message, len(s.messages))
cp := make([]kit.LLMMessage, len(s.messages))
copy(cp, s.messages)
return cp
}
+33 -27
View File
@@ -4,16 +4,29 @@ import (
"testing"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
// makeTextMsg builds a minimal fantasy.Message with a single TextPart.
func makeTextMsg(role, text string) fantasy.Message {
return fantasy.Message{
Role: fantasy.MessageRole(role),
// makeTextMsg builds a minimal kit.LLMMessage using fantasy.NewUserMessage
// or constructing with the given role.
func makeTextMsg(role, text string) kit.LLMMessage {
return kit.LLMMessage{
Role: kit.LLMMessageRole(role),
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
}
}
// textOf extracts the plain text from an LLMMessage for assertions.
func textOf(msg kit.LLMMessage) string {
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
return tp.Text
}
}
return ""
}
// --------------------------------------------------------------------------
// NewMessageStore / NewMessageStoreWithMessages
// --------------------------------------------------------------------------
@@ -29,7 +42,7 @@ func TestNewMessageStore_empty(t *testing.T) {
}
func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
msgs := []fantasy.Message{
msgs := []kit.LLMMessage{
makeTextMsg("user", "hello"),
makeTextMsg("assistant", "hi"),
}
@@ -42,7 +55,7 @@ func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
// NewMessageStoreWithMessages must deep-copy the slice so that external
// modifications don't affect the store.
func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
msgs := []fantasy.Message{makeTextMsg("user", "hello")}
msgs := []kit.LLMMessage{makeTextMsg("user", "hello")}
s := NewMessageStoreWithMessages(msgs)
// Mutate the source slice.
@@ -52,9 +65,8 @@ func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 message, got %d", len(got))
}
tp, ok := got[0].Content[0].(fantasy.TextPart)
if !ok || tp.Text != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", tp.Text)
if textOf(got[0]) != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", textOf(got[0]))
}
}
@@ -80,9 +92,8 @@ func TestAdd_preservesOrder(t *testing.T) {
}
got := s.GetAll()
for i, expected := range texts {
tp, ok := got[i].Content[0].(fantasy.TextPart)
if !ok || tp.Text != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, tp.Text)
if textOf(got[i]) != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, textOf(got[i]))
}
}
}
@@ -95,7 +106,7 @@ func TestReplace_swapsHistory(t *testing.T) {
s := NewMessageStore()
s.Add(makeTextMsg("user", "old"))
replacement := []fantasy.Message{
replacement := []kit.LLMMessage{
makeTextMsg("user", "new1"),
makeTextMsg("assistant", "new2"),
}
@@ -105,25 +116,22 @@ func TestReplace_swapsHistory(t *testing.T) {
t.Fatalf("expected 2 messages after replace, got %d", s.Len())
}
got := s.GetAll()
tp0, _ := got[0].Content[0].(fantasy.TextPart)
tp1, _ := got[1].Content[0].(fantasy.TextPart)
if tp0.Text != "new1" || tp1.Text != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", tp0.Text, tp1.Text)
if textOf(got[0]) != "new1" || textOf(got[1]) != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", textOf(got[0]), textOf(got[1]))
}
}
// Replace must deep-copy the incoming slice.
func TestReplace_isolatesInput(t *testing.T) {
s := NewMessageStore()
replacement := []fantasy.Message{makeTextMsg("user", "original")}
replacement := []kit.LLMMessage{makeTextMsg("user", "original")}
s.Replace(replacement)
replacement[0] = makeTextMsg("user", "mutated")
got := s.GetAll()
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", tp.Text)
if textOf(got[0]) != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", textOf(got[0]))
}
}
@@ -140,9 +148,8 @@ func TestGetAll_returnsCopy(t *testing.T) {
got[0] = makeTextMsg("user", "mutated")
internal := s.GetAll()
tp, _ := internal[0].Content[0].(fantasy.TextPart)
if tp.Text != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", tp.Text)
if textOf(internal[0]) != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", textOf(internal[0]))
}
}
@@ -179,9 +186,8 @@ func TestClear_allowsSubsequentAdds(t *testing.T) {
t.Fatalf("expected 1 message after Clear+Add, got %d", s.Len())
}
got := s.GetAll()
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "after" {
t.Fatalf("expected %q, got %q", "after", tp.Text)
if textOf(got[0]) != "after" {
t.Fatalf("expected %q, got %q", "after", textOf(got[0]))
}
}
+4 -6
View File
@@ -21,8 +21,10 @@ type UsageUpdater interface {
// the provider does not return exact counts.
EstimateAndUpdateUsage(inputText, outputText string)
// SetContextTokens records the approximate current context window fill
// level. This should be the final API call's input+output tokens (from
// FinalResponse.Usage), NOT the aggregate TotalUsage.
// level. This should be the sum of ALL token categories from the last
// API call: InputTokens + CacheReadTokens + CacheCreationTokens +
// OutputTokens. With Anthropic prompt caching, InputTokens can be
// near-zero while CacheReadTokens holds the bulk of the context.
SetContextTokens(tokens int)
}
@@ -67,10 +69,6 @@ type Options struct {
// Debug enables verbose debug logging.
Debug bool
// CompactMode selects the compact renderer instead of the block renderer for
// message formatting.
CompactMode bool
// UsageTracker is an optional callback for recording token usage after each
// agent step. When non-nil, the app layer calls UpdateUsage (or
// EstimateAndUpdateUsage as a fallback) using the usage data returned by the
+208 -9
View File
@@ -10,9 +10,10 @@ import (
)
// CredentialStore holds all stored credentials for various providers.
// Currently supports Anthropic credentials with both OAuth and API key authentication methods.
// Currently supports Anthropic and OpenAI credentials with both OAuth and API key authentication methods.
type CredentialStore struct {
Anthropic *AnthropicCredentials `json:"anthropic,omitempty"`
OpenAI *OpenAICredentials `json:"openai,omitempty"`
}
// AnthropicCredentials holds Anthropic API credentials supporting both OAuth
@@ -28,13 +29,44 @@ type AnthropicCredentials struct {
CreatedAt time.Time `json:"created_at"`
}
// OpenAICredentials holds OpenAI API credentials supporting both OAuth
// and API key authentication methods. The Type field indicates which authentication
// method is being used. For OAuth, tokens are stored with expiration timestamps
// for automatic refresh. For API keys, only the key itself is stored.
type OpenAICredentials struct {
Type string `json:"type"` // "oauth" or "api_key"
APIKey string `json:"api_key,omitempty"` // For API key auth
AccessToken string `json:"access_token,omitempty"` // For OAuth
RefreshToken string `json:"refresh_token,omitempty"` // For OAuth
ExpiresAt int64 `json:"expires_at,omitempty"` // For OAuth
AccountID string `json:"account_id,omitempty"` // For OAuth (ChatGPT account ID)
CreatedAt time.Time `json:"created_at"`
}
// oauthTokenExpired reports whether an OAuth token with the given type and
// expiry unix timestamp is past its expiry. Returns false for API key
// credentials or when no expiry is set.
func oauthTokenExpired(credType string, expiresAt int64) bool {
if credType != "oauth" || expiresAt == 0 {
return false
}
return time.Now().Unix() >= expiresAt
}
// oauthTokenNeedsRefresh reports whether an OAuth token will expire within the
// next 5 minutes, allowing proactive refresh before it becomes invalid.
// Returns false for API key credentials or when no expiry is set.
func oauthTokenNeedsRefresh(credType string, expiresAt int64) bool {
if credType != "oauth" || expiresAt == 0 {
return false
}
return time.Now().Unix() >= (expiresAt - 300) // 5 minutes buffer
}
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
// Returns false for API key authentication or if no expiration is set.
func (c *AnthropicCredentials) IsExpired() bool {
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= c.ExpiresAt
return oauthTokenExpired(c.Type, c.ExpiresAt)
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
@@ -42,10 +74,21 @@ func (c *AnthropicCredentials) IsExpired() bool {
// to avoid authentication failures during operations. Returns false for API key
// authentication or if no expiration is set.
func (c *AnthropicCredentials) NeedsRefresh() bool {
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
// Returns false for API key authentication or if no expiration is set.
func (c *OpenAICredentials) IsExpired() bool {
return oauthTokenExpired(c.Type, c.ExpiresAt)
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
// will expire within the next 5 minutes. This allows for proactive token refresh
// to avoid authentication failures during operations. Returns false for API key
// authentication or if no expiration is set.
func (c *OpenAICredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// CredentialManager handles secure storage and retrieval of authentication credentials.
@@ -212,6 +255,142 @@ func (cm *CredentialManager) HasAnthropicCredentials() (bool, error) {
}
}
// SetOpenAICredentials stores OpenAI API key credentials. It validates the
// API key format before storing. The API key must start with "sk-" and be
// at least 20 characters long. Returns an error if the API key is invalid or
// if storage fails.
func (cm *CredentialManager) SetOpenAICredentials(apiKey string) error {
if err := validateOpenAIAPIKey(apiKey); err != nil {
return err
}
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.OpenAI = &OpenAICredentials{
Type: "api_key",
APIKey: apiKey,
CreatedAt: time.Now(),
}
return cm.SaveCredentials(store)
}
// GetOpenAICredentials retrieves stored OpenAI credentials. Returns nil if
// no credentials are stored. The returned credentials may be either OAuth or API
// key type, check the Type field to determine which.
func (cm *CredentialManager) GetOpenAICredentials() (*OpenAICredentials, error) {
store, err := cm.LoadCredentials()
if err != nil {
return nil, err
}
return store.OpenAI, nil
}
// RemoveOpenAICredentials removes stored OpenAI credentials from storage.
// If this was the only credential stored, the entire credentials file is removed.
// Returns an error if the removal fails.
func (cm *CredentialManager) RemoveOpenAICredentials() error {
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.OpenAI = nil
// If store is empty, remove the file entirely
if store.Anthropic == nil && store.OpenAI == nil {
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove credentials file: %w", err)
}
return nil
}
return cm.SaveCredentials(store)
}
// HasOpenAICredentials checks if valid OpenAI credentials are stored.
// Returns true if either a non-empty OAuth access token or API key is present,
// false otherwise. Returns an error if credentials cannot be loaded.
func (cm *CredentialManager) HasOpenAICredentials() (bool, error) {
creds, err := cm.GetOpenAICredentials()
if err != nil {
return false, err
}
if creds == nil {
return false, nil
}
// Check based on credential type
switch creds.Type {
case "oauth":
return creds.AccessToken != "", nil
case "api_key":
return creds.APIKey != "", nil
default:
return false, nil
}
}
// SetOpenAIOAuthCredentials stores OpenAI OAuth credentials in the credential manager's secure storage.
// The credentials should include access token, refresh token, and expiration information.
// Returns an error if the credentials cannot be saved.
func (cm *CredentialManager) SetOpenAIOAuthCredentials(creds *OpenAICredentials) error {
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.OpenAI = creds
return cm.SaveCredentials(store)
}
// GetValidOpenAIAccessToken returns a valid access token for API requests. For OAuth credentials,
// it automatically refreshes the token if it's expired or about to expire. For API key
// credentials, it simply returns the API key. Returns an error if no credentials are found,
// if token refresh fails, or if the credential type is unknown.
func (cm *CredentialManager) GetValidOpenAIAccessToken() (string, error) {
creds, err := cm.GetOpenAICredentials()
if err != nil {
return "", err
}
if creds == nil {
return "", fmt.Errorf("no credentials found")
}
// For API key auth, return the API key
if creds.Type == "api_key" {
return creds.APIKey, nil
}
// For OAuth, check if token needs refresh
if creds.Type == "oauth" {
if creds.NeedsRefresh() {
// Refresh the token
client := NewOpenAIOAuthClient()
newCreds, err := client.RefreshToken(creds.RefreshToken)
if err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
}
// Update stored credentials
if err := cm.SetOpenAIOAuthCredentials(newCreds); err != nil {
return "", fmt.Errorf("failed to save refreshed token: %w", err)
}
return newCreds.AccessToken, nil
}
return creds.AccessToken, nil
}
return "", fmt.Errorf("unknown credential type: %s", creds.Type)
}
// GetCredentialsPath returns the absolute path to the credentials JSON file.
// This is useful for debugging or displaying the storage location to users.
func (cm *CredentialManager) GetCredentialsPath() string {
@@ -238,6 +417,26 @@ func validateAnthropicAPIKey(apiKey string) error {
return nil
}
// validateOpenAIAPIKey validates the format of an OpenAI API key
func validateOpenAIAPIKey(apiKey string) error {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return fmt.Errorf("API key cannot be empty")
}
// OpenAI API keys typically start with "sk-" and are quite long
if !strings.HasPrefix(apiKey, "sk-") {
return fmt.Errorf("invalid OpenAI API key format (should start with 'sk-')")
}
if len(apiKey) < 20 {
return fmt.Errorf("API key appears to be too short")
}
return nil
}
// GetAnthropicAPIKey retrieves an Anthropic API key from multiple sources in priority order:
// 1. Command-line flag value (highest priority)
// 2. Stored credentials (OAuth or API key)
+269 -3
View File
@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -30,6 +31,7 @@ type OAuthClient struct {
type AuthData struct {
URL string
Verifier string
State string // Optional state parameter for CSRF protection
}
// NewOAuthClient creates a new OAuth client configured for Anthropic's OAuth service.
@@ -49,12 +51,12 @@ func NewOAuthClient() *OAuthClient {
}
}
// GeneratePKCE generates a cryptographically secure PKCE verifier and challenge pair
// generatePKCE generates a cryptographically secure PKCE verifier and challenge pair
// for the OAuth 2.0 PKCE flow. The verifier is a random 32-byte string encoded as
// base64url, and the challenge is the SHA256 hash of the verifier, also base64url encoded.
// Returns the verifier (to be stored securely), challenge (to be sent with auth request),
// and any error encountered during generation.
func GeneratePKCE() (verifier, challenge string, err error) {
func generatePKCE() (verifier, challenge string, err error) {
// Generate 32 bytes of random data
verifierBytes := make([]byte, 32)
if _, err := rand.Read(verifierBytes); err != nil {
@@ -76,7 +78,7 @@ func GeneratePKCE() (verifier, challenge string, err error) {
// and PKCE challenge. Returns an AuthData structure containing the URL for user
// authentication and the PKCE verifier for the subsequent code exchange.
func (c *OAuthClient) GetAuthorizationURL() (*AuthData, error) {
verifier, challenge, err := GeneratePKCE()
verifier, challenge, err := generatePKCE()
if err != nil {
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
}
@@ -199,6 +201,270 @@ func (c *OAuthClient) parseCodeAndState(code string) (parsedCode, parsedState st
return
}
// OpenAIOAuthClient handles OAuth 2.0 authentication flow with OpenAI Codex (ChatGPT Plus/Pro).
// This uses OpenAI's auth0-based OAuth service for ChatGPT account authentication.
type OpenAIOAuthClient struct {
ClientID string
AuthorizeURL string
TokenURL string
RedirectURI string
Scopes string
}
// NewOpenAIOAuthClient creates a new OAuth client configured for OpenAI Codex OAuth.
// This uses the public client ID for CLI applications with PKCE for security.
func NewOpenAIOAuthClient() *OpenAIOAuthClient {
return &OpenAIOAuthClient{
// Public client ID for OpenAI Codex CLI OAuth
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
AuthorizeURL: "https://auth.openai.com/oauth/authorize",
TokenURL: "https://auth.openai.com/oauth/token",
RedirectURI: "http://localhost:1455/auth/callback",
Scopes: "openid profile email offline_access",
}
}
// GetAuthorizationURL generates a complete authorization URL for the OAuth flow with
// PKCE parameters. Returns an AuthData structure containing the URL for user
// authentication and the PKCE verifier for the subsequent code exchange.
func (c *OpenAIOAuthClient) GetAuthorizationURL() (*AuthData, error) {
verifier, challenge, err := generatePKCE()
if err != nil {
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
}
// Generate random state
stateBytes := make([]byte, 16)
if _, err := rand.Read(stateBytes); err != nil {
return nil, fmt.Errorf("failed to generate state: %w", err)
}
state := fmt.Sprintf("%x", stateBytes)
params := url.Values{
"response_type": {"code"},
"client_id": {c.ClientID},
"redirect_uri": {c.RedirectURI},
"scope": {c.Scopes},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {state},
"id_token_add_organizations": {"true"},
"codex_cli_simplified_flow": {"true"},
"originator": {"kit"},
}
authURL := fmt.Sprintf("%s?%s", c.AuthorizeURL, params.Encode())
return &AuthData{
URL: authURL,
Verifier: verifier,
State: state,
}, nil
}
// ExchangeCode exchanges an authorization code for access and refresh tokens.
// The code parameter should be the authorization code received from the OAuth callback.
// The verifier parameter must be the same PKCE verifier generated during GetAuthorizationURL.
// Returns OpenAICredentials containing the tokens, expiration, and account ID.
func (c *OpenAIOAuthClient) ExchangeCode(code, verifier string) (*OpenAICredentials, error) {
return c.exchangeAuthorizationCode(code, verifier, c.RedirectURI)
}
// exchangeAuthorizationCode performs the token exchange with the OAuth server
func (c *OpenAIOAuthClient) exchangeAuthorizationCode(code, verifier, redirectUri string) (*OpenAICredentials, error) {
data := url.Values{
"grant_type": {"authorization_code"},
"client_id": {c.ClientID},
"code": {code},
"code_verifier": {verifier},
"redirect_uri": {redirectUri},
}
req, err := http.NewRequestWithContext(context.Background(), "POST", c.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make token request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token exchange failed: %s", string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
if tokenResp.AccessToken == "" || tokenResp.RefreshToken == "" {
return nil, fmt.Errorf("token response missing required fields")
}
// Extract account ID from JWT token
accountID := extractOpenAIAccountID(tokenResp.AccessToken)
if accountID == "" {
return nil, fmt.Errorf("failed to extract account ID from token")
}
return &OpenAICredentials{
Type: "oauth",
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
CreatedAt: time.Now(),
AccountID: accountID,
}, nil
}
// RefreshToken refreshes an expired or expiring access token using a refresh token.
// Returns new OpenAICredentials with updated access token, refresh token (may be
// rotated), and new expiration timestamp. Returns an error if the refresh fails or
// the refresh token is invalid.
func (c *OpenAIOAuthClient) RefreshToken(refreshToken string) (*OpenAICredentials, error) {
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"client_id": {c.ClientID},
}
req, err := http.NewRequestWithContext(context.Background(), "POST", c.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make refresh request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token refresh failed: %s", string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode refresh response: %w", err)
}
if tokenResp.AccessToken == "" || tokenResp.RefreshToken == "" {
return nil, fmt.Errorf("refresh response missing required fields")
}
// Extract account ID from JWT token
accountID := extractOpenAIAccountID(tokenResp.AccessToken)
if accountID == "" {
return nil, fmt.Errorf("failed to extract account ID from refreshed token")
}
return &OpenAICredentials{
Type: "oauth",
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
CreatedAt: time.Now(),
AccountID: accountID,
}, nil
}
// extractOpenAIAccountID extracts the ChatGPT account ID from a JWT access token.
// The account ID is stored in the claim path https://api.openai.com/auth.chatgpt_account_id
func extractOpenAIAccountID(token string) string {
// JWT tokens are base64-encoded JSON payloads
parts := strings.Split(token, ".")
if len(parts) != 3 {
return ""
}
// Decode payload (second part)
payload := parts[1]
// Add padding if needed
if len(payload)%4 != 0 {
payload += strings.Repeat("=", 4-len(payload)%4)
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return ""
}
var claims map[string]any
if err := json.Unmarshal(decoded, &claims); err != nil {
return ""
}
// Navigate to the claim path: https://api.openai.com/auth.chatgpt_account_id
authPath, ok := claims["https://api.openai.com/auth"].(map[string]any)
if !ok {
return ""
}
accountID, ok := authPath["chatgpt_account_id"].(string)
if !ok {
return ""
}
return accountID
}
// ParseOpenAIAuthorizationInput parses various forms of authorization input:
// - Full callback URL: http://localhost:1455/auth/callback?code=xxx&state=yyy
// - Code#State format: abc123#state456
// - Query string: code=abc123&state=state456
// - Just the code: abc123
func ParseOpenAIAuthorizationInput(input string) (code, state string) {
input = strings.TrimSpace(input)
if input == "" {
return "", ""
}
// Try parsing as URL
if strings.HasPrefix(input, "http") {
if u, err := url.Parse(input); err == nil {
return u.Query().Get("code"), u.Query().Get("state")
}
}
// Try code#state format
if strings.Contains(input, "#") {
parts := strings.SplitN(input, "#", 2)
return parts[0], parts[1]
}
// Try query string format
if strings.Contains(input, "code=") {
if values, err := url.ParseQuery(input); err == nil {
return values.Get("code"), values.Get("state")
}
}
// Assume it's just the code
return input, ""
}
// SetOAuthCredentials stores OAuth credentials in the credential manager's secure storage.
// The credentials should include access token, refresh token, and expiration information.
// Returns an error if the credentials cannot be saved.
+2 -2
View File
@@ -71,5 +71,5 @@ func DetectMediaType(data []byte) string {
// ErrNoImage is returned when the clipboard does not contain image data.
var ErrNoImage = fmt.Errorf("no image data on clipboard")
// ErrNoClipboardTool is returned when no suitable clipboard tool is found.
var ErrNoClipboardTool = fmt.Errorf("no clipboard tool available (install xclip, wl-paste, or use macOS)")
// errNoClipboardTool is returned when no suitable clipboard tool is found.
var errNoClipboardTool = fmt.Errorf("no clipboard tool available (install xclip, wl-paste, or use macOS)")
+2 -3
View File
@@ -7,9 +7,8 @@ import (
)
// ReadImage reads image data from the system clipboard on macOS.
// It uses osascript to check if the clipboard contains an image and then
// reads the data using a temporary approach. If the clipboard contains
// an image, it writes it to stdout as PNG data.
// It uses osascript to check if the clipboard contains an image via
// NSPasteboard and writes it to stdout as PNG data.
func ReadImage() (*ImageData, error) {
// Use osascript to write clipboard image to stdout via a pipe.
// The script checks if the clipboard has a «class PNGf» item.
+1 -1
View File
@@ -41,7 +41,7 @@ func ReadImage() (*ImageData, error) {
return nil, ErrNoImage
}
return nil, ErrNoClipboardTool
return nil, errNoClipboardTool
}
// readWithXclip reads image data using xclip.
+1 -1
View File
@@ -5,5 +5,5 @@ package clipboard
// ReadImage reads image data from the system clipboard on Windows.
// Windows clipboard image support is not yet implemented.
func ReadImage() (*ImageData, error) {
return nil, ErrNoClipboardTool
return nil, errNoClipboardTool
}
+366 -32
View File
@@ -5,10 +5,18 @@
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
// count. Auto-compaction fires when estimated context usage exceeds
// contextWindow ReserveTokens.
//
// Features modelled after pi's compaction system:
// - Tool result truncation (2000 char max) during serialisation
// - Split turn handling: when a single turn exceeds the keep budget,
// the turn prefix is summarised separately and merged
// - Cumulative file tracking: read and modified files extracted from
// tool calls and carried forward across compactions
package compaction
import (
"context"
"encoding/json"
"fmt"
"strings"
@@ -19,8 +27,8 @@ import (
// Token estimation
// ---------------------------------------------------------------------------
// EstimateTokens provides a rough token count (~4 chars per token).
func EstimateTokens(text string) int {
// estimateTokens provides a rough token count (~4 chars per token).
func estimateTokens(text string) int {
return len(text) / 4
}
@@ -40,7 +48,7 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
total := 0
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
total += EstimateTokens(tp.Text)
total += estimateTokens(tp.Text)
}
}
return total
@@ -66,10 +74,13 @@ func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens
// CompactionResult contains statistics from a compaction operation.
type CompactionResult struct {
Summary string // LLM-generated summary of compacted messages
OriginalTokens int // Estimated token count before compaction
CompactedTokens int // Estimated token count after compaction
MessagesRemoved int // Number of messages replaced by the summary
Summary string // LLM-generated summary of compacted messages
OriginalTokens int // Estimated token count before compaction
CompactedTokens int // Estimated token count after compaction
MessagesRemoved int // Number of messages replaced by the summary
CutPoint int // Index in the original messages where the cut was made
ReadFiles []string // Files read during the compacted conversation
ModifiedFiles []string // Files modified during the compacted conversation
}
// CompactionOptions configures compaction behaviour. Token-based defaults
@@ -130,8 +141,34 @@ Use this EXACT format:
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
<read-files>
[One file path per line for files that were read during the conversation]
</read-files>
<modified-files>
[One file path per line for files that were created, edited, or written during the conversation]
</modified-files>
Keep each section concise. Preserve exact file paths, function names, and error messages.`
// ---------------------------------------------------------------------------
// Tool result truncation
// ---------------------------------------------------------------------------
// maxToolResultChars is the maximum length of tool result text preserved
// during serialisation. Longer results are truncated with a marker.
const maxToolResultChars = 2000
// truncateToolResult truncates text to maxToolResultChars, appending a
// marker indicating how many characters were removed.
func truncateToolResult(text string) string {
if len(text) <= maxToolResultChars {
return text
}
truncated := len(text) - maxToolResultChars
return text[:maxToolResultChars] + fmt.Sprintf("\n[...%d chars truncated]", truncated)
}
// ---------------------------------------------------------------------------
// Cut point (token-based)
// ---------------------------------------------------------------------------
@@ -143,11 +180,26 @@ func isValidCutPoint(msg fantasy.Message) bool {
return msg.Role != fantasy.MessageRoleTool
}
// findTurnStart returns the index of the user message that starts the turn
// containing messages[idx]. A "turn" starts with a user message and includes
// all subsequent assistant/tool messages until the next user message.
func findTurnStart(messages []fantasy.Message, idx int) int {
for i := idx; i >= 0; i-- {
if messages[i].Role == fantasy.MessageRoleUser {
return i
}
}
return 0
}
// FindCutPoint walks backward from the end of messages, accumulating tokens
// until the keepRecentTokens budget is filled. Returns the index that
// separates "old" messages (0..cutPoint-1, to be summarised) from "recent"
// messages (cutPoint..end, to be preserved).
//
// The cut point prefers turn boundaries (user messages). When a single turn
// exceeds the budget, the cut lands mid-turn (IsSplitTurn returns true).
//
// Returns 0 if there are fewer than 2 messages or all messages fit within
// the keep budget.
func FindCutPoint(messages []fantasy.Message, keepRecentTokens int) int {
@@ -193,6 +245,23 @@ func FindCutPoint(messages []fantasy.Message, keepRecentTokens int) int {
return 0
}
// IsSplitTurn returns true if the cut point lands in the middle of a turn
// (i.e. the message at cutPoint is not a user message, meaning we're
// splitting a single turn's assistant/tool messages).
func IsSplitTurn(messages []fantasy.Message, cutPoint int) bool {
if cutPoint <= 0 || cutPoint >= len(messages) {
return false
}
// If the cut point is at a user message, it's a clean turn boundary.
if messages[cutPoint].Role == fantasy.MessageRoleUser {
return false
}
// Otherwise we're cutting mid-turn — check if the turn started before
// the cut point.
turnStart := findTurnStart(messages, cutPoint)
return turnStart < cutPoint
}
// forceCutPoint returns a cut point that keeps only the last non-tool
// message, summarising everything before it. Used when the budget-based
// FindCutPoint returns 0 but the caller wants to compact anyway (manual
@@ -207,12 +276,104 @@ func forceCutPoint(messages []fantasy.Message) int {
return 0
}
// ---------------------------------------------------------------------------
// File tracking
// ---------------------------------------------------------------------------
// fileOps contains cumulative file operation tracking.
type fileOps struct {
ReadFiles map[string]bool
ModifiedFiles map[string]bool
}
func newFileOps() *fileOps {
return &fileOps{
ReadFiles: make(map[string]bool),
ModifiedFiles: make(map[string]bool),
}
}
// extractFileOps scans messages for tool calls and extracts file paths.
// It recognises the built-in Kit tools: read, write, edit, bash, grep, find, ls.
func extractFileOps(messages []fantasy.Message) *fileOps {
ops := newFileOps()
for _, msg := range messages {
for _, part := range msg.Content {
tc, ok := part.(fantasy.ToolCallPart)
if !ok {
continue
}
// Parse the JSON input to extract path arguments.
var args map[string]any
if err := json.Unmarshal([]byte(tc.Input), &args); err != nil {
continue
}
path, _ := args["path"].(string)
if path == "" {
continue
}
switch tc.ToolName {
case "read", "grep", "find", "ls":
ops.ReadFiles[path] = true
case "write", "edit":
ops.ModifiedFiles[path] = true
}
}
}
return ops
}
// merge combines another fileOps into this one (for cumulative tracking).
func (f *fileOps) merge(other *fileOps) {
if other == nil {
return
}
for k := range other.ReadFiles {
f.ReadFiles[k] = true
}
for k := range other.ModifiedFiles {
f.ModifiedFiles[k] = true
}
}
// mergeSlices adds previously tracked file lists (from a prior compaction).
func (f *fileOps) mergeSlices(readFiles, modifiedFiles []string) {
for _, p := range readFiles {
f.ReadFiles[p] = true
}
for _, p := range modifiedFiles {
f.ModifiedFiles[p] = true
}
}
// sortedKeys returns the keys of a bool map sorted alphabetically.
func sortedKeys(m map[string]bool) []string {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// Simple sort — no need for sort package for small lists.
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
return keys
}
// ---------------------------------------------------------------------------
// Message serialisation
// ---------------------------------------------------------------------------
// roleLabel returns a human-readable label for a fantasy message role,
// roleLabel returns a human-readable label for a fantasy message role.
func roleLabel(role fantasy.MessageRole) string {
switch role {
case fantasy.MessageRoleUser:
@@ -229,16 +390,26 @@ 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
// representation suitable for sending to the summarisation LLM. Tool result
// text is truncated to maxToolResultChars to keep the summarisation request
// within reasonable token budgets.
func serializeMessages(messages []fantasy.Message) string {
var sb strings.Builder
for _, msg := range messages {
sb.WriteString(roleLabel(msg.Role))
sb.WriteString(":\n")
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
sb.WriteString(tp.Text)
switch p := part.(type) {
case fantasy.TextPart:
if msg.Role == fantasy.MessageRoleTool {
sb.WriteString(truncateToolResult(p.Text))
} else {
sb.WriteString(p.Text)
}
case fantasy.ToolCallPart:
fmt.Fprintf(&sb, "[Tool call: %s(%s)]", p.ToolName, truncateToolResult(p.Input))
case fantasy.ReasoningPart:
fmt.Fprintf(&sb, "[Thinking]: %s", truncateToolResult(p.Text))
}
}
sb.WriteString("\n\n")
@@ -250,6 +421,17 @@ func serializeMessages(messages []fantasy.Message) string {
// Compact
// ---------------------------------------------------------------------------
// PreviousCompaction carries file tracking state from a prior compaction so
// that file operations accumulate across multiple compactions.
type PreviousCompaction struct {
ReadFiles []string
ModifiedFiles []string
}
// StreamCallback is called for each chunk of text during streaming compaction.
// Return a non-nil error to cancel the stream.
type StreamCallback func(delta string) error
// Compact summarises older messages using the LLM, returning the compaction
// result and a new message slice (summary message + preserved recent
// messages).
@@ -261,12 +443,19 @@ func serializeMessages(messages []fantasy.Message) string {
// customInstructions is optional text appended to the summary prompt (e.g.
// "Focus on the API design decisions"). Pass "" to use the default prompt
// only.
//
// prev carries file tracking from a previous compaction for cumulative
// tracking. Pass nil if there is no prior compaction.
// onChunk is an optional callback for streaming summary text. Pass nil for
// non-streaming compaction.
func Compact(
ctx context.Context,
model fantasy.LanguageModel,
messages []fantasy.Message,
opts CompactionOptions,
customInstructions string,
prev *PreviousCompaction,
onChunk StreamCallback,
) (*CompactionResult, []fantasy.Message, error) {
opts.defaults()
@@ -289,30 +478,30 @@ func Compact(
recentMessages := messages[cutPoint:]
originalTokens := EstimateMessageTokens(messages)
// Serialise old messages to text.
conversationText := serializeMessages(oldMessages)
// Build the user-facing prompt: conversation text + summary instructions.
userPrompt := opts.SummaryPrompt
if userPrompt == "" {
userPrompt = defaultSummaryPrompt
}
if customInstructions != "" {
userPrompt += "\n\nAdditional instructions: " + customInstructions
// Extract file operations from old messages.
ops := extractFileOps(oldMessages)
// Accumulate from previous compaction if present.
if prev != nil {
ops.mergeSlices(prev.ReadFiles, prev.ModifiedFiles)
}
// Also scan recent messages for file ops (they'll be carried forward).
recentOps := extractFileOps(recentMessages)
ops.merge(recentOps)
// Create a lightweight agent (no tools) just for summarisation.
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: conversationText + "\n\n" + userPrompt,
})
// Handle split turns: when the cut lands mid-turn, summarise the turn
// prefix separately and merge with the history summary.
var summaryText string
var err error
if IsSplitTurn(messages, cutPoint) {
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions, onChunk)
} else {
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions, onChunk)
}
if err != nil {
return nil, nil, fmt.Errorf("compaction summarisation failed: %w", err)
return nil, nil, err
}
summaryText := result.Response.Content.Text()
if summaryText == "" {
return nil, nil, fmt.Errorf("compaction produced an empty summary")
}
@@ -338,5 +527,150 @@ func Compact(
OriginalTokens: originalTokens,
CompactedTokens: compactedTokens,
MessagesRemoved: len(oldMessages),
CutPoint: cutPoint,
ReadFiles: sortedKeys(ops.ReadFiles),
ModifiedFiles: sortedKeys(ops.ModifiedFiles),
}, newMessages, nil
}
// compactNormal generates a summary for a clean turn-boundary cut.
// If onChunk is provided, text deltas are streamed to it.
func compactNormal(
ctx context.Context,
model fantasy.LanguageModel,
oldMessages []fantasy.Message,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
conversationText := serializeMessages(oldMessages)
return generateSummary(ctx, model, conversationText, opts, customInstructions, onChunk)
}
// compactSplitTurn handles the case where the cut point lands mid-turn.
// It generates two summaries and merges them:
// 1. History summary: all complete turns before the split turn
// 2. Turn prefix summary: the early part of the split turn (from the turn's
// user message up to the cut point)
//
// The merged result preserves context from both the older history and the
// beginning of the current long turn.
// If onChunk is provided, both summaries and the separator are streamed.
func compactSplitTurn(
ctx context.Context,
model fantasy.LanguageModel,
oldMessages []fantasy.Message,
allMessages []fantasy.Message,
cutPoint int,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
// Find where the split turn starts.
turnStart := findTurnStart(allMessages, cutPoint)
// Messages before the turn are the "history" portion.
historyMessages := oldMessages
if turnStart > 0 && turnStart < len(oldMessages) {
historyMessages = oldMessages[:turnStart]
}
// The turn prefix: from turnStart to cutPoint.
turnPrefixMessages := allMessages[turnStart:cutPoint]
var historySummary string
var err error
// Generate history summary if there are complete turns before the split.
if len(historyMessages) >= 2 {
historySummary, err = generateSummary(ctx, model,
serializeMessages(historyMessages), opts, "", onChunk)
if err != nil {
return "", fmt.Errorf("split turn history summary failed: %w", err)
}
}
// Stream the separator between history and turn prefix summaries.
if onChunk != nil && historySummary != "" {
if err := onChunk("\n\n---\n\n## Current Turn (in progress)\n\n"); err != nil {
return "", fmt.Errorf("streaming separator failed: %w", err)
}
}
// Generate turn prefix summary.
turnPrefixText := serializeMessages(turnPrefixMessages)
turnPrefixPrompt := "The messages above are the BEGINNING of a long turn that was split. " +
"Summarize the work done so far in this turn, preserving tool call results, " +
"file changes, and progress. Another LLM will continue this turn."
if customInstructions != "" {
turnPrefixPrompt += "\n\nAdditional instructions: " + customInstructions
}
turnPrefixSummary, err := generateSummary(ctx, model, turnPrefixText, opts, turnPrefixPrompt, onChunk)
if err != nil {
return "", fmt.Errorf("split turn prefix summary failed: %w", err)
}
// Merge the two summaries.
if historySummary != "" && turnPrefixSummary != "" {
return historySummary + "\n\n---\n\n## Current Turn (in progress)\n\n" + turnPrefixSummary, nil
}
if turnPrefixSummary != "" {
return turnPrefixSummary, nil
}
return historySummary, nil
}
// generateSummary calls the LLM to produce a structured summary.
// If onChunk is provided, the summary is streamed using Agent.Stream().
func generateSummary(
ctx context.Context,
model fantasy.LanguageModel,
conversationText string,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
userPrompt := opts.SummaryPrompt
if userPrompt == "" {
userPrompt = defaultSummaryPrompt
}
if customInstructions != "" {
userPrompt += "\n\nAdditional instructions: " + customInstructions
}
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
prompt := conversationText + "\n\n" + userPrompt
// Use streaming if onChunk is provided.
if onChunk != nil {
var fullText strings.Builder
_, err := summaryAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: prompt,
OnTextDelta: func(_, delta string) error {
if delta != "" {
fullText.WriteString(delta)
return onChunk(delta)
}
return nil
},
})
if err != nil {
return "", fmt.Errorf("compaction summarisation (streaming) failed: %w", err)
}
return fullText.String(), nil
}
// Non-streaming path.
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: prompt,
})
if err != nil {
return "", fmt.Errorf("compaction summarisation failed: %w", err)
}
return result.Response.Content.Text(), nil
}
+170 -4
View File
@@ -36,9 +36,9 @@ func TestEstimateTokens(t *testing.T) {
{"hello world", 2}, // 11 / 4 = 2
}
for _, tt := range tests {
got := EstimateTokens(tt.text)
got := estimateTokens(tt.text)
if got != tt.want {
t.Errorf("EstimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
t.Errorf("estimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
}
}
}
@@ -243,7 +243,7 @@ func TestCompact_TooFewMessages(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleUser, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "")
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -262,7 +262,7 @@ func TestCompact_WithinBudget(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleAssistant, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "")
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -273,3 +273,169 @@ func TestCompact_WithinBudget(t *testing.T) {
t.Errorf("messages changed: got %d, want %d", len(newMsgs), len(msgs))
}
}
// ---------------------------------------------------------------------------
// Tool result truncation
// ---------------------------------------------------------------------------
func TestTruncateToolResult(t *testing.T) {
// Short text — no truncation.
short := strings.Repeat("x", 100)
if got := truncateToolResult(short); got != short {
t.Errorf("truncated short text unexpectedly")
}
// Exactly at limit.
exact := strings.Repeat("x", maxToolResultChars)
if got := truncateToolResult(exact); got != exact {
t.Errorf("truncated text at exact limit")
}
// Over limit.
over := strings.Repeat("x", maxToolResultChars+500)
got := truncateToolResult(over)
if len(got) > maxToolResultChars+50 { // allow room for marker
t.Errorf("truncated text too long: %d chars", len(got))
}
if !strings.Contains(got, "500 chars truncated") {
t.Errorf("truncation marker missing, got: %s", got[maxToolResultChars:])
}
}
func TestSerializeMessages_TruncatesToolResults(t *testing.T) {
longResult := strings.Repeat("R", maxToolResultChars+1000)
msgs := []fantasy.Message{
makeTextMessage(fantasy.MessageRoleUser, "question"),
{
Role: fantasy.MessageRoleTool,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: longResult}},
},
}
serialized := serializeMessages(msgs)
if strings.Contains(serialized, longResult) {
t.Error("tool result was not truncated during serialisation")
}
if !strings.Contains(serialized, "chars truncated") {
t.Error("truncation marker missing in serialised output")
}
}
func TestSerializeMessages_PreservesNonToolText(t *testing.T) {
longText := strings.Repeat("T", maxToolResultChars+1000)
msgs := []fantasy.Message{
makeTextMessage(fantasy.MessageRoleUser, longText),
}
serialized := serializeMessages(msgs)
if !strings.Contains(serialized, longText) {
t.Error("non-tool text was unexpectedly truncated")
}
}
// ---------------------------------------------------------------------------
// Split turn detection
// ---------------------------------------------------------------------------
func TestIsSplitTurn(t *testing.T) {
msgs := []fantasy.Message{
makeTextMessageN(fantasy.MessageRoleUser, 400), // 0: turn 1 user
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 1: turn 1 assistant
makeTextMessageN(fantasy.MessageRoleUser, 400), // 2: turn 2 user
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 3: turn 2 assistant
makeTextMessageN(fantasy.MessageRoleTool, 400), // 4: turn 2 tool result
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 5: turn 2 assistant
}
tests := []struct {
name string
cutPoint int
want bool
}{
{"at user message (turn boundary)", 2, false},
{"at assistant mid-turn", 3, true},
{"at assistant after tool (mid-turn)", 5, true},
{"at 0 (no cut)", 0, false},
{"beyond range", 10, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsSplitTurn(msgs, tt.cutPoint)
if got != tt.want {
t.Errorf("IsSplitTurn(msgs, %d) = %v, want %v", tt.cutPoint, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// File operations extraction
// ---------------------------------------------------------------------------
func TestExtractFileOps(t *testing.T) {
// Create messages with tool calls.
msgs := []fantasy.Message{
{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{
fantasy.ToolCallPart{ToolCallID: "1", ToolName: "read", Input: `{"path":"src/main.go"}`},
fantasy.ToolCallPart{ToolCallID: "2", ToolName: "write", Input: `{"path":"src/out.go"}`},
fantasy.ToolCallPart{ToolCallID: "3", ToolName: "edit", Input: `{"path":"src/edit.go"}`},
fantasy.ToolCallPart{ToolCallID: "4", ToolName: "grep", Input: `{"path":"src/search"}`},
},
},
}
ops := extractFileOps(msgs)
if !ops.ReadFiles["src/main.go"] {
t.Error("read file not tracked: src/main.go")
}
if !ops.ReadFiles["src/search"] {
t.Error("grep path not tracked as read: src/search")
}
if !ops.ModifiedFiles["src/out.go"] {
t.Error("write file not tracked: src/out.go")
}
if !ops.ModifiedFiles["src/edit.go"] {
t.Error("edit file not tracked: src/edit.go")
}
}
func TestFileOps_MergeSlices(t *testing.T) {
ops := newFileOps()
ops.ReadFiles["a.go"] = true
ops.ModifiedFiles["b.go"] = true
ops.mergeSlices(
[]string{"c.go", "a.go"},
[]string{"d.go"},
)
if len(ops.ReadFiles) != 2 { // a.go, c.go
t.Errorf("ReadFiles len = %d, want 2", len(ops.ReadFiles))
}
if len(ops.ModifiedFiles) != 2 { // b.go, d.go
t.Errorf("ModifiedFiles len = %d, want 2", len(ops.ModifiedFiles))
}
}
func TestSortedKeys(t *testing.T) {
m := map[string]bool{"c": true, "a": true, "b": true}
got := sortedKeys(m)
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("sortedKeys len = %d, want %d", len(got), len(want))
}
for i, v := range got {
if v != want[i] {
t.Errorf("sortedKeys[%d] = %q, want %q", i, v, want[i])
}
}
}
func TestSortedKeys_Empty(t *testing.T) {
got := sortedKeys(nil)
if got != nil {
t.Errorf("sortedKeys(nil) = %v, want nil", got)
}
}
+157 -45
View File
@@ -105,42 +105,104 @@ type AdaptiveColor struct {
Dark string `json:"dark,omitempty" yaml:"dark,omitempty"`
}
// MarkdownThemeConfig defines color overrides for markdown rendering and
// syntax highlighting.
type MarkdownThemeConfig struct {
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
Heading AdaptiveColor `json:"heading,omitzero" yaml:"heading,omitempty"`
Emph AdaptiveColor `json:"emph,omitzero" yaml:"emph,omitempty"`
Strong AdaptiveColor `json:"strong,omitzero" yaml:"strong,omitempty"`
Link AdaptiveColor `json:"link,omitzero" yaml:"link,omitempty"`
Code AdaptiveColor `json:"code,omitzero" yaml:"code,omitempty"`
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
Keyword AdaptiveColor `json:"keyword,omitzero" yaml:"keyword,omitempty"`
String AdaptiveColor `json:"string,omitzero" yaml:"string,omitempty"`
Number AdaptiveColor `json:"number,omitzero" yaml:"number,omitempty"`
Comment AdaptiveColor `json:"comment,omitzero" yaml:"comment,omitempty"`
}
// Theme defines the color scheme for the application UI with adaptive colors
// that support both light and dark modes.
type Theme struct {
Primary AdaptiveColor `json:"primary" yaml:"primary"`
Secondary AdaptiveColor `json:"secondary" yaml:"secondary"`
Success AdaptiveColor `json:"success" yaml:"success"`
Warning AdaptiveColor `json:"warning" yaml:"warning"`
Error AdaptiveColor `json:"error" yaml:"error"`
Info AdaptiveColor `json:"info" yaml:"info"`
Text AdaptiveColor `json:"text" yaml:"text"`
Muted AdaptiveColor `json:"muted" yaml:"muted"`
VeryMuted AdaptiveColor `json:"very-muted" yaml:"very-muted"`
Background AdaptiveColor `json:"background" yaml:"background"`
Border AdaptiveColor `json:"border" yaml:"border"`
MutedBorder AdaptiveColor `json:"muted-border" yaml:"muted-border"`
System AdaptiveColor `json:"system" yaml:"system"`
Tool AdaptiveColor `json:"tool" yaml:"tool"`
Accent AdaptiveColor `json:"accent" yaml:"accent"`
Highlight AdaptiveColor `json:"highlight" yaml:"highlight"`
Primary AdaptiveColor `json:"primary,omitzero" yaml:"primary,omitempty"`
Secondary AdaptiveColor `json:"secondary,omitzero" yaml:"secondary,omitempty"`
Success AdaptiveColor `json:"success,omitzero" yaml:"success,omitempty"`
Warning AdaptiveColor `json:"warning,omitzero" yaml:"warning,omitempty"`
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
Info AdaptiveColor `json:"info,omitzero" yaml:"info,omitempty"`
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
VeryMuted AdaptiveColor `json:"very-muted,omitzero" yaml:"very-muted,omitempty"`
Background AdaptiveColor `json:"background,omitzero" yaml:"background,omitempty"`
Border AdaptiveColor `json:"border,omitzero" yaml:"border,omitempty"`
MutedBorder AdaptiveColor `json:"muted-border,omitzero" yaml:"muted-border,omitempty"`
System AdaptiveColor `json:"system,omitzero" yaml:"system,omitempty"`
Tool AdaptiveColor `json:"tool,omitzero" yaml:"tool,omitempty"`
Accent AdaptiveColor `json:"accent,omitzero" yaml:"accent,omitempty"`
Highlight AdaptiveColor `json:"highlight,omitzero" yaml:"highlight,omitempty"`
// Diff block backgrounds
DiffInsertBg AdaptiveColor `json:"diff-insert-bg,omitzero" yaml:"diff-insert-bg,omitempty"`
DiffDeleteBg AdaptiveColor `json:"diff-delete-bg,omitzero" yaml:"diff-delete-bg,omitempty"`
DiffEqualBg AdaptiveColor `json:"diff-equal-bg,omitzero" yaml:"diff-equal-bg,omitempty"`
DiffMissingBg AdaptiveColor `json:"diff-missing-bg,omitzero" yaml:"diff-missing-bg,omitempty"`
// Code/output block backgrounds
CodeBg AdaptiveColor `json:"code-bg,omitzero" yaml:"code-bg,omitempty"`
GutterBg AdaptiveColor `json:"gutter-bg,omitzero" yaml:"gutter-bg,omitempty"`
WriteBg AdaptiveColor `json:"write-bg,omitzero" yaml:"write-bg,omitempty"`
// Markdown rendering and syntax highlighting
Markdown MarkdownThemeConfig `json:"markdown,omitzero" yaml:"markdown,omitempty"`
}
// MarkdownTheme defines the color scheme for markdown rendering with syntax
// highlighting support and adaptive colors for light and dark modes.
type MarkdownTheme struct {
Text AdaptiveColor `json:"text" yaml:"text"`
Muted AdaptiveColor `json:"muted" yaml:"muted"`
Heading AdaptiveColor `json:"heading" yaml:"heading"`
Emph AdaptiveColor `json:"emph" yaml:"emph"`
Strong AdaptiveColor `json:"strong" yaml:"strong"`
Link AdaptiveColor `json:"link" yaml:"link"`
Code AdaptiveColor `json:"code" yaml:"code"`
Error AdaptiveColor `json:"error" yaml:"error"`
Keyword AdaptiveColor `json:"keyword" yaml:"keyword"`
String AdaptiveColor `json:"string" yaml:"string"`
Number AdaptiveColor `json:"number" yaml:"number"`
Comment AdaptiveColor `json:"comment" yaml:"comment"`
// GenerationParams defines generation parameter defaults that can be attached
// to individual models. These act as model-level defaults — CLI flags and
// global config values take precedence when explicitly set.
type GenerationParams struct {
MaxTokens *int `json:"maxTokens,omitempty" yaml:"maxTokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float32 `json:"topP,omitempty" yaml:"topP,omitempty"`
TopK *int32 `json:"topK,omitempty" yaml:"topK,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty" yaml:"frequencyPenalty,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty" yaml:"presencePenalty,omitempty"`
StopSequences []string `json:"stopSequences,omitempty" yaml:"stopSequences,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty" yaml:"thinkingLevel,omitempty"`
SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"`
}
// CustomModelConfig defines a custom model that can be used with custom/custom
// or other custom/ prefixed models. These models are loaded from the config file
// and merged into the custom provider in the model registry.
type CustomModelConfig struct {
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
Family string `json:"family,omitempty" yaml:"family,omitempty"`
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
Cost CostConfig `json:"cost" yaml:"cost"`
Limit LimitConfig `json:"limit" yaml:"limit"`
// Generation parameter defaults for this model.
// These are applied when the user hasn't explicitly set the corresponding
// CLI flag or global config value.
Params GenerationParams `json:"params,omitzero" yaml:"params,omitempty"`
}
// CostConfig defines the pricing for a custom model.
type CostConfig struct {
Input float64 `json:"input" yaml:"input"`
Output float64 `json:"output" yaml:"output"`
}
// LimitConfig defines context and output limits for a custom model.
type LimitConfig struct {
Context int `json:"context" yaml:"context"`
Output int `json:"output" yaml:"output"`
}
// Config represents the complete application configuration including MCP servers,
@@ -151,25 +213,38 @@ type Config struct {
Model string `json:"model,omitempty" yaml:"model,omitempty"`
MaxSteps int `json:"max-steps,omitempty" yaml:"max-steps,omitempty"`
Debug bool `json:"debug,omitempty" yaml:"debug,omitempty"`
Compact bool `json:"compact,omitempty" yaml:"compact,omitempty"`
SystemPrompt string `json:"system-prompt,omitempty" yaml:"system-prompt,omitempty"`
ProviderAPIKey string `json:"provider-api-key,omitempty" yaml:"provider-api-key,omitempty"`
ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"`
Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"`
Theme any `json:"theme" yaml:"theme"`
MarkdownTheme any `json:"markdown-theme" yaml:"markdown-theme"`
// Model generation parameters
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float32 `json:"top-p,omitempty" yaml:"top-p,omitempty"`
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float32 `json:"top-p,omitempty" yaml:"top-p,omitempty"`
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
FrequencyPenalty *float32 `json:"frequency-penalty,omitempty" yaml:"frequency-penalty,omitempty"`
PresencePenalty *float32 `json:"presence-penalty,omitempty" yaml:"presence-penalty,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"`
// Prompt templates configuration
Prompts []string `json:"prompts,omitempty" yaml:"prompts,omitempty"`
NoPromptTemplates bool `json:"no-prompt-templates,omitempty" yaml:"no-prompt-templates,omitempty"`
// Custom model definitions (under custom/ provider)
CustomModels map[string]CustomModelConfig `json:"customModels,omitempty" yaml:"customModels,omitempty"`
// Per-model generation parameter overrides. Keys are "provider/model" strings
// (e.g. "anthropic/claude-sonnet-4-5-20250929", "openai/gpt-4o"). These
// settings act as model-level defaults — CLI flags and global config values
// take precedence when explicitly set.
ModelSettings map[string]GenerationParams `json:"modelSettings,omitempty" yaml:"modelSettings,omitempty"`
}
// GetTransportType returns the transport type for the server config, mapping
@@ -318,16 +393,55 @@ mcpServers:
# debug: false # Enable debug logging
# system-prompt: "/path/to/system-prompt.txt" # System prompt text file
# Model generation parameters (all optional)
# Model generation parameters (all optional, apply globally to all models)
# max-tokens: 4096 # Maximum tokens in response
# temperature: 0.7 # Randomness (0.0-1.0)
# top-p: 0.95 # Nucleus sampling (0.0-1.0)
# top-k: 40 # Top K sampling
# frequency-penalty: 0.0 # Penalize frequent tokens (0.0-2.0)
# presence-penalty: 0.0 # Penalize present tokens (0.0-2.0)
# stop-sequences: ["Human:", "Assistant:"] # Custom stop sequences
# Per-model generation parameter overrides (apply to specific models)
# These act as model-level defaults — CLI flags and global settings above take precedence.
# Keys are "provider/model" strings matching the model you use.
# modelSettings:
# anthropic/claude-sonnet-4-5-20250929:
# temperature: 0.3
# maxTokens: 8192
# openai/gpt-4o:
# temperature: 0.7
# topP: 0.95
# topK: 40
# frequencyPenalty: 0.1
# presencePenalty: 0.1
# anthropic/claude-opus-4-6:
# thinkingLevel: "high"
# maxTokens: 16384
# systemPrompt: "You are a deep reasoning assistant." # or a file path
# API Configuration (can also use environment variables)
# provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google
# provider-url: "https://api.openai.com/v1" # Base URL for OpenAI, Anthropic, or Ollama
# Custom model definitions (under custom/ provider)
# customModels:
# my-local-llama:
# name: "Local Llama 3"
# baseUrl: "http://localhost:8080/v1"
# family: "llama"
# temperature: true
# cost:
# input: 0.0
# output: 0.0
# limit:
# context: 131072
# output: 8192
# params: # Generation parameter defaults for this model
# temperature: 0.8
# topP: 0.95
# topK: 40
# systemPrompt: "You are a helpful local assistant."
`
_, err = file.WriteString(content)
@@ -357,10 +471,9 @@ func FilepathOr[T any](key string, value *T) error {
if err != nil {
return err
}
filepath.Join(home, absPath[2:])
absPath = filepath.Join(home, absPath[2:])
}
if !filepath.IsAbs(absPath) {
// base := GetConfigPath()
base := configPath
if base == "" {
fmt.Fprintf(os.Stderr, "unable to build relative path to config.")
@@ -373,11 +486,10 @@ func FilepathOr[T any](key string, value *T) error {
fmt.Fprintf(os.Stderr, "%q", err)
os.Exit(1)
}
if filepath.Ext(absPath) == ".json" {
switch filepath.Ext(absPath) {
case ".json":
return json.Unmarshal(b, value)
}
if filepath.Ext(absPath) == ".yaml" {
case ".yaml", ".yml":
return yaml.Unmarshal(b, value)
}
}
+174 -29
View File
@@ -1,33 +1,47 @@
package core
import (
"bytes"
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"charm.land/fantasy"
)
// ToolOutputCallback is the signature for streaming tool output.
// It receives tool call ID, tool name, output chunk, and whether it's stderr.
type ToolOutputCallback func(toolCallID, toolName, chunk string, isStderr bool)
// contextKey is a custom type for context keys to avoid collisions.
type contextKey string
const toolOutputCallbackKey contextKey = "toolOutputCallback"
// ContextWithToolOutputCallback returns a new context with the tool output callback set.
func ContextWithToolOutputCallback(ctx context.Context, callback ToolOutputCallback) context.Context {
return context.WithValue(ctx, toolOutputCallbackKey, callback)
}
// toolOutputCallbackFromContext retrieves the tool output callback from context.
func toolOutputCallbackFromContext(ctx context.Context) ToolOutputCallback {
if cb, ok := ctx.Value(toolOutputCallbackKey).(ToolOutputCallback); ok {
return cb
}
return nil
}
const defaultBashTimeout = 120 * time.Second
const maxBashTimeout = 600 * time.Second
var bannedCommands = []string{
"alias ", "bg ", "bind ", "builtin ",
"caller ", "command ", "compgen ",
"complete ", "compopt ", "coproc ",
"dirs ", "disown ", "enable ",
"fc ", "fg ", "hash ", "help ",
"history ", "jobs ", "kill ",
"logout ", "mapfile ", "popd ",
"pushd ", "readonly ", "select ",
"set ", "shopt ", "source ",
"suspend ", "times ", "trap ",
"type ", "typeset ", "ulimit ",
"umask ", "unalias ", "wait ",
}
// bannedCmdRe matches bash builtin commands that are not allowed for security reasons.
var bannedCmdRe = regexp.MustCompile(`^(alias|bg|bind|builtin|caller|command|compgen|complete|compopt|coproc|dirs|disown|enable|fc|fg|hash|help|history|jobs|kill|logout|mapfile|popd|pushd|readonly|select|set|shopt|source|suspend|times|trap|type|typeset|ulimit|umask|unalias|wait)\s`)
type bashArgs struct {
Command string `json:"command"`
@@ -69,10 +83,8 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
// Check for banned commands
for _, banned := range bannedCommands {
if strings.HasPrefix(args.Command, banned) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", args.Command)), nil
}
if bannedCmdRe.MatchString(args.Command) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", args.Command)), nil
}
// Determine timeout
@@ -90,32 +102,165 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
cmd.Dir = workDir
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Ensure SHELL is set to bash so child processes (e.g. tmux) use bash
// rather than the user's login shell (which may be nushell, fish, etc.).
bashPath, err := exec.LookPath("bash")
if err != nil {
bashPath = "/bin/bash"
}
cmd.Env = append(os.Environ(), "SHELL="+bashPath)
err := cmd.Run()
// Get the output callback if present (for streaming support)
outputCallback := toolOutputCallbackFromContext(ctx)
if outputCallback != nil {
// Streaming mode: use pipes to capture output as it arrives
return executeBashStreaming(cmdCtx, call, cmd, outputCallback)
}
// Non-streaming mode: collect all output at once (original behavior)
return executeBashBuffered(cmdCtx, call, cmd)
}
// executeBashBuffered collects all output before returning (original behavior).
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
// close them when grandchild processes hold pipe handles open after the
// direct child exits.
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd) (fantasy.ToolResponse, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
}
if err := cmd.Start(); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
}
// Read pipes concurrently
var wg sync.WaitGroup
var stdout, stderr strings.Builder
var stdoutErr, stderrErr error
wg.Add(2)
go func() {
defer wg.Done()
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
}()
go func() {
defer wg.Done()
_, stderrErr = io.Copy(&stderr, stderrPipe)
}()
// Wait for the process to exit first. cmd.WaitDelay ensures that if
// pipes remain open (held by grandchild processes), they'll be forcibly
// closed after the grace period, which unblocks the io.Copy goroutines.
waitErr := cmd.Wait()
// Wait for pipe readers to finish draining.
wg.Wait()
// Ignore pipe read errors caused by WaitDelay force-closing —
// we still have whatever was read before the close.
_ = stdoutErr
_ = stderrErr
exitCode := 0
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if cmdCtx.Err() == context.DeadlineExceeded {
return fantasy.NewTextErrorResponse("command timed out"), nil
}
}
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
}
// executeBashStreaming streams output as it arrives via the callback.
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback) (fantasy.ToolResponse, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
}
// Start command execution
if err := cmd.Start(); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
}
// Stream stdout and stderr concurrently
var wg sync.WaitGroup
var mu sync.Mutex
var stdoutChunks, stderrChunks []string
streamOutput := func(reader io.Reader, isStderr bool) {
defer wg.Done()
scanner := bufio.NewScanner(reader)
// Use larger buffer for long lines
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
chunk := scanner.Text()
// Send chunk to UI
outputCallback(call.ID, "bash", chunk, isStderr)
// Collect for final result
mu.Lock()
if isStderr {
stderrChunks = append(stderrChunks, chunk)
} else {
stdoutChunks = append(stdoutChunks, chunk)
}
mu.Unlock()
}
}
wg.Add(2)
go streamOutput(stdoutPipe, false)
go streamOutput(stderrPipe, true)
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
// remain open (held by grandchild processes), they'll be forcibly closed
// after the grace period, which unblocks the scanners above.
err = cmd.Wait()
// Wait for the pipe readers to finish draining. This will complete
// quickly since cmd.Wait() (with WaitDelay) has already ensured
// the pipes are closed.
wg.Wait()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if cmdCtx.Err() == context.DeadlineExceeded {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command timed out after %v", timeout)), nil
return fantasy.NewTextErrorResponse("command timed out"), nil
}
}
// Build result
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
}
// buildBashResponse constructs the final tool response from stdout/stderr.
func buildBashResponse(stdout, stderr string, exitCode int) (fantasy.ToolResponse, error) {
var result strings.Builder
if stdout.Len() > 0 {
result.WriteString(stdout.String())
if stdout != "" {
result.WriteString(stdout)
}
if stderr.Len() > 0 {
if stderr != "" {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString("STDERR:\n")
result.WriteString(stderr.String())
result.WriteString(stderr)
}
if exitCode != 0 {
if result.Len() > 0 {
+129
View File
@@ -0,0 +1,129 @@
package core
import (
"context"
"encoding/json"
"testing"
"time"
"charm.land/fantasy"
)
// helper to create a bash tool call with the given command and optional timeout.
func bashCall(command string, timeout float64) fantasy.ToolCall {
args := map[string]any{"command": command}
if timeout > 0 {
args["timeout"] = timeout
}
input, _ := json.Marshal(args)
return fantasy.ToolCall{
ID: "test-call",
Name: "bash",
Input: string(input),
}
}
func TestBash_SimpleCommand(t *testing.T) {
resp, err := executeBash(context.Background(), bashCall("echo hello", 0), "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.IsError {
t.Fatalf("expected success, got error: %s", resp.Content)
}
if resp.Content != "hello\n" {
t.Errorf("expected 'hello\\n', got %q", resp.Content)
}
}
func TestBash_TimeoutKillsProcess(t *testing.T) {
start := time.Now()
resp, err := executeBash(context.Background(), bashCall("sleep 60", 2), "")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.IsError {
t.Fatal("expected error response for timed-out command")
}
if elapsed > 10*time.Second {
t.Errorf("command took %v, expected ~2s timeout", elapsed)
}
}
func TestBash_BackgroundProcessDoesNotHang(t *testing.T) {
// This command spawns a background sleep that would hold pipes open
// forever if we didn't have process group killing + WaitDelay.
start := time.Now()
resp, err := executeBash(context.Background(), bashCall("echo done; sleep 3600 &", 5), "")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The foreground command (echo) should complete quickly
if elapsed > 5*time.Second {
t.Errorf("command took %v, should complete in <5s (background process should not block)", elapsed)
}
if resp.IsError {
t.Fatalf("expected success, got error: %s", resp.Content)
}
}
func TestBash_BackgroundProcessDoesNotHang_Streaming(t *testing.T) {
// Same test but in streaming mode (with output callback).
ctx := ContextWithToolOutputCallback(context.Background(), func(_, _, _ string, _ bool) {})
start := time.Now()
resp, err := executeBash(ctx, bashCall("echo streaming; sleep 3600 &", 5), "")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if elapsed > 5*time.Second {
t.Errorf("streaming command took %v, should complete in <5s", elapsed)
}
if resp.IsError {
t.Fatalf("expected success, got error: %s", resp.Content)
}
}
func TestBash_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
_, _ = executeBash(ctx, bashCall("sleep 60", 0), "")
}()
// Cancel after a short delay
time.Sleep(500 * time.Millisecond)
cancel()
// Should return promptly after cancellation
select {
case <-done:
// success
case <-time.After(5 * time.Second):
t.Fatal("executeBash did not return after context cancellation")
}
}
func TestBash_BannedCommand(t *testing.T) {
resp, err := executeBash(context.Background(), bashCall("alias foo=bar", 0), "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.IsError {
t.Fatal("expected error for banned command")
}
}
func TestBash_EmptyCommand(t *testing.T) {
resp, err := executeBash(context.Background(), bashCall("", 0), "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.IsError {
t.Fatal("expected error for empty command")
}
}
+326 -128
View File
@@ -4,25 +4,55 @@ import (
"context"
"fmt"
"os"
"sort"
"strings"
"unicode"
"unicode/utf8"
"charm.land/fantasy"
udiff "github.com/aymanbagabas/go-udiff"
)
type editArgs struct {
Path string `json:"path"`
// Edit represents a single replacement in a multi-edit operation.
type Edit struct {
OldText string `json:"old_text"`
NewText string `json:"new_text"`
}
// editArgs holds the arguments for the edit tool.
// Supports both single-edit mode (old_text/new_text) and multi-edit mode (edits array).
type editArgs struct {
Path string `json:"path"`
OldText string `json:"old_text"` // Single-edit mode
NewText string `json:"new_text"` // Single-edit mode
Edits []Edit `json:"edits"` // Multi-edit mode
}
// replacement represents a normalized edit ready for processing.
type replacement struct {
oldText string // normalized old text for matching
newText string // normalized new text
originalOld string // original old text for metadata
originalNew string // original new text for metadata
index int // index in the original edits array (for error messages)
}
// matchedReplacement represents a replacement with its match location.
type matchedReplacement struct {
replacement
start int // start index in normalized content
end int // end index in normalized content
usedFuzzyMatch bool // true if fuzzy matching was used
}
// NewEditTool creates the edit core tool.
func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
cfg := ApplyOptions(opts)
return &coreTool{
info: fantasy.ToolInfo{
Name: "edit",
Description: "Edit a file by replacing exact text. The old_text must match exactly (including whitespace). Use this for precise, surgical edits. Fails if old_text is not found or matches multiple locations.",
Description: "Edit a file by replacing exact text. Supports single edit via old_text/new_text, or multiple edits via the edits array. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
@@ -30,14 +60,32 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
},
"old_text": map[string]any{
"type": "string",
"description": "Exact text to find and replace (must match exactly)",
"description": "Exact text to find and replace (single-edit mode). Must not be used with 'edits' array.",
},
"new_text": map[string]any{
"type": "string",
"description": "New text to replace the old text with",
"description": "New text to replace the old text with (single-edit mode). Must not be used with 'edits' array.",
},
"edits": map[string]any{
"type": "array",
"description": "Array of edits for multi-region replacement. Each edit must have unique, non-overlapping old_text. All matches are against the original file content.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"old_text": map[string]any{
"type": "string",
"description": "Exact text to find and replace for this edit",
},
"new_text": map[string]any{
"type": "string",
"description": "New text for this edit",
},
},
"required": []string{"old_text", "new_text"},
},
},
},
Required: []string{"path", "old_text", "new_text"},
Required: []string{"path"},
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeEdit(ctx, call, cfg.WorkDir)
@@ -48,7 +96,7 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
var args editArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path, old_text, and new_text parameters are required"), nil
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
}
if args.Path == "" {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
@@ -66,158 +114,308 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
content := string(contentBytes)
// Normalize line endings for matching
normalized := strings.ReplaceAll(content, "\r\n", "\n")
normalizedOld := strings.ReplaceAll(args.OldText, "\r\n", "\n")
// Try exact match first
count := strings.Count(normalized, normalizedOld)
// If no exact match, try fuzzy matching
if count == 0 {
if idx, matchLen := fuzzyMatch(normalized, normalizedOld); idx >= 0 {
// 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)
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
// Normalize and validate input
replacements, err := normalizeEditInput(args)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if count > 1 {
return fantasy.NewTextErrorResponse(fmt.Sprintf("found %d matches for old_text in %s. Provide more context to identify the correct match.", count, args.Path)), nil
// Apply all edits
newContent, applied, err := applyEdits(content, replacements)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Apply the edit
newContent := strings.Replace(normalized, normalizedOld, args.NewText, 1)
// Write the file
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
idx := strings.Index(normalized, normalizedOld)
diff := generateDiff(absPath, normalized, newContent, idx)
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff))
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, normalizedOld, args.NewText)), nil
// Generate diff
normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
diff := generateDiff(absPath, normalizedContent, newContent)
// Build response with fuzzy match indication
fuzzyCount := 0
for _, m := range applied {
if m.usedFuzzyMatch {
fuzzyCount++
}
}
var msg string
if len(applied) == 1 {
if fuzzyCount > 0 {
msg = fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)
} else {
msg = fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)
}
} else {
if fuzzyCount > 0 {
msg = fmt.Sprintf("Applied %d edits (%d fuzzy) to %s\n%s", len(applied), fuzzyCount, args.Path, diff)
} else {
msg = fmt.Sprintf("Applied %d edits to %s\n%s", len(applied), args.Path, diff)
}
}
resp := fantasy.NewTextResponse(msg)
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, applied)), nil
}
// normalizeEditInput validates and normalizes the edit input.
// Returns error if both single-edit and multi-edit modes are used.
func normalizeEditInput(args editArgs) ([]replacement, error) {
singleMode := args.OldText != "" || args.NewText != ""
multiMode := len(args.Edits) > 0
if singleMode && multiMode {
return nil, fmt.Errorf("cannot use old_text/new_text together with edits array")
}
if !singleMode && !multiMode {
return nil, fmt.Errorf("must provide either old_text/new_text or edits array")
}
if singleMode {
if args.OldText == "" {
return nil, fmt.Errorf("old_text is required when using single-edit mode")
}
if args.NewText == "" {
return nil, fmt.Errorf("new_text is required when using single-edit mode")
}
return []replacement{{
oldText: strings.ReplaceAll(args.OldText, "\r\n", "\n"),
newText: strings.ReplaceAll(args.NewText, "\r\n", "\n"),
originalOld: args.OldText,
originalNew: args.NewText,
index: 0,
}}, nil
}
// Multi-edit mode
var reps []replacement
for i, edit := range args.Edits {
if edit.OldText == "" {
return nil, fmt.Errorf("edits[%d].old_text is required", i)
}
reps = append(reps, replacement{
oldText: strings.ReplaceAll(edit.OldText, "\r\n", "\n"),
newText: strings.ReplaceAll(edit.NewText, "\r\n", "\n"),
originalOld: edit.OldText,
originalNew: edit.NewText,
index: i,
})
}
return reps, nil
}
// applyEdits applies multiple replacements to the content.
// All matches are against the original content (non-incremental).
// Returns the new content, the applied matches, and any error.
func applyEdits(content string, edits []replacement) (string, []matchedReplacement, error) {
normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
// Find all matches
var matched []matchedReplacement
for _, edit := range edits {
m, err := findMatch(normalizedContent, edit)
if err != nil {
return "", nil, err
}
matched = append(matched, *m)
}
// Sort by position
sort.Slice(matched, func(i, j int) bool {
return matched[i].start < matched[j].start
})
// Check for overlaps
for i := 1; i < len(matched); i++ {
if matched[i-1].end > matched[i].start {
return "", nil, fmt.Errorf("edits[%d] and edits[%d] overlap; merge them into a single edit",
matched[i-1].index, matched[i].index)
}
}
// Apply edits in reverse order (end to start) to maintain stable offsets
result := normalizedContent
for i := len(matched) - 1; i >= 0; i-- {
m := matched[i]
result = result[:m.start] + m.newText + result[m.end:]
}
return result, matched, nil
}
// findMatch finds a unique match for the edit in the content.
// Returns error if not found or ambiguous.
func findMatch(content string, edit replacement) (*matchedReplacement, error) {
// Try exact match first
count := strings.Count(content, edit.oldText)
if count == 0 {
// Try fuzzy match
idx, matchLen := fuzzyMatch(content, edit.oldText)
if idx < 0 {
return nil, fmt.Errorf("edits[%d]: could not find old_text in file. The text must match exactly (including whitespace)", edit.index)
}
// Use the matched text from content for the replacement
matchedText := content[idx : idx+matchLen]
return &matchedReplacement{
replacement: replacement{
oldText: matchedText,
newText: edit.newText,
originalOld: edit.originalOld,
originalNew: edit.originalNew,
index: edit.index,
},
start: idx,
end: idx + matchLen,
usedFuzzyMatch: true,
}, nil
}
if count > 1 {
return nil, fmt.Errorf("found %d matches for edits[%d].old_text; each old_text must be unique, provide more context to identify the correct match", count, edit.index)
}
// Single exact match
idx := strings.Index(content, edit.oldText)
return &matchedReplacement{
replacement: edit,
start: idx,
end: idx + len(edit.oldText),
}, nil
}
// editDiffMeta builds the structured metadata attached to edit tool responses.
func editDiffMeta(path, oldText, newText string) map[string]any {
func editDiffMeta(path string, applied []matchedReplacement) map[string]any {
var diffBlocks []map[string]any
totalAdditions, totalDeletions := 0, 0
for _, m := range applied {
diffBlocks = append(diffBlocks, map[string]any{
"old_text": m.originalOld,
"new_text": m.originalNew,
})
totalAdditions += strings.Count(m.originalNew, "\n") + 1
totalDeletions += strings.Count(m.originalOld, "\n") + 1
}
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,
}},
"path": path,
"additions": totalAdditions,
"deletions": totalDeletions,
"diff_blocks": diffBlocks,
}},
}
}
// fuzzyMatch tries to find old_text with relaxed matching:
// - Strips trailing whitespace per line
// - Normalizes unicode quotes to ASCII
// - Normalizes unicode dashes/spaces
// Returns (index, matchLength) or (-1, 0) if not found.
// - Strips trailing whitespace per line
// - Normalizes unicode quotes to ASCII
// - Normalizes unicode dashes/spaces
//
// Returns (index, matchLength) in the original content, or (-1, 0) if not
// found or ambiguous (multiple matches).
func fuzzyMatch(content, search string) (int, int) {
normalizedContent := normalizeForFuzzy(content)
normalizedSearch := normalizeForFuzzy(search)
normContent, contentMap := normalizeWithMap(content)
normSearch := normalizeForFuzzy(search)
idx := strings.Index(normalizedContent, normalizedSearch)
if normSearch == "" {
return -1, 0
}
idx := strings.Index(normContent, normSearch)
if idx < 0 {
return -1, 0
}
// Map back to original content position
// Since normalization can change lengths, we need to find the
// corresponding region in the original content
origIdx := mapFuzzyIndex(content, normalizedContent, idx)
origEnd := mapFuzzyIndex(content, normalizedContent, idx+len(normalizedSearch))
// Reject ambiguous matches — if there are multiple fuzzy matches
// we can't safely pick one.
if strings.Count(normContent, normSearch) > 1 {
return -1, 0
}
return origIdx, origEnd - origIdx
// Map normalized byte positions back to original byte positions.
origStart := contentMap[idx]
endNorm := idx + len(normSearch)
var origEnd int
if endNorm >= len(normContent) {
origEnd = len(content)
} else {
origEnd = contentMap[endNorm]
}
return origStart, origEnd - origStart
}
func normalizeForFuzzy(s string) string {
// Strip trailing whitespace per line
// normalizeWithMap normalizes s for fuzzy matching and returns both the
// normalized string and a byte-position mapping where mapping[i] is the
// original byte position corresponding to normalized byte position i.
//
// Normalization: trim trailing whitespace per line, replace unicode
// quotes/dashes/spaces with their ASCII equivalents.
func normalizeWithMap(s string) (string, []int) {
var result []byte
var mapping []int // mapping[i] = original byte position for result byte i
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRightFunc(line, unicode.IsSpace)
}
result := strings.Join(lines, "\n")
// Normalize smart quotes
replacer := strings.NewReplacer(
"\u201c", "\"", // left double quote
"\u201d", "\"", // right double quote
"\u2018", "'", // left single quote
"\u2019", "'", // right single quote
"\u2013", "-", // en dash
"\u2014", "-", // em dash
"\u00a0", " ", // non-breaking space
)
return replacer.Replace(result)
}
func mapFuzzyIndex(original, normalized string, normIdx int) int {
// Simple approach: count runes up to normIdx in normalized,
// then advance that many runes in original.
// This works because our normalization only replaces runes 1:1.
origRunes := []rune(original)
normRunes := []rune(normalized)
if normIdx >= len(normRunes) {
return len(original)
}
// Count bytes for the first normIdx runes in original
byteCount := 0
for i := 0; i < normIdx && i < len(origRunes); i++ {
byteCount += len(string(origRunes[i]))
}
return byteCount
}
// generateDiff creates a simple unified diff showing the change.
func generateDiff(path, old, new string, changeIdx int) string {
oldLines := strings.Split(old, "\n")
newLines := strings.Split(new, "\n")
// Find the line number where the change starts
lineNum := strings.Count(old[:changeIdx], "\n") + 1
// Show context around the change
contextLines := 3
start := max(lineNum-contextLines-1, 0)
var diff strings.Builder
fmt.Fprintf(&diff, "--- %s\n+++ %s\n", path, path)
// Find changed region
endOld := min(lineNum+contextLines+countNewlines(old[changeIdx:])+1, len(oldLines))
endNew := min(lineNum+contextLines+countNewlines(new[changeIdx:])+1, len(newLines))
fmt.Fprintf(&diff, "@@ -%d,%d +%d,%d @@\n", start+1, endOld-start, start+1, endNew-start)
// Very simplified diff: show old lines as removed, new lines as added
// around the change region
for i := start; i < endOld && i < len(oldLines); i++ {
prefix := " "
if i >= lineNum-1 && i < lineNum-1+countNewlines(old[changeIdx:])+1 {
prefix = "-"
origPos := 0
for li, line := range lines {
if li > 0 {
result = append(result, '\n')
mapping = append(mapping, origPos)
origPos++ // skip \n in original
}
fmt.Fprintf(&diff, "%s %s\n", prefix, oldLines[i])
trimmed := strings.TrimRightFunc(line, unicode.IsSpace)
for j := 0; j < len(trimmed); {
r, size := utf8.DecodeRuneInString(trimmed[j:])
repl := normalizeRune(r)
for k := 0; k < len(repl); k++ {
mapping = append(mapping, origPos+j)
}
result = append(result, repl...)
j += size
}
origPos += len(line) // advance past full original line including trailing ws
}
return diff.String()
return string(result), mapping
}
func countNewlines(s string) int {
return strings.Count(s, "\n")
// normalizeRune maps unicode quotes, dashes, and non-breaking spaces to
// their ASCII equivalents. Returns the original rune as a string for all
// other characters.
func normalizeRune(r rune) string {
switch r {
case '\u201c', '\u201d': // left/right double quote
return "\""
case '\u2018', '\u2019': // left/right single quote
return "'"
case '\u2013', '\u2014': // en dash, em dash
return "-"
case '\u00a0': // non-breaking space
return " "
default:
return string(r)
}
}
// normalizeForFuzzy normalizes s for fuzzy matching (without position mapping).
// Used for the search string where position mapping is not needed.
func normalizeForFuzzy(s string) string {
norm, _ := normalizeWithMap(s)
return norm
}
// generateDiff creates a unified diff showing the change between old and new
// file contents. Uses the go-udiff library for correct diff computation.
func generateDiff(path, old, new string) string {
return udiff.Unified(path, path, old, new)
}
File diff suppressed because it is too large Load Diff
+1 -20
View File
@@ -67,7 +67,7 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
if info.IsDir() {
return readDirectory(absPath)
return fantasy.NewTextErrorResponse(fmt.Sprintf("'%s' is a directory, not a file. Use the ls tool to list directory contents.", args.Path)), nil
}
content, err := os.ReadFile(absPath)
@@ -116,25 +116,6 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
return fantasy.NewTextResponse(tr.Content), nil
}
func readDirectory(absPath string) (fantasy.ToolResponse, error) {
entries, err := os.ReadDir(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read directory: %v", err)), nil
}
var result strings.Builder
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
name += "/"
}
result.WriteString(name + "\n")
}
tr := truncateHead(result.String(), 500, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
// resolvePathWithWorkDir resolves a path to an absolute path relative to the
// given workDir. If workDir is empty, os.Getwd() is used.
func resolvePathWithWorkDir(path, workDir string) (string, error) {
+42 -6
View File
@@ -28,12 +28,14 @@ type SubagentSpawnResult struct {
// 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)
// The toolCallID parameter is the LLM-assigned ID of the subagent
// tool call, enabling the parent to correlate subagent events.
type SubagentSpawnFunc func(ctx context.Context, toolCallID, 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.
// subagent core tool can create in-process subagents.
func WithSubagentSpawner(ctx context.Context, fn SubagentSpawnFunc) context.Context {
return context.WithValue(ctx, subagentCtxKey{}, fn)
}
@@ -47,7 +49,7 @@ func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
}
// ---------------------------------------------------------------------------
// spawn_subagent tool
// subagent tool
// ---------------------------------------------------------------------------
type subagentArgs struct {
@@ -57,11 +59,11 @@ type subagentArgs struct {
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// NewSubagentTool creates the spawn_subagent core tool.
// NewSubagentTool creates the subagent core tool.
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "spawn_subagent",
Name: "subagent",
Description: `Spawn a subagent to perform a task autonomously.
The subagent runs as a separate in-process Kit instance with full tool access
@@ -128,8 +130,25 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
), fmt.Errorf("no subagent spawner in context")
}
// Build a clean context for the subagent that inherits values (e.g. the
// spawner callback) but is completely detached from the parent's
// deadline AND cancellation. The subagent gets its own independent
// timeout (applied downstream in Kit.Subagent).
//
// Why full detachment instead of propagating parent cancellation?
// The parent context may already be done (deadline exceeded or
// cancelled) by the time this tool handler executes — for example when
// the generation loop context carries a deadline, when the user
// double-ESC cancels mid-turn, or when parallel tool execution
// encounters a race between stream completion and tool dispatch. Using
// context.WithoutCancel (Go 1.21+) ensures the subagent always starts
// cleanly with a fresh timeout, following the pattern used by crush for
// shutdown-resilient child work. The subagent's own timeout
// (defaultSubagentTimeout / user-specified) provides the safety net.
spawnCtx := context.WithoutCancel(valuesContext{parent: ctx})
// Spawn in-process subagent.
result, err := spawner(ctx, args.Task, args.Model, args.SystemPrompt, timeout)
result, err := spawner(spawnCtx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout)
if err != nil || result.Error != nil {
spawnErr := err
if spawnErr == nil {
@@ -162,6 +181,23 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
return resp, nil
}
// ---------------------------------------------------------------------------
// Context helpers
// ---------------------------------------------------------------------------
// valuesContext preserves a parent context's values (e.g. the subagent
// spawner callback) while stripping its deadline and cancellation. Combined
// with context.WithoutCancel() this gives the subagent a completely clean
// context that only inherits value-based dependencies.
type valuesContext struct {
parent context.Context
}
func (v valuesContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (v valuesContext) Done() <-chan struct{} { return nil }
func (v valuesContext) Err() error { return nil }
func (v valuesContext) Value(key any) any { return v.parent.Value(key) }
// truncateResponse limits the response length to avoid overwhelming context windows.
func truncateResponse(s string, maxLen int) string {
if len(s) <= maxLen {
+115
View File
@@ -0,0 +1,115 @@
package core
import (
"context"
"testing"
"time"
)
func TestValuesContext_StripsDeadlineAndCancellation(t *testing.T) {
// Parent with a tight deadline.
parent, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
time.Sleep(5 * time.Millisecond) // Let deadline expire.
if parent.Err() == nil {
t.Fatal("expected parent to be expired")
}
vc := valuesContext{parent: parent}
if _, ok := vc.Deadline(); ok {
t.Error("valuesContext should report no deadline")
}
if vc.Done() != nil {
t.Error("valuesContext.Done() should return nil")
}
if vc.Err() != nil {
t.Errorf("valuesContext.Err() should be nil, got %v", vc.Err())
}
}
func TestValuesContext_PreservesValues(t *testing.T) {
type testKey struct{}
parent := context.WithValue(context.Background(), testKey{}, "hello")
vc := valuesContext{parent: parent}
got, ok := vc.Value(testKey{}).(string)
if !ok || got != "hello" {
t.Errorf("expected value 'hello', got %q (ok=%v)", got, ok)
}
}
func TestSpawnContext_SurvivesCancelledParent(t *testing.T) {
// Simulate the exact scenario from the bug: the parent generation
// context is already cancelled when the subagent tool handler runs.
parent, cancel := context.WithCancel(context.Background())
cancel() // Cancelled before detach.
// This is what executeSubagent now does:
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
// The spawn context must be alive.
if spawnCtx.Err() != nil {
t.Fatalf("spawnCtx should be alive, got err: %v", spawnCtx.Err())
}
// Adding a timeout should produce a working context.
tCtx, tCancel := context.WithTimeout(spawnCtx, 5*time.Second)
defer tCancel()
if tCtx.Err() != nil {
t.Fatalf("timeout context should be alive, got err: %v", tCtx.Err())
}
}
func TestSpawnContext_SurvivesDeadlineExceededParent(t *testing.T) {
// Simulate: parent had a deadline that already expired.
parent, pCancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer pCancel()
time.Sleep(5 * time.Millisecond)
if parent.Err() != context.DeadlineExceeded {
t.Fatalf("expected parent deadline exceeded, got: %v", parent.Err())
}
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
if spawnCtx.Err() != nil {
t.Fatalf("spawnCtx should be alive after deadline-exceeded parent, got: %v", spawnCtx.Err())
}
}
func TestSpawnContext_PreservesSpawnerValue(t *testing.T) {
// Verify the subagent spawner callback survives context detachment.
called := false
spawner := SubagentSpawnFunc(func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error) {
called = true
return &SubagentSpawnResult{Response: "ok"}, nil
})
parent := WithSubagentSpawner(context.Background(), spawner)
// Cancel the parent.
parentCtx, cancel := context.WithCancel(parent)
cancel()
spawnCtx := context.WithoutCancel(valuesContext{parent: parentCtx})
// Should be able to retrieve the spawner from the detached context.
recovered := getSubagentSpawner(spawnCtx)
if recovered == nil {
t.Fatal("spawner should be recoverable from detached context")
}
result, err := recovered(spawnCtx, "tc1", "test task", "", "", time.Minute)
if err != nil {
t.Fatalf("spawner call failed: %v", err)
}
if !called {
t.Error("spawner was not called")
}
if result.Response != "ok" {
t.Errorf("expected 'ok', got %q", result.Response)
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
}
}
// SubagentTools returns all core tools except spawn_subagent. This prevents
// SubagentTools returns all core tools except subagent. This prevents
// infinite recursion when a subagent is itself a Kit instance.
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
+28 -10
View File
@@ -6,14 +6,17 @@ import (
)
const (
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
grepMaxLineLen = 500
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
defaultMaxLineLen = 2000 // max characters per line before truncation
grepMaxLineLen = 500
// DefaultMaxLines is the exported default line limit for truncation.
DefaultMaxLines = defaultMaxLines
// DefaultMaxBytes is the exported default byte limit for truncation.
DefaultMaxBytes = defaultMaxBytes
// DefaultMaxLineLen is the exported default per-line character limit.
DefaultMaxLineLen = defaultMaxLineLen
)
// TruncationResult describes how output was truncated.
@@ -26,6 +29,8 @@ type TruncationResult struct {
}
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
// Individual lines longer than defaultMaxLineLen are truncated to prevent
// extremely long single lines from blowing up the TUI when wrapped.
// Used for bash output where the tail is most relevant.
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
@@ -38,11 +43,11 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
// Truncate individual long lines first to prevent single lines from
// wrapping into hundreds of visual lines in the TUI.
lines = truncateLongLines(lines, defaultMaxLineLen)
// Truncate by lines first (keep tail)
// Truncate by lines (keep tail)
truncBy := ""
if total > maxLines {
lines = lines[total-maxLines:]
@@ -78,6 +83,7 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
}
// truncateHead keeps the first maxLines lines and at most maxBytes bytes.
// Individual lines longer than defaultMaxLineLen are truncated.
// Used for read, grep, find, ls output where the head is most relevant.
func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
@@ -90,9 +96,8 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
// Truncate individual long lines first.
lines = truncateLongLines(lines, defaultMaxLineLen)
truncBy := ""
if total > maxLines {
@@ -125,6 +130,19 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
}
}
// truncateLongLines caps each line to maxLen characters, appending a
// "[...N chars truncated]" marker to any line that exceeds the limit.
// This prevents a single very long line (e.g. minified JSON/JS) from
// wrapping into hundreds of visual rows and blowing up the TUI.
func truncateLongLines(lines []string, maxLen int) []string {
for i, line := range lines {
if len(line) > maxLen {
lines[i] = line[:maxLen] + fmt.Sprintf("... [%d chars truncated]", len(line)-maxLen)
}
}
return lines
}
// truncateLine truncates a single line to maxChars, appending "..." if cut.
func truncateLine(line string, maxChars int) string {
if maxChars <= 0 {
+163
View File
@@ -0,0 +1,163 @@
package core
import (
"strings"
"testing"
)
func TestTruncateTail_LongLines(t *testing.T) {
// A single line of 5000 chars should be truncated to defaultMaxLineLen.
longLine := strings.Repeat("x", 5000)
tr := TruncateTail(longLine, 2000, 50*1024)
if len(tr.Content) > defaultMaxLineLen+100 { // +100 for the "[...N chars truncated]" suffix
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
}
if !strings.Contains(tr.Content, "chars truncated]") {
t.Error("truncated line should contain truncation marker")
}
}
func TestTruncateTail_NormalLines(t *testing.T) {
// Lines within the limit should pass through unchanged.
content := "line1\nline2\nline3"
tr := TruncateTail(content, 2000, 50*1024)
if tr.Content != content {
t.Errorf("got %q, want %q", tr.Content, content)
}
if tr.Truncated {
t.Error("should not be marked as truncated")
}
}
func TestTruncateTail_LineCount(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
content := strings.Join(lines, "\n")
tr := TruncateTail(content, 10, 50*1024)
if !tr.Truncated {
t.Error("should be marked as truncated")
}
if tr.Total != 100 {
t.Errorf("total = %d, want 100", tr.Total)
}
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
}
func TestTruncateHead_LongLines(t *testing.T) {
longLine := strings.Repeat("y", 5000)
tr := truncateHead(longLine, 2000, 50*1024)
if len(tr.Content) > defaultMaxLineLen+100 {
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
}
if !strings.Contains(tr.Content, "chars truncated]") {
t.Error("truncated line should contain truncation marker")
}
}
func TestTruncateHead_NormalLines(t *testing.T) {
content := "line1\nline2\nline3"
tr := truncateHead(content, 2000, 50*1024)
if tr.Content != content {
t.Errorf("got %q, want %q", tr.Content, content)
}
if tr.Truncated {
t.Error("should not be marked as truncated")
}
}
func TestTruncateHead_LineCount(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
content := strings.Join(lines, "\n")
tr := truncateHead(content, 10, 50*1024)
if !tr.Truncated {
t.Error("should be marked as truncated")
}
if tr.Total != 100 {
t.Errorf("total = %d, want 100", tr.Total)
}
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
}
func TestTruncateLongLines(t *testing.T) {
lines := []string{
"short",
strings.Repeat("a", 3000),
"also short",
}
result := truncateLongLines(lines, 100)
if result[0] != "short" {
t.Error("short line should be unchanged")
}
if len(result[1]) > 200 { // 100 chars + marker
t.Errorf("long line not truncated: len=%d", len(result[1]))
}
if !strings.Contains(result[1], "chars truncated]") {
t.Error("should contain truncation marker")
}
if result[2] != "also short" {
t.Error("short line should be unchanged")
}
}
func TestTruncateTail_MixedLongAndManyLines(t *testing.T) {
// 50 lines, each 3000 chars — tests both per-line and total truncation.
lines := make([]string, 50)
for i := range lines {
lines[i] = strings.Repeat("z", 3000)
}
content := strings.Join(lines, "\n")
tr := TruncateTail(content, 10, 50*1024)
// Should keep 10 lines.
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
// Each line should be capped at ~defaultMaxLineLen.
resultLines := strings.Split(tr.Content, "\n")
for i, line := range resultLines {
if len(line) > defaultMaxLineLen+100 {
t.Errorf("line %d too long: %d chars", i, len(line))
}
}
}
func TestTruncateLine(t *testing.T) {
short := "hello"
if truncateLine(short, 10) != short {
t.Error("short line should be unchanged")
}
long := strings.Repeat("x", 100)
result := truncateLine(long, 10)
if len(result) != 13 { // 10 + "..."
t.Errorf("got len %d, want 13", len(result))
}
// Default max for 0 — input shorter than default, so unchanged
result2 := truncateLine(long, 0)
if result2 != long {
t.Errorf("100-char line should be unchanged when maxChars defaults to %d", grepMaxLineLen)
}
// Longer input with default
veryLong := strings.Repeat("x", 1000)
result3 := truncateLine(veryLong, 0)
if len(result3) != grepMaxLineLen+3 {
t.Errorf("got len %d, want %d", len(result3), grepMaxLineLen+3)
}
}
+555 -1
View File
@@ -77,6 +77,64 @@ type Context struct {
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
CancelAndSend func(string)
// Abort cancels the current agent turn (if running) and clears the
// message queue. Unlike CancelAndSend, no new message is injected —
// the agent simply stops. Safe to call when idle (no-op).
//
// Example:
//
// ctx.Abort() // stop whatever the agent is doing
Abort func()
// IsIdle returns true when the agent is not processing a turn.
// Extensions can use this to decide whether to dispatch immediately
// or queue work for later.
//
// Example:
//
// if ctx.IsIdle() {
// ctx.SendMessage("start new task")
// }
IsIdle func() bool
// Compact triggers context compaction, summarising older messages to
// free context window space. Returns an error if compaction cannot
// start (e.g. agent is busy or app is closed). The actual compaction
// runs asynchronously; use OnComplete/OnError callbacks in
// CompactConfig to observe the result.
//
// Example:
//
// err := ctx.Compact(ext.CompactConfig{
// OnComplete: func() { ctx.PrintInfo("Compaction done") },
// OnError: func(errMsg string) { ctx.PrintError("Compact failed: " + errMsg) },
// })
Compact func(CompactConfig) error
// SendMultimodalMessage injects a message with file attachments (images,
// documents) into the conversation and triggers a new agent turn. Files
// are described by FilePart structs containing the raw bytes, filename,
// and MIME type. If the agent is busy the message is queued.
//
// Example:
//
// data, _ := os.ReadFile("photo.jpg")
// ctx.SendMultimodalMessage("Describe this image", []ext.FilePart{
// {Filename: "photo.jpg", Data: data, MediaType: "image/jpeg"},
// })
SendMultimodalMessage func(text string, files []FilePart)
// GetSessionUsage returns aggregated token usage and cost statistics
// for the current session. This includes total input/output tokens,
// cache read/write tokens, total cost, and request count.
//
// Example:
//
// usage := ctx.GetSessionUsage()
// fmt.Sprintf("Tokens: ↑%d ↓%d Cost: $%.3f",
// usage.TotalInputTokens, usage.TotalOutputTokens, usage.TotalCost)
GetSessionUsage func() SessionUsage
// 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
@@ -174,6 +232,22 @@ type Context struct {
// }
PromptInput func(PromptInputConfig) PromptInputResult
// PromptMultiSelect shows a multi-selection list to the user, allowing
// them to toggle options with spacebar and confirm with enter. In
// non-interactive mode, returns all options as selected.
//
// Example:
//
// result := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{
// Message: "Select extensions to install:",
// Options: []string{"git", "todo", "weather"},
// DefaultSelected: []int{0, 1, 2}, // All selected by default
// })
// if !result.Cancelled {
// fmt.Println("Selected:", result.Values)
// }
PromptMultiSelect func(PromptMultiSelectConfig) PromptMultiSelectResult
// ShowOverlay displays a modal overlay dialog that blocks until the
// user dismisses it or selects an action. The overlay renders as a
// centered (or anchored) bordered box over the TUI. Returns a
@@ -469,6 +543,36 @@ type Context struct {
// ctx.RenderMessage("build-status", "All 42 tests passed.")
RenderMessage func(rendererName string, content string)
// RegisterTheme adds a named theme to the runtime theme registry.
// If a theme with the same name already exists it is replaced.
// The theme becomes available via /theme and ctx.SetTheme().
//
// Example:
//
// ctx.RegisterTheme("neon", ext.ThemeColorConfig{
// Primary: ext.ThemeColor{Dark: "#FF00FF"},
// Secondary: ext.ThemeColor{Dark: "#00FFFF"},
// Success: ext.ThemeColor{Dark: "#00FF00"},
// Warning: ext.ThemeColor{Dark: "#FFFF00"},
// Error: ext.ThemeColor{Dark: "#FF0000"},
// Info: ext.ThemeColor{Dark: "#00FFFF"},
// Text: ext.ThemeColor{Dark: "#FFFFFF"},
// Background: ext.ThemeColor{Dark: "#000000"},
// })
RegisterTheme func(name string, config ThemeColorConfig)
// SetTheme switches the active color theme by name. The name must
// match a built-in theme, a user/project theme file, or a theme
// registered via RegisterTheme. Returns an error if not found.
//
// Example:
//
// err := ctx.SetTheme("neon")
SetTheme func(name string) error
// ListThemes returns the names of all available themes.
ListThemes func() []string
// ReloadExtensions hot-reloads all extensions from disk. Existing
// extensions receive a SessionShutdown event, then new code is loaded
// and receives a SessionStart event. Event handlers, commands,
@@ -526,6 +630,102 @@ type Context struct {
// })
// // handle.Kill() to cancel, handle.Wait() to block
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge)
// -------------------------------------------------------------------------
// GetTreeNode returns a node by ID with full metadata and children.
// Returns nil if entry not found.
GetTreeNode func(entryID string) *TreeNode
// GetCurrentBranch returns the path from root to current leaf.
// Each node contains full metadata (unlike GetMessages which flattens).
GetCurrentBranch func() []TreeNode
// GetChildren returns direct child IDs of an entry.
GetChildren func(entryID string) []string
// NavigateTo branches/forks the session to the specified entry ID.
// Equivalent to SDK's Branch() but for extensions.
NavigateTo func(entryID string) TreeNavigationResult
// SummarizeBranch uses LLM to summarize a branch range.
// Returns summary text or error string (empty if success).
SummarizeBranch func(fromID, toID string) string
// CollapseBranch replaces a branch range with a summary entry.
// This is the "fresh context" primitive for context window management.
CollapseBranch func(fromID, toID, summary string) TreeNavigationResult
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge)
// -------------------------------------------------------------------------
// LoadSkill loads a single skill file from path.
// Parses YAML frontmatter, returns skill with content ready for injection.
LoadSkill func(path string) (*Skill, string)
// LoadSkillsFromDir discovers and loads all skills from a directory.
LoadSkillsFromDir func(dir string) SkillLoadResult
// DiscoverSkills finds skills in standard locations.
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
DiscoverSkills func() SkillLoadResult
// InjectSkillAsContext sends a skill's content as a system message.
// Looks up skill by name from discovered skills.
InjectSkillAsContext func(skillName string) string
// InjectRawSkillAsContext loads and immediately injects a skill file.
InjectRawSkillAsContext func(path string) string
// GetAvailableSkills returns all currently loaded/discovered skills.
GetAvailableSkills func() []Skill
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge)
// -------------------------------------------------------------------------
// ParseTemplate extracts {{variables}} from template content.
ParseTemplate func(name, content string) PromptTemplate
// RenderTemplate substitutes variables into template content.
RenderTemplate func(tpl PromptTemplate, vars map[string]string) string
// ParseArguments parses command-line style arguments.
ParseArguments func(input string, pattern ArgumentPattern) ParseResult
// SimpleParseArguments parses $1, $2, $@ style arguments.
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
SimpleParseArguments func(input string, count int) []string
// EvaluateModelConditional checks if condition matches current model.
// Condition supports wildcards: * matches any, ? matches single char.
EvaluateModelConditional func(condition string) bool
// RenderWithModelConditionals processes <if-model> blocks in content.
RenderWithModelConditionals func(content string) string
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge)
// -------------------------------------------------------------------------
// ResolveModelChain attempts each model in order until one is available.
ResolveModelChain func(preferences []string) ModelResolutionResult
// GetModelCapabilities returns capabilities for a specific model.
// If model is empty, uses current model.
GetModelCapabilities func(model string) (ModelCapabilities, string)
// CheckModelAvailable verifies if a model string is valid.
CheckModelAvailable func(model string) bool
// GetCurrentProvider returns just the provider part of current model.
GetCurrentProvider func() string
// GetCurrentModelID returns just the model ID part of current model.
GetCurrentModelID func() string
}
// ---------------------------------------------------------------------------
@@ -552,6 +752,148 @@ type SessionMessage struct {
Timestamp string
}
// ---------------------------------------------------------------------------
// Tree navigation types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// TreeNode represents a node in the session tree for navigation.
// Extensions use this to traverse conversation history and implement
// features like "fresh context" loops and branch summarization.
type TreeNode struct {
// ID is the unique entry identifier.
ID string
// ParentID links this entry to its parent (empty if root).
ParentID string
// Type is the entry type: "message", "branch_summary", "model_change", "extension_data", "tool_execution".
Type string
// Role is the message role for message entries: "user", "assistant", "system", "tool".
Role string
// Content is the text content or summary.
Content string
// Model is the model that generated this (for assistant messages).
Model string
// Provider is the provider used.
Provider string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
// Children is the list of child entry IDs for tree traversal.
Children []string
}
// TreeNavigationResult reports success or failure of tree operations.
type TreeNavigationResult struct {
// Success is true if the operation completed.
Success bool
// Error describes what went wrong (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Skill types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// Skill represents a loaded skill file with parsed YAML frontmatter.
type Skill struct {
// Name is the human-readable identifier.
Name string
// Description summarizes what this skill provides.
Description string
// Content is the markdown body (frontmatter stripped).
Content string
// Path is the absolute filesystem path.
Path string
// Tags are optional labels for categorization.
Tags []string
// When controls automatic inclusion: "always", "on-demand", or file-glob.
When string
}
// SkillLoadResult reports skills loaded from a directory.
type SkillLoadResult struct {
// Skills is the list of loaded skills.
Skills []Skill
// Error describes loading failures (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Template parsing types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// PromptTemplate represents a parsed template with variable placeholders.
type PromptTemplate struct {
// Name is the template identifier.
Name string
// Content is the original template content.
Content string
// Variables are the extracted {{variable}} names.
Variables []string
}
// ArgumentPattern defines how to parse command arguments.
type ArgumentPattern struct {
// Positional names for $1, $2, etc.
Positional []string
// Rest is the variable name for $@ (all remaining).
Rest string
// Flags maps flag names to variable names (e.g., "--loop" -> "loop").
Flags map[string]string
}
// ParseResult reports argument parsing outcome.
type ParseResult struct {
// Vars maps variable names to values for positional args.
Vars map[string]string
// Flags maps flag names to values.
Flags map[string]string
// Rest is remaining unparsed text.
Rest string
// Error describes parsing failures (empty if success).
Error string
}
// ModelConditional represents an <if-model> block for evaluation.
type ModelConditional struct {
// Condition is the model pattern (e.g., "claude-*", "anthropic/*").
Condition string
// Content is rendered if condition matches.
Content string
// Else is rendered if condition doesn't match.
Else string
}
// ---------------------------------------------------------------------------
// Model resolution types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ModelCapabilities describes what a model supports.
type ModelCapabilities struct {
// Provider is the provider ID (e.g., "anthropic").
Provider string
// ModelID is the model identifier (e.g., "claude-sonnet-4-20250929").
ModelID string
// ContextLimit is the maximum context window in tokens.
ContextLimit int
// OutputLimit is the maximum output tokens.
OutputLimit int
// Reasoning indicates if the model supports reasoning/thinking.
Reasoning bool
// Streaming indicates if the model supports streaming.
Streaming bool
}
// ModelResolutionResult reports model chain resolution outcome.
type ModelResolutionResult struct {
// Model is the selected model in "provider/model" format.
Model string
// Capabilities describes the selected model.
Capabilities ModelCapabilities
// Attempted lists models tried before success.
Attempted []string
// Error describes resolution failures (empty if success).
Error 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.
@@ -653,6 +995,48 @@ type StatusBarEntry struct {
Priority int
}
// CompactConfig configures a programmatic context compaction request.
type CompactConfig struct {
// CustomInstructions is optional text appended to the summary prompt
// (e.g. "Focus on the API design decisions"). Empty uses the default.
CustomInstructions string
// OnComplete is called when compaction finishes successfully.
// May be nil if the caller doesn't need notification.
OnComplete func()
// OnError is called when compaction fails. The argument is the error message.
// May be nil if the caller doesn't need notification.
OnError func(errMsg string)
}
// FilePart describes a file attachment for multimodal messages. Extensions
// use this with SendMultimodalMessage to attach images or documents.
type FilePart struct {
// Filename is the name of the file (e.g. "photo.jpg").
Filename string
// Data is the raw file content.
Data []byte
// MediaType is the MIME type (e.g. "image/jpeg", "application/pdf").
MediaType string
}
// SessionUsage contains aggregated token usage and cost statistics for
// the current session. Extensions use this with GetSessionUsage() to
// report usage information.
type SessionUsage struct {
// TotalInputTokens is the sum of input tokens across all requests.
TotalInputTokens int
// TotalOutputTokens is the sum of output tokens across all requests.
TotalOutputTokens int
// TotalCacheReadTokens is the sum of cache read tokens.
TotalCacheReadTokens int
// TotalCacheWriteTokens is the sum of cache write tokens.
TotalCacheWriteTokens int
// TotalCost is the total cost in USD across all requests.
TotalCost float64
// RequestCount is the number of LLM requests made in this session.
RequestCount int
}
// PrintBlockOpts configures a custom styled block for PrintBlock.
type PrintBlockOpts struct {
// Text is the main content to display.
@@ -681,6 +1065,7 @@ type API struct {
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
onToolExecStart func(func(ToolExecutionStartEvent, Context))
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
onToolOutput func(func(ToolOutputEvent, Context))
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
onInput func(func(InputEvent, Context) *InputResult)
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
@@ -703,6 +1088,9 @@ type API struct {
registerOption func(OptionDef)
registerShortcutFn func(ShortcutDef, func(Context))
registerMessageRendererFn func(MessageRendererConfig)
onSubagentStart func(func(SubagentStartEvent, Context))
onSubagentChunk func(func(SubagentChunkEvent, Context))
onSubagentEnd func(func(SubagentEndEvent, Context))
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -721,12 +1109,40 @@ func (a *API) OnToolExecutionEnd(handler func(ToolExecutionEndEvent, Context)) {
a.onToolExecEnd(handler)
}
// OnToolOutput registers a handler for streaming tool output chunks.
// This fires for each output line as it arrives from tools like bash,
// allowing extensions to observe or process output in real-time.
func (a *API) OnToolOutput(handler func(ToolOutputEvent, Context)) {
a.onToolOutput(handler)
}
// OnToolResult registers a handler that fires after tool execution.
// Return a non-nil ToolResultResult to modify the output.
func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultResult) {
a.onToolResult(handler)
}
// OnSubagentStart registers a handler that fires when a subagent tool
// call begins executing. Use the ToolCallID to correlate with subsequent
// OnSubagentChunk and OnSubagentEnd events for the same subagent.
func (a *API) OnSubagentStart(handler func(SubagentStartEvent, Context)) {
a.onSubagentStart(handler)
}
// OnSubagentChunk registers a handler for real-time events from a running
// subagent. ChunkType identifies the kind of event ("text", "tool_call",
// "tool_result", "tool_execution_start", "tool_execution_end", etc.).
// Correlate with OnSubagentStart via the ToolCallID field.
func (a *API) OnSubagentChunk(handler func(SubagentChunkEvent, Context)) {
a.onSubagentChunk(handler)
}
// OnSubagentEnd registers a handler that fires when a subagent call
// completes. ErrorMsg is non-empty when the subagent failed.
func (a *API) OnSubagentEnd(handler func(SubagentEndEvent, Context)) {
a.onSubagentEnd(handler)
}
// OnInput registers a handler that fires when user input is received.
// Return a non-nil InputResult to transform or handle the input.
func (a *API) OnInput(handler func(InputEvent, Context) *InputResult) {
@@ -1000,6 +1416,29 @@ type PromptInputResult struct {
Cancelled bool
}
// PromptMultiSelectConfig configures a multi-selection prompt that allows
// the user to toggle multiple options and confirm their selection.
type PromptMultiSelectConfig struct {
// Message is the question or instruction displayed to the user.
Message string
// Options is the list of choices the user can select from.
Options []string
// DefaultSelected contains indices of options that should be
// pre-selected when the prompt appears. If nil, all options are selected.
DefaultSelected []int
}
// PromptMultiSelectResult is the response from a multi-selection prompt.
type PromptMultiSelectResult struct {
// Values contains the text of selected options.
Values []string
// Indices contains the zero-based indices of selected options.
Indices []int
// Cancelled is true if the user dismissed the prompt (ESC) or
// the prompt was unavailable (non-interactive mode).
Cancelled bool
}
// ---------------------------------------------------------------------------
// Header/Footer types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
@@ -1469,6 +1908,19 @@ type ToolExecutionEndEvent struct {
func (e ToolExecutionEndEvent) Type() EventType { return ToolExecutionEnd }
// ToolOutputEvent fires when a tool produces streaming output chunks.
// This is primarily used for long-running tools like bash to show output
// in real-time as it arrives, before the tool completes.
type ToolOutputEvent struct {
ToolCallID string
ToolName string
ToolKind string
Chunk string // Output text chunk
IsStderr bool // Whether this chunk came from stderr
}
func (e ToolOutputEvent) Type() EventType { return ToolOutput }
// ToolResultEvent fires after tool execution with the output.
type ToolResultEvent struct {
ToolCallID string
@@ -1674,13 +2126,115 @@ type BeforeCompactEvent struct {
func (e BeforeCompactEvent) Type() EventType { return BeforeCompact }
// BeforeCompactResult controls whether compaction proceeds. Return
// Cancel=true with an optional Reason to block compaction.
// Cancel=true with an optional Reason to block compaction, or provide
// a custom Summary to replace the default LLM-generated one.
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
// Summary, when non-empty, replaces the default LLM-generated summary.
// The extension is responsible for generating a useful summary.
// Ignored when Cancel is true.
Summary string
}
func (BeforeCompactResult) isResult() {}
// ---------------------------------------------------------------------------
// Subagent lifecycle events (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// SubagentStartEvent fires when a subagent tool call begins executing.
type SubagentStartEvent struct {
// ToolCallID is the LLM-assigned ID of the subagent tool call.
// Use this to correlate SubagentChunkEvent and SubagentEndEvent.
ToolCallID string
// Task is the task description passed to the subagent.
Task string
}
func (e SubagentStartEvent) Type() EventType { return SubagentStart }
// SubagentChunkEvent fires for each real-time event from a running subagent.
// Type field indicates the kind of event; read the relevant fields accordingly.
type SubagentChunkEvent struct {
// ToolCallID matches the SubagentStartEvent.ToolCallID for this subagent.
ToolCallID string
// Task is the task description (repeated for convenience).
Task string
// ChunkType identifies the event kind:
// "text" — LLM text chunk (read Content)
// "reasoning" — reasoning/thinking delta (read Content)
// "tool_call" — subagent called a tool (read ToolName, ToolArgs)
// "tool_result" — tool returned a result (read ToolName, ToolResult, IsError)
// "tool_execution_start" — tool began executing (read ToolName)
// "tool_execution_end" — tool finished executing (read ToolName)
// "turn_start" — subagent turn began
// "turn_end" — subagent turn ended
ChunkType string
// Content carries text for "text" and "reasoning" chunk types.
Content string
// ToolName is set on tool-related chunk types.
ToolName string
// ToolArgs is the JSON-encoded tool arguments for "tool_call" chunks.
ToolArgs string
// ToolResult is the tool output for "tool_result" chunks.
ToolResult string
// IsError is true when a "tool_result" chunk represents an error.
IsError bool
}
func (e SubagentChunkEvent) Type() EventType { return SubagentChunk }
// SubagentEndEvent fires when a subagent tool call completes.
type SubagentEndEvent struct {
// ToolCallID matches the SubagentStartEvent.ToolCallID for this subagent.
ToolCallID string
// Task is the task description.
Task string
// Response is the subagent's final text response (empty on error).
Response string
// ErrorMsg is non-empty when the subagent failed.
ErrorMsg string
}
func (e SubagentEndEvent) Type() EventType { return SubagentEnd }
// ThemeColor is an adaptive color pair with light and dark hex values.
// Either field may be empty to inherit from the default theme.
type ThemeColor struct {
Light string
Dark string
}
// ThemeColorConfig defines a complete color theme that extensions can register
// programmatically via ctx.RegisterTheme(). Uses plain hex strings (not
// color.Color) so the type is safe to pass across the Yaegi boundary.
type ThemeColorConfig struct {
Primary ThemeColor
Secondary ThemeColor
Success ThemeColor
Warning ThemeColor
Error ThemeColor
Info ThemeColor
Text ThemeColor
Muted ThemeColor
VeryMuted ThemeColor
Background ThemeColor
Border ThemeColor
MutedBorder ThemeColor
System ThemeColor
Tool ThemeColor
Accent ThemeColor
Highlight ThemeColor
// Markdown/syntax highlighting overrides.
MdHeading ThemeColor
MdLink ThemeColor
MdKeyword ThemeColor
MdString ThemeColor
MdNumber ThemeColor
MdComment ThemeColor
}
+16
View File
@@ -19,6 +19,9 @@ const (
// ToolExecutionEnd fires when a tool finishes executing.
ToolExecutionEnd EventType = "tool_execution_end"
// ToolOutput fires when a tool produces streaming output chunks.
ToolOutput EventType = "tool_output"
// ToolResult fires after a tool executes. Handlers can modify the result.
ToolResult EventType = "tool_result"
@@ -68,6 +71,18 @@ const (
// BeforeCompact fires before context compaction runs. Handlers can
// cancel compaction by returning Cancel=true.
BeforeCompact EventType = "before_compact"
// SubagentStart fires when a subagent tool call begins executing.
// Carries the tool call ID and the task description.
SubagentStart EventType = "subagent_start"
// SubagentChunk fires for each real-time event emitted by a running
// subagent: text chunks, tool calls, tool results, etc.
SubagentChunk EventType = "subagent_chunk"
// SubagentEnd fires when a subagent tool call completes (success
// or error). Carries the final response and any error message.
SubagentEnd EventType = "subagent_end"
)
// AllEventTypes returns every supported event type.
@@ -79,6 +94,7 @@ func AllEventTypes() []EventType {
SessionStart, SessionShutdown,
ModelChange, ContextPrepare,
BeforeFork, BeforeSessionSwitch, BeforeCompact,
SubagentStart, SubagentChunk, SubagentEnd,
}
}
+5 -2
View File
@@ -4,8 +4,8 @@ import "testing"
func TestAllEventTypes_Count(t *testing.T) {
all := AllEventTypes()
if len(all) != 18 {
t.Fatalf("expected 18 event types, got %d", len(all))
if len(all) != 21 {
t.Fatalf("expected 21 event types, got %d", len(all))
}
}
@@ -55,6 +55,9 @@ func TestEventType_TypeMethod(t *testing.T) {
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
{SubagentStartEvent{ToolCallID: "x", Task: "t"}, SubagentStart},
{SubagentChunkEvent{ToolCallID: "x", ChunkType: "text"}, SubagentChunk},
{SubagentEndEvent{ToolCallID: "x"}, SubagentEnd},
}
for _, tt := range tests {
+536
View File
@@ -0,0 +1,536 @@
package extensions
import (
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// InstallScope defines where a package should be installed.
type InstallScope string
const (
ScopeGlobal InstallScope = "global"
ScopeProject InstallScope = "project"
)
// GitSource represents a parsed git repository URL.
type GitSource struct {
Repo string // Clone URL (e.g., https://github.com/user/repo.git)
Host string // Host (e.g., github.com)
Path string // Path (e.g., user/repo)
Ref string // Optional ref (tag, branch, commit)
Pinned bool // Whether a specific ref is pinned
}
// String returns the canonical string representation.
func (g GitSource) String() string {
if g.Pinned {
return fmt.Sprintf("git:%s/%s@%s", g.Host, g.Path, g.Ref)
}
return fmt.Sprintf("git:%s/%s", g.Host, g.Path)
}
// Identity returns a normalized identity string for deduplication.
func (g GitSource) Identity() string {
return fmt.Sprintf("%s/%s", g.Host, g.Path)
}
// ParseGitSource parses a git source string into a GitSource.
// Supports formats like:
// - git:github.com/user/repo
// - git:github.com/user/repo@v1.0.0
// - https://github.com/user/repo
// - https://github.com/user/repo@v1.0.0
// - ssh://git@github.com/user/repo
// - git@github.com:user/repo
// - github.com/user/repo (shorthand, defaults to https)
func ParseGitSource(source string) (*GitSource, error) {
source = strings.TrimSpace(source)
// Check for @ref suffix
ref := ""
pinned := false
if atIdx := strings.LastIndex(source, "@"); atIdx > 0 {
// Make sure it's not part of the protocol (e.g., @ in ssh://git@)
after := source[atIdx+1:]
if !strings.Contains(after, "/") && !strings.Contains(after, ":") {
ref = after
pinned = true
source = source[:atIdx]
}
}
// Handle git: prefix
source, _ = strings.CutPrefix(source, "git:")
var repo, host, path string
// Handle explicit URLs
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
u, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
host = u.Host
path = strings.TrimPrefix(u.Path, "/")
path, _ = strings.CutSuffix(path, ".git")
repo = source
if !strings.HasSuffix(repo, ".git") {
repo += ".git"
}
} else if strings.HasPrefix(source, "ssh://") {
u, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid SSH URL: %w", err)
}
host = u.Host
path = strings.TrimPrefix(u.Path, "/")
path, _ = strings.CutSuffix(path, ".git")
repo = source
} else if strings.HasPrefix(source, "git@") {
// SSH shorthand: git@github.com:user/repo
parts := strings.SplitN(source, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid SSH shorthand format")
}
host = strings.TrimPrefix(parts[0], "git@")
path = parts[1]
path, _ = strings.CutSuffix(path, ".git")
repo = source
} else if strings.HasPrefix(source, "github.com/") || strings.HasPrefix(source, "gitlab.com/") || strings.HasPrefix(source, "bitbucket.org/") {
// Shorthand for known hosts: host/path
parts := strings.SplitN(source, "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid shorthand format, expected host/path")
}
host = parts[0]
path = parts[1]
repo = fmt.Sprintf("https://%s/%s.git", host, path)
} else if strings.HasPrefix(source, ".") || strings.HasPrefix(source, "/") || strings.HasPrefix(source, "~") {
// Local paths are not supported
return nil, fmt.Errorf("local paths not supported, use explicit extension path with -e flag")
} else {
// Generic shorthand: host/user/repo (3+ path segments)
parts := strings.Split(source, "/")
if len(parts) >= 3 {
host = parts[0]
path = strings.Join(parts[1:], "/")
repo = fmt.Sprintf("https://%s/%s.git", host, path)
} else {
return nil, fmt.Errorf("unrecognized source format: %s", source)
}
}
return &GitSource{
Repo: repo,
Host: host,
Path: path,
Ref: ref,
Pinned: pinned,
}, nil
}
// Installer handles installing, updating, and removing git-based extensions.
type Installer struct {
// Global packages root: $XDG_DATA_HOME/kit/git/ (default ~/.local/share/kit/git/)
globalGitRoot string
// Project packages root: .kit/git/
projectGitRoot string
}
// NewInstaller creates a new Installer.
func NewInstaller(projectDir string) *Installer {
return &Installer{
globalGitRoot: globalGitInstallRoot(),
projectGitRoot: filepath.Join(projectDir, ".kit", "git"),
}
}
// Install clones a git repository to the appropriate scope.
func (i *Installer) Install(source *GitSource, scope InstallScope) error {
return i.install(source, scope, nil)
}
// install is the internal implementation that supports optional include paths.
func (i *Installer) install(source *GitSource, scope InstallScope, includePaths []string) error {
targetDir := i.getInstallPath(source, scope)
// Check if already installed
if _, err := os.Stat(targetDir); err == nil {
return fmt.Errorf("extension already installed at %s", targetDir)
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetDir), 0755); err != nil {
return fmt.Errorf("creating parent directory: %w", err)
}
// Clone the repository
cmd := exec.Command("git", "clone", "--depth=1", source.Repo, targetDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git clone failed: %w\n%s", err, string(output))
}
// Checkout specific ref if pinned
if source.Pinned && source.Ref != "" {
checkoutCmd := exec.Command("git", "checkout", source.Ref)
checkoutCmd.Dir = targetDir
if output, err := checkoutCmd.CombinedOutput(); err != nil {
// Clean up on failed checkout
_ = os.RemoveAll(targetDir)
return fmt.Errorf("git checkout failed: %w\n%s", err, string(output))
}
}
// Validate that the package contains valid extensions
if err := i.validatePackage(targetDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("validation failed: %w", err)
}
// Add to manifest
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: source.Ref,
Pinned: source.Pinned,
Scope: scope,
Installed: time.Now(),
Include: includePaths,
}
if err := i.addToManifest(entry, scope); err != nil {
// Don't fail the install, just log the error
// The package is installed, manifest update failed
return fmt.Errorf("installed but failed to update manifest: %w", err)
}
return nil
}
// Uninstall removes an installed package.
func (i *Installer) Uninstall(source *GitSource, scope InstallScope) error {
targetDir := i.getInstallPath(source, scope)
if _, err := os.Stat(targetDir); err != nil {
return fmt.Errorf("extension not found at %s", targetDir)
}
// Remove the directory
if err := os.RemoveAll(targetDir); err != nil {
return fmt.Errorf("removing extension directory: %w", err)
}
// Remove from manifest
if err := i.removeFromManifest(source.Identity(), scope); err != nil {
return fmt.Errorf("removed but failed to update manifest: %w", err)
}
return nil
}
// Update fetches and resets a git package to the latest.
// For pinned packages, this does nothing.
func (i *Installer) Update(source *GitSource, scope InstallScope) error {
if source.Pinned {
return nil // Don't update pinned packages
}
targetDir := i.getInstallPath(source, scope)
if _, err := os.Stat(targetDir); err != nil {
return i.Install(source, scope)
}
// Fetch latest
fetchCmd := exec.Command("git", "fetch", "--prune", "origin")
fetchCmd.Dir = targetDir
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch failed: %w\n%s", err, string(output))
}
// Reset to tracking branch or origin/HEAD
resetCmd := exec.Command("git", "reset", "--hard", "@{upstream}")
resetCmd.Dir = targetDir
if _, err := resetCmd.CombinedOutput(); err != nil {
// Try alternative: set HEAD and reset to origin/HEAD
_ = exec.Command("git", "remote", "set-head", "origin", "-a").Run()
resetCmd = exec.Command("git", "reset", "--hard", "origin/HEAD")
resetCmd.Dir = targetDir
if output, err := resetCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git reset failed: %w\n%s", err, string(output))
}
}
// Clean untracked files
cleanCmd := exec.Command("git", "clean", "-fdx")
cleanCmd.Dir = targetDir
_ = cleanCmd.Run() // Ignore errors - clean is best effort
// Update manifest timestamp, preserving existing fields like Include
existing, _ := i.loadManifest(scope)
var include []string
var installed time.Time
if existing != nil {
for _, p := range existing.Packages {
if p.Host+"/"+p.Path == source.Identity() {
include = p.Include
installed = p.Installed
break
}
}
}
if installed.IsZero() {
installed = time.Now()
}
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: "",
Pinned: false,
Scope: scope,
Installed: installed,
Updated: time.Now(),
Include: include,
}
_ = i.addToManifest(entry, scope) // Best effort - don't fail update if manifest fails
return nil
}
// getInstallPath returns the target directory for a source.
func (i *Installer) getInstallPath(source *GitSource, scope InstallScope) string {
root := i.globalGitRoot
if scope == ScopeProject {
root = i.projectGitRoot
}
return filepath.Join(root, source.Host, source.Path)
}
// validatePackage checks that the cloned repo contains valid .go extension files.
func (i *Installer) validatePackage(dir string) error {
// Find all .go files in the directory
var goFiles []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") {
goFiles = append(goFiles, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walking directory: %w", err)
}
if len(goFiles) == 0 {
return fmt.Errorf("no .go files found in package")
}
// Try to load the first .go file to validate it's a valid extension
// We don't fail if validation fails - the extension might be fine but
// have dependencies that aren't available during install time
_, err = loadSingleExtension(goFiles[0])
if err != nil {
// Log but don't fail - the extension might need runtime deps
// User can use `kit extensions validate` to check later
return nil
}
return nil
}
// addToManifest adds an entry to the manifest.
func (i *Installer) addToManifest(entry ManifestEntry, scope InstallScope) error {
manifest, err := i.loadManifest(scope)
if err != nil {
return err
}
// Remove any existing entry with same identity
identity := entry.Host + "/" + entry.Path
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Host+"/"+p.Path != identity {
filtered = append(filtered, p)
}
}
filtered = append(filtered, entry)
manifest.Packages = filtered
return i.saveManifest(manifest, scope)
}
// removeFromManifest removes an entry from the manifest by identity.
func (i *Installer) removeFromManifest(identity string, scope InstallScope) error {
manifest, err := i.loadManifest(scope)
if err != nil {
return err
}
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Host+"/"+p.Path != identity {
filtered = append(filtered, p)
}
}
manifest.Packages = filtered
return i.saveManifest(manifest, scope)
}
// loadManifest loads the manifest for the given scope.
func (i *Installer) loadManifest(scope InstallScope) (*Manifest, error) {
path := i.manifestPath(scope)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Manifest{Packages: []ManifestEntry{}}, nil
}
return nil, err
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &manifest, nil
}
// saveManifest saves the manifest for the given scope.
func (i *Installer) saveManifest(manifest *Manifest, scope InstallScope) error {
path := i.manifestPath(scope)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// manifestPath returns the path to the manifest file.
func (i *Installer) manifestPath(scope InstallScope) string {
if scope == ScopeProject {
return filepath.Join(i.projectGitRoot, "packages.json")
}
return filepath.Join(i.globalGitRoot, "packages.json")
}
// globalGitInstallRoot returns the global git install root.
func globalGitInstallRoot() string {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "kit", "git")
}
// GetInstalledPackages returns all installed packages from both scopes.
func (i *Installer) GetInstalledPackages() ([]ManifestEntry, error) {
var all []ManifestEntry
global, err := i.loadManifest(ScopeGlobal)
if err != nil {
return nil, fmt.Errorf("loading global manifest: %w", err)
}
all = append(all, global.Packages...)
project, err := i.loadManifest(ScopeProject)
if err != nil {
return nil, fmt.Errorf("loading project manifest: %w", err)
}
all = append(all, project.Packages...)
return all, nil
}
// IsInstalled checks if a package is installed in either scope.
// Returns (scope, true) if installed, ("", false) otherwise.
func (i *Installer) IsInstalled(source *GitSource) (InstallScope, bool) {
globalPath := i.getInstallPath(source, ScopeGlobal)
if _, err := os.Stat(globalPath); err == nil {
return ScopeGlobal, true
}
projectPath := i.getInstallPath(source, ScopeProject)
if _, err := os.Stat(projectPath); err == nil {
return ScopeProject, true
}
return "", false
}
// PreviewExtensions clones a repo to a temporary directory and scans for extensions.
// Returns the preview list and the temp directory path (caller should clean up).
func (i *Installer) PreviewExtensions(source *GitSource) ([]ExtensionPreview, string, error) {
// Create temp directory
tempDir, err := os.MkdirTemp("", "kit-install-preview-*")
if err != nil {
return nil, "", fmt.Errorf("creating temp directory: %w", err)
}
// Clone to temp
cloneDir := filepath.Join(tempDir, "repo")
cmd := exec.Command("git", "clone", "--depth=1", source.Repo, cloneDir)
if output, err := cmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("git clone failed: %w\n%s", err, string(output))
}
// Checkout specific ref if pinned
if source.Pinned && source.Ref != "" {
checkoutCmd := exec.Command("git", "checkout", source.Ref)
checkoutCmd.Dir = cloneDir
if output, err := checkoutCmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("git checkout failed: %w\n%s", err, string(output))
}
}
// Scan for extensions
previews, err := ScanForExtensions(cloneDir)
if err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("scanning extensions: %w", err)
}
return previews, tempDir, nil
}
// InstallWithInclude clones a repo and installs only the specified extensions.
// includePaths are relative paths like "./git/main.go" - if empty, installs all.
func (i *Installer) InstallWithInclude(source *GitSource, scope InstallScope, includePaths []string) error {
return i.install(source, scope, includePaths)
}
// CleanupTempDir removes a temporary directory used for preview.
func CleanupTempDir(tempDir string) {
if tempDir != "" {
_ = os.RemoveAll(tempDir)
}
}
+392
View File
@@ -0,0 +1,392 @@
package extensions
import (
"os"
"path/filepath"
"testing"
)
func TestParseGitSource(t *testing.T) {
tests := []struct {
name string
source string
wantRepo string
wantHost string
wantPath string
wantRef string
wantPinned bool
wantErr bool
}{
{
name: "github shorthand",
source: "github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "github shorthand with version",
source: "github.com/user/repo@v1.0.0",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "v1.0.0",
wantPinned: true,
},
{
name: "git prefix shorthand",
source: "git:github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "https URL",
source: "https://github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "https URL with .git suffix",
source: "https://github.com/user/repo.git",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "ssh shorthand",
source: "git@github.com:user/repo",
wantRepo: "git@github.com:user/repo",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "ssh URL",
source: "ssh://git@github.com/user/repo",
wantRepo: "ssh://git@github.com/user/repo",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "gitlab shorthand",
source: "gitlab.com/user/repo",
wantRepo: "https://gitlab.com/user/repo.git",
wantHost: "gitlab.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "bitbucket shorthand",
source: "bitbucket.org/user/repo",
wantRepo: "https://bitbucket.org/user/repo.git",
wantHost: "bitbucket.org",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "generic host",
source: "gitea.example.com/user/repo",
wantRepo: "https://gitea.example.com/user/repo.git",
wantHost: "gitea.example.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "with branch ref",
source: "github.com/user/repo@main",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "main",
wantPinned: true,
},
{
name: "with commit ref",
source: "github.com/user/repo@abc1234",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "abc1234",
wantPinned: true,
},
{
name: "local path should error",
source: "./local/path",
wantErr: true,
},
{
name: "absolute path should error",
source: "/absolute/path",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseGitSource(tt.source)
if (err != nil) != tt.wantErr {
t.Errorf("ParseGitSource() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
if got.Repo != tt.wantRepo {
t.Errorf("ParseGitSource() Repo = %v, want %v", got.Repo, tt.wantRepo)
}
if got.Host != tt.wantHost {
t.Errorf("ParseGitSource() Host = %v, want %v", got.Host, tt.wantHost)
}
if got.Path != tt.wantPath {
t.Errorf("ParseGitSource() Path = %v, want %v", got.Path, tt.wantPath)
}
if got.Ref != tt.wantRef {
t.Errorf("ParseGitSource() Ref = %v, want %v", got.Ref, tt.wantRef)
}
if got.Pinned != tt.wantPinned {
t.Errorf("ParseGitSource() Pinned = %v, want %v", got.Pinned, tt.wantPinned)
}
})
}
}
func TestGitSourceIdentity(t *testing.T) {
source := &GitSource{
Host: "github.com",
Path: "user/repo",
}
if got := source.Identity(); got != "github.com/user/repo" {
t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo")
}
}
func TestGitSourceString(t *testing.T) {
tests := []struct {
name string
source GitSource
want string
}{
{
name: "unpinned",
source: GitSource{
Host: "github.com",
Path: "user/repo",
Pinned: false,
},
want: "git:github.com/user/repo",
},
{
name: "pinned",
source: GitSource{
Host: "github.com",
Path: "user/repo",
Ref: "v1.0.0",
Pinned: true,
},
want: "git:github.com/user/repo@v1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.source.String(); got != tt.want {
t.Errorf("String() = %v, want %v", got, tt.want)
}
})
}
}
func TestInstallerGetInstallPath(t *testing.T) {
tempDir := t.TempDir()
installer := NewInstaller(tempDir)
source := &GitSource{
Host: "github.com",
Path: "user/repo",
}
// Test global scope
globalPath := installer.getInstallPath(source, ScopeGlobal)
if !filepath.IsAbs(globalPath) {
t.Error("Global install path should be absolute")
}
// Test project scope
projectPath := installer.getInstallPath(source, ScopeProject)
expectedProjectPath := filepath.Join(tempDir, ".kit", "git", "github.com", "user", "repo")
if projectPath != expectedProjectPath {
t.Errorf("Project path = %v, want %v", projectPath, expectedProjectPath)
}
}
func TestManifestEntryIdentity(t *testing.T) {
entry := ManifestEntry{
Host: "github.com",
Path: "user/repo",
}
if got := entry.Identity(); got != "github.com/user/repo" {
t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo")
}
}
func TestLoadAndSaveManifest(t *testing.T) {
tempDir := t.TempDir()
manifestPath := filepath.Join(tempDir, "packages.json")
// Test loading non-existent manifest
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected empty packages, got %d", len(manifest.Packages))
}
// Create a manifest
manifest = &Manifest{
Packages: []ManifestEntry{
{
Source: "git:github.com/user/repo",
Repo: "https://github.com/user/repo.git",
Host: "github.com",
Path: "user/repo",
Pinned: false,
Scope: ScopeGlobal,
},
},
}
// Save it
err = saveManifestToPath(manifest, manifestPath)
if err != nil {
t.Fatalf("saveManifestToPath() error = %v", err)
}
// Load it back
loaded, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(loaded.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(loaded.Packages))
}
if loaded.Packages[0].Host != "github.com" {
t.Errorf("Expected host github.com, got %s", loaded.Packages[0].Host)
}
}
func TestAddAndRemoveFromManifest(t *testing.T) {
tempDir := t.TempDir()
// Set up environment for manifest path
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// The manifest path when XDG_DATA_HOME is set
manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json")
// Add an entry
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Verify it was added
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(manifest.Packages))
}
// Remove it
err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal)
if err != nil {
t.Fatalf("removeEntryFromManifest() error = %v", err)
}
// Verify it was removed
manifest, err = loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected 0 packages, got %d", len(manifest.Packages))
}
}
func TestFindInManifest(t *testing.T) {
tempDir := t.TempDir()
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// Add an entry to global manifest
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Find it
found, scope, err := FindInManifest("github.com/user/repo")
if err != nil {
t.Fatalf("FindInManifest() error = %v", err)
}
if found == nil {
t.Fatal("Expected to find entry, got nil")
}
if scope != ScopeGlobal {
t.Errorf("Expected scope global, got %s", scope)
}
// Try to find non-existent
notFound, _, err := FindInManifest("github.com/other/repo")
if err != nil {
t.Fatalf("FindInManifest() error = %v", err)
}
if notFound != nil {
t.Error("Expected nil for non-existent entry")
}
}
+277 -26
View File
@@ -34,47 +34,64 @@ func LoadExtensions(extraPaths []string) ([]LoadedExtension, error) {
for _, p := range paths {
ext, err := loadSingleExtension(p)
if err != nil {
log.Warn("skipping extension", "path", p, "err", err)
continue
}
loaded = append(loaded, *ext)
log.Debug("loaded extension", "path", p,
"handlers", countHandlers(ext),
"tools", len(ext.Tools),
"commands", len(ext.Commands),
"tool_renderers", len(ext.ToolRenderers))
log.Debug("loaded extension", "path", p, "handlers", countHandlers(ext), "tools", len(ext.Tools), "commands", len(ext.Commands), "tool_renderers", len(ext.ToolRenderers))
}
return loaded, nil
}
// pathSet is a thread-safe helper for deduplicating and ordering file paths.
type pathSet struct {
m map[string]bool
list []string
}
func newPathSet() *pathSet {
return &pathSet{m: make(map[string]bool)}
}
func (ps *pathSet) add(p string) bool {
abs, err := filepath.Abs(p)
if err != nil {
return false
}
if ps.m[abs] {
return false
}
ps.m[abs] = true
ps.list = append(ps.list, abs)
return true
}
// discoverExtensionPaths returns deduplicated paths to extension files in
// load-order (global first, then project-local, then explicit).
func discoverExtensionPaths(extraPaths []string) []string {
seen := make(map[string]bool)
var paths []string
add := func(p string) {
abs, err := filepath.Abs(p)
if err != nil {
return
}
if seen[abs] {
return
}
seen[abs] = true
paths = append(paths, abs)
}
ps := newPathSet()
// Global extensions: $XDG_CONFIG_HOME/kit/extensions/ (default ~/.config/kit/extensions/)
globalDir := globalExtensionsDir()
for _, p := range findExtensionsInDir(globalDir) {
add(p)
ps.add(p)
}
// Global installed git packages: $XDG_DATA_HOME/kit/git/
globalGitDir := globalGitInstallRoot()
for _, p := range findExtensionsInGitPackages(globalGitDir) {
ps.add(p)
}
// Project-local extensions: .kit/extensions/
localDir := filepath.Join(".kit", "extensions")
for _, p := range findExtensionsInDir(localDir) {
add(p)
ps.add(p)
}
// Project-local installed git packages: .kit/git/
projectGitDir := filepath.Join(".kit", "git")
for _, p := range findExtensionsInGitPackages(projectGitDir) {
ps.add(p)
}
// Explicit paths (highest precedence)
@@ -85,14 +102,14 @@ func discoverExtensionPaths(extraPaths []string) []string {
}
if info.IsDir() {
for _, found := range findExtensionsInDir(p) {
add(found)
ps.add(found)
}
} else if strings.HasSuffix(p, ".go") {
add(p)
ps.add(p)
}
}
return paths
return ps.list
}
// findExtensionsInDir returns .go files in dir and main.go in immediate subdirs.
@@ -111,7 +128,7 @@ func findExtensionsInDir(dir string) []string {
for _, entry := range entries {
full := filepath.Join(dir, entry.Name())
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
results = append(results, full)
} else if entry.IsDir() {
main := filepath.Join(full, "main.go")
@@ -123,6 +140,216 @@ func findExtensionsInDir(dir string) []string {
return results
}
// findExtensionsInRepo scans a git repository for extensions using opinionated conventions.
// Extensions are ONLY recognized in:
// 1. Root-level *.go files
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
// 3. Files in any top-level ext/ directory
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
//
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
func findExtensionsInRepo(repoPath string) []string {
var results []string
multiFileDirs := make(map[string]bool)
_ = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(repoPath, path)
relPath = filepath.ToSlash(relPath)
// Skip directories we know don't contain extensions
if info.IsDir() {
switch info.Name() {
case ".git", ".github", "node_modules", "vendor", "dist", "build":
return filepath.SkipDir
}
// Skip internal code directories
if strings.HasPrefix(relPath, "internal/") ||
strings.HasPrefix(relPath, "cmd/") ||
strings.HasPrefix(relPath, "pkg/") ||
strings.HasPrefix(relPath, "test/") ||
strings.HasPrefix(relPath, "tests/") {
return filepath.SkipDir
}
// Root directory - scan it
if relPath == "." {
return nil
}
base := info.Name()
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
// Allow walking into examples/ so we can reach examples/extensions/ etc,
// but don't treat examples/ itself or non-extension subdirs as extension locations.
if relPath == "examples" {
return nil
}
if !isExtDir {
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if relPath == base { // Top-level directory
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
}
return filepath.SkipDir
}
// Check for main.go
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
return nil
}
// It's a file
if !strings.HasSuffix(info.Name(), ".go") || strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
if info.Name() == "main.go" {
return nil
}
parentDir := filepath.Dir(relPath)
if parentDir == "." {
// Root-level .go file - valid extension
results = append(results, path)
return nil
}
// Must be in valid extension directory
isValidExtDir := false
if strings.HasPrefix(parentDir, "examples/extensions/") ||
parentDir == "examples/extensions" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
parentDir == "examples/ext" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "ext/") ||
parentDir == "ext" {
isValidExtDir = true
} else if strings.Contains(parentDir, "-extensions/") ||
strings.HasSuffix(parentDir, "-extensions") {
isValidExtDir = true
} else if strings.Contains(parentDir, "-ext/") ||
strings.HasSuffix(parentDir, "-ext") {
isValidExtDir = true
}
if !isValidExtDir {
return nil
}
results = append(results, path)
return nil
})
return results
}
// Each git package is stored at <gitRoot>/<host>/<owner>/<repo>/ and can contain
// .go files or a main.go in subdirectories.
// If a package has a manifest with Include field, only those paths are loaded.
func findExtensionsInGitPackages(gitRoot string) []string {
info, err := os.Stat(gitRoot)
if err != nil || !info.IsDir() {
return nil
}
var results []string
// Load the manifest if it exists
manifestPath := filepath.Join(gitRoot, "packages.json")
manifest, _ := loadManifestFromPath(manifestPath)
// Build a map of package identity -> include list
includeMap := make(map[string][]string)
if manifest != nil {
for _, entry := range manifest.Packages {
if len(entry.Include) > 0 {
identity := fmt.Sprintf("%s/%s", entry.Host, entry.Path)
includeMap[identity] = entry.Include
}
}
}
// Walk through host directories (e.g., github.com/)
hosts, err := os.ReadDir(gitRoot)
if err != nil {
return nil
}
for _, host := range hosts {
if !host.IsDir() {
continue
}
hostPath := filepath.Join(gitRoot, host.Name())
// Walk through owner directories (e.g., github.com/user/)
owners, err := os.ReadDir(hostPath)
if err != nil {
continue
}
for _, owner := range owners {
if !owner.IsDir() {
continue
}
ownerPath := filepath.Join(hostPath, owner.Name())
// Walk through repo directories (e.g., github.com/user/repo/)
repos, err := os.ReadDir(ownerPath)
if err != nil {
continue
}
for _, repo := range repos {
if !repo.IsDir() {
continue
}
repoPath := filepath.Join(ownerPath, repo.Name())
// Check if there's an include filter for this package
identity := fmt.Sprintf("%s/%s/%s", host.Name(), owner.Name(), repo.Name())
includes, hasFilter := includeMap[identity]
if hasFilter {
// Only include specific paths
for _, include := range includes {
// Convert relative path to absolute
include = strings.TrimPrefix(include, "./")
fullPath := filepath.Join(repoPath, filepath.FromSlash(include))
if _, err := os.Stat(fullPath); err == nil {
results = append(results, fullPath)
}
}
} else {
// Find all extensions within this repo using convention-based scanning
results = append(results, findExtensionsInRepo(repoPath)...)
}
}
}
}
return results
}
// globalExtensionsDir returns the global extensions directory, respecting
// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/extensions.
func globalExtensionsDir() string {
@@ -214,6 +441,12 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
return nil
})
},
onToolOutput: func(h func(ToolOutputEvent, Context)) {
reg(ToolOutput, func(e Event, c Context) Result {
h(e.(ToolOutputEvent), c)
return nil
})
},
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
reg(ToolResult, func(e Event, c Context) Result {
r := h(e.(ToolResultEvent), c)
@@ -349,6 +582,24 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
},
onSubagentStart: func(h func(SubagentStartEvent, Context)) {
reg(SubagentStart, func(e Event, c Context) Result {
h(e.(SubagentStartEvent), c)
return nil
})
},
onSubagentChunk: func(h func(SubagentChunkEvent, Context)) {
reg(SubagentChunk, func(e Event, c Context) Result {
h(e.(SubagentChunkEvent), c)
return nil
})
},
onSubagentEnd: func(h func(SubagentEndEvent, Context)) {
reg(SubagentEnd, func(e Event, c Context) Result {
h(e.(SubagentEndEvent), c)
return nil
})
},
}
// Call Init — the extension registers its handlers, tools, commands.
+9
View File
@@ -304,6 +304,15 @@ func Init(api ext.API) {
func TestLoadExtensions_SkipsBadFiles(t *testing.T) {
dir := t.TempDir()
// Isolate from host environment so globally-installed extensions
// are not discovered alongside the test fixtures.
isolated := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, "config"))
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, "data"))
origWd, _ := os.Getwd()
_ = os.Chdir(isolated)
t.Cleanup(func() { _ = os.Chdir(origWd) })
// Good extension
good := `package main
import "kit/ext"
+389
View File
@@ -0,0 +1,389 @@
package extensions
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Manifest tracks installed git packages.
type Manifest struct {
Packages []ManifestEntry `json:"packages"`
}
// ManifestEntry represents a single installed package.
type ManifestEntry struct {
// Source is the canonical string representation (e.g., "git:github.com/user/repo@v1.0.0")
Source string `json:"source"`
// Repo is the clone URL
Repo string `json:"repo"`
// Host is the git host (e.g., github.com)
Host string `json:"host"`
// Path is the path on the host (e.g., user/repo)
Path string `json:"path"`
// Ref is the optional pinned ref (tag/branch/commit)
Ref string `json:"ref,omitempty"`
// Pinned indicates if the ref is pinned
Pinned bool `json:"pinned"`
// Scope is where the package is installed (global or project)
Scope InstallScope `json:"scope"`
// Installed is when the package was first installed
Installed time.Time `json:"installed"`
// Updated is when the package was last updated (only for unpinned, zero time means never updated)
Updated time.Time `json:"updated,omitzero"`
// Include is a list of relative paths to extensions that should be loaded.
// If empty, all extensions in the package are loaded.
// Paths are relative to the package root (e.g., "./git/main.go", "./weather.go")
Include []string `json:"include,omitempty"`
}
// Identity returns the normalized identity for deduplication.
func (e ManifestEntry) Identity() string {
return fmt.Sprintf("%s/%s", e.Host, e.Path)
}
// loadManifest loads the manifest from the given scope.
func loadManifestFromScope(scope InstallScope) (*Manifest, error) {
path := manifestPathForScope(scope)
return loadManifestFromPath(path)
}
// loadManifestFromPath loads a manifest from a specific file path.
func loadManifestFromPath(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Manifest{Packages: []ManifestEntry{}}, nil
}
return nil, fmt.Errorf("reading manifest: %w", err)
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &manifest, nil
}
// saveManifestToScope saves the manifest to the given scope.
func saveManifestToScope(manifest *Manifest, scope InstallScope) error {
path := manifestPathForScope(scope)
return saveManifestToPath(manifest, path)
}
// saveManifestToPath saves a manifest to a specific file path.
func saveManifestToPath(manifest *Manifest, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// manifestPathForScope returns the manifest file path for a scope.
func manifestPathForScope(scope InstallScope) string {
if scope == ScopeProject {
return filepath.Join(".kit", "git", "packages.json")
}
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "kit", "git", "packages.json")
}
// GetGlobalManifest returns the global manifest.
func GetGlobalManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeGlobal)
}
// GetProjectManifest returns the project manifest.
func GetProjectManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeProject)
}
// addEntryToManifest adds or replaces an entry in the manifest for a scope.
func addEntryToManifest(entry ManifestEntry, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
// Remove any existing entry with same identity
identity := entry.Identity()
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
filtered = append(filtered, entry)
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// removeEntryFromManifest removes an entry by identity from the manifest for a scope.
func removeEntryFromManifest(identity string, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// FindInManifest finds an entry by identity in either global or project manifest.
// Returns the entry and its scope, or nil if not found.
func FindInManifest(identity string) (*ManifestEntry, InstallScope, error) {
global, err := loadManifestFromScope(ScopeGlobal)
if err != nil {
return nil, "", fmt.Errorf("loading global manifest: %w", err)
}
for _, p := range global.Packages {
if p.Identity() == identity {
return &p, ScopeGlobal, nil
}
}
project, err := loadManifestFromScope(ScopeProject)
if err != nil {
return nil, "", fmt.Errorf("loading project manifest: %w", err)
}
for _, p := range project.Packages {
if p.Identity() == identity {
return &p, ScopeProject, nil
}
}
return nil, "", nil
}
// ExtensionPreview represents a discovered extension in a package before installation.
type ExtensionPreview struct {
// Path is the relative path from the package root (e.g., "./git/main.go")
Path string `json:"path"`
// Name is a display name for the extension (derived from path or metadata)
Name string `json:"name"`
// Description is an optional description (could be extracted from comments)
Description string `json:"description,omitempty"`
// IsMain indicates if this is a main.go in a subdirectory
IsMain bool `json:"is_main"`
}
// ScanForExtensions discovers all extensions in a directory using opinionated conventions.
// Extensions are ONLY recognized in these specific locations:
// 1. Root-level *.go files
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
// 3. Files in any top-level ext/ directory
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
//
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return nil, fmt.Errorf("not a directory: %s", dir)
}
var previews []ExtensionPreview
multiFileDirs := make(map[string]bool)
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(dir, path)
relPath = filepath.ToSlash(relPath)
// Skip directories we know don't contain extensions
if info.IsDir() {
// Never scan these directories
switch info.Name() {
case ".git", ".github", "node_modules", "vendor", "dist", "build":
return filepath.SkipDir
}
// Skip internal code directories
if strings.HasPrefix(relPath, "internal/") ||
strings.HasPrefix(relPath, "cmd/") ||
strings.HasPrefix(relPath, "pkg/") ||
strings.HasPrefix(relPath, "test/") ||
strings.HasPrefix(relPath, "tests/") {
return filepath.SkipDir
}
// Root directory - scan it
if relPath == "." {
return nil
}
// Check if this directory is an extension location by name
// Pattern: must be named "extensions", "ext", or end with those
base := info.Name()
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
// Allow walking into examples/ so we can reach examples/extensions/ etc,
// but don't treat examples/ itself or non-extension subdirs as extension locations.
if relPath == "examples" {
return nil
}
if !isExtDir {
// Check for main.go before skipping
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
// This is a package with main.go at root level
if relPath == base { // Top-level directory
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
}
// Not an extension location
return filepath.SkipDir
}
// Check for main.go in this directory
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
// Scan this extensions directory
return nil
}
// It's a file - check if it's a valid extension
if !strings.HasSuffix(info.Name(), ".go") || strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
if info.Name() == "main.go" {
return nil // Already handled above
}
// Check if parent is a valid extension location
parentDir := filepath.Dir(relPath)
if parentDir == "." {
// Root-level .go file - valid extension
previews = append(previews, ExtensionPreview{
Path: "./" + relPath,
Name: deriveExtensionName(relPath, false),
IsMain: false,
})
return nil
}
// Check if we're in a valid extension directory
// Valid locations are:
// - examples/extensions/*
// - examples/ext/*
// - ext/* (top-level)
// - Any *-extensions/* or *-ext/* directory
isValidExtDir := false
if strings.HasPrefix(parentDir, "examples/extensions/") ||
parentDir == "examples/extensions" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
parentDir == "examples/ext" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "ext/") ||
parentDir == "ext" {
isValidExtDir = true
} else if strings.Contains(parentDir, "-extensions/") ||
strings.HasSuffix(parentDir, "-extensions") {
isValidExtDir = true
} else if strings.Contains(parentDir, "-ext/") ||
strings.HasSuffix(parentDir, "-ext") {
isValidExtDir = true
}
if !isValidExtDir {
return nil
}
previews = append(previews, ExtensionPreview{
Path: "./" + relPath,
Name: deriveExtensionName(relPath, false),
IsMain: false,
})
return nil
})
if err != nil {
return nil, err
}
return previews, nil
}
// deriveExtensionName creates a display name from a file path.
func deriveExtensionName(relPath string, isMain bool) string {
// Convert path to a readable name
// e.g., "git/main.go" -> "Git Extension"
// e.g., "weather.go" -> "Weather"
dir := filepath.Dir(relPath)
base := filepath.Base(relPath)
if isMain && dir != "." {
// Use immediate parent directory name for main.go files
name := filepath.Base(dir)
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, "-", " ")
return cases.Title(language.English).String(name) + " Extension"
}
// Use filename without extension
name := strings.TrimSuffix(base, ".go")
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, "-", " ")
return cases.Title(language.English).String(name)
}
+270 -10
View File
@@ -2,12 +2,12 @@ package extensions
import (
"fmt"
"log"
"os"
"sort"
"strings"
"sync"
"github.com/charmbracelet/log"
"github.com/spf13/viper"
)
@@ -56,11 +56,276 @@ func NewRunner(exts []LoadedExtension) *Runner {
}
// SetContext updates the runtime context (session ID, model, etc.) that is
// passed to every handler invocation. Thread-safe.
// passed to every handler invocation. Nil function fields are replaced with
// safe no-ops so extension handlers never panic on a missing callback.
// Thread-safe.
func (r *Runner) SetContext(ctx Context) {
r.mu.Lock()
defer r.mu.Unlock()
r.ctx = ctx
r.ctx = normalizeContext(ctx)
}
// normalizeContext replaces nil function fields in ctx with no-op stubs so
// that extension handlers can call any ctx method without a nil-function panic.
func normalizeContext(ctx Context) Context {
if ctx.Print == nil {
ctx.Print = func(string) {}
}
if ctx.PrintInfo == nil {
ctx.PrintInfo = func(string) {}
}
if ctx.PrintError == nil {
ctx.PrintError = func(string) {}
}
if ctx.PrintBlock == nil {
ctx.PrintBlock = func(PrintBlockOpts) {}
}
if ctx.SendMessage == nil {
ctx.SendMessage = func(string) {}
}
if ctx.CancelAndSend == nil {
ctx.CancelAndSend = func(string) {}
}
if ctx.Abort == nil {
ctx.Abort = func() {}
}
if ctx.IsIdle == nil {
ctx.IsIdle = func() bool { return true }
}
if ctx.Compact == nil {
ctx.Compact = func(CompactConfig) error { return fmt.Errorf("compact not available") }
}
if ctx.SendMultimodalMessage == nil {
ctx.SendMultimodalMessage = func(string, []FilePart) {}
}
if ctx.GetSessionUsage == nil {
ctx.GetSessionUsage = func() SessionUsage { return SessionUsage{} }
}
if ctx.SetWidget == nil {
ctx.SetWidget = func(WidgetConfig) {}
}
if ctx.RemoveWidget == nil {
ctx.RemoveWidget = func(string) {}
}
if ctx.SetHeader == nil {
ctx.SetHeader = func(HeaderFooterConfig) {}
}
if ctx.RemoveHeader == nil {
ctx.RemoveHeader = func() {}
}
if ctx.SetFooter == nil {
ctx.SetFooter = func(HeaderFooterConfig) {}
}
if ctx.RemoveFooter == nil {
ctx.RemoveFooter = func() {}
}
if ctx.PromptSelect == nil {
ctx.PromptSelect = func(PromptSelectConfig) PromptSelectResult {
return PromptSelectResult{Cancelled: true}
}
}
if ctx.PromptConfirm == nil {
ctx.PromptConfirm = func(PromptConfirmConfig) PromptConfirmResult {
return PromptConfirmResult{Cancelled: true}
}
}
if ctx.PromptInput == nil {
ctx.PromptInput = func(PromptInputConfig) PromptInputResult {
return PromptInputResult{Cancelled: true}
}
}
if ctx.PromptMultiSelect == nil {
ctx.PromptMultiSelect = func(PromptMultiSelectConfig) PromptMultiSelectResult {
return PromptMultiSelectResult{Cancelled: true}
}
}
if ctx.ShowOverlay == nil {
ctx.ShowOverlay = func(OverlayConfig) OverlayResult {
return OverlayResult{Cancelled: true, Index: -1}
}
}
if ctx.SetEditor == nil {
ctx.SetEditor = func(EditorConfig) {}
}
if ctx.ResetEditor == nil {
ctx.ResetEditor = func() {}
}
if ctx.SetEditorText == nil {
ctx.SetEditorText = func(string) {}
}
if ctx.SetUIVisibility == nil {
ctx.SetUIVisibility = func(UIVisibility) {}
}
if ctx.SetStatus == nil {
ctx.SetStatus = func(string, string, int) {}
}
if ctx.RemoveStatus == nil {
ctx.RemoveStatus = func(string) {}
}
if ctx.GetContextStats == nil {
ctx.GetContextStats = func() ContextStats { return ContextStats{} }
}
if ctx.GetMessages == nil {
ctx.GetMessages = func() []SessionMessage { return nil }
}
if ctx.GetSessionPath == nil {
ctx.GetSessionPath = func() string { return "" }
}
if ctx.AppendEntry == nil {
ctx.AppendEntry = func(string, string) (string, error) { return "", nil }
}
if ctx.GetEntries == nil {
ctx.GetEntries = func(string) []ExtensionEntry { return nil }
}
if ctx.GetOption == nil {
ctx.GetOption = func(string) string { return "" }
}
if ctx.SetOption == nil {
ctx.SetOption = func(string, string) {}
}
if ctx.SetModel == nil {
ctx.SetModel = func(string) error { return nil }
}
if ctx.GetAvailableModels == nil {
ctx.GetAvailableModels = func() []ModelInfoEntry { return nil }
}
if ctx.EmitCustomEvent == nil {
ctx.EmitCustomEvent = func(string, string) {}
}
if ctx.GetAllTools == nil {
ctx.GetAllTools = func() []ToolInfo { return nil }
}
if ctx.SetActiveTools == nil {
ctx.SetActiveTools = func([]string) {}
}
if ctx.Exit == nil {
ctx.Exit = func() {}
}
if ctx.Complete == nil {
ctx.Complete = func(CompleteRequest) (CompleteResponse, error) {
return CompleteResponse{}, nil
}
}
if ctx.SuspendTUI == nil {
ctx.SuspendTUI = func(callback func()) error { callback(); return nil }
}
if ctx.RenderMessage == nil {
ctx.RenderMessage = func(string, string) {}
}
if ctx.RegisterTheme == nil {
ctx.RegisterTheme = func(string, ThemeColorConfig) {}
}
if ctx.SetTheme == nil {
ctx.SetTheme = func(string) error { return nil }
}
if ctx.ListThemes == nil {
ctx.ListThemes = func() []string { return nil }
}
if ctx.ReloadExtensions == nil {
ctx.ReloadExtensions = func() error { return nil }
}
if ctx.SpawnSubagent == nil {
ctx.SpawnSubagent = func(SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
return nil, nil, nil
}
}
// -------------------------------------------------------------------------
// Tree Navigation API no-ops
// -------------------------------------------------------------------------
if ctx.GetTreeNode == nil {
ctx.GetTreeNode = func(string) *TreeNode { return nil }
}
if ctx.GetCurrentBranch == nil {
ctx.GetCurrentBranch = func() []TreeNode { return nil }
}
if ctx.GetChildren == nil {
ctx.GetChildren = func(string) []string { return nil }
}
if ctx.NavigateTo == nil {
ctx.NavigateTo = func(string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
if ctx.SummarizeBranch == nil {
ctx.SummarizeBranch = func(string, string) string {
return ""
}
}
if ctx.CollapseBranch == nil {
ctx.CollapseBranch = func(string, string, string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
// -------------------------------------------------------------------------
// Skill Loading API no-ops
// -------------------------------------------------------------------------
if ctx.LoadSkill == nil {
ctx.LoadSkill = func(string) (*Skill, string) { return nil, "" }
}
if ctx.LoadSkillsFromDir == nil {
ctx.LoadSkillsFromDir = func(string) SkillLoadResult { return SkillLoadResult{} }
}
if ctx.DiscoverSkills == nil {
ctx.DiscoverSkills = func() SkillLoadResult { return SkillLoadResult{} }
}
if ctx.InjectSkillAsContext == nil {
ctx.InjectSkillAsContext = func(string) string { return "" }
}
if ctx.InjectRawSkillAsContext == nil {
ctx.InjectRawSkillAsContext = func(string) string { return "" }
}
if ctx.GetAvailableSkills == nil {
ctx.GetAvailableSkills = func() []Skill { return nil }
}
// -------------------------------------------------------------------------
// Template Parsing API no-ops
// -------------------------------------------------------------------------
if ctx.ParseTemplate == nil {
ctx.ParseTemplate = func(string, string) PromptTemplate { return PromptTemplate{} }
}
if ctx.RenderTemplate == nil {
ctx.RenderTemplate = func(PromptTemplate, map[string]string) string { return "" }
}
if ctx.ParseArguments == nil {
ctx.ParseArguments = func(string, ArgumentPattern) ParseResult { return ParseResult{} }
}
if ctx.SimpleParseArguments == nil {
ctx.SimpleParseArguments = func(string, int) []string { return nil }
}
if ctx.EvaluateModelConditional == nil {
ctx.EvaluateModelConditional = func(string) bool { return false }
}
if ctx.RenderWithModelConditionals == nil {
ctx.RenderWithModelConditionals = func(string) string { return "" }
}
// -------------------------------------------------------------------------
// Model Resolution API no-ops
// -------------------------------------------------------------------------
if ctx.ResolveModelChain == nil {
ctx.ResolveModelChain = func([]string) ModelResolutionResult {
return ModelResolutionResult{Error: "not implemented"}
}
}
if ctx.GetModelCapabilities == nil {
ctx.GetModelCapabilities = func(string) (ModelCapabilities, string) {
return ModelCapabilities{}, "not implemented"
}
}
if ctx.CheckModelAvailable == nil {
ctx.CheckModelAvailable = func(string) bool { return false }
}
if ctx.GetCurrentProvider == nil {
ctx.GetCurrentProvider = func() string { return "" }
}
if ctx.GetCurrentModelID == nil {
ctx.GetCurrentModelID = func() string { return "" }
}
return ctx
}
// GetContext returns a snapshot of the current runtime context. Thread-safe.
@@ -105,10 +370,7 @@ func (r *Runner) Emit(event Event) (Result, error) {
for _, handler := range handlers {
result, err := safeCall(handler, event, ctx)
if err != nil {
log.Warn("extension handler error",
"path", ext.Path,
"event", event.Type(),
"err", err)
log.Printf("WARN extension handler error: path=%s event=%s err=%v", ext.Path, event.Type(), err)
continue
}
if result == nil {
@@ -442,9 +704,7 @@ func (r *Runner) EmitCustomEvent(name, data string) {
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))
log.Printf("WARN custom event handler panicked: event=%s err=%v", name, rec)
}
}()
h(data)
+2 -2
View File
@@ -173,10 +173,10 @@ type subagentJSONOutput struct {
} `json:"usage,omitempty"`
}
var subagentCounter uint64
var subagentCounter atomic.Uint64
func generateSubagentID() string {
n := atomic.AddUint64(&subagentCounter, 1)
n := subagentCounter.Add(1)
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
}
+39 -6
View File
@@ -31,6 +31,7 @@ func Symbols() interp.Exports {
// Session types
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
"SessionUsage": reflect.ValueOf((*SessionUsage)(nil)),
// Option types
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
@@ -44,6 +45,8 @@ func Symbols() interp.Exports {
// LLM completion types
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
"CompactConfig": reflect.ValueOf((*CompactConfig)(nil)),
"FilePart": reflect.ValueOf((*FilePart)(nil)),
// Status bar types
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
@@ -90,12 +93,14 @@ func Symbols() interp.Exports {
"EditorConfig": reflect.ValueOf((*EditorConfig)(nil)),
// Prompt types
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
"PromptMultiSelectConfig": reflect.ValueOf((*PromptMultiSelectConfig)(nil)),
"PromptMultiSelectResult": reflect.ValueOf((*PromptMultiSelectResult)(nil)),
// Context filtering types
"ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),
@@ -117,11 +122,39 @@ func Symbols() interp.Exports {
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
// Subagent lifecycle events
"SubagentStartEvent": reflect.ValueOf((*SubagentStartEvent)(nil)),
"SubagentChunkEvent": reflect.ValueOf((*SubagentChunkEvent)(nil)),
"SubagentEndEvent": reflect.ValueOf((*SubagentEndEvent)(nil)),
// Theme types
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
// Tree navigation types
"TreeNode": reflect.ValueOf((*TreeNode)(nil)),
"TreeNavigationResult": reflect.ValueOf((*TreeNavigationResult)(nil)),
// Skill types
"Skill": reflect.ValueOf((*Skill)(nil)),
"SkillLoadResult": reflect.ValueOf((*SkillLoadResult)(nil)),
// Template parsing types
"PromptTemplate": reflect.ValueOf((*PromptTemplate)(nil)),
"ArgumentPattern": reflect.ValueOf((*ArgumentPattern)(nil)),
"ParseResult": reflect.ValueOf((*ParseResult)(nil)),
"ModelConditional": reflect.ValueOf((*ModelConditional)(nil)),
// Model resolution types
"ModelCapabilities": reflect.ValueOf((*ModelCapabilities)(nil)),
"ModelResolutionResult": reflect.ValueOf((*ModelResolutionResult)(nil)),
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
"ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)),
"ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)),
"ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)),
"ToolResultEvent": reflect.ValueOf((*ToolResultEvent)(nil)),
"ToolResultResult": reflect.ValueOf((*ToolResultResult)(nil)),
"InputEvent": reflect.ValueOf((*InputEvent)(nil)),
+193
View File
@@ -0,0 +1,193 @@
package extensions
// NewTestAPI creates an API object wired for testing.
// This is used by the test harness to load extensions and verify behavior.
// The registration functions wire handlers directly to the provided extension.
func NewTestAPI(ext *LoadedExtension) API {
reg := func(event EventType, fn HandlerFunc) {
ext.Handlers[event] = append(ext.Handlers[event], fn)
}
return API{
onToolCall: func(h func(ToolCallEvent, Context) *ToolCallResult) {
reg(ToolCall, func(e Event, c Context) Result {
r := h(e.(ToolCallEvent), c)
if r == nil {
return nil
}
return *r
})
},
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
reg(ToolExecutionStart, func(e Event, c Context) Result {
h(e.(ToolExecutionStartEvent), c)
return nil
})
},
onToolExecEnd: func(h func(ToolExecutionEndEvent, Context)) {
reg(ToolExecutionEnd, func(e Event, c Context) Result {
h(e.(ToolExecutionEndEvent), c)
return nil
})
},
onToolOutput: func(h func(ToolOutputEvent, Context)) {
reg(ToolOutput, func(e Event, c Context) Result {
h(e.(ToolOutputEvent), c)
return nil
})
},
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
reg(ToolResult, func(e Event, c Context) Result {
r := h(e.(ToolResultEvent), c)
if r == nil {
return nil
}
return *r
})
},
onInput: func(h func(InputEvent, Context) *InputResult) {
reg(Input, func(e Event, c Context) Result {
r := h(e.(InputEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeAgentStart: func(h func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) {
reg(BeforeAgentStart, func(e Event, c Context) Result {
r := h(e.(BeforeAgentStartEvent), c)
if r == nil {
return nil
}
return *r
})
},
onAgentStart: func(h func(AgentStartEvent, Context)) {
reg(AgentStart, func(e Event, c Context) Result {
h(e.(AgentStartEvent), c)
return nil
})
},
onAgentEnd: func(h func(AgentEndEvent, Context)) {
reg(AgentEnd, func(e Event, c Context) Result {
h(e.(AgentEndEvent), c)
return nil
})
},
onMessageStart: func(h func(MessageStartEvent, Context)) {
reg(MessageStart, func(e Event, c Context) Result {
h(e.(MessageStartEvent), c)
return nil
})
},
onMessageUpdate: func(h func(MessageUpdateEvent, Context)) {
reg(MessageUpdate, func(e Event, c Context) Result {
h(e.(MessageUpdateEvent), c)
return nil
})
},
onMessageEnd: func(h func(MessageEndEvent, Context)) {
reg(MessageEnd, func(e Event, c Context) Result {
h(e.(MessageEndEvent), c)
return nil
})
},
onSessionStart: func(h func(SessionStartEvent, Context)) {
reg(SessionStart, func(e Event, c Context) Result {
h(e.(SessionStartEvent), c)
return nil
})
},
onSessionShutdown: func(h func(SessionShutdownEvent, Context)) {
reg(SessionShutdown, func(e Event, c Context) Result {
h(e.(SessionShutdownEvent), c)
return nil
})
},
onModelChange: func(h func(ModelChangeEvent, Context)) {
reg(ModelChange, func(e Event, c Context) Result {
h(e.(ModelChangeEvent), c)
return nil
})
},
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
reg(ContextPrepare, func(e Event, c Context) Result {
r := h(e.(ContextPrepareEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
reg(BeforeFork, func(e Event, c Context) Result {
r := h(e.(BeforeForkEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
r := h(e.(BeforeSessionSwitchEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
reg(BeforeCompact, func(e Event, c Context) Result {
r := h(e.(BeforeCompactEvent), c)
if r == nil {
return nil
}
return *r
})
},
registerToolFn: func(tool ToolDef) {
ext.Tools = append(ext.Tools, tool)
},
registerCmdFn: func(cmd CommandDef) {
ext.Commands = append(ext.Commands, cmd)
},
registerToolRendererFn: func(config ToolRenderConfig) {
ext.ToolRenderers = append(ext.ToolRenderers, config)
},
onCustomEvent: func(name string, handler func(string)) {
if ext.CustomEventHandlers == nil {
ext.CustomEventHandlers = make(map[string][]func(string))
}
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
},
registerOption: func(opt OptionDef) {
ext.Options = append(ext.Options, opt)
},
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
},
registerMessageRendererFn: func(config MessageRendererConfig) {
ext.MessageRenderers = append(ext.MessageRenderers, config)
},
onSubagentStart: func(h func(SubagentStartEvent, Context)) {
reg(SubagentStart, func(e Event, c Context) Result {
h(e.(SubagentStartEvent), c)
return nil
})
},
onSubagentChunk: func(h func(SubagentChunkEvent, Context)) {
reg(SubagentChunk, func(e Event, c Context) Result {
h(e.(SubagentChunkEvent), c)
return nil
})
},
onSubagentEnd: func(h func(SubagentEndEvent, Context)) {
reg(SubagentEnd, func(e Event, c Context) Result {
h(e.(SubagentEndEvent), c)
return nil
})
},
}
}
+192
View File
@@ -0,0 +1,192 @@
package extensions
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
// Watcher monitors extension directories for file changes and triggers
// a reload callback when .go files are created, modified, or removed.
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
type Watcher struct {
watcher *fsnotify.Watcher
onReload func()
debounce time.Duration
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
}
// NewWatcher creates a file watcher that monitors the given directories
// for .go file changes. When a change is detected (after debouncing),
// onReload is called. The watcher must be started with Start() and
// stopped with Close().
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating file watcher: %w", err)
}
for _, dir := range dirs {
// Watch the directory itself.
if err := fsw.Add(dir); err != nil {
log.Printf("DEBUG watcher: skipping directory: dir=%s err=%v", dir, err)
continue
}
// Also watch immediate subdirectories (for */main.go pattern).
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
subdir := filepath.Join(dir, entry.Name())
if err := fsw.Add(subdir); err != nil {
log.Printf("DEBUG watcher: skipping subdirectory: dir=%s err=%v", subdir, err)
}
}
}
}
return &Watcher{
watcher: fsw,
onReload: onReload,
debounce: 300 * time.Millisecond,
done: make(chan struct{}),
}, nil
}
// Start begins watching for file changes. It blocks until the context
// is cancelled or Close() is called. Typically called in a goroutine.
func (w *Watcher) Start(ctx context.Context) {
w.mu.Lock()
ctx, w.cancel = context.WithCancel(ctx)
w.mu.Unlock()
defer close(w.done)
var timer *time.Timer
var timerC <-chan time.Time
for {
select {
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Only care about .go files.
if !strings.HasSuffix(event.Name, ".go") {
continue
}
// React to write, create, remove, rename events.
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
continue
}
log.Printf("DEBUG watcher: file changed: file=%s op=%s", event.Name, event.Op)
// Debounce: reset timer on each event.
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(w.debounce)
timerC = timer.C
case <-timerC:
timerC = nil
timer = nil
log.Printf("DEBUG watcher: reloading extensions")
w.onReload()
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("WARN watcher: error: %v", err)
}
}
}
// Close stops the watcher and releases resources.
func (w *Watcher) Close() error {
w.mu.Lock()
cancel := w.cancel
w.mu.Unlock()
if cancel != nil {
cancel()
}
// Wait for the event loop to finish.
<-w.done
return w.watcher.Close()
}
// WatchedDirs returns the directories to watch for extension changes.
// This includes the global extensions directory and the project-local
// .kit/extensions/ directory (if they exist). Explicit -e paths that
// point to directories are also included; explicit file paths cause
// their parent directory to be watched instead.
func WatchedDirs(extraPaths []string) []string {
var dirs []string
seen := make(map[string]bool)
add := func(dir string) {
abs, err := filepath.Abs(dir)
if err != nil {
return
}
if seen[abs] {
return
}
// Verify the directory exists.
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
return
}
seen[abs] = true
dirs = append(dirs, abs)
}
// Global extensions dir.
add(globalExtensionsDir())
// Project-local extensions dir.
add(filepath.Join(".kit", "extensions"))
// Explicit paths that are directories.
for _, p := range extraPaths {
info, err := os.Stat(p)
if err != nil {
continue
}
if info.IsDir() {
add(p)
} else {
// For explicit files, watch the parent directory.
add(filepath.Dir(p))
}
}
return dirs
}
+158
View File
@@ -0,0 +1,158 @@
package extensions
import (
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatcher_ReloadsOnGoFileChange(t *testing.T) {
dir := t.TempDir()
// Write an initial extension file.
extFile := filepath.Join(dir, "test.go")
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
// Modify the file.
time.Sleep(50 * time.Millisecond) // let watcher settle
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
t.Fatal(err)
}
// Wait for debounce (300ms) + margin.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 1 {
t.Errorf("expected 1 reload, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatcher_IgnoresNonGoFiles(t *testing.T) {
dir := t.TempDir()
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
// Write a non-.go file.
time.Sleep(50 * time.Millisecond)
txtFile := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(txtFile, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
// Wait past the debounce window.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 0 {
t.Errorf("expected 0 reloads for .txt file, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatcher_Debounces(t *testing.T) {
dir := t.TempDir()
extFile := filepath.Join(dir, "ext.go")
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
time.Sleep(50 * time.Millisecond)
// Rapid-fire writes (simulating editor save: write temp, rename, etc.).
for range 5 {
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}
// Wait for debounce to fire.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 1 {
t.Errorf("expected 1 debounced reload, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatchedDirs_Deduplicates(t *testing.T) {
dir := t.TempDir()
dirs := WatchedDirs([]string{dir, dir})
count := 0
for _, d := range dirs {
abs, _ := filepath.Abs(dir)
if d == abs {
count++
}
}
if count != 1 {
t.Errorf("expected directory to appear once, got %d", count)
}
}
func TestWatchedDirs_FileParent(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "ext.go")
if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
dirs := WatchedDirs([]string{file})
abs, _ := filepath.Abs(dir)
found := false
for _, d := range dirs {
if d == abs {
found = true
}
}
if !found {
t.Errorf("expected parent dir %s in watched dirs %v", abs, dirs)
}
}

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