Compare commits

...

88 Commits

Author SHA1 Message Date
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
110 changed files with 10328 additions and 3037 deletions
+1
View File
@@ -3,6 +3,7 @@
.env
.kit/*
!.kit/extensions/
!.kit/prompts/
aidocs/
*.log
/kit
+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")
}
})
}
+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
+31 -20
View File
@@ -18,7 +18,7 @@ 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, spawn_subagent - 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
@@ -209,7 +209,7 @@ kit auth status # Check authentication status
# Model database
kit models [provider] # List available models (optionally filter by provider)
kit models --all # Show all providers (not just Fantasy-compatible)
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
@@ -287,7 +287,7 @@ kit -e examples/extensions/minimal.go
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent
**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
@@ -307,6 +307,12 @@ kit -e examples/extensions/minimal.go
- **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:
@@ -318,6 +324,7 @@ See the `examples/extensions/` directory:
- `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
@@ -332,6 +339,7 @@ See the `examples/extensions/` directory:
- `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
@@ -494,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?")
@@ -535,23 +543,26 @@ host, err := kit.New(ctx, &kit.Options{
### 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)
},
)
```
@@ -715,7 +726,7 @@ Use `custom/custom` when pointing Kit at any OpenAI-compatible endpoint with `--
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 fantasy's `openaicompat` provider and supports:
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
+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.
+300 -7
View File
@@ -1,9 +1,13 @@
package cmd
import (
"context"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"charm.land/huh/v2"
"github.com/mark3labs/kit/internal/auth"
@@ -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
}
@@ -282,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
}
+1 -1
View File
@@ -55,7 +55,7 @@ func printAllProviders(showAll bool) error {
if showAll {
providerIDs = kit.GetSupportedProviders()
} else {
providerIDs = kit.GetFantasyProviders()
providerIDs = kit.GetLLMProviders()
}
sort.Strings(providerIDs)
+573 -108
View File
@@ -13,6 +13,7 @@ import (
"charm.land/fantasy"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/auth"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
@@ -414,7 +415,7 @@ func runKit(ctx context.Context) error {
// normalised to start with "/" so they integrate with the slash-command
// autocomplete and dispatch pipeline.
func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
defs := k.ExtensionCommands()
defs := k.Extensions().Commands()
if len(defs) == 0 {
return nil
}
@@ -428,12 +429,12 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
Name: name,
Description: d.Description,
Execute: func(args string) (string, error) {
return d.Execute(args, k.GetExtensionContext())
return d.Execute(args, k.Extensions().GetContext())
},
}
if d.Complete != nil {
ec.Complete = func(prefix string) []string {
return d.Complete(prefix, k.GetExtensionContext())
return d.Complete(prefix, k.Extensions().GetContext())
}
}
cmds = append(cmds, ec)
@@ -445,11 +446,11 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
// ui.WidgetData for the given placement. Returns nil if extensions are
// disabled, which is safe — the UI treats a nil GetWidgets as "no widgets".
func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func(placement string) []ui.WidgetData {
configs := k.GetExtensionWidgets(extensions.WidgetPlacement(placement))
configs := k.Extensions().GetWidgets(extensions.WidgetPlacement(placement))
if len(configs) == 0 {
return nil
}
@@ -466,25 +467,34 @@ func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
}
}
// headerFooterProviderForUI returns a provider func that maps an
// extensions.HeaderFooterConfig getter into the ui.WidgetData shape
// expected by AppModel. The getter argument selects header vs footer.
func headerFooterProviderForUI(k *kit.Kit, getter func() *extensions.HeaderFooterConfig) func() *ui.WidgetData {
if !k.Extensions().HasExtensions() {
return nil
}
return func() *ui.WidgetData {
cfg := getter()
if cfg == nil {
return nil
}
return &ui.WidgetData{
Text: cfg.Content.Text,
Markdown: cfg.Content.Markdown,
BorderColor: cfg.Style.BorderColor,
NoBorder: cfg.Style.NoBorder,
}
}
}
// headerProviderForUI returns a function that converts the extension header
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
// which is safe — the UI treats a nil GetHeader as "no header".
func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
if !k.HasExtensions() {
return nil
}
return func() *ui.WidgetData {
config := k.GetExtensionHeader()
if config == nil {
return nil
}
return &ui.WidgetData{
Text: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
NoBorder: config.Style.NoBorder,
}
}
return headerFooterProviderForUI(k, func() *extensions.HeaderFooterConfig {
return k.Extensions().GetHeader()
})
}
// toolRendererProviderForUI returns a function that converts extension tool
@@ -492,11 +502,11 @@ func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
// disabled, which is safe — the UI treats a nil GetToolRenderer as "no
// custom renderers".
func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func(toolName string) *ui.ToolRendererData {
config := k.GetExtensionToolRenderer(toolName)
config := k.Extensions().GetToolRenderer(toolName)
if config == nil {
return nil
}
@@ -516,11 +526,11 @@ func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData {
// Returns nil if extensions are disabled, which is safe — the UI treats a
// nil GetEditorInterceptor as "no interceptor".
func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func() *ui.EditorInterceptor {
config := k.GetExtensionEditor()
config := k.Extensions().GetEditor()
if config == nil {
return nil
}
@@ -554,11 +564,11 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if
// extensions are disabled — the UI treats nil as "show everything".
func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func() *ui.UIVisibility {
v := k.GetExtensionUIVisibility()
v := k.Extensions().GetUIVisibility()
if v == nil {
return nil
}
@@ -575,21 +585,9 @@ func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
// which is safe — the UI treats a nil GetFooter as "no footer".
func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
if !k.HasExtensions() {
return nil
}
return func() *ui.WidgetData {
config := k.GetExtensionFooter()
if config == nil {
return nil
}
return &ui.WidgetData{
Text: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
NoBorder: config.Style.NoBorder,
}
}
return headerFooterProviderForUI(k, func() *extensions.HeaderFooterConfig {
return k.Extensions().GetFooter()
})
}
// statusBarProviderForUI returns a function that fetches extension status bar
@@ -597,11 +595,11 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
// if extensions are disabled, which is safe — the TUI treats a nil
// GetStatusBarEntries as "no extension entries".
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func() []ui.StatusBarEntryData {
entries := k.GetExtensionStatusEntries()
entries := k.Extensions().GetStatusEntries()
if len(entries) == 0 {
return nil
}
@@ -621,30 +619,36 @@ func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
// and returns (cancelled, reason). Returns nil if extensions are disabled —
// the UI treats nil as "no hook".
func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, string) {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return k.EmitBeforeFork
return func(targetID string, isUserMsg bool, userText string) (bool, string) {
return k.Extensions().EmitBeforeFork(targetID, isUserMsg, userText)
}
}
// beforeSessionSwitchProviderForUI returns a callback that emits a
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
// if extensions are disabled — the UI treats nil as "no hook".
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return k.EmitBeforeSessionSwitch
return func(switchReason string) (bool, string) {
return k.Extensions().EmitBeforeSessionSwitch(switchReason)
}
}
// globalShortcutsProviderForUI returns a callback that queries the extension
// runner for registered keyboard shortcuts. Returns nil if extensions are
// disabled — the UI treats nil as "no shortcuts".
func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return k.GetExtensionShortcuts
return func() map[string]func() {
return k.Extensions().GetShortcuts()
}
}
func runNormalMode(ctx context.Context) error {
@@ -676,9 +680,15 @@ func runNormalMode(ctx context.Context) error {
// Restore persisted model preference when no explicit --model flag or
// config file model is set. Precedence: CLI flag > config file > saved
// preference > built-in default. This mirrors how themes are persisted.
// Skip custom/* models unless --provider-url is also provided, since the
// custom provider requires a URL that was only valid for the previous session.
if !modelFlagChanged && !viper.InConfig("model") {
if pref := ui.LoadModelPreference(); pref != "" {
viper.Set("model", pref)
if strings.HasPrefix(pref, "custom/") && viper.GetString("provider-url") == "" {
// Don't restore custom models without a provider URL
} else {
viper.Set("model", pref)
}
}
}
@@ -699,6 +709,15 @@ func runNormalMode(ctx context.Context) error {
viper.Set("model", "custom/custom")
}
// When --provider-url is set with an explicit --model that lacks a provider
// prefix (no "/"), auto-prefix with "custom/" for OpenAI-compatible endpoints.
if viper.GetString("provider-url") != "" && modelFlagChanged {
model := viper.GetString("model")
if model != "" && !strings.Contains(model, "/") {
viper.Set("model", "custom/"+model)
}
}
// Load MCP configuration.
mcpConfig, err := config.LoadAndValidateConfig()
if err != nil {
@@ -775,7 +794,7 @@ func runNormalMode(ctx context.Context) error {
treeSession := kitInstance.GetTreeSession()
var messages []fantasy.Message
if treeSession != nil {
messages = treeSession.GetFantasyMessages()
messages = treeSession.GetLLMMessages()
}
// Create the app.App instance.
@@ -798,43 +817,53 @@ func runNormalMode(ctx context.Context) error {
appInstance := app.New(appOpts, messages)
defer appInstance.Close()
// Buffer for extension messages during startup (printed after startup banner).
var startupExtensionMessages []string
// Set up extension context and emit SessionStart.
if kitInstance.HasExtensions() {
if kitInstance.Extensions().HasExtensions() {
cwd, _ := os.Getwd()
kitInstance.SetExtensionContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: positionalPrompt == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
kitInstance.Extensions().SetContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: positionalPrompt == "",
Print: func(text string) {
// Capture messages during startup, print after startup banner.
startupExtensionMessages = append(startupExtensionMessages, text)
},
PrintInfo: func(text string) {
startupExtensionMessages = append(startupExtensionMessages, text)
},
PrintError: func(text string) {
startupExtensionMessages = append(startupExtensionMessages, text)
},
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.Steer(text) },
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveWidget: func(id string) {
kitInstance.RemoveExtensionWidget(id)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveWidget(id)
go appInstance.NotifyWidgetUpdate()
},
SetHeader: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionHeader(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetHeader(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveHeader: func() {
kitInstance.RemoveExtensionHeader()
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveHeader()
go appInstance.NotifyWidgetUpdate()
},
SetFooter: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionFooter(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetFooter(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveFooter: func() {
kitInstance.RemoveExtensionFooter()
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveFooter()
go appInstance.NotifyWidgetUpdate()
},
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
ch := make(chan app.PromptResponse, 1)
@@ -884,8 +913,8 @@ func runNormalMode(ctx context.Context) error {
return extensions.PromptInputResult{Value: resp.Value}
},
SetUIVisibility: func(v extensions.UIVisibility) {
kitInstance.SetExtensionUIVisibility(v)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetUIVisibility(v)
go appInstance.NotifyWidgetUpdate()
},
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
@@ -897,53 +926,52 @@ func runNormalMode(ctx context.Context) error {
}
},
SetEditor: func(config extensions.EditorConfig) {
kitInstance.SetExtensionEditor(config)
// Use a goroutine for NotifyWidgetUpdate because this may be
// called from within an editor HandleKey callback, which runs
// synchronously inside BubbleTea's Update(). Calling prog.Send()
// directly from Update() deadlocks the event loop.
kitInstance.Extensions().SetEditor(config)
// Always use a goroutine for NotifyWidgetUpdate: prog.Send()
// deadlocks if called synchronously from inside BubbleTea's
// Update() handler. All call sites use go-routines uniformly.
go appInstance.NotifyWidgetUpdate()
},
ResetEditor: func() {
kitInstance.ResetExtensionEditor()
kitInstance.Extensions().ResetEditor()
go appInstance.NotifyWidgetUpdate()
},
GetMessages: func() []extensions.SessionMessage {
return kitInstance.GetSessionMessages()
return kitInstance.Extensions().GetSessionMessages()
},
GetSessionPath: func() string {
return kitInstance.GetSessionFilePath()
return kitInstance.GetSessionPath()
},
AppendEntry: func(entryType string, 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)
},
SetEditorText: func(text string) {
appInstance.SetEditorTextFromExtension(text)
},
SetStatus: func(key string, text string, priority int) {
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
})
appInstance.NotifyWidgetUpdate()
go appInstance.NotifyWidgetUpdate()
},
RemoveStatus: func(key string) {
kitInstance.RemoveExtensionStatus(key)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveStatus(key)
go appInstance.NotifyWidgetUpdate()
},
GetOption: func(name string) string {
return kitInstance.GetExtensionOption(name)
return kitInstance.Extensions().GetOption(name)
},
SetOption: func(name string, value string) {
kitInstance.SetExtensionOption(name, value)
kitInstance.Extensions().SetOption(name, value)
},
SetModel: func(modelString string) error {
// Capture previous model for the ModelChange event.
previousModel := kitInstance.GetExtensionContext().Model
previousModel := kitInstance.Extensions().GetContext().Model
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
@@ -952,16 +980,34 @@ func runNormalMode(ctx context.Context) error {
p, m, _ := models.ParseModelString(modelString)
appInstance.NotifyModelChanged(p, m)
// Update the context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.Extensions().UpdateContextModel(modelString)
// Fire OnModelChange event to extensions.
kitInstance.EmitModelChange(modelString, previousModel, "extension")
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
// Update usage tracker with new model info for correct token counting.
if usageTracker != nil {
newProvider, newModel, _ := models.ParseModelString(modelString)
if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" {
registry := models.GetGlobalRegistry()
if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil {
// Check OAuth status for Anthropic models
isOAuth := false
if newProvider == "anthropic" {
_, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key"))
if err == nil && strings.HasPrefix(source, "stored OAuth") {
isOAuth = true
}
}
usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth)
}
}
}
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry {
return kitInstance.GetAvailableModels()
},
EmitCustomEvent: func(name string, data string) {
kitInstance.EmitExtensionCustomEvent(name, data)
kitInstance.Extensions().EmitCustomEvent(name, data)
},
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
@@ -970,7 +1016,7 @@ func runNormalMode(ctx context.Context) error {
return appInstance.SuspendTUI(callback)
},
RenderMessage: func(rendererName, content string) {
renderer := kitInstance.GetExtensionMessageRenderer(rendererName)
renderer := kitInstance.Extensions().GetMessageRenderer(rendererName)
if renderer == nil || renderer.Render == nil {
appInstance.PrintFromExtension("", content)
return
@@ -983,19 +1029,19 @@ func runNormalMode(ctx context.Context) error {
appInstance.PrintFromExtension("", rendered)
},
ReloadExtensions: func() error {
err := kitInstance.ReloadExtensions()
err := kitInstance.Extensions().Reload()
if err != nil {
return err
}
// Notify TUI that widgets/status/commands may have changed.
appInstance.NotifyWidgetUpdate()
go appInstance.NotifyWidgetUpdate()
return nil
},
GetAllTools: func() []extensions.ToolInfo {
return kitInstance.GetExtensionToolInfos()
return kitInstance.Extensions().GetToolInfos()
},
SetActiveTools: func(names []string) {
kitInstance.SetExtensionActiveTools(names)
kitInstance.Extensions().SetActiveTools(names)
},
RegisterTheme: func(name string, config extensions.ThemeColorConfig) {
tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} }
@@ -1066,7 +1112,7 @@ func runNormalMode(ctx context.Context) error {
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: result.Error,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
@@ -1078,8 +1124,398 @@ func runNormalMode(ctx context.Context) error {
}
return nil, extResult, err
},
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge)
// -------------------------------------------------------------------------
GetTreeNode: func(entryID string) *extensions.TreeNode {
node := kitInstance.GetTreeNode(entryID)
if node == nil {
return nil
}
return &extensions.TreeNode{
ID: node.ID,
ParentID: node.ParentID,
Type: node.Type,
Role: node.Role,
Content: node.Content,
Model: node.Model,
Provider: node.Provider,
Timestamp: node.Timestamp,
Children: node.Children,
}
},
GetCurrentBranch: func() []extensions.TreeNode {
nodes := kitInstance.GetCurrentBranch()
result := make([]extensions.TreeNode, len(nodes))
for i, n := range nodes {
result[i] = extensions.TreeNode{
ID: n.ID,
ParentID: n.ParentID,
Type: n.Type,
Role: n.Role,
Content: n.Content,
Model: n.Model,
Provider: n.Provider,
Timestamp: n.Timestamp,
Children: n.Children,
}
}
return result
},
GetChildren: kitInstance.GetChildren,
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
err := kitInstance.NavigateTo(entryID)
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
SummarizeBranch: func(fromID, toID string) string {
summary, _ := kitInstance.SummarizeBranch(fromID, toID)
return summary
},
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
err := kitInstance.CollapseBranch(fromID, toID, summary)
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge)
// -------------------------------------------------------------------------
LoadSkill: func(path string) (*extensions.Skill, string) {
s, err := kitInstance.LoadSkillForExtension(path)
return s, err
},
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
return kitInstance.LoadSkillsFromDirForExtension(dir)
},
DiscoverSkills: func() extensions.SkillLoadResult {
skills := kitInstance.DiscoverSkillsForExtension()
return extensions.SkillLoadResult{Skills: skills}
},
InjectSkillAsContext: func(skillName string) string {
// Find skill by name
skills := kitInstance.DiscoverSkillsForExtension()
for _, s := range skills {
if s.Name == skillName {
// Inject via SendMessage as a system context message
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
}
}
return fmt.Sprintf("skill not found: %s", skillName)
},
InjectRawSkillAsContext: func(path string) string {
s, err := kitInstance.LoadSkillForExtension(path)
if err != "" {
return err
}
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
},
GetAvailableSkills: kitInstance.DiscoverSkillsForExtension,
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge)
// -------------------------------------------------------------------------
ParseTemplate: kit.ParseTemplate,
RenderTemplate: kit.RenderTemplate,
ParseArguments: kit.ParseArguments,
SimpleParseArguments: kit.SimpleParseArguments,
EvaluateModelConditional: func(condition string) bool {
return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition)
},
RenderWithModelConditionals: func(content string) string {
return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model)
},
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge)
// -------------------------------------------------------------------------
ResolveModelChain: kit.ResolveModelChain,
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
return kit.GetModelCapabilities(model)
},
CheckModelAvailable: kit.CheckModelAvailable,
GetCurrentProvider: func() string {
return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model)
},
GetCurrentModelID: func() string {
return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model)
},
})
kitInstance.Extensions().EmitSessionStart()
// Restore normal print functions for runtime use.
kitInstance.Extensions().SetContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: positionalPrompt == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveWidget: func(id string) {
kitInstance.Extensions().RemoveWidget(id)
go appInstance.NotifyWidgetUpdate()
},
SetHeader: func(config extensions.HeaderFooterConfig) {
kitInstance.Extensions().SetHeader(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveHeader: func() {
kitInstance.Extensions().RemoveHeader()
go appInstance.NotifyWidgetUpdate()
},
SetFooter: func(config extensions.HeaderFooterConfig) {
kitInstance.Extensions().SetFooter(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveFooter: func() {
kitInstance.Extensions().RemoveFooter()
go appInstance.NotifyWidgetUpdate()
},
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "select",
Message: config.Message,
Options: config.Options,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptSelectResult{Cancelled: true}
}
return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index}
},
PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
ch := make(chan app.PromptResponse, 1)
def := "false"
if config.DefaultValue {
def = "true"
}
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "confirm",
Message: config.Message,
Default: def,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptConfirmResult{Cancelled: true}
}
return extensions.PromptConfirmResult{Value: resp.Confirmed}
},
PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "input",
Message: config.Message,
Placeholder: config.Placeholder,
Default: config.Default,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptInputResult{Cancelled: true}
}
return extensions.PromptInputResult{Value: resp.Value}
},
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
ch := make(chan app.OverlayResponse, 1)
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
Title: config.Title,
Content: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
Background: config.Style.Background,
Width: config.Width,
MaxHeight: config.MaxHeight,
Anchor: string(config.Anchor),
Actions: config.Actions,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.OverlayResult{Cancelled: true, Index: -1}
}
return extensions.OverlayResult{
Action: resp.Action,
Index: resp.Index,
}
},
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
// In-process subagent via SDK.
sdkCfg := kit.SubagentConfig{
Prompt: config.Prompt,
Model: config.Model,
SystemPrompt: config.SystemPrompt,
Timeout: config.Timeout,
NoSession: config.NoSession,
}
// Bridge SDK events to extension SubagentEvents.
if config.OnEvent != nil {
sdkCfg.OnEvent = func(e kit.Event) {
se := sdkEventToSubagentEvent(e)
if se.Type != "" {
config.OnEvent(se)
}
}
}
result, err := kitInstance.Subagent(ctx, sdkCfg)
if result == nil {
return nil, &extensions.SubagentResult{Error: err}, err
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
if result.Usage != nil {
extResult.Usage = &extensions.SubagentUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
}
}
return nil, extResult, err
},
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge) - Second Context
// -------------------------------------------------------------------------
GetTreeNode: func(entryID string) *extensions.TreeNode {
node := kitInstance.GetTreeNode(entryID)
if node == nil {
return nil
}
return &extensions.TreeNode{
ID: node.ID,
ParentID: node.ParentID,
Type: node.Type,
Role: node.Role,
Content: node.Content,
Model: node.Model,
Provider: node.Provider,
Timestamp: node.Timestamp,
Children: node.Children,
}
},
GetCurrentBranch: func() []extensions.TreeNode {
nodes := kitInstance.GetCurrentBranch()
result := make([]extensions.TreeNode, len(nodes))
for i, n := range nodes {
result[i] = extensions.TreeNode{
ID: n.ID,
ParentID: n.ParentID,
Type: n.Type,
Role: n.Role,
Content: n.Content,
Model: n.Model,
Provider: n.Provider,
Timestamp: n.Timestamp,
Children: n.Children,
}
}
return result
},
GetChildren: kitInstance.GetChildren,
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
err := kitInstance.NavigateTo(entryID)
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
SummarizeBranch: func(fromID, toID string) string {
summary, _ := kitInstance.SummarizeBranch(fromID, toID)
return summary
},
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
err := kitInstance.CollapseBranch(fromID, toID, summary)
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge) - Second Context
// -------------------------------------------------------------------------
LoadSkill: func(path string) (*extensions.Skill, string) {
s, err := kitInstance.LoadSkillForExtension(path)
return s, err
},
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
return kitInstance.LoadSkillsFromDirForExtension(dir)
},
DiscoverSkills: func() extensions.SkillLoadResult {
skills := kitInstance.DiscoverSkillsForExtension()
return extensions.SkillLoadResult{Skills: skills}
},
InjectSkillAsContext: func(skillName string) string {
skills := kitInstance.DiscoverSkillsForExtension()
for _, s := range skills {
if s.Name == skillName {
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
}
}
return fmt.Sprintf("skill not found: %s", skillName)
},
InjectRawSkillAsContext: func(path string) string {
s, err := kitInstance.LoadSkillForExtension(path)
if err != "" {
return err
}
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
},
GetAvailableSkills: func() []extensions.Skill {
return kitInstance.DiscoverSkillsForExtension()
},
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge) - Second Context
// -------------------------------------------------------------------------
ParseTemplate: kit.ParseTemplate,
RenderTemplate: kit.RenderTemplate,
ParseArguments: kit.ParseArguments,
SimpleParseArguments: kit.SimpleParseArguments,
EvaluateModelConditional: func(condition string) bool {
return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition)
},
RenderWithModelConditionals: func(content string) string {
return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model)
},
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge) - Second Context
// -------------------------------------------------------------------------
ResolveModelChain: kit.ResolveModelChain,
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
return kit.GetModelCapabilities(model)
},
CheckModelAvailable: kit.CheckModelAvailable,
GetCurrentProvider: func() string {
return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model)
},
GetCurrentModelID: func() string {
return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model)
},
})
kitInstance.EmitSessionStart()
}
// Convert extension commands to UI-layer type for the interactive TUI.
@@ -1147,15 +1583,33 @@ func runNormalMode(ctx context.Context) error {
return err
}
// Update the extension context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.Extensions().UpdateContextModel(modelString)
// NOTE: We do NOT call appInstance.NotifyModelChanged() here because
// this callback runs synchronously inside BubbleTea's Update(), and
// NotifyModelChanged calls prog.Send() which deadlocks. The UI layer
// updates m.providerName and m.modelName directly after setModel returns.
// Update usage tracker with new model info for correct token counting.
if usageTracker != nil {
newProvider, newModel, _ := models.ParseModelString(modelString)
if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" {
registry := models.GetGlobalRegistry()
if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil {
// Check OAuth status for Anthropic models
isOAuth := false
if newProvider == "anthropic" {
_, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key"))
if err == nil && strings.HasPrefix(source, "stored OAuth") {
isOAuth = true
}
}
usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth)
}
}
}
return nil
}
emitModelChangeForUI := func(newModel, previousModel, source string) {
kitInstance.EmitModelChange(newModel, previousModel, source)
kitInstance.Extensions().EmitModelChange(newModel, previousModel, source)
}
// Build thinking level callback.
@@ -1185,7 +1639,7 @@ func runNormalMode(ctx context.Context) error {
return fmt.Errorf("--quiet requires a prompt")
}
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, startupExtensionMessages)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -1241,7 +1695,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// If --no-exit was requested, hand off to the interactive TUI.
if noExit {
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, nil)
}
return nil
@@ -1296,7 +1750,7 @@ func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
}
for _, fmsg := range result.Messages {
converted := kit.ConvertFromFantasyMessage(fmsg)
converted := kit.ConvertFromLLMMessage(fmsg)
m := jsonMessage{Role: string(converted.Role)}
for _, p := range converted.Parts {
switch c := p.(type) {
@@ -1339,7 +1793,7 @@ func writeJSONError(err error) {
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, startupExtensionMessages []string) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -1386,9 +1840,20 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
ShowSessionPicker: resumeFlag,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
// Print KIT banner and startup info to stdout before Bubble Tea takes over the screen.
fmt.Println(kitBanner())
fmt.Println()
appModel.PrintStartupInfo()
// Print any extension messages that were captured during startup.
if len(startupExtensionMessages) > 0 {
fmt.Println()
for _, msg := range startupExtensionMessages {
fmt.Println(msg)
}
fmt.Println()
}
program := tea.NewProgram(appModel)
// Register the program with the app layer so agent events are sent to the TUI.
@@ -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)
}
}
})
}
}
+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] + "..."
}
+1 -1
View File
@@ -908,7 +908,7 @@ func summarizeToolAction(toolName string, inputJSON string) string {
return "searching " + getStr("pattern", "text")
case "ls":
return "listing " + getStr("path", "directory")
case "spawn_subagent":
case "subagent":
return "spawning subagent"
default:
return "using " + toolName
+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
+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,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) {
+31 -39
View File
@@ -3,9 +3,9 @@ module github.com/mark3labs/kit
go 1.26.1
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/fantasy v0.16.0
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
@@ -13,7 +13,9 @@ require (
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/coder/acp-go-sdk v0.6.3
github.com/mark3labs/mcp-go v0.45.0
github.com/indaco/herald v0.10.0
github.com/indaco/herald-md v0.1.0
github.com/mark3labs/mcp-go v0.46.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/traefik/yaegi v0.16.1
@@ -23,30 +25,27 @@ require (
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth v0.19.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.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // 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.13 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // 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.20 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // 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.14 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // 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.2 // 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
@@ -54,11 +53,11 @@ require (
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4 // 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
@@ -77,26 +76,22 @@ require (
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.19.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/googleapis/gax-go/v2 v2.20.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.0 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
github.com/kaptinlin/jsonschema v0.7.6 // indirect
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
github.com/mailru/easyjson v0.9.2 // 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/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
@@ -105,11 +100,9 @@ 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.17 // 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.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
@@ -122,8 +115,8 @@ require (
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.272.0 // indirect
google.golang.org/genai v1.51.0 // indirect
google.golang.org/api v0.273.0 // indirect
google.golang.org/genai v1.52.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
@@ -132,11 +125,10 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/glamour v1.0.0
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/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
+66 -81
View File
@@ -1,17 +1,17 @@
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/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.16.0 h1:vE/6sR9nPcSD8qXJXX6wR8NXjtWlBVAzwQmTh5pHVrs=
charm.land/fantasy v0.16.0/go.mod h1:VZjpXVh7IgeiIzGQybEnKzd68ofDsRj94+kzH1ZCAfQ=
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.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
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=
@@ -34,46 +34,40 @@ 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.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
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.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk=
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.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
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.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/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.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
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=
@@ -84,8 +78,6 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
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=
@@ -94,8 +86,8 @@ github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdR
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-20260316091819-b93f6a3b8502 h1:hzWNs3UQRSUTS6YCbLaQnwqKBFXT5Yh1OOw6+26apqg=
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502/go.mod h1:mkUCcxn9w9j89JJp3pOza5tmDQZPgIB75UfmQlFYvas=
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=
@@ -104,14 +96,14 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
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-20260322003602-9b007323c5cd h1:eStB6uX52pgrm6TxQcEKctPrEC+a/9ubJC+P671idOc=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 h1:pIj18ZCZO4WOVj7jwjLoUb1lC7rS/I8oC3fZWXugNaY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd h1:U8xj0UXwqHzO+UYHZJopKF+gWaQEW8oj60fmiq9TFY4=
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4 h1:VSd4zShIAf/4FgEDFJpapEcAPrc7h3dyyN7V9JlJpQw=
github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4/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=
@@ -173,51 +165,48 @@ 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.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
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.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs=
github.com/googleapis/gax-go/v2 v2.20.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.10.0 h1:XzahEKX6cr50qZQrUdA3QrQBHg8uGm5jETD0UDi21BI=
github.com/indaco/herald v0.10.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.1.0 h1:zmYudYo+uamzKTBcIffJVJYrqk9xDNnVrTh+de2zciw=
github.com/indaco/herald-md v0.1.0/go.mod h1:Z1HxPCbSn+/+TFzOM/UbsmKeEk/28NNI6JOTileKXto=
github.com/kaptinlin/go-i18n v0.3.0 h1:wP76dvYg04bvwTb+8NB+CmdZ2kL7lSSCQ9B/kFv7QHo=
github.com/kaptinlin/go-i18n v0.3.0/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
github.com/kaptinlin/jsonschema v0.7.6 h1:UUMqZGFAk7nOzQsYAxvgygm4wpDp/nwXxA4VP9mCPCs=
github.com/kaptinlin/jsonschema v0.7.6/go.mod h1:GGk/oE+F1lWUfYrzKaCf4QWZmMdytt0LL4XdFEFB0LE=
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.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
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.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo=
github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -228,22 +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/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=
@@ -279,16 +264,12 @@ 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.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/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.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
@@ -328,10 +309,14 @@ 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg=
google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/genai v1.52.0 h1:ekVIxWHtLUNbt+v0WWi4j3JT4yrHDEbysMcHQcaCQoI=
google.golang.org/genai v1.52.0/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-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
+322 -17
View File
@@ -7,8 +7,11 @@ package acpserver
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"sync/atomic"
"github.com/charmbracelet/log"
@@ -20,6 +23,17 @@ import (
// Version is injected at build time; fallback to "dev".
var Version = "dev"
// thinkingTagOpen and thinkingTagClose are the XML-style tags that some models
// (Qwen, DeepSeek) wrap reasoning content in. We parse these to extract
// reasoning/thinking content and send it as ACP thought updates.
// Also support <think> format used by some models.
const (
thinkingTagOpen = "<thinking>"
thinkingTagClose = "</thinking>"
shortThinkTagOpen = "<think>"
shortThinkTagClose = "</think>"
)
// Agent implements the acp.Agent interface, delegating to Kit for LLM
// execution, tool calls, and session management.
type Agent struct {
@@ -28,6 +42,10 @@ type Agent struct {
// toolCallCounter provides unique IDs for tool calls within a turn.
toolCallCounter atomic.Int64
// inThinkingTag tracks whether we're currently inside a <thinking> tag
// when parsing streaming content from models that wrap reasoning in XML tags.
inThinkingTag bool
}
// NewAgent creates a new ACP agent backed by Kit.
@@ -111,13 +129,23 @@ 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))
// Reset thinking tag state for this new prompt turn
a.inThinkingTag = false
// Create a cancellable context for this prompt turn.
promptCtx, cancel := context.WithCancel(ctx)
@@ -129,7 +157,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{
@@ -178,8 +212,24 @@ func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.S
var update *acp.SessionUpdate
switch ev := e.(type) {
case kit.MessageUpdateEvent:
u := acp.UpdateAgentMessageText(ev.Chunk)
update = &u
// Handle models that wrap reasoning in <thinking> tags (Qwen, DeepSeek)
// Parse the chunk and separate reasoning from regular text
reasoning, text := a.parseThinkingTags(ev.Chunk)
// Send reasoning update if we have reasoning content
if reasoning != "" {
u := acp.UpdateAgentThoughtText(reasoning)
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
SessionId: sessionID,
Update: u,
})
}
// Send text update if we have text content
if text != "" {
u := acp.UpdateAgentMessageText(text)
update = &u
}
case kit.ReasoningDeltaEvent:
u := acp.UpdateAgentThoughtText(ev.Delta)
@@ -231,19 +281,274 @@ 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
}
// parseThinkingTags parses a text chunk for <thinking> or tags and separates
// reasoning content from regular text. This handles models (Qwen, DeepSeek)
// that wrap reasoning in XML-style tags instead of using proper reasoning events.
// Returns (reasoningContent, textContent).
func (a *Agent) parseThinkingTags(chunk string) (reasoning string, text string) {
// Handle empty chunk
if chunk == "" {
return "", ""
}
// Determine which tag format to use (long or short)
openTag := thinkingTagOpen
closeTag := thinkingTagClose
if strings.Contains(chunk, shortThinkTagOpen) || strings.Contains(chunk, shortThinkTagClose) {
openTag = shortThinkTagOpen
closeTag = shortThinkTagClose
} else if !strings.Contains(chunk, thinkingTagOpen) && !strings.Contains(chunk, thinkingTagClose) && !a.inThinkingTag {
// No tags at all and not in thinking mode - return as text
return "", chunk
}
// Check for opening tag
if strings.Contains(chunk, openTag) {
parts := strings.SplitN(chunk, openTag, 2)
// Content before the opening tag is regular text
if !a.inThinkingTag && parts[0] != "" {
text = parts[0]
}
a.inThinkingTag = true
// Content after the opening tag is reasoning
if len(parts) > 1 {
// Check if the same chunk contains the closing tag
if strings.Contains(parts[1], closeTag) {
innerParts := strings.SplitN(parts[1], closeTag, 2)
reasoning = innerParts[0]
a.inThinkingTag = false
// Content after closing tag is regular text
if len(innerParts) > 1 && innerParts[1] != "" {
text += innerParts[1]
}
} else if parts[1] != "" {
// No closing tag yet, all remaining content is reasoning
reasoning = parts[1]
}
}
return reasoning, text
}
// Check for closing tag
if strings.Contains(chunk, closeTag) {
parts := strings.SplitN(chunk, closeTag, 2)
a.inThinkingTag = false
// Content before closing tag is reasoning
reasoning = parts[0]
// Content after closing tag is regular text
if len(parts) > 1 && parts[1] != "" {
text = parts[1]
}
return reasoning, text
}
// No tags found - content goes to current mode
if a.inThinkingTag {
return chunk, ""
}
return "", chunk
}
// 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 := 512
if len(data) < sampleSize {
sampleSize = len(data)
}
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{
+95 -33
View File
@@ -31,7 +31,7 @@ type AgentConfig struct {
CoreTools []fantasy.AgentTool
// 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
@@ -70,9 +70,14 @@ type ReasoningDeltaHandler func(delta string)
// Note: This is an alias for core.ToolOutputCallback to avoid import cycles.
type ToolOutputHandler = core.ToolOutputCallback
// Agent represents an AI agent with core tool integration using the fantasy library.
// 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.
type Agent struct {
toolManager *tools.MCPToolManager
@@ -95,7 +100,7 @@ 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
@@ -107,13 +112,13 @@ type GenerateWithLoopResult struct {
// 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.
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 {
@@ -153,7 +158,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
allTools = agentConfig.ToolWrapper(allTools)
}
// Build fantasy agent options
// Build agent options
var agentOpts []fantasy.AgentOption
if agentConfig.SystemPrompt != "" {
@@ -178,7 +183,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 {
@@ -192,7 +198,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
}
}
// Create the fantasy agent
// Create the agent
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
// Determine provider type from model string
@@ -225,11 +231,11 @@ 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, nil)
onResponse, onToolCallContent, 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,
@@ -237,6 +243,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
onStreamingResponse StreamingResponseHandler,
onReasoningDelta ReasoningDeltaHandler,
onToolOutput ToolOutputHandler,
onStepUsage StepUsageHandler,
) (*GenerateWithLoopResult, error) {
// Inject tool output handler into context for use by core tools (e.g., bash).
@@ -244,18 +251,21 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
}
// Fantasy requires the current user input as Prompt, with prior messages as history.
// 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 ||
@@ -263,13 +273,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if a.streamingEnabled || hasCallbacks {
// Track completed step messages so we can return partial results
// on cancellation. Fantasy's Stream() discards accumulated steps
// 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
// Use fantasy's streaming agent
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
// Use the streaming agent
streamCall := fantasy.AgentStreamCall{
Prompt: prompt,
Files: files,
Messages: history,
@@ -301,7 +311,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
@@ -351,9 +360,63 @@ 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 []string
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 _, text := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(text))
}
// 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
@@ -396,13 +459,11 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
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) {
@@ -445,8 +506,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
@@ -459,7 +520,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{
@@ -471,7 +532,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) {
@@ -484,7 +545,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.
@@ -597,7 +658,7 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
allTools = a.toolWrapper(allTools)
}
// Rebuild fantasy agent options.
// Rebuild agent options.
var agentOpts []fantasy.AgentOption
if a.systemPrompt != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
@@ -617,7 +678,8 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
}
// Pass generation parameters when available.
if config.MaxTokens > 0 {
// Skip max_output_tokens for providers that don't support it (e.g., Codex OAuth)
if config.MaxTokens > 0 && !providerResult.SkipMaxOutputTokens {
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(config.MaxTokens)))
}
if config.Temperature != nil {
@@ -657,7 +719,7 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
return nil
}
// GetModel returns the underlying fantasy LanguageModel.
// GetModel returns the underlying LanguageModel.
func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
}
+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
}
+1 -1
View File
@@ -39,7 +39,7 @@ type AgentCreationOptions struct {
// 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.
// 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
+35
View File
@@ -0,0 +1,35 @@
package agent
import "context"
// 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 string) 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 string {
ch, _ := ctx.Value(steerChKey{}).(<-chan string)
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
}
+244 -38
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"
@@ -65,6 +69,15 @@ 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.
@@ -159,11 +172,57 @@ 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 {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return 0
}
if !a.busy {
// Not busy — start immediately, same as Run().
item := queueItem{Prompt: prompt}
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.InjectSteer(prompt)
}
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,6 +271,17 @@ 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
@@ -226,10 +296,14 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
_ = 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.GetFantasyMessages())
a.store.Replace(ts.GetLLMMessages())
}
}
@@ -245,7 +319,7 @@ func (a *App) AddContextMessage(text string) {
// Persist to tree session if active.
if ts := a.opts.TreeSession; ts != nil {
_, _ = ts.AppendFantasyMessage(msg)
_, _ = ts.AppendLLMMessage(msg)
}
}
@@ -295,7 +369,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{
@@ -401,6 +475,13 @@ 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)
}
}
}
// --------------------------------------------------------------------------
@@ -434,6 +515,24 @@ func (a *App) drainQueue(first queueItem) {
// 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, text := range leftover {
steerItems[i] = queueItem{Prompt: text}
}
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
@@ -491,7 +590,7 @@ func (a *App) runQueueBatch(items []queueItem) {
// 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.GetFantasyMessages())
a.store.Replace(ts.GetLLMMessages())
}
a.sendEvent(StepCancelledEvent{})
return
@@ -522,9 +621,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.
@@ -544,8 +644,9 @@ 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
}
@@ -569,9 +670,10 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
}
}
// 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.
@@ -604,8 +706,8 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
messages = append(messages, item.Prompt)
}
// TODO: Handle file attachments in batch mode
// For now, files are ignored in batch mode (rare edge case)
// 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 {
@@ -626,8 +728,10 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker (using last item's prompt for tracking).
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
// 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
}
@@ -644,9 +748,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()
@@ -678,6 +783,10 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
Chunk: ev.Chunk,
IsStderr: ev.IsStderr,
})
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
case kit.StepUsageEvent:
a.recordStepUsage(ev, stepUsageSeen)
}
}))
@@ -746,12 +855,32 @@ 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)
}
}
@@ -847,29 +976,106 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
}
}
// 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 using FinalUsage.InputTokens,
// which reflects the full accumulated context. 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)
}
if result.FinalUsage != nil {
if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 {
a.opts.UsageTracker.SetContextTokens(ct)
// --- 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) ---
// Use FinalUsage.InputTokens as the context window fill. The API's InputTokens
// already includes the full conversation history (system prompt + all previous
// messages + current user message). Adding OutputTokens would double-count since
// the output becomes part of the input for the next turn.
if result.FinalUsage != nil && result.FinalUsage.InputTokens > 0 {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: calling SetContextTokens=%d (FinalUsage.InputTokens)",
result.FinalUsage.InputTokens)
}
a.opts.UsageTracker.SetContextTokens(int(result.FinalUsage.InputTokens))
}
}
+173
View File
@@ -7,6 +7,8 @@ import (
"testing"
"time"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -14,6 +16,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}
@@ -489,3 +532,133 @@ 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: &fantasy.Usage{
InputTokens: 999,
OutputTokens: 111,
CacheReadTokens: 7,
CacheCreationTokens: 3,
},
FinalUsage: &fantasy.Usage{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: &fantasy.Usage{
InputTokens: 0, // All cached - subtracted from prompt
OutputTokens: 150, // Actual generated tokens
CacheReadTokens: 500, // Cache hit
CacheCreationTokens: 0,
},
FinalUsage: &fantasy.Usage{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_contextTokensUsesInputOnly verifies that context
// window fill uses InputTokens only (not input+output). The API's InputTokens
// already includes the full conversation history; adding output would double-count.
func TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
InputTokens: 1000,
OutputTokens: 200,
},
FinalUsage: &fantasy.Usage{
InputTokens: 1000, // Full context including history
OutputTokens: 200,
},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
// Context tokens should be InputTokens only (1000), not input+output (1200)
// because InputTokens already includes the full conversation history
if usage.contextCalls != 1 || usage.lastContextTokens != 1000 {
t.Fatalf("expected context tokens=1000 (InputTokens only), got calls=%d tokens=%d",
usage.contextCalls, usage.lastContextTokens)
}
}
+6
View File
@@ -141,6 +141,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.
+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)
+266
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.
@@ -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.
+1 -2
View File
@@ -403,10 +403,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.")
+5 -18
View File
@@ -7,6 +7,7 @@ import (
"io"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
@@ -39,20 +40,8 @@ func toolOutputCallbackFromContext(ctx context.Context) ToolOutputCallback {
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"`
@@ -94,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
+234 -44
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"sort"
"strings"
"unicode"
"unicode/utf8"
@@ -13,19 +14,45 @@ import (
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",
@@ -33,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)
@@ -51,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
@@ -69,56 +114,201 @@ 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)
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
}
diff := generateDiff(absPath, normalized, newContent)
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,
}},
}
}
+312
View File
@@ -715,3 +715,315 @@ func TestExecuteEdit_MetadataContainsFileDiffs(t *testing.T) {
t.Fatal("file_diffs should be a non-empty array")
}
}
// ---------------------------------------------------------------------------
// Multi-edit tests
// ---------------------------------------------------------------------------
func TestExecuteEdit_MultiEdit_Basic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "multi.txt")
writeFileOrFail(t, path, "line1\nline2\nline3\nline4\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "line1", NewText: "LINE1"},
{OldText: "line3", NewText: "LINE3"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
if !strings.Contains(gotStr, "LINE1") {
t.Error("first edit not applied: missing LINE1")
}
if !strings.Contains(gotStr, "LINE3") {
t.Error("second edit not applied: missing LINE3")
}
if !strings.Contains(gotStr, "line2") {
t.Error("line2 was modified but should be untouched")
}
if !strings.Contains(gotStr, "line4") {
t.Error("line4 was modified but should be untouched")
}
// Check response mentions multiple edits
if !strings.Contains(resp.Content, "2 edits") {
t.Errorf("response should mention '2 edits', got: %s", resp.Content)
}
}
func TestExecuteEdit_MultiEdit_NonIncrementalMatching(t *testing.T) {
// All edits are matched against the original content, not incrementally
dir := t.TempDir()
path := filepath.Join(dir, "noninc.txt")
writeFileOrFail(t, path, "aaa\nbbb\nccc\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "aaa", NewText: "AAA"},
{OldText: "bbb", NewText: "BBB"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
want := "AAA\nBBB\nccc\n"
if gotStr != want {
t.Errorf("got %q, want %q", gotStr, want)
}
}
func TestExecuteEdit_MultiEdit_OverlapDetection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "overlap.txt")
writeFileOrFail(t, path, "hello world\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "hello", NewText: "HELLO"},
{OldText: "hello world", NewText: "GOODBYE"}, // Overlaps with first edit
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for overlapping edits")
}
if !strings.Contains(resp.Content, "overlap") {
t.Errorf("expected 'overlap' in error, got: %s", resp.Content)
}
// File should be untouched
got, _ := os.ReadFile(path)
if string(got) != "hello world\n" {
t.Error("file was modified despite error")
}
}
func TestExecuteEdit_MultiEdit_DuplicateDetection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dup.txt")
writeFileOrFail(t, path, "hello\nworld\nhello\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "hello", NewText: "HELLO"},
{OldText: "world", NewText: "WORLD"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for ambiguous old_text (duplicate matches)")
}
if !strings.Contains(resp.Content, "unique") {
t.Errorf("expected 'unique' in error, got: %s", resp.Content)
}
// File should be untouched
got, _ := os.ReadFile(path)
if string(got) != "hello\nworld\nhello\n" {
t.Error("file was modified despite error")
}
}
func TestExecuteEdit_MultiEdit_NotFound(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notfound.txt")
writeFileOrFail(t, path, "hello world\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "nonexistent", NewText: "REPLACEMENT"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for not found")
}
if !strings.Contains(resp.Content, "edits[0]") {
t.Errorf("expected 'edits[0]' in error, got: %s", resp.Content)
}
// File should be untouched
got, _ := os.ReadFile(path)
if string(got) != "hello world\n" {
t.Error("file was modified despite error")
}
}
func TestExecuteEdit_MultiEdit_EmptyArray(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.txt")
writeFileOrFail(t, path, "hello\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for empty edits array")
}
}
func TestExecuteEdit_MultiEdit_MixedWithSingleMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mixed.txt")
writeFileOrFail(t, path, "hello\n")
input, _ := json.Marshal(map[string]any{
"path": path,
"old_text": "hello",
"new_text": "HELLO",
"edits": []Edit{
{OldText: "hello", NewText: "HI"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error when mixing single and multi-edit modes")
}
if !strings.Contains(resp.Content, "cannot use") {
t.Errorf("expected 'cannot use' in error, got: %s", resp.Content)
}
}
func TestExecuteEdit_MultiEdit_FuzzyMatch(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fuzzy_multi.txt")
// File has trailing whitespace
original := "func foo() { \n\treturn 1 \n}\nfunc bar() { \n\treturn 2 \n}\n"
writeFileOrFail(t, path, original)
// Search without trailing whitespace (common LLM behavior)
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "func foo() {\n\treturn 1\n}", NewText: "func foo() {\n\treturn 10\n}"},
{OldText: "func bar() {\n\treturn 2\n}", NewText: "func bar() {\n\treturn 20\n}"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
if !strings.Contains(gotStr, "return 10") {
t.Error("first edit not applied")
}
if !strings.Contains(gotStr, "return 20") {
t.Error("second edit not applied")
}
// Response should mention fuzzy match
if !strings.Contains(resp.Content, "fuzzy") {
t.Errorf("response should mention 'fuzzy', got: %s", resp.Content)
}
}
func TestExecuteEdit_MultiEdit_Metadata(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "meta_multi.txt")
writeFileOrFail(t, path, "aaa\nbbb\nccc\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "aaa", NewText: "AAA"},
{OldText: "bbb", NewText: "BBB"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
var meta map[string]any
if err := json.Unmarshal([]byte(resp.Metadata), &meta); err != nil {
t.Fatalf("metadata is not valid JSON: %v", err)
}
diffs, ok := meta["file_diffs"].([]any)
if !ok || len(diffs) == 0 {
t.Fatal("metadata missing file_diffs")
}
firstDiff, ok := diffs[0].(map[string]any)
if !ok {
t.Fatal("first diff is not an object")
}
// Check that diff_blocks contains both edits
diffBlocks, ok := firstDiff["diff_blocks"].([]any)
if !ok || len(diffBlocks) != 2 {
t.Fatalf("expected 2 diff_blocks, got %d", len(diffBlocks))
}
// Verify each block has old_text and new_text
for i, block := range diffBlocks {
b, ok := block.(map[string]any)
if !ok {
t.Fatalf("diff_block[%d] is not an object", i)
}
if _, ok := b["old_text"]; !ok {
t.Fatalf("diff_block[%d] missing old_text", i)
}
if _, ok := b["new_text"]; !ok {
t.Fatalf("diff_block[%d] missing new_text", i)
}
}
}
+5 -5
View File
@@ -28,14 +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).
// The toolCallID parameter is the LLM-assigned ID of the spawn_subagent
// 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)
}
@@ -49,7 +49,7 @@ func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
}
// ---------------------------------------------------------------------------
// spawn_subagent tool
// subagent tool
// ---------------------------------------------------------------------------
type subagentArgs struct {
@@ -59,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
+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{
+319 -1
View File
@@ -572,6 +572,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
}
// ---------------------------------------------------------------------------
@@ -598,6 +694,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.
@@ -750,6 +988,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.
@@ -781,6 +1022,27 @@ func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultRes
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) {
@@ -1781,9 +2043,65 @@ type BeforeCompactResult struct {
func (BeforeCompactResult) isResult() {}
// ---------------------------------------------------------------------------
// Theme types (exposed to Yaegi — concrete structs, string hex colors)
// 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 {
+13
View File
@@ -71,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.
@@ -82,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 {
+49 -21
View File
@@ -47,46 +47,56 @@ func LoadExtensions(extraPaths []string) ([]LoadedExtension, error) {
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) {
add(p)
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) {
add(p)
ps.add(p)
}
// Explicit paths (highest precedence)
@@ -97,14 +107,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.
@@ -580,6 +590,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.
+252 -2
View File
@@ -56,11 +56,261 @@ 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.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.
+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)
}
+23
View File
@@ -119,10 +119,33 @@ 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)),
+18
View File
@@ -171,5 +171,23 @@ func NewTestAPI(ext *LoadedExtension) API {
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
})
},
}
}
+8 -8
View File
@@ -42,14 +42,14 @@ func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool
// coreToolKinds maps built-in tool names to their kind classification.
var coreToolKinds = map[string]string{
"bash": "execute",
"edit": "edit",
"write": "edit",
"read": "read",
"ls": "read",
"grep": "search",
"find": "search",
"spawn_subagent": "agent",
"bash": "execute",
"edit": "edit",
"write": "edit",
"read": "read",
"ls": "read",
"grep": "search",
"find": "search",
"subagent": "agent",
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
+60 -10
View File
@@ -4,11 +4,44 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"charm.land/fantasy"
)
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
// reasoning content in. Used to strip these tags from text content.
// The (?s) flag makes . match newlines.
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
// sanitizeToolCallID ensures the ID matches Anthropic's required pattern:
// ^[a-zA-Z0-9_-]+$ (alphanumeric, underscores, and hyphens only).
// Invalid characters are replaced with underscores.
func sanitizeToolCallID(id string) string {
var sb strings.Builder
for _, r := range id {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
sb.WriteRune(r)
case r >= '0' && r <= '9':
sb.WriteRune(r)
case r == '_' || r == '-':
sb.WriteRune(r)
default:
// Replace invalid characters with underscore
sb.WriteByte('_')
}
}
result := sb.String()
// Ensure non-empty (Anthropic requires at least one character)
if result == "" {
return "tool_0"
}
return result
}
// ContentPart is the marker interface for all message content block types.
// A message contains a heterogeneous slice of ContentPart values, enabling
// rich structured messages that carry text, reasoning, tool calls, tool
@@ -88,9 +121,9 @@ const (
)
// Message is a single conversation message containing a heterogeneous slice
// of ContentPart blocks. This design (borrowed from crush) enables a single
// assistant message to carry text, reasoning, and multiple tool calls as
// discrete, typed blocks rather than flattening everything into strings.
// of ContentPart blocks. This design enables a single assistant message to
// carry text, reasoning, and multiple tool calls as discrete, typed blocks
// rather than flattening everything into strings.
type Message struct {
ID string `json:"id"`
Role MessageRole `json:"role"`
@@ -285,12 +318,18 @@ func UnmarshalParts(data []byte) ([]ContentPart, error) {
return parts, nil
}
// --- Fantasy bridge ---
// --- LLM bridge ---
// ToFantasyMessages converts a Message to one or more fantasy.Message values.
// An assistant message with tool calls produces a single fantasy message with
// ToLLMMessages converts a Message to one or more LLM message values.
// An assistant message with tool calls produces a single message with
// mixed TextPart and ToolCallPart content. Tool-role messages produce
// ToolResultPart entries.
func (m *Message) ToLLMMessages() []fantasy.Message {
return m.ToFantasyMessages()
}
// Deprecated: Use ToLLMMessages instead.
// ToFantasyMessages converts a Message to one or more LLM message values.
func (m *Message) ToFantasyMessages() []fantasy.Message {
switch m.Role {
case RoleAssistant:
@@ -312,7 +351,7 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
// Add tool calls
for _, tc := range m.ToolCalls() {
parts = append(parts, fantasy.ToolCallPart{
ToolCallID: tc.ID,
ToolCallID: sanitizeToolCallID(tc.ID),
ToolName: tc.Name,
Input: tc.Input,
})
@@ -340,7 +379,7 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
}
}
parts = append(parts, fantasy.ToolResultPart{
ToolCallID: result.ToolCallID,
ToolCallID: sanitizeToolCallID(result.ToolCallID),
Output: output,
})
}
@@ -389,7 +428,14 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
}
}
// FromFantasyMessage converts a fantasy.Message into our Message type,
// FromLLMMessage converts an LLM message into our Message type,
// extracting all content parts into the appropriate block types.
func FromLLMMessage(msg fantasy.Message) Message {
return FromFantasyMessage(msg)
}
// Deprecated: Use FromLLMMessage instead.
// FromFantasyMessage converts an LLM message into our Message type,
// extracting all content parts into the appropriate block types.
func FromFantasyMessage(msg fantasy.Message) Message {
m := Message{
@@ -403,7 +449,11 @@ func FromFantasyMessage(msg fantasy.Message) Message {
switch p := part.(type) {
case fantasy.TextPart:
if p.Text != "" {
m.Parts = append(m.Parts, TextContent{Text: p.Text})
// Strip ... tags that some models wrap reasoning in
cleanedText := thinkTagRegex.ReplaceAllString(p.Text, "")
if cleanedText != "" {
m.Parts = append(m.Parts, TextContent{Text: cleanedText})
}
}
case fantasy.ToolCallPart:
m.Parts = append(m.Parts, ToolCall{
+113
View File
@@ -0,0 +1,113 @@
package message
import (
"testing"
)
func TestSanitizeToolCallID(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "valid alphanumeric ID",
input: "call_123abc",
expected: "call_123abc",
},
{
name: "ID with dots (OpenCode/Kimi style)",
input: "call.123.abc",
expected: "call_123_abc",
},
{
name: "ID with colons",
input: "tool:123:abc",
expected: "tool_123_abc",
},
{
name: "ID with special characters",
input: "tool@#$%^&*()",
expected: "tool_________",
},
{
name: "Anthropic style ID (already valid)",
input: "toolu_0123456789ABCDEF",
expected: "toolu_0123456789ABCDEF",
},
{
name: "OpenAI style ID (already valid)",
input: "call_O17Uplv4lJvD6DVdIvFFeRMw",
expected: "call_O17Uplv4lJvD6DVdIvFFeRMw",
},
{
name: "ID with hyphens",
input: "my-tool-call-123",
expected: "my-tool-call-123",
},
{
name: "empty string",
input: "",
expected: "tool_0",
},
{
name: "only special characters",
input: "@#$%",
expected: "____",
},
{
name: "mixed valid and invalid",
input: "call_123.abc-def@ghi",
expected: "call_123_abc-def_ghi",
},
{
name: "Unicode characters",
input: "tool_日本語",
expected: "tool____",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizeToolCallID(tt.input)
if result != tt.expected {
t.Errorf("sanitizeToolCallID(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSanitizeToolCallID_MatchesAnthropicPattern(t *testing.T) {
// Test that sanitized IDs match Anthropic's required pattern: ^[a-zA-Z0-9_-]+$
// This is a simplified check - in reality the pattern allows alphanumeric, underscore, hyphen
testIDs := []string{
"call.123.abc",
"tool:123:def",
"id@#$%^&*()",
"mixed.valid-id_test",
"",
}
for _, id := range testIDs {
sanitized := sanitizeToolCallID(id)
// Verify each character is valid
for i, r := range sanitized {
valid := (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '_' ||
r == '-'
if !valid {
t.Errorf("sanitizeToolCallID(%q) = %q, contains invalid character at position %d: %q",
id, sanitized, i, string(r))
}
}
// Verify non-empty
if sanitized == "" {
t.Errorf("sanitizeToolCallID(%q) returned empty string", id)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package models
import (
"crypto/sha256"
"encoding/hex"
"maps"
"os"
"charm.land/fantasy"
"charm.land/fantasy/providers/openai"
)
// buildCacheProviderOptions returns caching options for supported models.
// Caching is enabled by default for all supported models to reduce costs.
// Set KIT_DISABLE_CACHE=1 or ProviderConfig.DisableCaching=true to opt out.
func buildCacheProviderOptions(modelInfo *ModelInfo, config *ProviderConfig) fantasy.ProviderOptions {
// Check explicit opt-out via config
if config.DisableCaching {
return nil
}
// Check global opt-out via environment
if os.Getenv("KIT_DISABLE_CACHE") != "" {
return nil
}
// Check if model supports caching
if modelInfo == nil || !modelInfo.SupportsCaching() {
return nil
}
switch modelInfo.CacheType() {
case "anthropic-ephemeral":
// Provider-level Anthropic caching disabled - use message-level caching instead.
return nil
case "openai-prompt-cache":
return buildOpenAICacheOptions(config, modelInfo.ID)
case "google-cached-content":
// Google caching not yet implemented.
return nil
default:
return nil
}
}
// buildOpenAICacheOptions enables prompt caching for OpenAI models.
// Uses a deterministic cache key based on system prompt and model ID.
func buildOpenAICacheOptions(config *ProviderConfig, modelID string) fantasy.ProviderOptions {
cacheKey := generateCacheKey(config.SystemPrompt, modelID)
return fantasy.ProviderOptions{
openai.Name: &openai.ProviderOptions{
PromptCacheKey: &cacheKey,
},
}
}
// generateCacheKey creates a deterministic cache key from system prompt and model.
// This ensures the same system prompt + model combination gets cache hits.
func generateCacheKey(systemPrompt, modelID string) string {
if systemPrompt == "" {
systemPrompt = "default"
}
h := sha256.New()
h.Write([]byte(systemPrompt))
h.Write([]byte(modelID))
// Prefix with "kit-" to identify KIT-generated cache keys
return "kit-" + hex.EncodeToString(h.Sum(nil))[:24]
}
// mergeProviderOptions merges multiple ProviderOptions maps.
// Later maps take precedence over earlier ones.
func mergeProviderOptions(opts ...fantasy.ProviderOptions) fantasy.ProviderOptions {
result := make(fantasy.ProviderOptions)
for _, opt := range opts {
maps.Copy(result, opt)
}
if len(result) == 0 {
return nil
}
return result
}
+248
View File
@@ -0,0 +1,248 @@
package models
import (
"os"
"testing"
"charm.land/fantasy"
)
func TestModelInfo_SupportsCaching(t *testing.T) {
tests := []struct {
name string
family string
expected bool
}{
{"Claude model", "claude-3-5-sonnet", true},
{"Claude 4 model", "claude-4-opus", true},
{"GPT model", "gpt-4", true},
{"GPT-5 model", "gpt-5", true},
{"O1 model", "o1", true},
{"O3 model", "o3", true},
{"O4 model", "o4-mini", true},
{"Codex model", "codex", true},
{"Gemini model", "gemini-2.5-pro", true},
{"Gemini 1.5 model", "gemini-1.5-flash", true},
{"Llama model", "llama-3", false},
{"Unknown model", "unknown", false},
{"Empty family", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &ModelInfo{Family: tt.family}
if got := m.SupportsCaching(); got != tt.expected {
t.Errorf("ModelInfo.SupportsCaching() = %v, want %v", got, tt.expected)
}
})
}
}
func TestModelInfo_CacheType(t *testing.T) {
tests := []struct {
name string
family string
expected string
}{
{"Claude model", "claude-3-5-sonnet", "anthropic-ephemeral"},
{"GPT model", "gpt-4", "openai-prompt-cache"},
{"O1 model", "o1", "openai-prompt-cache"},
{"Gemini model", "gemini-2.5-pro", "google-cached-content"},
{"Unknown model", "llama-3", ""},
{"Empty family", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &ModelInfo{Family: tt.family}
if got := m.CacheType(); got != tt.expected {
t.Errorf("ModelInfo.CacheType() = %v, want %v", got, tt.expected)
}
})
}
}
func TestGenerateCacheKey(t *testing.T) {
key1 := generateCacheKey("system prompt", "model-id")
key2 := generateCacheKey("system prompt", "model-id")
if key1 != key2 {
t.Errorf("generateCacheKey should be deterministic: got %q and %q", key1, key2)
}
key3 := generateCacheKey("different prompt", "model-id")
if key1 == key3 {
t.Errorf("generateCacheKey should produce different keys for different inputs")
}
key4 := generateCacheKey("", "model-id")
key5 := generateCacheKey("default", "model-id")
if key4 != key5 {
t.Errorf("generateCacheKey should treat empty prompt as 'default'")
}
if len(key1) < 4 || key1[:4] != "kit-" {
t.Errorf("generateCacheKey should produce keys with 'kit-' prefix, got %q", key1)
}
}
func TestBuildCacheProviderOptions_Disabled(t *testing.T) {
config := &ProviderConfig{DisableCaching: true}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when DisableCaching=true")
}
}
func TestBuildCacheProviderOptions_EnvironmentVariable(t *testing.T) {
_ = os.Setenv("KIT_DISABLE_CACHE", "1")
defer func() { _ = os.Unsetenv("KIT_DISABLE_CACHE") }()
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when KIT_DISABLE_CACHE is set")
}
}
func TestBuildCacheProviderOptions_UnsupportedModel(t *testing.T) {
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "llama-3", ID: "llama-3-70b"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil for unsupported model families")
}
}
func TestBuildCacheProviderOptions_NilModelInfo(t *testing.T) {
config := &ProviderConfig{DisableCaching: false}
if opts := buildCacheProviderOptions(nil, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when modelInfo is nil")
}
}
func TestBuildCacheProviderOptions_Anthropic(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
opts := buildCacheProviderOptions(modelInfo, config)
// Provider-level Anthropic caching is disabled; message-level caching is used instead
if opts != nil {
t.Logf("Provider-level Anthropic caching disabled; using message-level caching")
}
}
func TestBuildCacheProviderOptions_OpenAI(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
config := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test system prompt",
}
modelInfo := &ModelInfo{Family: "gpt-4", ID: "gpt-4o"}
opts := buildCacheProviderOptions(modelInfo, config)
if opts == nil {
t.Fatalf("buildCacheProviderOptions should return options for OpenAI models")
}
if _, ok := opts["openai"]; !ok {
t.Errorf("buildCacheProviderOptions should include 'openai' key for GPT models")
}
}
func TestCachingPriorityOverThinking(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
// Anthropic uses message-level caching; provider-level returns nil
config1 := &ProviderConfig{
DisableCaching: false,
ThinkingLevel: ThinkingOff,
}
modelInfo1 := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
opts1 := buildCacheProviderOptions(modelInfo1, config1)
if opts1 != nil {
t.Logf("Provider-level Anthropic caching disabled; using message-level caching")
}
// OpenAI provider-level caching works with thinking enabled
config2 := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test prompt",
ThinkingLevel: ThinkingMedium,
}
modelInfo2 := &ModelInfo{Family: "gpt-4", ID: "gpt-4o"}
opts2 := buildCacheProviderOptions(modelInfo2, config2)
if opts2 == nil {
t.Errorf("OpenAI caching should work with thinking enabled")
}
// OpenAI caching also works with thinking disabled
config3 := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test prompt",
ThinkingLevel: ThinkingOff,
}
opts3 := buildCacheProviderOptions(modelInfo2, config3)
if opts3 == nil {
t.Errorf("OpenAI caching should work when thinking is OFF")
}
}
func TestMergeProviderOptions(t *testing.T) {
opts1 := fantasy.ProviderOptions{
"provider1": &testProviderData{value: "value1"},
}
opts2 := fantasy.ProviderOptions{
"provider2": &testProviderData{value: "value2"},
}
merged := mergeProviderOptions(opts1, opts2)
if len(merged) != 2 {
t.Errorf("mergeProviderOptions should combine options from multiple maps, got %d items", len(merged))
}
if _, ok := merged["provider1"]; !ok {
t.Errorf("merged options should contain 'provider1' key")
}
if _, ok := merged["provider2"]; !ok {
t.Errorf("merged options should contain 'provider2' key")
}
// Later options should override earlier ones
opts3 := fantasy.ProviderOptions{
"provider1": &testProviderData{value: "overridden"},
}
merged2 := mergeProviderOptions(opts1, opts3)
if data, ok := merged2["provider1"].(*testProviderData); ok {
if data.value != "overridden" {
t.Errorf("later options should override earlier ones, got %q", data.value)
}
}
if mergeProviderOptions() != nil {
t.Errorf("mergeProviderOptions with no args should return nil")
}
}
// testProviderData is a simple implementation of ProviderOptionsData for testing
type testProviderData struct {
value string
}
func (t *testProviderData) Options() {}
func (t *testProviderData) MarshalJSON() ([]byte, error) {
return []byte(`"` + t.value + `"`), nil
}
func (t *testProviderData) UnmarshalJSON(data []byte) error {
return nil
}
+17 -11
View File
@@ -17,15 +17,21 @@ type modelsDBProvider struct {
// modelsDBModel represents a model entry from models.dev/api.json.
type modelsDBModel struct {
ID string `json:"id"`
Name string `json:"name"`
Family string `json:"family,omitempty"`
Attachment bool `json:"attachment"`
Reasoning bool `json:"reasoning"`
ToolCall bool `json:"tool_call"`
Temperature bool `json:"temperature"`
Cost modelsDBCost `json:"cost"`
Limit modelsDBLimit `json:"limit"`
ID string `json:"id"`
Name string `json:"name"`
Family string `json:"family,omitempty"`
Attachment bool `json:"attachment"`
Reasoning bool `json:"reasoning"`
ToolCall bool `json:"tool_call"`
Temperature bool `json:"temperature"`
Cost modelsDBCost `json:"cost"`
Limit modelsDBLimit `json:"limit"`
Provider *modelsDBModelProvider `json:"provider,omitempty"` // Model-specific provider override
}
// modelsDBModelProvider represents a provider reference within a model.
type modelsDBModelProvider struct {
NPM string `json:"npm"`
}
// modelsDBCost represents model pricing from models.dev.
@@ -42,10 +48,10 @@ type modelsDBLimit struct {
Output int `json:"output"`
}
// npmToFantasyProvider maps npm package names from models.dev to fantasy
// npmToLLMProvider maps npm package names from models.dev to LLM
// provider identifiers. Providers not in this map but with an api URL
// can be auto-routed through openaicompat.
var npmToFantasyProvider = map[string]string{
var npmToLLMProvider = map[string]string{
"@ai-sdk/anthropic": "anthropic",
"@ai-sdk/openai": "openai",
"@ai-sdk/google": "google",
+382 -29
View File
@@ -10,6 +10,7 @@ import (
"maps"
"net/http"
"os"
"regexp"
"strings"
"time"
@@ -22,6 +23,7 @@ import (
"charm.land/fantasy/providers/openaicompat"
"charm.land/fantasy/providers/openrouter"
"charm.land/fantasy/providers/vercel"
openaisdk "github.com/charmbracelet/openai-go"
"github.com/mark3labs/kit/internal/auth"
"github.com/mark3labs/kit/internal/ui/progress"
@@ -155,6 +157,7 @@ type ProviderConfig struct {
MainGPU *int32
TLSSkipVerify bool
ThinkingLevel ThinkingLevel
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
}
// ProviderResult contains the result of provider creation.
@@ -169,6 +172,9 @@ type ProviderResult struct {
// ProviderOptions contains provider-specific options to be passed to the
// fantasy agent (e.g. OpenAI Responses API reasoning options).
ProviderOptions fantasy.ProviderOptions
// SkipMaxOutputTokens indicates that this provider doesn't support the
// max_output_tokens parameter (e.g., OpenAI Codex OAuth API).
SkipMaxOutputTokens bool
}
// ParseModelString parses a model string in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5").
@@ -234,49 +240,86 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
validateModelConfig(config, modelInfo)
}
// Create the base provider
var result *ProviderResult
var createErr error
switch provider {
case "anthropic":
return createAnthropicProvider(ctx, config, modelName)
result, createErr = createAnthropicProvider(ctx, config, modelName)
case "openai":
return createOpenAIProvider(ctx, config, modelName)
result, createErr = createOpenAIProvider(ctx, config, modelName)
case "google", "gemini":
return createGoogleProvider(ctx, config, modelName)
result, createErr = createGoogleProvider(ctx, config, modelName)
case "ollama":
return createOllamaProvider(ctx, config, modelName)
result, createErr = createOllamaProvider(ctx, config, modelName)
case "azure":
return createAzureProvider(ctx, config, modelName)
result, createErr = createAzureProvider(ctx, config, modelName)
case "google-vertex-anthropic":
return createVertexAnthropicProvider(ctx, config, modelName)
result, createErr = createVertexAnthropicProvider(ctx, config, modelName)
case "openrouter":
return createOpenRouterProvider(ctx, config, modelName)
result, createErr = createOpenRouterProvider(ctx, config, modelName)
case "bedrock":
return createBedrockProvider(ctx, config, modelName)
result, createErr = createBedrockProvider(ctx, config, modelName)
case "vercel":
return createVercelProvider(ctx, config, modelName)
result, createErr = createVercelProvider(ctx, config, modelName)
case "custom":
return createCustomProvider(ctx, config, modelName)
result, createErr = createCustomProvider(ctx, config, modelName)
default:
return autoRouteProvider(ctx, config, provider, modelName, registry)
result, createErr = autoRouteProvider(ctx, config, provider, modelName, registry)
}
if createErr != nil {
return nil, createErr
}
// AUTOMATICALLY ENABLE CACHING for supported models (unless disabled).
// This works for BOTH native and auto-routed providers by detecting
// the model family from the model metadata.
if cacheOpts := buildCacheProviderOptions(modelInfo, config); cacheOpts != nil {
if result.ProviderOptions == nil {
result.ProviderOptions = cacheOpts
} else {
// Merge cache options with existing provider options.
// Only add cache options for providers that don't already have
// options set, to avoid type conflicts (e.g., Anthropic has
// different types for regular options vs cache control options).
for k, v := range cacheOpts {
if _, exists := result.ProviderOptions[k]; !exists {
result.ProviderOptions[k] = v
}
}
}
}
return result, nil
}
// autoRouteProvider attempts to create a provider by looking up its npm package
// in the models.dev database and routing through the appropriate fantasy provider.
// For openai-compatible providers, it uses the api URL from models.dev.
// Models may have a provider override that specifies a different npm package than
// the provider's default (e.g., opencode's claude-opus-4-6 uses @ai-sdk/anthropic).
func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, modelName string, registry *ModelsRegistry) (*ProviderResult, error) {
providerInfo := registry.GetProviderInfo(provider)
if providerInfo == nil {
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", provider)
}
// Determine the fantasy provider for this npm package
fantasyProvider := npmToFantasyProvider[providerInfo.NPM]
if fantasyProvider == "" && providerInfo.API != "" {
// Unknown npm but has API URL → route through openaicompat
fantasyProvider = "openaicompat"
// Check for model-specific provider override
npmPackage := providerInfo.NPM
if modelInfo := registry.LookupModel(provider, modelName); modelInfo != nil && modelInfo.ProviderNPM != "" {
npmPackage = modelInfo.ProviderNPM
}
switch fantasyProvider {
// Determine the LLM provider for this npm package
llmProvider := npmToLLMProvider[npmPackage]
if llmProvider == "" && providerInfo.API != "" {
// Unknown npm but has API URL → route through openaicompat
llmProvider = "openaicompat"
}
switch llmProvider {
case "openaicompat":
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case "anthropic":
@@ -290,7 +333,7 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
}
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no fantasy mapping)", provider, providerInfo.NPM)
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage)
}
}
@@ -348,7 +391,10 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
opts = append(opts, anthropic.WithAPIKey(apiKey))
if config.ProviderURL != "" {
opts = append(opts, anthropic.WithBaseURL(config.ProviderURL))
// The anthropic client appends "/v1/messages" to the base URL.
// If the provider URL ends with "/v1", strip it to avoid double "/v1/v1" paths.
baseURL := strings.TrimSuffix(config.ProviderURL, "/v1")
opts = append(opts, anthropic.WithBaseURL(baseURL))
}
if config.TLSSkipVerify {
@@ -496,10 +542,15 @@ func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort
// SendReasoning to true and configures the thinking budget. For thinking-off
// or non-reasoning models the returned map is nil.
//
// NOTE: With message-level caching, thinking and caching can work together.
// Message-level cache control (ProviderCacheControlOptions) doesn't conflict
// with provider-level thinking options (ProviderOptions).
//
// Anthropic requires max_tokens > thinking.budget_tokens. If the configured
// MaxTokens is too low, it is bumped to budget + 4096 to leave room for the
// actual response.
func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
// Thinking is OFF by default. If user hasn't explicitly enabled it, return nil.
if config.ThinkingLevel == "" || config.ThinkingLevel == ThinkingOff {
return nil
}
@@ -610,13 +661,52 @@ func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig,
func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
apiKey := config.ProviderAPIKey
source := "command-line flag"
var accountID string
var isCodexOAuth bool
if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY")
}
if apiKey == "" {
return nil, fmt.Errorf("OpenAI API key not provided. Use --provider-api-key flag or OPENAI_API_KEY environment variable")
// Check stored credentials first
cm, err := auth.NewCredentialManager()
if err == nil {
if creds, err := cm.GetOpenAICredentials(); err == nil && creds != nil {
if creds.Type == "oauth" && creds.AccessToken != "" {
// For OAuth, get a valid access token (may refresh if needed)
token, err := cm.GetValidOpenAIAccessToken()
if err == nil && token != "" {
apiKey = token
accountID = creds.AccountID
isCodexOAuth = true
source = "stored Codex OAuth credentials"
}
} else if creds.Type == "api_key" && creds.APIKey != "" {
apiKey = creds.APIKey
source = "stored API key"
}
}
}
}
// Fall back to environment variable
if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY")
source = "OPENAI_API_KEY environment variable"
}
if apiKey == "" {
return nil, fmt.Errorf("OpenAI API key not provided. Use 'kit auth login openai', --provider-api-key flag, or OPENAI_API_KEY environment variable")
}
if os.Getenv("DEBUG") != "" || os.Getenv("KIT_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Using OpenAI API key from: %s\n", source)
}
// For Codex OAuth, use the ChatGPT backend API with custom headers
if isCodexOAuth {
return createOpenAICodexProvider(ctx, config, modelName, apiKey, accountID)
}
// Regular OpenAI API key flow
var opts []openai.Option
opts = append(opts, openai.WithAPIKey(apiKey))
opts = append(opts, openai.WithUseResponsesAPI())
@@ -645,6 +735,135 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
}
// createOpenAICodexProvider creates a provider for ChatGPT/Codex OAuth tokens.
// Uses the chatgpt.com/backend-api/codex endpoint with special headers.
func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, modelName, token, accountID string) (*ProviderResult, error) {
// Check for spark models which are not accessible via OAuth
if detectCodexModelFamily(modelName) == "gpt-codex-spark" {
return nil, fmt.Errorf("gpt-codex-spark models are not accessible via ChatGPT OAuth. " +
"These models require special access or a different authentication method. " +
"Please use regular Codex models like 'openai/gpt-5.3-codex' instead")
}
// Use the ChatGPT backend API with /codex path
baseURL := "https://chatgpt.com/backend-api/codex"
if config.ProviderURL != "" {
baseURL = config.ProviderURL
}
// Build custom HTTP client with required headers
httpClient := createCodexHTTPClient(token, accountID, config.TLSSkipVerify)
var opts []openai.Option
opts = append(opts, openai.WithAPIKey(token))
opts = append(opts, openai.WithBaseURL(baseURL))
opts = append(opts, openai.WithUseResponsesAPI())
opts = append(opts, openai.WithHTTPClient(httpClient))
provider, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI Codex provider: %w", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI Codex model: %w", err)
}
providerOpts := buildCodexProviderOptions(config, modelName)
return &ProviderResult{
Model: model,
ProviderOptions: providerOpts,
SkipMaxOutputTokens: true,
}, nil
}
// buildCodexProviderOptions returns fantasy.ProviderOptions configured for
// OpenAI Codex API. The Codex API requires the system prompt to be passed
// as 'instructions' rather than as a system message.
func buildCodexProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
store := false
opts := &openai.ResponsesProviderOptions{
Store: &store,
}
if config.SystemPrompt != "" {
opts.Instructions = &config.SystemPrompt
}
if openai.IsResponsesReasoningModel(modelName) {
opts.ReasoningEffort = thinkingLevelToReasoningEffort(config.ThinkingLevel)
}
return fantasy.ProviderOptions{openai.Name: opts}
}
// detectCodexModelFamily determines the model family from the model name
func detectCodexModelFamily(modelName string) string {
modelName = strings.ToLower(modelName)
if strings.Contains(modelName, "spark") {
return "gpt-codex-spark"
}
if strings.Contains(modelName, "codex-mini") || strings.Contains(modelName, "mini-latest") {
return "gpt-codex-mini"
}
if strings.Contains(modelName, "codex") {
return "gpt-codex"
}
return ""
}
// createCodexHTTPClient creates an HTTP client with headers required for ChatGPT/Codex API
func createCodexHTTPClient(token, accountID string, skipVerify bool) *http.Client {
var base http.RoundTripper
if skipVerify {
base = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
} else {
base = http.DefaultTransport
}
return &http.Client{
Transport: &codexTransport{
base: base,
token: token,
accountID: accountID,
},
Timeout: 120 * time.Second,
}
}
// codexTransport is a custom RoundTripper that adds ChatGPT/Codex specific headers
type codexTransport struct {
base http.RoundTripper
token string
accountID string
}
func (t *codexTransport) RoundTrip(req *http.Request) (*http.Response, error) {
newReq := req.Clone(req.Context())
// Add required headers for ChatGPT/Codex API
// These headers mimic the official pi client to avoid Cloudflare blocking
newReq.Header.Set("Authorization", "Bearer "+t.token)
if t.accountID != "" {
newReq.Header.Set("chatgpt-account-id", t.accountID)
}
newReq.Header.Set("originator", "kit")
newReq.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
newReq.Header.Set("OpenAI-Beta", "responses=experimental")
newReq.Header.Set("Accept", "text/event-stream")
newReq.Header.Set("Accept-Language", "en-US,en;q=0.9")
newReq.Header.Set("Cache-Control", "no-cache")
newReq.Header.Set("Pragma", "no-cache")
return t.base.RoundTrip(newReq)
}
func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
apiKey := firstNonEmpty(
config.ProviderAPIKey,
@@ -781,6 +1000,133 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
return &ProviderResult{Model: model}, nil
}
// thinkTagRegex matches <think>...</think> tags for extracting reasoning content
// from models that wrap thinking in XML-like tags (e.g., Qwen, DeepSeek).
var thinkTagRegex = regexp.MustCompile(`(?s)<think>(.*?)</think>`)
// customExtraContentFunc extracts reasoning from <think> tags in the content field.
// This handles models like Qwen and DeepSeek that return reasoning wrapped in XML tags
// rather than using a separate reasoning_content field.
func customExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
var content []fantasy.Content
if choice.Message.Content == "" {
return content
}
// Check for <think> tags in the content
matches := thinkTagRegex.FindStringSubmatch(choice.Message.Content)
if len(matches) > 1 {
// Found reasoning content in <think> tags
reasoning := strings.TrimSpace(matches[1])
if reasoning != "" {
content = append(content, fantasy.ReasoningContent{
Text: reasoning,
})
}
}
return content
}
// customStreamExtraFunc handles streaming responses with <think> tags.
// It extracts reasoning content and emits proper reasoning events.
func customStreamExtraFunc(
chunk openaisdk.ChatCompletionChunk,
yield func(fantasy.StreamPart) bool,
ctx map[string]any,
) (map[string]any, bool) {
if len(chunk.Choices) == 0 {
return ctx, true
}
const reasoningStartedKey = "reasoning_started"
const reasoningBufferKey = "reasoning_buffer"
const inThinkTagKey = "in_think_tag"
reasoningStarted, _ := ctx[reasoningStartedKey].(bool)
inThinkTag, _ := ctx[inThinkTagKey].(bool)
reasoningBuffer, _ := ctx[reasoningBufferKey].(string)
for i, choice := range chunk.Choices {
content := choice.Delta.Content
if content == "" {
continue
}
// Check for <think> tag start
if strings.Contains(content, "<think>") {
inThinkTag = true
ctx[inThinkTagKey] = true
// Emit reasoning start event
if !reasoningStarted {
reasoningStarted = true
ctx[reasoningStartedKey] = true
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningStart,
ID: fmt.Sprintf("%d", i),
}) {
return ctx, false
}
}
// Extract content after <think>
parts := strings.SplitN(content, "<think>", 2)
if len(parts) > 1 && parts[1] != "" {
reasoningBuffer += parts[1]
ctx[reasoningBufferKey] = reasoningBuffer
}
continue
}
// Check for </think> tag end
if strings.Contains(content, "</think>") {
inThinkTag = false
ctx[inThinkTagKey] = false
// Extract content before </think>
parts := strings.SplitN(content, "</think>", 2)
if len(parts) > 0 {
reasoningBuffer += parts[0]
}
// Emit the accumulated reasoning
if reasoningBuffer != "" {
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningDelta,
ID: fmt.Sprintf("%d", i),
Delta: reasoningBuffer,
}) {
return ctx, false
}
ctx[reasoningBufferKey] = ""
}
// Emit reasoning end
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningEnd,
ID: fmt.Sprintf("%d", i),
}) {
return ctx, false
}
continue
}
// Accumulate reasoning content while in think tag
if inThinkTag {
reasoningBuffer += content
ctx[reasoningBufferKey] = reasoningBuffer
}
}
return ctx, true
}
// customToPromptFunc converts prompts to OpenAI format using the default conversion.
func customToPromptFunc(prompt fantasy.Prompt, systemPrompt, user string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
return openai.DefaultToPrompt(prompt, systemPrompt, user)
}
func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
if config.ProviderURL == "" {
return nil, fmt.Errorf("custom provider requires --provider-url")
@@ -795,16 +1141,23 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
apiKey = "custom"
}
var opts []openaicompat.Option
opts = append(opts, openaicompat.WithBaseURL(config.ProviderURL))
opts = append(opts, openaicompat.WithAPIKey(apiKey))
opts = append(opts, openaicompat.WithName("custom"))
// Use the openai provider directly with custom hooks to handle <think> tags
// from models like Qwen and DeepSeek that wrap reasoning in XML tags.
var opts []openai.Option
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
opts = append(opts, openai.WithAPIKey(apiKey))
opts = append(opts, openai.WithName("custom"))
opts = append(opts, openai.WithLanguageModelOptions(
openai.WithLanguageModelExtraContentFunc(customExtraContentFunc),
openai.WithLanguageModelStreamExtraFunc(customStreamExtraFunc),
openai.WithLanguageModelToPromptFunc(customToPromptFunc),
))
if config.TLSSkipVerify {
opts = append(opts, openaicompat.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
opts = append(opts, openai.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
}
p, err := openaicompat.New(opts...)
p, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create custom provider: %w", err)
}
+67 -7
View File
@@ -17,11 +17,51 @@ var embeddedModelsJSON []byte
type ModelInfo struct {
ID string
Name string
Family string // Model family (e.g., "claude", "gpt", "gemini")
Attachment bool
Reasoning bool
Temperature bool
Cost Cost
Limit Limit
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
}
// SupportsCaching returns true if this model family supports prompt caching.
// This enables automatic cost savings for supported models regardless of provider.
func (m *ModelInfo) SupportsCaching() bool {
switch {
case strings.HasPrefix(m.Family, "claude"):
return true
case strings.HasPrefix(m.Family, "gpt"),
strings.HasPrefix(m.Family, "o1"),
strings.HasPrefix(m.Family, "o3"),
strings.HasPrefix(m.Family, "o4"),
strings.HasPrefix(m.Family, "codex"):
return true
case strings.HasPrefix(m.Family, "gemini"):
return true
default:
return false
}
}
// CacheType returns the appropriate cache mechanism for this model family.
// Returns empty string if caching is not supported.
func (m *ModelInfo) CacheType() string {
switch {
case strings.HasPrefix(m.Family, "claude"):
return "anthropic-ephemeral"
case strings.HasPrefix(m.Family, "gpt"),
strings.HasPrefix(m.Family, "o1"),
strings.HasPrefix(m.Family, "o3"),
strings.HasPrefix(m.Family, "o4"),
strings.HasPrefix(m.Family, "codex"):
return "openai-prompt-cache"
case strings.HasPrefix(m.Family, "gemini"):
return "google-cached-content"
default:
return ""
}
}
// Cost represents the pricing information for a model.
@@ -78,9 +118,14 @@ func buildFromModelsDB() map[string]ProviderInfo {
for providerID, dp := range dbProviders {
modelsMap := make(map[string]ModelInfo, len(dp.Models))
for modelID, dm := range dp.Models {
providerNPM := ""
if dm.Provider != nil {
providerNPM = dm.Provider.NPM
}
modelsMap[modelID] = ModelInfo{
ID: dm.ID,
Name: dm.Name,
Family: dm.Family,
Attachment: dm.Attachment,
Reasoning: dm.Reasoning,
Temperature: dm.Temperature,
@@ -94,6 +139,7 @@ func buildFromModelsDB() map[string]ProviderInfo {
Context: dm.Limit.Context,
Output: dm.Limit.Output,
},
ProviderNPM: providerNPM,
}
}
@@ -219,6 +265,15 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
}
}
// For openai, check stored credentials (OAuth / API key)
if provider == "openai" {
if cm, err := auth.NewCredentialManager(); err == nil {
if has, _ := cm.HasOpenAICredentials(); has {
return nil
}
}
}
envVars, err := r.getRequiredEnvVars(provider)
if err != nil {
// Unknown provider — nothing to validate
@@ -293,27 +348,32 @@ func (r *ModelsRegistry) GetSupportedProviders() []string {
return providers
}
// GetFantasyProviders returns provider IDs that can be used with fantasy,
// GetLLMProviders returns provider IDs that have LLM support,
// either through a native provider or via openaicompat auto-routing.
func (r *ModelsRegistry) GetFantasyProviders() []string {
func (r *ModelsRegistry) GetLLMProviders() []string {
var providers []string
for providerID, info := range r.providers {
if isProviderFantasySupported(providerID, &info) {
if isProviderLLMSupported(providerID, &info) {
providers = append(providers, providerID)
}
}
return providers
}
// isProviderFantasySupported checks if a provider can be used with fantasy.
func isProviderFantasySupported(providerID string, info *ProviderInfo) bool {
// Deprecated: Use GetLLMProviders instead.
func (r *ModelsRegistry) GetFantasyProviders() []string {
return r.GetLLMProviders()
}
// isProviderLLMSupported checks if a provider can be used with the LLM layer.
func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
// Ollama is always supported (via openaicompat pointed at localhost)
if providerID == "ollama" {
return true
}
// Check if npm maps to a fantasy provider
if _, ok := npmToFantasyProvider[info.NPM]; ok {
// Check if npm maps to an LLM provider
if _, ok := npmToLLMProvider[info.NPM]; ok {
return true
}
+6
View File
@@ -96,6 +96,7 @@ func ListAllSessions() ([]SessionInfo, error) {
}
// listSessionsInDir reads all .jsonl files in a directory and extracts session info.
// Empty sessions (no messages) are automatically cleaned up and not returned.
func listSessionsInDir(dir string) ([]SessionInfo, error) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return nil, nil
@@ -117,6 +118,11 @@ func listSessionsInDir(dir string) ([]SessionInfo, error) {
if err != nil {
continue // skip malformed session files
}
// Clean up and skip empty sessions (no messages)
if info.MessageCount == 0 {
_ = os.Remove(path)
continue
}
sessions = append(sessions, *info)
}
+35 -15
View File
@@ -181,7 +181,7 @@ func OpenTreeSession(path string) (*TreeManager, error) {
// Set leaf to the last entry.
if len(tm.entries) > 0 {
tm.leafID = tm.entryID(tm.entries[len(tm.entries)-1])
tm.leafID = tm.EntryID(tm.entries[len(tm.entries)-1])
}
// Open file for appending.
@@ -242,9 +242,14 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
return entry.ID, nil
}
// AppendFantasyMessage converts a fantasy.Message and appends it.
// AppendLLMMessage converts an LLM message and appends it.
func (tm *TreeManager) AppendLLMMessage(msg fantasy.Message) (string, error) {
return tm.AppendMessage(message.FromLLMMessage(msg))
}
// Deprecated: Use AppendLLMMessage instead.
func (tm *TreeManager) AppendFantasyMessage(msg fantasy.Message) (string, error) {
return tm.AppendMessage(message.FromFantasyMessage(msg))
return tm.AppendLLMMessage(msg)
}
// AppendModelChange records a model/provider change.
@@ -521,7 +526,7 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
for _, entry := range branch {
// Once we reach the first kept entry, stop skipping.
if skipping {
entryID := tm.entryID(entry)
entryID := tm.EntryID(entry)
if entryID == lastCompaction.FirstKeptEntryID {
skipping = false
} else {
@@ -535,7 +540,7 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
if err != nil {
continue // skip malformed entries
}
msgs := msg.ToFantasyMessages()
msgs := msg.ToLLMMessages()
messages = append(messages, msgs...)
case *BranchSummaryEntry:
@@ -628,6 +633,11 @@ func (tm *TreeManager) MessageCount() int {
return count
}
// IsEmpty returns true if the session has no messages (only header).
func (tm *TreeManager) IsEmpty() bool {
return tm.MessageCount() == 0
}
// Close closes the underlying file handle.
func (tm *TreeManager) Close() error {
tm.mu.Lock()
@@ -679,7 +689,7 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
skipping := lastCompaction != nil
for _, entry := range branch {
if skipping {
entryID := tm.entryID(entry)
entryID := tm.EntryID(entry)
if entryID == lastCompaction.FirstKeptEntryID {
skipping = false
} else {
@@ -693,7 +703,7 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
if err != nil {
continue
}
msgs := msg.ToFantasyMessages()
msgs := msg.ToLLMMessages()
for range msgs {
ids = append(ids, e.ID)
}
@@ -732,31 +742,41 @@ func (tm *TreeManager) GetLastCompaction() *CompactionEntry {
// --- Legacy bridge ---
// AddFantasyMessages appends multiple fantasy messages as entries. This is
// AddLLMMessages appends multiple LLM messages as entries. This is
// used when syncing from the agent's ConversationMessages after a step.
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
for _, msg := range msgs {
if _, err := tm.AppendFantasyMessage(msg); err != nil {
if _, err := tm.AppendLLMMessage(msg); err != nil {
return err
}
}
return nil
}
// GetFantasyMessages builds the context and returns just the messages.
// Deprecated: Use AddLLMMessages instead.
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
return tm.AddLLMMessages(msgs)
}
// GetLLMMessages builds the context and returns just the messages.
// This satisfies the same conceptual role as the old Manager.GetMessages().
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
msgs, _, _ := tm.BuildContext()
return msgs
}
// Deprecated: Use GetLLMMessages instead.
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
return tm.GetLLMMessages()
}
// --- Internal helpers ---
// addEntryToIndex adds an entry to the in-memory indices.
func (tm *TreeManager) addEntryToIndex(entry any) {
tm.entries = append(tm.entries, entry)
id := tm.entryID(entry)
id := tm.EntryID(entry)
parentID := tm.entryParentID(entry)
if id != "" {
@@ -793,8 +813,8 @@ func (tm *TreeManager) writeEntry(entry any) error {
return err
}
// entryID extracts the ID from any entry type.
func (tm *TreeManager) entryID(entry any) string {
// EntryID extracts the ID from any entry type.
func (tm *TreeManager) EntryID(entry any) string {
switch e := entry.(type) {
case *MessageEntry:
return e.ID
+1 -3
View File
@@ -127,9 +127,7 @@ func (p *MCPConnectionPool) GetConnection(ctx context.Context, serverName string
return conn, nil
} else {
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Connection %s unhealthy, removing", serverName))
}
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Connection %s unhealthy, removing", serverName))
}
_ = conn.client.Close()
delete(p.connections, serverName)
+2 -11
View File
@@ -3,6 +3,7 @@ package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@@ -70,7 +71,7 @@ func TestMCPToolManager_LoadTools_GracefulFailure(t *testing.T) {
}
// The error should mention that all servers failed
if err != nil && !contains(err.Error(), "all MCP servers failed") {
if err != nil && !strings.Contains(err.Error(), "all MCP servers failed") {
t.Errorf("Expected error message to mention all servers failed, got: %v", err)
}
@@ -459,13 +460,3 @@ func sliceEqual(a, b []any) bool {
}
return true
}
// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+267 -22
View File
@@ -149,11 +149,13 @@ func TestInputComponent_QuitReturnsTeaQuit(t *testing.T) {
}
// --------------------------------------------------------------------------
// TestInputComponent_ClearCallsClearMessages verifies that /clear (and its
// aliases) calls appCtrl.ClearMessages() and returns no submitMsg.
// TestInputComponent_ClearForwardsAsSubmitMsg verifies that /clear (and its
// aliases) are forwarded as submitMsg to the parent model so that the parent
// can call ClearMessages(), update scrollback, and print the confirmation
// message in one place. InputComponent must NOT call ClearMessages() directly.
// --------------------------------------------------------------------------
func TestInputComponent_ClearCallsClearMessages(t *testing.T) {
func TestInputComponent_ClearForwardsAsSubmitMsg(t *testing.T) {
aliases := []string{"/clear", "/c", "/cls"}
for _, alias := range aliases {
t.Run(alias, func(t *testing.T) {
@@ -164,22 +166,29 @@ func TestInputComponent_ClearCallsClearMessages(t *testing.T) {
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
if ctrl.clearMsgCalled != 1 {
t.Fatalf("%s: expected ClearMessages() called once, got %d", alias, ctrl.clearMsgCalled)
// InputComponent must NOT call ClearMessages() directly.
if ctrl.clearMsgCalled != 0 {
t.Fatalf("%s: InputComponent must not call ClearMessages(), got %d", alias, ctrl.clearMsgCalled)
}
// No cmd should be returned (no submitMsg forwarded to parent).
if cmd != nil {
msg := runCmd(cmd)
if _, ok := msg.(submitMsg); ok {
t.Fatalf("%s: /clear should not emit submitMsg, got submitMsg", alias)
}
// A submitMsg must be emitted so the parent model handles /clear.
if cmd == nil {
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
}
msg := runCmd(cmd)
sm, ok := msg.(submitMsg)
if !ok {
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
}
if sm.Text != alias {
t.Fatalf("%s: expected submitMsg text %q, got %q", alias, alias, sm.Text)
}
})
}
}
// TestInputComponent_ClearNilCtrl_NoPanic verifies that /clear with a nil
// appCtrl does not panic.
// appCtrl does not panic. Since /clear is now forwarded to the parent via
// submitMsg, no appCtrl interaction happens in InputComponent at all.
func TestInputComponent_ClearNilCtrl_NoPanic(t *testing.T) {
c := newTestInput(nil)
c.textarea.SetValue("/clear")
@@ -349,7 +358,7 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
@@ -376,7 +385,7 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
}
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
got := c.streamContent.String()
want := "Hello, world!"
@@ -396,6 +405,7 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
c := newTestStream()
_, cmd := c.Update(app.ToolExecutionEvent{
ToolCallID: "call-exec-1",
ToolName: "exec_tool",
IsStarting: true,
})
@@ -403,8 +413,9 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
tools := c.activeToolDisplays()
if len(tools) != 1 || !strings.Contains(tools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", tools)
}
if cmd == nil {
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
@@ -418,11 +429,13 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
// Simulate a tool starting
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: true,
})
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: false,
})
@@ -440,9 +453,9 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
c := newTestStream()
// Start three tools in parallel
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: true})
if len(c.activeTools) != 3 {
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
@@ -455,19 +468,44 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
}
// Finish one tool
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: false})
if len(c.activeTools) != 2 {
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
}
// Finish remaining tools
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: false})
if len(c.activeTools) != 0 {
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
}
}
// TestStreamComponent_ParallelSameToolName_UsesToolCallID verifies finishing one
// tool call does not remove another concurrent call with the same tool name.
func TestStreamComponent_ParallelSameToolName_UsesToolCallID(t *testing.T) {
c := newTestStream()
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: true})
tools := c.activeToolDisplays()
if len(tools) != 2 {
t.Fatalf("expected 2 active read calls, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: false})
tools = c.activeToolDisplays()
if len(tools) != 1 {
t.Fatalf("expected 1 active read call after finishing one ID, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: false})
if len(c.activeToolDisplays()) != 0 {
t.Fatalf("expected no active tools after finishing both IDs, got %v", c.activeToolDisplays())
}
}
// --------------------------------------------------------------------------
// TestStreamComponent_GetRenderedContent verifies the method returns rendered
// text when content is accumulated, and empty string when not.
@@ -621,3 +659,210 @@ func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
t.Fatal("current-gen tick should reschedule")
}
}
// TestStreamComponent_StaleFlushTick_Discarded verifies that flush ticks from a
// previous generation (e.g. pre-Reset) are ignored.
func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
c := newTestStream()
// Start a pending flush and capture its generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "old"})
staleGen := c.flushGeneration
if !c.flushPending {
t.Fatal("precondition: expected flushPending=true after first chunk")
}
// Reset should invalidate in-flight flush ticks.
c.Reset()
if c.flushGeneration == staleGen {
t.Fatal("expected flushGeneration to change after Reset")
}
// New content in a new generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "new"})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("expected pendingStream='new', got %q", got)
}
// Stale flush tick should be ignored.
c = sendStreamMsg(c, streamFlushTickMsg{generation: staleGen})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("stale flush tick should not commit pending stream, got %q", got)
}
// Current generation flush should commit.
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
if got := c.pendingStream.String(); got != "" {
t.Fatalf("expected pendingStream empty after current flush, got %q", got)
}
if got := c.streamContent.String(); got != "new" {
t.Fatalf("expected streamContent='new' after current flush, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
// unconstrained (0), ConsumeOverflow always returns "".
func TestStreamComponent_ConsumeOverflow_NoHeight(t *testing.T) {
c := newTestStream()
// Commit some content directly.
c.streamContent.WriteString("line1\nline2\nline3")
c.phase = streamPhaseActive
c.renderDirty = true
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty with height=0, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoOverflow verifies that when content fits
// within the allocated height, ConsumeOverflow returns "".
func TestStreamComponent_ConsumeOverflow_NoOverflow(t *testing.T) {
c := newTestStream()
c.streamContent.WriteString("line1\nline2")
c.phase = streamPhaseActive
c.renderDirty = true
c.height = 20 // plenty of room
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty when content fits, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_EmitsTopLines verifies that when the
// rendered content has more lines than the allocated height, ConsumeOverflow
// returns the top overflow lines and advances the internal pointer.
func TestStreamComponent_ConsumeOverflow_EmitsTopLines(t *testing.T) {
c := newTestStream()
c.height = 2
// Build raw content that when "rendered" (plain text for this test)
// is 5 lines — we bypass the markdown renderer by writing directly to
// streamContent and using a nil renderer.
c.renderer = nil
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.phase = streamPhaseActive
c.renderDirty = true
// First call: should return lines a, b, c (5 lines - 2 visible = 3 overflow).
overflow1 := c.ConsumeOverflow()
if overflow1 == "" {
t.Fatal("expected overflow, got empty")
}
overflowLines := strings.Split(overflow1, "\n")
if len(overflowLines) != 3 {
t.Fatalf("expected 3 overflow lines, got %d: %q", len(overflowLines), overflow1)
}
if overflowLines[0] != "a" || overflowLines[1] != "b" || overflowLines[2] != "c" {
t.Fatalf("unexpected overflow lines: %v", overflowLines)
}
// Second call without new content should return "" (pointer already advanced).
overflow2 := c.ConsumeOverflow()
if overflow2 != "" {
t.Fatalf("expected empty on second call, got %q", overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_IncrementalFlush verifies that as new
// content arrives, ConsumeOverflow incrementally returns only newly overflowed
// lines on each call.
func TestStreamComponent_ConsumeOverflow_IncrementalFlush(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// Start with 3 lines — 1 overflows.
c.streamContent.WriteString("a\nb\nc")
c.renderDirty = true
overflow1 := c.ConsumeOverflow()
if overflow1 != "a" {
t.Fatalf("expected 'a', got %q", overflow1)
}
// Add 2 more lines — 2 additional overflows.
c.streamContent.WriteString("\nd\ne")
c.renderDirty = true
overflow2 := c.ConsumeOverflow()
want := "b\nc"
if overflow2 != want {
t.Fatalf("expected %q, got %q", want, overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_ResetClearsPointer verifies that Reset()
// resets the scrollback pointer so the next response starts fresh.
func TestStreamComponent_ConsumeOverflow_ResetClearsPointer(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
c.streamContent.WriteString("a\nb")
c.renderDirty = true
overflow := c.ConsumeOverflow()
if overflow != "a" {
t.Fatalf("expected 'a', got %q", overflow)
}
c.Reset()
if c.scrollbackFlushedLines != 0 {
t.Fatalf("expected scrollbackFlushedLines=0 after Reset, got %d", c.scrollbackFlushedLines)
}
}
// TestStreamComponent_GetRenderedContent_SkipsFlushedLines verifies that
// GetRenderedContent skips lines already emitted via ConsumeOverflow so the
// caller doesn't re-print content already in the terminal scrollback.
func TestStreamComponent_GetRenderedContent_SkipsFlushedLines(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// 5 lines → 3 overflow, 2 visible.
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.renderDirty = true
// Consume the overflow: lines a, b, c.
overflow := c.ConsumeOverflow()
if overflow != "a\nb\nc" {
t.Fatalf("expected 'a\\nb\\nc', got %q", overflow)
}
if c.scrollbackFlushedLines != 3 {
t.Fatalf("expected flushedLines=3, got %d", c.scrollbackFlushedLines)
}
// GetRenderedContent should only return the non-flushed portion: d, e.
got := c.GetRenderedContent()
if got != "d\ne" {
t.Fatalf("expected 'd\\ne', got %q", got)
}
}
// TestStreamComponent_GetRenderedContent_AllFlushed verifies that when all
// lines have been pushed via ConsumeOverflow, GetRenderedContent returns "".
func TestStreamComponent_GetRenderedContent_AllFlushed(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
// 2 lines → height=1, so 1 overflow.
c.streamContent.WriteString("a\nb")
c.renderDirty = true
// Consume overflow (line a), leaving 1 visible line (b).
_ = c.ConsumeOverflow()
// Now bump height so everything overflows — simulate a resize that made
// the viewable area 0, forcing all content to be "flushed".
c.scrollbackFlushedLines = 2 // pretend both lines were flushed
got := c.GetRenderedContent()
if got != "" {
t.Fatalf("expected empty when all lines flushed, got %q", got)
}
}
+8 -9
View File
@@ -179,9 +179,8 @@ func (c *CLI) DisplayDebugConfig(config map[string]any) {
}
// UpdateUsageFromResponse records token usage using metadata from the fantasy
// response when available. Falls back to text-based estimation if the metadata is
// missing or appears unreliable. This provides more accurate usage tracking when
// providers supply token count information.
// response. Only actual API-reported tokens are used for cost tracking.
// If the provider doesn't report token counts, no usage is recorded.
func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText string) {
if c.usageTracker == nil {
return
@@ -191,19 +190,19 @@ func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText stri
inputTokens := int(usage.InputTokens)
outputTokens := int(usage.OutputTokens)
// Validate that the metadata seems reasonable
if inputTokens > 0 && outputTokens > 0 {
// Only use actual API-reported tokens for cost tracking.
// We intentionally do NOT estimate tokens - estimation is inaccurate
// and should never be used for cost calculations.
if inputTokens > 0 {
cacheReadTokens := int(usage.CacheReadTokens)
cacheWriteTokens := int(usage.CacheCreationTokens)
c.usageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
// Per-response usage is a single API call, so it represents the
// actual context window fill level.
c.usageTracker.SetContextTokens(inputTokens + outputTokens)
} else {
// Fallback to estimation if no metadata is available.
// EstimateAndUpdateUsage sets context tokens internally.
c.usageTracker.EstimateAndUpdateUsage(inputText, response.Content.Text())
}
// If inputTokens is 0, the provider didn't report usage - we skip recording
// rather than estimating, to ensure cost accuracy.
}
// DisplayUsageAfterResponse renders and displays token usage information immediately
+36 -50
View File
@@ -43,7 +43,7 @@ func (r *CompactRenderer) SetWidth(width int) {
// while minimizing vertical space usage. Returns a UIMessage with formatted content
// and metadata.
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
@@ -96,7 +96,7 @@ func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Primary).Render("<")
// Use the full model name, fallback to "Assistant" if empty
@@ -127,26 +127,31 @@ func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
// RenderToolCallMessage renders a tool call notification in compact format, showing
// the tool being executed with its arguments in a single line. The tool name is
// highlighted and arguments are displayed in a muted color for visual distinction.
func (r *CompactRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("[")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(toolName)
// Format args for compact display
argsDisplay := r.formatToolArgs(toolArgs)
if argsDisplay != "" {
argsDisplay = lipgloss.NewStyle().Foreground(theme.Muted).Render(argsDisplay)
// RenderReasoningBlock renders a reasoning/thinking block in compact format
// with the same muted italic styling as live streaming. This is used when
// resuming sessions to display saved reasoning content.
func (r *CompactRenderer) RenderReasoningBlock(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
return UIMessage{
Type: AssistantMessage,
Content: "",
Height: 0,
Timestamp: timestamp,
}
}
line := fmt.Sprintf("%s %s %s", symbol, label, argsDisplay)
theme := GetTheme()
// Match live streaming styling: muted italic text
// Compact mode uses direct lipgloss styling (no typography)
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted).Italic(true)
rendered := mutedStyle.Render(contentStr)
return UIMessage{
Type: ToolCallMessage,
Content: line,
Height: 1,
Type: AssistantMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
@@ -155,7 +160,7 @@ func (r *CompactRenderer) RenderToolCallMessage(toolName, toolArgs string, times
// the tool invocation header (icon + display name + params) with the execution
// result body. Status is indicated by icon: checkmark for success, cross for error.
func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
theme := getTheme()
theme := GetTheme()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
@@ -244,7 +249,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
// compact format with a distinctive symbol (*) and "System" label. Content is
// formatted to fit on a single line for minimal space usage.
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("◇")
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
@@ -264,7 +269,7 @@ func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Tim
// distinctive error symbol (!) and styling to ensure visibility. The error
// content is displayed in a single line with appropriate color highlighting.
func (r *CompactRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Error).Render("!")
label := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).Render("Error")
@@ -284,7 +289,7 @@ func (r *CompactRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim
// mode is enabled. Messages are truncated if they exceed the available width to
// maintain single-line display.
func (r *CompactRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
@@ -308,7 +313,7 @@ func (r *CompactRenderer) RenderDebugMessage(message string, timestamp time.Time
// debugging purposes. Config entries are displayed as key=value pairs separated
// by commas, truncated if necessary to fit on a single line.
func (r *CompactRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
@@ -426,32 +431,6 @@ func (r *CompactRenderer) wrapText(text string, width int) string {
return strings.Join(wrappedLines, "\n")
}
// formatToolArgs formats tool arguments for compact display
func (r *CompactRenderer) formatToolArgs(args string) string {
if args == "" || args == "{}" {
return ""
}
// Remove JSON braces and format compactly
args = strings.TrimSpace(args)
if strings.HasPrefix(args, "{") && strings.HasSuffix(args, "}") {
args = strings.TrimPrefix(args, "{")
args = strings.TrimSuffix(args, "}")
args = strings.TrimSpace(args)
}
// Remove quotes around simple values
args = strings.ReplaceAll(args, `"`, "")
// Remove parameter names (e.g., "command: ls" -> "ls", "path: /home" -> "/home")
// Look for pattern "key: value" and extract just the value
if colonIndex := strings.Index(args, ":"); colonIndex != -1 {
args = strings.TrimSpace(args[colonIndex+1:])
}
return r.formatCompactContent(args)
}
// formatToolResult formats tool results preserving formatting but limiting to 5 lines
func (r *CompactRenderer) formatToolResult(result string) string {
if result == "" {
@@ -490,5 +469,12 @@ func (r *CompactRenderer) formatToolResult(result string) string {
// and styling appropriately. Delegates tag parsing to the shared parseBashOutput
// helper.
func (r *CompactRenderer) formatBashOutput(result string) string {
return parseBashOutput(result, getTheme())
return parseBashOutput(result, GetTheme())
}
// UpdateTheme is a no-op for CompactRenderer since it fetches theme colors
// directly from GetTheme() in each rendering method. This stub satisfies
// the Renderer interface.
func (r *CompactRenderer) UpdateTheme() {
// No-op: theme colors are fetched fresh on each render
}
+3 -42
View File
@@ -35,8 +35,11 @@ func GetTheme() Theme {
// SetTheme updates the global UI theme, affecting all subsequent rendering
// operations. This allows runtime theme switching for different visual preferences.
// It also invalidates the markdownTypographyCache so the next call to
// GetMarkdownTypography picks up the new theme.
func SetTheme(theme Theme) {
currentTheme = theme
markdownTypographyCache = nil // invalidate cached renderer; colors may have changed
}
// MarkdownThemeColors defines colors for markdown rendering and syntax highlighting.
@@ -291,45 +294,3 @@ func ApplyGradient(text string, colorA, colorB color.Color) string {
return result.String()
}
// CreateGradientText creates styled text with a gradient effect between two colors.
func CreateGradientText(text string, startColor, endColor color.Color) string {
return ApplyGradient(text, startColor, endColor)
}
// Compact styling utilities
// StyleCompactSymbol creates a lipgloss style for message type indicators in
// compact mode, using bold colored text to distinguish different message categories.
func StyleCompactSymbol(symbol string, c color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(c).
Bold(true)
}
// StyleCompactLabel creates a lipgloss style for message labels in compact mode
// with fixed width for alignment and bold colored text for readability.
func StyleCompactLabel(c color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(c).
Bold(true).
Width(8)
}
// StyleCompactContent creates a simple lipgloss style for message content in
// compact mode, applying only color without additional formatting.
func StyleCompactContent(c color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(c)
}
// FormatCompactLine assembles a complete compact mode message line with consistent
// spacing and styling. Combines a symbol, fixed-width label, and content with their
// respective colors to create a uniform appearance across all message types.
func FormatCompactLine(symbol, label, content string, symbolColor, labelColor, contentColor color.Color) string {
styledSymbol := StyleCompactSymbol(symbol, symbolColor).Render(symbol)
styledLabel := StyleCompactLabel(labelColor).Render(label)
styledContent := StyleCompactContent(contentColor).Render(content)
return fmt.Sprintf("%s %-8s %s", styledSymbol, styledLabel, styledContent)
}
+2 -33
View File
@@ -6,7 +6,6 @@ import (
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// FileSuggestion represents a single file or directory suggestion for the @
@@ -345,44 +344,14 @@ func scoreFilePath(query, path string) int {
}
// Fuzzy character match on basename.
if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
if score := fuzzyCharacterMatch(query, baseNameLower); score > 0 {
return score
}
// Fuzzy character match on full path.
if score := fuzzyCharMatch(query, pathLower); score > 0 {
if score := fuzzyCharacterMatch(query, pathLower); score > 0 {
return score - 50
}
return 0
}
// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a
// positive score if all query characters appear in order in the target.
func fuzzyCharMatch(query, target string) int {
if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) {
return 0
}
qRunes := []rune(query)
tRunes := []rune(target)
qi := 0
score := 100
consecutive := 0
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
if tRunes[ti] == qRunes[qi] {
qi++
consecutive++
score += consecutive * 5
} else {
consecutive = 0
score -= 2
}
}
if qi < len(qRunes) {
return 0
}
return score
}
+2
View File
@@ -13,12 +13,14 @@ import (
type Renderer interface {
RenderUserMessage(content string, timestamp time.Time) UIMessage
RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage
RenderReasoningBlock(content string, timestamp time.Time) UIMessage
RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage
RenderSystemMessage(content string, timestamp time.Time) UIMessage
RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage
RenderDebugMessage(message string, timestamp time.Time) UIMessage
RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage
SetWidth(width int)
UpdateTheme()
}
// Compile-time checks that both renderers satisfy the Renderer interface.
+11 -7
View File
@@ -113,19 +113,23 @@ func fuzzyScore(query string, cmd *SlashCommand) int {
return 0
}
// fuzzyCharacterMatch performs character-by-character fuzzy matching
// fuzzyCharacterMatch performs character-by-character fuzzy matching using
// rune-safe iteration so multi-byte Unicode characters are handled correctly.
// Returns a positive score if all query runes appear in order within target.
func fuzzyCharacterMatch(query, target string) int {
if len(query) > len(target) {
qRunes := []rune(query)
tRunes := []rune(target)
if len(qRunes) > len(tRunes) {
return 0
}
queryIdx := 0
qi := 0
score := 100
consecutiveMatches := 0
for i := 0; i < len(target) && queryIdx < len(query); i++ {
if target[i] == query[queryIdx] {
queryIdx++
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
if tRunes[ti] == qRunes[qi] {
qi++
consecutiveMatches++
score += consecutiveMatches * 10
} else {
@@ -135,7 +139,7 @@ func fuzzyCharacterMatch(query, target string) int {
}
// Must match all characters in query
if queryIdx < len(query) {
if qi < len(qRunes) {
return 0
}
+18 -12
View File
@@ -65,6 +65,10 @@ type InputComponent struct {
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
hideHint bool
// agentBusy indicates the agent is currently working. When true, the
// hint text shows steering shortcut (Ctrl+S) instead of submit.
agentBusy bool
// pendingImages holds clipboard images attached to the next submission.
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
pendingImages []ImageAttachment
@@ -405,21 +409,14 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
}
// Resolve via canonical command lookup so aliases are handled uniformly.
// Only /quit and /clear are handled locally — /clear-queue must go
// through the parent model so it can update queueCount directly
// (calling ClearQueue here would skip the UI state update since we
// can't send events from within Update without deadlocking).
// Only /quit is handled locally — all other slash commands (including
// /clear and /clear-queue) are forwarded to the parent model via
// submitMsg so the parent can update its own state (scrollback, queue
// counts, etc.) in one place.
if sc := GetCommandByName(trimmed); sc != nil {
switch sc.Name {
case "/quit":
return tea.Quit
case "/clear":
if s.appCtrl != nil {
s.appCtrl.ClearMessages()
}
// Don't forward to app.Run(); just clear silently.
return nil
}
}
@@ -514,7 +511,16 @@ func (s *InputComponent) View() tea.View {
// Adapt hint text to available width (accounting for left padding of 3).
var hint string
availableHintWidth := s.width - 3
if availableHintWidth >= 67 {
if s.agentBusy {
// When the agent is working, show steering shortcut.
if availableHintWidth >= 55 {
hint = "enter queue • ctrl+s steer • esc esc cancel"
} else if availableHintWidth >= 35 {
hint = "↵ queue • ^S steer • esc×2 cancel"
} else {
hint = "^S steer"
}
} else if availableHintWidth >= 67 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
} else if availableHintWidth >= 40 {
hint = "↵ submit • ctrl+j newline • ctrl+v image"
+151 -356
View File
@@ -9,6 +9,7 @@ import (
"time"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
)
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
@@ -22,9 +23,9 @@ const (
UserMessage MessageType = iota
AssistantMessage
ToolMessage
ToolCallMessage // New type for showing tool calls in progress
SystemMessage // New type for KIT system messages (help, tools, etc.)
ErrorMessage // New type for error messages
ToolCallMessage
SystemMessage
ErrorMessage
)
// UIMessage encapsulates a fully rendered message ready for display in the UI,
@@ -40,29 +41,9 @@ type UIMessage struct {
Streaming bool
}
// Helper functions to get theme colors
func getTheme() Theme {
return GetTheme()
}
// toolDisplayNames maps raw tool names to human-friendly display names.
var toolDisplayNames = map[string]string{
"bash": "Bash",
"read": "Read",
"write": "Write",
"edit": "Edit",
"grep": "Grep",
"find": "Find",
"ls": "Ls",
"run_shell_cmd": "Bash",
}
// toolDisplayName returns a human-friendly display name for a tool.
// Falls back to capitalizing the first letter of the raw name.
// toolDisplayName returns a human-friendly display name for a tool,
// title-casing the first letter of the raw name.
func toolDisplayName(rawName string) string {
if display, ok := toolDisplayNames[rawName]; ok {
return display
}
if rawName != "" {
return strings.ToUpper(rawName[:1]) + rawName[1:]
}
@@ -70,8 +51,6 @@ func toolDisplayName(rawName string) string {
}
// formatToolParams formats tool input parameters for inline header display.
// Extracts the primary parameter (command/filePath) first, then shows
// remaining params as (key=val, ...). Truncates to maxWidth.
func formatToolParams(toolArgs string, maxWidth int) string {
args := strings.TrimSpace(toolArgs)
if args == "" || args == "{}" {
@@ -80,7 +59,6 @@ func formatToolParams(toolArgs string, maxWidth int) string {
var params map[string]any
if err := json.Unmarshal([]byte(args), &params); err != nil {
// Fallback: strip braces and return raw content
args = strings.TrimPrefix(args, "{")
args = strings.TrimSuffix(args, "}")
args = strings.TrimSpace(args)
@@ -94,7 +72,6 @@ func formatToolParams(toolArgs string, maxWidth int) string {
return ""
}
// Identify primary parameter by checking known keys in priority order
primaryKeys := []string{"command", "filePath", "path", "pattern", "query", "url"}
var primaryKey string
var primaryVal string
@@ -111,14 +88,13 @@ func formatToolParams(toolArgs string, maxWidth int) string {
result.WriteString(primaryVal)
}
// Collect remaining parameters, skipping body-content keys (already
// rendered in the tool body) and any values that are too large.
bodyKeys := map[string]bool{
"content": true,
"old_text": true,
"new_text": true,
"oldText": true,
"newText": true,
"edits": true,
"todos": true,
}
var remaining []string
@@ -154,65 +130,35 @@ func formatToolParams(toolArgs string, maxWidth int) string {
}
// MessageRenderer handles the formatting and rendering of different message types
// with consistent styling, markdown support, and appropriate visual hierarchies
// for the standard (non-compact) display mode.
type MessageRenderer struct {
width int
debug bool
// getToolRenderer returns extension-provided rendering overrides for a
// specific tool. May be nil if no extensions are loaded. Used in
// RenderToolMessage to check for custom header/body formatting before
// falling back to builtin renderers.
width int
debug bool
ty *herald.Typography
getToolRenderer func(toolName string) *ToolRendererData
}
// newMessageRenderer creates and initializes a new MessageRenderer with the specified
// terminal width and debug mode setting. The width parameter determines line wrapping
// and layout calculations.
// newMessageRenderer creates and initializes a new MessageRenderer
func newMessageRenderer(width int, debug bool) *MessageRenderer {
return &MessageRenderer{
width: width,
debug: debug,
ty: createTypography(GetTheme()),
}
}
// SetWidth updates the terminal width for the renderer, affecting how content
// is wrapped and formatted in subsequent render operations.
// SetWidth updates the terminal width for the renderer
func (r *MessageRenderer) SetWidth(width int) {
r.width = width
}
// RenderUserMessage renders a user's input message with distinctive right-aligned
// formatting, including the system username, timestamp, and markdown-rendered content.
// The message is displayed with a colored right border for visual distinction.
// RenderUserMessage renders a user's input message using herald Tip alert
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var messageContent string
if strings.Contains(content, "`") {
// Glamour treats single \n as a soft break, so convert to paragraph
// breaks and collapse the resulting blank lines after rendering.
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
messageContent = r.renderMarkdown(mdContent, r.width-8)
messageContent = removeBlankLines(messageContent)
} else {
messageContent = content
if strings.TrimSpace(content) == "" {
content = "(empty message)"
}
fullContent := strings.TrimSuffix(messageContent, "\n")
// Left border with Blue color for user messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Info),
WithMarginBottom(1),
)
rendered := r.ty.Tip(content)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: UserMessage,
@@ -222,12 +168,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
}
}
// RenderAssistantMessage renders an AI assistant's response with left-aligned formatting,
// including the model name, timestamp, and markdown-rendered content. Empty responses
// are ignored and return an empty message. The message features a colored left border
// for visual distinction.
// RenderAssistantMessage renders an AI assistant's response
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
// Ignore empty responses - don't render anything
if strings.TrimSpace(content) == "" {
return UIMessage{
Type: AssistantMessage,
@@ -237,17 +179,9 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
theme := getTheme()
messageContent := r.renderMarkdown(content, r.width-8)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Left border with Primary (Mauve) color for assistant messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithBorderColor(theme.Primary),
WithMarginBottom(1),
)
// Use markdown rendering with Chroma syntax highlighting
rendered := toMarkdown(content, r.width-4)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: AssistantMessage,
@@ -257,30 +191,44 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
// RenderSystemMessage renders KIT system messages such as help text, command outputs,
// and informational notifications. These messages are displayed with a distinctive system
// color border and "KIT System" label to differentiate them from user and AI content.
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
var messageContent string
// RenderReasoningBlock renders a reasoning/thinking block with the same styling
// as live streaming: muted italic text with margin. This is used when resuming
// sessions to display saved reasoning content.
func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
messageContent = "No content available"
} else if strings.Contains(content, "`") {
messageContent = r.renderMarkdown(content, r.width-8)
} else {
messageContent = content
return UIMessage{
Type: AssistantMessage,
Content: "",
Height: 0,
Timestamp: timestamp,
}
}
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
theme := GetTheme()
// Match live streaming styling: muted italic text
// Same as stream.go renderReasoningBlock()
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
rendered := mutedStyle.Render(r.ty.Italic(contentStr))
rendered = styleMarginBottom1.Render(rendered)
rendered := renderContentBlock(
fullContent,
r.width,
WithNoBorder(),
WithForeground(theme.Muted),
WithMarginBottom(1),
)
return UIMessage{
Type: AssistantMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderSystemMessage renders KIT system messages using herald Note alert
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
content = "No content available"
}
rendered := r.ty.Note(content)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: SystemMessage,
@@ -290,27 +238,9 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
}
}
// RenderDebugMessage renders diagnostic and debugging information with special formatting
// including a debug icon, colored border, and structured layout. Debug messages are only
// displayed when debug mode is enabled and help developers troubleshoot issues.
// RenderDebugMessage renders diagnostic and debugging information
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
theme := getTheme()
style := baseStyle.
Width(r.width - 3).
BorderLeft(true).
Foreground(theme.Muted).
BorderForeground(theme.Tool).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1).
MarginLeft(2).
MarginBottom(1)
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔍 Debug Output")
header := r.ty.H6("🔍 Debug Output")
lines := strings.Split(message, "\n")
var formattedLines []string
@@ -320,87 +250,52 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
}
}
content := baseStyle.
Foreground(theme.Muted).
Render(strings.Join(formattedLines, "\n"))
fullContent := lipgloss.JoinVertical(lipgloss.Left,
content := r.ty.Compose(
header,
content,
r.ty.P(strings.Join(formattedLines, "\n")),
)
content = styleMarginBottom1.Render(content)
return UIMessage{
Content: style.Render(fullContent),
Height: lipgloss.Height(style.Render(fullContent)),
Content: content,
Height: lipgloss.Height(content),
}
}
// RenderDebugConfigMessage renders configuration settings in a formatted debug display
// with key-value pairs shown in a structured layout. Used to display runtime configuration
// for debugging purposes with a distinctive icon and border styling.
// RenderDebugConfigMessage renders configuration settings
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
theme := getTheme()
style := baseStyle.
Width(r.width - 1).
BorderLeft(true).
Foreground(theme.Muted).
BorderForeground(theme.Tool).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1)
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔧 Debug Configuration")
header := r.ty.H6("🔧 Debug Configuration")
var configLines []string
for key, value := range config {
if value != nil {
configLines = append(configLines, fmt.Sprintf(" %s: %v", key, value))
configLines = append(configLines, fmt.Sprintf("%s: %v", key, value))
}
}
configContent := baseStyle.
Foreground(theme.Muted).
Render(strings.Join(configLines, "\n"))
parts := []string{header}
var content string
if len(configLines) > 0 {
parts = append(parts, configContent)
content = r.ty.Compose(
header,
r.ty.P(strings.Join(configLines, "\n")),
)
} else {
content = header
}
rendered := style.Render(
lipgloss.JoinVertical(lipgloss.Left, parts...),
)
content = styleMarginBottom1.Render(content)
return UIMessage{
Type: SystemMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Content: content,
Height: lipgloss.Height(content),
Timestamp: timestamp,
}
}
// RenderErrorMessage renders error notifications with distinctive red coloring and
// bold text to ensure visibility. Error messages include timestamp information and
// are displayed with an error-colored border for immediate recognition.
// RenderErrorMessage renders error notifications
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
theme := getTheme()
errorContent := lipgloss.NewStyle().
Foreground(theme.Error).
Bold(true).
Render(errorMsg)
rendered := renderContentBlock(
errorContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Error),
WithMarginBottom(1),
)
rendered := r.ty.Caution(errorMsg)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: ErrorMessage,
@@ -410,93 +305,18 @@ func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim
}
}
// RenderToolCallMessage renders a notification that a tool is being executed, showing
// the tool name, formatted arguments (if any), and execution timestamp. The message
// uses tool-specific coloring to distinguish it from regular conversation messages.
func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Format arguments with better presentation
theme := getTheme()
var argsContent string
if toolArgs != "" && toolArgs != "{}" {
argsContent = lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(fmt.Sprintf("Arguments: %s", r.formatToolArgs(toolArgs)))
}
// Create info line
info := fmt.Sprintf(" Executing %s (%s)", toolName, timeStr)
// Combine parts
var fullContent string
if argsContent != "" {
fullContent = argsContent + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
} else {
fullContent = lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
}
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Tool),
WithMarginBottom(1),
)
return UIMessage{
Type: ToolCallMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderToolMessage renders a unified tool block combining the tool invocation
// header (icon + display name + params) with the execution result body. The
// border color indicates status: green for success, red for error. This replaces
// the previous two-block approach (separate call + result blocks).
// RenderToolMessage renders a unified tool block
func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
theme := getTheme()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
if r.getToolRenderer != nil {
extRd = r.getToolRenderer(toolName)
}
// --- Header: [icon] [name] [params] ---
var icon string
borderColor := theme.Success
iconColor := theme.Success
if isError {
icon = "×"
borderColor = theme.Error
iconColor = theme.Error
} else {
icon = "✓"
}
// Extension can override border color (applies to both success and error).
if extRd != nil && extRd.BorderColor != "" {
borderColor = lipgloss.Color(extRd.BorderColor)
}
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
// Extension can override display name.
displayName := toolDisplayName(toolName)
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params with width budget for the header line.
// Check extension renderer for custom header params first.
paramBudget := max(r.width-10-len(displayName), 20)
var params string
if extRd != nil && extRd.RenderHeader != nil {
@@ -506,97 +326,70 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
params = formatToolParams(toolArgs, paramBudget)
}
header := iconStr + " " + nameStr
if params != "" {
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
var icon string
iconColor := GetTheme().Success
if isError {
icon = "×"
iconColor = GetTheme().Error
} else {
icon = "✓"
}
// --- Body: check extension renderer first, then builtin, then default ---
// Style the tool name with color
theme := GetTheme()
nameColor := theme.Info
if isError {
nameColor = theme.Error
}
styledName := lipgloss.NewStyle().Foreground(nameColor).Bold(true).Render(displayName)
styledIcon := lipgloss.NewStyle().Foreground(iconColor).Render(icon)
// Build the content: icon + name + params on first line, then body
headerLine := styledIcon + " " + styledName
if params != "" {
headerLine += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Get body content
var body string
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-8)
// Apply markdown rendering if requested and body is non-empty.
if body != "" && extRd.BodyMarkdown {
body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n")
}
}
if body == "" {
if isError {
body = lipgloss.NewStyle().
Foreground(theme.Error).
Render(toolResult)
body = r.formatToolResult(toolName, toolResult)
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
if body == "" {
body = r.formatToolResult(toolName, toolResult, r.width-8)
body = r.formatToolResult(toolName, toolResult)
}
}
}
if strings.TrimSpace(body) == "" {
body = lipgloss.NewStyle().
Italic(true).
Foreground(theme.Muted).
Render("(no output)")
body = r.ty.Italic("(no output)")
}
// Combine header + body into a single block.
fullContent := header + "\n\n" + strings.TrimSuffix(body, "\n")
// Build rendering options; extension can override background.
blockOpts := []renderingOption{
WithAlign(lipgloss.Left),
WithBorderColor(borderColor),
WithMarginBottom(1),
}
if extRd != nil && extRd.Background != "" {
blockOpts = append(blockOpts, WithBackground(lipgloss.Color(extRd.Background)))
}
rendered := renderContentBlock(
fullContent,
r.width,
blockOpts...,
// Compose: icon + name + params, then body
fullContent := r.ty.Compose(
headerLine,
"",
body,
)
fullContent = styleMarginBottom1.Render(fullContent)
return UIMessage{
Type: ToolMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Content: fullContent,
Height: lipgloss.Height(fullContent),
}
}
// formatToolArgs formats tool arguments for display
func (r *MessageRenderer) formatToolArgs(args string) string {
// Remove outer braces and clean up JSON formatting
args = strings.TrimSpace(args)
if strings.HasPrefix(args, "{") && strings.HasSuffix(args, "}") {
args = strings.TrimPrefix(args, "{")
args = strings.TrimSuffix(args, "}")
args = strings.TrimSpace(args)
}
// If it's empty after cleanup, return a placeholder
if args == "" {
return "(no arguments)"
}
// Truncate if too long, but skip truncation in debug mode
if !r.debug {
maxLen := 100
if len(args) > maxLen {
return args[:maxLen] + "..."
}
}
return args
}
// formatToolResult formats tool results based on tool type
func (r *MessageRenderer) formatToolResult(toolName, result string, width int) string {
baseStyle := lipgloss.NewStyle()
// Truncate very long results only if not in debug mode
func (r *MessageRenderer) formatToolResult(toolName, result string) string {
if !r.debug {
maxLines := 10
lines := strings.Split(result, "\n")
@@ -605,51 +398,53 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s
}
}
// Format bash/command output with better formatting
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") || toolName == "run_shell_cmd" {
theme := getTheme()
// Split result into sections if it contains both stdout and stderr
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") ||
strings.Contains(toolName, "shell") {
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
return r.formatBashOutput(result, width, theme)
return parseBashOutput(result, GetTheme())
}
// For simple output, just render as monospace text with proper line breaks
return baseStyle.
Width(width).
Foreground(theme.Muted).
Render(result)
}
// For other tools, render as muted text
theme := getTheme()
return baseStyle.
Width(width).
Foreground(theme.Muted).
Render(result)
return result
}
// formatBashOutput formats bash command output with proper section handling.
// Delegates tag parsing to the shared parseBashOutput helper.
func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string {
parsed := parseBashOutput(result, theme)
return lipgloss.NewStyle().
Width(width).
Foreground(theme.Muted).
Render(parsed)
// createTypography creates a typography instance from theme
func createTypography(theme Theme) *herald.Typography {
return herald.New(
herald.WithPalette(herald.ColorPalette{
Primary: theme.Primary,
Secondary: theme.Secondary,
Tertiary: theme.Info,
Accent: theme.Accent,
Highlight: theme.Highlight,
Muted: theme.Muted,
Text: theme.Text,
Surface: theme.Background,
Base: theme.CodeBg,
}),
herald.WithAlertPalette(herald.AlertPalette{
Note: theme.Info,
Tip: theme.Success,
Important: theme.Accent,
Warning: theme.Warning,
Caution: theme.Error,
}),
herald.WithCodeLineNumbers(true),
// Customize alert labels
herald.WithAlertLabel(herald.AlertNote, "Info"),
herald.WithAlertLabel(herald.AlertTip, "You"),
herald.WithAlertLabel(herald.AlertWarning, "Working"),
herald.WithAlertLabel(herald.AlertCaution, "Error"),
)
}
// renderMarkdown renders markdown content using glamour
func (r *MessageRenderer) renderMarkdown(content string, width int) string {
rendered := toMarkdown(content, width)
return strings.TrimSuffix(rendered, "\n")
// UpdateTheme refreshes the renderer's typography instance with colors from
// the current theme. This is called when the user changes themes via /theme.
func (r *MessageRenderer) UpdateTheme() {
r.ty = createTypography(GetTheme())
}
// removeBlankLines removes lines that are visually blank from rendered output.
// Glamour wraps every character (including padding spaces) with ANSI color
// codes, so we must strip escape sequences before checking whether a line is
// empty. This collapses paragraph spacing so user messages render without
// extra vertical gaps.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
filtered := lines[:0]
+379 -155
View File
@@ -3,11 +3,11 @@ package ui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
tea "charm.land/bubbletea/v2"
@@ -74,6 +74,11 @@ type AppController interface {
ClearQueue()
// ClearMessages clears the conversation history.
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.
ReloadMessagesFromTree()
// CompactConversation summarises older messages to free context space.
// Runs asynchronously; results are delivered via CompactCompleteEvent or
// CompactErrorEvent sent through the registered tea.Program. Returns an
@@ -83,6 +88,9 @@ type AppController interface {
// GetTreeSession returns the tree session manager, or nil if tree sessions
// are not enabled. Used by slash commands like /tree, /fork, /session.
GetTreeSession() *session.TreeManager
// SwitchTreeSession replaces the active tree session with a new one,
// closing the old session. Used by /new to create a completely fresh session.
SwitchTreeSession(ts *session.TreeManager)
// SendEvent sends a tea.Msg to the program asynchronously. Safe to call
// from any goroutine. Used by extension command goroutines to deliver
// results back to the TUI without going through tea.Cmd (which can stall
@@ -98,6 +106,12 @@ type AppController interface {
// alongside the text. Returns the current queue depth (0 = started
// immediately, >0 = queued).
RunWithFiles(prompt string, files []fantasy.FilePart) int
// Steer injects a steering message into the currently running agent
// turn. If the agent is busy, the message is delivered between steps
// (after current tool finishes, before next LLM call). If idle, the
// message starts executing immediately. Returns 0 if started
// immediately, >0 if injected/pending.
Steer(prompt string) int
}
// SkillItem holds display metadata about a loaded skill for the startup
@@ -139,6 +153,22 @@ type ToolRendererData struct {
RenderBody func(toolResult string, isError bool, width int) string
}
// noopCmd is a sentinel tea.Cmd returned by handlers that have consumed an
// event but produce no side-effects. It returns a nil Msg which BubbleTea
// discards, but its non-nil value lets callers distinguish "handled" from
// "not handled" (nil tea.Cmd).
var noopCmd tea.Cmd = func() tea.Msg { return nil }
// Package-level lipgloss styles that are invariant across frames (only depend
// on theme colors, which are updated via SetTheme). Defined at package level
// to avoid allocating new lipgloss.Style structs on every render call.
//
// Note: theme-sensitive styles (those using theme.Warning, theme.Muted, etc.)
// are rebuilt on theme change via ApplyTheme. The cancel warning style
// intentionally reads the theme at render time because themes can change at
// runtime; only truly static styles belong here.
var styleMarginBottom1 = lipgloss.NewStyle().MarginBottom(1)
// ---------------------------------------------------------------------------
// Editor interceptor types (UI-layer, decoupled from extensions package)
// ---------------------------------------------------------------------------
@@ -415,6 +445,11 @@ type AppModel struct {
// the input and move to scrollback when the agent picks them up.
queuedMessages []string
// steeringMessages stores the text of prompts that were sent as steer
// messages (injected mid-turn via Ctrl+S). Rendered with a "STEERING"
// badge above the input. Cleared when the steer is consumed.
steeringMessages []string
// pendingUserPrints holds user messages that have been consumed from the
// queue but not yet printed to scrollback. They are deferred until
// SpinnerEvent{Show: true} so the previous assistant response can be
@@ -569,22 +604,30 @@ type AppModel struct {
streamingBashStderr []string
// streamingBashMaxLines caps how many lines to accumulate to prevent memory issues.
streamingBashMaxLines int
// streamingMu protects the streaming bash output fields from concurrent access.
streamingMu sync.RWMutex
// streaming bash fields are only mutated/read from the Bubble Tea event loop
// (Update/View), so no mutex is required here.
// streamingBashCommand holds the command being executed for display as a header.
streamingBashCommand string
// ---------- Cached layout heights (invalidated by layoutDirty) ----------
// layoutDirty marks that distributeHeight must recompute the stream height
// on the next View() call. Set by any state change that affects sizing
// (resize, queue changes, widget updates, visibility changes, etc.).
// View() calls distributeHeight() when this is true and then clears it.
layoutDirty bool
}
// --------------------------------------------------------------------------
// Child component interfaces (stubs until TAS-15/16/17 implement them)
// Child component interfaces
// --------------------------------------------------------------------------
// inputComponentIface is the interface the parent requires from InputComponent.
// It will be satisfied by the real InputComponent created in TAS-15.
type inputComponentIface interface {
tea.Model
}
// streamComponentIface is the interface the parent requires from StreamComponent.
// It will be satisfied by the real StreamComponent created in TAS-16.
type streamComponentIface interface {
tea.Model
// Reset clears accumulated state between agent steps.
@@ -594,6 +637,10 @@ type streamComponentIface interface {
// GetRenderedContent returns the rendered assistant message from accumulated
// streaming text, or empty string if nothing has been accumulated.
GetRenderedContent() string
// ConsumeOverflow returns lines from the top of the rendered content that
// have overflowed the allocated height and haven't been pushed to the
// terminal scrollback yet. Returns "" when no new overflow exists.
ConsumeOverflow() string
// SpinnerView returns the rendered spinner line (animation + optional label).
// Returns "" when the spinner is not active. The parent renders this in the
// status bar so the spinner never changes the view height.
@@ -602,6 +649,8 @@ type streamComponentIface interface {
SetThinkingVisible(visible bool)
// HasReasoning returns true if any reasoning content has been accumulated.
HasReasoning() bool
// UpdateTheme refreshes typography with colors from the current theme.
UpdateTheme()
}
// --------------------------------------------------------------------------
@@ -737,16 +786,9 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Init implements tea.Model. Initialises child components. Startup info is
// printed to stdout before the program starts via PrintStartupInfo().
func (m *AppModel) Init() tea.Cmd {
var cmds []tea.Cmd
if m.input != nil {
cmds = append(cmds, m.input.Init())
}
if m.stream != nil {
cmds = append(cmds, m.stream.Init())
}
return tea.Batch(cmds...)
// m.input is always set by NewAppModel; its Init starts the textarea cursor blink.
// m.stream.Init() always returns nil, so there is nothing to batch.
return m.input.Init()
}
// uiVis returns the current UIVisibility, defaulting to zero value (show all)
@@ -770,28 +812,29 @@ func (m *AppModel) PrintStartupInfo() {
return
}
render := func(text string) string {
return m.renderer.RenderSystemMessage(text, time.Now()).Content
}
// Create typography instance for startup rendering
ty := createTypography(GetTheme())
fmt.Println()
// Build the combined startup content.
var lines []string
// Build key-value pairs for startup info
var pairs [][2]string
if m.providerName != "" && m.modelName != "" {
lines = append(lines, fmt.Sprintf("Model loaded: %s (%s)", m.providerName, m.modelName))
pairs = append(pairs, [2]string{"Model", fmt.Sprintf("%s (%s)", m.providerName, m.modelName)})
}
if m.loadingMessage != "" {
lines = append(lines, m.loadingMessage)
pairs = append(pairs, [2]string{"Status", m.loadingMessage})
}
// Context — loaded AGENTS.md files.
if len(m.contextPaths) > 0 {
for _, p := range m.contextPaths {
lines = append(lines, fmt.Sprintf("Context: %s", tildeHome(p)))
contextStr := tildeHome(m.contextPaths[0])
if len(m.contextPaths) > 1 {
contextStr += fmt.Sprintf(" +%d more", len(m.contextPaths)-1)
}
pairs = append(pairs, [2]string{"Context", contextStr})
}
// Skills — listed by name.
@@ -800,21 +843,23 @@ func (m *AppModel) PrintStartupInfo() {
for i, si := range m.skillItems {
names[i] = si.Name
}
lines = append(lines, fmt.Sprintf("Skills: %s", strings.Join(names, ", ")))
pairs = append(pairs, [2]string{"Skills", strings.Join(names, ", ")})
}
// Extension tool count (only shown when > 0).
if m.extensionToolCount > 0 {
lines = append(lines, fmt.Sprintf("Loaded %d extension tools", m.extensionToolCount))
pairs = append(pairs, [2]string{"Extensions", fmt.Sprintf("%d tools", m.extensionToolCount)})
}
// MCP tool count (only shown when > 0).
if m.mcpToolCount > 0 {
lines = append(lines, fmt.Sprintf("Loaded %d tools from MCP servers", m.mcpToolCount))
pairs = append(pairs, [2]string{"MCP", fmt.Sprintf("%d tools", m.mcpToolCount)})
}
if len(lines) > 0 {
fmt.Println(render(strings.Join(lines, "\n\n")))
if len(pairs) > 0 {
rendered := ty.KVGroup(pairs)
rendered = styleMarginBottom1.Render(rendered)
fmt.Println(rendered)
}
}
@@ -884,7 +929,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}()
m.treeSelector = nil
m.state = stateInput
return m, func() tea.Msg { return nil }
return m, noopCmd
}
cmds = append(cmds, m.performFork(targetID, msg.IsUser, msg.UserText))
@@ -966,14 +1011,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.distributeHeight()
m.layoutDirty = true
// Propagate to children.
if m.input != nil {
_, cmd := m.input.Update(msg)
updated, cmd := m.input.Update(msg)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
@@ -1070,6 +1117,45 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// In other states pass ESC through to children below.
case "ctrl+s":
// Steer: inject the current input as a steering message into the
// running agent turn. Only active during stateWorking — in input
// state, Ctrl+S is passed through to children (no-op by default).
if m.state == stateWorking && m.appCtrl != nil {
var text string
if ic, ok := m.input.(*InputComponent); ok {
text = strings.TrimSpace(ic.textarea.Value())
}
if text != "" {
// Clear the input and push to history.
if ic, ok := m.input.(*InputComponent); ok {
ic.pushHistory(text)
ic.textarea.SetValue("")
}
// Preprocess @file references.
processedText := text
if m.cwd != "" {
processedText = ProcessFileAttachments(text, m.cwd)
}
// Inject the steer message.
sLen := m.appCtrl.Steer(processedText)
if sLen > 0 {
m.steeringMessages = append(m.steeringMessages, text)
m.layoutDirty = true
} else {
// Started immediately (agent was idle).
m.pendingUserPrints = append(m.pendingUserPrints, text)
m.flushStreamAndPendingUserMessages()
if m.state != stateWorking {
m.state = stateWorking
}
}
}
return m, tea.Batch(cmds...)
}
}
// Route key events to the focused child. Check for editor
@@ -1129,63 +1215,17 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Input submitted ──────────────────────────────────────────────────────
case submitMsg:
// Handle slash commands locally — they should never reach app.Run().
if sc := GetCommandByName(msg.Text); sc != nil {
if cmd := m.handleSlashCommand(sc); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
// /compact and /model support optional args (e.g. "/compact Focus on API",
// "/model anthropic/claude-haiku-3-5-20241022").
// GetCommandByName won't match the full text, so check the prefix.
if name, args, ok := strings.Cut(msg.Text, " "); ok {
// Parse once: split on the first space so argument-bearing commands
// (e.g. "/model anthropic/foo", "/compact Focus on X") are matched by
// their name and their args are passed through to the handler.
if strings.HasPrefix(msg.Text, "/") {
name, args, _ := strings.Cut(msg.Text, " ")
if sc := GetCommandByName(name); sc != nil {
switch sc.Name {
case "/compact":
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/model":
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/thinking":
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/theme":
if cmd := m.handleThemeCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/name":
if cmd := m.handleNameCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/export":
if cmd := m.handleExportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/import":
if cmd := m.handleImportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
if cmd := m.handleSlashCommand(sc, strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
@@ -1242,7 +1282,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// "queued" badge. It will be printed to scrollback when
// the agent picks it up (via SpinnerEvent).
m.queuedMessages = append(m.queuedMessages, displayText)
m.distributeHeight()
m.layoutDirty = true
} else {
// Started immediately. Flush any leftover stream content
// from the previous step first, then print the user
@@ -1263,7 +1303,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Show spinner while the shell command runs.
m.state = stateWorking
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
// Execute the shell command asynchronously so the TUI stays responsive.
@@ -1272,7 +1313,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case shellCommandResultMsg:
// Stop spinner now that the command has finished.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
m.state = stateInput
@@ -1290,22 +1332,25 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Show {
m.flushStreamAndPendingUserMessages()
m.state = stateWorking
m.distributeHeight()
m.layoutDirty = true
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
case app.ReasoningChunkEvent:
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
case app.StreamChunkEvent:
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
@@ -1316,10 +1361,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// rendered when the ToolResultEvent arrives.
m.flushStreamContent()
// For bash commands, extract and store the command for the streaming output header.
if msg.ToolName == "bash" {
var args struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(msg.ToolArgs), &args); err == nil && args.Command != "" {
m.streamingBashCommand = args.Command
}
}
case app.ToolExecutionEvent:
// Pass to stream component for execution spinner display.
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
@@ -1327,19 +1383,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Buffer tool result for scrollback.
m.printToolResult(msg)
// Clear streaming bash output since tool completed.
m.streamingMu.Lock()
m.streamingBashOutput = nil
m.streamingBashStderr = nil
m.streamingMu.Unlock()
m.streamingBashCommand = ""
// Start spinner again while waiting for the next LLM response.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
case app.ToolOutputEvent:
// Accumulate streaming bash output for display.
m.streamingMu.Lock()
if msg.IsStderr {
m.streamingBashStderr = append(m.streamingBashStderr, msg.Chunk)
// Cap stderr lines to prevent memory issues.
@@ -1353,7 +1408,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingBashOutput = m.streamingBashOutput[len(m.streamingBashOutput)-m.streamingBashMaxLines:]
}
}
m.streamingMu.Unlock()
case app.ToolCallContentEvent:
// In streaming mode this text was already delivered via StreamChunkEvents
@@ -1387,7 +1441,39 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.queuedMessages = m.queuedMessages[1:]
m.pendingUserPrints = append(m.pendingUserPrints, text)
}
m.distributeHeight()
m.layoutDirty = true
case app.SteerConsumedEvent:
// Steering messages were consumed — either injected mid-turn via
// PrepareStep, or drained into the queue after a text-only turn.
//
// Two cases:
//
// 1. Mid-turn (stateWorking, PrepareStep fired): no SpinnerEvent{Show:
// true} will follow within this turn, so we cannot rely on
// flushStreamAndPendingUserMessages() being called. Flush any live
// stream content first (assistant text up to the steer point), then
// render the steering user messages immediately to scrollback.
//
// 2. Post-turn (text-only response, drained after StepComplete): a
// SpinnerEvent{Show: true} for the next turn is already in flight.
// Defer to pendingUserPrints so the previous assistant response is
// flushed first, preserving chronological order.
if m.state == stateWorking {
// Case 1: mid-turn — flush + print immediately.
m.flushStreamContent()
for _, text := range m.steeringMessages {
m.printUserMessage(text)
}
m.steeringMessages = m.steeringMessages[:0]
m.layoutDirty = true
cmds = append(cmds, m.drainScrollback())
} else {
// Case 2: post-turn — defer so SpinnerEvent orders correctly.
m.pendingUserPrints = append(m.pendingUserPrints, m.steeringMessages...)
m.steeringMessages = m.steeringMessages[:0]
m.layoutDirty = true
}
case app.StepCompleteEvent:
// Keep stream content visible in the view — don't flush to scrollback
@@ -1397,7 +1483,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// scrollback when the next step starts (SpinnerEvent{Show: true}).
// Just stop the spinner and return to input state.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
m.state = stateInput
@@ -1407,7 +1494,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// User cancelled the step (double-ESC). Keep partial stream content
// visible (same reasoning as StepCompleteEvent). Just stop the spinner.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
m.state = stateInput
@@ -1418,7 +1506,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// StepCompleteEvent). Print the error to scrollback — it appears
// above the view, and the partial response stays visible below.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
if msg.Err != nil {
@@ -1451,7 +1540,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Extension widget changed — recalculate height distribution so the
// stream region accounts for widget space. View() will read the
// latest widget state on the next render.
m.distributeHeight()
m.layoutDirty = true
// Refresh extension commands (e.g. after hot-reload). The callback
// returns the current set from the runner which may have changed.
@@ -1596,15 +1685,34 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
default:
// Pass unrecognised messages to all children.
if m.input != nil {
_, cmd := m.input.Update(msg)
updated, cmd := m.input.Update(msg)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
}
// Flush any stream overflow lines that have grown past the allocated
// height into the terminal's real scrollback buffer. This ensures the
// diagram's invariant: streaming text starts at the top of the viewable
// terminal and overflows upward into the scrollback buffer rather than
// silently discarding the older lines.
//
// IMPORTANT: overflow is emitted directly via tea.Println rather than
// via appendScrollback. Using appendScrollback would cause drainScrollback
// to see a non-empty scrollbackBuf and trigger its auto-flush, which calls
// GetRenderedContent() + Reset() while the stream is still active —
// causing duplication and premature resets.
if m.stream != nil {
if overflow := m.stream.ConsumeOverflow(); overflow != "" {
cmds = append(cmds, tea.Println(overflow))
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1634,6 +1742,15 @@ func (m *AppModel) View() tea.View {
return tea.NewView(m.overlay.Render())
}
// Recompute layout heights if any Update() changed state that affects
// sizing. Deferring this to View() guarantees exactly one call per frame
// regardless of how many events triggered a layout change in a single
// Update() invocation.
if m.layoutDirty {
m.distributeHeight()
m.layoutDirty = false
}
vis := m.uiVis()
streamView := m.renderStream()
@@ -1641,6 +1758,7 @@ func (m *AppModel) View() tea.View {
// Propagate hint visibility to the input component before rendering.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
ic.agentBusy = m.state == stateWorking
}
// When a prompt is active, it replaces the input area for consistency
@@ -1742,20 +1860,23 @@ func (m *AppModel) renderStream() string {
// renderStreamingBashOutput renders accumulated streaming bash output (stdout + stderr)
// below the LLM streaming text. Returns empty string if no bash output is present.
// Lines are truncated to the terminal width and capped to maxBashLines to prevent
// long-running commands from blowing up the TUI layout.
func (m *AppModel) renderStreamingBashOutput(theme Theme) string {
m.streamingMu.RLock()
stdoutLines := make([]string, len(m.streamingBashOutput))
copy(stdoutLines, m.streamingBashOutput)
stderrLines := make([]string, len(m.streamingBashStderr))
copy(stderrLines, m.streamingBashStderr)
m.streamingMu.RUnlock()
command := m.streamingBashCommand
if len(stdoutLines) == 0 && len(stderrLines) == 0 {
return ""
}
const lineIndent = " "
width := m.width - 2 // Account for indent and padding
lineWidth := max(m.width-2-len(lineIndent), 20)
// Account for PaddingLeft(1) on the output/stderr styles.
maxLineChars := lineWidth - 1
outputStyle := lipgloss.NewStyle().
Background(theme.CodeBg).
@@ -1766,17 +1887,59 @@ func (m *AppModel) renderStreamingBashOutput(theme Theme) string {
Background(theme.CodeBg).
PaddingLeft(1)
// Header style for the command - muted text with a subtle indicator.
headerStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
PaddingLeft(1)
// Cap displayed lines to maxBashLines (show the tail, since streaming
// output is most useful at the end). The buffer itself is larger to
// preserve context, but we only render the last N lines.
totalLines := len(stdoutLines) + len(stderrLines)
var hiddenCount int
if totalLines > maxBashLines {
hiddenCount = totalLines - maxBashLines
// Trim from stdout first (older output), then stderr.
remaining := maxBashLines
if len(stderrLines) >= remaining {
stdoutLines = nil
stderrLines = stderrLines[len(stderrLines)-remaining:]
} else {
remaining -= len(stderrLines)
if len(stdoutLines) > remaining {
stdoutLines = stdoutLines[len(stdoutLines)-remaining:]
}
}
}
var lines []string
// Command header - show the bash command being executed.
if command != "" {
headerText := fmt.Sprintf("$ %s", command)
headerContent := headerStyle.Width(lineWidth).Render(truncateLine(headerText, maxLineChars))
lines = append(lines, lineIndent+headerContent)
}
// Truncation hint at the top.
if hiddenCount > 0 {
hint := fmt.Sprintf("...(%d more lines above)", hiddenCount)
hintContent := outputStyle.Width(lineWidth).
Foreground(theme.Muted).Italic(true).Render(hint)
lines = append(lines, lineIndent+hintContent)
}
// Render stdout lines.
for _, line := range stdoutLines {
styled := outputStyle.Width(width - len(lineIndent)).Render(line)
line = truncateLine(strings.TrimRight(line, "\n"), maxLineChars)
styled := outputStyle.Width(lineWidth).Render(line)
lines = append(lines, lineIndent+styled)
}
// Render stderr lines with error styling.
for _, line := range stderrLines {
styled := stderrStyle.Width(width - len(lineIndent)).Render(line)
line = truncateLine(strings.TrimRight(line, "\n"), maxLineChars)
styled := stderrStyle.Width(lineWidth).Render(line)
lines = append(lines, lineIndent+styled)
}
@@ -1901,16 +2064,26 @@ func (m *AppModel) cycleThinkingLevel() {
go func() { _ = SaveThinkingLevelPreference(next) }()
}
// renderSeparator renders the separator line with an optional queue count badge.
// renderSeparator renders the separator line with an optional queue/steer count badge.
func (m *AppModel) renderSeparator() string {
theme := GetTheme()
lineStyle := lipgloss.NewStyle().Foreground(theme.Muted)
queueLen := len(m.queuedMessages)
steerLen := len(m.steeringMessages)
if queueLen > 0 {
badge := lipgloss.NewStyle().
Foreground(theme.Secondary).
Render(fmt.Sprintf("%d queued", queueLen))
if steerLen > 0 || queueLen > 0 {
var parts []string
if steerLen > 0 {
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Warning).
Render(fmt.Sprintf("%d steering", steerLen)))
}
if queueLen > 0 {
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Secondary).
Render(fmt.Sprintf("%d queued", queueLen)))
}
badge := strings.Join(parts, " ")
// Fill the separator with dashes up to the badge.
dashWidth := max(m.width-lipgloss.Width(badge)-1, 0)
@@ -2009,27 +2182,47 @@ func (m *AppModel) renderHeaderFooter(getter func() *WidgetData) string {
return renderContentBlock(data.Text, m.width, opts...)
}
// renderQueuedMessages renders queued prompts as styled content blocks with a
// "QUEUED" badge, anchored between the separator and input. Each message is
// displayed in a bordered block matching the overall message styling.
// renderQueuedMessages renders queued and steering prompts as styled content
// blocks with badges, anchored between the separator and input. Steering
// messages use a distinct "STEERING" badge to differentiate from queued ones.
func (m *AppModel) renderQueuedMessages() string {
if len(m.queuedMessages) == 0 {
if len(m.queuedMessages) == 0 && len(m.steeringMessages) == 0 {
return ""
}
theme := GetTheme()
badge := CreateBadge("QUEUED", theme.Accent)
var blocks []string
for _, msg := range m.queuedMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Muted),
)
blocks = append(blocks, rendered)
// Render steering messages first (higher priority).
if len(m.steeringMessages) > 0 {
badge := CreateBadge("STEERING", theme.Warning)
for _, msg := range m.steeringMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Warning),
)
blocks = append(blocks, rendered)
}
}
// Render queued messages.
if len(m.queuedMessages) > 0 {
badge := CreateBadge("QUEUED", theme.Accent)
for _, msg := range m.queuedMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Muted),
)
blocks = append(blocks, rendered)
}
}
return strings.Join(blocks, "\n")
}
@@ -2065,10 +2258,10 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
// Slash command handlers
// --------------------------------------------------------------------------
// handleSlashCommand executes a recognized slash command and returns a tea.Cmd
// that emits the appropriate output to scrollback. Returns tea.Quit for /quit,
// nil for commands with no visible output, or a tea.Println cmd for display.
func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
// handleSlashCommand executes a recognized slash command and returns a tea.Cmd.
// args contains any text after the command name (may be empty). Returns tea.Quit
// for /quit, nil for commands with no output, or a tea.Println cmd for display.
func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
switch sc.Name {
case "/quit":
return tea.Quit
@@ -2083,13 +2276,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/reset-usage":
m.printResetUsage()
case "/model":
return m.handleModelCommand("")
return m.handleModelCommand(args)
case "/theme":
return m.handleThemeCommand("")
return m.handleThemeCommand(args)
case "/thinking":
return m.handleThinkingCommand("")
return m.handleThinkingCommand(args)
case "/compact":
return m.handleCompactCommand("")
return m.handleCompactCommand(args)
case "/clear":
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
@@ -2100,7 +2293,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
m.appCtrl.ClearQueue()
}
m.queuedMessages = m.queuedMessages[:0]
m.distributeHeight()
m.steeringMessages = m.steeringMessages[:0]
m.layoutDirty = true
case "/tree":
return m.handleTreeCommand()
@@ -2109,15 +2303,15 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/new":
return m.handleNewCommand()
case "/name":
return m.handleNameCommand("")
return m.handleNameCommand(args)
case "/resume":
return m.handleResumeCommand()
case "/export":
return m.handleExportCommand("")
return m.handleExportCommand(args)
case "/share":
return m.handleShareCommand()
case "/import":
return m.handleImportCommand("")
return m.handleImportCommand(args)
case "/session":
return m.handleSessionInfoCommand()
@@ -2204,7 +2398,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
// Return a non-nil Cmd so the caller knows the command was handled
// and doesn't fall through to the regular prompt path. The Cmd itself
// is a no-op.
return func() tea.Msg { return nil }
return noopCmd
}
// expandPromptTemplate checks if the submitted text matches a prompt template
@@ -2246,7 +2440,7 @@ func (m *AppModel) printHelpMessage() {
"**Navigation:**\n" +
"- `/tree`: Navigate session tree (switch branches)\n" +
"- `/fork`: Branch from an earlier message\n" +
"- `/new`: Start a new branch (preserves history)\n" +
"- `/new`: Start a new session (discards context, saves old session)\n" +
"- `/resume`: Open session picker to switch sessions\n" +
"- `/name <name>`: Set a display name for this session\n\n" +
"**System:**\n" +
@@ -2287,7 +2481,9 @@ func (m *AppModel) printHelpMessage() {
"- `!!command`: Run shell command, output excluded from LLM context\n\n" +
"**Keys:**\n" +
"- `Ctrl+C`: Exit at any time\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n" +
"- `Ctrl+S`: Steer — redirect the agent mid-turn (injected between tool calls)\n" +
"- `Enter` (while working): Queue message for after the agent finishes\n\n" +
"You can also just type your message to chat with the AI assistant."
m.printSystemMessage(help)
}
@@ -2720,6 +2916,8 @@ func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
return nil
}
m.renderer.UpdateTheme()
m.stream.UpdateTheme()
m.printSystemMessage(fmt.Sprintf("Switched to theme: %s", args))
return nil
}
@@ -2812,7 +3010,8 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
return nil
}
// handleNewCommand starts a fresh session by resetting the tree leaf.
// handleNewCommand starts a completely new session (Pi-style /new behavior).
// Creates a new session file, discarding all context from the previous conversation.
func (m *AppModel) handleNewCommand() tea.Cmd {
// Emit before-session-switch event in a goroutine so that extension
// handlers can call blocking operations (e.g. ctx.PromptConfirm) without
@@ -2827,7 +3026,7 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
reason: reason,
})
}()
return func() tea.Msg { return nil }
return noopCmd
}
return m.performNewSession()
@@ -2835,6 +3034,8 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
// performNewSession performs the actual session reset. Called either directly
// (when no before-hook exists) or after the async hook completes.
// Matches Pi behavior: creates a completely new session file, discarding all
// context from the previous conversation.
func (m *AppModel) performNewSession() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
@@ -2842,15 +3043,28 @@ func (m *AppModel) performNewSession() tea.Cmd {
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
// Reset usage statistics for fresh session
if m.usageTracker != nil {
m.usageTracker.Reset()
}
m.printSystemMessage("Conversation cleared. Starting fresh.")
return nil
}
ts.ResetLeaf()
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
// Create a brand new session file (Pi-style /new behavior)
newTs, err := session.CreateTreeSession(m.cwd)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to create new session: %v", err))
return nil
}
m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
// Switch to the new session, closing the old one
m.appCtrl.SwitchTreeSession(newTs)
// Reset usage statistics for the new session
if m.usageTracker != nil {
m.usageTracker.Reset()
}
m.printSystemMessage("New session started. Previous conversation saved.")
return nil
}
@@ -2863,8 +3077,12 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
return nil
}
// Branch the tree session to the target entry. We must NOT call
// ClearMessages() here because it resets the leaf pointer back to "",
// undoing the branch we just set. Instead, branch first and then
// reload the in-memory store from the tree session's current branch.
_ = ts.Branch(targetID)
m.appCtrl.ClearMessages()
m.appCtrl.ReloadMessagesFromTree()
// If it was a user message, populate the input with the text.
if isUser && userText != "" {
@@ -3153,6 +3371,12 @@ func (m *AppModel) renderSessionHistory() {
}
case message.RoleAssistant:
// First render any reasoning/thinking content
reasoning := msg.Reasoning()
if reasoning.Thinking != "" {
m.appendScrollback(m.renderer.RenderReasoningBlock(reasoning.Thinking, msg.CreatedAt).Content)
}
// Then render the text content
text := msg.Content()
if text != "" {
modelName := m.modelName
+1 -1
View File
@@ -50,7 +50,7 @@ func NewModelSelector(currentModel string, width, height int) *ModelSelectorComp
registry := models.GetGlobalRegistry()
var allModels []ModelEntry
for _, providerID := range registry.GetFantasyProviders() {
for _, providerID := range registry.GetLLMProviders() {
// Only include providers with valid API keys configured.
if err := registry.ValidateEnvironment(providerID, ""); err != nil {
continue
+71 -1
View File
@@ -46,6 +46,10 @@ func (s *stubAppController) ClearMessages() {
s.clearMsgCalled++
}
func (s *stubAppController) ReloadMessagesFromTree() {
// no-op in tests
}
func (s *stubAppController) CompactConversation(_ string) error {
return nil
}
@@ -54,6 +58,10 @@ func (s *stubAppController) GetTreeSession() *session.TreeManager {
return nil
}
func (s *stubAppController) SwitchTreeSession(_ *session.TreeManager) {
// no-op in tests
}
func (s *stubAppController) SendEvent(_ tea.Msg) {
// no-op in tests
}
@@ -67,6 +75,11 @@ func (s *stubAppController) RunWithFiles(prompt string, _ []fantasy.FilePart) in
return s.queueLen
}
func (s *stubAppController) Steer(prompt string) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
// --------------------------------------------------------------------------
// Stub child components
// --------------------------------------------------------------------------
@@ -88,9 +101,11 @@ func (s *stubStreamComponent) View() tea.View { return tea.NewView("
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
func (s *stubStreamComponent) ConsumeOverflow() string { return "" }
func (s *stubStreamComponent) SpinnerView() string { return "" }
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
func (s *stubStreamComponent) HasReasoning() bool { return false }
func (s *stubStreamComponent) UpdateTheme() {}
// stubInputComponent satisfies inputComponentIface without rendering anything.
type stubInputComponent struct {
@@ -133,7 +148,11 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
// sendMsg calls m.Update once with the given message and returns the updated model.
func sendMsg(m *AppModel, msg tea.Msg) *AppModel {
updated, _ := m.Update(msg)
return updated.(*AppModel)
result := updated.(*AppModel)
// Simulate BubbleTea's frame cycle: View() is called after every Update().
// This flushes any pending layoutDirty work (e.g. distributeHeight).
_ = result.View()
return result
}
// --------------------------------------------------------------------------
@@ -679,6 +698,57 @@ func TestToolResult_clearsStreamingBashOutput(t *testing.T) {
}
}
// TestToolCallStarted_extractsBashCommand verifies that ToolCallStartedEvent
// extracts the bash command from ToolArgs and stores it for the streaming output header.
func TestToolCallStarted_extractsBashCommand(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
// Send ToolCallStartedEvent with bash command.
m = sendMsg(m, app.ToolCallStartedEvent{
ToolCallID: "call-1",
ToolName: "bash",
ToolArgs: `{"command":"ls -la /home"}`,
})
if m.streamingBashCommand != "ls -la /home" {
t.Fatalf("expected streamingBashCommand='ls -la /home', got %q", m.streamingBashCommand)
}
// ToolResultEvent should clear the command.
m = sendMsg(m, app.ToolResultEvent{
ToolCallID: "call-1",
ToolName: "bash",
ToolArgs: `{"command":"ls -la /home"}`,
Result: "output",
IsError: false,
})
if m.streamingBashCommand != "" {
t.Fatalf("expected streamingBashCommand cleared, got %q", m.streamingBashCommand)
}
}
// TestToolCallStarted_nonBashTool_doesNotSetCommand verifies that non-bash tools
// do not set the streamingBashCommand field.
func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
// Send ToolCallStartedEvent with a non-bash tool.
m = sendMsg(m, app.ToolCallStartedEvent{
ToolCallID: "call-1",
ToolName: "read",
ToolArgs: `{"file":"/etc/passwd"}`,
})
if m.streamingBashCommand != "" {
t.Fatalf("expected streamingBashCommand to remain empty for non-bash tools, got %q", m.streamingBashCommand)
}
}
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
// produces a non-nil cmd (the tea.Println call for the error message).
func TestStepError_printCmd(t *testing.T) {
-352
View File
@@ -1,352 +0,0 @@
package ui
import (
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// SlashCommandInput provides an interactive text input field with intelligent
// slash command autocomplete functionality. It displays a popup menu of matching
// commands as the user types, supporting fuzzy matching and keyboard navigation.
type SlashCommandInput struct {
textarea textarea.Model
commands []SlashCommand
showPopup bool
filtered []FuzzyMatch
selected int
width int
lastValue string
popupHeight int
title string
quitting bool
value string
submitNext bool // Flag to submit on next update
renderedLines int // Track how many lines were rendered
hideHint bool // Suppress the "enter submit · ctrl+j..." hint
}
// NewSlashCommandInput creates and initializes a new slash command input field with
// the specified width and title. The input supports multi-line text entry, command
// autocomplete, and is styled to match the application's theme.
func NewSlashCommandInput(width int, title string) *SlashCommandInput {
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 5000
ta.SetWidth(width - 8) // Account for container padding, border and internal padding
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
// Style the textarea using theme colors.
theme := GetTheme()
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
styles.Focused.Prompt = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
return &SlashCommandInput{
textarea: ta,
commands: SlashCommands,
width: width,
popupHeight: 7,
title: title,
}
}
// Init implements the tea.Model interface, returning the initial command to start
// the cursor blinking animation for the text input field.
func (s *SlashCommandInput) Init() tea.Cmd {
return textarea.Blink
}
// Update implements the tea.Model interface, handling keyboard input for text entry,
// command selection, and navigation. Manages the autocomplete popup display and
// processes submission or cancellation actions.
func (s *SlashCommandInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// Check if we need to submit after updating the view
if s.submitNext {
s.value = s.textarea.Value()
s.quitting = true
return s, tea.Quit
}
switch msg := msg.(type) {
case tea.KeyPressMsg: // Check for quit keys first (when popup is not shown)
if !s.showPopup {
switch msg.String() {
case "ctrl+c", "esc":
s.quitting = true
return s, tea.Quit
case "ctrl+d", "enter": // Enter always submits
s.value = s.textarea.Value()
s.quitting = true
return s, tea.Quit
}
}
// Handle popup navigation
if s.showPopup {
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))):
if s.selected > 0 {
s.selected--
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))):
if s.selected < len(s.filtered)-1 {
s.selected++
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
// Complete with selected command
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
// Move cursor to end
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
// Populate the field with the selected command
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.textarea.CursorEnd()
// Hide the popup
s.showPopup = false
s.selected = 0
// Set flag to submit on next update (after view refresh)
s.submitNext = true
// Force a refresh
return s, nil
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
s.showPopup = false
s.selected = 0
return s, nil
}
}
// Update textarea
s.textarea, cmd = s.textarea.Update(msg)
// Check if we should show/update popup
value := s.textarea.Value()
if value != s.lastValue {
s.lastValue = value
// Only show popup if we're on the first line and it starts with /
lines := strings.Split(value, "\n")
if len(lines) > 0 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") && len(lines) == 1 {
// Show and update popup
s.showPopup = true
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 0
} else {
// Hide popup
s.showPopup = false
}
}
return s, cmd
default:
// Pass through other messages
s.textarea, cmd = s.textarea.Update(msg)
return s, cmd
}
}
// View implements the tea.Model interface, rendering the complete input field
// including the title, text area, autocomplete popup (when active), and help text.
// The view adapts based on whether single or multi-line input is detected.
func (s *SlashCommandInput) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
Foreground(theme.Text).
MarginBottom(1).
PaddingLeft(3)
// Input box with huh-like styling
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(theme.Primary).
PaddingLeft(2). // match message block paddingLeft
Width(s.width - 1) // full width minus left border
// Build the view
var view strings.Builder
view.WriteString(titleStyle.Render(s.title))
view.WriteString("\n")
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
// Count rendered lines
s.renderedLines = 2 + s.textarea.Height() // title + newline + textarea height
// Add popup if visible
if s.showPopup && len(s.filtered) > 0 {
view.WriteString("\n")
view.WriteString(s.renderPopup())
// Add popup lines
visibleItems := min(len(s.filtered), s.popupHeight)
scrollIndicators := 0
if s.selected >= s.popupHeight {
scrollIndicators++ // top indicator
}
if len(s.filtered) > s.popupHeight {
scrollIndicators++ // bottom indicator
}
popupLines := visibleItems + scrollIndicators + 5 // items + scroll + border + padding + footer
s.renderedLines += 1 + popupLines // newline + popup
}
// Add help text at bottom (unless hidden by extension).
if !s.hideHint {
helpStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
MarginTop(1).
PaddingLeft(3)
helpText := "enter submit • ctrl+j / shift+enter new line"
view.WriteString("\n")
view.WriteString(helpStyle.Render(helpText))
s.renderedLines += 2 // newline + help text
}
// Apply container padding to entire view
return tea.NewView(containerStyle.Render(view.String()))
}
// renderPopup renders the autocomplete popup
func (s *SlashCommandInput) renderPopup() string {
theme := GetTheme()
// Popup styling
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.MutedBorder).
Padding(1, 2).
Width(s.width - 4). // Account for container padding
MarginLeft(0) // No extra margin needed due to container padding
var items []string
// Calculate visible window
visibleItems := min(len(s.filtered), s.popupHeight)
startIdx := 0
// Adjust window to keep selected item visible
if s.selected >= s.popupHeight {
startIdx = s.selected - s.popupHeight + 1
}
endIdx := min(startIdx+visibleItems, len(s.filtered))
for i := startIdx; i < endIdx; i++ {
match := s.filtered[i]
cmd := match.Command
// Create the selection indicator
var indicator string
if i == s.selected {
indicator = lipgloss.NewStyle().
Foreground(theme.Primary).
Render("> ")
} else {
indicator = " "
}
// Format item
nameStyle := lipgloss.NewStyle().
Foreground(theme.Secondary).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(theme.Muted)
// Highlight selected item
if i == s.selected {
nameStyle = nameStyle.Foreground(theme.Primary)
descStyle = descStyle.Foreground(theme.Text)
}
// Format with proper spacing
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(cmd.Name)
// Truncate description if needed
desc := cmd.Description
maxDescLen := s.width - nameWidth - 14 // Account for padding and indicator
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
}
line := indicator + name + descStyle.Render(desc)
items = append(items, line)
}
// Add scroll indicators if needed
if startIdx > 0 {
scrollUpStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
items = append([]string{scrollUpStyle.Render(" ↑ more above")}, items...)
}
if endIdx < len(s.filtered) {
scrollDownStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
items = append(items, scrollDownStyle.Render(" ↓ more below"))
}
// Join items
content := strings.Join(items, "\n")
// Add footer hint
footerStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Italic(true)
footer := footerStyle.Render("↑↓ navigate • tab complete • ↵ select • esc dismiss")
// Combine content and footer
popupContent := content + "\n\n" + footer
return popupStyle.Render(popupContent)
}
// Value returns the final text value entered by the user after submission.
// This will be empty if the input was cancelled.
func (s *SlashCommandInput) Value() string {
return s.value
}
// Cancelled returns true if the user cancelled the input operation (e.g., by
// pressing ESC or Ctrl+C) without submitting any text.
func (s *SlashCommandInput) Cancelled() bool {
return s.quitting && s.value == ""
}
// RenderedLines returns the total number of terminal lines used by the last
// rendered view, including the title, input area, popup, and help text. This
// is used for proper screen clearing when the input is dismissed.
func (s *SlashCommandInput) RenderedLines() int {
return s.renderedLines
}
+261 -97
View File
@@ -2,14 +2,27 @@ package ui
import (
"fmt"
"regexp"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
"github.com/mark3labs/kit/internal/app"
)
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
// reasoning content in. Used to strip these tags from streaming text content.
// The (?s) flag makes . match newlines.
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
// thinkTagOpen and thinkTagClose are the opening and closing think tag strings.
const (
thinkTagOpen = "<think>"
thinkTagClose = "</think>"
)
// knightRiderFrames generates a KITT-style scanning animation where a bright
// light bounces back and forth across a row of dots with a trailing glow.
// Colors are derived from the active theme. Used by StreamComponent (TUI
@@ -79,7 +92,12 @@ func streamSpinnerTickCmd(generation uint64) tea.Cmd {
// streamFlushTickMsg fires when it's time to commit pending chunks to the
// main content builders and trigger a re-render. This coalesces rapid
// streaming chunks into fewer expensive markdown re-renders.
type streamFlushTickMsg struct{}
//
// generation ties the tick to the pending flush session that created it so
// stale ticks from a prior Reset() are discarded.
type streamFlushTickMsg struct {
generation uint64
}
// streamFlushInterval is the coalescing window for stream chunks. Chunks
// arriving within this window are batched into a single render pass.
@@ -89,9 +107,9 @@ const streamFlushInterval = 16 * time.Millisecond
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
// the coalescing interval.
func streamFlushTickCmd() tea.Cmd {
func streamFlushTickCmd(generation uint64) tea.Cmd {
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
return streamFlushTickMsg{}
return streamFlushTickMsg{generation: generation}
})
}
@@ -149,9 +167,11 @@ type StreamComponent struct {
// spinnerFrame is the current frame index.
spinnerFrame int
// activeTools tracks the names of tools currently executing in parallel.
// When multiple tools run concurrently, all are displayed in the spinner.
activeTools []string
// activeTools maps ToolCallID -> display label for currently running tools.
activeTools map[string]string
// activeToolOrder preserves deterministic display order for active tools.
activeToolOrder []string
// streamContent holds committed streaming text (flushed from pending).
streamContent strings.Builder
@@ -172,6 +192,10 @@ type StreamComponent struct {
// the same coalescing window.
flushPending bool
// flushGeneration is incremented when stream state resets so stale flush
// ticks from a previous step can be discarded.
flushGeneration uint64
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
@@ -181,6 +205,14 @@ type StreamComponent struct {
// the cache.
renderDirty bool
// scrollbackFlushedLines is the number of lines from the top of the
// rendered content that have already been emitted to the terminal
// scrollback buffer. On each flush, lines that overflow the allocated
// height and haven't been pushed yet are emitted via tea.Println so
// they appear in the terminal's real scrollback (scrollable with the
// terminal's own scroll mechanism).
scrollbackFlushedLines int
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
@@ -190,14 +222,12 @@ type StreamComponent struct {
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// messageRenderer renders assistant messages in standard mode.
messageRenderer *MessageRenderer
// inThinkTag tracks whether we're currently inside a section
// from models that wrap reasoning in XML-like tags (Qwen, DeepSeek).
inThinkTag bool
// compactRenderer renders assistant messages in compact mode.
compactRenderer *CompactRenderer
// compactMode selects which renderer to use.
compactMode bool
// renderer renders streaming assistant text in either compact or standard mode.
renderer Renderer
// modelName is displayed in the streaming text header.
modelName string
@@ -211,6 +241,9 @@ type StreamComponent struct {
// height constrains the render output to at most this many lines.
// 0 means unconstrained.
height int
// ty provides typography functions for rendering text.
ty *herald.Typography
}
// NewStreamComponent creates a new StreamComponent ready to be embedded in AppModel.
@@ -218,13 +251,20 @@ func NewStreamComponent(compactMode bool, width int, modelName string) *StreamCo
if width == 0 {
width = 80
}
var renderer Renderer
if compactMode {
renderer = NewCompactRenderer(width, false)
} else {
renderer = newMessageRenderer(width, false)
}
return &StreamComponent{
spinnerFrames: knightRiderFrames(),
compactMode: compactMode,
modelName: modelName,
messageRenderer: newMessageRenderer(width, false),
compactRenderer: NewCompactRenderer(width, false),
width: width,
spinnerFrames: knightRiderFrames(),
modelName: modelName,
renderer: renderer,
width: width,
ty: createTypography(GetTheme()),
}
}
@@ -251,16 +291,54 @@ func (s *StreamComponent) Reset() {
s.spinnerGeneration++ // invalidate any in-flight tick commands
s.spinnerFrame = 0
s.activeTools = nil
s.activeToolOrder = nil
s.streamContent.Reset()
s.reasoningContent.Reset()
s.pendingStream.Reset()
s.pendingReasoning.Reset()
s.flushPending = false
s.flushGeneration++
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
s.scrollbackFlushedLines = 0
}
// ConsumeOverflow returns any lines from the rendered stream content that have
// overflowed the allocated height and have not yet been pushed to the terminal
// scrollback buffer. It advances the internal flushed-line pointer so
// subsequent calls only return newly overflowed lines.
//
// Returns "" when there is no overflow or height is unconstrained (0).
// The caller should emit the returned string via tea.Println so the content
// appears in the terminal's real scrollback (not just discarded).
func (s *StreamComponent) ConsumeOverflow() string {
if s.height <= 0 {
return ""
}
content := s.render()
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
totalLines := len(lines)
// Number of lines that overflow the viewable height.
overflowLines := totalLines - s.height
if overflowLines <= 0 {
return ""
}
// How many overflow lines are new (not yet flushed to scrollback).
newOverflow := overflowLines - s.scrollbackFlushedLines
if newOverflow <= 0 {
return ""
}
// The new overflow is lines [s.scrollbackFlushedLines .. overflowLines).
start := s.scrollbackFlushedLines
end := overflowLines
s.scrollbackFlushedLines = overflowLines
return strings.Join(lines[start:end], "\n")
}
// GetRenderedContent returns the rendered assistant message from the accumulated
@@ -269,6 +347,10 @@ func (s *StreamComponent) Reset() {
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
//
// Lines already pushed to the terminal scrollback buffer via ConsumeOverflow
// are skipped so that callers do not re-emit content that is already visible
// in the terminal's real scrollback.
func (s *StreamComponent) GetRenderedContent() string {
// Commit any pending chunks so the final output is complete.
s.commitPending()
@@ -282,20 +364,35 @@ func (s *StreamComponent) GetRenderedContent() string {
text := s.streamContent.String()
if text != "" {
sections = append(sections, s.renderStreamingText(text))
rendered := s.renderStreamingText(text)
sections = append(sections, rendered)
}
if len(sections) == 0 {
return ""
}
return strings.Join(sections, "\n")
fullContent := strings.Join(sections, "\n")
// Skip lines already emitted to the terminal scrollback via ConsumeOverflow
// so the caller doesn't re-print content that is already there.
if s.scrollbackFlushedLines > 0 {
lines := strings.Split(fullContent, "\n")
if s.scrollbackFlushedLines >= len(lines) {
return "" // everything already in scrollback
}
return strings.Join(lines[s.scrollbackFlushedLines:], "\n")
}
return fullContent
}
// commitPending moves any pending chunks to the committed content builders.
// Called before reading content for scrollback output or on flush tick.
func (s *StreamComponent) commitPending() {
if s.pendingStream.Len() > 0 {
s.streamContent.WriteString(s.pendingStream.String())
// Strip ... tags that some models wrap reasoning in
cleanedText := thinkTagRegex.ReplaceAllString(s.pendingStream.String(), "")
s.streamContent.WriteString(cleanedText)
s.pendingStream.Reset()
s.renderDirty = true
}
@@ -322,8 +419,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.messageRenderer.SetWidth(s.width)
s.compactRenderer.SetWidth(s.width)
if s.renderer != nil {
s.renderer.SetWidth(s.width)
}
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
@@ -359,6 +457,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case streamFlushTickMsg:
if msg.generation != s.flushGeneration {
break
}
s.flushPending = false
s.commitPending()
@@ -373,7 +474,7 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
return s, streamFlushTickCmd(s.flushGeneration)
}
case app.StreamChunkEvent:
@@ -385,17 +486,66 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
s.reasoningDuration = time.Since(s.reasoningStartTime)
}
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
// Handle models that wrap reasoning in tags (Qwen, DeepSeek)
// Filter out all content between and tags
content := msg.Content
// Check for opening tag
if strings.Contains(content, thinkTagOpen) {
parts := strings.SplitN(content, thinkTagOpen, 2)
// Content before the tag can be written
if !s.inThinkTag && parts[0] != "" {
s.pendingStream.WriteString(parts[0])
}
s.inThinkTag = true
// Content after the opening tag is reasoning - don't write it
if len(parts) > 1 && parts[1] != "" {
// Check if the same chunk contains the closing tag
if strings.Contains(parts[1], thinkTagClose) {
innerParts := strings.SplitN(parts[1], thinkTagClose, 2)
s.inThinkTag = false
// Content after closing tag can be written
if len(innerParts) > 1 && innerParts[1] != "" {
s.pendingStream.WriteString(innerParts[1])
}
}
}
} else if strings.Contains(content, thinkTagClose) {
// Closing tag found
parts := strings.SplitN(content, thinkTagClose, 2)
s.inThinkTag = false
// Content after closing tag can be written
if len(parts) > 1 && parts[1] != "" {
s.pendingStream.WriteString(parts[1])
}
} else if !s.inThinkTag {
// Normal content, not inside think tags
s.pendingStream.WriteString(content)
}
// else: inside think tag, don't write this content
if !s.flushPending && s.pendingStream.Len() > 0 {
s.flushPending = true
return s, streamFlushTickCmd()
return s, streamFlushTickCmd(s.flushGeneration)
}
case app.ToolExecutionEvent:
toolID := msg.ToolCallID
if toolID == "" {
// Defensive fallback for older/third-party emitters that may omit
// ToolCallID. Best-effort only: same-name+args concurrent calls can
// still collide without a stable ID.
toolID = fmt.Sprintf("%s|%s", msg.ToolName, msg.ToolArgs)
}
if msg.IsStarting {
// Add tool to active list for parallel execution display.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = append(s.activeTools, toolDisplay)
if s.activeTools == nil {
s.activeTools = make(map[string]string)
}
if _, exists := s.activeTools[toolID]; !exists {
s.activeToolOrder = append(s.activeToolOrder, toolID)
}
s.activeTools[toolID] = formatToolExecutionMessage(msg.ToolName)
s.spinnerFrame = 0
if !s.spinning {
s.phase = streamPhaseActive
@@ -404,9 +554,10 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
} else {
// Tool finished — remove from active list but keep spinning if others remain.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
if s.activeTools != nil {
delete(s.activeTools, toolID)
}
s.activeToolOrder = removeToolID(s.activeToolOrder, toolID)
}
}
@@ -415,7 +566,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model. Renders the current stream region content.
func (s *StreamComponent) View() tea.View {
return tea.NewView(s.render())
fullContent := s.render()
visibleContent := s.viewContent(fullContent)
return tea.NewView(visibleContent)
}
// --------------------------------------------------------------------------
@@ -458,54 +611,51 @@ func (s *StreamComponent) render() string {
content := strings.Join(sections, "\n")
// Clamp to height if constrained: keep the last h lines so the most
// recent output is always visible.
if s.height > 0 && content != "" {
lines := strings.Split(content, "\n")
if len(lines) > s.height {
lines = lines[len(lines)-s.height:]
content = strings.Join(lines, "\n")
}
}
// Cache FULL content without height clamping.
// Height clamping is applied in View() for display only.
s.renderCache = content
s.renderDirty = false
return content
}
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
// box. When collapsed, shows the last 10 lines with a truncation hint. When
// viewContent returns the visible portion of content based on height constraint.
// This is called by View() to get the slice that fits in the terminal.
func (s *StreamComponent) viewContent(fullContent string) string {
if s.height > 0 && fullContent != "" {
lines := strings.Split(fullContent, "\n")
if len(lines) > s.height {
// Keep only the last h lines so the most recent output is visible.
lines = lines[len(lines)-s.height:]
return strings.Join(lines, "\n")
}
}
return fullContent
}
// renderReasoningBlock renders the reasoning/thinking content using blockquote.
// When collapsed, shows the last 10 lines with a truncation hint. When
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
theme := GetTheme()
maxWidth := max(s.width-4, 20)
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
contentStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.MutedBorder).
Italic(true)
var parts []string
// When collapsed and content exceeds 10 lines, show only the last 10
// with a truncation hint (matching iteratr's thinking block pattern).
// with a truncation hint.
const maxCollapsedLines = 10
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
hidden := len(lines) - maxCollapsedLines
hintStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Background(theme.MutedBorder).
Italic(true)
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
parts = append(parts, s.ty.Italic(fmt.Sprintf("... (%d lines hidden)", hidden)))
lines = lines[len(lines)-maxCollapsedLines:]
}
// Render reasoning text.
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
// Main content using Italic with Muted color for visual distinction.
content := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
theme := GetTheme()
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
parts = append(parts, mutedStyle.Render(s.ty.Italic(content)))
// Duration footer.
// Duration footer with VeryMuted label and Accent duration.
var duration time.Duration
if s.reasoningDuration > 0 {
duration = s.reasoningDuration
@@ -519,21 +669,21 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Background(theme.MutedBorder).Render("Thought for ") +
lipgloss.NewStyle().Foreground(theme.Info).Background(theme.MutedBorder).Render(durationStr)
parts = append(parts, footer)
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationStyled := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
parts = append(parts, label+durationStyled)
}
innerContent := strings.Join(parts, "\n")
// Wrap in box with surface background for visual distinction.
boxStyle := lipgloss.NewStyle().
Background(theme.MutedBorder). // Surface0 (#313244)
PaddingLeft(1).
Width(maxWidth + 2).
MarginBottom(1)
return boxStyle.Render(innerContent)
// Concatenate parts with newline between blockquote and footer
var result string
if len(parts) == 1 {
result = parts[0]
} else if len(parts) == 2 {
result = parts[0] + "\n" + parts[1]
} else {
result = strings.Join(parts, "\n")
}
return styleMarginBottom1.Render(result)
}
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
@@ -559,7 +709,8 @@ func (s *StreamComponent) SpinnerView() string {
return ""
}
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
if len(s.activeTools) == 0 {
tools := s.activeToolDisplays()
if len(tools) == 0 {
return " " + frame
}
theme := GetTheme()
@@ -569,10 +720,10 @@ func (s *StreamComponent) SpinnerView() string {
// Format active tools list
var toolsMsg string
if len(s.activeTools) == 1 {
toolsMsg = s.activeTools[0]
if len(tools) == 1 {
toolsMsg = tools[0]
} else {
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
toolsMsg = "Running: " + strings.Join(tools, ", ")
}
return " " + frame + " " + msgStyle.Render(toolsMsg)
}
@@ -584,30 +735,43 @@ func (s *StreamComponent) renderStreamingText(text string) string {
if ts.IsZero() {
ts = time.Now()
}
if s.compactMode {
msg := s.compactRenderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
if s.renderer == nil {
return text
}
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
msg := s.renderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
}
// removeFromSlice removes the first occurrence of a string from a slice.
func removeFromSlice(slice []string, s string) []string {
for i, v := range slice {
if v == s {
return append(slice[:i], slice[i+1:]...)
func (s *StreamComponent) activeToolDisplays() []string {
if len(s.activeTools) == 0 {
return nil
}
out := make([]string, 0, len(s.activeToolOrder))
for _, id := range s.activeToolOrder {
if display, ok := s.activeTools[id]; ok {
out = append(out, display)
}
}
return slice
return out
}
// removeToolID removes the first occurrence of a tool ID from a slice.
func removeToolID(ids []string, id string) []string {
for i, v := range ids {
if v == id {
return append(ids[:i], ids[i+1:]...)
}
}
return ids
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
func formatToolExecutionMessage(toolName, toolArgs string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
func formatToolExecutionMessage(toolName string) string {
return toolName
}
// UpdateTheme refreshes the component's typography instance with colors from
// the current theme. This is called when the user changes themes via /theme.
func (s *StreamComponent) UpdateTheme() {
s.ty = createTypography(GetTheme())
}
+75 -248
View File
@@ -1,19 +1,11 @@
package ui
import (
"fmt"
"image/color"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/indaco/herald"
heraldmd "github.com/indaco/herald-md"
)
// uintPtr returns a pointer to u. Used by ansi.StyleConfig fields.
//
//go:fix inline
func uintPtr(u uint) *uint { return new(u) }
// BaseStyle returns a new, empty lipgloss style that can be customized with
// additional styling methods. This serves as the foundation for building more
// complex styled components.
@@ -21,248 +13,83 @@ func BaseStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
// colorHex converts a color.Color to a hex string suitable for ansi.StyleConfig.
func colorHex(c color.Color) string {
r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
// markdownTypographyCache holds the last-created Typography instance for
// herald-md rendering. It is cached to avoid re-initialization on every
// streaming flush tick. The cache is invalidated by SetTheme when the
// active theme changes.
// This is only accessed from BubbleTea's single-threaded Update/View cycle,
// so no mutex is required.
var markdownTypographyCache *herald.Typography
// colorHexPtr returns a pointer to the hex string of a color.Color.
func colorHexPtr(c color.Color) *string {
s := colorHex(c)
return &s
}
// GetMarkdownRenderer creates and returns a configured glamour.TermRenderer for
// rendering markdown content with syntax highlighting and proper formatting. The
// renderer is customized with our theme colors and adapted to the specified width.
func GetMarkdownRenderer(width int) *glamour.TermRenderer {
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(generateMarkdownStyleConfig()),
glamour.WithWordWrap(width),
)
return r
}
// generateMarkdownStyleConfig creates an ansi.StyleConfig from the active theme.
func generateMarkdownStyleConfig() ansi.StyleConfig {
md := GetTheme().Markdown
text := colorHexPtr(md.Text)
muted := colorHexPtr(md.Muted)
heading := colorHexPtr(md.Heading)
emph := colorHexPtr(md.Emph)
strong := colorHexPtr(md.Strong)
link := colorHexPtr(md.Link)
code := colorHexPtr(md.Code)
errClr := colorHexPtr(md.Error)
keyword := colorHexPtr(md.Keyword)
str := colorHexPtr(md.String)
number := colorHexPtr(md.Number)
comment := colorHexPtr(md.Comment)
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "",
BlockSuffix: "",
Color: text,
},
Margin: uintPtr(0),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: muted,
Italic: new(true),
Prefix: "┃ ",
},
Indent: uintPtr(1),
},
List: ansi.StyleList{
LevelIndent: 0,
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: text,
},
},
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: heading,
Bold: new(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "# ",
Color: heading,
Bold: new(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: heading,
Bold: new(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: heading,
Bold: new(true),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: heading,
Bold: new(true),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: heading,
Bold: new(true),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: heading,
Bold: new(true),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: new(true),
Color: muted,
},
Emph: ansi.StylePrimitive{
Color: emph,
Italic: new(true),
},
Strong: ansi.StylePrimitive{
Bold: new(true),
Color: strong,
},
HorizontalRule: ansi.StylePrimitive{
Color: muted,
Format: "\n─────────────────────────────────────────\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
Color: text,
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
Color: text,
},
Task: ansi.StyleTask{
StylePrimitive: ansi.StylePrimitive{},
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: link,
Underline: new(true),
},
LinkText: ansi.StylePrimitive{
Color: link,
Bold: new(true),
},
Image: ansi.StylePrimitive{
Color: link,
Underline: new(true),
Format: "🖼 {{.text}}",
},
ImageText: ansi.StylePrimitive{
Color: link,
Format: "{{.text}}",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: code,
Prefix: "",
Suffix: "",
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "",
Color: code,
},
Margin: uintPtr(0),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{Color: text},
Error: ansi.StylePrimitive{Color: errClr},
Comment: ansi.StylePrimitive{Color: comment},
CommentPreproc: ansi.StylePrimitive{Color: keyword},
Keyword: ansi.StylePrimitive{Color: keyword},
KeywordReserved: ansi.StylePrimitive{Color: keyword},
KeywordNamespace: ansi.StylePrimitive{Color: keyword},
KeywordType: ansi.StylePrimitive{Color: keyword},
Operator: ansi.StylePrimitive{Color: text},
Punctuation: ansi.StylePrimitive{Color: text},
Name: ansi.StylePrimitive{Color: text},
NameBuiltin: ansi.StylePrimitive{Color: text},
NameTag: ansi.StylePrimitive{Color: keyword},
NameAttribute: ansi.StylePrimitive{Color: text},
NameClass: ansi.StylePrimitive{Color: keyword},
NameConstant: ansi.StylePrimitive{Color: text},
NameDecorator: ansi.StylePrimitive{Color: text},
NameFunction: ansi.StylePrimitive{Color: text},
LiteralNumber: ansi.StylePrimitive{Color: number},
LiteralString: ansi.StylePrimitive{Color: str},
LiteralStringEscape: ansi.StylePrimitive{
Color: keyword,
},
GenericDeleted: ansi.StylePrimitive{Color: errClr},
GenericEmph: ansi.StylePrimitive{
Color: emph,
Italic: new(true),
},
GenericInserted: ansi.StylePrimitive{Color: str},
GenericStrong: ansi.StylePrimitive{
Color: strong,
Bold: new(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: heading,
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
},
},
CenterSeparator: new("┼"),
ColumnSeparator: new("│"),
RowSeparator: new("─"),
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n ",
Color: link,
},
Text: ansi.StylePrimitive{
Color: text,
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: text,
},
},
// GetMarkdownTypography returns a herald.Typography configured with our
// active theme colors. The typography is cached and only rebuilt when
// the theme changes via SetTheme.
func GetMarkdownTypography() *herald.Typography {
if markdownTypographyCache != nil {
return markdownTypographyCache
}
theme := GetTheme()
md := theme.Markdown
// Build herald theme from our theme colors
hty := herald.Theme{
// Headings - use heading color
H1: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H2: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H3: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H4: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H5: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H6: lipgloss.NewStyle().Foreground(md.Muted).Bold(true),
// Text blocks
Paragraph: lipgloss.NewStyle().Foreground(md.Text),
Blockquote: lipgloss.NewStyle().Foreground(md.Muted).Italic(true),
CodeInline: lipgloss.NewStyle().Foreground(md.Code),
CodeBlock: lipgloss.NewStyle().Foreground(md.Code),
HR: lipgloss.NewStyle().Foreground(md.Muted),
// Lists
ListBullet: lipgloss.NewStyle().Foreground(md.Text),
ListItem: lipgloss.NewStyle().Foreground(md.Text),
// Inline styles
Bold: lipgloss.NewStyle().Foreground(md.Strong).Bold(true),
Italic: lipgloss.NewStyle().Foreground(md.Emph).Italic(true),
Strikethrough: lipgloss.NewStyle().Foreground(md.Muted).Strikethrough(true),
Link: lipgloss.NewStyle().Foreground(md.Link).Underline(true),
// Definition lists
DT: lipgloss.NewStyle().Foreground(md.Text).Bold(true),
DD: lipgloss.NewStyle().Foreground(md.Muted),
// Key-value
KVKey: lipgloss.NewStyle().Foreground(md.Text).Bold(true),
KVValue: lipgloss.NewStyle().Foreground(md.Text),
// Badges/Tags - use semantic colors
Badge: lipgloss.NewStyle().Foreground(md.Text).Bold(true),
SuccessBadge: lipgloss.NewStyle().Foreground(theme.Success).Bold(true),
WarningBadge: lipgloss.NewStyle().Foreground(theme.Warning).Bold(true),
ErrorBadge: lipgloss.NewStyle().Foreground(theme.Error).Bold(true),
InfoBadge: lipgloss.NewStyle().Foreground(theme.Info).Bold(true),
// Heading decorations
H1UnderlineChar: "═",
H2UnderlineChar: "─",
H3UnderlineChar: "·",
}
ty := herald.New(herald.WithTheme(hty))
markdownTypographyCache = ty
return ty
}
// toMarkdown renders markdown content using glamour.
// toMarkdown renders markdown content using herald-md.
// The width parameter is currently unused as herald handles wrapping
// based on terminal width internally.
func toMarkdown(content string, width int) string {
r := GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
ty := GetMarkdownTypography()
rendered := heraldmd.Render(ty, []byte(content))
return rendered
}
+10 -3
View File
@@ -129,13 +129,20 @@ type presetColors struct {
}
func makeTheme(p presetColors) Theme {
ac := func(pair [2]string) color.Color { return AdaptiveColor(pair[0], pair[1]) }
def := DefaultTheme()
acOr := func(pair [2]string, fb color.Color) color.Color {
ac := func(pair [2]string) color.Color {
c := AdaptiveColor(pair[0], pair[1])
if pair[0] == "" && pair[1] == "" {
return nil
}
return c
}
acOr := func(pair [2]string, fb color.Color) color.Color {
c := ac(pair)
if c == nil {
return fb
}
return ac(pair)
return c
}
t := Theme{
Primary: ac(p.primary),
+167 -144
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -15,6 +16,7 @@ import (
"github.com/alecthomas/chroma/v2/styles"
udiff "github.com/aymanbagabas/go-udiff"
xansi "github.com/charmbracelet/x/ansi"
"github.com/indaco/herald"
)
// Maximum visible lines per tool type before truncation.
@@ -26,6 +28,14 @@ const (
maxLsLines = 20 // lines for Ls directory listings
)
// isShellTool reports if the tool name matches a shell-like tool (bash, grep, find, or
// tools with "shell"/"command" in the name). Used by both renderToolBody and
// renderToolBodyCompact to avoid code duplication.
func isShellTool(toolName string) bool {
return toolName == "bash" || toolName == "grep" || toolName == "find" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command")
}
// renderToolBody dispatches to tool-specific body renderers based on tool name.
// Returns the styled body string, or empty string to fall back to default rendering.
func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
@@ -46,12 +56,11 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderWriteBody(toolArgs, toolResult, width); body != "" {
return body
}
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
case isShellTool(toolName):
if body := renderBashBody(toolResult, width); body != "" {
return body
}
case toolName == "spawn_subagent":
case toolName == "subagent":
if body := renderSubagentBody(toolResult, width); body != "" {
return body
}
@@ -64,21 +73,44 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
// ---------------------------------------------------------------------------
// renderEditBody renders a side-by-side diff from old_text/new_text in toolArgs.
// Supports both single-edit mode and multi-edit mode (edits array).
func renderEditBody(toolArgs, toolResult string, width int) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
// Try to extract the starting line number from the unified diff in the result
startLine := extractDiffStartLine(toolResult)
// Check for multi-edit mode (edits array)
if editsArr, ok := args["edits"].([]any); ok && len(editsArr) > 0 {
var results []string
for _, edit := range editsArr {
if e, ok := edit.(map[string]any); ok {
oldText, _ := e["old_text"].(string)
newText, _ := e["new_text"].(string)
if oldText != "" || newText != "" {
diff := renderDiffBlock(oldText, newText, startLine, width)
if diff != "" {
results = append(results, diff)
}
}
}
}
if len(results) > 0 {
return strings.Join(results, "\n")
}
return ""
}
// Single-edit mode (legacy)
oldText, _ := args["old_text"].(string)
newText, _ := args["new_text"].(string)
if oldText == "" && newText == "" {
return ""
}
// Try to extract the starting line number from the unified diff in the result
startLine := extractDiffStartLine(toolResult)
return renderDiffBlock(oldText, newText, startLine, width)
}
@@ -221,7 +253,7 @@ func renderDiffBlock(before, after string, startLine int, width int) string {
gutterWidth := max(len(fmt.Sprintf("%d", maxLineNum)), 3)
contentWidth := max(panelWidth-gutterWidth-4, 10) // gutter + " - " or " + "
theme := getTheme()
theme := GetTheme()
// Styles for each cell type
gutterInsert := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.DiffInsertBg)
@@ -326,7 +358,7 @@ func renderLsBody(toolResult string, width int) string {
const indent = " "
codeWidth := max(width-len(indent), 20)
theme := getTheme()
theme := GetTheme()
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
var result []string
@@ -351,137 +383,106 @@ func renderLsBody(toolResult string, width int) string {
// Read tool — code block with line numbers + syntax highlighting
// ---------------------------------------------------------------------------
// renderReadBody renders Read tool output with styled line numbers and optional
// syntax highlighting based on file extension.
// renderReadBody renders Read tool output using herald.CodeBlock with line numbers
// and syntax highlighting. Uses WithCodeLineNumberOffset to show correct offsets
// based on the Read tool's offset parameter.
func renderReadBody(toolArgs, toolResult string, width int) string {
if strings.TrimSpace(toolResult) == "" {
return ""
}
// Extract file path for syntax highlighting
// Extract file path and offset from tool args
var fileName string
var offset = 1
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err == nil {
if p, ok := args["path"].(string); ok {
fileName = p
}
if o, ok := args["offset"].(float64); ok {
offset = int(o)
}
}
return renderCodeBlock(toolResult, fileName, width)
}
// codeLine holds a parsed line with optional line number.
type codeLine struct {
lineNum string
code string
}
// renderCodeBlock renders content with a styled gutter (line numbers) and
// optional syntax highlighting.
func renderCodeBlock(content, fileName string, width int) string {
rawLines := strings.Split(content, "\n")
// Parse lines: detect "N: content" format from Read tool
var parsed []codeLine
maxNumWidth := 0
var codeOnly []string
// Parse lines to extract pure code content (removing "N: " prefixes)
rawLines := strings.Split(toolResult, "\n")
var codeLines []string
var footerLines []string
var codeHiddenCount int
for _, line := range rawLines {
// Detect "N: content" format from Read tool
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
numPart := line[:idx]
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
parsed = append(parsed, codeLine{lineNum: numPart, code: line[idx+2:]})
if len(numPart) > maxNumWidth {
maxNumWidth = len(numPart)
}
codeOnly = append(codeOnly, line[idx+2:])
codeLines = append(codeLines, line[idx+2:])
continue
}
}
// No line number — treat as metadata/footer
parsed = append(parsed, codeLine{code: line})
codeOnly = append(codeOnly, line)
// No line number — treat as footer/metadata (e.g., truncation notice)
footerLines = append(footerLines, line)
}
if len(parsed) == 0 {
return ""
// Apply maxCodeLines truncation
totalCodeLines := len(codeLines)
if totalCodeLines > maxCodeLines {
codeHiddenCount = totalCodeLines - maxCodeLines
codeLines = codeLines[:maxCodeLines]
}
// Truncate to maxCodeLines visible lines (preserve footer/metadata lines)
var codeHiddenCount int
totalParsed := len(parsed)
if totalParsed > maxCodeLines {
// Check if last line is a footer (no line number) — keep it
var footerLines []codeLine
for totalParsed > 0 && parsed[totalParsed-1].lineNum == "" {
footerLines = append([]codeLine{parsed[totalParsed-1]}, footerLines...)
totalParsed--
}
if totalParsed > maxCodeLines {
codeHiddenCount = totalParsed - maxCodeLines
parsed = append(parsed[:maxCodeLines], footerLines...)
codeOnly = codeOnly[:maxCodeLines]
for _, fl := range footerLines {
codeOnly = append(codeOnly, fl.code)
}
} else {
// Restore — footer trimming was enough
parsed = parsed[:totalParsed]
parsed = append(parsed, footerLines...)
// Build language hint from file extension
lang := ""
if fileName != "" {
// Extract extension without the dot
if ext := strings.TrimPrefix(filepath.Ext(fileName), "."); ext != "" {
lang = ext
}
}
// Syntax highlight the code portion
highlighted := syntaxHighlight(strings.Join(codeOnly, "\n"), fileName)
highlightedLines := strings.Split(highlighted, "\n")
// Layout
const codeIndent = " "
gutterWidth := max(maxNumWidth+2, 5)
codeWidth := max(width-gutterWidth-len(codeIndent), 20)
theme := getTheme()
gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1)
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
var result []string
for i, p := range parsed {
// If this line has no line number, it's a metadata/footer line (e.g. truncation notice).
if p.lineNum == "" {
// Render footer lines with code background but no gutter
truncatedFooter := truncateLine(p.code, codeWidth-1) // account for PaddingLeft(1)
footer := codeStyle.Width(codeWidth).Render(truncatedFooter)
emptyGutter := gutterStyle.Width(gutterWidth).Render("")
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footer))
continue
}
gutter := gutterStyle.Width(gutterWidth).Render(p.lineNum)
var codePart string
if i < len(highlightedLines) {
codePart = highlightedLines[i]
} else {
codePart = p.code
}
// Truncate the (possibly ANSI-highlighted) line to fit within
// the code column, preventing lipgloss from wrapping it.
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
styledCode := codeStyle.Width(codeWidth).Render(codePart)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
// Create typography with line number offset and custom formatter
// Match Write tool: GutterBg for line numbers, CodeBg for content
codeContent := strings.Join(codeLines, "\n")
theme := GetTheme()
hty := herald.Theme{
CodeBlock: lipgloss.NewStyle().
Background(theme.CodeBg).
PaddingLeft(1),
CodeLineNumber: lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.GutterBg),
}
ty := herald.New(
herald.WithTheme(hty),
herald.WithCodeLineNumbers(true),
herald.WithCodeLineNumberOffset(offset),
herald.WithCodeFormatter(func(code, _ string) string {
// Use our syntax highlighter with the filename for lexer detection
return syntaxHighlight(code, fileName)
}),
)
// Truncation hint
// Render the code block
result := ty.CodeBlock(codeContent, lang)
// Add truncation hint if needed
if codeHiddenCount > 0 {
hint := fmt.Sprintf("...(%d more lines)", codeHiddenCount)
emptyGutter := gutterStyle.Width(gutterWidth).Render("")
hintContent := codeStyle.Width(codeWidth).
Foreground(theme.Muted).Italic(true).Render(hint)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, hintContent))
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Italic(true).Render(hint)
}
return strings.Join(result, "\n")
// Add any footer lines
if len(footerLines) > 0 {
footer := strings.Join(footerLines, "\n")
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Render(footer)
}
// Indent entire block to match Write/Edit tools (2 spaces)
const blockIndent = " "
lines := strings.Split(result, "\n")
for i, line := range lines {
lines[i] = blockIndent + line
}
return strings.Join(lines, "\n")
}
// ---------------------------------------------------------------------------
@@ -535,7 +536,7 @@ func renderWriteBlock(content, fileName string, width int) string {
gutterWidth := numDigits + 2
codeWidth := max(width-gutterWidth-len(codeIndent), 20)
theme := getTheme()
theme := GetTheme()
gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1)
writeStyle := lipgloss.NewStyle().Background(theme.WriteBg).PaddingLeft(1)
@@ -587,7 +588,7 @@ func renderBashBody(toolResult string, width int) string {
return ""
}
theme := getTheme()
theme := GetTheme()
outputStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
stderrStyle := lipgloss.NewStyle().Foreground(theme.Error).Background(theme.CodeBg).PaddingLeft(1)
@@ -604,7 +605,6 @@ func renderBashBody(toolResult string, width int) string {
const lineIndent = " "
// Truncate individual lines to the available width so they never wrap.
// This mirrors Crush's approach: truncate, don't wrap.
lineWidth := max(width-len(lineIndent), 20)
// Account for PaddingLeft(1) on the output/stderr styles
maxLineChars := lineWidth - 1
@@ -754,10 +754,9 @@ func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) str
return renderReadCompact(toolResult)
case toolName == "write":
return renderWriteCompact(toolArgs)
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
case isShellTool(toolName):
return renderBashCompact(toolResult, width)
case toolName == "spawn_subagent":
case toolName == "subagent":
return renderSubagentCompact(toolResult)
}
return ""
@@ -786,7 +785,7 @@ func renderReadCompact(toolResult string) string {
return ""
}
theme := getTheme()
theme := GetTheme()
summary := fmt.Sprintf("%d lines", codeLines)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
@@ -807,7 +806,7 @@ func renderEditCompact(toolArgs, toolResult string) string {
oldCount := len(strings.Split(oldText, "\n"))
newCount := len(strings.Split(newText, "\n"))
theme := getTheme()
theme := GetTheme()
var summary string
if oldCount == newCount {
summary = fmt.Sprintf("%d lines modified", oldCount)
@@ -830,7 +829,7 @@ func renderWriteCompact(toolArgs string) string {
}
count := len(strings.Split(content, "\n"))
theme := getTheme()
theme := GetTheme()
summary := fmt.Sprintf("%d lines written", count)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
@@ -843,7 +842,7 @@ func renderLsCompact(toolResult string) string {
}
entries := strings.Split(content, "\n")
theme := getTheme()
theme := GetTheme()
summary := fmt.Sprintf("%d entries", len(entries))
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
@@ -881,14 +880,14 @@ func renderBashCompact(toolResult string, width int) string {
if len(outputLines) == 0 {
if exitCode != "" {
theme := getTheme()
theme := GetTheme()
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return ""
}
const maxLines = 3
theme := getTheme()
theme := GetTheme()
display := outputLines
if len(display) > maxLines {
@@ -916,10 +915,10 @@ func renderBashCompact(toolResult string, width int) string {
// Subagent tool renderers — show only summary, not full output
// ---------------------------------------------------------------------------
// renderSubagentBody renders a clean summary of subagent results.
// Extracts timing/token info and shows only a brief summary instead of raw output.
// renderSubagentBody renders a clean summary of subagent results with bash-style
// background styling for consistency with other tools.
func renderSubagentBody(toolResult string, width int) string {
theme := getTheme()
theme := GetTheme()
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
@@ -937,9 +936,19 @@ func renderSubagentBody(toolResult string, width int) string {
// First line is always the status summary
statusLine := lines[0]
// Build a clean summary
var summary strings.Builder
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
// Build content lines for display with bash-style background
outputStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
errorStyle := lipgloss.NewStyle().Foreground(theme.Error).Background(theme.CodeBg).PaddingLeft(1)
const lineIndent = " "
lineWidth := max(width-len(lineIndent), 20)
maxLineChars := lineWidth - 1 // account for PaddingLeft(1)
var contentLines []string
// Add status line
styledStatus := outputStyle.Width(lineWidth).Render(truncateLine(statusLine, maxLineChars))
contentLines = append(contentLines, lineIndent+styledStatus)
// For successful results, extract a brief preview of the actual result
if strings.Contains(statusLine, "successfully") {
@@ -947,25 +956,45 @@ func renderSubagentBody(toolResult string, width int) string {
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
resultContent = strings.TrimSpace(resultContent)
if resultContent != "" {
// Show first 3 meaningful lines as preview
preview := extractSubagentPreview(resultContent, 3, width-4)
if preview != "" {
summary.WriteString("\n\n")
summary.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(preview))
// Show first few meaningful lines as preview
previewLines := extractSubagentPreviewLines(resultContent, 5, maxLineChars)
if len(previewLines) > 0 {
// Add blank separator line
blankLine := outputStyle.Width(lineWidth).Render("")
contentLines = append(contentLines, lineIndent+blankLine)
for _, line := range previewLines {
styled := outputStyle.Width(lineWidth).Render(line)
contentLines = append(contentLines, lineIndent+styled)
}
}
}
}
} else {
// For failed results, show error info
if _, errorContent, found := strings.Cut(result, "Error:\n"); found {
errorContent = strings.TrimSpace(errorContent)
if errorContent != "" {
previewLines := extractSubagentPreviewLines(errorContent, 3, maxLineChars)
if len(previewLines) > 0 {
blankLine := outputStyle.Width(lineWidth).Render("")
contentLines = append(contentLines, lineIndent+blankLine)
for _, line := range previewLines {
styled := errorStyle.Width(lineWidth).Render(line)
contentLines = append(contentLines, lineIndent+styled)
}
}
}
}
}
return summary.String()
return strings.Join(contentLines, "\n")
}
// extractSubagentPreview extracts the first N non-empty lines from content,
// truncating each line to maxWidth.
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
// extractSubagentPreviewLines extracts the first N non-empty lines from content,
// truncating each line to maxWidth. Returns as a slice of strings.
func extractSubagentPreviewLines(content string, maxLines, maxWidth int) []string {
lines := strings.Split(content, "\n")
var preview []string
@@ -984,12 +1013,6 @@ func extractSubagentPreview(content string, maxLines, maxWidth int) string {
}
}
if len(preview) == 0 {
return ""
}
result := strings.Join(preview, "\n")
// Count remaining lines for "more" indicator
totalLines := 0
for _, line := range lines {
@@ -998,10 +1021,10 @@ func extractSubagentPreview(content string, maxLines, maxWidth int) string {
}
}
if totalLines > maxLines {
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
preview = append(preview, fmt.Sprintf("...(%d more lines)", totalLines-maxLines))
}
return result
return preview
}
// renderSubagentCompact returns a brief one-line summary for subagent results.
@@ -1011,7 +1034,7 @@ func renderSubagentCompact(toolResult string) string {
return ""
}
theme := getTheme()
theme := GetTheme()
// Extract just the first line which contains the status
lines := strings.Split(result, "\n")
+24 -7
View File
@@ -134,13 +134,23 @@ func (ut *UsageTracker) EstimateAndUpdateUsage(inputText, outputText string) {
}
// SetContextTokens records the approximate current context window utilization.
// This should be set from the final API call's input + output tokens (i.e.
// FinalResponse.Usage) rather than the aggregate TotalUsage, because TotalUsage
// This should be set from FinalUsage.InputTokens, which already includes the
// full conversation history (system prompt + all previous messages). Do NOT
// add OutputTokens as that would double-count (output becomes input next turn).
// Use FinalResponse.Usage rather than aggregate TotalUsage, because TotalUsage
// sums across all tool-calling steps and overstates the actual window fill level.
func (ut *UsageTracker) SetContextTokens(tokens int) {
ut.mu.Lock()
defer ut.mu.Unlock()
ut.contextTokens = tokens
// Track the maximum context seen so far. In multi-step tool calls,
// FinalUsage.InputTokens may reflect only the last step's input, which
// can be smaller than previous steps. We want to show the largest context
// the model has processed in this session.
if tokens > ut.contextTokens {
ut.contextTokens = tokens
}
// If tokens < current, we keep the larger value (no-op)
// This prevents the display from dropping during multi-step tool calls.
}
// RenderUsageInfo generates a formatted string displaying current usage statistics
@@ -151,10 +161,6 @@ func (ut *UsageTracker) RenderUsageInfo() string {
ut.mu.RLock()
defer ut.mu.RUnlock()
if ut.sessionStats.RequestCount == 0 {
return ""
}
baseStyle := lipgloss.NewStyle()
// Display the current context window token count (from the last API call),
@@ -266,3 +272,14 @@ func (ut *UsageTracker) SetWidth(width int) {
defer ut.mu.Unlock()
ut.width = width
}
// UpdateModelInfo updates the model information and OAuth status when the model
// is switched mid-session. This ensures token costs and context limits are
// calculated correctly for the new model.
func (ut *UsageTracker) UpdateModelInfo(modelInfo *models.ModelInfo, provider string, isOAuth bool) {
ut.mu.Lock()
defer ut.mu.Unlock()
ut.modelInfo = modelInfo
ut.provider = provider
ut.isOAuth = isOAuth
}
+59
View File
@@ -67,3 +67,62 @@ func TestUsageTracker_RenderUsageInfo_OAuth(t *testing.T) {
t.Errorf("Expected regular rendered output to show actual cost, got: %s", regularRendered)
}
}
func TestUsageTracker_RenderUsageInfo_StartupState(t *testing.T) {
// Create a mock model info with costs and context limit
modelInfo := &models.ModelInfo{
ID: "claude-3-5-sonnet-20241022",
Name: "Claude 3.5 Sonnet v2",
Cost: models.Cost{
Input: 3.0,
Output: 15.0,
},
Limit: models.Limit{
Context: 200000,
Output: 8192,
},
}
// Test startup state (no requests made yet) - Regular API key
regularTracker := NewUsageTracker(modelInfo, "anthropic", 80, false)
rendered := stripAnsi(regularTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if rendered == "" {
t.Errorf("Expected non-empty output on startup, got empty string")
}
// Should show 0 tokens
if !strings.Contains(rendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup, got: %s", rendered)
}
// Should NOT show percentage when tokens are 0
if strings.Contains(rendered, "(%") {
t.Errorf("Expected no percentage on startup with 0 tokens, got: %s", rendered)
}
// Should show $0.0000 cost for regular API key
if !strings.Contains(rendered, "Cost: $0.0000") {
t.Errorf("Expected 'Cost: $0.0000' on startup, got: %s", rendered)
}
// Test startup state (no requests made yet) - OAuth
oauthTracker := NewUsageTracker(modelInfo, "anthropic", 80, true)
oauthRendered := stripAnsi(oauthTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if oauthRendered == "" {
t.Errorf("Expected non-empty output on startup for OAuth, got empty string")
}
// Should show 0 tokens for OAuth
if !strings.Contains(oauthRendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup for OAuth, got: %s", oauthRendered)
}
// Should show $0.00 cost for OAuth
if !strings.Contains(oauthRendered, "Cost: $0.00") {
t.Errorf("Expected 'Cost: $0.00' on startup for OAuth, got: %s", oauthRendered)
}
}
+1 -17
View File
@@ -52,7 +52,6 @@ type Harness struct {
t *testing.T
runner *extensions.Runner
context *MockContext
extPath string
}
// New creates a new test harness for the given test.
@@ -72,15 +71,9 @@ func New(t *testing.T) *Harness {
func (h *Harness) LoadFile(path string) *extensions.LoadedExtension {
h.t.Helper()
// Verify file exists
if _, err := os.Stat(path); err != nil {
h.t.Fatalf("extension file not found: %s: %v", path, err)
}
// Read extension source
src, err := os.ReadFile(path)
if err != nil {
h.t.Fatalf("failed to read extension file: %v", err)
h.t.Fatalf("failed to read extension file %s: %v", path, err)
}
return h.loadSource(string(src), path)
@@ -144,7 +137,6 @@ func (h *Harness) loadSource(src string, path string) *extensions.LoadedExtensio
// Create runner with the loaded extension
h.runner = extensions.NewRunner([]extensions.LoadedExtension{*ext})
h.extPath = path
// Wire the mock context
h.runner.SetContext(h.context.ToContext())
@@ -222,11 +214,3 @@ func (h *Harness) RegisteredCommands() []extensions.CommandDef {
}
return h.runner.RegisteredCommands()
}
// MustLoad is like LoadFile but fails the test immediately on error.
// It returns the harness for chaining.
func (h *Harness) MustLoad(path string) *Harness {
h.t.Helper()
h.LoadFile(path)
return h
}
-17
View File
@@ -59,29 +59,12 @@ type MockContext struct {
Overlays []extensions.OverlayConfig
}
// StatusBarEntry represents a recorded status bar entry
type StatusBarEntry struct {
Key string
Text string
Priority int
}
// NewMockContext creates a new mock context with default values.
func NewMockContext() *MockContext {
return &MockContext{
Prints: make([]string, 0),
PrintInfos: make([]string, 0),
PrintErrors: make([]string, 0),
PrintBlocks: make([]extensions.PrintBlockOpts, 0),
Messages: make([]string, 0),
CancelSends: make([]string, 0),
Widgets: make(map[string]extensions.WidgetConfig),
RemovedIDs: make([]string, 0),
StatusEntries: make(map[string]extensions.StatusBarEntry),
RemovedStatus: make([]string, 0),
EditorTexts: make([]string, 0),
Options: make(map[string]string),
Overlays: make([]extensions.OverlayConfig, 0),
Interactive: true,
SessionID: "test-session",
CWD: "/test",
+76 -41
View File
@@ -1,6 +1,6 @@
# KIT SDK
The KIT SDK allows you to use KIT programmatically from Go applications without spawning OS processes.
The KIT SDK (`pkg/kit`) lets you embed Kit's full agent capabilities — LLM interactions, tool execution, session management, streaming, hooks — into any Go application.
## Installation
@@ -17,26 +17,26 @@ import (
"context"
"fmt"
"log"
kit "github.com/mark3labs/kit/pkg/kit"
)
func main() {
ctx := context.Background()
// Create Kit instance with default configuration
host, err := kit.New(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer host.Close()
defer func() { _ = host.Close() }()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
if err != nil {
log.Fatal(err)
}
fmt.Println(response)
}
```
@@ -56,11 +56,23 @@ You can override specific settings:
```go
host, err := kit.New(ctx, &kit.Options{
Model: "ollama/llama3", // Override model
SystemPrompt: "You are a helpful bot", // Override system prompt
ConfigFile: "/path/to/config.yml", // Use specific config file
MaxSteps: 10, // Override max steps
Streaming: true, // Enable streaming
Quiet: true, // Suppress debug output
SystemPrompt: "You are a helpful bot", // Override system prompt
ConfigFile: "/path/to/config.yml", // Use specific config file
MaxSteps: 10, // Override max steps
Streaming: true, // Enable streaming
Quiet: true, // Suppress debug output
// Session options
SessionPath: "./session.jsonl", // Open specific session
Continue: true, // Resume most recent session
NoSession: true, // Ephemeral mode
// Tool options
Tools: []kit.Tool{kit.NewBashTool()}, // Replace default tool set
ExtraTools: []kit.Tool{myTool}, // Add alongside defaults
// Compaction
AutoCompact: true, // Auto-compact near context limit
})
```
@@ -71,22 +83,28 @@ host, err := kit.New(ctx, &kit.Options{
Monitor tool execution in real-time:
```go
response, err := host.PromptWithCallbacks(
unsub := host.OnToolCall(func(e kit.ToolCallEvent) {
fmt.Printf("Calling tool: %s\n", e.ToolName)
})
defer unsub()
unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
if e.IsError {
fmt.Printf("Tool %s failed: %s\n", e.ToolName, e.Result)
} else {
fmt.Printf("Tool %s succeeded\n", e.ToolName)
}
})
defer unsub2()
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk)
})
defer unsub3()
response, err := host.Prompt(
ctx,
"List files in the current directory",
func(name, args string) {
fmt.Printf("Calling tool: %s\n", name)
},
func(name, args, result string, isError bool) {
if isError {
fmt.Printf("Tool %s failed: %s\n", name, result)
} else {
fmt.Printf("Tool %s succeeded\n", name)
}
},
func(chunk string) {
fmt.Print(chunk) // Stream output
},
)
```
@@ -102,35 +120,52 @@ host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
// Response: "Your name is Alice"
// Save session
host.SaveSession("./session.json")
// Load session later
host.LoadSession("./session.json")
// Clear session
// Clear conversation history
host.ClearSession()
```
## Re-exported Types
The SDK re-exports types so you don't need direct internal imports:
```go
// Message types
kit.Message, kit.MessageRole, kit.ContentPart
kit.TextContent, kit.ReasoningContent, kit.ToolCall, kit.ToolResult, kit.Finish
kit.RoleUser, kit.RoleAssistant, kit.RoleTool, kit.RoleSystem
// LLM types (re-exported from the underlying LLM library)
kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart
// Conversion helpers
msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages
msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message
```
## API Reference
### Types
- `Kit` - Main SDK type
- `Options` - Configuration options
- `Message` - Conversation message
- `ToolCall` - Tool invocation details
- `Message` - Conversation message with typed content parts
- `Tool` - Agent tool interface
- `TurnResult` - Full result from a prompt including usage stats
### Methods
### Key Methods
- `New(ctx, opts)` - Create new Kit instance
- `Prompt(ctx, message)` - Send message and get response
- `PromptWithCallbacks(ctx, message, ...)` - Send message with progress callbacks
- `LoadSession(path)` - Load session from file
- `SaveSession(path)` - Save session to file
- `ClearSession()` - Clear conversation history
- `GetSessionManager()` - Get session manager for advanced usage
- `Prompt(ctx, message)` - Send message and get response string
- `PromptResult(ctx, message)` - Send message and get full TurnResult
- `PromptWithOptions(ctx, message, opts)` - Prompt with per-call options
- `Steer(ctx, instruction)` - System-level steering
- `FollowUp(ctx, text)` - Continue without new user input
- `SetModel(ctx, model)` - Switch model at runtime
- `GetModelString()` - Get current model string
- `GetModelInfo()` - Get model capabilities and limits
- `ClearSession()` - Clear conversation history
- `GetSessionPath()` - Get session file path
- `GetSessionID()` - Get session UUID
- `Close()` - Clean up resources
## Environment Variables
+38 -1
View File
@@ -1,6 +1,10 @@
package kit
import "github.com/mark3labs/kit/internal/auth"
import (
"os"
"github.com/mark3labs/kit/internal/auth"
)
// CredentialManager manages API keys and OAuth credentials.
type CredentialManager = auth.CredentialManager
@@ -9,6 +13,10 @@ type CredentialManager = auth.CredentialManager
// and API key authentication methods.
type AnthropicCredentials = auth.AnthropicCredentials
// OpenAICredentials holds OpenAI API credentials supporting both OAuth
// and API key authentication methods.
type OpenAICredentials = auth.OpenAICredentials
// CredentialStore holds all stored credentials for various providers.
type CredentialStore = auth.CredentialStore
@@ -42,3 +50,32 @@ func GetAnthropicAPIKey() string {
}
return key
}
// HasOpenAICredentials checks if valid OpenAI credentials are stored
// (either OAuth token or API key).
func HasOpenAICredentials() bool {
cm, err := auth.NewCredentialManager()
if err != nil {
return false
}
has, err := cm.HasOpenAICredentials()
if err != nil {
return false
}
return has
}
// GetOpenAIAPIKey resolves the OpenAI API key using the standard
// resolution order: stored credentials -> OPENAI_API_KEY env var.
// Returns an empty string if no key is found.
func GetOpenAIAPIKey() string {
cm, err := auth.NewCredentialManager()
if err == nil {
// Try to get valid access token (handles OAuth refresh)
if token, err := cm.GetValidOpenAIAccessToken(); err == nil && token != "" {
return token
}
}
// Fall back to environment variable
return os.Getenv("OPENAI_API_KEY")
}
+46 -42
View File
@@ -2,6 +2,7 @@ package kit
import (
"context"
"errors"
"fmt"
"charm.land/fantasy"
@@ -17,10 +18,14 @@ type ContextStats struct {
MessageCount int // Number of messages in the conversation
}
// defaultReserveTokens is the number of tokens to keep free in the context
// window as a safety margin during compaction checks.
const defaultReserveTokens = 16384
// EstimateContextTokens returns the estimated token count of the current
// conversation based on tree session messages.
func (m *Kit) EstimateContextTokens() int {
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
return compaction.EstimateMessageTokens(messages)
}
@@ -34,12 +39,12 @@ func (m *Kit) ShouldCompact() bool {
return false
}
reserveTokens := 16384
reserveTokens := defaultReserveTokens
if m.compactionOpts != nil && m.compactionOpts.ReserveTokens > 0 {
reserveTokens = m.compactionOpts.ReserveTokens
}
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
return compaction.ShouldCompact(messages, info.Limit.Context, reserveTokens)
}
@@ -52,7 +57,7 @@ func (m *Kit) ShouldCompact() bool {
// because it includes system prompts, tool definitions, and other overhead
// that the heuristic cannot account for.
func (m *Kit) GetContextStats() ContextStats {
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
// Prefer the real API-reported input token count when available.
m.lastInputTokensMu.RLock()
@@ -111,7 +116,7 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
}
}
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
if len(messages) < 2 {
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
}
@@ -131,7 +136,7 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
if reason == "" {
reason = "compaction cancelled by extension"
}
return nil, fmt.Errorf("%s", reason)
return nil, errors.New(reason)
}
// Extension provided a custom summary — use it directly.
if hookResult.Summary != "" {
@@ -166,27 +171,10 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
firstKeptEntryID = entryIDs[result.CutPoint]
}
if _, err := m.treeSession.AppendCompaction(
result.Summary,
firstKeptEntryID,
result.OriginalTokens,
result.CompactedTokens,
result.MessagesRemoved,
result.ReadFiles,
result.ModifiedFiles,
); err != nil {
return nil, fmt.Errorf("failed to persist compaction entry: %w", err)
if err := m.persistAndEmitCompaction(result.Summary, firstKeptEntryID, result.OriginalTokens, result.CompactedTokens, result.MessagesRemoved, result.ReadFiles, result.ModifiedFiles); err != nil {
return nil, err
}
m.events.emit(CompactionEvent{
Summary: result.Summary,
OriginalTokens: result.OriginalTokens,
CompactedTokens: result.CompactedTokens,
MessagesRemoved: result.MessagesRemoved,
ReadFiles: result.ReadFiles,
ModifiedFiles: result.ModifiedFiles,
})
return result, nil
}
@@ -218,17 +206,6 @@ func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message,
recentTokens := compaction.EstimateMessageTokens(messages[cutPoint:])
compactedTokens := summaryTokens + recentTokens
if _, err := m.treeSession.AppendCompaction(
summary,
firstKeptEntryID,
originalTokens,
compactedTokens,
cutPoint,
nil, nil, // no file tracking for custom summaries
); err != nil {
return nil, fmt.Errorf("failed to persist compaction entry: %w", err)
}
result := &CompactionResult{
Summary: summary,
OriginalTokens: originalTokens,
@@ -236,12 +213,39 @@ func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message,
MessagesRemoved: cutPoint,
}
m.events.emit(CompactionEvent{
Summary: result.Summary,
OriginalTokens: result.OriginalTokens,
CompactedTokens: result.CompactedTokens,
MessagesRemoved: result.MessagesRemoved,
})
if err := m.persistAndEmitCompaction(summary, firstKeptEntryID, originalTokens, compactedTokens, cutPoint, nil, nil); err != nil {
return nil, err
}
return result, nil
}
// persistAndEmitCompaction writes a CompactionEntry to the session tree and
// emits a CompactionEvent. It is the single implementation shared by
// compactInternal and applyCustomCompaction.
func (m *Kit) persistAndEmitCompaction(
summary, firstKeptEntryID string,
originalTokens, compactedTokens, messagesRemoved int,
readFiles, modifiedFiles []string,
) error {
if _, err := m.treeSession.AppendCompaction(
summary,
firstKeptEntryID,
originalTokens,
compactedTokens,
messagesRemoved,
readFiles,
modifiedFiles,
); err != nil {
return fmt.Errorf("failed to persist compaction entry: %w", err)
}
m.events.emit(CompactionEvent{
Summary: summary,
OriginalTokens: originalTokens,
CompactedTokens: compactedTokens,
MessagesRemoved: messagesRemoved,
ReadFiles: readFiles,
ModifiedFiles: modifiedFiles,
})
return nil
}
+11 -11
View File
@@ -12,6 +12,10 @@ import (
// defaultSystemPrompt is the built-in system prompt used when no custom
// prompt is configured. It describes the available core tools and provides
// usage guidelines.
//
// NOTE: Keep this in sync with the CLI default in cmd/root.go (search for
// defaultSystemPrompt or system-prompt flag default). Changes here should
// generally be reflected there, and vice versa.
const defaultSystemPrompt = `You are an expert coding assistant operating inside kit, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
@@ -78,20 +82,16 @@ func InitConfig(configFile string, debug bool) error {
viper.AddConfigPath(home)
configLoaded := false
configNames := []string{".kit"}
for _, name := range configNames {
viper.SetConfigName(name)
if err := viper.ReadInConfig(); err == nil {
configPath := viper.ConfigFileUsed()
if err := LoadConfigWithEnvSubstitution(configPath); err != nil {
if strings.Contains(err.Error(), "environment variable substitution failed") {
return fmt.Errorf("error reading config file '%s': %w", configPath, err)
}
continue
viper.SetConfigName(".kit")
if err := viper.ReadInConfig(); err == nil {
configPath := viper.ConfigFileUsed()
if err := LoadConfigWithEnvSubstitution(configPath); err != nil {
if strings.Contains(err.Error(), "environment variable substitution failed") {
return fmt.Errorf("error reading config file '%s': %w", configPath, err)
}
} else {
configLoaded = true
break
}
}
+43 -52
View File
@@ -41,6 +41,10 @@ const (
EventReasoningDelta EventType = "reasoning_delta"
// EventToolOutput fires when a tool produces streaming output chunks.
EventToolOutput EventType = "tool_output"
EventStepUsage EventType = "step_usage"
// EventSteerConsumed fires when one or more steering messages have been
// injected into the agent turn via PrepareStep.
EventSteerConsumed EventType = "steer_consumed"
)
// ---------------------------------------------------------------------------
@@ -66,20 +70,20 @@ const (
ToolKindEdit = "edit" // File modification (edit, write)
ToolKindRead = "read" // File reading (read, ls)
ToolKindSearch = "search" // Content/file search (grep, find)
ToolKindSubagent = "agent" // Subagent spawning (spawn_subagent)
ToolKindSubagent = "agent" // Subagent spawning (subagent)
)
// coreToolKinds maps built-in tool names to their kind. MCP and extension
// tools without an entry default to ToolKindExecute.
var coreToolKinds = map[string]string{
"bash": ToolKindExecute,
"edit": ToolKindEdit,
"write": ToolKindEdit,
"read": ToolKindRead,
"ls": ToolKindRead,
"grep": ToolKindSearch,
"find": ToolKindSearch,
"spawn_subagent": ToolKindSubagent,
"bash": ToolKindExecute,
"edit": ToolKindEdit,
"write": ToolKindEdit,
"read": ToolKindRead,
"ls": ToolKindRead,
"grep": ToolKindSearch,
"find": ToolKindSearch,
"subagent": ToolKindSubagent,
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
@@ -212,7 +216,7 @@ type ToolResultEvent struct {
// ToolResultMetadata carries structured data from tool executions.
type ToolResultMetadata struct {
FileDiffs []FileDiffInfo `json:"file_diffs,omitempty"` // Present for edit/write tools
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for spawn_subagent tool
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for subagent tool
}
// FileDiffInfo describes a file modification from an edit or write tool.
@@ -249,6 +253,19 @@ type ResponseEvent struct {
// EventType implements Event.
func (e ResponseEvent) EventType() EventType { return EventResponse }
// StepUsageEvent fires after each complete step in a multi-step agent turn,
// carrying the token usage for that specific step. This enables real-time
// cost tracking during long-running tool-calling conversations.
type StepUsageEvent struct {
InputTokens uint64
OutputTokens uint64
CacheReadTokens uint64
CacheWriteTokens uint64
}
// EventType implements Event.
func (e StepUsageEvent) EventType() EventType { return EventStepUsage }
// CompactionEvent fires after a successful compaction.
type CompactionEvent struct {
Summary string
@@ -262,6 +279,16 @@ type CompactionEvent struct {
// EventType implements Event.
func (e CompactionEvent) EventType() EventType { return EventCompaction }
// SteerConsumedEvent fires when one or more steering messages have been
// injected into the agent turn via PrepareStep. The Count indicates how
// many messages were consumed in this batch.
type SteerConsumedEvent struct {
Count int
}
// EventType implements Event.
func (e SteerConsumedEvent) EventType() EventType { return EventSteerConsumed }
// ---------------------------------------------------------------------------
// EventBus
// ---------------------------------------------------------------------------
@@ -389,68 +416,32 @@ func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
// Subagent event subscriptions
// ---------------------------------------------------------------------------
// subagentListenerSet holds per-tool-call listeners for subagent events.
type subagentListenerSet struct {
mu sync.RWMutex
listeners map[int]EventListener
nextID int
}
func newSubagentListenerSet() *subagentListenerSet {
return &subagentListenerSet{listeners: make(map[int]EventListener)}
}
func (s *subagentListenerSet) add(listener EventListener) func() {
s.mu.Lock()
id := s.nextID
s.nextID++
s.listeners[id] = listener
s.mu.Unlock()
return func() {
s.mu.Lock()
delete(s.listeners, id)
s.mu.Unlock()
}
}
func (s *subagentListenerSet) emit(event Event) {
s.mu.RLock()
snapshot := make([]EventListener, 0, len(s.listeners))
for _, l := range s.listeners {
snapshot = append(snapshot, l)
}
s.mu.RUnlock()
for _, l := range snapshot {
l(event)
}
}
// SubscribeSubagent registers a listener for real-time events from a subagent
// identified by its tool call ID. Returns an unsubscribe function.
//
// The listener receives the same event types as Subscribe() (ToolCallEvent,
// MessageUpdateEvent, etc.) but scoped to the child agent's activity. If the
// tool call ID doesn't correspond to an active or future spawn_subagent call,
// tool call ID doesn't correspond to an active or future subagent call,
// the listener simply never fires.
//
// Typical usage — register inside an OnToolCall handler:
//
// kit.OnToolCall(func(e kit.ToolCallEvent) {
// if e.ToolName == "spawn_subagent" {
// if e.ToolName == "subagent" {
// kit.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
// // real-time subagent events
// })
// }
// })
func (m *Kit) SubscribeSubagent(toolCallID string, listener EventListener) func() {
actual, _ := m.subagentListeners.LoadOrStore(toolCallID, newSubagentListenerSet())
return actual.(*subagentListenerSet).add(listener)
actual, _ := m.subagentListeners.LoadOrStore(toolCallID, newEventBus())
return actual.(*eventBus).subscribe(listener)
}
// getSubagentListenerSet returns the listener set for a tool call, or nil.
func (m *Kit) getSubagentListenerSet(toolCallID string) *subagentListenerSet {
func (m *Kit) getSubagentListenerSet(toolCallID string) *eventBus {
if v, ok := m.subagentListeners.Load(toolCallID); ok {
return v.(*subagentListenerSet)
return v.(*eventBus)
}
return nil
}
+23 -2
View File
@@ -140,8 +140,14 @@ func TestEventBusConcurrentSubscribeEmit(t *testing.T) {
wg.Wait()
// We can't assert an exact count because subscribe/emit ordering is
// non-deterministic, but it must not panic or deadlock.
t.Logf("total events received across subscribers: %d", total.Load())
// non-deterministic, but we can assert the count is non-negative and
// that no events were lost (each subscriber that registered before an
// emit must have received it at least partially).
got := total.Load()
if got < 0 {
t.Errorf("expected non-negative total event count, got %d", got)
}
t.Logf("total events received across subscribers: %d", got)
}
// TestEventBusEmitNoListeners verifies emit is a no-op with no subscribers.
@@ -169,6 +175,11 @@ func TestEventTypes(t *testing.T) {
{ToolResultEvent{}, EventToolResult},
{ToolCallContentEvent{}, EventToolCallContent},
{ResponseEvent{}, EventResponse},
{CompactionEvent{}, EventCompaction},
{ReasoningDeltaEvent{}, EventReasoningDelta},
{ToolOutputEvent{}, EventToolOutput},
{StepUsageEvent{}, EventStepUsage},
{SteerConsumedEvent{}, EventSteerConsumed},
}
for _, tt := range tests {
@@ -212,26 +223,36 @@ func TestEventOrdering(t *testing.T) {
EventTurnStart,
EventMessageStart,
EventMessageUpdate,
EventReasoningDelta,
EventToolOutput,
EventToolCall,
EventToolExecutionStart,
EventToolExecutionEnd,
EventToolResult,
EventToolCallContent,
EventMessageEnd,
EventStepUsage,
EventResponse,
EventCompaction,
EventSteerConsumed,
EventTurnEnd,
}
bus.emit(TurnStartEvent{})
bus.emit(MessageStartEvent{})
bus.emit(MessageUpdateEvent{Chunk: "hello"})
bus.emit(ReasoningDeltaEvent{Delta: "thinking..."})
bus.emit(ToolOutputEvent{ToolName: "bash", Chunk: "output"})
bus.emit(ToolCallEvent{ToolName: "bash"})
bus.emit(ToolExecutionStartEvent{ToolName: "bash"})
bus.emit(ToolExecutionEndEvent{ToolName: "bash"})
bus.emit(ToolResultEvent{ToolName: "bash", Result: "ok"})
bus.emit(ToolCallContentEvent{Content: "I'll run bash"})
bus.emit(MessageEndEvent{Content: "done"})
bus.emit(StepUsageEvent{InputTokens: 100})
bus.emit(ResponseEvent{Content: "done"})
bus.emit(CompactionEvent{Summary: "compacted"})
bus.emit(SteerConsumedEvent{Count: 1})
bus.emit(TurnEndEvent{Response: "done"})
if len(types) != len(expected) {
+435
View File
@@ -0,0 +1,435 @@
package kit
import (
"fmt"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
)
// ExtensionAPI provides grouped access to all extension-related functionality.
// This cleans up the main Kit API surface while keeping all extension capabilities available.
type ExtensionAPI interface {
// Context management
SetContext(ctx extensions.Context)
GetContext() extensions.Context
UpdateContextModel(model string)
// Widgets
SetWidget(config extensions.WidgetConfig)
RemoveWidget(id string)
GetWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig
// Header/Footer
SetHeader(config extensions.HeaderFooterConfig)
RemoveHeader()
GetHeader() *extensions.HeaderFooterConfig
SetFooter(config extensions.HeaderFooterConfig)
RemoveFooter()
GetFooter() *extensions.HeaderFooterConfig
// Editor
SetEditor(config extensions.EditorConfig)
ResetEditor()
GetEditor() *extensions.EditorConfig
// UI Visibility
SetUIVisibility(v extensions.UIVisibility)
GetUIVisibility() *extensions.UIVisibility
// Tool rendering
GetToolRenderer(toolName string) *extensions.ToolRenderConfig
GetMessageRenderer(name string) *extensions.MessageRendererConfig
// Session data
GetSessionMessages() []extensions.SessionMessage
AppendEntry(extType, data string) (string, error)
GetEntries(extType string) []extensions.ExtensionEntry
// Status bar
SetStatus(entry extensions.StatusBarEntry)
RemoveStatus(key string)
GetStatusEntries() []extensions.StatusBarEntry
// Shortcuts
GetShortcuts() map[string]func()
// Tools
GetToolInfos() []extensions.ToolInfo
SetActiveTools(names []string)
// Options
GetOption(name string) string
SetOption(name, value string)
// Events
EmitSessionStart()
EmitModelChange(newModel, previousModel, source string)
EmitCustomEvent(name, data string)
EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string)
EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string)
// Commands
Commands() []extensions.CommandDef
// Lifecycle
Reload() error
HasExtensions() bool
}
// extensionAPI implements ExtensionAPI by wrapping a Kit instance.
type extensionAPI struct {
kit *Kit
}
// Extensions returns the ExtensionAPI for accessing all extension-related functionality.
func (m *Kit) Extensions() ExtensionAPI {
return &extensionAPI{kit: m}
}
// Context management
func (e *extensionAPI) SetContext(ctx extensions.Context) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetContext(ctx)
}
}
func (e *extensionAPI) GetContext() extensions.Context {
if e.kit.extRunner != nil {
return e.kit.extRunner.GetContext()
}
return extensions.Context{}
}
func (e *extensionAPI) UpdateContextModel(model string) {
if e.kit.extRunner != nil {
ctx := e.kit.extRunner.GetContext()
ctx.Model = model
e.kit.extRunner.SetContext(ctx)
}
}
// Widgets
func (e *extensionAPI) SetWidget(config extensions.WidgetConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetWidget(config)
}
}
func (e *extensionAPI) RemoveWidget(id string) {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveWidget(id)
}
}
func (e *extensionAPI) GetWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetWidgets(placement)
}
// Header/Footer
func (e *extensionAPI) SetHeader(config extensions.HeaderFooterConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetHeader(config)
}
}
func (e *extensionAPI) RemoveHeader() {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveHeader()
}
}
func (e *extensionAPI) GetHeader() *extensions.HeaderFooterConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetHeader()
}
func (e *extensionAPI) SetFooter(config extensions.HeaderFooterConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetFooter(config)
}
}
func (e *extensionAPI) RemoveFooter() {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveFooter()
}
}
func (e *extensionAPI) GetFooter() *extensions.HeaderFooterConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetFooter()
}
// Editor
func (e *extensionAPI) SetEditor(config extensions.EditorConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetEditor(config)
}
}
func (e *extensionAPI) ResetEditor() {
if e.kit.extRunner != nil {
e.kit.extRunner.ResetEditor()
}
}
func (e *extensionAPI) GetEditor() *extensions.EditorConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetEditor()
}
// UI Visibility
func (e *extensionAPI) SetUIVisibility(v extensions.UIVisibility) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetUIVisibility(v)
}
}
func (e *extensionAPI) GetUIVisibility() *extensions.UIVisibility {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetUIVisibility()
}
// Tool rendering
func (e *extensionAPI) GetToolRenderer(toolName string) *extensions.ToolRenderConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetToolRenderer(toolName)
}
func (e *extensionAPI) GetMessageRenderer(name string) *extensions.MessageRendererConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetMessageRenderer(name)
}
// Session data
func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
return iterBranchMessages(e.kit.treeSession, func(me *session.MessageEntry, msg message.Message) extensions.SessionMessage {
return extensions.SessionMessage{
ID: me.ID,
Role: string(msg.Role),
Content: msg.Content(),
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
}
})
}
func (e *extensionAPI) AppendEntry(extType, data string) (string, error) {
if e.kit.treeSession == nil {
return "", fmt.Errorf("no session available")
}
return e.kit.treeSession.AppendExtensionData(extType, data)
}
func (e *extensionAPI) GetEntries(extType string) []extensions.ExtensionEntry {
if e.kit.treeSession == nil {
return nil
}
entries := e.kit.treeSession.GetExtensionData(extType)
result := make([]extensions.ExtensionEntry, 0, len(entries))
for _, e := range entries {
result = append(result, extensions.ExtensionEntry{
ID: e.ID,
EntryType: e.ExtType,
Data: e.Data,
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return result
}
// Status bar
func (e *extensionAPI) SetStatus(entry extensions.StatusBarEntry) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetStatusEntry(entry)
}
}
func (e *extensionAPI) RemoveStatus(key string) {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveStatusEntry(key)
}
}
func (e *extensionAPI) GetStatusEntries() []extensions.StatusBarEntry {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetStatusEntries()
}
// Shortcuts
func (e *extensionAPI) GetShortcuts() map[string]func() {
if e.kit.extRunner == nil {
return nil
}
entries := e.kit.extRunner.GetShortcuts()
if entries == nil {
return nil
}
result := make(map[string]func(), len(entries))
for key, entry := range entries {
h := entry.Handler
r := e.kit.extRunner
result[key] = func() {
ctx := r.GetContext()
h(ctx)
}
}
return result
}
// Tools
func (e *extensionAPI) GetToolInfos() []extensions.ToolInfo {
agentTools := e.kit.agent.GetTools()
coreCount := e.kit.agent.GetCoreToolCount()
mcpCount := e.kit.agent.GetMCPToolCount()
result := make([]extensions.ToolInfo, 0, len(agentTools))
for i, t := range agentTools {
info := t.Info()
source := "core"
if i >= coreCount && i < coreCount+mcpCount {
source = "mcp"
} else if i >= coreCount+mcpCount {
source = "extension"
}
enabled := true
if e.kit.extRunner != nil && e.kit.extRunner.IsToolDisabled(info.Name) {
enabled = false
}
result = append(result, extensions.ToolInfo{
Name: info.Name,
Description: info.Description,
Source: source,
Enabled: enabled,
})
}
return result
}
func (e *extensionAPI) SetActiveTools(names []string) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetActiveTools(names)
}
}
// Options
func (e *extensionAPI) GetOption(name string) string {
if e.kit.extRunner == nil {
return ""
}
return e.kit.extRunner.GetOption(name)
}
func (e *extensionAPI) SetOption(name, value string) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetOption(name, value)
}
}
// Events
func (e *extensionAPI) EmitSessionStart() {
if e.kit.extRunner != nil && e.kit.extRunner.HasHandlers(extensions.SessionStart) {
_, _ = e.kit.extRunner.Emit(extensions.SessionStartEvent{})
}
}
func (e *extensionAPI) EmitModelChange(newModel, previousModel, source string) {
if e.kit.extRunner != nil && e.kit.extRunner.HasHandlers(extensions.ModelChange) {
_, _ = e.kit.extRunner.Emit(extensions.ModelChangeEvent{
NewModel: newModel,
PreviousModel: previousModel,
Source: source,
})
}
}
func (e *extensionAPI) EmitCustomEvent(name, data string) {
if e.kit.extRunner != nil {
e.kit.extRunner.EmitCustomEvent(name, data)
}
}
func (e *extensionAPI) EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string) {
if e.kit.extRunner == nil || !e.kit.extRunner.HasHandlers(extensions.BeforeFork) {
return false, ""
}
result, _ := e.kit.extRunner.Emit(extensions.BeforeForkEvent{
TargetID: targetID,
IsUserMessage: isUserMsg,
UserText: userText,
})
if r, ok := result.(extensions.BeforeForkResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Fork cancelled by extension."
}
return true, reason
}
return false, ""
}
func (e *extensionAPI) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
if e.kit.extRunner == nil || !e.kit.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
return false, ""
}
result, _ := e.kit.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
Reason: switchReason,
})
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Session switch cancelled by extension."
}
return true, reason
}
return false, ""
}
// Commands
func (e *extensionAPI) Commands() []extensions.CommandDef {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.RegisteredCommands()
}
// Lifecycle
func (e *extensionAPI) Reload() error {
return e.kit.ReloadExtensions()
}
func (e *extensionAPI) HasExtensions() bool {
return e.kit.extRunner != nil
}
+130 -6
View File
@@ -2,6 +2,7 @@ package kit
import (
"strings"
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
@@ -103,11 +104,9 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
if runner.HasHandlers(extensions.AgentEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnEndEvent); ok {
stopReason := ev.StopReason
response := ev.Response
stopReason, response := ev.StopReason, ev.Response
if ev.Error != nil {
stopReason = "error"
response = ""
stopReason, response = "error", ""
} else if stopReason == "" {
stopReason = "completed"
}
@@ -119,11 +118,136 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
})
}
// --- Subagent lifecycle events ---
// When an extension registers OnSubagentStart/Chunk/End handlers, bridge
// the SDK's per-subagent event stream (SubscribeSubagent) into the
// extension runner.
//
// Flow:
// ToolExecutionStartEvent(subagent) → emit SubagentStartEvent
// → SubscribeSubagent → emit SubagentChunkEvents
// ToolResultEvent(subagent) → emit SubagentEndEvent
//
// We use ToolExecutionStart (not ToolCall) for SubagentStart because that
// is when the subagent actually begins running. We use ToolResult for
// SubagentEnd because that carries the final response text.
wantsSubagent := runner.HasHandlers(extensions.SubagentStart) ||
runner.HasHandlers(extensions.SubagentChunk) ||
runner.HasHandlers(extensions.SubagentEnd)
if wantsSubagent {
// taskByCallID tracks the task description extracted from ToolCall input,
// keyed by toolCallID. Populated on ToolCall, consumed on ToolResult.
taskByCallID := make(map[string]string)
var taskMu sync.Mutex
// Intercept ToolCall to capture the task and subscribe to child events.
m.Subscribe(func(e Event) {
ev, ok := e.(ToolCallEvent)
if !ok || ev.ToolName != "subagent" {
return
}
// Extract task from parsed args.
task := ""
if ev.ParsedArgs != nil {
if t, ok := ev.ParsedArgs["task"].(string); ok {
task = t
}
}
taskMu.Lock()
taskByCallID[ev.ToolCallID] = task
taskMu.Unlock()
// Subscribe to child events so we can forward them as SubagentChunkEvents.
if runner.HasHandlers(extensions.SubagentChunk) {
m.SubscribeSubagent(ev.ToolCallID, func(childEvent Event) {
chunk := extensions.SubagentChunkEvent{
ToolCallID: ev.ToolCallID,
Task: task,
}
switch ce := childEvent.(type) {
case MessageUpdateEvent:
chunk.ChunkType = "text"
chunk.Content = ce.Chunk
case TurnStartEvent:
chunk.ChunkType = "turn_start"
case TurnEndEvent:
chunk.ChunkType = "turn_end"
case ToolCallEvent:
chunk.ChunkType = "tool_call"
chunk.ToolName = ce.ToolName
chunk.ToolArgs = ce.ToolArgs
case ToolExecutionStartEvent:
chunk.ChunkType = "tool_execution_start"
chunk.ToolName = ce.ToolName
case ToolExecutionEndEvent:
chunk.ChunkType = "tool_execution_end"
chunk.ToolName = ce.ToolName
case ToolResultEvent:
chunk.ChunkType = "tool_result"
chunk.ToolName = ce.ToolName
chunk.ToolResult = ce.Result
chunk.IsError = ce.IsError
default:
return // skip unknown event types
}
_, _ = runner.Emit(chunk)
})
}
})
// Emit SubagentStartEvent when execution begins.
if runner.HasHandlers(extensions.SubagentStart) {
m.Subscribe(func(e Event) {
ev, ok := e.(ToolExecutionStartEvent)
if !ok || ev.ToolName != "subagent" {
return
}
taskMu.Lock()
task := taskByCallID[ev.ToolCallID]
taskMu.Unlock()
_, _ = runner.Emit(extensions.SubagentStartEvent{
ToolCallID: ev.ToolCallID,
Task: task,
})
})
}
// Emit SubagentEndEvent when the tool result arrives.
if runner.HasHandlers(extensions.SubagentEnd) {
m.Subscribe(func(e Event) {
ev, ok := e.(ToolResultEvent)
if !ok || ev.ToolName != "subagent" {
return
}
taskMu.Lock()
task := taskByCallID[ev.ToolCallID]
delete(taskByCallID, ev.ToolCallID)
taskMu.Unlock()
errMsg := ""
if ev.IsError {
errMsg = ev.Result
}
response := ""
if !ev.IsError {
response = ev.Result
}
_, _ = runner.Emit(extensions.SubagentEndEvent{
ToolCallID: ev.ToolCallID,
Task: task,
Response: response,
ErrorMsg: errMsg,
})
})
}
}
// --- Context filtering hook ---
// Extension ContextPrepare → SDK ContextPrepare hook.
if runner.HasHandlers(extensions.ContextPrepare) {
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
// Convert fantasy.Message slice to extension ContextMessage slice.
// Convert LLM message slice to extension ContextMessage slice.
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
@@ -146,7 +270,7 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
return nil
}
// Rebuild fantasy.Message slice from extension result.
// Rebuild LLM message slice from extension result.
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
for _, cm := range r.Messages {
if cm.Index >= 0 && cm.Index < len(h.Messages) {
+7 -2
View File
@@ -82,7 +82,7 @@ type AfterTurnResult struct{}
// is assembled from the session tree (including compaction) and before the
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
type ContextPrepareHook struct {
// Messages is the current context as fantasy.Message objects.
// Messages is the current context as LLM message objects.
Messages []fantasy.Message
}
@@ -167,8 +167,13 @@ func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func(
}
// run executes all hooks in priority order. The first non-nil result wins.
// Returns nil immediately if no hooks are registered.
func (hr *hookRegistry[In, Out]) run(input In) *Out {
hr.mu.RLock()
if len(hr.hooks) == 0 {
hr.mu.RUnlock()
return nil
}
snapshot := make([]hookEntry[In, Out], len(hr.hooks))
copy(snapshot, hr.hooks)
hr.mu.RUnlock()
@@ -247,7 +252,7 @@ func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeC
// Tool wrapping via hooks
// ---------------------------------------------------------------------------
// hookedTool wraps a fantasy.AgentTool to run BeforeToolCall and
// hookedTool wraps an AgentTool to run BeforeToolCall and
// AfterToolResult hooks around each execution. The registries are referenced
// by pointer so hooks added after agent creation are still invoked.
type hookedTool struct {
+13 -26
View File
@@ -107,6 +107,11 @@ func TestHookRegistry_SamePriorityPreservesOrder(t *testing.T) {
func TestHookRegistry_Unregister(t *testing.T) {
hr := newHookRegistry[string, string]()
// Verify initial state (merged from TestHookRegistry_HasHooks).
if hr.hasHooks() {
t.Error("expected hasHooks to be false initially")
}
unregister := hr.register(HookPriorityNormal, func(input string) *string {
result := "should be gone"
return &result
@@ -137,24 +142,6 @@ func TestHookRegistry_NoHooksReturnsNil(t *testing.T) {
}
}
func TestHookRegistry_HasHooks(t *testing.T) {
hr := newHookRegistry[string, string]()
if hr.hasHooks() {
t.Error("expected hasHooks to be false initially")
}
unsub := hr.register(HookPriorityNormal, func(_ string) *string { return nil })
if !hr.hasHooks() {
t.Error("expected hasHooks to be true after registration")
}
unsub()
if hr.hasHooks() {
t.Error("expected hasHooks to be false after unregister")
}
}
func TestHookRegistry_ConcurrentAccess(t *testing.T) {
hr := newHookRegistry[int, int]()
@@ -187,7 +174,7 @@ func TestHookRegistry_ConcurrentAccess(t *testing.T) {
// hookedTool tests
// ---------------------------------------------------------------------------
// mockAgentTool implements fantasy.AgentTool for testing.
// mockAgentTool implements the AgentTool interface for testing.
type mockAgentTool struct {
name string
runFn func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error)
@@ -206,10 +193,14 @@ func (m *mockAgentTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy
return fantasy.NewTextResponse("default output"), nil
}
func TestHookedTool_Passthrough(t *testing.T) {
// newEmptyHookedTool creates a hookedTool with empty hook registries and the given mock tool.
func newEmptyHookedTool(mock *mockAgentTool) *hookedTool {
before := newHookRegistry[BeforeToolCallHook, BeforeToolCallResult]()
after := newHookRegistry[AfterToolResultHook, AfterToolResultResult]()
return &hookedTool{inner: mock, beforeToolCall: before, afterToolResult: after}
}
func TestHookedTool_Passthrough(t *testing.T) {
mock := &mockAgentTool{
name: "test_tool",
runFn: func(_ context.Context, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
@@ -217,7 +208,7 @@ func TestHookedTool_Passthrough(t *testing.T) {
},
}
ht := &hookedTool{inner: mock, beforeToolCall: before, afterToolResult: after}
ht := newEmptyHookedTool(mock)
resp, err := ht.Run(context.Background(), fantasy.ToolCall{Input: "{}"})
if err != nil {
@@ -372,11 +363,7 @@ func TestHookedTool_HookReceivesToolInfo(t *testing.T) {
func TestHookedTool_InfoDelegates(t *testing.T) {
mock := &mockAgentTool{name: "delegate_test"}
ht := &hookedTool{
inner: mock,
beforeToolCall: newHookRegistry[BeforeToolCallHook, BeforeToolCallResult](),
afterToolResult: newHookRegistry[AfterToolResultHook, AfterToolResultResult](),
}
ht := newEmptyHookedTool(mock)
if ht.Info().Name != "delegate_test" {
t.Errorf("expected Info() to delegate to inner tool")
+219 -565
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -16,10 +16,15 @@ func GetSupportedProviders() []string {
return models.GetGlobalRegistry().GetSupportedProviders()
}
// GetFantasyProviders returns provider IDs that can be used with fantasy,
// GetLLMProviders returns provider IDs that have LLM support,
// either through a native provider or via openaicompat auto-routing.
func GetLLMProviders() []string {
return models.GetGlobalRegistry().GetLLMProviders()
}
// Deprecated: Use GetLLMProviders instead.
func GetFantasyProviders() []string {
return models.GetGlobalRegistry().GetFantasyProviders()
return GetLLMProviders()
}
// GetModelsForProvider returns all known models for a provider.
+194
View File
@@ -1,9 +1,14 @@
package kit
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
)
@@ -86,3 +91,192 @@ func (m *Kit) SetSessionName(name string) error {
_, err := m.treeSession.AppendSessionInfo(name)
return err
}
// ---------------------------------------------------------------------------
// Tree Navigation Bridge for Extensions (Phase 1)
// ---------------------------------------------------------------------------
// GetTreeNode returns a node by ID with full metadata and children.
// Returns nil if entry not found or no tree session.
func (m *Kit) GetTreeNode(entryID string) *TreeNode {
if m.treeSession == nil {
return nil
}
entry := m.treeSession.GetEntry(entryID)
if entry == nil {
return nil
}
return m.entryToTreeNode(entry)
}
// GetCurrentBranch returns the path from root to current leaf as TreeNodes.
func (m *Kit) GetCurrentBranch() []TreeNode {
if m.treeSession == nil {
return nil
}
branch := m.treeSession.GetBranch("")
var nodes []TreeNode
for _, entry := range branch {
node := m.entryToTreeNode(entry)
if node != nil {
nodes = append(nodes, *node)
}
}
return nodes
}
// GetChildren returns direct child IDs of an entry.
func (m *Kit) GetChildren(parentID string) []string {
if m.treeSession == nil {
return nil
}
return m.treeSession.GetChildren(parentID)
}
// NavigateTo branches/forks the session to the specified entry ID.
// Returns an error if the session is unavailable or the entry ID is not found.
func (m *Kit) NavigateTo(entryID string) error {
if m.treeSession == nil {
return fmt.Errorf("no tree session available")
}
return m.treeSession.Branch(entryID)
}
// SummarizeBranch uses the LLM to summarize the conversation between two
// entry IDs. Returns the summary text, or an error if the range is invalid,
// the session is unavailable, or the LLM call fails.
func (m *Kit) SummarizeBranch(fromID, toID string) (string, error) {
if m.treeSession == nil {
return "", fmt.Errorf("no tree session available")
}
// Get the branch and find the range
branch := m.treeSession.GetBranch("")
var startIdx, endIdx = -1, -1
for i, entry := range branch {
id := m.treeSession.EntryID(entry)
if id == fromID {
startIdx = i
}
if id == toID {
endIdx = i
}
}
if startIdx < 0 || endIdx < 0 || startIdx > endIdx {
return "", fmt.Errorf("entry IDs not found or out of order in current branch")
}
// Build text to summarize
var content strings.Builder
for i := startIdx; i <= endIdx; i++ {
node := m.entryToTreeNode(branch[i])
if node != nil && node.Content != "" {
fmt.Fprintf(&content, "[%s] %s\n\n", node.Role, node.Content)
}
}
if content.Len() == 0 {
return "", fmt.Errorf("no content found in the specified range")
}
// Use LLM to summarize
resp, err := m.ExecuteCompletion(context.Background(), extensions.CompleteRequest{
Model: "", // Use current model
System: "You are a concise summarization assistant. Summarize the conversation in 2-3 sentences.",
Prompt: content.String(),
})
if err != nil {
return "", fmt.Errorf("summarization failed: %w", err)
}
return resp.Text, nil
}
// CollapseBranch replaces a branch range with a summary entry.
// Returns an error if the session is unavailable or the operation fails.
func (m *Kit) CollapseBranch(fromID, toID, summary string) error {
if m.treeSession == nil {
return fmt.Errorf("no tree session available")
}
_, err := m.treeSession.AppendBranchSummary(fromID, summary)
return err
}
// entryToTreeNode converts a session entry to a TreeNode.
func (m *Kit) entryToTreeNode(entry any) *TreeNode {
switch e := entry.(type) {
case *session.MessageEntry:
msg, err := e.ToMessage()
if err != nil {
return nil
}
var content strings.Builder
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content.WriteString(pt.Text)
case message.ReasoningContent:
content.WriteString(pt.Thinking)
case message.ToolCall:
fmt.Fprintf(&content, "[tool_call: %s]", pt.Name)
case message.ToolResult:
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
}
}
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "message",
Role: string(msg.Role),
Content: content.String(),
Model: msg.Model,
Provider: msg.Provider,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.BranchSummaryEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "branch_summary",
Content: e.Summary,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.ModelChangeEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "model_change",
Content: fmt.Sprintf("Model changed to %s/%s", e.Provider, e.ModelID),
Model: e.Provider + "/" + e.ModelID,
Provider: e.Provider,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.ExtensionDataEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "extension_data",
Content: fmt.Sprintf("Extension data: %s", e.ExtType),
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
default:
return nil
}
}
// TreeNode represents a node in the session tree for SDK consumers.
type TreeNode struct {
ID string
ParentID string
Type string // "message", "branch_summary", "model_change", "extension_data"
Role string // for messages: "user", "assistant", "system", "tool"
Content string
Model string
Provider string
Timestamp string
Children []string
}
+70 -1
View File
@@ -1,6 +1,11 @@
package kit
import "github.com/mark3labs/kit/internal/skills"
import (
"os"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/skills"
)
// ==== Skills Types ====
@@ -67,3 +72,67 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) {
func NewPromptBuilder(basePrompt string) *PromptBuilder {
return skills.NewPromptBuilder(basePrompt)
}
// ---------------------------------------------------------------------------
// Skill Bridge for Extensions (Phase 2)
// ---------------------------------------------------------------------------
// DiscoverSkillsForExtension finds skills in standard locations for extensions.
// Returns skills in the extension-facing format. Results are cached per-Kit
// instance to avoid reloading on every call.
func (m *Kit) DiscoverSkillsForExtension() []extensions.Skill {
cwd, _ := os.Getwd()
m.skillCache.mu.Lock()
defer m.skillCache.mu.Unlock()
if len(m.skillCache.skills) == 0 {
m.skillCache.skills, _ = skills.LoadSkills(cwd)
}
return m.convertSkills(m.skillCache.skills)
}
// LoadSkillForExtension loads a single skill file for extensions.
func (m *Kit) LoadSkillForExtension(path string) (*extensions.Skill, string) {
s, err := skills.LoadSkill(path)
if err != nil {
return nil, err.Error()
}
return m.convertSkill(s), ""
}
// LoadSkillsFromDirForExtension loads all skills from a directory for extensions.
func (m *Kit) LoadSkillsFromDirForExtension(dir string) extensions.SkillLoadResult {
skillList, err := skills.LoadSkillsFromDir(dir)
if err != nil {
return extensions.SkillLoadResult{Error: err.Error()}
}
return extensions.SkillLoadResult{Skills: m.convertSkills(skillList)}
}
// convertSkill converts internal skill to extension-facing format.
func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
return &extensions.Skill{
Name: s.Name,
Description: s.Description,
Content: s.Content,
Path: s.Path,
Tags: s.Tags,
When: s.When,
}
}
// convertSkills converts a slice of skills.
func (m *Kit) convertSkills(skillList []*skills.Skill) []extensions.Skill {
result := make([]extensions.Skill, 0, len(skillList))
for _, s := range skillList {
result = append(result, *m.convertSkill(s))
}
return result
}
// ClearSkillCache clears the skill cache for this Kit instance.
func (m *Kit) ClearSkillCache() {
m.skillCache.mu.Lock()
defer m.skillCache.mu.Unlock()
m.skillCache.skills = nil
}
+457
View File
@@ -0,0 +1,457 @@
package kit
import (
"regexp"
"strings"
"sync"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
)
// ---------------------------------------------------------------------------
// Template Parsing Bridge for Extensions (Phase 3)
// ---------------------------------------------------------------------------
// varRegex matches {{variable}} placeholders in templates.
var varRegex = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
// ParseTemplate extracts {{variables}} from template content.
func ParseTemplate(name, content string) extensions.PromptTemplate {
matches := varRegex.FindAllStringSubmatch(content, -1)
vars := make([]string, 0, len(matches))
seen := make(map[string]bool)
for _, m := range matches {
if len(m) > 1 && !seen[m[1]] {
seen[m[1]] = true
vars = append(vars, m[1])
}
}
return extensions.PromptTemplate{
Name: name,
Content: content,
Variables: vars,
}
}
// RenderTemplate substitutes variables into template content.
// Handles {{name}} and {{ name }} (any whitespace) placeholders.
func RenderTemplate(tpl extensions.PromptTemplate, vars map[string]string) string {
return varRegex.ReplaceAllStringFunc(tpl.Content, func(m string) string {
sub := varRegex.FindStringSubmatch(m)
if len(sub) > 1 {
if v, ok := vars[sub[1]]; ok {
return v
}
}
return m
})
}
// ParseArguments parses command-line style arguments.
func ParseArguments(input string, pattern extensions.ArgumentPattern) extensions.ParseResult {
result := extensions.ParseResult{
Vars: make(map[string]string),
Flags: make(map[string]string),
}
fields := parseFields(input)
if len(fields) == 0 {
return result
}
// First field is the command itself (if present); skip it.
startIdx := 0
if len(fields) > 0 && !strings.HasPrefix(fields[0], "-") {
startIdx = 1
}
// Parse flags
i := startIdx
for i < len(fields) {
field := fields[i]
// Check for flags
if strings.HasPrefix(field, "--") {
flagName := field[2:]
if varName, ok := pattern.Flags["--"+flagName]; ok {
// Flag with value
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
result.Flags["--"+flagName] = fields[i+1]
result.Vars[varName] = fields[i+1]
i += 2
continue
}
// Boolean flag
result.Flags["--"+flagName] = "true"
result.Vars[varName] = "true"
}
i++
continue
}
if strings.HasPrefix(field, "-") && len(field) > 1 {
flagName := field[1:]
if varName, ok := pattern.Flags["-"+flagName]; ok {
// Flag with value
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
result.Flags["-"+flagName] = fields[i+1]
result.Vars[varName] = fields[i+1]
i += 2
continue
}
// Boolean flag
result.Flags["-"+flagName] = "true"
result.Vars[varName] = "true"
}
i++
continue
}
i++
}
// Collect remaining as positional args and "rest"
positional := make([]string, 0)
i = startIdx
for i < len(fields) {
field := fields[i]
if !strings.HasPrefix(field, "-") {
// Check if this was consumed as a flag value
consumed := false
for _, v := range result.Vars {
if v == field {
// Might be consumed, check previous field
if i > 0 {
prev := fields[i-1]
if strings.HasPrefix(prev, "-") {
consumed = true
break
}
}
}
}
if !consumed {
positional = append(positional, field)
}
}
i++
}
// Map positional args
for i, name := range pattern.Positional {
if i < len(positional) {
result.Vars[name] = positional[i]
}
}
// Set rest
if pattern.Rest != "" && len(positional) > len(pattern.Positional) {
restStart := len(pattern.Positional)
if restStart < len(positional) {
result.Vars[pattern.Rest] = strings.Join(positional[restStart:], " ")
}
}
result.Rest = strings.Join(fields, " ")
return result
}
// SimpleParseArguments parses $1, $2, $@ style arguments.
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
func SimpleParseArguments(input string, count int) []string {
fields := parseFields(input)
result := make([]string, 0, count+2)
result = append(result, input) // [0] = full input
// [1]..[count] = positional args
for i := range count {
if i < len(fields) {
result = append(result, fields[i])
} else {
result = append(result, "")
}
}
// [n] = $@ (all remaining)
if len(fields) > count {
result = append(result, strings.Join(fields[count:], " "))
} else {
result = append(result, "")
}
return result
}
// parseFields splits input respecting quoted strings.
func parseFields(input string) []string {
var fields []string
var current strings.Builder
inQuote := false
quoteChar := rune(0)
for _, r := range input {
switch r {
case '"', '\'':
if !inQuote {
inQuote = true
quoteChar = r
} else if r == quoteChar {
inQuote = false
quoteChar = 0
} else {
current.WriteRune(r)
}
case ' ', '\t':
if inQuote {
current.WriteRune(r)
} else {
if current.Len() > 0 {
fields = append(fields, current.String())
current.Reset()
}
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
fields = append(fields, current.String())
}
return fields
}
// EvaluateModelConditional checks if condition matches current model.
// Condition supports wildcards: * matches any, ? matches single char.
func EvaluateModelConditional(currentModel, condition string) bool {
// Handle comma-separated conditions (OR logic)
for c := range strings.SplitSeq(condition, ",") {
c = strings.TrimSpace(c)
if matchModelPattern(currentModel, c) {
return true
}
}
return false
}
// modelPatternCache caches compiled regexps for model glob patterns.
var modelPatternCache sync.Map
// matchModelPattern matches a model against a pattern with wildcards.
// Compiled regexps are cached to avoid recompilation on hot paths.
func matchModelPattern(model, pattern string) bool {
rePattern := "^" + strings.ReplaceAll(strings.ReplaceAll(pattern, "*", ".*"), "?", ".") + "$"
var re *regexp.Regexp
if v, ok := modelPatternCache.Load(rePattern); ok {
re = v.(*regexp.Regexp)
} else {
compiled, err := regexp.Compile(rePattern)
if err != nil {
// Fallback: exact match
return model == pattern
}
modelPatternCache.Store(rePattern, compiled)
re = compiled
}
return re.MatchString(model)
}
// RenderWithModelConditionals processes <if-model> blocks in content.
func RenderWithModelConditionals(content, currentModel string) string {
// Simple regex-based processor for <if-model> blocks
// Supports: <if-model is="pattern">content</if-model>
// And: <if-model is="pattern">content<else>other</if-model>
result := content
// Pattern for if-model blocks
ifModelRegex := regexp.MustCompile(`(?s)<if-model\s+is="([^"]+)">(.*?)(?:<else>(.*?))?</if-model>`)
for {
match := ifModelRegex.FindStringSubmatchIndex(result)
if match == nil {
break
}
condition := result[match[2]:match[3]]
ifContent := result[match[4]:match[5]]
elseContent := ""
if match[6] >= 0 && match[7] >= 0 {
elseContent = result[match[6]:match[7]]
}
var replacement string
if EvaluateModelConditional(currentModel, condition) {
replacement = ifContent
} else {
replacement = elseContent
}
result = result[:match[0]] + replacement + result[match[1]:]
}
return result
}
// ---------------------------------------------------------------------------
// Model Resolution Bridge for Extensions (Phase 4)
// ---------------------------------------------------------------------------
// ResolveModelChain attempts each model in order until one is available.
func ResolveModelChain(preferences []string) extensions.ModelResolutionResult {
result := extensions.ModelResolutionResult{
Attempted: make([]string, 0, len(preferences)),
}
registry := models.GetGlobalRegistry()
for _, pref := range preferences {
pref = strings.TrimSpace(pref)
result.Attempted = append(result.Attempted, pref)
// Parse model string
provider, modelID, err := models.ParseModelString(pref)
if err != nil {
continue
}
// Check if provider exists
if registry.GetProviderInfo(provider) == nil {
continue
}
// Check if model exists in registry
modelInfo := registry.LookupModel(provider, modelID)
if modelInfo == nil {
// Try with just the model as bare name
continue
}
// Found available model
result.Model = provider + "/" + modelID
result.Capabilities = extensions.ModelCapabilities{
Provider: provider,
ModelID: modelID,
ContextLimit: modelInfo.Limit.Context,
OutputLimit: modelInfo.Limit.Output,
Reasoning: modelInfo.Reasoning,
Streaming: true, // Assume streaming support
}
return result
}
result.Error = "no models in chain are available"
return result
}
// GetModelCapabilities returns capabilities for a specific model.
// If model is empty, returns zero capabilities.
func GetModelCapabilities(model string) (extensions.ModelCapabilities, string) {
if model == "" {
return extensions.ModelCapabilities{}, "no model specified"
}
provider, modelID, err := models.ParseModelString(model)
if err != nil {
return extensions.ModelCapabilities{}, err.Error()
}
registry := models.GetGlobalRegistry()
modelInfo := registry.LookupModel(provider, modelID)
if modelInfo == nil {
return extensions.ModelCapabilities{}, "model not found in registry"
}
return extensions.ModelCapabilities{
Provider: provider,
ModelID: modelID,
ContextLimit: modelInfo.Limit.Context,
OutputLimit: modelInfo.Limit.Output,
Reasoning: modelInfo.Reasoning,
Streaming: true,
}, ""
}
// CheckModelAvailable verifies if a model string is valid and provider exists.
func CheckModelAvailable(model string) bool {
provider, _, err := models.ParseModelString(model)
if err != nil {
return false
}
registry := models.GetGlobalRegistry()
if registry.GetProviderInfo(provider) == nil {
return false
}
// Model doesn't need to be in registry - could be dynamic/Ollama
return true
}
// GetCurrentProvider extracts provider from model string.
func GetCurrentProvider(model string) string {
provider, _, _ := models.ParseModelString(model)
return provider
}
// GetCurrentModelID extracts model ID from model string.
func GetCurrentModelID(model string) string {
_, modelID, _ := models.ParseModelString(model)
return modelID
}
// JoinModel combines provider and model ID into a model string.
func JoinModel(provider, modelID string) string {
if provider == "" {
return modelID
}
return provider + "/" + modelID
}
// MatchModelGlob matches a model against a glob pattern.
// Pattern can contain * (match any) and ? (match single).
func MatchModelGlob(model, pattern string) bool {
return matchModelPattern(model, pattern)
}
// ExtractProviderFromPath extracts provider from a path-like model string.
func ExtractProviderFromPath(model string) string {
parts := strings.Split(model, "/")
if len(parts) >= 2 {
return parts[0]
}
return ""
}
// ExtractModelFromPath extracts model ID from a path-like model string.
func ExtractModelFromPath(model string) string {
parts := strings.Split(model, "/")
if len(parts) >= 2 {
return parts[1]
}
return model
}
// IsBareModelID checks if a string is a bare model ID (no provider).
func IsBareModelID(model string) bool {
return !strings.Contains(model, "/")
}
// AddProviderToModel adds a provider prefix to a bare model ID.
func AddProviderToModel(provider, model string) string {
if strings.Contains(model, "/") {
return model // Already has provider
}
return provider + "/" + model
}
// RemoveProviderFromModel removes the provider prefix from a model string.
func RemoveProviderFromModel(model string) string {
parts := strings.SplitN(model, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return model
}
+1 -1
View File
@@ -52,7 +52,7 @@ func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) }
// read, grep, find, ls.
func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) }
// SubagentTools returns all core tools except spawn_subagent. Use this when
// SubagentTools returns all core tools except subagent. Use this when
// creating child Kit instances (in-process subagents) to prevent infinite
// recursion.
func SubagentTools(opts ...ToolOption) []Tool { return core.SubagentTools(opts...) }
+52 -29
View File
@@ -1,6 +1,8 @@
package kit
import (
"context"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/agent"
@@ -76,10 +78,6 @@ type MCPServerConfig = config.MCPServerConfig
// AgentConfig holds configuration options for creating a new Agent.
type AgentConfig = agent.AgentConfig
// GenerateResult contains the result and conversation history from an agent
// interaction.
type GenerateResult = agent.GenerateWithLoopResult
type (
// ToolCallHandler is a function type for handling tool calls as they happen.
ToolCallHandler = agent.ToolCallHandler
@@ -128,18 +126,22 @@ type ModelsRegistry = models.ModelsRegistry
// Ollama model loading. Signature: func(fn func() error) error.
type SpinnerFunc = agent.SpinnerFunc
// ==== Fantasy Types (re-exported) ====
// ==== LLM Types ====
// FantasyMessage is the underlying message type used by the fantasy agent
// library. Re-exported so SDK users can work with fantasy types without a
// direct import of charm.land/fantasy.
type FantasyMessage = fantasy.Message
// LLMMessage is the underlying message type used by the LLM agent
// library. Re-exported so SDK users can work with LLM types without a
// direct import of the underlying LLM library.
type LLMMessage = fantasy.Message
// FantasyUsage contains token usage information from an LLM response.
type FantasyUsage = fantasy.Usage
// LLMUsage contains token usage information from an LLM response.
type LLMUsage = fantasy.Usage
// FantasyResponse is the response type returned by the fantasy agent library.
type FantasyResponse = fantasy.Response
// LLMResponse is the response type returned by the LLM agent library.
type LLMResponse = fantasy.Response
// LLMFilePart represents a file attachment (image, document, etc.) that can
// be included in a prompt via PromptResultWithFiles.
type LLMFilePart = fantasy.FilePart
// ==== Compaction Types (internal/compaction/) ====
@@ -151,27 +153,48 @@ type CompactionOptions = compaction.CompactionOptions
// ==== Constructor & Helper Functions ====
var (
// ParseModelString parses a model string in "provider/model" format.
ParseModelString = models.ParseModelString
// CreateProvider creates a fantasy LanguageModel based on provider config.
CreateProvider = models.CreateProvider
// GetGlobalRegistry returns the global models registry instance.
GetGlobalRegistry = models.GetGlobalRegistry
// LoadSystemPrompt loads system prompt from file or returns string directly.
LoadSystemPrompt = config.LoadSystemPrompt
)
// ParseModelString parses a model string in "provider/model" format.
// Returns provider, modelID, and an error if the format is invalid.
func ParseModelString(model string) (provider, modelID string, err error) {
return models.ParseModelString(model)
}
// CreateProvider creates a LanguageModel based on provider config.
func CreateProvider(ctx context.Context, cfg *ProviderConfig) (*ProviderResult, error) {
return models.CreateProvider(ctx, cfg)
}
// GetGlobalRegistry returns the global models registry instance.
func GetGlobalRegistry() *ModelsRegistry {
return models.GetGlobalRegistry()
}
// LoadSystemPrompt loads a system prompt from a file path, or returns the
// string directly if it is not a valid file path.
func LoadSystemPrompt(pathOrContent string) (string, error) {
return config.LoadSystemPrompt(pathOrContent)
}
// ==== Conversion Helpers ====
// ConvertToFantasyMessages converts an SDK message to the underlying fantasy
// ConvertToLLMMessages converts an SDK message to the underlying LLM
// messages used by the agent for LLM interactions.
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
return msg.ToFantasyMessages()
func ConvertToLLMMessages(msg *Message) []fantasy.Message {
return msg.ToLLMMessages()
}
// ConvertFromFantasyMessage converts a fantasy message from the agent to an SDK
// ConvertFromLLMMessage converts an LLM message from the agent to an SDK
// message format for use in the SDK API.
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
return message.FromFantasyMessage(msg)
func ConvertFromLLMMessage(msg fantasy.Message) Message {
return message.FromLLMMessage(msg)
}
// Deprecated: Use ConvertToLLMMessages instead.
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
return ConvertToLLMMessages(msg)
}
// Deprecated: Use ConvertFromLLMMessage instead.
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
return ConvertFromLLMMessage(msg)
}
+4 -4
View File
@@ -49,12 +49,12 @@ func TestTypeExports(t *testing.T) {
Role: kit.RoleUser,
Parts: []kit.ContentPart{kit.TextContent{Text: "test"}},
}
fantasyMsgs := kit.ConvertToFantasyMessages(&userMsg)
if len(fantasyMsgs) == 0 {
t.Error("ConvertToFantasyMessages returned empty slice")
llmMsgs := kit.ConvertToLLMMessages(&userMsg)
if len(llmMsgs) == 0 {
t.Error("ConvertToLLMMessages returned empty slice")
}
roundTrip := kit.ConvertFromFantasyMessage(fantasyMsgs[0])
roundTrip := kit.ConvertFromLLMMessage(llmMsgs[0])
if roundTrip.Content() != "test" {
t.Errorf("round-trip Content() = %q, want %q", roundTrip.Content(), "test")
}
+123
View File
@@ -1210,6 +1210,129 @@ func applyMode(ctx ext.Context, active bool, tools []string) {
}
```
---
## Bridged SDK APIs (New)
Extensions can now access powerful internal SDK capabilities that enable advanced features like conversation tree navigation, dynamic skill loading, template parsing, and model resolution.
### Tree Navigation
Navigate the conversation tree, summarize branches, and implement "fresh context" loops:
```go
// Get a specific node by ID with full metadata and children
node := ctx.GetTreeNode("entry-id")
// node.ID, node.ParentID, node.Type ("message"/"branch_summary"/etc)
// node.Role, node.Content, node.Model, node.Children ([]string)
// Get the current branch from root to leaf
branch := ctx.GetCurrentBranch() // []ext.TreeNode
// Get child entry IDs of a node
children := ctx.GetChildren("entry-id") // []string
// Navigate/fork to a different entry in the tree
result := ctx.NavigateTo("entry-id") // ext.TreeNavigationResult{Success, Error}
// Summarize a range of the branch using LLM
summary := ctx.SummarizeBranch("from-id", "to-id") // string
// Collapse a branch range into a summary entry (fresh context primitive)
result := ctx.CollapseBranch("from-id", "to-id", "summary text")
```
### Skill Loading
Load and inject skills dynamically at runtime:
```go
// Discover skills from standard locations
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
// Load a specific skill file
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
// Load all skills from a directory
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
// Inject a skill as context (pre-loads for next turn)
err := ctx.InjectSkillAsContext("skill-name") // error string
// Inject a skill file directly
err := ctx.InjectRawSkillAsContext("/path/to/skill.md") // error string
// Get all discovered skills
skills := ctx.GetAvailableSkills() // []ext.Skill
```
### Template Parsing
Parse and render templates with variable substitution:
```go
// Parse a template to extract {{variables}}
tpl := ctx.ParseTemplate("name", "Hello {{name}}, welcome to {{place}}!")
// tpl.Name, tpl.Content, tpl.Variables ([]string)
// Render a template with variable values
vars := map[string]string{"name": "Alice", "place": "Kit"}
rendered := ctx.RenderTemplate(tpl, vars) // "Hello Alice, welcome to Kit!"
// Parse command-line style arguments
pattern := ext.ArgumentPattern{
Positional: []string{"command", "target"}, // $1, $2
Rest: "args", // $@
Flags: map[string]string{"--loop": "loop", "-f": "force"},
}
result := ctx.ParseArguments("deploy staging --loop 5", pattern)
// result.Vars["command"] = "deploy"
// result.Vars["target"] = "staging"
// result.Flags["--loop"] = "5"
// Simple positional argument parsing ($1, $2, $@)
args := ctx.SimpleParseArguments("deploy staging --force", 2)
// args[0] = "deploy staging --force" (full input)
// args[1] = "deploy" ($1)
// args[2] = "staging" ($2)
// args[3] = "--force" ($@)
// Evaluate model conditionals with wildcards
matches := ctx.EvaluateModelConditional("claude-*") // bool
// Patterns: * matches any, ? matches single char, comma = OR
// Render content with <if-model> conditionals
content := `<if-model is="claude-*">Hi Claude<else>Hi there</if-model>`
rendered := ctx.RenderWithModelConditionals(content) // based on current model
```
### Model Resolution
Resolve model fallback chains and query capabilities:
```go
// Resolve a chain of model preferences (tries each until available)
result := ctx.ResolveModelChain([]string{
"anthropic/claude-opus-4",
"anthropic/claude-sonnet-4",
"openai/gpt-4o",
})
// result.Model (selected), result.Capabilities, result.Attempted, result.Error
// Get capabilities for a specific model
caps, err := ctx.GetModelCapabilities("anthropic/claude-sonnet-4")
// caps.Provider, caps.ModelID, caps.ContextLimit, caps.Reasoning, caps.Streaming
// Check if a model is available (provider exists)
available := ctx.CheckModelAvailable("anthropic/claude-sonnet-4") // bool
// Get current provider/model ID
provider := ctx.GetCurrentProvider() // "anthropic"
modelID := ctx.GetCurrentModelID() // "claude-sonnet-4"
```
## Key Files for Reference
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
+64 -26
View File
@@ -119,17 +119,15 @@ result, err := host.PromptResult(ctx, "Analyze this file")
// result.Response — assistant's text
// result.StopReason — "stop", "length", "tool-calls", "error", etc.
// result.SessionID — session UUID
// result.TotalUsage — aggregate tokens across all steps (*kit.FantasyUsage)
// result.TotalUsage — aggregate tokens across all steps (*kit.LLMUsage)
// result.FinalUsage — tokens from last API call only
// result.Messages — full updated conversation ([]kit.FantasyMessage)
// result.Messages — full updated conversation ([]kit.LLMMessage)
```
### Multimodal with file attachments
```go
import "charm.land/fantasy"
files := []fantasy.FilePart{{
files := []kit.LLMFilePart{{
Name: "screenshot.png",
MediaType: "image/png",
Data: imageBytes,
@@ -167,16 +165,6 @@ result, err := host.PromptResultWithMessages(ctx, []string{
})
```
### Legacy inline callbacks (deprecated — use event subscribers instead)
```go
response, err := host.PromptWithCallbacks(ctx, "List files",
func(name, args string) { fmt.Printf("Tool: %s\n", name) },
func(name, args, result string, isError bool) { /* tool result */ },
func(chunk string) { fmt.Print(chunk) }, // streaming
)
```
---
## Event System
@@ -252,6 +240,8 @@ unsub := host.Subscribe(func(e kit.Event) {
| `response` | `ResponseEvent` | `Content` |
| `compaction` | `CompactionEvent` | `Summary`, `OriginalTokens`, `CompactedTokens`, `MessagesRemoved`, `ReadFiles`, `ModifiedFiles` |
| `reasoning_delta` | `ReasoningDeltaEvent` | `Delta` |
| `step_usage` | `StepUsageEvent` | `InputTokens`, `OutputTokens`, `CacheReadTokens`, `CacheWriteTokens` |
| `steer_consumed` | `SteerConsumedEvent` | `Count` |
### Tool kind constants
@@ -261,7 +251,7 @@ Tools are classified by kind for UI rendering:
- `ToolKindEdit` = `"edit"` — edit, write
- `ToolKindRead` = `"read"` — read, ls
- `ToolKindSearch` = `"search"` — grep, find
- `ToolKindSubagent` = `"agent"`spawn_subagent
- `ToolKindSubagent` = `"agent"` — subagent
---
@@ -318,7 +308,7 @@ host.OnAfterTurn(kit.HookPriorityNormal, func(h kit.AfterTurnHook) {
```go
host.OnContextPrepare(kit.HookPriorityNormal, func(h kit.ContextPrepareHook) *kit.ContextPrepareResult {
// h.Messages — []fantasy.Message (the full context being sent to the LLM)
// h.Messages — []kit.LLMMessage (the full context being sent to the LLM)
// Return nil to pass through, or replace entire context:
return &kit.ContextPrepareResult{Messages: filteredMessages}
})
@@ -368,7 +358,7 @@ kit.NewLsTool(opts...) // directory listing
kit.AllTools(opts...) // all 7 core tools
kit.CodingTools(opts...) // bash, read, write, edit
kit.ReadOnlyTools(opts...) // read, grep, find, ls
kit.SubagentTools(opts...) // all except spawn_subagent (prevents recursion)
kit.SubagentTools(opts...) // all except subagent (prevents recursion)
```
### Tool options
@@ -467,7 +457,7 @@ err = host.SetThinkingLevel(ctx, "medium") // recreates agent with new thinking
```go
models := host.GetAvailableModels() // []extensions.ModelInfoEntry
providers := kit.GetSupportedProviders() // []string
providers := kit.GetFantasyProviders() // providers usable with fantasy
providers := kit.GetLLMProviders() // providers with LLM support
models, _ := kit.GetModelsForProvider("anthropic") // map[string]kit.ModelInfo
info := kit.LookupModel("anthropic", "claude-sonnet-4-5-20250929") // *kit.ModelInfo
info := kit.GetProviderInfo("openai") // *kit.ProviderInfo (env vars, API URL)
@@ -524,7 +514,7 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
Prompt: "Analyze the test files and summarize coverage",
Model: "anthropic/claude-haiku-3-5-20241022", // empty = parent's model
SystemPrompt: "You are a test analysis expert.",
Tools: nil, // nil = SubagentTools() (all except spawn_subagent)
Tools: nil, // nil = SubagentTools() (all except subagent)
NoSession: true, // ephemeral
Timeout: 2 * time.Minute, // 0 = 5 minute default
OnEvent: func(e kit.Event) {
@@ -535,14 +525,14 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
},
})
// result.Response, result.Error, result.SessionID, result.StopReason
// result.Usage (*kit.FantasyUsage), result.Elapsed (time.Duration)
// result.Usage (*kit.LLMUsage), result.Elapsed (time.Duration)
```
### Subscribing to subagent events from parent
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
if e.ToolName == "subagent" {
host.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
// Real-time events scoped to this subagent
})
@@ -552,6 +542,53 @@ host.OnToolCall(func(e kit.ToolCallEvent) {
---
## Extension API
The `Extensions()` method returns an `ExtensionAPI` interface that groups all extension-related functionality. This is the primary way to interact with extension state from the SDK.
```go
extAPI := host.Extensions()
// Check if extensions are loaded
if extAPI.HasExtensions() {
// Context management
extAPI.SetContext(extensions.Context{...})
ctx := extAPI.GetContext()
extAPI.UpdateContextModel("anthropic/claude-sonnet-4-5-20250929")
// Widgets, headers, footers
extAPI.SetWidget(extensions.WidgetConfig{...})
extAPI.RemoveWidget("widget-id")
extAPI.SetHeader(extensions.HeaderFooterConfig{...})
extAPI.SetFooter(extensions.HeaderFooterConfig{...})
// Status bar
extAPI.SetStatus(extensions.StatusBarEntry{...})
extAPI.RemoveStatus("key")
// Options
extAPI.SetOption("name", "value")
val := extAPI.GetOption("name")
// Tools
tools := extAPI.GetToolInfos()
extAPI.SetActiveTools([]string{"bash", "read"})
// Events
extAPI.EmitSessionStart()
extAPI.EmitModelChange("new/model", "old/model", "extension")
extAPI.EmitCustomEvent("my-event", "data")
// Commands and lifecycle
cmds := extAPI.Commands()
err := extAPI.Reload()
}
```
All methods are no-ops when extensions are disabled (nil runner), so callers don't need nil checks.
---
## Authentication
```go
@@ -603,15 +640,15 @@ kit.Config, kit.MCPServerConfig
// Provider types
kit.ProviderConfig, kit.ProviderResult, kit.ModelInfo, kit.ModelCost, kit.ModelLimit
// Fantasy types (from charm.land/fantasy)
kit.FantasyMessage, kit.FantasyUsage, kit.FantasyResponse
// LLM types (re-exported from the underlying LLM library)
kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart
// Compaction types
kit.CompactionResult, kit.CompactionOptions
// Conversion helpers
msgs := kit.ConvertToFantasyMessages(&msg) // SDK message → fantasy messages
msg := kit.ConvertFromFantasyMessage(fMsg) // fantasy message → SDK message
msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages
msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message
```
---
@@ -759,6 +796,7 @@ kit.LoadConfigWithEnvSubstitution("/path/to/config.yml")
## Key Files for Reference
- [`pkg/kit/kit.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/kit.go) — Kit struct, New(), Prompt methods, Subagent, Close
- [`pkg/kit/extension_api.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/extension_api.go) — ExtensionAPI interface, kit.Extensions() accessor
- [`pkg/kit/types.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/types.go) — Re-exported types from internal packages
- [`pkg/kit/tools.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/tools.go) — Tool constructors and bundles
- [`pkg/kit/events.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/events.go) — Event types, EventBus, typed subscribers
+78 -5
View File
@@ -32,12 +32,12 @@ Key flags for subprocess usage:
Positional arguments are the prompt. `@file` arguments attach file content as context.
## Built-in spawn_subagent tool
## Built-in subagent tool
Kit includes a built-in `spawn_subagent` tool that the LLM can use to delegate tasks to independent child agents:
Kit includes a built-in `subagent` tool that the LLM can use to delegate tasks to independent child agents:
```
spawn_subagent(
subagent(
task: "Analyze the test files and summarize coverage",
model: "anthropic/claude-haiku-latest", // optional
system_prompt: "You are a test analysis expert.", // optional
@@ -59,6 +59,79 @@ result := ctx.SpawnSubagent(ext.SubagentConfig{
})
```
### Monitoring subagents from extensions
When the LLM (not the extension itself) spawns a subagent using the `subagent` tool, extensions can monitor its activity in real-time using three lifecycle event handlers:
```go
// Track active subagents and display their output
var subagentWidgets map[string]*SubagentWidget
func Init(api ext.API) {
// Subagent started by the main agent
api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) {
// e.ToolCallID — unique ID for this subagent invocation
// e.Task — the task/prompt sent to the subagent
widget := NewWidget(e.ToolCallID, e.Task)
subagentWidgets[e.ToolCallID] = widget
ctx.SetWidget(widget.Config())
})
// Real-time streaming from subagent
api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) {
// e.ToolCallID — matches the start event
// e.ChunkType — "text", "tool_call", "tool_execution_start", "tool_result"
// e.Content — text content
// e.ToolName — tool name (for tool chunks)
// e.IsError — true if tool result failed
widget := subagentWidgets[e.ToolCallID]
if widget != nil {
widget.AddOutput(e)
ctx.SetWidget(widget.Config())
}
})
// Subagent completed
api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) {
// e.Response — final response from subagent
// e.ErrorMsg — error message if subagent failed
widget := subagentWidgets[e.ToolCallID]
if widget != nil {
widget.MarkComplete(e.Response, e.ErrorMsg)
ctx.SetWidget(widget.Config())
delete(subagentWidgets, e.ToolCallID)
}
})
}
```
**Event structs:**
```go
type SubagentStartEvent struct {
ToolCallID string // Unique ID for this subagent invocation
Task string // The task/prompt sent to subagent
}
type SubagentChunkEvent struct {
ToolCallID string // Matches SubagentStartEvent.ToolCallID
Task string // Task description
ChunkType string // "text", "tool_call", "tool_execution_start", "tool_result"
Content string // For text chunks
ToolName string // For tool-related chunks
IsError bool // For tool_result chunks
}
type SubagentEndEvent struct {
ToolCallID string // Matches start event
Task string // Task description
Response string // Final response from subagent
ErrorMsg string // Error message if failed
}
```
This enables building monitoring widgets that display real-time activity from all subagents spawned by the main agent.
## Go SDK subagents
The SDK provides in-process subagent spawning:
@@ -74,11 +147,11 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
### Real-time subagent events
Use `SubscribeSubagent` to receive real-time events from LLM-initiated subagents (i.e., when the model uses the `spawn_subagent` tool). Register inside an `OnToolCall` handler using the tool call ID:
Use `SubscribeSubagent` to receive real-time events from LLM-initiated subagents (i.e., when the model uses the `subagent` tool). Register inside an `OnToolCall` handler using the tool call ID:
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
if e.ToolName == "subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
switch ev := event.(type) {
case kit.MessageUpdateEvent:

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