mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aede76d807 | |||
| 9e1df38836 | |||
| 8f5efee837 | |||
| a392d3e572 | |||
| c40dc2f4fb | |||
| 37e82781b1 | |||
| 23c16bb197 | |||
| 9449f1fcdf | |||
| dc59cfc81e | |||
| 8407d924b9 | |||
| 91474af503 | |||
| e252791b3a |
@@ -39,12 +39,42 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: npm
|
||||
run: |
|
||||
TAG=${{ inputs.tag || github.ref_name }}
|
||||
VERSION=${TAG#v}
|
||||
echo "Setting npm version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [goreleaser, npm-publish]
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') && (needs.npm-publish.result == 'success') }}
|
||||
steps:
|
||||
- name: Send Discord Notification
|
||||
if: success()
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.RELEASES_WEBHOOK }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}
|
||||
TAG_NAME: ${{ inputs.tag || github.ref_name }}
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
curl -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
@@ -73,29 +103,3 @@ jobs:
|
||||
}]
|
||||
}" \
|
||||
$DISCORD_WEBHOOK
|
||||
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: npm
|
||||
run: |
|
||||
TAG=${{ inputs.tag || github.ref_name }}
|
||||
VERSION=${TAG#v}
|
||||
echo "Setting npm version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
# Kit vs Pi Extension System: Comprehensive Gap Analysis
|
||||
|
||||
> Generated: 2026-03-01
|
||||
> Source: [pi-mono extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Pi's extension ecosystem contains **57+ example extensions** spanning safety guards, git integration, custom providers, session management, resource discovery, and advanced UI patterns. Kit has **10 example extensions** and a solid foundation with 13 lifecycle events and a rich widget/overlay/editor system, but lacks several critical API surfaces that Pi exposes. The gaps fall into three tiers:
|
||||
|
||||
- **Critical (17 gaps)**: Missing API capabilities that block entire categories of extensions
|
||||
- **Moderate (7 gaps)**: Capabilities that exist but lack depth compared to Pi
|
||||
- **Covered (14 areas)**: Capabilities where Kit has parity or near-parity
|
||||
|
||||
---
|
||||
|
||||
## Pi Extension Inventory (57 extensions)
|
||||
|
||||
### Safety & Lifecycle (5)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `permission-gate.ts` | Confirms dangerous bash commands (rm -rf, sudo) |
|
||||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/) |
|
||||
| `confirm-destructive.ts` | Confirms destructive session actions (clear, fork) |
|
||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git |
|
||||
| `sandbox/` | OS-level sandboxing via `@anthropic-ai/sandbox-runtime` |
|
||||
|
||||
### Git Integration (2)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `git-checkpoint.ts` | Git stash checkpoints per turn, restore on fork |
|
||||
| `auto-commit-on-exit.ts` | Auto-commits using assistant message as commit msg |
|
||||
|
||||
### Custom Tools (11)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `hello.ts` | Minimal custom tool |
|
||||
| `question.ts` | Agent-initiated user questions with custom UI |
|
||||
| `questionnaire.ts` | Multi-question tabbed tool |
|
||||
| `tool-override.ts` | Override built-in tools |
|
||||
| `built-in-tool-renderer.ts` | Custom compact rendering for built-in tools |
|
||||
| `minimal-mode.ts` | Override all tool rendering for minimal display |
|
||||
| `truncated-tool.ts` | Ripgrep with output truncation |
|
||||
| `antigravity-image-gen.ts` | Image generation with external API |
|
||||
| `ssh.ts` | Delegate tools to remote via SSH |
|
||||
| `subagent/` | Delegate to specialized subagents |
|
||||
| `todo.ts` | Todo list tool with state persistence |
|
||||
|
||||
### Commands & UI (25)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `preset.ts` | Named presets (model, thinking, tools, instructions) |
|
||||
| `plan-mode/` | Read-only exploration with /plan command |
|
||||
| `tools.ts` | Interactive /tools to enable/disable tools |
|
||||
| `handoff.ts` | Transfer context to new session |
|
||||
| `qna.ts` | Extract questions from response into editor |
|
||||
| `commands.ts` | /commands with introspection and tab completion |
|
||||
| `model-status.ts` | Model change notifications in status bar |
|
||||
| `send-user-message.ts` | Programmatic message injection (3 modes) |
|
||||
| `timed-confirm.ts` | Auto-dismissing dialogs with AbortSignal |
|
||||
| `rpc-demo.ts` | Full UI method catalog |
|
||||
| `modal-editor.ts` | Vim-like modal editor (full editor replacement) |
|
||||
| `rainbow-editor.ts` | Animated rainbow editor |
|
||||
| `notify.ts` | Desktop OS notifications |
|
||||
| `titlebar-spinner.ts` | Terminal title animations |
|
||||
| `summarize.ts` | Conversation summarization |
|
||||
| `custom-footer.ts` | Custom footer with git branch + token stats |
|
||||
| `custom-header.ts` | Custom ASCII art header |
|
||||
| `overlay-test.ts` | Custom overlay with Focusable components |
|
||||
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests |
|
||||
| `doom-overlay/` | DOOM game running as overlay |
|
||||
| `shutdown-command.ts` | /quit command via ctx.shutdown() |
|
||||
| `reload-runtime.ts` | Hot reload extensions at runtime |
|
||||
| `interactive-shell.ts` | Full terminal takeover for vim/htop |
|
||||
| `inline-bash.ts` | !{command} expansion in prompts |
|
||||
| `snake.ts` | Snake game with custom UI |
|
||||
|
||||
### System Prompt & Compaction (4)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `pirate.ts` | System prompt append |
|
||||
| `claude-rules.ts` | Project rules loader from .claude/rules/ |
|
||||
| `custom-compaction.ts` | Custom compaction with cross-model summarization |
|
||||
| `trigger-compact.ts` | Auto-trigger compaction at token threshold |
|
||||
|
||||
### System Integration (1)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `mac-system-theme.ts` | Syncs theme with macOS dark/light mode |
|
||||
|
||||
### Resources (1)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `dynamic-resources/` | Dynamic skill/prompt/theme loading |
|
||||
|
||||
### Messages & Communication (2)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `message-renderer.ts` | Custom message rendering with expandable details |
|
||||
| `event-bus.ts` | Inter-extension pub/sub communication |
|
||||
|
||||
### Session Metadata (2)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `session-name.ts` | Name sessions for selector |
|
||||
| `bookmark.ts` | Bookmark entries with labels for /tree |
|
||||
|
||||
### Custom Providers (3)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `custom-provider-anthropic/` | Custom Anthropic provider with OAuth |
|
||||
| `custom-provider-gitlab-duo/` | GitLab Duo provider |
|
||||
| `custom-provider-qwen-cli/` | Qwen CLI provider |
|
||||
|
||||
### External Dependencies (1)
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `with-deps/` | Extension with own package.json |
|
||||
|
||||
---
|
||||
|
||||
## Kit Extension Inventory (10 extensions)
|
||||
|
||||
| Extension | Description |
|
||||
|---|---|
|
||||
| `minimal.go` | UI visibility, footer, context stats polling |
|
||||
| `widget-status.go` | Persistent widgets, OnToolResult, input commands |
|
||||
| `tool-logger.go` | Tool event logging, PrintBlock with styling |
|
||||
| `header-footer-demo.go` | Custom header/footer with slash commands |
|
||||
| `prompt-demo.go` | All 3 prompt types + chained workflow |
|
||||
| `overlay-demo.go` | Modal overlay dialogs (info, actions, markdown, scroll) |
|
||||
| `custom-editor-demo.go` | Vim-like editor interceptor |
|
||||
| `tool-renderer-demo.go` | Custom tool rendering for read/bash |
|
||||
| `subagent-widget.go` | Background subprocess agents with live widgets |
|
||||
| `kit-kit.go` | Meta-agent with parallel experts, grid widget |
|
||||
|
||||
---
|
||||
|
||||
## Gap Analysis: Critical Gaps (Missing API Capabilities)
|
||||
|
||||
### Gap 1: Session Management API
|
||||
**Pi has:** `ctx.sessionManager` with full conversation access
|
||||
- `getEntries()` / `getBranch()` -- Read conversation history
|
||||
- `getLeafEntry()` -- Current leaf entry
|
||||
- `getLabel(entryId)` / `pi.setLabel()` -- Entry metadata/labeling
|
||||
- `getSessionFile()` -- Session file path
|
||||
|
||||
**Kit has:** Nothing. Extensions cannot read conversation history.
|
||||
|
||||
**Impact:** Blocks auto-commit (needs last assistant message), git checkpoints (needs entry IDs), handoff (needs full conversation), QnA extraction, state restoration on session resume, bookmark labeling.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `GetMessages func() []MessageEntry` to `Context` returning conversation messages
|
||||
- Add `GetCurrentEntryID func() string` for session tree position
|
||||
- Add `SetLabel func(entryId, label string)` / `GetLabel func(entryId string) string`
|
||||
- Wire in `cmd/root.go` via closures reading from the session store
|
||||
|
||||
---
|
||||
|
||||
### Gap 2: Model Management API
|
||||
**Pi has:**
|
||||
- `ctx.modelRegistry.find(provider, model)` -- Look up models
|
||||
- `ctx.modelRegistry.getApiKey(model)` -- Get API keys
|
||||
- `pi.setModel(model)` -- Change active model at runtime
|
||||
- `pi.setThinkingLevel(level)` -- Set reasoning budget
|
||||
|
||||
**Kit has:** `ctx.Model string` (read-only model name)
|
||||
|
||||
**Impact:** Blocks preset system (model switching), custom compaction (cross-model calls), QnA extraction (direct LLM calls), any extension needing to invoke a different model.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `SetModel func(provider, model string) error` to `Context`
|
||||
- Add `GetAvailableModels func() []ModelInfo` returning provider/model/context info
|
||||
- Add `GetAPIKey func(provider string) string` for credential access
|
||||
- Add `SetThinkingLevel func(level string)` for reasoning budget control
|
||||
- Wire through to the existing `llm.Provider` interface
|
||||
|
||||
---
|
||||
|
||||
### Gap 3: Tool Management API
|
||||
**Pi has:**
|
||||
- `pi.getAllTools()` -- List all registered tools
|
||||
- `pi.setActiveTools(names)` -- Enable/disable specific tools
|
||||
|
||||
**Kit has:** Nothing for tool introspection or filtering.
|
||||
|
||||
**Impact:** Blocks plan-mode (restricts tools to read-only set), preset system (tool filtering), /tools interactive toggle, any policy-based tool restriction.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `GetAllTools func() []ToolInfo` to `Context` with name, description, enabled status
|
||||
- Add `SetActiveTools func(names []string)` to filter which tools the LLM can use
|
||||
- Add `IsToolEnabled func(name string) bool` for individual checks
|
||||
- Integrate with the existing tool wrapper pipeline in `wrapper.go`
|
||||
|
||||
---
|
||||
|
||||
### Gap 4: Session Lifecycle Events (Before-hooks with Cancel)
|
||||
**Pi has:**
|
||||
- `session_before_switch` -- Can cancel session switching
|
||||
- `session_before_fork` -- Can cancel forking
|
||||
- `session_switch` -- React to session changes
|
||||
|
||||
**Kit has:** `OnSessionStart` and `OnSessionShutdown` only. No before-hooks, no cancel capability, no fork/branch events.
|
||||
|
||||
**Impact:** Blocks dirty-repo-guard, confirm-destructive, git-checkpoint (restore on fork), any defensive workflow that needs to gate session operations.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `OnSessionBeforeSwitch` event with `SessionBeforeSwitchResult{Cancel bool, Reason string}`
|
||||
- Add `OnSessionBeforeFork` event with similar cancel capability
|
||||
- Add `OnSessionSwitch` event for post-switch notifications
|
||||
- Emit these from session management code before performing operations
|
||||
|
||||
---
|
||||
|
||||
### Gap 5: Compaction Events
|
||||
**Pi has:** `session_before_compact` allowing custom compaction strategies (e.g., summarize entire conversation with a cheaper model instead of truncating).
|
||||
|
||||
**Kit has:** Nothing.
|
||||
|
||||
**Impact:** Blocks custom-compaction and trigger-compact patterns. Users cannot customize how context compaction works.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `OnBeforeCompact` event with `BeforeCompactEvent{EstimatedTokens, ContextLimit int}`
|
||||
- Result type: `BeforeCompactResult{Summary *string, FirstKeptEntryID *string}`
|
||||
- If extension returns a summary, use it instead of default compaction
|
||||
- Add `TriggerCompact func()` to `Context` for manual compaction triggers
|
||||
|
||||
---
|
||||
|
||||
### Gap 6: Custom Provider Registration
|
||||
**Pi has:** `pi.registerProvider()` allowing extensions to register complete LLM providers with streaming, OAuth, and model definitions.
|
||||
|
||||
**Kit has:** No extension-facing provider registration. Providers are compiled-in via the `llm.Provider` interface.
|
||||
|
||||
**Impact:** Blocks custom-provider-anthropic, custom-provider-gitlab-duo, custom-provider-qwen-cli patterns. Users cannot add new LLM backends via extensions.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `RegisterProvider(ProviderDef)` to `API` with:
|
||||
- `Name string`, `Models []ModelDef`
|
||||
- `Stream func(model, messages, options) StreamResult`
|
||||
- This is a large undertaking. The `ProviderDef` would need to bridge to the compiled `llm.Provider` interface.
|
||||
- **Yaegi limitation:** Complex streaming interfaces may hit Yaegi's interface generation bugs. May need concrete struct wrappers.
|
||||
- **Priority:** Lower -- this is architecturally complex and has narrow use cases.
|
||||
|
||||
---
|
||||
|
||||
### Gap 7: CLI Flag Registration
|
||||
**Pi has:** `pi.registerFlag("preset", {description, type})` and `pi.getFlag("preset")` allowing extensions to add CLI flags.
|
||||
|
||||
**Kit has:** Nothing. Extensions cannot influence CLI argument parsing.
|
||||
|
||||
**Impact:** Blocks preset system (--preset flag), plan-mode (--plan flag), sandbox (--no-sandbox flag).
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `RegisterFlag(FlagDef)` to `API` with `Name, Description, Type string, Default any`
|
||||
- Add `GetFlag func(name string) any` to `Context`
|
||||
- Parse extension flags after loading extensions but before `Init()`
|
||||
- Store in a `map[string]any` on the Runner
|
||||
|
||||
---
|
||||
|
||||
### Gap 8: Keyboard Shortcut Registration
|
||||
**Pi has:** `pi.registerShortcut(Key.ctrlShift("u"), {description, handler})` for global keyboard shortcuts.
|
||||
|
||||
**Kit has:** Nothing. Only editor interceptors can handle keys, and only when the editor has focus.
|
||||
|
||||
**Impact:** Blocks global shortcuts like Ctrl+Alt+P for plan mode toggle, Ctrl+Shift+U for preset switching.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `RegisterShortcut(ShortcutDef)` to `API` with `Key string, Description string, Handler func(Context)`
|
||||
- Bridge to BubbleTea's key handling in `model.go` Update() method
|
||||
- Query registered shortcuts from Runner in the key dispatch path
|
||||
|
||||
---
|
||||
|
||||
### Gap 9: Custom Message Rendering
|
||||
**Pi has:** `pi.registerMessageRenderer(customType, renderFn)` for custom visual rendering of specific message types (not just tool results).
|
||||
|
||||
**Kit has:** `RegisterToolRenderer` for tool-specific rendering only. No general message renderer.
|
||||
|
||||
**Impact:** Blocks status-update messages, extension-branded messages, and any custom message type that needs bespoke visual treatment.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `RegisterMessageRenderer(MessageRenderConfig)` to `API` with:
|
||||
- `CustomType string` -- message type to match
|
||||
- `Render func(content, details string, expanded bool, width int) string`
|
||||
- Integrate with the stream component's message rendering pipeline
|
||||
|
||||
---
|
||||
|
||||
### Gap 10: Programmatic Editor Control
|
||||
**Pi has:**
|
||||
- `ctx.ui.setEditorText(text)` -- Pre-fill the input editor
|
||||
- `ctx.ui.setEditorComponent(factory)` -- Replace the entire editor
|
||||
|
||||
**Kit has:** `SetEditor(EditorConfig)` which is an interceptor (HandleKey/Render) but does NOT allow setting editor text or full replacement.
|
||||
|
||||
**Impact:** Blocks QnA (pre-fill editor with extracted questions), handoff (pre-fill with handoff prompt), and full editor replacement patterns.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `SetEditorText func(text string)` to `Context` -- inserts text into the active editor
|
||||
- Optionally add `SetEditorComponent func(EditorComponentConfig)` for full replacement (complex due to BubbleTea integration)
|
||||
|
||||
---
|
||||
|
||||
### Gap 11: Turn-Level Events
|
||||
**Pi has:**
|
||||
- `turn_start` -- Fires when a new LLM turn begins
|
||||
- `turn_end` -- Fires when a turn completes
|
||||
|
||||
**Kit has:** `OnAgentStart`/`OnAgentEnd` which fire at the agent loop level (may span multiple turns), and `OnMessageStart`/`OnMessageEnd` for streaming. No dedicated turn boundary events.
|
||||
|
||||
**Impact:** Blocks git-checkpoint (create stash per turn), plan-mode (track done markers per turn), preset (persist state per turn), progress tracking.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `OnTurnStart(func(TurnStartEvent, Context))` and `OnTurnEnd(func(TurnEndEvent, Context))`
|
||||
- `TurnStartEvent{TurnNumber int, Prompt string}`
|
||||
- `TurnEndEvent{TurnNumber int, Response string, StopReason string}`
|
||||
- Emit from the agent loop between turns
|
||||
|
||||
---
|
||||
|
||||
### Gap 12: Context Filtering Event
|
||||
**Pi has:** `pi.on("context", ...)` -- Lets extensions filter/modify messages before sending to the LLM. Returns `{messages: [...]}` to replace the context window.
|
||||
|
||||
**Kit has:** Nothing. Extensions cannot influence what messages the LLM sees.
|
||||
|
||||
**Impact:** Blocks plan-mode (filter stale messages), any extension needing to manage context window content, RAG-style context injection.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `OnContextPrepare(func(ContextPrepareEvent, Context) *ContextPrepareResult)`
|
||||
- `ContextPrepareEvent{Messages []MessageEntry}`
|
||||
- `ContextPrepareResult{Messages []MessageEntry}` -- return filtered/modified set
|
||||
- Emit just before sending messages to the LLM provider
|
||||
|
||||
---
|
||||
|
||||
### Gap 13: Inter-Extension Event Bus
|
||||
**Pi has:** `pi.events.on(name, handler)` / `pi.events.emit(name, data)` for decoupled inter-extension communication.
|
||||
|
||||
**Kit has:** Nothing. Extensions are isolated; they cannot communicate with each other.
|
||||
|
||||
**Impact:** Blocks coordinated multi-extension workflows (e.g., theme extension reacting to mode changes from another extension).
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `OnCustomEvent func(name string, handler func(data string))` to `API`
|
||||
- Add `EmitCustomEvent func(name, data string)` to `Context`
|
||||
- Store handlers in Runner's event map, dispatch via `Emit`
|
||||
|
||||
---
|
||||
|
||||
### Gap 14: Session Persistence for Extensions
|
||||
**Pi has:** `pi.appendEntry(customType, data)` -- Persists extension-specific data in the session journal. Survives across session resume.
|
||||
|
||||
**Kit has:** Nothing. Extension state is ephemeral (package-level vars lost on restart).
|
||||
|
||||
**Impact:** Blocks preset state restoration, plan-mode progress persistence, todo list persistence across sessions, any extension needing durable state.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `AppendEntry func(entryType string, data string)` to `Context`
|
||||
- Add `GetEntries func(entryType string) []string` to `Context` for retrieval
|
||||
- Store in session file as custom entry types
|
||||
- Emit entries during `OnSessionStart` for restoration
|
||||
|
||||
---
|
||||
|
||||
### Gap 15: Resource Discovery System
|
||||
**Pi has:** `resources_discover` event where extensions can dynamically register skills, prompts, and themes by returning file paths.
|
||||
|
||||
**Kit has:** Nothing. No concept of dynamic resource loading.
|
||||
|
||||
**Impact:** Blocks dynamic-resources pattern. Extensions cannot contribute prompts, skills, or themes at runtime.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `OnResourceDiscovery(func(ResourceDiscoveryEvent, Context) *ResourceDiscoveryResult)`
|
||||
- `ResourceDiscoveryResult{SkillPaths, PromptPaths, ThemePaths []string}`
|
||||
- Integrate with any future resource/skill loading system
|
||||
|
||||
---
|
||||
|
||||
### Gap 16: Programmatic Shutdown and Reload
|
||||
**Pi has:**
|
||||
- `ctx.shutdown()` -- Programmatically quit the application
|
||||
- `ctx.reload()` -- Hot-reload all extensions at runtime
|
||||
|
||||
**Kit has:** Neither capability.
|
||||
|
||||
**Impact:** Blocks shutdown-command, reload-runtime patterns. Extensions cannot control app lifecycle.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `Shutdown func()` to `Context` -- triggers graceful shutdown
|
||||
- Add `Reload func() error` to `Context` -- reloads all extensions
|
||||
- Wire via BubbleTea Quit msg and loader re-initialization
|
||||
|
||||
---
|
||||
|
||||
### Gap 17: Direct LLM Completion from Extensions
|
||||
**Pi has:** Extensions can call `complete()` from `@mariozechner/pi-ai` to make LLM calls outside the main agent loop (e.g., summarization, question extraction, handoff generation).
|
||||
|
||||
**Kit has:** No way for extensions to invoke LLM completions directly. Extensions can only spawn Kit subprocesses.
|
||||
|
||||
**Impact:** Blocks in-process LLM calls for summarization, QnA extraction, context transfer. The subprocess pattern works but is heavier.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `Complete func(CompleteRequest) (string, error)` to `Context`
|
||||
- `CompleteRequest{Model, SystemPrompt string, Messages []SimpleMessage}`
|
||||
- Wire through to existing `llm.Provider.Complete()` method
|
||||
- Consider rate limiting and cost awareness
|
||||
|
||||
---
|
||||
|
||||
## Gap Analysis: Moderate Gaps (Partial Coverage)
|
||||
|
||||
### Gap M1: Tool Registration Depth
|
||||
**Pi has:** `renderCall(args, theme)`, `renderResult(result, {expanded, isPartial}, theme)` directly on tool definition. Also `onUpdate` streaming callback, `AbortSignal`, and TypeBox schemas.
|
||||
|
||||
**Kit has:** Separate `RegisterToolRenderer()` and simpler `RegisterTool()` with JSON schema string and basic execute handler.
|
||||
|
||||
**Implementation approach:** Enhance `ToolDef` with optional `RenderHeader`/`RenderBody` fields. Add `onUpdate func(string)` to execute handler for streaming tool progress. Add abort/cancel context.
|
||||
|
||||
---
|
||||
|
||||
### Gap M2: Command Tab Completion
|
||||
**Pi has:** `getArgumentCompletions(prefix)` on command registration for tab-completing command arguments.
|
||||
|
||||
**Kit has:** `RegisterCommand()` without completion support.
|
||||
|
||||
**Implementation approach:** Add optional `Complete func(prefix string) []string` to `CommandDef`.
|
||||
|
||||
---
|
||||
|
||||
### Gap M3: Keyed Status Bar Entries
|
||||
**Pi has:** `ctx.ui.setStatus(key, text)` for multiple independent status bar indicators.
|
||||
|
||||
**Kit has:** `SetFooter(HeaderFooterConfig)` as a single custom footer, not keyed status entries.
|
||||
|
||||
**Implementation approach:** Add `SetStatus func(key, text string)` / `RemoveStatus func(key string)` to `Context`. Render all keyed entries in the status bar region.
|
||||
|
||||
---
|
||||
|
||||
### Gap M4: Full Custom TUI Components
|
||||
**Pi has:** `ctx.ui.custom<T>(factory)` where factory receives `(tui, theme, keybindings, done)` and returns a `Focusable` component. Supports overlays and full TUI takeover (including `tui.stop()`/`tui.start()` for subprocess terminal sharing).
|
||||
|
||||
**Kit has:** `ShowOverlay(OverlayConfig)` with text content and action buttons. No way to render completely custom interactive components or suspend the TUI.
|
||||
|
||||
**Implementation approach:**
|
||||
- This is architecturally complex with Yaegi. A simpler approach: add `SuspendTUI func(callback func())` to `Context` that stops BubbleTea, runs the callback (allowing raw terminal use), then restarts.
|
||||
- For custom overlays: enhance `OverlayConfig` with a `RenderFunc` option for custom content rendering.
|
||||
|
||||
---
|
||||
|
||||
### Gap M5: SendMessage Delivery Modes
|
||||
**Pi has:** Three modes:
|
||||
- `pi.sendUserMessage(text)` -- Normal (triggers turn)
|
||||
- `pi.sendUserMessage(text, {deliverAs: "steer"})` -- Interrupts current stream
|
||||
- `pi.sendUserMessage(text, {deliverAs: "followUp"})` -- Queues after current stream
|
||||
|
||||
**Kit has:** `ctx.SendMessage(string)` which queues if agent is busy (similar to followUp), but no steering/interrupt mode and no structured content.
|
||||
|
||||
**Implementation approach:**
|
||||
- Add `SendMessageOpts{DeliverAs string}` parameter to `SendMessage`
|
||||
- Support `"steer"` (cancel current + send) and `"followUp"` (queue) modes
|
||||
- Add `SendStructuredMessage func(content []ContentBlock, opts SendMessageOpts)` for multi-part messages
|
||||
|
||||
---
|
||||
|
||||
### Gap M6: Model Change Event
|
||||
**Pi has:** `model_select` event with `event.model`, `event.previousModel`, `event.source`.
|
||||
|
||||
**Kit has:** No model change notification.
|
||||
|
||||
**Implementation approach:** Add `OnModelChange(func(ModelChangeEvent, Context))` with `NewModel, PreviousModel, Source string`.
|
||||
|
||||
---
|
||||
|
||||
### Gap M7: User Bash Hook
|
||||
**Pi has:** `user_bash` event for intercepting user-initiated `!command` invocations, separate from tool-initiated bash. Can return custom `result` to override execution.
|
||||
|
||||
**Kit has:** No distinction between user-initiated and tool-initiated bash. `OnToolCall` catches both.
|
||||
|
||||
**Implementation approach:** Add `OnUserBash(func(UserBashEvent, Context) *UserBashResult)` or tag `ToolCallEvent` with a `Source` field (`"user"` vs `"tool"`).
|
||||
|
||||
---
|
||||
|
||||
## Capabilities with Parity (Covered)
|
||||
|
||||
| Capability | Kit | Pi | Status |
|
||||
|---|---|---|---|
|
||||
| Session start/shutdown events | `OnSessionStart`, `OnSessionShutdown` | `session_start`, `session_shutdown` | Parity |
|
||||
| Before agent start (system prompt injection) | `OnBeforeAgentStart` returns `InjectText`, `SystemPrompt` | `before_agent_start` returns `systemPrompt`, `message` | Parity |
|
||||
| Agent lifecycle events | `OnAgentStart`, `OnAgentEnd` | `agent_start`, `agent_end` | Parity |
|
||||
| Message streaming events | `OnMessageStart`, `OnMessageUpdate`, `OnMessageEnd` | N/A (Pi uses `turn_start`/`turn_end` instead) | Kit advantage |
|
||||
| Tool call interception (blocking) | `OnToolCall` returns `Block`, `Reason` | `tool_call` returns `block`, `reason` | Parity |
|
||||
| Tool result modification | `OnToolResult` returns modified `Content`, `IsError` | `tool_result` returns modified content | Parity |
|
||||
| Tool execution timing | `OnToolExecutionStart`, `OnToolExecutionEnd` | N/A | Kit advantage |
|
||||
| Input interception/transform | `OnInput` returns `Action` (continue/transform/handled) | `input` returns `action` (continue/transform/handled) | Parity |
|
||||
| Custom tool registration | `RegisterTool(ToolDef)` | `pi.registerTool({...})` | Parity (Pi richer) |
|
||||
| Custom command registration | `RegisterCommand(CommandDef)` | `pi.registerCommand(name, {...})` | Parity |
|
||||
| Widget system | `SetWidget`/`RemoveWidget` with placement, priority | `setWidget(key, lines)` | Parity |
|
||||
| Header/Footer | `SetHeader`/`SetFooter` with content/style | `setHeader`/`setFooter` with factory | Parity (different models) |
|
||||
| Overlay dialogs | `ShowOverlay` with actions, scrolling, markdown | `ctx.ui.custom({overlay: true})` | Pi richer |
|
||||
| Interactive prompts | `PromptSelect`, `PromptConfirm`, `PromptInput` | `ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input` | Parity |
|
||||
| Editor interceptor | `SetEditor(EditorConfig)` with HandleKey/Render | `setEditorComponent()` for full replacement | Pi richer |
|
||||
| Tool renderer customization | `RegisterToolRenderer(ToolRenderConfig)` | `renderCall`/`renderResult` on tool def | Parity |
|
||||
| UI visibility control | `SetUIVisibility(UIVisibility)` | N/A (Pi uses direct component replacement) | Kit advantage |
|
||||
| Context stats | `GetContextStats()` returns tokens, limit, usage% | Token data via `sessionManager.getBranch()` | Kit advantage (dedicated API) |
|
||||
| Print functions | `Print`, `PrintInfo`, `PrintError`, `PrintBlock` | `ctx.ui.notify(msg, level)` | Different models, both adequate |
|
||||
| Subprocess spawning | `os/exec` via Yaegi stdlib access | `pi.exec()` abstracted API | Parity (different approach) |
|
||||
|
||||
---
|
||||
|
||||
## Priority-Ordered Implementation Roadmap
|
||||
|
||||
### Phase 1: High-Impact, Lower Complexity
|
||||
These gaps block the most important extension patterns and are relatively straightforward to implement.
|
||||
|
||||
1. **Session Management API** (Gap 1) -- Enables git integration, state restoration, bookmarks
|
||||
2. **Turn-Level Events** (Gap 11) -- Enables per-turn checkpoints and progress tracking
|
||||
3. **Session Persistence** (Gap 14) -- Enables durable extension state across restarts
|
||||
4. **Programmatic Editor Control** (Gap 10) -- Enables QnA and handoff patterns
|
||||
5. **Keyed Status Bar** (Gap M3) -- Enables richer status display
|
||||
|
||||
### Phase 2: Medium Impact, Medium Complexity
|
||||
6. **Tool Management API** (Gap 3) -- Enables plan-mode and tool filtering
|
||||
7. **Model Management API** (Gap 2) -- Enables presets and model switching
|
||||
8. **CLI Flag Registration** (Gap 7) -- Enables --preset, --plan flags
|
||||
9. **Inter-Extension Event Bus** (Gap 13) -- Enables cross-extension coordination
|
||||
10. **SendMessage Delivery Modes** (Gap M5) -- Enables steering and follow-up patterns
|
||||
|
||||
### Phase 3: High Impact, High Complexity
|
||||
11. **Session Lifecycle Before-Hooks** (Gap 4) -- Enables safety guards with cancel
|
||||
12. **Context Filtering Event** (Gap 12) -- Enables context management
|
||||
13. **Compaction Events** (Gap 5) -- Enables custom compaction strategies
|
||||
14. **Direct LLM Completion** (Gap 17) -- Enables in-process sub-agent calls
|
||||
15. **Full Custom TUI Components** (Gap M4) -- Enables interactive-shell, games
|
||||
|
||||
### Phase 4: Specialized / Lower Priority
|
||||
16. **Keyboard Shortcut Registration** (Gap 8) -- Nice-to-have for power users
|
||||
17. **Custom Message Rendering** (Gap 9) -- Nice-to-have for branded messages
|
||||
18. **Custom Provider Registration** (Gap 6) -- Architecturally complex, narrow use cases
|
||||
19. **Resource Discovery** (Gap 15) -- Depends on future skill/resource system
|
||||
20. **Programmatic Shutdown/Reload** (Gap 16) -- Nice-to-have lifecycle control
|
||||
21. **Model Change Event** (Gap M6) -- Nice-to-have notification
|
||||
22. **User Bash Hook** (Gap M7) -- Nice-to-have distinction
|
||||
23. **Command Tab Completion** (Gap M2) -- Nice-to-have UX improvement
|
||||
24. **Tool Registration Depth** (Gap M1) -- Incremental improvement
|
||||
|
||||
---
|
||||
|
||||
## Extension Ecosystem Gap: Example Extensions We Should Build
|
||||
|
||||
Beyond API gaps, Pi simply has more example extensions demonstrating real-world patterns. Extensions we should create (once APIs exist):
|
||||
|
||||
| Extension | Pi Equivalent | Required API Additions |
|
||||
|---|---|---|
|
||||
| Permission gate (dangerous command confirmation) | `permission-gate.ts` | None (works today with OnToolCall) |
|
||||
| Protected paths (block writes to .env, .git/) | `protected-paths.ts` | None (works today with OnToolCall) |
|
||||
| Auto-commit on exit | `auto-commit-on-exit.ts` | Gap 1 (session messages) |
|
||||
| Git checkpoints per turn | `git-checkpoint.ts` | Gaps 1, 4, 11 |
|
||||
| Desktop notifications | `notify.ts` | None (works today with OnAgentEnd + os/exec) |
|
||||
| Inline bash expansion (!{cmd}) | `inline-bash.ts` | None (works today with OnInput transform) |
|
||||
| Plan mode (read-only exploration) | `plan-mode/` | Gaps 3, 7, 11, 12, 14 |
|
||||
| Preset system | `preset.ts` | Gaps 2, 3, 7, 8, 14 |
|
||||
| Dirty repo guard | `dirty-repo-guard.ts` | Gap 4 |
|
||||
| QnA extraction | `qna.ts` | Gaps 1, 10, 17 |
|
||||
| Handoff to new session | `handoff.ts` | Gaps 1, 10, 17, 22 (newSession) |
|
||||
| Custom compaction | `custom-compaction.ts` | Gaps 2, 5 |
|
||||
| Interactive shell (vim/htop) | `interactive-shell.ts` | Gap M4 (TUI suspend) |
|
||||
| Event bus | `event-bus.ts` | Gap 13 |
|
||||
|
||||
### Extensions Buildable Today (No API Changes Needed)
|
||||
These can be built right now with Kit's existing extension API:
|
||||
|
||||
1. **Permission gate** -- Use `OnToolCall` to intercept bash with `rm -rf`, return `Block: true`
|
||||
2. **Protected paths** -- Use `OnToolCall` to check write/edit tool paths against deny-list
|
||||
3. **Desktop notifications** -- Use `OnAgentEnd` + `os/exec` for OSC 777 or `notify-send`
|
||||
4. **Inline bash expansion** -- Use `OnInput` with `Action: "transform"` to expand `!{cmd}`
|
||||
5. **Pirate mode** -- Use `OnBeforeAgentStart` to append to system prompt
|
||||
6. **Project rules loader** -- Use `OnSessionStart` to scan, `OnBeforeAgentStart` to inject
|
||||
7. **Titlebar spinner** -- Use `OnAgentStart`/`OnAgentEnd` + `os.Stdout` for OSC sequences
|
||||
8. **File trigger** -- Use `OnSessionStart` to set up `fsnotify` watcher, `SendMessage` to inject
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
| Metric | Pi | Kit | Gap |
|
||||
|---|---|---|---|
|
||||
| Example extensions | 57+ | 10 | -47 |
|
||||
| Lifecycle events | 16+ | 13 | -3+ |
|
||||
| API methods on context | 35+ | 22 | -13+ |
|
||||
| Custom providers | 3 | 0 | -3 |
|
||||
| Session management APIs | 6 | 0 | -6 |
|
||||
| Model management APIs | 4 | 1 (read-only) | -3 |
|
||||
| Tool management APIs | 2 | 0 | -2 |
|
||||
| Critical API gaps | -- | -- | 17 |
|
||||
| Moderate API gaps | -- | -- | 7 |
|
||||
| Extensions buildable today | -- | -- | 8 |
|
||||
+335
-35
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -29,6 +31,7 @@ var (
|
||||
debugMode bool
|
||||
promptFlag string
|
||||
quietFlag bool
|
||||
jsonFlag bool
|
||||
noExitFlag bool
|
||||
maxSteps int
|
||||
streamFlag bool // Enable streaming output
|
||||
@@ -203,6 +206,8 @@ func init() {
|
||||
StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&jsonFlag, "json", false, "output response as JSON (only works with --prompt)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&noExitFlag, "no-exit", false, "prevent non-interactive mode from exiting, show input prompt instead")
|
||||
rootCmd.PersistentFlags().
|
||||
@@ -291,13 +296,19 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
|
||||
if len(name) > 0 && name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
cmds = append(cmds, ui.ExtensionCommand{
|
||||
ec := ui.ExtensionCommand{
|
||||
Name: name,
|
||||
Description: d.Description,
|
||||
Execute: func(args string) (string, error) {
|
||||
return d.Execute(args, k.GetExtensionContext())
|
||||
},
|
||||
})
|
||||
}
|
||||
if d.Complete != nil {
|
||||
ec.Complete = func(prefix string) []string {
|
||||
return d.Complete(prefix, k.GetExtensionContext())
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, ec)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
@@ -411,6 +422,27 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
// uiVisibilityProviderForUI returns a function that converts extension UI
|
||||
// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if
|
||||
// extensions are disabled — the UI treats nil as "show everything".
|
||||
func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.UIVisibility {
|
||||
v := k.GetExtensionUIVisibility()
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.UIVisibility{
|
||||
HideStartupMessage: v.HideStartupMessage,
|
||||
HideStatusBar: v.HideStatusBar,
|
||||
HideSeparator: v.HideSeparator,
|
||||
HideInputHint: v.HideInputHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// footerProviderForUI returns a function that converts the extension footer
|
||||
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
|
||||
// which is safe — the UI treats a nil GetFooter as "no footer".
|
||||
@@ -432,11 +464,72 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
||||
}
|
||||
}
|
||||
|
||||
// statusBarProviderForUI returns a function that fetches extension status bar
|
||||
// entries and converts them to ui.StatusBarEntryData for the TUI. Returns nil
|
||||
// if extensions are disabled, which is safe — the TUI treats a nil
|
||||
// GetStatusBarEntries as "no extension entries".
|
||||
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() []ui.StatusBarEntryData {
|
||||
entries := k.GetExtensionStatusEntries()
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]ui.StatusBarEntryData, len(entries))
|
||||
for i, e := range entries {
|
||||
result[i] = ui.StatusBarEntryData{
|
||||
Key: e.Key,
|
||||
Text: e.Text,
|
||||
Priority: e.Priority,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// beforeForkProviderForUI returns a callback that emits a BeforeFork event
|
||||
// and returns (cancelled, reason). Returns nil if extensions are disabled —
|
||||
// the UI treats nil as "no hook".
|
||||
func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, string) {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.EmitBeforeFork
|
||||
}
|
||||
|
||||
// beforeSessionSwitchProviderForUI returns a callback that emits a
|
||||
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
|
||||
// if extensions are disabled — the UI treats nil as "no hook".
|
||||
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.EmitBeforeSessionSwitch
|
||||
}
|
||||
|
||||
// globalShortcutsProviderForUI returns a callback that queries the extension
|
||||
// runner for registered keyboard shortcuts. Returns nil if extensions are
|
||||
// disabled — the UI treats nil as "no shortcuts".
|
||||
func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.GetExtensionShortcuts
|
||||
}
|
||||
|
||||
func runNormalMode(ctx context.Context) error {
|
||||
// Validate flag combinations
|
||||
if quietFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
}
|
||||
if jsonFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--json flag can only be used with --prompt/-p")
|
||||
}
|
||||
if jsonFlag && noExitFlag {
|
||||
return fmt.Errorf("--json and --no-exit flags cannot be used together")
|
||||
}
|
||||
if noExitFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--no-exit flag can only be used with --prompt/-p")
|
||||
}
|
||||
@@ -550,14 +643,16 @@ func runNormalMode(ctx context.Context) error {
|
||||
if kitInstance.HasExtensions() {
|
||||
cwd, _ := os.Getwd()
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: promptFlag == "",
|
||||
Print: func(text string) { appInstance.PrintFromExtension("", text) },
|
||||
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
PrintBlock: appInstance.PrintBlockFromExtension,
|
||||
SendMessage: func(text string) { appInstance.Run(text) },
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: promptFlag == "",
|
||||
Print: func(text string) { appInstance.PrintFromExtension("", text) },
|
||||
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
PrintBlock: appInstance.PrintBlockFromExtension,
|
||||
SendMessage: func(text string) { appInstance.Run(text) },
|
||||
CancelAndSend: func(text string) { appInstance.Steer(text) },
|
||||
Exit: func() { appInstance.QuitFromExtension() },
|
||||
SetWidget: func(config extensions.WidgetConfig) {
|
||||
kitInstance.SetExtensionWidget(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
@@ -629,6 +724,19 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
return extensions.PromptInputResult{Value: resp.Value}
|
||||
},
|
||||
SetUIVisibility: func(v extensions.UIVisibility) {
|
||||
kitInstance.SetExtensionUIVisibility(v)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
SetEditor: func(config extensions.EditorConfig) {
|
||||
kitInstance.SetExtensionEditor(config)
|
||||
// Use a goroutine for NotifyWidgetUpdate because this may be
|
||||
@@ -641,6 +749,95 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.ResetExtensionEditor()
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage {
|
||||
return kitInstance.GetSessionMessages()
|
||||
},
|
||||
GetSessionPath: func() string {
|
||||
return kitInstance.GetSessionFilePath()
|
||||
},
|
||||
AppendEntry: func(entryType string, data string) (string, error) {
|
||||
return kitInstance.AppendExtensionEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.GetExtensionEntries(entryType)
|
||||
},
|
||||
SetEditorText: func(text string) {
|
||||
appInstance.SetEditorTextFromExtension(text)
|
||||
},
|
||||
SetStatus: func(key string, text string, priority int) {
|
||||
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
|
||||
Key: key,
|
||||
Text: text,
|
||||
Priority: priority,
|
||||
})
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveStatus: func(key string) {
|
||||
kitInstance.RemoveExtensionStatus(key)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetOption: func(name string) string {
|
||||
return kitInstance.GetExtensionOption(name)
|
||||
},
|
||||
SetOption: func(name string, value string) {
|
||||
kitInstance.SetExtensionOption(name, value)
|
||||
},
|
||||
SetModel: func(modelString string) error {
|
||||
// Capture previous model for the ModelChange event.
|
||||
previousModel := kitInstance.GetExtensionContext().Model
|
||||
err := kitInstance.SetModel(context.Background(), modelString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI so it updates model in status bar.
|
||||
p, m, _ := models.ParseModelString(modelString)
|
||||
appInstance.NotifyModelChanged(p, m)
|
||||
// Update the context's Model field so handlers see it.
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
// Fire OnModelChange event to extensions.
|
||||
kitInstance.EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry {
|
||||
return kitInstance.GetAvailableModels()
|
||||
},
|
||||
EmitCustomEvent: func(name string, data string) {
|
||||
kitInstance.EmitExtensionCustomEvent(name, data)
|
||||
},
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SuspendTUI: func(callback func()) error {
|
||||
return appInstance.SuspendTUI(callback)
|
||||
},
|
||||
RenderMessage: func(rendererName, content string) {
|
||||
renderer := kitInstance.GetExtensionMessageRenderer(rendererName)
|
||||
if renderer == nil || renderer.Render == nil {
|
||||
appInstance.PrintFromExtension("", content)
|
||||
return
|
||||
}
|
||||
w, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||
if w == 0 {
|
||||
w = 80
|
||||
}
|
||||
rendered := renderer.Render(content, w)
|
||||
appInstance.PrintFromExtension("", rendered)
|
||||
},
|
||||
ReloadExtensions: func() error {
|
||||
err := kitInstance.ReloadExtensions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI that widgets/status/commands may have changed.
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
return nil
|
||||
},
|
||||
GetAllTools: func() []extensions.ToolInfo {
|
||||
return kitInstance.GetExtensionToolInfos()
|
||||
},
|
||||
SetActiveTools: func(names []string) {
|
||||
kitInstance.SetExtensionActiveTools(names)
|
||||
},
|
||||
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
|
||||
ch := make(chan app.OverlayResponse, 1)
|
||||
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
|
||||
@@ -696,10 +893,18 @@ func runNormalMode(ctx context.Context) error {
|
||||
getFooter := footerProviderForUI(kitInstance)
|
||||
getToolRenderer := toolRendererProviderForUI(kitInstance)
|
||||
getEditorInterceptor := editorInterceptorProviderForUI(kitInstance)
|
||||
getUIVisibility := uiVisibilityProviderForUI(kitInstance)
|
||||
getStatusBarEntries := statusBarProviderForUI(kitInstance)
|
||||
emitBeforeFork := beforeForkProviderForUI(kitInstance)
|
||||
emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance)
|
||||
getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance)
|
||||
getExtensionCommands := func() []ui.ExtensionCommand {
|
||||
return extensionCommandsForUI(kitInstance)
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if promptFlag != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -707,7 +912,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -720,8 +925,20 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
|
||||
if quiet {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error {
|
||||
if jsonOutput {
|
||||
// JSON mode: no intermediate display, structured JSON output.
|
||||
result, err := appInstance.RunOnceResult(ctx, prompt)
|
||||
if err != nil {
|
||||
writeJSONError(err)
|
||||
return err
|
||||
}
|
||||
data, err := buildJSONOutput(result, modelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON output: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
} else if quiet {
|
||||
// Quiet mode: no intermediate display, just print final response.
|
||||
if err := appInstance.RunOnce(ctx, prompt); err != nil {
|
||||
return err
|
||||
@@ -746,12 +963,89 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON output helpers (--json mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildJSONOutput converts a TurnResult into a structured JSON byte slice
|
||||
// suitable for machine consumption.
|
||||
func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
|
||||
type jsonPart struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
type jsonMessage struct {
|
||||
Role string `json:"role"`
|
||||
Parts []jsonPart `json:"parts"`
|
||||
}
|
||||
type jsonUsage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
}
|
||||
type jsonEnvelope struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *jsonUsage `json:"usage,omitempty"`
|
||||
Messages []jsonMessage `json:"messages"`
|
||||
}
|
||||
|
||||
out := jsonEnvelope{
|
||||
Response: result.Response,
|
||||
Model: model,
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
out.Usage = &jsonUsage{
|
||||
InputTokens: result.TotalUsage.InputTokens,
|
||||
OutputTokens: result.TotalUsage.OutputTokens,
|
||||
TotalTokens: result.TotalUsage.TotalTokens,
|
||||
CacheReadTokens: result.TotalUsage.CacheReadTokens,
|
||||
CacheCreationTokens: result.TotalUsage.CacheCreationTokens,
|
||||
}
|
||||
}
|
||||
|
||||
for _, fmsg := range result.Messages {
|
||||
converted := kit.ConvertFromFantasyMessage(fmsg)
|
||||
m := jsonMessage{Role: string(converted.Role)}
|
||||
for _, p := range converted.Parts {
|
||||
switch c := p.(type) {
|
||||
case kit.TextContent:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "text", Data: c})
|
||||
case kit.ToolCall:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "tool_call", Data: c})
|
||||
case kit.ToolResult:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "tool_result", Data: c})
|
||||
case kit.ReasoningContent:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "reasoning", Data: c})
|
||||
case kit.Finish:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "finish", Data: c})
|
||||
}
|
||||
}
|
||||
out.Messages = append(out.Messages, m)
|
||||
}
|
||||
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// writeJSONError writes a JSON-formatted error object to stdout so that
|
||||
// callers using --json always receive parseable output.
|
||||
func writeJSONError(err error) {
|
||||
type jsonError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
data, _ := json.MarshalIndent(jsonError{Error: err.Error()}, "", " ")
|
||||
fmt.Fprintln(os.Stderr, string(data))
|
||||
}
|
||||
|
||||
// runInteractiveModeBubbleTea starts the new unified Bubble Tea interactive TUI.
|
||||
//
|
||||
// It:
|
||||
@@ -763,7 +1057,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -772,25 +1066,31 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
}
|
||||
|
||||
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
GetUIVisibility: getUIVisibility,
|
||||
GetStatusBarEntries: getStatusBarEntries,
|
||||
EmitBeforeFork: emitBeforeFork,
|
||||
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
||||
GetGlobalShortcuts: getGlobalShortcuts,
|
||||
GetExtensionCommands: getExtensionCommands,
|
||||
})
|
||||
|
||||
// Print startup info to stdout before Bubble Tea takes over the screen.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init automatically commits staged changes when the session shuts down,
|
||||
// using the last assistant message as the commit message.
|
||||
//
|
||||
// Only commits if:
|
||||
// - There are staged changes (git diff --cached is non-empty)
|
||||
// - There is at least one assistant message to use as commit message
|
||||
//
|
||||
// The commit message is derived from the last assistant response, trimmed
|
||||
// to the first paragraph (max 72 chars for the subject line).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/auto-commit.go
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// Check for staged changes.
|
||||
diff, err := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput()
|
||||
_ = diff
|
||||
if err == nil {
|
||||
return // exit code 0 means no staged changes
|
||||
}
|
||||
|
||||
// Get the last assistant message.
|
||||
msgs := ctx.GetMessages()
|
||||
var lastAssistant string
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "assistant" {
|
||||
lastAssistant = msgs[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastAssistant == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Build commit message: first paragraph, subject line max 72 chars.
|
||||
subject := firstParagraph(lastAssistant)
|
||||
if len(subject) > 72 {
|
||||
subject = subject[:69] + "..."
|
||||
}
|
||||
|
||||
// Commit.
|
||||
cmd := exec.Command("git", "commit", "-m", subject)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.PrintError("Auto-commit failed: " + string(output))
|
||||
return
|
||||
}
|
||||
ctx.PrintInfo("Auto-committed: " + subject)
|
||||
})
|
||||
}
|
||||
|
||||
// firstParagraph returns the first non-empty paragraph of text.
|
||||
func firstParagraph(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
// Split on double newlines (paragraph breaks).
|
||||
parts := strings.SplitN(text, "\n\n", 2)
|
||||
line := strings.TrimSpace(parts[0])
|
||||
// Collapse to single line.
|
||||
line = strings.ReplaceAll(line, "\n", " ")
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds bookmark commands for marking and recalling important points in
|
||||
// a conversation. Bookmarks are persisted in the session tree and survive
|
||||
// restarts.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /bookmark <label> — bookmark the current point with a label
|
||||
// /bookmarks — list all bookmarks in this session
|
||||
//
|
||||
// Usage: kit -e examples/extensions/bookmark.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmark",
|
||||
Description: "Bookmark the current point in the conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
label := strings.TrimSpace(args)
|
||||
if label == "" {
|
||||
label = time.Now().Format("15:04:05")
|
||||
}
|
||||
|
||||
// Count existing messages to record position.
|
||||
msgs := ctx.GetMessages()
|
||||
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"label": label,
|
||||
"messages": len(msgs),
|
||||
})
|
||||
|
||||
_, err := ctx.AppendEntry("bookmark", string(data))
|
||||
if err != nil {
|
||||
ctx.PrintError("Failed to save bookmark: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs)))
|
||||
return "", nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest existing bookmark labels so the user can quickly
|
||||
// re-bookmark at the same label.
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
if label == "" || seen[label] {
|
||||
continue
|
||||
}
|
||||
if prefix == "" || strings.HasPrefix(strings.ToLower(label), strings.ToLower(prefix)) {
|
||||
labels = append(labels, label)
|
||||
seen[label] = true
|
||||
}
|
||||
}
|
||||
return labels
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmarks",
|
||||
Description: "List all bookmarks in this session",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No bookmarks yet. Use /bookmark <label> to create one.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
msgCount, _ := data["messages"].(float64)
|
||||
lines = append(lines, fmt.Sprintf(" %d. %s (msg %d, %s)",
|
||||
i+1, label, int(msgCount), e.Timestamp[:19]))
|
||||
}
|
||||
|
||||
ctx.PrintInfo("Bookmarks:\n" + strings.Join(lines, "\n"))
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build ignore
|
||||
|
||||
// branded-output.go — Custom Message Rendering example extension for Kit.
|
||||
//
|
||||
// Demonstrates api.RegisterMessageRenderer() and ctx.RenderMessage() which
|
||||
// let extensions define reusable visual styles for output. Each renderer has
|
||||
// a name and a render function that receives content and terminal width.
|
||||
//
|
||||
// This extension registers three renderers:
|
||||
// "success" — green-bordered block for success messages
|
||||
// "warning" — yellow-bordered block for warnings
|
||||
// "metric" — compact key=value display for metrics
|
||||
//
|
||||
// Commands:
|
||||
// /demo-render — shows all three renderers in action
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register a "success" renderer — green-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[32m┌%s┐\033[0m\n \033[32m│\033[0m \033[1;32m%s\033[0m\n \033[32m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "warning" renderer — yellow-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "warning",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[33m┌%s┐\033[0m\n \033[33m│\033[0m \033[1;33m%s\033[0m\n \033[33m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "metric" renderer — compact label: value format.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "metric",
|
||||
Render: func(content string, width int) string {
|
||||
return fmt.Sprintf(" \033[36m▸\033[0m %s", content)
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-render",
|
||||
Description: "Demonstrate custom message renderers",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.RenderMessage("success", "All 42 tests passed in 3.2s")
|
||||
ctx.RenderMessage("warning", "3 deprecation warnings detected")
|
||||
ctx.RenderMessage("metric", fmt.Sprintf("build_time=%.1fs tests=42 coverage=87%% timestamp=%s",
|
||||
3.2, time.Now().Format("15:04:05")))
|
||||
|
||||
return "Rendered three message styles.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers a before-compact hook that notifies the user when
|
||||
// compaction is about to happen and optionally blocks automatic compaction.
|
||||
//
|
||||
// When automatic compaction is triggered (via --auto-compact), the extension
|
||||
// asks for user confirmation. Manual /compact commands are always allowed.
|
||||
//
|
||||
// This demonstrates the OnBeforeCompact event which allows extensions to
|
||||
// inspect context usage stats and gate the compaction process.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/compact-notify.go --auto-compact
|
||||
func Init(api ext.API) {
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
pct := int(e.UsagePercent * 100)
|
||||
summary := fmt.Sprintf("Context: %dk/%dk tokens (%d%%), %d messages",
|
||||
e.EstimatedTokens/1000, e.ContextLimit/1000, pct, e.MessageCount)
|
||||
|
||||
if e.IsAutomatic {
|
||||
// Auto-compaction: ask user first.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Auto-compaction triggered.\n" + summary,
|
||||
BorderColor: "#f9e2af",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Allow automatic compaction?",
|
||||
DefaultValue: true,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: "Auto-compaction skipped by user.",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Manual /compact: just notify.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Compacting conversation...\n" + summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
}
|
||||
|
||||
return nil // allow compaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers before-hooks for destructive session operations:
|
||||
// - Forks: Asks for confirmation before branching to a different tree node.
|
||||
// - New sessions: Checks for uncommitted git changes and warns before
|
||||
// starting a new branch if the working tree is dirty.
|
||||
//
|
||||
// This demonstrates the OnBeforeFork and OnBeforeSessionSwitch events
|
||||
// which allow extensions to cancel session lifecycle operations.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/confirm-destructive.go --continue
|
||||
func Init(api ext.API) {
|
||||
// Gate /new command: warn if there are uncommitted git changes.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
if !isGitDirty() {
|
||||
return nil // clean repo, allow switch
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Working tree has uncommitted changes. Start new session anyway?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeSessionSwitchResult{
|
||||
Cancel: true,
|
||||
Reason: "Session switch cancelled: uncommitted git changes.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
|
||||
// Gate fork: ask for confirmation before branching.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
msg := "Branch to this point in the conversation?"
|
||||
if e.IsUserMessage && e.UserText != "" {
|
||||
// Show a preview of the user message being forked to.
|
||||
preview := e.UserText
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:77] + "..."
|
||||
}
|
||||
msg = "Fork and edit: " + preview + "\n\nContinue?"
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: msg,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeForkResult{
|
||||
Cancel: true,
|
||||
Reason: "Fork cancelled by user.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
}
|
||||
|
||||
// isGitDirty returns true if the git working tree has uncommitted changes.
|
||||
func isGitDirty() bool {
|
||||
out, err := exec.Command("git", "status", "--porcelain").Output()
|
||||
if err != nil {
|
||||
return false // not a git repo or git not available
|
||||
}
|
||||
return len(strings.TrimSpace(string(out))) > 0
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build ignore
|
||||
|
||||
// context-inject.go — Injects context from a local file into every LLM turn.
|
||||
//
|
||||
// Reads a context file (default: .kit/context.md) and prepends it as a system
|
||||
// message to every LLM context window via OnContextPrepare. This is useful for
|
||||
// injecting project-specific knowledge, coding standards, or RAG results that
|
||||
// should always be visible to the model — without cluttering the session history.
|
||||
//
|
||||
// The injected message does NOT persist in the session tree (it's ephemeral,
|
||||
// added at query time only). This means:
|
||||
// - Changing the context file immediately affects future turns
|
||||
// - No session bloat from repeated context injection
|
||||
// - The model always sees the latest version of the context
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// KIT_OPT_CONTEXT_FILE — path to context file (default: .kit/context.md)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/context-inject.go
|
||||
// echo "Always use error wrapping with fmt.Errorf" > .kit/context.md
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "context-file",
|
||||
Description: "Path to the context file to inject into every turn",
|
||||
Default: ".kit/context.md",
|
||||
})
|
||||
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
path := ctx.GetOption("context-file")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// File doesn't exist or can't be read — skip silently.
|
||||
return nil
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend a system message with the context file contents.
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1,
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("[Project Context from %s]\n\n%s", path, content),
|
||||
}
|
||||
|
||||
msgs := make([]ext.ContextMessage, 0, len(e.Messages)+1)
|
||||
msgs = append(msgs, injected)
|
||||
msgs = append(msgs, e.Messages...)
|
||||
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "context",
|
||||
Description: "Show or edit the injected context file path",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
path := ctx.GetOption("context-file")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Context file: %s (not found or unreadable)", path), nil
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
preview := strings.Join(lines, "\n")
|
||||
if len(lines) > 10 {
|
||||
preview = strings.Join(lines[:10], "\n") + "\n..."
|
||||
}
|
||||
return fmt.Sprintf("Context file: %s (%d lines)\n\n%s", path, len(lines), preview), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
// dev-reload.go — Extension Hot-Reload example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.ReloadExtensions() which hot-reloads all extensions
|
||||
// from disk without restarting Kit. This is invaluable during extension
|
||||
// development: edit your extension source, then type /reload to pick up
|
||||
// changes immediately.
|
||||
//
|
||||
// Event handlers, slash commands, tool renderers, message renderers, and
|
||||
// keyboard shortcuts update immediately. Extension-defined tools are NOT
|
||||
// updated (they are baked into the agent at creation time and require a
|
||||
// restart).
|
||||
//
|
||||
// Commands:
|
||||
// /reload — hot-reload all extensions from disk
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
var loadedAt string
|
||||
|
||||
func Init(api ext.API) {
|
||||
loadedAt = time.Now().Format("15:04:05")
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "reload",
|
||||
Description: "Hot-reload all extensions from disk",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.Print("Reloading extensions...")
|
||||
err := ctx.ReloadExtensions()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reload failed: %w", err)
|
||||
}
|
||||
return "Extensions reloaded successfully.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "load-time",
|
||||
Description: "Show when this extension was loaded",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
return fmt.Sprintf("This extension was loaded at %s", loadedAt), nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print(fmt.Sprintf("[dev-reload] Extension loaded at %s", loadedAt))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init expands inline bash expressions in user prompts before they reach the
|
||||
// LLM. Text like !{git branch --show-current} is replaced with the command's
|
||||
// stdout.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "Fix the tests on !{git branch --show-current}"
|
||||
// → "Fix the tests on main"
|
||||
//
|
||||
// "The current directory is !{pwd}"
|
||||
// → "The current directory is /home/user/project"
|
||||
//
|
||||
// Usage: kit -e examples/extensions/inline-bash.go
|
||||
func Init(api ext.API) {
|
||||
// Matches !{...} with non-greedy content.
|
||||
re := regexp.MustCompile(`!\{([^}]+)\}`)
|
||||
|
||||
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
if !re.MatchString(ev.Text) {
|
||||
return nil
|
||||
}
|
||||
|
||||
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
|
||||
// Extract the command between !{ and }.
|
||||
cmd := re.FindStringSubmatch(match)[1]
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
out, err := exec.Command("bash", "-c", cmd).Output()
|
||||
if err != nil {
|
||||
return match // keep original on error
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
})
|
||||
|
||||
return &ext.InputResult{
|
||||
Action: "transform",
|
||||
Text: expanded,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build ignore
|
||||
|
||||
// interactive-shell.go — TUI Suspend example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.SuspendTUI() which temporarily releases the terminal
|
||||
// from the TUI so interactive subprocesses can run with full terminal
|
||||
// control. The TUI is automatically restored when the callback returns.
|
||||
//
|
||||
// Commands:
|
||||
// /edit <file> — opens $EDITOR (or vi) to edit a file
|
||||
// /shell — drops into an interactive shell session
|
||||
// /run <cmd> — runs a command with full terminal I/O (no TUI capture)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "edit",
|
||||
Description: "Open $EDITOR to edit a file (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
file := strings.TrimSpace(args)
|
||||
if file == "" {
|
||||
return "", fmt.Errorf("usage: /edit <file>")
|
||||
}
|
||||
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Opening %s in %s...", file, editor))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(editor, file)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("editor session failed: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Finished editing %s", file), nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest files in the current directory.
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var results []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
results = append(results, name)
|
||||
}
|
||||
}
|
||||
return results
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "shell",
|
||||
Description: "Drop into an interactive shell (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Starting %s... (type 'exit' to return to Kit)", shell))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shell session failed: %w", err)
|
||||
}
|
||||
|
||||
return "Shell session ended, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command with full terminal I/O (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
cmdStr := strings.TrimSpace(args)
|
||||
if cmdStr == "" {
|
||||
return "", fmt.Errorf("usage: /run <command>")
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Running: %s", cmdStr))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("sh", "-c", cmdStr)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
|
||||
return "Command finished, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -31,6 +32,16 @@ import (
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// kitJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type kitJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -474,27 +485,33 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Build subprocess arguments. Don't pass --model; the subprocess
|
||||
// inherits the same config/env and will use the same default.
|
||||
// Build subprocess arguments. Use --json for structured output parsing.
|
||||
// Don't pass --model; the subprocess inherits the same config/env default.
|
||||
args := []string{
|
||||
"--prompt", question,
|
||||
"--quiet",
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--system-prompt", tmpFile.Name(),
|
||||
}
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
outBytes, err := cmd.CombinedOutput()
|
||||
err = cmd.Run()
|
||||
close(done)
|
||||
elapsed = time.Since(start)
|
||||
result := strings.TrimSpace(string(outBytes))
|
||||
|
||||
if err != nil {
|
||||
// Extract a single-line summary for the card (no newlines).
|
||||
errLine := result
|
||||
// On error, prefer stderr for the error message; fall back to stdout.
|
||||
errText := strings.TrimSpace(stderrBuf.String())
|
||||
if errText == "" {
|
||||
errText = strings.TrimSpace(stdoutBuf.String())
|
||||
}
|
||||
errLine := errText
|
||||
if idx := strings.Index(errLine, "\n"); idx >= 0 {
|
||||
errLine = errLine[:idx]
|
||||
}
|
||||
@@ -505,10 +522,18 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
}
|
||||
return result, code, elapsed
|
||||
return errText, code, elapsed
|
||||
}
|
||||
|
||||
// Success — extract last non-empty line for the card.
|
||||
// Parse JSON output from subprocess.
|
||||
var parsed kitJSONOutput
|
||||
result := strings.TrimSpace(stdoutBuf.String())
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err == nil {
|
||||
result = parsed.Response
|
||||
}
|
||||
// else: fall back to raw stdout (e.g. older kit binary without --json)
|
||||
|
||||
// Extract last non-empty line for the card.
|
||||
lines := strings.Split(result, "\n")
|
||||
var lastLine string
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates a minimal-chrome extension.
|
||||
// Hides the startup banner, status bar, separator, and input hint, replacing
|
||||
// them with a compact footer showing model name and a context usage bar:
|
||||
//
|
||||
// claude-sonnet-4-5-20250929 [###-------] 30% (3.9K/200K tokens)
|
||||
//
|
||||
// Usage: kit -e examples/extensions/minimal.go
|
||||
func Init(api ext.API) {
|
||||
// updateFooter builds the footer text from current context stats.
|
||||
updateFooter := func(ctx ext.Context) {
|
||||
stats := ctx.GetContextStats()
|
||||
pct := stats.UsagePercent * 100
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
filled := int(math.Round(pct)) / 10
|
||||
bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)
|
||||
|
||||
// Format token counts like the built-in status bar (e.g. "3.9K/200K").
|
||||
fmtTokens := func(n int) string {
|
||||
if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fK", float64(n)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s [%s] %d%%", ctx.Model, bar, int(math.Round(pct)))
|
||||
if stats.ContextLimit > 0 {
|
||||
text += fmt.Sprintf(" (%s/%s tokens)",
|
||||
fmtTokens(stats.EstimatedTokens), fmtTokens(stats.ContextLimit))
|
||||
}
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
}
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
// Strip built-in chrome for a minimal look.
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
// Refresh after each agent turn — context usage changes here.
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init sends a desktop notification when the agent finishes responding.
|
||||
// Useful for long-running tasks — get notified without watching the terminal.
|
||||
|
||||
// Supports: Linux (notify-send), macOS (osascript).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/notify.go
|
||||
func Init(api ext.API) {
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
sendNotification("Kit", "Agent finished responding")
|
||||
})
|
||||
}
|
||||
|
||||
func sendNotification(title, body string) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
// Uses notify-send (libnotify) — available on most Linux desktops.
|
||||
_ = exec.Command("notify-send", "-a", "Kit", title, body).Start()
|
||||
case "darwin":
|
||||
// Uses macOS built-in osascript for native notifications.
|
||||
script := `display notification "` + body + `" with title "` + title + `"`
|
||||
_ = exec.Command("osascript", "-e", script).Start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init intercepts potentially dangerous bash commands and asks the user for
|
||||
// confirmation before allowing execution.
|
||||
//
|
||||
// Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/
|
||||
//
|
||||
// Usage: kit -e examples/extensions/permission-gate.go
|
||||
func Init(api ext.API) {
|
||||
// Patterns that require user confirmation.
|
||||
dangerousPatterns := []string{
|
||||
"rm -rf",
|
||||
"rm -r /",
|
||||
"sudo ",
|
||||
"chmod 777",
|
||||
"chmod -R 777",
|
||||
"mkfs",
|
||||
"dd if=",
|
||||
"> /dev/",
|
||||
":(){ :|:& };:",
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName != "Bash" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the command from the tool input JSON.
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
cmd := strings.ToLower(input.Command)
|
||||
|
||||
// Check for dangerous patterns.
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if strings.Contains(cmd, strings.ToLower(pattern)) {
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Dangerous command detected: " + input.Command + "\n\nAllow execution?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "User denied execution of dangerous command: " + input.Command,
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
// Init injects a pirate persona into the system prompt, causing the LLM to
|
||||
// respond in pirate-speak. Demonstrates OnBeforeAgentStart system prompt
|
||||
// injection.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/pirate.go
|
||||
func Init(api ext.API) {
|
||||
piratePrompt := `
|
||||
You are a pirate! You must:
|
||||
- Start every response with "Ahoy!"
|
||||
- Use pirate slang (ye, matey, arr, landlubber, etc.)
|
||||
- Refer to files as "scrolls" and directories as "treasure chests"
|
||||
- Call errors "cursed mishaps" and bugs "sea monsters"
|
||||
- End responses with a pirate saying
|
||||
|
||||
Despite the pirate persona, your technical advice must remain accurate and helpful.`
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &piratePrompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init implements a plan/explore mode that restricts the agent to read-only
|
||||
// tools. Toggle with /plan (or start in plan mode via KIT_OPT_PLAN=true).
|
||||
|
||||
// In plan mode the agent can only use read, grep, find, and ls — it cannot
|
||||
// write files, run bash, or make edits. This is useful for exploring a
|
||||
// codebase, reviewing architecture, or generating plans before executing.
|
||||
//
|
||||
// The status bar shows the current mode and the system prompt is augmented
|
||||
// with planning instructions when active.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/plan-mode.go
|
||||
//
|
||||
// Start in plan mode: KIT_OPT_PLAN=true kit -e examples/extensions/plan-mode.go
|
||||
func Init(api ext.API) {
|
||||
// Read-only tool set (matches core.ReadOnlyTools).
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
|
||||
var planActive bool
|
||||
|
||||
// Register "plan" option so users can start in plan mode via env/config.
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// ctrl+alt+p — global shortcut to toggle plan mode.
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
// /plan — toggle plan mode on or off.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode (ctrl+alt+p)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Check option at session start to enable plan mode from env/config.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
opt := strings.ToLower(ctx.GetOption("plan"))
|
||||
if opt == "true" || opt == "1" || opt == "yes" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
// Inject planning instructions into the system prompt when active.
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only exploration).
|
||||
You can ONLY read, search, and explore the codebase. You CANNOT write files,
|
||||
run commands, or make edits. Focus on:
|
||||
- Understanding the codebase structure and architecture
|
||||
- Identifying relevant files and patterns
|
||||
- Generating detailed plans and recommendations
|
||||
- Answering questions about how the code works
|
||||
|
||||
When the user is ready to execute, they will exit plan mode with /plan.`
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &prompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, readOnlyTools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(readOnlyTools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON — agent restricted to read-only tools (read, grep, find, ls).\nUse /plan to toggle off.")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF — all tools re-enabled.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init loads project-specific rules from .kit/rules/ into the system prompt.
|
||||
// Each .md file in the rules directory is injected as additional context,
|
||||
// giving projects a way to customise LLM behaviour without editing the
|
||||
// main system prompt.
|
||||
//
|
||||
// Place rule files in:
|
||||
//
|
||||
// .kit/rules/code-style.md
|
||||
// .kit/rules/testing.md
|
||||
// .kit/rules/security.md
|
||||
//
|
||||
// Usage: kit -e examples/extensions/project-rules.go
|
||||
func Init(api ext.API) {
|
||||
var rules string
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
rulesDir := filepath.Join(ctx.CWD, ".kit", "rules")
|
||||
entries, err := os.ReadDir(rulesDir)
|
||||
if err != nil {
|
||||
return // no rules directory, nothing to do
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".txt") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rulesDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content != "" {
|
||||
parts = append(parts, "## "+strings.TrimSuffix(name, filepath.Ext(name))+"\n\n"+content)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rules = "# Project Rules\n\n" + strings.Join(parts, "\n\n---\n\n")
|
||||
ctx.PrintInfo(fmt.Sprintf("[project-rules] Loaded %d rule file(s) from .kit/rules/", len(parts)))
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if rules == "" {
|
||||
return nil
|
||||
}
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &rules,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init blocks tool calls that attempt to write, edit, or delete files in
|
||||
// protected paths.
|
||||
//
|
||||
// Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key
|
||||
//
|
||||
// Usage: kit -e examples/extensions/protected-paths.go
|
||||
func Init(api ext.API) {
|
||||
// Tools that modify files.
|
||||
writeTools := map[string]bool{
|
||||
"Write": true,
|
||||
"Edit": true,
|
||||
"Bash": true,
|
||||
}
|
||||
|
||||
// Path patterns to protect (checked against the file_path / filePath field).
|
||||
protectedPatterns := []string{
|
||||
".env",
|
||||
".git/",
|
||||
"secrets/",
|
||||
"credentials",
|
||||
".pem",
|
||||
".key",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
}
|
||||
|
||||
// Bash commands that could modify protected files.
|
||||
bashWritePatterns := []string{
|
||||
"rm ", "mv ", "cp ", "> ",
|
||||
"cat >", "echo >", "tee ",
|
||||
"chmod ", "chown ",
|
||||
}
|
||||
|
||||
isProtected := func(path string) bool {
|
||||
lower := strings.ToLower(path)
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if !writeTools[tc.ToolName] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Write/Edit: check the file_path / filePath field.
|
||||
if tc.ToolName == "Write" || tc.ToolName == "Edit" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Try both naming conventions.
|
||||
filePath, _ := input["file_path"].(string)
|
||||
if filePath == "" {
|
||||
filePath, _ = input["filePath"].(string)
|
||||
}
|
||||
if isProtected(filePath) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: writing to protected path: " + filePath,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Bash: check if the command references protected paths.
|
||||
if tc.ToolName == "Bash" {
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only check bash commands that look like file mutations.
|
||||
isMutation := false
|
||||
for _, pat := range bashWritePatterns {
|
||||
if strings.Contains(input.Command, pat) {
|
||||
isMutation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMutation {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if any protected pattern appears in the command.
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(input.Command, p) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: bash command references protected path (" + p + "): " + input.Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,11 @@ import (
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// subJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -205,7 +210,7 @@ func spawnAgent(state *subState) {
|
||||
|
||||
args := []string{
|
||||
"--prompt", prompt,
|
||||
"--quiet",
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
}
|
||||
@@ -261,7 +266,7 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stderr in background goroutine.
|
||||
// Read stderr in background goroutine (live widget updates).
|
||||
var readWg sync.WaitGroup
|
||||
readWg.Add(1)
|
||||
go func() {
|
||||
@@ -277,12 +282,12 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stdout in foreground.
|
||||
// Read stdout into a separate buffer (JSON output from --json mode).
|
||||
var stdoutBuf strings.Builder
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
state.appendChunk(scanner.Text() + "\n")
|
||||
updateWidgets()
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
// Wait for all pipe readers, then the process.
|
||||
@@ -290,6 +295,17 @@ func spawnAgent(state *subState) {
|
||||
waitErr := cmd.Wait()
|
||||
close(doneCh) // stop timer
|
||||
|
||||
// Parse JSON output from --json mode to extract the response.
|
||||
var result string
|
||||
rawStdout := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subJSONOutput
|
||||
if rawStdout != "" && json.Unmarshal([]byte(rawStdout), &parsed) == nil && parsed.Response != "" {
|
||||
result = parsed.Response
|
||||
} else {
|
||||
// Fallback: use raw stdout (e.g. older kit binary without --json).
|
||||
result = rawStdout
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.Elapsed = time.Since(start)
|
||||
state.Proc = nil
|
||||
@@ -298,7 +314,6 @@ func spawnAgent(state *subState) {
|
||||
} else {
|
||||
state.Status = "done"
|
||||
}
|
||||
result := strings.Join(state.Chunks, "")
|
||||
|
||||
// Save history for /subcont continuations (cap at 16 KB).
|
||||
state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds a /summarize command that generates a concise summary of the
|
||||
// current conversation using a direct LLM completion. Demonstrates the
|
||||
// ctx.Complete API.
|
||||
//
|
||||
// The summary is displayed in a styled block and can optionally be saved
|
||||
// to the session via AppendEntry for later retrieval.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/summarize.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summarize",
|
||||
Description: "Summarize the current conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
msgs := ctx.GetMessages()
|
||||
if len(msgs) == 0 {
|
||||
ctx.PrintInfo("Nothing to summarize — no messages yet.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Build a text representation of the conversation.
|
||||
var parts []string
|
||||
for _, m := range msgs {
|
||||
content := m.Content
|
||||
if len(content) > 2000 {
|
||||
content = content[:1997] + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s]: %s", m.Role, content))
|
||||
}
|
||||
conversation := strings.Join(parts, "\n\n")
|
||||
|
||||
ctx.PrintInfo("Generating summary...")
|
||||
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
System: `You are a concise summarization assistant. Summarize the conversation below in 3-5 bullet points. Focus on:
|
||||
- What was discussed or requested
|
||||
- Key decisions or outcomes
|
||||
- Any pending action items
|
||||
|
||||
Be concise. Use plain text, no markdown headers.`,
|
||||
Prompt: conversation,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.PrintError("Summary failed: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
summary := strings.TrimSpace(resp.Text)
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary (%d messages, %d tokens used)", len(msgs), resp.InputTokens+resp.OutputTokens),
|
||||
})
|
||||
|
||||
// Persist the summary in the session for later retrieval.
|
||||
ctx.AppendEntry("summary", summary)
|
||||
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /summaries — list all saved summaries.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summaries",
|
||||
Description: "List saved conversation summaries",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("summary")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No summaries saved yet. Use /summarize to create one.")
|
||||
return "", nil
|
||||
}
|
||||
for i, e := range entries {
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: e.Data,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary #%d (%s)", i+1, e.Timestamp[:19]),
|
||||
})
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -74,6 +74,7 @@ type Agent struct {
|
||||
streamingEnabled bool
|
||||
coreTools []fantasy.AgentTool
|
||||
extraTools []fantasy.AgentTool
|
||||
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
|
||||
}
|
||||
|
||||
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
|
||||
@@ -179,6 +180,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
streamingEnabled: agentConfig.StreamingEnabled,
|
||||
coreTools: coreTools,
|
||||
extraTools: agentConfig.ExtraTools,
|
||||
toolWrapper: agentConfig.ToolWrapper,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -455,6 +457,11 @@ func (a *Agent) GetTools() []fantasy.AgentTool {
|
||||
return allTools
|
||||
}
|
||||
|
||||
// GetCoreToolCount returns the number of core tools.
|
||||
func (a *Agent) GetCoreToolCount() int {
|
||||
return len(a.coreTools)
|
||||
}
|
||||
|
||||
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
|
||||
func (a *Agent) GetMCPToolCount() int {
|
||||
if a.toolManager == nil {
|
||||
@@ -481,6 +488,69 @@ func (a *Agent) GetLoadedServerNames() []string {
|
||||
return a.toolManager.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
// SetModel swaps the agent's LLM provider to a new model. The existing tools,
|
||||
// system prompt, and configuration are preserved. The old provider is closed
|
||||
// if it has a closer. Returns the previous model string for notification.
|
||||
func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error {
|
||||
providerResult, err := models.CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create model provider: %v", err)
|
||||
}
|
||||
|
||||
// Rebuild tool list (same as NewAgent).
|
||||
allTools := make([]fantasy.AgentTool, len(a.coreTools))
|
||||
copy(allTools, a.coreTools)
|
||||
if a.toolManager != nil {
|
||||
allTools = append(allTools, a.toolManager.GetTools()...)
|
||||
}
|
||||
if len(a.extraTools) > 0 {
|
||||
allTools = append(allTools, a.extraTools...)
|
||||
}
|
||||
if a.toolWrapper != nil {
|
||||
allTools = a.toolWrapper(allTools)
|
||||
}
|
||||
|
||||
// Rebuild fantasy agent options.
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if a.systemPrompt != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
|
||||
}
|
||||
if len(allTools) > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
|
||||
}
|
||||
if a.maxSteps > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithStopConditions(
|
||||
fantasy.StepCountIs(a.maxSteps),
|
||||
))
|
||||
}
|
||||
|
||||
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Close old provider.
|
||||
if a.providerCloser != nil {
|
||||
_ = a.providerCloser.Close()
|
||||
}
|
||||
|
||||
// Update model info on MCP tool manager.
|
||||
if a.toolManager != nil {
|
||||
a.toolManager.SetModel(providerResult.Model)
|
||||
}
|
||||
|
||||
// Swap fields.
|
||||
a.fantasyAgent = newFantasyAgent
|
||||
a.model = providerResult.Model
|
||||
a.providerCloser = providerResult.Closer
|
||||
|
||||
// Update provider type.
|
||||
if config.ModelString != "" {
|
||||
if p, _, err := models.ParseModelString(config.ModelString); err == nil {
|
||||
a.providerType = p
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetModel returns the underlying fantasy LanguageModel.
|
||||
func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
return a.model
|
||||
|
||||
@@ -141,6 +141,34 @@ func (a *App) QueueLength() int {
|
||||
return len(a.queue)
|
||||
}
|
||||
|
||||
// Steer cancels the current agent step (if running), clears the queue, and
|
||||
// sends a new message that will execute as soon as the current step finishes
|
||||
// cancelling. If the agent is idle, the message executes immediately.
|
||||
// This is the "steer" delivery mode for SendMessage.
|
||||
func (a *App) Steer(prompt string) {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(prompt)
|
||||
return
|
||||
}
|
||||
|
||||
// Agent is busy: clear queue, insert steer message, then cancel.
|
||||
a.queue = []string{prompt}
|
||||
cancel := a.cancelStep
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
|
||||
// ClearQueue discards all queued prompts. The caller is responsible for
|
||||
// updating any UI state (e.g. queue badge) — ClearQueue does NOT send
|
||||
// events to the program, because it may be called synchronously from
|
||||
@@ -254,6 +282,20 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnceResult executes a single agent step synchronously and returns the
|
||||
// full TurnResult without printing anything. This is used by --json mode to
|
||||
// capture structured output for serialization.
|
||||
func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult, error) {
|
||||
stepCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
a.mu.Lock()
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
return a.executeStep(stepCtx, prompt, nil)
|
||||
}
|
||||
|
||||
// RunOnceWithDisplay executes a single agent step synchronously, sending
|
||||
// intermediate display events (spinner, tool calls, streaming chunks, etc.)
|
||||
// to eventFn. This is the non-TUI equivalent of the interactive Run() path —
|
||||
@@ -474,6 +516,22 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
|
||||
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
|
||||
// non-interactive mode it cancels the root context, stopping any in-flight
|
||||
// step. Safe to call from any goroutine; idempotent.
|
||||
func (a *App) QuitFromExtension() {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(tea.QuitMsg{})
|
||||
return
|
||||
}
|
||||
// Non-interactive: cancel the root context.
|
||||
a.rootCancel()
|
||||
}
|
||||
|
||||
// PrintFromExtension outputs text from an extension to the user. The level
|
||||
// controls styling: "" for plain text, "info" for a system message block,
|
||||
// "error" for an error block. In interactive mode it sends an
|
||||
@@ -491,6 +549,28 @@ func (a *App) PrintFromExtension(level, text string) {
|
||||
fmt.Println(text)
|
||||
}
|
||||
|
||||
// SetEditorTextFromExtension sends an EditorTextSetEvent to the TUI to
|
||||
// pre-fill the input editor. In non-interactive mode this is a no-op.
|
||||
func (a *App) SetEditorTextFromExtension(text string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(EditorTextSetEvent{Text: text})
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
|
||||
// the model name in the status bar and message attribution.
|
||||
func (a *App) NotifyModelChanged(provider, model string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(ModelChangedEvent{ProviderName: provider, ModelName: model})
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
|
||||
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
|
||||
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
|
||||
@@ -547,6 +627,32 @@ func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// SuspendTUI temporarily releases the terminal from the TUI, runs the
|
||||
// callback (which may spawn interactive subprocesses), and then restores
|
||||
// the TUI. In non-interactive mode (no program registered) the callback
|
||||
// runs directly with no terminal state changes.
|
||||
//
|
||||
// Safe to call from any goroutine (extension command handlers run in
|
||||
// goroutines). Blocks until the callback returns.
|
||||
func (a *App) SuspendTUI(callback func()) error {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog == nil {
|
||||
// Non-interactive: just run the callback directly.
|
||||
callback()
|
||||
return nil
|
||||
}
|
||||
if err := prog.ReleaseTerminal(); err != nil {
|
||||
return fmt.Errorf("release terminal: %w", err)
|
||||
}
|
||||
callback()
|
||||
if err := prog.RestoreTerminal(); err != nil {
|
||||
return fmt.Errorf("restore terminal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintBlockFromExtension outputs a custom styled block from an extension.
|
||||
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -113,11 +113,28 @@ type CompactErrorEvent struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// ModelChangedEvent is sent when an extension changes the active model via
|
||||
// ctx.SetModel. The TUI updates the model name shown in the status bar and
|
||||
// message attribution.
|
||||
type ModelChangedEvent struct {
|
||||
// ProviderName is the new provider (e.g. "anthropic").
|
||||
ProviderName string
|
||||
// ModelName is the new model ID (e.g. "claude-3-5-haiku-20241022").
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
|
||||
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
|
||||
// from its WidgetProvider on the next render cycle.
|
||||
type WidgetUpdateEvent struct{}
|
||||
|
||||
// EditorTextSetEvent is sent when an extension calls ctx.SetEditorText to
|
||||
// pre-fill the input editor with text. The TUI handles this by setting the
|
||||
// textarea content and moving the cursor to the end.
|
||||
type EditorTextSetEvent struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo,
|
||||
// ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate
|
||||
// renderer and tea.Println (scrollback); the CLI handler uses
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package compaction provides context window management with token estimation,
|
||||
// compaction triggers, and LLM-based conversation summarization.
|
||||
//
|
||||
// The algorithm mirrors Pi's approach: preserve a token budget of recent
|
||||
// The algorithm preserves a token budget of recent
|
||||
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
|
||||
// count. Auto-compaction fires when estimated context usage exceeds
|
||||
// contextWindow − ReserveTokens.
|
||||
@@ -50,8 +50,8 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
|
||||
// Auto-compact trigger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ShouldCompact reports whether auto-compaction should fire. It uses Pi's
|
||||
// formula: contextTokens > contextWindow − reserveTokens.
|
||||
// ShouldCompact reports whether auto-compaction should fire.
|
||||
// Formula: contextTokens > contextWindow − reserveTokens.
|
||||
func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens int) bool {
|
||||
if contextWindow <= 0 || reserveTokens <= 0 {
|
||||
return false
|
||||
@@ -72,8 +72,8 @@ type CompactionResult struct {
|
||||
MessagesRemoved int // Number of messages replaced by the summary
|
||||
}
|
||||
|
||||
// CompactionOptions configures compaction behaviour. Pi-style token-based
|
||||
// defaults are applied for zero-value fields.
|
||||
// CompactionOptions configures compaction behaviour. Token-based defaults
|
||||
// are applied for zero-value fields.
|
||||
type CompactionOptions struct {
|
||||
ContextWindow int // Model's context window size (tokens)
|
||||
ReserveTokens int // Tokens to reserve for LLM response, default 16384
|
||||
@@ -81,7 +81,7 @@ type CompactionOptions struct {
|
||||
SummaryPrompt string // Custom summary prompt (empty = use default)
|
||||
}
|
||||
|
||||
// defaults fills zero-value fields with sensible Pi-style defaults.
|
||||
// defaults fills zero-value fields with sensible defaults.
|
||||
func (o *CompactionOptions) defaults() {
|
||||
if o.ReserveTokens <= 0 {
|
||||
o.ReserveTokens = 16384
|
||||
@@ -92,13 +92,13 @@ func (o *CompactionOptions) defaults() {
|
||||
}
|
||||
|
||||
// defaultSystemPrompt is the system prompt sent to the summarisation LLM.
|
||||
// Matches Pi's compaction system prompt.
|
||||
|
||||
const defaultSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
|
||||
|
||||
// defaultSummaryPrompt is the user prompt appended after the serialised
|
||||
// conversation. Matches Pi's initial-compaction format.
|
||||
// conversation.
|
||||
const defaultSummaryPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
@@ -133,7 +133,7 @@ Use this EXACT format:
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cut point (token-based, Pi-style)
|
||||
// Cut point (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// isValidCutPoint returns true if the message at index i is a valid place to
|
||||
@@ -208,11 +208,11 @@ func forceCutPoint(messages []fantasy.Message) int {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message serialisation (Pi-style)
|
||||
// Message serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// roleLabel returns a human-readable label for a fantasy message role,
|
||||
// matching Pi's serialisation format.
|
||||
|
||||
func roleLabel(role fantasy.MessageRole) string {
|
||||
switch role {
|
||||
case fantasy.MessageRoleUser:
|
||||
@@ -230,7 +230,7 @@ func roleLabel(role fantasy.MessageRole) string {
|
||||
|
||||
// serializeMessages converts a slice of fantasy messages into a plain-text
|
||||
// representation suitable for sending to the summarisation LLM. The format
|
||||
// mirrors Pi's compaction serialisation.
|
||||
|
||||
func serializeMessages(messages []fantasy.Message) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range messages {
|
||||
@@ -277,8 +277,8 @@ func Compact(
|
||||
cutPoint := FindCutPoint(messages, opts.KeepRecentTokens)
|
||||
if cutPoint == 0 {
|
||||
// All messages fit within the keep budget. Force a cut that
|
||||
// keeps only the last non-tool message — matching Pi, which
|
||||
// always compacts when the user explicitly requests it.
|
||||
// keeps only the last non-tool message — always compact when
|
||||
// the user explicitly requests it.
|
||||
cutPoint = forceCutPoint(messages)
|
||||
if cutPoint == 0 {
|
||||
return nil, messages, nil
|
||||
@@ -289,7 +289,7 @@ func Compact(
|
||||
recentMessages := messages[cutPoint:]
|
||||
originalTokens := EstimateMessageTokens(messages)
|
||||
|
||||
// Serialise old messages to text, matching Pi's format.
|
||||
// Serialise old messages to text.
|
||||
conversationText := serializeMessages(oldMessages)
|
||||
|
||||
// Build the user-facing prompt: conversation text + summary instructions.
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestEstimateMessageTokens_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShouldCompact (Pi-style: contextTokens > contextWindow - reserveTokens)
|
||||
// ShouldCompact (contextTokens > contextWindow - reserveTokens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShouldCompact(t *testing.T) {
|
||||
@@ -94,7 +94,7 @@ func TestShouldCompact(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FindCutPoint (token-based, Pi-style)
|
||||
// FindCutPoint (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindCutPoint_TokenBased(t *testing.T) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package core provides the built-in core tools for KIT's coding agent.
|
||||
// These tools are direct fantasy.AgentTool implementations — no MCP layer,
|
||||
// no JSON-RPC, no serialization overhead. They match the pi coding agent's
|
||||
// core tool set: bash, read, write, edit, grep, find, ls.
|
||||
// no JSON-RPC, no serialization overhead. Core tool set: bash, read, write,
|
||||
// edit, grep, find, ls.
|
||||
package core
|
||||
|
||||
import (
|
||||
@@ -65,7 +65,7 @@ func parseArgs(input string, target any) error {
|
||||
}
|
||||
|
||||
// CodingTools returns the default set of core tools for a coding agent:
|
||||
// bash, read, write, edit. This matches pi's codingTools collection.
|
||||
// bash, read, write, edit.
|
||||
func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewBashTool(opts...),
|
||||
@@ -76,7 +76,7 @@ func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
}
|
||||
|
||||
// ReadOnlyTools returns tools for read-only exploration:
|
||||
// read, grep, find, ls. This matches pi's readOnlyTools collection.
|
||||
// read, grep, find, ls.
|
||||
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewReadTool(opts...),
|
||||
|
||||
+802
-19
@@ -64,6 +64,19 @@ type Context struct {
|
||||
// }()
|
||||
SendMessage func(string)
|
||||
|
||||
// CancelAndSend cancels the current agent turn (if running), clears
|
||||
// the message queue, and sends a new message that executes as soon as
|
||||
// cancellation completes. If the agent is idle, the message executes
|
||||
// immediately. This is the "steer" delivery mode.
|
||||
//
|
||||
// Use this for directive changes that should interrupt the current
|
||||
// operation, e.g. switching modes or redirecting the agent.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
|
||||
CancelAndSend func(string)
|
||||
|
||||
// SetWidget places or updates a persistent widget in the TUI. Widgets
|
||||
// remain visible across agent turns until explicitly removed. The
|
||||
// widget is identified by WidgetConfig.ID; calling SetWidget with the
|
||||
@@ -206,6 +219,403 @@ type Context struct {
|
||||
// ResetEditor removes the active editor interceptor and restores the
|
||||
// default built-in editor behavior. No-op if no interceptor is set.
|
||||
ResetEditor func()
|
||||
|
||||
// SetUIVisibility controls which built-in TUI chrome elements are
|
||||
// visible. By default all elements are shown (zero value = show all).
|
||||
// Call this during OnSessionStart to configure the initial layout.
|
||||
//
|
||||
// Example — minimal chrome:
|
||||
//
|
||||
// ctx.SetUIVisibility(ext.UIVisibility{
|
||||
// HideStartupMessage: true,
|
||||
// HideStatusBar: true,
|
||||
// HideSeparator: true,
|
||||
// HideInputHint: true,
|
||||
// })
|
||||
SetUIVisibility func(UIVisibility)
|
||||
|
||||
// GetContextStats returns current context-window usage information
|
||||
// (estimated tokens, context limit, usage percentage, message count).
|
||||
// Useful for building context meters, auto-compaction triggers, etc.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// stats := ctx.GetContextStats()
|
||||
// pct := int(stats.UsagePercent * 100)
|
||||
// fmt.Sprintf("[%s%s] %d%%", strings.Repeat("#", pct/10), strings.Repeat("-", 10-pct/10), pct)
|
||||
GetContextStats func() ContextStats
|
||||
|
||||
// GetMessages returns the conversation messages on the current branch,
|
||||
// ordered from root to leaf. This is a read-only view; extensions
|
||||
// cannot modify messages directly.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// msgs := ctx.GetMessages()
|
||||
// for _, m := range msgs {
|
||||
// if m.Role == "assistant" {
|
||||
// lastResponse = m.Content
|
||||
// }
|
||||
// }
|
||||
GetMessages func() []SessionMessage
|
||||
|
||||
// GetSessionPath returns the file path of the current session's JSONL
|
||||
// file. Returns empty string for in-memory (ephemeral) sessions.
|
||||
GetSessionPath func() string
|
||||
|
||||
// AppendEntry persists custom extension data in the session tree.
|
||||
// The data survives across session restarts and can be retrieved via
|
||||
// GetEntries. Use entryType to namespace your data (e.g. "myext:state").
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// data, _ := json.Marshal(myState)
|
||||
// ctx.AppendEntry("myext:state", string(data))
|
||||
AppendEntry func(entryType string, data string) (string, error)
|
||||
|
||||
// GetEntries retrieves all persisted extension data entries matching
|
||||
// the given type on the current branch, ordered root to leaf. Pass
|
||||
// empty string to retrieve all extension data entries.
|
||||
//
|
||||
// Example — restore state on session resume:
|
||||
//
|
||||
// entries := ctx.GetEntries("myext:state")
|
||||
// if len(entries) > 0 {
|
||||
// last := entries[len(entries)-1]
|
||||
// json.Unmarshal([]byte(last.Data), &myState)
|
||||
// }
|
||||
GetEntries func(entryType string) []ExtensionEntry
|
||||
|
||||
// SetEditorText sets the text content of the input editor. This can
|
||||
// be used to pre-fill the editor with suggested text (e.g. extracted
|
||||
// questions, handoff prompts). The cursor is moved to the end.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetEditorText("Please review the changes in src/main.go")
|
||||
SetEditorText func(text string)
|
||||
|
||||
// SetStatus places or updates a keyed entry in the TUI status bar.
|
||||
// Multiple entries from different extensions coexist; each is identified
|
||||
// by a unique key. Lower priority values render further left.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetStatus("myext:branch", "main", 50)
|
||||
SetStatus func(key string, text string, priority int)
|
||||
|
||||
// RemoveStatus removes a keyed status bar entry. No-op if the key
|
||||
// does not exist.
|
||||
RemoveStatus func(key string)
|
||||
|
||||
// GetOption returns the value of a named extension option. Options are
|
||||
// resolved in priority order:
|
||||
// 1. Runtime override (via SetOption)
|
||||
// 2. Environment variable: KIT_OPT_<NAME> (uppercase, dashes → underscores)
|
||||
// 3. Config file: options.<name> in .kit.yml
|
||||
// 4. Default value registered by the extension
|
||||
//
|
||||
// Returns empty string if the option was not registered.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// preset := ctx.GetOption("preset")
|
||||
// if preset == "fast" {
|
||||
// ctx.SetModel("anthropic/claude-haiku-3-5-20241022")
|
||||
// }
|
||||
GetOption func(name string) string
|
||||
|
||||
// SetOption sets a runtime override for a named extension option. This
|
||||
// takes highest priority over env vars, config, and defaults. Useful for
|
||||
// persisting user choices during a session.
|
||||
SetOption func(name string, value string)
|
||||
|
||||
// SetModel changes the active LLM model at runtime. The model string
|
||||
// should be in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
|
||||
// Existing tools, system prompt, and session are preserved. Returns an
|
||||
// error if the model string is invalid or the provider cannot be created.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := ctx.SetModel("openai/gpt-4o")
|
||||
// if err != nil {
|
||||
// ctx.PrintError("Failed to switch model: " + err.Error())
|
||||
// }
|
||||
SetModel func(modelString string) error
|
||||
|
||||
// GetAvailableModels returns a list of known models from the registry.
|
||||
// This is an advisory list — models not in the registry can still be
|
||||
// used by specifying their provider/model string directly.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// models := ctx.GetAvailableModels()
|
||||
// for _, m := range models {
|
||||
// fmt.Printf("%s/%s (ctx: %dk)\n", m.Provider, m.ModelID, m.ContextLimit/1000)
|
||||
// }
|
||||
GetAvailableModels func() []ModelInfoEntry
|
||||
|
||||
// EmitCustomEvent publishes a named event that other extensions can
|
||||
// subscribe to via api.OnCustomEvent(). Data is an arbitrary string
|
||||
// (JSON-encode complex payloads). Handlers run synchronously in
|
||||
// registration order.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.EmitCustomEvent("plan-mode:toggled", `{"active":true}`)
|
||||
EmitCustomEvent func(name string, data string)
|
||||
|
||||
// GetAllTools returns information about all tools available to the agent,
|
||||
// including core tools (bash, read, write, etc.), MCP server tools, and
|
||||
// extension-registered tools. Each entry includes the tool's enabled status.
|
||||
//
|
||||
// Example — list read-only tools:
|
||||
//
|
||||
// for _, t := range ctx.GetAllTools() {
|
||||
// if t.Source == "core" && t.Enabled {
|
||||
// fmt.Println(t.Name, "-", t.Description)
|
||||
// }
|
||||
// }
|
||||
GetAllTools func() []ToolInfo
|
||||
|
||||
// SetActiveTools restricts the agent to only the named tools. Tools not
|
||||
// in the list are blocked from execution (the LLM receives an error if
|
||||
// it tries to call them). Pass nil or an empty slice to re-enable all
|
||||
// tools. Tool names are case-sensitive.
|
||||
//
|
||||
// Example — plan mode (read-only):
|
||||
//
|
||||
// ctx.SetActiveTools([]string{"Read", "Glob", "Grep", "LS"})
|
||||
SetActiveTools func(names []string)
|
||||
|
||||
// Exit triggers a graceful application shutdown. In interactive mode
|
||||
// this sends a quit signal to the TUI; in non-interactive mode it
|
||||
// cancels the current operation. Safe to call from any goroutine.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.RegisterCommand(ext.CommandDef{
|
||||
// Name: "quit",
|
||||
// Description: "Exit the application",
|
||||
// Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
// ctx.Exit()
|
||||
// return "", nil
|
||||
// },
|
||||
// })
|
||||
Exit func()
|
||||
|
||||
// Complete makes a standalone LLM completion call, bypassing the agent
|
||||
// tool loop. Use this for summarisation, question extraction, or any
|
||||
// sub-task that needs an LLM response without tool access.
|
||||
//
|
||||
// If Model is empty the current session model is reused (no extra
|
||||
// provider creation overhead). Specify a different model string to
|
||||
// use a cheaper/faster model for the sub-task.
|
||||
//
|
||||
// Example — summarise with a fast model:
|
||||
//
|
||||
// resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
// Model: "anthropic/claude-haiku-3-5-20241022",
|
||||
// System: "You are a concise summarisation assistant.",
|
||||
// Prompt: "Summarise this conversation:\n" + text,
|
||||
// })
|
||||
// if err != nil {
|
||||
// ctx.PrintError("completion failed: " + err.Error())
|
||||
// return
|
||||
// }
|
||||
// ctx.PrintInfo(resp.Text)
|
||||
//
|
||||
// Example — streaming completion:
|
||||
//
|
||||
// resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
// Prompt: "Explain quantum computing",
|
||||
// OnChunk: func(chunk string) {
|
||||
// fmt.Print(chunk) // stream to stdout
|
||||
// },
|
||||
// })
|
||||
Complete func(CompleteRequest) (CompleteResponse, error)
|
||||
|
||||
// SuspendTUI temporarily releases the terminal from the TUI, runs the
|
||||
// provided callback (which may spawn interactive processes like vim or
|
||||
// htop), and then restores the TUI. In non-interactive mode the
|
||||
// callback runs directly with no terminal changes.
|
||||
//
|
||||
// The callback has full access to stdin/stdout/stderr while the TUI is
|
||||
// suspended. Return from the callback to restore the TUI.
|
||||
//
|
||||
// Example — launch $EDITOR:
|
||||
//
|
||||
// err := ctx.SuspendTUI(func() {
|
||||
// editor := os.Getenv("EDITOR")
|
||||
// if editor == "" { editor = "vim" }
|
||||
// cmd := exec.Command(editor, "file.go")
|
||||
// cmd.Stdin = os.Stdin
|
||||
// cmd.Stdout = os.Stdout
|
||||
// cmd.Stderr = os.Stderr
|
||||
// cmd.Run()
|
||||
// })
|
||||
SuspendTUI func(callback func()) error
|
||||
|
||||
// RenderMessage outputs text using a named message renderer registered
|
||||
// by an extension via api.RegisterMessageRenderer(). If no renderer
|
||||
// with the given name exists, the content is printed as plain text.
|
||||
//
|
||||
// This allows extensions to define reusable visual styles (borders,
|
||||
// colors, formatting) for specific message categories and invoke them
|
||||
// by name at runtime.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.RenderMessage("build-status", "All 42 tests passed.")
|
||||
RenderMessage func(rendererName string, content string)
|
||||
|
||||
// ReloadExtensions hot-reloads all extensions from disk. Existing
|
||||
// extensions receive a SessionShutdown event, then new code is loaded
|
||||
// and receives a SessionStart event. Event handlers, commands,
|
||||
// renderers, and shortcuts update immediately; extension-defined tools
|
||||
// are NOT updated (they are baked into the agent at creation time).
|
||||
//
|
||||
// After calling ReloadExtensions the calling extension's code has been
|
||||
// replaced; the caller should return promptly.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.RegisterCommand(ext.CommandDef{
|
||||
// Name: "reload",
|
||||
// Description: "Hot-reload all extensions",
|
||||
// Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
// if err := ctx.ReloadExtensions(); err != nil {
|
||||
// return "", err
|
||||
// }
|
||||
// return "Extensions reloaded", nil
|
||||
// },
|
||||
// })
|
||||
ReloadExtensions func() error
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session types (exposed to Yaegi — concrete structs for session access)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SessionMessage represents a conversation message exposed to extensions.
|
||||
// This is a simplified, read-only view of the internal message structures.
|
||||
type SessionMessage struct {
|
||||
// ID is the unique entry identifier in the session tree.
|
||||
ID string
|
||||
// ParentID links this entry to its parent in the tree.
|
||||
ParentID string
|
||||
// Role is the message role: "user", "assistant", "tool", or "system".
|
||||
Role string
|
||||
// Content is the text content of the message (tool calls and results
|
||||
// are serialized as text summaries).
|
||||
Content string
|
||||
// Model is the model that generated this message (empty for user messages).
|
||||
Model string
|
||||
// Provider is the provider used (empty for user messages).
|
||||
Provider string
|
||||
// Timestamp is the RFC3339-formatted creation time.
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// ExtensionEntry represents persisted extension data stored in the session.
|
||||
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
|
||||
// it on session resume.
|
||||
type ExtensionEntry struct {
|
||||
// ID is the unique entry identifier.
|
||||
ID string
|
||||
// EntryType is the extension-defined type string (e.g. "plan-mode:state").
|
||||
EntryType string
|
||||
// Data is the extension-defined payload (JSON or plain text).
|
||||
Data string
|
||||
// Timestamp is the RFC3339-formatted creation time.
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context filtering types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ContextMessage represents a single message in the LLM context window.
|
||||
// Used by OnContextPrepare to let extensions inspect and modify the messages
|
||||
// that will be sent to the LLM.
|
||||
type ContextMessage struct {
|
||||
// Index is the position of this message in the original context array
|
||||
// (0-based). When returning messages from a ContextPrepareResult,
|
||||
// messages with Index >= 0 reuse the original fantasy.Message at that
|
||||
// position (preserving tool calls, reasoning, and other complex parts).
|
||||
// Set Index to -1 for newly injected messages (created from Role + Content).
|
||||
Index int
|
||||
|
||||
// Role is the message role: "user", "assistant", "system", or "tool".
|
||||
Role string
|
||||
|
||||
// Content is the text content of the message. For assistant messages
|
||||
// with tool calls, this includes a text summary of the calls.
|
||||
Content string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM completion types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CompleteRequest configures a standalone LLM completion call. Extensions use
|
||||
// this with ctx.Complete() to make direct LLM calls without the agent tool loop.
|
||||
type CompleteRequest struct {
|
||||
// Model is the model to use in "provider/model" format (e.g.
|
||||
// "anthropic/claude-haiku-3-5-20241022"). Empty string uses the current
|
||||
// session model, avoiding extra provider creation overhead.
|
||||
Model string
|
||||
|
||||
// Prompt is the user input text sent to the model.
|
||||
Prompt string
|
||||
|
||||
// System is an optional system prompt. Empty uses no system prompt.
|
||||
System string
|
||||
|
||||
// Messages is optional conversation history. If provided, Prompt is
|
||||
// appended as the final user message.
|
||||
Messages []SessionMessage
|
||||
|
||||
// MaxTokens limits the response length (0 = provider default).
|
||||
MaxTokens int
|
||||
|
||||
// OnChunk is called for each streaming text delta. When set, the
|
||||
// completion is performed in streaming mode. When nil, the call blocks
|
||||
// until the full response is available.
|
||||
OnChunk func(chunk string)
|
||||
}
|
||||
|
||||
// CompleteResponse contains the LLM response and usage metadata from a
|
||||
// standalone completion call.
|
||||
type CompleteResponse struct {
|
||||
// Text is the complete response text.
|
||||
Text string
|
||||
|
||||
// InputTokens is the number of tokens in the request.
|
||||
InputTokens int
|
||||
|
||||
// OutputTokens is the number of tokens in the response.
|
||||
OutputTokens int
|
||||
|
||||
// Model is the actual model used (useful when CompleteRequest.Model was empty).
|
||||
Model string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// StatusBarEntry represents a keyed entry in the TUI status bar. Extensions
|
||||
// can set multiple independent entries that render alongside the built-in
|
||||
// model name and token usage display.
|
||||
type StatusBarEntry struct {
|
||||
// Key uniquely identifies this entry (e.g. "myext:git-branch").
|
||||
Key string
|
||||
// Text is the rendered content shown in the status bar.
|
||||
Text string
|
||||
// Priority controls ordering. Lower values render further left.
|
||||
// Built-in entries (model, usage) have implicit priority 100-110.
|
||||
Priority int
|
||||
}
|
||||
|
||||
// PrintBlockOpts configures a custom styled block for PrintBlock.
|
||||
@@ -233,22 +643,31 @@ type PrintBlockOpts struct {
|
||||
// register typed event handlers, custom tools, and slash commands.
|
||||
type API struct {
|
||||
// Event-specific registration functions (wired by the loader).
|
||||
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
|
||||
onToolExecStart func(func(ToolExecutionStartEvent, Context))
|
||||
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
|
||||
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
|
||||
onInput func(func(InputEvent, Context) *InputResult)
|
||||
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
|
||||
onAgentStart func(func(AgentStartEvent, Context))
|
||||
onAgentEnd func(func(AgentEndEvent, Context))
|
||||
onMessageStart func(func(MessageStartEvent, Context))
|
||||
onMessageUpdate func(func(MessageUpdateEvent, Context))
|
||||
onMessageEnd func(func(MessageEndEvent, Context))
|
||||
onSessionStart func(func(SessionStartEvent, Context))
|
||||
onSessionShutdown func(func(SessionShutdownEvent, Context))
|
||||
registerToolFn func(ToolDef)
|
||||
registerCmdFn func(CommandDef)
|
||||
registerToolRendererFn func(ToolRenderConfig)
|
||||
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
|
||||
onToolExecStart func(func(ToolExecutionStartEvent, Context))
|
||||
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
|
||||
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
|
||||
onInput func(func(InputEvent, Context) *InputResult)
|
||||
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
|
||||
onAgentStart func(func(AgentStartEvent, Context))
|
||||
onAgentEnd func(func(AgentEndEvent, Context))
|
||||
onMessageStart func(func(MessageStartEvent, Context))
|
||||
onMessageUpdate func(func(MessageUpdateEvent, Context))
|
||||
onMessageEnd func(func(MessageEndEvent, Context))
|
||||
onSessionStart func(func(SessionStartEvent, Context))
|
||||
onSessionShutdown func(func(SessionShutdownEvent, Context))
|
||||
registerToolFn func(ToolDef)
|
||||
registerCmdFn func(CommandDef)
|
||||
registerToolRendererFn func(ToolRenderConfig)
|
||||
onModelChange func(func(ModelChangeEvent, Context))
|
||||
onContextPrepare func(func(ContextPrepareEvent, Context) *ContextPrepareResult)
|
||||
onBeforeFork func(func(BeforeForkEvent, Context) *BeforeForkResult)
|
||||
onBeforeSessionSwitch func(func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult)
|
||||
onBeforeCompact func(func(BeforeCompactEvent, Context) *BeforeCompactResult)
|
||||
onCustomEvent func(name string, handler func(string))
|
||||
registerOption func(OptionDef)
|
||||
registerShortcutFn func(ShortcutDef, func(Context))
|
||||
registerMessageRendererFn func(MessageRendererConfig)
|
||||
}
|
||||
|
||||
// OnToolCall registers a handler that fires before a tool executes.
|
||||
@@ -319,6 +738,36 @@ func (a *API) OnSessionShutdown(handler func(SessionShutdownEvent, Context)) {
|
||||
a.onSessionShutdown(handler)
|
||||
}
|
||||
|
||||
// OnModelChange registers a handler that fires after the active model is
|
||||
// changed via ctx.SetModel(). The handler receives the new and previous model
|
||||
// strings plus the source of the change.
|
||||
func (a *API) OnModelChange(handler func(ModelChangeEvent, Context)) {
|
||||
a.onModelChange(handler)
|
||||
}
|
||||
|
||||
// OnContextPrepare registers a handler that fires after the context window is
|
||||
// built from the session tree (including compaction) and before the messages
|
||||
// are sent to the LLM. The handler can inspect the context and return a
|
||||
// modified message set to filter, reorder, or inject messages.
|
||||
//
|
||||
// Return nil to leave the context unchanged. Return a non-nil result with
|
||||
// a Messages slice to replace the context window entirely. Messages with a
|
||||
// non-negative Index reuse the original message at that position (preserving
|
||||
// tool calls, reasoning parts, etc.); messages with Index < 0 are created
|
||||
// fresh from Role + Content.
|
||||
//
|
||||
// Example — inject a RAG context message:
|
||||
//
|
||||
// api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
// ragContext := fetchRelevantDocs(e.Messages[len(e.Messages)-1].Content)
|
||||
// injected := ext.ContextMessage{Index: -1, Role: "system", Content: ragContext}
|
||||
// msgs := append([]ext.ContextMessage{injected}, e.Messages...)
|
||||
// return &ext.ContextPrepareResult{Messages: msgs}
|
||||
// })
|
||||
func (a *API) OnContextPrepare(handler func(ContextPrepareEvent, Context) *ContextPrepareResult) {
|
||||
a.onContextPrepare(handler)
|
||||
}
|
||||
|
||||
// RegisterTool adds a custom tool that the LLM can invoke.
|
||||
func (a *API) RegisterTool(tool ToolDef) {
|
||||
a.registerToolFn(tool)
|
||||
@@ -329,6 +778,55 @@ func (a *API) RegisterCommand(cmd CommandDef) {
|
||||
a.registerCmdFn(cmd)
|
||||
}
|
||||
|
||||
// RegisterOption declares a named configuration option. The option can be set
|
||||
// via environment variables (KIT_OPT_<NAME>) or config file (options.<name>).
|
||||
// Multiple extensions can register options with the same name; the last default
|
||||
// wins.
|
||||
func (a *API) RegisterOption(opt OptionDef) {
|
||||
a.registerOption(opt)
|
||||
}
|
||||
|
||||
// RegisterShortcut registers a global keyboard shortcut that fires across
|
||||
// all app states except modal prompts/overlays. Use modifier combinations
|
||||
// like "ctrl+p", "alt+t", or "f1" — avoid bare characters that conflict
|
||||
// with text input. If multiple extensions register the same key, the last
|
||||
// registration wins. The handler runs in a goroutine so it can call blocking
|
||||
// APIs like PromptSelect without stalling the TUI event loop.
|
||||
func (a *API) RegisterShortcut(def ShortcutDef, handler func(Context)) {
|
||||
if a.registerShortcutFn != nil {
|
||||
a.registerShortcutFn(def, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// OnCustomEvent registers a handler for a custom inter-extension event.
|
||||
// The handler receives the data string published by EmitCustomEvent.
|
||||
// Multiple handlers can subscribe to the same event name; they execute
|
||||
// in registration order.
|
||||
func (a *API) OnCustomEvent(name string, handler func(string)) {
|
||||
a.onCustomEvent(name, handler)
|
||||
}
|
||||
|
||||
// OnBeforeFork registers a handler that fires before the session tree is
|
||||
// branched to a different entry point. Return a non-nil BeforeForkResult
|
||||
// with Cancel=true to prevent the fork.
|
||||
func (a *API) OnBeforeFork(handler func(BeforeForkEvent, Context) *BeforeForkResult) {
|
||||
a.onBeforeFork(handler)
|
||||
}
|
||||
|
||||
// OnBeforeSessionSwitch registers a handler that fires before the session
|
||||
// is switched to a new branch (e.g. /new command). Return a non-nil
|
||||
// BeforeSessionSwitchResult with Cancel=true to prevent the switch.
|
||||
func (a *API) OnBeforeSessionSwitch(handler func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
|
||||
a.onBeforeSessionSwitch(handler)
|
||||
}
|
||||
|
||||
// OnBeforeCompact registers a handler that fires before context compaction
|
||||
// runs. Return a non-nil BeforeCompactResult with Cancel=true to prevent
|
||||
// compaction from proceeding.
|
||||
func (a *API) OnBeforeCompact(handler func(BeforeCompactEvent, Context) *BeforeCompactResult) {
|
||||
a.onBeforeCompact(handler)
|
||||
}
|
||||
|
||||
// RegisterToolRenderer registers a custom renderer for a specific tool's
|
||||
// display in the TUI. The renderer controls the header (parameter summary)
|
||||
// and/or body (result display) of the tool's output block. If multiple
|
||||
@@ -337,6 +835,17 @@ func (a *API) RegisterToolRenderer(config ToolRenderConfig) {
|
||||
a.registerToolRendererFn(config)
|
||||
}
|
||||
|
||||
// RegisterMessageRenderer registers a named message renderer that extensions
|
||||
// can invoke via ctx.RenderMessage(name, content). Use this to define
|
||||
// reusable visual styles for branded output, progress reports, or custom
|
||||
// notification formats. If multiple extensions register the same name, the
|
||||
// last one wins.
|
||||
func (a *API) RegisterMessageRenderer(config MessageRendererConfig) {
|
||||
if a.registerMessageRendererFn != nil {
|
||||
a.registerMessageRendererFn(config)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget types (exposed to Yaegi — concrete structs, no interfaces)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -472,6 +981,36 @@ type HeaderFooterConfig struct {
|
||||
Style WidgetStyle
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI visibility (exposed to Yaegi — concrete struct)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UIVisibility controls which built-in TUI chrome elements are visible.
|
||||
// The zero value shows everything (backward compatible). Extensions call
|
||||
// ctx.SetUIVisibility to customise the layout — for example, a "minimal"
|
||||
// theme can hide the startup banner, status bar, and input hint and replace
|
||||
// them with a single custom footer.
|
||||
type UIVisibility struct {
|
||||
HideStartupMessage bool // Hide the "Model loaded..." startup block
|
||||
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
|
||||
HideSeparator bool // Hide the "────────" divider between stream and input
|
||||
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context stats (exposed to Yaegi — concrete struct)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ContextStats contains current context-window usage information.
|
||||
// Extensions can poll this via ctx.GetContextStats() to build usage
|
||||
// meters, auto-compaction triggers, etc.
|
||||
type ContextStats struct {
|
||||
EstimatedTokens int // Estimated token count of the current conversation
|
||||
ContextLimit int // Model's context window size (tokens), 0 if unknown
|
||||
UsagePercent float64 // Fraction of context used (0.0–1.0), 0 if limit unknown
|
||||
MessageCount int // Number of messages in the conversation
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -563,16 +1102,73 @@ type OverlayResult struct {
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model info types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ModelInfoEntry represents a known model from the registry. Used by
|
||||
// GetAvailableModels to let extensions discover which models are available.
|
||||
type ModelInfoEntry struct {
|
||||
// Provider is the provider ID (e.g. "anthropic", "openai").
|
||||
Provider string
|
||||
// ModelID is the model identifier (e.g. "claude-sonnet-4-5-20250929").
|
||||
ModelID string
|
||||
// Name is the human-readable model name.
|
||||
Name string
|
||||
// ContextLimit is the maximum context window in tokens (0 if unknown).
|
||||
ContextLimit int
|
||||
// OutputLimit is the maximum output tokens (0 if unknown).
|
||||
OutputLimit int
|
||||
// Reasoning is true if the model supports extended thinking.
|
||||
Reasoning bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool info types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ToolInfo provides read-only information about a tool available to the agent.
|
||||
// Used by GetAllTools to let extensions inspect and filter the tool set.
|
||||
type ToolInfo struct {
|
||||
// Name is the tool's unique identifier.
|
||||
Name string
|
||||
// Description is the tool's human-readable description.
|
||||
Description string
|
||||
// Source indicates where the tool came from: "core", "mcp", or "extension".
|
||||
Source string
|
||||
// Enabled is true if the tool is currently active.
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolDef / CommandDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ToolContext provides runtime context to a tool's ExecuteWithContext handler.
|
||||
// It allows tools to check for cancellation and report progress while running.
|
||||
type ToolContext struct {
|
||||
// IsCancelled returns true when the tool's execution has been cancelled
|
||||
// (e.g. the user interrupted the agent or the request timed out).
|
||||
// Long-running tools should poll this periodically and return early.
|
||||
IsCancelled func() bool
|
||||
// OnProgress sends a progress message that is displayed in the TUI
|
||||
// while the tool is executing. Useful for long-running operations
|
||||
// that want to show incremental status.
|
||||
OnProgress func(text string)
|
||||
}
|
||||
|
||||
// ToolDef describes a custom tool registered by an extension.
|
||||
type ToolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters string // JSON Schema string
|
||||
Execute func(input string) (string, error)
|
||||
// Execute is the simple handler — receives JSON input, returns text result.
|
||||
// Use this for tools that don't need cancellation or progress reporting.
|
||||
Execute func(input string) (string, error)
|
||||
// ExecuteWithContext is the rich handler — receives JSON input plus a
|
||||
// ToolContext that provides cancellation checking and progress reporting.
|
||||
// If both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
|
||||
ExecuteWithContext func(input string, tc ToolContext) (string, error)
|
||||
}
|
||||
|
||||
// CommandDef describes a slash command registered by an extension.
|
||||
@@ -580,6 +1176,74 @@ type CommandDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Execute func(args string, ctx Context) (string, error)
|
||||
// Complete provides argument tab-completion for this command.
|
||||
// Called with the partial argument text typed so far; returns
|
||||
// candidate completions. Nil means no argument completion.
|
||||
Complete func(prefix string, ctx Context) []string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ShortcutDef describes a global keyboard shortcut registered by an extension.
|
||||
// Shortcuts fire across all app states except modal prompts/overlays.
|
||||
// Use modifier combinations (e.g., "ctrl+p", "alt+t", "f1") — avoid bare
|
||||
// characters like "a" or "x" which conflict with text input.
|
||||
type ShortcutDef struct {
|
||||
// Key is the key binding (e.g., "ctrl+p", "alt+t", "f1", "ctrl+shift+s").
|
||||
Key string
|
||||
// Description explains what the shortcut does (shown in /shortcuts help).
|
||||
Description string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom message rendering (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// MessageRendererConfig provides a named rendering function that extensions
|
||||
// can invoke via ctx.RenderMessage(name, content). Unlike tool renderers
|
||||
// (which hook into the automatic tool result display), message renderers are
|
||||
// invoked explicitly by extension code for branded status updates, progress
|
||||
// reports, or any custom visual output.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
// Name: "build-status",
|
||||
// Render: func(content string, width int) string {
|
||||
// border := strings.Repeat("─", width-4)
|
||||
// return "╭" + border + "╮\n│ " + content + "\n╰" + border + "╯"
|
||||
// },
|
||||
// })
|
||||
type MessageRendererConfig struct {
|
||||
// Name uniquely identifies this renderer. Used by ctx.RenderMessage
|
||||
// to look it up at call time. Should be namespaced to avoid collisions
|
||||
// (e.g. "myext:build-status").
|
||||
Name string
|
||||
|
||||
// Render produces the styled output string from raw content. Receives
|
||||
// the content and the terminal width in columns. Return the final
|
||||
// ANSI-styled string to print; it will be emitted via tea.Println
|
||||
// (or plain stdout in non-interactive mode).
|
||||
Render func(content string, width int) string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension options (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// OptionDef describes a configuration option that an extension can register.
|
||||
// Options are resolved from env vars, config file, or default value.
|
||||
type OptionDef struct {
|
||||
// Name is the option identifier. Used as:
|
||||
// - Env var: KIT_OPT_<NAME> (uppercased, dashes → underscores)
|
||||
// - Config key: options.<name> in .kit.yml
|
||||
Name string
|
||||
// Description explains what the option controls.
|
||||
Description string
|
||||
// Default is the fallback value if not set via env or config.
|
||||
Default string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -693,8 +1357,7 @@ type EditorKeyAction struct {
|
||||
// submit) and/or modify the rendered output (add mode indicators, apply visual
|
||||
// effects).
|
||||
//
|
||||
// This follows Pi's extension editor pattern (modal editor, rainbow editor)
|
||||
// but uses concrete function fields instead of interfaces for Yaegi safety.
|
||||
// Uses concrete function fields instead of interfaces for Yaegi safety.
|
||||
//
|
||||
// IMPORTANT (Yaegi limitation): Function fields MUST be set using anonymous
|
||||
// function literals (closures), NOT bare function references. Yaegi does not
|
||||
@@ -735,6 +1398,10 @@ type ToolCallEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
Input string // JSON-encoded tool parameters
|
||||
// Source indicates who initiated the tool call.
|
||||
// Currently always "llm" (all tool calls originate from the LLM agent loop).
|
||||
// Future user-initiated tool features may set this to "user".
|
||||
Source string
|
||||
}
|
||||
|
||||
func (e ToolCallEvent) Type() EventType { return ToolCall }
|
||||
@@ -856,4 +1523,120 @@ func (e SessionStartEvent) Type() EventType { return SessionStart }
|
||||
// SessionShutdownEvent fires when the application is closing.
|
||||
type SessionShutdownEvent struct{}
|
||||
|
||||
// ModelChangeEvent fires after the active model is changed via ctx.SetModel().
|
||||
type ModelChangeEvent struct {
|
||||
// NewModel is the model string that was set (e.g. "anthropic/claude-sonnet-4-5-20250929").
|
||||
NewModel string
|
||||
// PreviousModel is the model string before the change.
|
||||
PreviousModel string
|
||||
// Source indicates what triggered the change: "extension" for ctx.SetModel(),
|
||||
// "user" for interactive model selection.
|
||||
Source string
|
||||
}
|
||||
|
||||
func (e SessionShutdownEvent) Type() EventType { return SessionShutdown }
|
||||
|
||||
func (e ModelChangeEvent) Type() EventType { return ModelChange }
|
||||
|
||||
// ContextPrepareEvent fires after the context window is built from the session
|
||||
// tree and before the messages are sent to the LLM. Handlers can inspect the
|
||||
// messages and return a modified set to filter, reorder, or inject context.
|
||||
type ContextPrepareEvent struct {
|
||||
// Messages is the current context window that will be sent to the LLM.
|
||||
// Each ContextMessage includes an Index field that maps back to the
|
||||
// position in the original message array (for identity-preserving edits).
|
||||
Messages []ContextMessage
|
||||
}
|
||||
|
||||
func (e ContextPrepareEvent) Type() EventType { return ContextPrepare }
|
||||
|
||||
// ContextPrepareResult allows extensions to replace the context window.
|
||||
// Return nil to leave the context unchanged.
|
||||
type ContextPrepareResult struct {
|
||||
// Messages replaces the entire context window. Each entry with a
|
||||
// non-negative Index reuses the original message at that position
|
||||
// (preserving tool calls, reasoning, etc.); entries with Index < 0
|
||||
// are created fresh from Role + Content.
|
||||
Messages []ContextMessage
|
||||
}
|
||||
|
||||
func (ContextPrepareResult) isResult() {}
|
||||
|
||||
// BeforeForkEvent fires before the session tree is branched to a different
|
||||
// entry point (via the tree selector or /fork command).
|
||||
type BeforeForkEvent struct {
|
||||
// TargetID is the session entry ID being branched to.
|
||||
TargetID string
|
||||
// IsUserMessage is true if the selected entry is a user message
|
||||
// (which causes the fork to target the parent entry).
|
||||
IsUserMessage bool
|
||||
// UserText is the user message text (non-empty only when IsUserMessage is true).
|
||||
UserText string
|
||||
}
|
||||
|
||||
func (e BeforeForkEvent) Type() EventType { return BeforeFork }
|
||||
|
||||
// BeforeForkResult controls whether the fork proceeds. Return Cancel=true
|
||||
// with an optional Reason to block the fork.
|
||||
type BeforeForkResult struct {
|
||||
// Cancel, when true, prevents the fork from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation shown to the user when
|
||||
// Cancel is true. Empty string uses a default message.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (BeforeForkResult) isResult() {}
|
||||
|
||||
// BeforeSessionSwitchEvent fires before the session is switched to a new
|
||||
// branch (e.g. /new or /clear commands).
|
||||
type BeforeSessionSwitchEvent struct {
|
||||
// Reason describes why the switch is happening: "new" for /new command,
|
||||
// "clear" for /clear command.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e BeforeSessionSwitchEvent) Type() EventType { return BeforeSessionSwitch }
|
||||
|
||||
// BeforeSessionSwitchResult controls whether the session switch proceeds.
|
||||
// Return Cancel=true with an optional Reason to block the switch.
|
||||
type BeforeSessionSwitchResult struct {
|
||||
// Cancel, when true, prevents the session switch from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation shown to the user when
|
||||
// Cancel is true. Empty string uses a default message.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (BeforeSessionSwitchResult) isResult() {}
|
||||
|
||||
// BeforeCompactEvent fires before context compaction runs. Provides
|
||||
// information about the current context state to help extensions decide
|
||||
// whether to allow or block compaction.
|
||||
type BeforeCompactEvent struct {
|
||||
// EstimatedTokens is the estimated token count of the conversation.
|
||||
EstimatedTokens int
|
||||
// ContextLimit is the model's context window size in tokens.
|
||||
ContextLimit int
|
||||
// UsagePercent is the fraction of context used (0.0–1.0).
|
||||
UsagePercent float64
|
||||
// MessageCount is the number of messages in the conversation.
|
||||
MessageCount int
|
||||
// IsAutomatic is true when compaction was triggered automatically
|
||||
// (as opposed to manual /compact command).
|
||||
IsAutomatic bool
|
||||
}
|
||||
|
||||
func (e BeforeCompactEvent) Type() EventType { return BeforeCompact }
|
||||
|
||||
// BeforeCompactResult controls whether compaction proceeds. Return
|
||||
// Cancel=true with an optional Reason to block compaction.
|
||||
type BeforeCompactResult struct {
|
||||
// Cancel, when true, prevents compaction from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation shown to the user when
|
||||
// Cancel is true. Empty string uses a default message.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (BeforeCompactResult) isResult() {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package extensions implements a Pi-style in-process extension system for KIT.
|
||||
// Package extensions implements an in-process extension system for KIT.
|
||||
// Extensions are plain Go files loaded at runtime via Yaegi (a Go interpreter).
|
||||
// They register event handlers using an API object, enabling tool interception,
|
||||
// input transformation, and lifecycle observation — all without recompilation.
|
||||
@@ -48,6 +48,26 @@ const (
|
||||
|
||||
// SessionShutdown fires when the application is closing.
|
||||
SessionShutdown EventType = "session_shutdown"
|
||||
|
||||
// ModelChange fires after the active model is changed via ctx.SetModel().
|
||||
ModelChange EventType = "model_change"
|
||||
|
||||
// ContextPrepare fires after context is built from the session tree and
|
||||
// before the messages are sent to the LLM. Handlers can filter, reorder,
|
||||
// or inject messages into the context window.
|
||||
ContextPrepare EventType = "context_prepare"
|
||||
|
||||
// BeforeFork fires before the session tree is branched to a different
|
||||
// entry point. Handlers can cancel the fork by returning Cancel=true.
|
||||
BeforeFork EventType = "before_fork"
|
||||
|
||||
// BeforeSessionSwitch fires before the session is switched to a new
|
||||
// branch (e.g. /new command). Handlers can cancel by returning Cancel=true.
|
||||
BeforeSessionSwitch EventType = "before_session_switch"
|
||||
|
||||
// BeforeCompact fires before context compaction runs. Handlers can
|
||||
// cancel compaction by returning Cancel=true.
|
||||
BeforeCompact EventType = "before_compact"
|
||||
)
|
||||
|
||||
// AllEventTypes returns every supported event type.
|
||||
@@ -57,6 +77,8 @@ func AllEventTypes() []EventType {
|
||||
Input, BeforeAgentStart, AgentStart, AgentEnd,
|
||||
MessageStart, MessageUpdate, MessageEnd,
|
||||
SessionStart, SessionShutdown,
|
||||
ModelChange, ContextPrepare,
|
||||
BeforeFork, BeforeSessionSwitch, BeforeCompact,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import "testing"
|
||||
|
||||
func TestAllEventTypes_Count(t *testing.T) {
|
||||
all := AllEventTypes()
|
||||
if len(all) != 13 {
|
||||
t.Fatalf("expected 13 event types, got %d", len(all))
|
||||
if len(all) != 18 {
|
||||
t.Fatalf("expected 18 event types, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ func TestEventType_TypeMethod(t *testing.T) {
|
||||
{MessageEndEvent{Content: "done"}, MessageEnd},
|
||||
{SessionStartEvent{SessionID: "abc"}, SessionStart},
|
||||
{SessionShutdownEvent{}, SessionShutdown},
|
||||
{ModelChangeEvent{NewModel: "a/b"}, ModelChange},
|
||||
{ContextPrepareEvent{Messages: []ContextMessage{{Index: 0, Role: "user", Content: "hi"}}}, ContextPrepare},
|
||||
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
|
||||
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
|
||||
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -283,6 +283,48 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onModelChange: func(h func(ModelChangeEvent, Context)) {
|
||||
reg(ModelChange, func(e Event, c Context) Result {
|
||||
h(e.(ModelChangeEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
|
||||
reg(ContextPrepare, func(e Event, c Context) Result {
|
||||
r := h(e.(ContextPrepareEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
|
||||
reg(BeforeFork, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeForkEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
|
||||
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeSessionSwitchEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
|
||||
reg(BeforeCompact, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeCompactEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
registerToolFn: func(tool ToolDef) {
|
||||
ext.Tools = append(ext.Tools, tool)
|
||||
},
|
||||
@@ -292,6 +334,21 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
registerToolRendererFn: func(config ToolRenderConfig) {
|
||||
ext.ToolRenderers = append(ext.ToolRenderers, config)
|
||||
},
|
||||
registerMessageRendererFn: func(config MessageRendererConfig) {
|
||||
ext.MessageRenderers = append(ext.MessageRenderers, config)
|
||||
},
|
||||
onCustomEvent: func(name string, handler func(string)) {
|
||||
if ext.CustomEventHandlers == nil {
|
||||
ext.CustomEventHandlers = make(map[string][]func(string))
|
||||
}
|
||||
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
|
||||
},
|
||||
registerOption: func(opt OptionDef) {
|
||||
ext.Options = append(ext.Options, opt)
|
||||
},
|
||||
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
|
||||
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
|
||||
},
|
||||
}
|
||||
|
||||
// Call Init — the extension registers its handlers, tools, commands.
|
||||
|
||||
+333
-20
@@ -2,34 +2,52 @@ package extensions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Runner manages loaded extensions and dispatches events to their handlers
|
||||
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
|
||||
// sequentially. Handlers execute in extension
|
||||
// load order; for cancellable events the first blocking result wins.
|
||||
type Runner struct {
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
customEditor *EditorConfig // nil = no custom editor interceptor
|
||||
mu sync.RWMutex
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
statusEntries map[string]StatusBarEntry // keyed by status key
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
customEditor *EditorConfig // nil = no custom editor interceptor
|
||||
uiVisibility *UIVisibility // nil = show everything (default)
|
||||
disabledTools map[string]bool // nil = all tools enabled
|
||||
customEventSubs map[string][]func(string) // inter-extension event bus
|
||||
optionOverrides map[string]string // runtime option overrides
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ShortcutEntry pairs a shortcut definition with its handler.
|
||||
type ShortcutEntry struct {
|
||||
Def ShortcutDef
|
||||
Handler func(Context)
|
||||
}
|
||||
|
||||
// LoadedExtension represents a single extension that has been discovered,
|
||||
// loaded, and initialised. It holds the registered handlers and any custom
|
||||
// tools, commands, or tool renderers the extension provided.
|
||||
type LoadedExtension struct {
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
MessageRenderers []MessageRendererConfig // named message renderers
|
||||
CustomEventHandlers map[string][]func(string) // inter-extension event bus
|
||||
Options []OptionDef // registered configuration options
|
||||
Shortcuts []ShortcutEntry // global keyboard shortcuts
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner from a set of loaded extensions.
|
||||
@@ -45,6 +63,13 @@ func (r *Runner) SetContext(ctx Context) {
|
||||
r.ctx = ctx
|
||||
}
|
||||
|
||||
// GetContext returns a snapshot of the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// HasHandlers returns true if any loaded extension has at least one handler
|
||||
// registered for the given event type.
|
||||
func (r *Runner) HasHandlers(event EventType) bool {
|
||||
@@ -121,13 +146,6 @@ func (r *Runner) RegisteredCommands() []CommandDef {
|
||||
return cmds
|
||||
}
|
||||
|
||||
// GetContext returns the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// Extensions returns the loaded extensions for inspection (e.g. CLI list).
|
||||
func (r *Runner) Extensions() []LoadedExtension {
|
||||
return r.extensions
|
||||
@@ -177,6 +195,45 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetStatusEntry places or updates a keyed status bar entry. Thread-safe.
|
||||
func (r *Runner) SetStatusEntry(entry StatusBarEntry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.statusEntries == nil {
|
||||
r.statusEntries = make(map[string]StatusBarEntry)
|
||||
}
|
||||
r.statusEntries[entry.Key] = entry
|
||||
}
|
||||
|
||||
// RemoveStatusEntry removes a status bar entry by key. Thread-safe.
|
||||
func (r *Runner) RemoveStatusEntry(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.statusEntries, key)
|
||||
}
|
||||
|
||||
// GetStatusEntries returns all status bar entries, sorted by priority
|
||||
// (ascending). Thread-safe.
|
||||
func (r *Runner) GetStatusEntries() []StatusBarEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]StatusBarEntry, 0, len(r.statusEntries))
|
||||
for _, e := range r.statusEntries {
|
||||
result = append(result, e)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Priority != result[j].Priority {
|
||||
return result[i].Priority < result[j].Priority
|
||||
}
|
||||
return result[i].Key < result[j].Key
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,6 +326,29 @@ func (r *Runner) GetEditor() *EditorConfig {
|
||||
return &e
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI visibility management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetUIVisibility updates the UI visibility overrides. Thread-safe.
|
||||
func (r *Runner) SetUIVisibility(v UIVisibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.uiVisibility = &v
|
||||
}
|
||||
|
||||
// GetUIVisibility returns the current UI visibility overrides, or nil if
|
||||
// none have been set (meaning show everything). Thread-safe.
|
||||
func (r *Runner) GetUIVisibility() *UIVisibility {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.uiVisibility == nil {
|
||||
return nil
|
||||
}
|
||||
v := *r.uiVisibility
|
||||
return &v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -290,6 +370,233 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetMessageRenderer returns the named message renderer, or nil if no
|
||||
// extension registered a renderer with that name. If multiple extensions
|
||||
// register the same name, the last one (by load order) wins.
|
||||
func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for j := len(r.extensions[i].MessageRenderers) - 1; j >= 0; j-- {
|
||||
if r.extensions[i].MessageRenderers[j].Name == name {
|
||||
config := r.extensions[i].MessageRenderers[j]
|
||||
return &config
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot-reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reload replaces the loaded extensions with a fresh set and clears all
|
||||
// dynamic state (widgets, status, header/footer, editor, visibility,
|
||||
// disabled tools, custom event subscriptions). Option overrides are
|
||||
// preserved across reloads since they represent user intent.
|
||||
//
|
||||
// The caller is responsible for emitting SessionShutdown before calling
|
||||
// Reload and SessionStart after.
|
||||
func (r *Runner) Reload(exts []LoadedExtension) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.extensions = exts
|
||||
r.widgets = nil
|
||||
r.statusEntries = nil
|
||||
r.header = nil
|
||||
r.footer = nil
|
||||
r.customEditor = nil
|
||||
r.uiVisibility = nil
|
||||
r.disabledTools = nil
|
||||
r.customEventSubs = nil
|
||||
// optionOverrides are intentionally preserved.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inter-extension event bus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubscribeCustomEvent registers a handler for a named custom event. Handlers
|
||||
// execute in registration order when EmitCustomEvent is called. Thread-safe.
|
||||
func (r *Runner) SubscribeCustomEvent(name string, handler func(string)) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.customEventSubs == nil {
|
||||
r.customEventSubs = make(map[string][]func(string))
|
||||
}
|
||||
r.customEventSubs[name] = append(r.customEventSubs[name], handler)
|
||||
}
|
||||
|
||||
// EmitCustomEvent dispatches a named event to all subscribed handlers.
|
||||
// Handlers run synchronously in extension load order. Panics are recovered
|
||||
// and logged. Thread-safe.
|
||||
func (r *Runner) EmitCustomEvent(name, data string) {
|
||||
// Collect handlers: extension-registered (Init-time) + dynamic subs.
|
||||
r.mu.RLock()
|
||||
dynamicHandlers := r.customEventSubs[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
safeInvoke := func(h func(string)) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Warn("custom event handler panicked",
|
||||
"event", name,
|
||||
"err", fmt.Sprintf("%v", rec))
|
||||
}
|
||||
}()
|
||||
h(data)
|
||||
}
|
||||
|
||||
// Extension-registered handlers first (in load order).
|
||||
for i := range r.extensions {
|
||||
for _, h := range r.extensions[i].CustomEventHandlers[name] {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
// Then dynamic subscriptions.
|
||||
for _, h := range dynamicHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetActiveTools restricts the tool set to the named tools. All tools not in
|
||||
// the list are disabled. Passing nil or an empty slice re-enables all tools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) SetActiveTools(names []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(names) == 0 {
|
||||
r.disabledTools = nil
|
||||
return
|
||||
}
|
||||
active := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
active[n] = true
|
||||
}
|
||||
r.disabledTools = active // non-nil = only these tools are allowed
|
||||
}
|
||||
|
||||
// IsToolDisabled returns true if the tool has been disabled via SetActiveTools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) IsToolDisabled(toolName string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.disabledTools == nil {
|
||||
return false // no filter = all enabled
|
||||
}
|
||||
return !r.disabledTools[toolName]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetOption resolves a named option value in priority order:
|
||||
// 1. Runtime override (via SetOption)
|
||||
// 2. Environment variable: KIT_OPT_<NAME> (uppercased, dashes → underscores)
|
||||
// 3. Viper config: options.<name>
|
||||
// 4. Default value from RegisterOption
|
||||
//
|
||||
// Returns empty string if the option was never registered.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetOption(name string) string {
|
||||
// 1. Runtime override.
|
||||
r.mu.RLock()
|
||||
if v, ok := r.optionOverrides[name]; ok {
|
||||
r.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// 2. Environment variable: KIT_OPT_<NAME>
|
||||
envKey := "KIT_OPT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
|
||||
if v := os.Getenv(envKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 3. Viper config: options.<name>
|
||||
configKey := "options." + name
|
||||
if v := viper.GetString(configKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 4. Default from registered option defs.
|
||||
for i := range r.extensions {
|
||||
for _, opt := range r.extensions[i].Options {
|
||||
if opt.Name == name {
|
||||
return opt.Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetOption stores a runtime override for a named option. This takes highest
|
||||
// priority over env vars, config, and defaults. Thread-safe.
|
||||
func (r *Runner) SetOption(name, value string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.optionOverrides == nil {
|
||||
r.optionOverrides = make(map[string]string)
|
||||
}
|
||||
r.optionOverrides[name] = value
|
||||
}
|
||||
|
||||
// RegisteredOptions returns all option definitions from all loaded extensions.
|
||||
func (r *Runner) RegisteredOptions() []OptionDef {
|
||||
var opts []OptionDef
|
||||
for i := range r.extensions {
|
||||
opts = append(opts, r.extensions[i].Options...)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetShortcuts returns all registered keyboard shortcuts as a map of
|
||||
// key binding → handler. If multiple extensions register the same key,
|
||||
// the last registration wins. Thread-safe (reads extension list which is
|
||||
// immutable after loading).
|
||||
func (r *Runner) GetShortcuts() map[string]ShortcutEntry {
|
||||
result := make(map[string]ShortcutEntry)
|
||||
for i := range r.extensions {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
result[sc.Def.Key] = sc
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RegisteredShortcuts returns all shortcut definitions from all loaded
|
||||
// extensions. Used for help/listing commands.
|
||||
func (r *Runner) RegisteredShortcuts() []ShortcutDef {
|
||||
var defs []ShortcutDef
|
||||
seen := make(map[string]bool)
|
||||
// Iterate in reverse so last registration for a key wins.
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
if !seen[sc.Def.Key] {
|
||||
seen[sc.Def.Key] = true
|
||||
defs = append(defs, sc.Def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,6 +618,12 @@ func isBlocking(result Result) bool {
|
||||
return r.Block
|
||||
case InputResult:
|
||||
return r.Action == "handled"
|
||||
case BeforeForkResult:
|
||||
return r.Cancel
|
||||
case BeforeSessionSwitchResult:
|
||||
return r.Cancel
|
||||
case BeforeCompactResult:
|
||||
return r.Cancel
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -23,9 +23,31 @@ func Symbols() interp.Exports {
|
||||
"API": reflect.ValueOf((*API)(nil)),
|
||||
"Context": reflect.ValueOf((*Context)(nil)),
|
||||
"ToolDef": reflect.ValueOf((*ToolDef)(nil)),
|
||||
"ToolContext": reflect.ValueOf((*ToolContext)(nil)),
|
||||
"ShortcutDef": reflect.ValueOf((*ShortcutDef)(nil)),
|
||||
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
|
||||
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
|
||||
|
||||
// Session types
|
||||
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
|
||||
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
|
||||
|
||||
// Option types
|
||||
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
|
||||
|
||||
// Model info types
|
||||
"ModelInfoEntry": reflect.ValueOf((*ModelInfoEntry)(nil)),
|
||||
|
||||
// Tool info types
|
||||
"ToolInfo": reflect.ValueOf((*ToolInfo)(nil)),
|
||||
|
||||
// LLM completion types
|
||||
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
|
||||
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
|
||||
|
||||
// Status bar types
|
||||
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
|
||||
|
||||
// Widget types
|
||||
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
|
||||
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
|
||||
@@ -37,6 +59,12 @@ func Symbols() interp.Exports {
|
||||
// Header/Footer types
|
||||
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
|
||||
|
||||
// UI visibility
|
||||
"UIVisibility": reflect.ValueOf((*UIVisibility)(nil)),
|
||||
|
||||
// Context stats
|
||||
"ContextStats": reflect.ValueOf((*ContextStats)(nil)),
|
||||
|
||||
// Overlay types
|
||||
"OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)),
|
||||
"OverlayCenter": reflect.ValueOf(OverlayCenter),
|
||||
@@ -49,6 +77,9 @@ func Symbols() interp.Exports {
|
||||
// Tool renderer types
|
||||
"ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)),
|
||||
|
||||
// Message renderer types
|
||||
"MessageRendererConfig": reflect.ValueOf((*MessageRendererConfig)(nil)),
|
||||
|
||||
// Editor interceptor types
|
||||
"EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)),
|
||||
"EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough),
|
||||
@@ -66,6 +97,19 @@ func Symbols() interp.Exports {
|
||||
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
|
||||
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
|
||||
|
||||
// Context filtering types
|
||||
"ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),
|
||||
"ContextPrepareEvent": reflect.ValueOf((*ContextPrepareEvent)(nil)),
|
||||
"ContextPrepareResult": reflect.ValueOf((*ContextPrepareResult)(nil)),
|
||||
|
||||
// Session lifecycle types
|
||||
"BeforeForkEvent": reflect.ValueOf((*BeforeForkEvent)(nil)),
|
||||
"BeforeForkResult": reflect.ValueOf((*BeforeForkResult)(nil)),
|
||||
"BeforeSessionSwitchEvent": reflect.ValueOf((*BeforeSessionSwitchEvent)(nil)),
|
||||
"BeforeSessionSwitchResult": reflect.ValueOf((*BeforeSessionSwitchResult)(nil)),
|
||||
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
|
||||
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
@@ -84,6 +128,7 @@ func Symbols() interp.Exports {
|
||||
"MessageEndEvent": reflect.ValueOf((*MessageEndEvent)(nil)),
|
||||
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
|
||||
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
|
||||
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,17 @@ import (
|
||||
|
||||
// WrapToolsWithExtensions wraps each tool so that ToolCall and ToolResult
|
||||
// events are emitted through the extension runner before and after execution.
|
||||
// This is the Go equivalent of Pi's wrapper.ts pattern.
|
||||
//
|
||||
|
||||
// If the runner has no relevant handlers the original tools are returned
|
||||
// unchanged (zero overhead).
|
||||
func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantasy.AgentTool {
|
||||
if runner == nil {
|
||||
return tools
|
||||
}
|
||||
if !runner.HasHandlers(ToolCall) && !runner.HasHandlers(ToolResult) &&
|
||||
!runner.HasHandlers(ToolExecutionStart) && !runner.HasHandlers(ToolExecutionEnd) {
|
||||
return tools
|
||||
}
|
||||
|
||||
// Always wrap tools through the runner so that SetActiveTools
|
||||
// (disabled-tool checking) and event handlers both work. The
|
||||
// overhead for disabled-tool checking is a single map lookup
|
||||
// per tool call, which is negligible.
|
||||
wrapped := make([]fantasy.AgentTool, len(tools))
|
||||
for i, tool := range tools {
|
||||
wrapped[i] = &wrappedTool{inner: tool, runner: runner}
|
||||
@@ -31,10 +29,12 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas
|
||||
|
||||
// ExtensionToolsAsFantasy converts ToolDef values registered by extensions
|
||||
// into fantasy.AgentTool implementations so the LLM can invoke them.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef) []fantasy.AgentTool {
|
||||
// The runner is optional; if provided, ToolContext.OnProgress routes
|
||||
// progress messages through the runner's Print function.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool {
|
||||
tools := make([]fantasy.AgentTool, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
tools = append(tools, &extensionTool{def: def})
|
||||
tools = append(tools, &extensionTool{def: def, runner: runner})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
@@ -55,12 +55,20 @@ func (w *wrappedTool) SetProviderOptions(o fantasy.ProviderOptions) { w.inner.Se
|
||||
func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
toolName := w.inner.Info().Name
|
||||
|
||||
// 0. Check if tool is disabled via SetActiveTools.
|
||||
if w.runner.IsToolDisabled(toolName) {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
|
||||
fmt.Errorf("tool %q disabled by extension", toolName)
|
||||
}
|
||||
|
||||
// 1. Emit ToolCall — extensions can block execution.
|
||||
if w.runner.HasHandlers(ToolCall) {
|
||||
result, _ := w.runner.Emit(ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
ToolCallID: call.ID,
|
||||
Input: call.Input,
|
||||
Source: "llm",
|
||||
})
|
||||
if r, ok := result.(ToolCallResult); ok && r.Block {
|
||||
reason := r.Reason
|
||||
@@ -112,6 +120,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
type extensionTool struct {
|
||||
def ToolDef
|
||||
runner *Runner // optional; enables ToolContext.OnProgress
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
@@ -125,8 +134,31 @@ func (t *extensionTool) Info() fantasy.ToolInfo {
|
||||
func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
|
||||
func (t *extensionTool) SetProviderOptions(o fantasy.ProviderOptions) { t.providerOptions = o }
|
||||
|
||||
func (t *extensionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result, err := t.def.Execute(call.Input)
|
||||
func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var result string
|
||||
var err error
|
||||
|
||||
if t.def.ExecuteWithContext != nil {
|
||||
tc := ToolContext{
|
||||
IsCancelled: func() bool {
|
||||
return ctx.Err() != nil
|
||||
},
|
||||
OnProgress: func(text string) {
|
||||
if t.runner != nil {
|
||||
t.runner.mu.RLock()
|
||||
printFn := t.runner.ctx.Print
|
||||
t.runner.mu.RUnlock()
|
||||
if printFn != nil {
|
||||
printFn(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
result, err = t.def.ExecuteWithContext(call.Input, tc)
|
||||
} else {
|
||||
result, err = t.def.Execute(call.Input)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), err
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
|
||||
}))
|
||||
tools := []fantasy.AgentTool{newMockTool("test")}
|
||||
result := WrapToolsWithExtensions(tools, r)
|
||||
if result[0] != tools[0] {
|
||||
t.Error("expected original tool when no tool handlers exist")
|
||||
// Tools are always wrapped now (for SetActiveTools support),
|
||||
// but Info() should pass through correctly.
|
||||
if result[0] == tools[0] {
|
||||
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
|
||||
}
|
||||
if result[0].Info().Name != "test" {
|
||||
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +107,22 @@ func TestWrappedTool_NormalExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_SourceField(t *testing.T) {
|
||||
var gotSource string
|
||||
r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{
|
||||
ToolCall: {func(e Event, c Context) Result {
|
||||
gotSource = e.(ToolCallEvent).Source
|
||||
return nil
|
||||
}},
|
||||
}))
|
||||
|
||||
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r)
|
||||
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
|
||||
if gotSource != "llm" {
|
||||
t.Errorf("expected Source='llm', got %q", gotSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_BlockExecution(t *testing.T) {
|
||||
var toolRan bool
|
||||
r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{
|
||||
@@ -181,7 +202,7 @@ func TestExtensionToolsAsFantasy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
@@ -211,7 +232,7 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
@@ -221,9 +242,104 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContext(t *testing.T) {
|
||||
var gotCancelled bool
|
||||
var gotProgress []string
|
||||
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "rich",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
gotCancelled = tc.IsCancelled()
|
||||
tc.OnProgress("step 1")
|
||||
tc.OnProgress("step 2")
|
||||
return "done: " + input, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Without runner, OnProgress is a no-op.
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "done: test" {
|
||||
t.Errorf("expected 'done: test', got %q", resp.Content)
|
||||
}
|
||||
if gotCancelled {
|
||||
t.Error("expected IsCancelled=false for non-cancelled context")
|
||||
}
|
||||
|
||||
// With runner, OnProgress routes through Print.
|
||||
runner := NewRunner(nil)
|
||||
runner.SetContext(Context{
|
||||
Print: func(text string) { gotProgress = append(gotProgress, text) },
|
||||
})
|
||||
defs2 := []ToolDef{
|
||||
{
|
||||
Name: "rich2",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
tc.OnProgress("hello")
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools2 := ExtensionToolsAsFantasy(defs2, runner)
|
||||
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(gotProgress) != 1 || gotProgress[0] != "hello" {
|
||||
t.Errorf("expected [hello], got %v", gotProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
|
||||
// When both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "both",
|
||||
Execute: func(input string) (string, error) { return "simple", nil },
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
return "rich", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "rich" {
|
||||
t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_CancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
var sawCancelled bool
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "checkcancel",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
sawCancelled = tc.IsCancelled()
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
|
||||
if !sawCancelled {
|
||||
t.Error("expected IsCancelled=true for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ProviderOptions(t *testing.T) {
|
||||
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
|
||||
// Initially nil.
|
||||
opts := tools[0].ProviderOptions()
|
||||
|
||||
@@ -186,7 +186,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
|
||||
return extensions.WrapToolsWithExtensions(tools, runner)
|
||||
}
|
||||
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools())
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools(), runner)
|
||||
|
||||
return runner, extensionCreationOpts{
|
||||
toolWrapper: wrapper,
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// EntryType identifies the kind of entry stored in a JSONL session file.
|
||||
// Following pi's design, sessions are append-only JSONL files where each line
|
||||
// is a typed entry linked by id/parent_id to form a tree structure.
|
||||
// Sessions are append-only JSONL files where each line is a typed entry
|
||||
// linked by id/parent_id to form a tree structure.
|
||||
type EntryType string
|
||||
|
||||
const (
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
EntryTypeBranchSummary EntryType = "branch_summary"
|
||||
EntryTypeLabel EntryType = "label"
|
||||
EntryTypeSessionInfo EntryType = "session_info"
|
||||
EntryTypeExtensionData EntryType = "extension_data"
|
||||
)
|
||||
|
||||
// CurrentVersion is the session format version for JSONL tree sessions.
|
||||
@@ -89,6 +90,14 @@ type SessionInfoEntry struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ExtensionDataEntry stores custom extension data in the session tree.
|
||||
// Extensions use this to persist state that survives across session restarts.
|
||||
type ExtensionDataEntry struct {
|
||||
Entry
|
||||
ExtType string `json:"ext_type"` // Extension-defined type string (e.g. "plan-mode:state")
|
||||
Data string `json:"data"` // Extension-defined data (JSON or plain text)
|
||||
}
|
||||
|
||||
// GenerateEntryID creates a unique entry identifier (16 hex chars).
|
||||
func GenerateEntryID() string {
|
||||
bytes := make([]byte, 8)
|
||||
@@ -177,6 +186,15 @@ func NewSessionInfoEntry(parentID, name string) *SessionInfoEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// NewExtensionDataEntry creates an ExtensionDataEntry.
|
||||
func NewExtensionDataEntry(parentID, extType, data string) *ExtensionDataEntry {
|
||||
return &ExtensionDataEntry{
|
||||
Entry: NewEntry(EntryTypeExtensionData, parentID),
|
||||
ExtType: extType,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSONL marshaling helpers ---
|
||||
|
||||
// MarshalEntry serializes any entry to a JSON line (no trailing newline).
|
||||
@@ -241,6 +259,13 @@ func UnmarshalEntry(data []byte) (any, error) {
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
case EntryTypeExtensionData:
|
||||
var e ExtensionDataEntry
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal extension_data entry: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown entry type: %q", env.Type)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// SessionInfo contains metadata about a discovered session, used for listing
|
||||
// and session picker display. Follows pi's SessionInfo design.
|
||||
// and session picker display.
|
||||
type SessionInfo struct {
|
||||
// Path is the absolute path to the JSONL session file.
|
||||
Path string
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// TreeNode represents a node in the session tree for display purposes.
|
||||
// It mirrors pi's SessionTreeNode design.
|
||||
|
||||
type TreeNode struct {
|
||||
Entry any // the underlying entry (*MessageEntry, *ModelChangeEntry, etc.)
|
||||
ID string // entry ID
|
||||
@@ -25,7 +25,7 @@ type TreeNode struct {
|
||||
}
|
||||
|
||||
// TreeManager manages a tree-structured JSONL session. It is the replacement
|
||||
// for the linear session.Manager, following pi's design decisions:
|
||||
// for the linear session.Manager:
|
||||
//
|
||||
// - JSONL append-only format (one JSON object per line)
|
||||
// - Tree structure via id/parent_id on every entry
|
||||
@@ -283,6 +283,44 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
|
||||
return entry.ID, nil
|
||||
}
|
||||
|
||||
// AppendExtensionData adds an extension data entry to the tree and persists it.
|
||||
// Extensions use this to store custom state that survives across session restarts.
|
||||
func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
entry := NewExtensionDataEntry(tm.leafID, extType, data)
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
}
|
||||
|
||||
// GetExtensionData returns all extension data entries matching the given type,
|
||||
// walking the current branch from root to leaf. If extType is empty, all
|
||||
// extension data entries on the branch are returned.
|
||||
func (tm *TreeManager) GetExtensionData(extType string) []*ExtensionDataEntry {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
if tm.leafID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
branch := tm.getBranchLocked(tm.leafID)
|
||||
var results []*ExtensionDataEntry
|
||||
for _, entry := range branch {
|
||||
if e, ok := entry.(*ExtensionDataEntry); ok {
|
||||
if extType == "" || e.ExtType == extType {
|
||||
results = append(results, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// --- Tree navigation ---
|
||||
|
||||
// Branch moves the leaf pointer to the given entry ID, creating a branch
|
||||
@@ -601,6 +639,8 @@ func (tm *TreeManager) entryID(entry any) string {
|
||||
return e.ID
|
||||
case *SessionInfoEntry:
|
||||
return e.ID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -619,6 +659,8 @@ func (tm *TreeManager) entryParentID(entry any) string {
|
||||
return e.ParentID
|
||||
case *SessionInfoEntry:
|
||||
return e.ParentID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ParentID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -675,7 +717,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
|
||||
// --- Path conventions ---
|
||||
|
||||
// DefaultSessionDir returns the default session storage directory for a cwd.
|
||||
// Following pi's convention: ~/.kit/sessions/--<cwd-path>--/
|
||||
// Convention: ~/.kit/sessions/--<cwd-path>--/
|
||||
func DefaultSessionDir(cwd string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -202,7 +202,7 @@ func LoadSkills(cwd string) ([]*Skill, error) {
|
||||
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
|
||||
// system prompt. Only the name, description, and file location are included;
|
||||
// the agent reads the full skill file on demand using the read tool. This
|
||||
// matches the Pi SDK's formatSkillsForPrompt convention.
|
||||
|
||||
func FormatForPrompt(skills []*Skill) string {
|
||||
if len(skills) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -9,7 +9,8 @@ type SlashCommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Aliases []string
|
||||
Category string // e.g., "Navigation", "System", "Info"
|
||||
Category string // e.g., "Navigation", "System", "Info"
|
||||
Complete func(prefix string) []string // optional argument tab-completion
|
||||
}
|
||||
|
||||
// SlashCommands provides the global registry of all available slash commands
|
||||
@@ -136,6 +137,7 @@ type ExtensionCommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Execute func(args string) (string, error)
|
||||
Complete func(prefix string) []string // optional argument tab-completion
|
||||
}
|
||||
|
||||
// FindExtensionCommand looks up an extension command by name from the given
|
||||
|
||||
@@ -188,7 +188,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
|
||||
}
|
||||
|
||||
// Format body: check extension renderer first, then builtin, then default.
|
||||
// Format body: check extension renderer first, then compact builtin, then default.
|
||||
var body string
|
||||
if extRd != nil && extRd.RenderBody != nil {
|
||||
body = extRd.RenderBody(toolResult, isError, r.width-4)
|
||||
@@ -201,7 +201,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
|
||||
// Use compact summary renderers instead of full tool body renderers.
|
||||
body = renderToolBodyCompact(toolName, toolArgs, toolResult, r.width-4)
|
||||
if body == "" {
|
||||
formatted := r.formatToolResult(toolResult)
|
||||
if formatted == "" {
|
||||
|
||||
+91
-13
@@ -36,9 +36,19 @@ type InputComponent struct {
|
||||
title string
|
||||
submitNext bool // defer submit one tick so popup dismisses cleanly
|
||||
|
||||
// Argument completion state. When the user types "/cmd " followed by
|
||||
// a partial argument and the command has a Complete function, the popup
|
||||
// switches to argument-completion mode showing suggestions from Complete.
|
||||
argMode bool // true when showing arg completions
|
||||
argCommand string // command prefix for arg mode (e.g. "/bookmark")
|
||||
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
|
||||
|
||||
// appCtrl is used for slash commands that mutate app state.
|
||||
// May be nil in tests; nil-safe.
|
||||
appCtrl AppController
|
||||
|
||||
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
|
||||
hideHint bool
|
||||
}
|
||||
|
||||
// NewInputComponent creates a new InputComponent with the given width, title,
|
||||
@@ -138,7 +148,11 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||
if s.selected < len(s.filtered) {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
if s.argMode {
|
||||
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
|
||||
} else {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
}
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
s.textarea.CursorEnd()
|
||||
@@ -147,8 +161,12 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if s.selected < len(s.filtered) {
|
||||
// Populate textarea with selected command and submit on next tick.
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
// Populate textarea with selected item and submit on next tick.
|
||||
if s.argMode {
|
||||
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
|
||||
} else {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
}
|
||||
s.textarea.CursorEnd()
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
@@ -172,12 +190,26 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if value != s.lastValue {
|
||||
s.lastValue = value
|
||||
lines := strings.Split(value, "\n")
|
||||
if len(lines) == 1 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") {
|
||||
s.showPopup = true
|
||||
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
|
||||
s.selected = 0
|
||||
if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
|
||||
if !strings.Contains(lines[0], " ") {
|
||||
// Command name completion.
|
||||
s.showPopup = true
|
||||
s.argMode = false
|
||||
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
|
||||
s.selected = 0
|
||||
} else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 {
|
||||
// Argument completion for a command with a Complete function.
|
||||
s.showPopup = true
|
||||
// s.argMode, s.argCommand, s.argSynthCmds, s.filtered
|
||||
// are set by completeArgs.
|
||||
s.selected = 0
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.argMode = false
|
||||
}
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.argMode = false
|
||||
}
|
||||
}
|
||||
return s, cmd
|
||||
@@ -254,13 +286,15 @@ func (s *InputComponent) View() tea.View {
|
||||
view.WriteString(s.renderPopup())
|
||||
}
|
||||
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
|
||||
}
|
||||
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
}
|
||||
@@ -326,3 +360,47 @@ func (s *InputComponent) renderPopup() string {
|
||||
|
||||
return popupStyle.Render(content + "\n\n" + footer)
|
||||
}
|
||||
|
||||
// completeArgs checks whether the input line matches a command with a Complete
|
||||
// function, calls it, and populates the arg-mode state on success. Returns the
|
||||
// list of suggestions (empty means no completions available).
|
||||
func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
cmdName := parts[0]
|
||||
argPrefix := ""
|
||||
if len(parts) > 1 {
|
||||
argPrefix = parts[1]
|
||||
}
|
||||
|
||||
cmd := s.findCommandWithComplete(cmdName)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
suggestions := cmd.Complete(argPrefix)
|
||||
if len(suggestions) == 0 {
|
||||
s.argMode = false
|
||||
return nil
|
||||
}
|
||||
|
||||
s.argMode = true
|
||||
s.argCommand = cmdName
|
||||
s.argSynthCmds = make([]SlashCommand, len(suggestions))
|
||||
s.filtered = make([]FuzzyMatch, len(suggestions))
|
||||
for i, sug := range suggestions {
|
||||
s.argSynthCmds[i] = SlashCommand{Name: sug}
|
||||
s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]}
|
||||
}
|
||||
return s.filtered
|
||||
}
|
||||
|
||||
// findCommandWithComplete looks up a command by name that has a non-nil
|
||||
// Complete function.
|
||||
func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
|
||||
for i := range s.commands {
|
||||
if s.commands[i].Name == name && s.commands[i].Complete != nil {
|
||||
return &s.commands[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+218
-14
@@ -167,6 +167,24 @@ type WidgetData struct {
|
||||
NoBorder bool
|
||||
}
|
||||
|
||||
// StatusBarEntryData represents a keyed extension entry in the TUI status bar.
|
||||
// Multiple entries from different extensions coexist, ordered by Priority
|
||||
// (lower values render further left).
|
||||
type StatusBarEntryData struct {
|
||||
Key string // unique identifier (e.g. "myext:git-branch")
|
||||
Text string // rendered content shown in the status bar
|
||||
Priority int // lower = further left; built-in entries use 100-110
|
||||
}
|
||||
|
||||
// UIVisibility controls which built-in TUI chrome elements are visible.
|
||||
// The zero value shows everything (backward compatible).
|
||||
type UIVisibility struct {
|
||||
HideStartupMessage bool // Hide the "Model loaded..." startup block
|
||||
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
|
||||
HideSeparator bool // Hide the "────────" divider between stream and input
|
||||
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
|
||||
}
|
||||
|
||||
// AppModelOptions holds configuration passed to NewAppModel.
|
||||
type AppModelOptions struct {
|
||||
// CompactMode selects the compact renderer for message formatting.
|
||||
@@ -242,6 +260,40 @@ type AppModelOptions struct {
|
||||
// intercept key events and during View() to wrap input rendering.
|
||||
// May be nil if no extensions are loaded.
|
||||
GetEditorInterceptor func() *EditorInterceptor
|
||||
|
||||
// GetUIVisibility returns the current UI visibility overrides set by
|
||||
// an extension, or nil if none have been set (show everything).
|
||||
// Called during View() and PrintStartupInfo() to conditionally hide
|
||||
// built-in chrome elements. May be nil if no extensions are loaded.
|
||||
GetUIVisibility func() *UIVisibility
|
||||
|
||||
// GetStatusBarEntries returns extension-provided status bar entries,
|
||||
// sorted by priority. Called during renderStatusBar() to inject
|
||||
// extension entries alongside the built-in model/usage display.
|
||||
// May be nil if no extensions are loaded.
|
||||
GetStatusBarEntries func() []StatusBarEntryData
|
||||
|
||||
// EmitBeforeFork, if non-nil, is called before branching to a
|
||||
// different session tree entry. Returns (cancelled, reason) where
|
||||
// cancelled=true means the fork should be aborted. May be nil if
|
||||
// no extensions are loaded.
|
||||
EmitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
|
||||
|
||||
// EmitBeforeSessionSwitch, if non-nil, is called before switching
|
||||
// to a new session branch (e.g. /new, /clear). Returns (cancelled,
|
||||
// reason). May be nil if no extensions are loaded.
|
||||
EmitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
|
||||
// GetGlobalShortcuts, if non-nil, returns extension-registered global
|
||||
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
|
||||
// Handlers are called in a goroutine to avoid blocking the TUI event
|
||||
// loop. May be nil if no extensions are loaded.
|
||||
GetGlobalShortcuts func() map[string]func()
|
||||
|
||||
// GetExtensionCommands, if non-nil, returns the current extension
|
||||
// commands. Called on WidgetUpdateEvent to refresh the command list
|
||||
// after an extension hot-reload. May be nil if no extensions loaded.
|
||||
GetExtensionCommands func() []ExtensionCommand
|
||||
}
|
||||
|
||||
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
|
||||
@@ -349,6 +401,28 @@ type AppModel struct {
|
||||
// getEditorInterceptor returns the current editor interceptor. May be nil.
|
||||
getEditorInterceptor func() *EditorInterceptor
|
||||
|
||||
// getUIVisibility returns extension-provided UI visibility overrides. May be nil.
|
||||
getUIVisibility func() *UIVisibility
|
||||
|
||||
// getStatusBarEntries returns extension-provided status bar entries. May be nil.
|
||||
getStatusBarEntries func() []StatusBarEntryData
|
||||
|
||||
// emitBeforeFork emits a before-fork event to extensions. Returns
|
||||
// (cancelled, reason). May be nil if no extensions are loaded.
|
||||
emitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
|
||||
|
||||
// emitBeforeSessionSwitch emits a before-session-switch event to extensions.
|
||||
// Returns (cancelled, reason). May be nil if no extensions are loaded.
|
||||
emitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
|
||||
// getGlobalShortcuts returns extension-registered keyboard shortcuts.
|
||||
// May be nil if no extensions are loaded.
|
||||
getGlobalShortcuts func() map[string]func()
|
||||
|
||||
// getExtensionCommands returns the current extension commands. Used
|
||||
// to refresh the command list after an extension hot-reload. May be nil.
|
||||
getExtensionCommands func() []ExtensionCommand
|
||||
|
||||
// prompt holds the state of an active interactive prompt overlay. Nil
|
||||
// when no prompt is active. Managed by updatePromptState().
|
||||
prompt *promptOverlay
|
||||
@@ -462,6 +536,12 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
m.getHeader = opts.GetHeader
|
||||
m.getFooter = opts.GetFooter
|
||||
m.getEditorInterceptor = opts.GetEditorInterceptor
|
||||
m.getUIVisibility = opts.GetUIVisibility
|
||||
m.getStatusBarEntries = opts.GetStatusBarEntries
|
||||
m.emitBeforeFork = opts.EmitBeforeFork
|
||||
m.emitBeforeSessionSwitch = opts.EmitBeforeSessionSwitch
|
||||
m.getGlobalShortcuts = opts.GetGlobalShortcuts
|
||||
m.getExtensionCommands = opts.GetExtensionCommands
|
||||
|
||||
// Store context/skills metadata and tool counts for startup display.
|
||||
m.contextPaths = opts.ContextPaths
|
||||
@@ -479,6 +559,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
Name: ec.Name,
|
||||
Description: ec.Description,
|
||||
Category: "Extensions",
|
||||
Complete: ec.Complete,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -510,12 +591,27 @@ func (m *AppModel) Init() tea.Cmd {
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// uiVis returns the current UIVisibility, defaulting to zero value (show all)
|
||||
// if no extension has set visibility overrides.
|
||||
func (m *AppModel) uiVis() UIVisibility {
|
||||
if m.getUIVisibility != nil {
|
||||
if v := m.getUIVisibility(); v != nil {
|
||||
return *v
|
||||
}
|
||||
}
|
||||
return UIVisibility{}
|
||||
}
|
||||
|
||||
// PrintStartupInfo prints the startup banner (model name, context, skills,
|
||||
// tool counts) to stdout. Call this before program.Run() so the messages are
|
||||
// visible above the Bubble Tea managed region.
|
||||
//
|
||||
// All startup information is rendered inside a single system message block.
|
||||
func (m *AppModel) PrintStartupInfo() {
|
||||
if m.uiVis().HideStartupMessage {
|
||||
return
|
||||
}
|
||||
|
||||
render := func(text string) string {
|
||||
return m.renderer.RenderSystemMessage(text, time.Now()).Content
|
||||
}
|
||||
@@ -608,6 +704,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit before-fork event — extensions can cancel the operation.
|
||||
if m.emitBeforeFork != nil {
|
||||
if cancelled, reason := m.emitBeforeFork(targetID, msg.IsUser, msg.UserText); cancelled {
|
||||
m.treeSelector = nil
|
||||
m.state = stateInput
|
||||
return m, m.printSystemMessage(reason)
|
||||
}
|
||||
}
|
||||
|
||||
_ = ts.Branch(targetID)
|
||||
m.appCtrl.ClearMessages()
|
||||
|
||||
@@ -672,6 +778,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
// Check extension-registered global keyboard shortcuts. These fire
|
||||
// in all app states except modal prompts/overlays (which return early
|
||||
// above). Matched shortcuts are consumed — the key does not propagate
|
||||
// to child components.
|
||||
if m.getGlobalShortcuts != nil {
|
||||
if shortcuts := m.getGlobalShortcuts(); shortcuts != nil {
|
||||
if handler, ok := shortcuts[msg.String()]; ok {
|
||||
// Run in goroutine so blocking extension calls
|
||||
// (PromptSelect, etc.) don't stall the event loop.
|
||||
go handler()
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route to tree selector when active.
|
||||
if m.state == stateTreeSelector && m.treeSelector != nil {
|
||||
updated, cmd := m.treeSelector.Update(msg)
|
||||
@@ -930,12 +1051,50 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.state = stateInput
|
||||
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err)))
|
||||
|
||||
case app.ModelChangedEvent:
|
||||
// Extension changed the model — update display name in status bar
|
||||
// and message attribution.
|
||||
m.providerName = msg.ProviderName
|
||||
m.modelName = msg.ModelName
|
||||
|
||||
case app.WidgetUpdateEvent:
|
||||
// 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()
|
||||
|
||||
// Refresh extension commands (e.g. after hot-reload). The callback
|
||||
// returns the current set from the runner which may have changed.
|
||||
if m.getExtensionCommands != nil {
|
||||
newCmds := m.getExtensionCommands()
|
||||
m.extensionCommands = newCmds
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
// Remove old extension commands and add fresh ones.
|
||||
var builtins []SlashCommand
|
||||
for _, sc := range ic.commands {
|
||||
if sc.Category != "Extensions" {
|
||||
builtins = append(builtins, sc)
|
||||
}
|
||||
}
|
||||
for _, ec := range newCmds {
|
||||
builtins = append(builtins, SlashCommand{
|
||||
Name: ec.Name,
|
||||
Description: ec.Description,
|
||||
Category: "Extensions",
|
||||
Complete: ec.Complete,
|
||||
})
|
||||
}
|
||||
ic.commands = builtins
|
||||
}
|
||||
}
|
||||
|
||||
case app.EditorTextSetEvent:
|
||||
// Extension wants to pre-fill the input editor with text.
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.textarea.SetValue(msg.Text)
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
|
||||
case app.PromptRequestEvent:
|
||||
// Extension wants to show an interactive prompt. Enter prompt state.
|
||||
// If already in prompt state (concurrent prompt from another
|
||||
@@ -1049,8 +1208,14 @@ func (m *AppModel) View() tea.View {
|
||||
return tea.NewView(m.overlay.Render())
|
||||
}
|
||||
|
||||
vis := m.uiVis()
|
||||
|
||||
streamView := m.renderStream()
|
||||
separator := m.renderSeparator()
|
||||
|
||||
// Propagate hint visibility to the input component before rendering.
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.hideHint = vis.HideInputHint
|
||||
}
|
||||
|
||||
// When a prompt is active, it replaces the input area for consistency
|
||||
// (appears below the separator, in the same position as the input).
|
||||
@@ -1060,7 +1225,6 @@ func (m *AppModel) View() tea.View {
|
||||
} else {
|
||||
inputView = m.renderInput()
|
||||
}
|
||||
statusBar := m.renderStatusBar()
|
||||
|
||||
// Build the stacked layout. Optional header/footer wrap the core layout.
|
||||
var parts []string
|
||||
@@ -1076,7 +1240,10 @@ func (m *AppModel) View() tea.View {
|
||||
if streamView != "" {
|
||||
parts = append(parts, streamView)
|
||||
}
|
||||
parts = append(parts, separator)
|
||||
|
||||
if !vis.HideSeparator {
|
||||
parts = append(parts, m.renderSeparator())
|
||||
}
|
||||
|
||||
// Render "above" widgets between separator and queued messages.
|
||||
if aboveView := m.renderWidgetSlot("above"); aboveView != "" {
|
||||
@@ -1094,7 +1261,9 @@ func (m *AppModel) View() tea.View {
|
||||
parts = append(parts, belowView)
|
||||
}
|
||||
|
||||
parts = append(parts, statusBar)
|
||||
if !vis.HideStatusBar {
|
||||
parts = append(parts, m.renderStatusBar())
|
||||
}
|
||||
|
||||
// Custom footer (if set by extension) — below everything.
|
||||
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
|
||||
@@ -1132,7 +1301,8 @@ func (m *AppModel) renderStream() string {
|
||||
}
|
||||
|
||||
// renderStatusBar renders a persistent single-line status bar below the input.
|
||||
// Left side: spinner (when active). Right side: provider · model + usage stats.
|
||||
// Left side: spinner (when active). Middle: extension status entries (sorted by
|
||||
// priority). Right side: provider · model + usage stats.
|
||||
// This bar is always present so its height is constant, eliminating layout
|
||||
// shifts from spinner or usage info appearing/disappearing.
|
||||
func (m *AppModel) renderStatusBar() string {
|
||||
@@ -1143,7 +1313,21 @@ func (m *AppModel) renderStatusBar() string {
|
||||
if m.stream != nil {
|
||||
leftSide = m.stream.SpinnerView()
|
||||
}
|
||||
leftWidth := lipgloss.Width(leftSide)
|
||||
|
||||
// Middle: extension status bar entries (sorted by priority).
|
||||
var middleParts []string
|
||||
if m.getStatusBarEntries != nil {
|
||||
entries := m.getStatusBarEntries()
|
||||
for _, e := range entries {
|
||||
middleParts = append(middleParts, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(e.Text))
|
||||
}
|
||||
}
|
||||
middleSide := strings.Join(middleParts, " ")
|
||||
if middleSide != "" && leftSide != "" {
|
||||
middleSide = " " + middleSide
|
||||
}
|
||||
|
||||
// Right side: provider · model + usage stats.
|
||||
var rightParts []string
|
||||
@@ -1167,12 +1351,12 @@ func (m *AppModel) renderStatusBar() string {
|
||||
}
|
||||
|
||||
rightSide := strings.Join(rightParts, " ")
|
||||
rightWidth := lipgloss.Width(rightSide)
|
||||
|
||||
// Fill the gap between left and right with spaces.
|
||||
gap := max(m.width-leftWidth-rightWidth, 1)
|
||||
// Fill the gap between left+middle and right with spaces.
|
||||
usedWidth := lipgloss.Width(leftSide) + lipgloss.Width(middleSide) + lipgloss.Width(rightSide)
|
||||
gap := max(m.width-usedWidth, 1)
|
||||
|
||||
return leftSide + strings.Repeat(" ", gap) + rightSide
|
||||
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
|
||||
}
|
||||
|
||||
// renderSeparator renders the separator line with an optional queue count badge.
|
||||
@@ -1631,7 +1815,7 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
|
||||
// and on step completion.
|
||||
//
|
||||
// After flushing, a ClearScreen is issued to force a full terminal redraw.
|
||||
// This is the bubbletea equivalent of pi's "clearOnShrink" mechanism: when
|
||||
// When
|
||||
// the stream content is moved to scrollback the view height shrinks, and
|
||||
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
|
||||
// below the managed region. ClearScreen ensures a clean redraw.
|
||||
@@ -1666,11 +1850,24 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
// status bar = 1 line (always present)
|
||||
// footer = measured dynamically (0 if not set)
|
||||
func (m *AppModel) distributeHeight() {
|
||||
const separatorLines = 1
|
||||
const statusBarLines = 1 // always-present status bar
|
||||
vis := m.uiVis()
|
||||
|
||||
separatorLines := 1
|
||||
if vis.HideSeparator {
|
||||
separatorLines = 0
|
||||
}
|
||||
statusBarLines := 1
|
||||
if vis.HideStatusBar {
|
||||
statusBarLines = 0
|
||||
}
|
||||
const linesPerQueuedMsg = 5
|
||||
queuedLines := len(m.queuedMessages) * linesPerQueuedMsg
|
||||
|
||||
// Propagate hint visibility before measuring input height.
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.hideHint = vis.HideInputHint
|
||||
}
|
||||
|
||||
// Measure the actual rendered input (or prompt overlay) height so we
|
||||
// don't rely on a fragile constant that drifts when styling changes.
|
||||
// Use renderInput() which includes the editor interceptor's Render
|
||||
@@ -1807,6 +2004,13 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
|
||||
|
||||
// handleNewCommand starts a fresh session by resetting the tree leaf.
|
||||
func (m *AppModel) handleNewCommand() tea.Cmd {
|
||||
// Emit before-session-switch event — extensions can cancel.
|
||||
if m.emitBeforeSessionSwitch != nil {
|
||||
if cancelled, reason := m.emitBeforeSessionSwitch("new"); cancelled {
|
||||
return m.printSystemMessage(reason)
|
||||
}
|
||||
}
|
||||
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
// No tree session — just clear messages.
|
||||
@@ -1831,7 +2035,7 @@ func (m *AppModel) handleNameCommand() tea.Cmd {
|
||||
}
|
||||
// For now, prompt user to provide name via input. We print instructions
|
||||
// and the next non-command input starting with "name:" will be captured.
|
||||
// TODO: inline input dialog like pi's implementation.
|
||||
// TODO: inline input dialog.
|
||||
currentName := ts.GetSessionName()
|
||||
if currentName != "" {
|
||||
return m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
|
||||
|
||||
@@ -26,6 +26,7 @@ type SlashCommandInput struct {
|
||||
value string
|
||||
submitNext bool // Flag to submit on next update
|
||||
renderedLines int // Track how many lines were rendered
|
||||
hideHint bool // Suppress the "enter submit · ctrl+j..." hint
|
||||
}
|
||||
|
||||
// NewSlashCommandInput creates and initializes a new slash command input field with
|
||||
@@ -219,17 +220,19 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
s.renderedLines += 1 + popupLines // newline + popup
|
||||
}
|
||||
|
||||
// Add help text at bottom
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
// Add help text at bottom (unless hidden by extension).
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
s.renderedLines += 2 // newline + help text
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
s.renderedLines += 2 // newline + help text
|
||||
}
|
||||
|
||||
// Apply container padding to entire view
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
|
||||
@@ -32,7 +32,11 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
|
||||
if body := renderEditBody(toolArgs, toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "read" || toolName == "ls":
|
||||
case toolName == "ls":
|
||||
if body := renderLsBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "read":
|
||||
if body := renderReadBody(toolArgs, toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
@@ -292,6 +296,35 @@ func renderDiffBlock(before, after string, startLine int, width int) string {
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ls tool — simple list without gutter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderLsBody renders ls output as a plain list with code background and no
|
||||
// line-number gutter.
|
||||
func renderLsBody(toolResult string, width int) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
const indent = " "
|
||||
codeWidth := max(width-len(indent), 20)
|
||||
|
||||
theme := getTheme()
|
||||
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
|
||||
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
styled := codeStyle.Width(codeWidth).Render(line)
|
||||
result = append(result, indent+styled)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read tool — code block with line numbers + syntax highlighting
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -663,3 +696,177 @@ func truncateLine(s string, maxWidth int) string {
|
||||
}
|
||||
return s[:maxWidth-1] + "…"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact tool body renderers — one-line summaries for compact mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderToolBodyCompact returns a brief summary string for tool results in
|
||||
// compact display mode. Returns empty string to fall back to default.
|
||||
func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) string {
|
||||
switch {
|
||||
case toolName == "edit":
|
||||
return renderEditCompact(toolArgs, toolResult)
|
||||
case toolName == "ls":
|
||||
return renderLsCompact(toolResult)
|
||||
case toolName == "read":
|
||||
return renderReadCompact(toolResult)
|
||||
case toolName == "write":
|
||||
return renderWriteCompact(toolArgs)
|
||||
case toolName == "bash" || toolName == "run_shell_cmd" ||
|
||||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
|
||||
return renderBashCompact(toolResult, width)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderReadCompact returns a line-count summary for Read tool output.
|
||||
func renderReadCompact(toolResult string) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
// Count actual code lines (those with "N: " line-number prefix)
|
||||
codeLines := 0
|
||||
for _, line := range lines {
|
||||
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
|
||||
numPart := line[:idx]
|
||||
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
|
||||
codeLines++
|
||||
}
|
||||
}
|
||||
}
|
||||
if codeLines == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d lines", codeLines)
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderEditCompact returns a change-count summary for Edit tool output.
|
||||
func renderEditCompact(toolArgs, toolResult string) string {
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
oldText, _ := args["old_text"].(string)
|
||||
newText, _ := args["new_text"].(string)
|
||||
if oldText == "" && newText == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
oldCount := len(strings.Split(oldText, "\n"))
|
||||
newCount := len(strings.Split(newText, "\n"))
|
||||
|
||||
theme := getTheme()
|
||||
var summary string
|
||||
if oldCount == newCount {
|
||||
summary = fmt.Sprintf("%d lines modified", oldCount)
|
||||
} else {
|
||||
summary = fmt.Sprintf("-%d/+%d lines", oldCount, newCount)
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderWriteCompact returns a line-count summary for Write tool output.
|
||||
func renderWriteCompact(toolArgs string) string {
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
count := len(strings.Split(content, "\n"))
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d lines written", count)
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderLsCompact returns an entry-count summary for Ls tool output.
|
||||
func renderLsCompact(toolResult string) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
entries := strings.Split(content, "\n")
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d entries", len(entries))
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderBashCompact returns the first few lines of bash output as a compact
|
||||
// summary. Shows up to 3 meaningful output lines.
|
||||
func renderBashCompact(toolResult string, width int) string {
|
||||
result := strings.TrimSpace(toolResult)
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(result, "\n")
|
||||
|
||||
// Filter to meaningful output lines (skip STDERR: label, keep exit codes separate)
|
||||
var outputLines []string
|
||||
var exitCode string
|
||||
inStderr := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "STDERR:" {
|
||||
inStderr = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Exit code:") {
|
||||
exitCode = trimmed
|
||||
continue
|
||||
}
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
outputLines = append(outputLines, line)
|
||||
_ = inStderr // stderr lines are included in output
|
||||
}
|
||||
|
||||
if len(outputLines) == 0 {
|
||||
if exitCode != "" {
|
||||
theme := getTheme()
|
||||
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxLines = 3
|
||||
theme := getTheme()
|
||||
|
||||
display := outputLines
|
||||
if len(display) > maxLines {
|
||||
display = display[:maxLines]
|
||||
}
|
||||
|
||||
// Truncate each line to available width
|
||||
lineMax := max(width-4, 20)
|
||||
for i, line := range display {
|
||||
if len(line) > lineMax {
|
||||
display[i] = line[:lineMax-3] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
summary := strings.Join(display, "\n")
|
||||
if len(outputLines) > maxLines {
|
||||
summary += fmt.Sprintf("\n...(%d more lines)", len(outputLines)-maxLines)
|
||||
}
|
||||
if exitCode != "" {
|
||||
summary += "\n" + lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ type FlatNode struct {
|
||||
}
|
||||
|
||||
// TreeSelectorComponent is a Bubble Tea component that renders the session
|
||||
// tree as an ASCII art list with navigation and selection. It follows pi's
|
||||
// tree selector design.
|
||||
// tree as an ASCII art list with navigation and selection.
|
||||
type TreeSelectorComponent struct {
|
||||
tm *session.TreeManager
|
||||
flatNodes []FlatNode
|
||||
|
||||
+41
-3
@@ -23,8 +23,8 @@ func (m *Kit) EstimateContextTokens() int {
|
||||
}
|
||||
|
||||
// ShouldCompact reports whether the conversation is near the model's context
|
||||
// limit and should be compacted. Uses Pi's formula:
|
||||
// contextTokens > contextWindow − reserveTokens.
|
||||
// limit and should be compacted.
|
||||
// Formula: contextTokens > contextWindow − reserveTokens.
|
||||
// Returns false if the model's context limit is unknown.
|
||||
func (m *Kit) ShouldCompact() bool {
|
||||
info := m.GetModelInfo()
|
||||
@@ -43,9 +43,23 @@ func (m *Kit) ShouldCompact() bool {
|
||||
|
||||
// GetContextStats returns current context usage statistics including
|
||||
// estimated token count, context limit, usage percentage, and message count.
|
||||
//
|
||||
// When API-reported token counts are available (after at least one turn),
|
||||
// EstimatedTokens uses the real input token count from the most recent API
|
||||
// response. This is significantly more accurate than the text-based heuristic
|
||||
// because it includes system prompts, tool definitions, and other overhead
|
||||
// that the heuristic cannot account for.
|
||||
func (m *Kit) GetContextStats() ContextStats {
|
||||
messages := m.treeSession.GetFantasyMessages()
|
||||
estimated := compaction.EstimateMessageTokens(messages)
|
||||
|
||||
// Prefer the real API-reported input token count when available.
|
||||
m.lastInputTokensMu.RLock()
|
||||
estimated := m.lastInputTokens
|
||||
m.lastInputTokensMu.RUnlock()
|
||||
if estimated == 0 {
|
||||
// Fall back to heuristic before first turn completes.
|
||||
estimated = compaction.EstimateMessageTokens(messages)
|
||||
}
|
||||
|
||||
stats := ContextStats{
|
||||
EstimatedTokens: estimated,
|
||||
@@ -72,6 +86,12 @@ func (m *Kit) GetContextStats() ContextStats {
|
||||
// After compaction, the tree session is cleared and replaced with the
|
||||
// compacted messages (summary + preserved recent messages).
|
||||
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstructions string) (*CompactionResult, error) {
|
||||
return m.compactInternal(ctx, opts, customInstructions, false)
|
||||
}
|
||||
|
||||
// compactInternal is the shared compaction implementation. The isAutomatic
|
||||
// flag distinguishes auto-triggered compaction from manual /compact.
|
||||
func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
|
||||
if opts == nil {
|
||||
if m.compactionOpts != nil {
|
||||
opts = m.compactionOpts
|
||||
@@ -92,6 +112,24 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
|
||||
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
|
||||
}
|
||||
|
||||
// Run before-compact hook — extensions can cancel compaction.
|
||||
if m.beforeCompact.hasHooks() {
|
||||
stats := m.GetContextStats()
|
||||
if hookResult := m.beforeCompact.run(BeforeCompactHook{
|
||||
EstimatedTokens: stats.EstimatedTokens,
|
||||
ContextLimit: stats.ContextLimit,
|
||||
UsagePercent: stats.UsagePercent,
|
||||
MessageCount: stats.MessageCount,
|
||||
IsAutomatic: isAutomatic,
|
||||
}); hookResult != nil && hookResult.Cancel {
|
||||
reason := hookResult.Reason
|
||||
if reason == "" {
|
||||
reason = "compaction cancelled by extension"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
model := m.agent.GetModel()
|
||||
result, newMessages, err := compaction.Compact(ctx, model, messages, *opts, customInstructions)
|
||||
if err != nil {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// defaultSystemPrompt is the built-in system prompt used when no custom
|
||||
// prompt is configured. It describes the available core tools and provides
|
||||
// usage guidelines, matching the Pi SDK's default prompt style.
|
||||
// usage guidelines.
|
||||
const defaultSystemPrompt = `You are an expert coding assistant operating inside kit, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
|
||||
|
||||
Available tools:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package kit
|
||||
|
||||
import "github.com/mark3labs/kit/internal/extensions"
|
||||
import (
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// bridgeExtensions registers extension event handlers as SDK hooks and
|
||||
// subscribes to SDK observation events to forward them to the extension runner.
|
||||
@@ -97,4 +100,82 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Context filtering hook ---
|
||||
// Extension ContextPrepare → SDK ContextPrepare hook.
|
||||
if runner.HasHandlers(extensions.ContextPrepare) {
|
||||
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
|
||||
// Convert fantasy.Message slice to extension ContextMessage slice.
|
||||
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
|
||||
for i, msg := range h.Messages {
|
||||
// Extract text from content parts.
|
||||
var text string
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
text += tp.Text
|
||||
}
|
||||
}
|
||||
extMsgs[i] = extensions.ContextMessage{
|
||||
Index: i,
|
||||
Role: string(msg.Role),
|
||||
Content: text,
|
||||
}
|
||||
}
|
||||
|
||||
result, _ := runner.Emit(extensions.ContextPrepareEvent{Messages: extMsgs})
|
||||
r, ok := result.(extensions.ContextPrepareResult)
|
||||
if !ok || r.Messages == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rebuild fantasy.Message slice from extension result.
|
||||
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
|
||||
for _, cm := range r.Messages {
|
||||
if cm.Index >= 0 && cm.Index < len(h.Messages) {
|
||||
// Reuse original message (preserves tool calls, reasoning, etc.)
|
||||
rebuilt = append(rebuilt, h.Messages[cm.Index])
|
||||
} else {
|
||||
// New message injected by extension.
|
||||
role := fantasy.MessageRoleUser
|
||||
switch cm.Role {
|
||||
case "assistant":
|
||||
role = fantasy.MessageRoleAssistant
|
||||
case "system":
|
||||
role = fantasy.MessageRoleSystem
|
||||
case "tool":
|
||||
role = fantasy.MessageRoleTool
|
||||
}
|
||||
rebuilt = append(rebuilt, fantasy.Message{
|
||||
Role: role,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: cm.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &ContextPrepareResult{Messages: rebuilt}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Compaction hook ---
|
||||
// Extension BeforeCompact → SDK BeforeCompact hook.
|
||||
if runner.HasHandlers(extensions.BeforeCompact) {
|
||||
m.OnBeforeCompact(HookPriorityNormal, func(h BeforeCompactHook) *BeforeCompactResult {
|
||||
result, _ := runner.Emit(extensions.BeforeCompactEvent{
|
||||
EstimatedTokens: h.EstimatedTokens,
|
||||
ContextLimit: h.ContextLimit,
|
||||
UsagePercent: h.UsagePercent,
|
||||
MessageCount: h.MessageCount,
|
||||
IsAutomatic: h.IsAutomatic,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeCompactResult); ok && r.Cancel {
|
||||
return &BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: r.Reason,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,43 @@ type AfterTurnHook struct {
|
||||
// AfterTurnResult is a placeholder — after-turn hooks are observation-only.
|
||||
type AfterTurnResult struct{}
|
||||
|
||||
// ContextPrepareHook is the input for hooks that fire after the context window
|
||||
// is assembled from the session tree (including compaction) and before the
|
||||
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
|
||||
type ContextPrepareHook struct {
|
||||
// Messages is the current context as fantasy.Message objects.
|
||||
Messages []fantasy.Message
|
||||
}
|
||||
|
||||
// ContextPrepareResult can replace the context window.
|
||||
type ContextPrepareResult struct {
|
||||
// Messages replaces the entire context window. If nil, the original
|
||||
// messages are used.
|
||||
Messages []fantasy.Message
|
||||
}
|
||||
|
||||
// BeforeCompactHook is the input for hooks that fire before compaction runs.
|
||||
type BeforeCompactHook struct {
|
||||
// EstimatedTokens is the estimated token count of the conversation.
|
||||
EstimatedTokens int
|
||||
// ContextLimit is the model's context window size in tokens.
|
||||
ContextLimit int
|
||||
// UsagePercent is the fraction of context used (0.0–1.0).
|
||||
UsagePercent float64
|
||||
// MessageCount is the number of messages in the conversation.
|
||||
MessageCount int
|
||||
// IsAutomatic is true when compaction was triggered automatically.
|
||||
IsAutomatic bool
|
||||
}
|
||||
|
||||
// BeforeCompactResult controls whether compaction proceeds.
|
||||
type BeforeCompactResult struct {
|
||||
// Cancel, when true, prevents compaction from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation when Cancel is true.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic hook registry with priority ordering
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -181,6 +218,23 @@ func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() {
|
||||
})
|
||||
}
|
||||
|
||||
// OnContextPrepare registers a hook that fires after the context window is
|
||||
// built from the session tree and before messages are sent to the LLM. Return
|
||||
// a non-nil ContextPrepareResult with Messages to replace the entire context.
|
||||
// Hooks execute in priority order; the first non-nil result wins.
|
||||
// Returns an unregister function.
|
||||
func (m *Kit) OnContextPrepare(p HookPriority, h func(ContextPrepareHook) *ContextPrepareResult) func() {
|
||||
return m.contextPrepare.register(p, h)
|
||||
}
|
||||
|
||||
// OnBeforeCompact registers a hook that fires before context compaction runs.
|
||||
// Return a non-nil BeforeCompactResult with Cancel=true to prevent compaction.
|
||||
// Hooks execute in priority order; the first non-nil result wins.
|
||||
// Returns an unregister function.
|
||||
func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeCompactResult) func() {
|
||||
return m.beforeCompact.register(p, h)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool wrapping via hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+520
-8
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/kitsetup"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
@@ -48,6 +51,15 @@ type Kit struct {
|
||||
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
|
||||
beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult]
|
||||
afterTurn *hookRegistry[AfterTurnHook, AfterTurnResult]
|
||||
contextPrepare *hookRegistry[ContextPrepareHook, ContextPrepareResult]
|
||||
beforeCompact *hookRegistry[BeforeCompactHook, BeforeCompactResult]
|
||||
|
||||
// lastInputTokens stores the API-reported input token count from the
|
||||
// most recent turn. Used by GetContextStats() to return accurate usage
|
||||
// instead of the text-based heuristic which misses system prompts,
|
||||
// tool definitions, etc.
|
||||
lastInputTokensMu sync.RWMutex
|
||||
lastInputTokens int
|
||||
}
|
||||
|
||||
// Subscribe registers an EventListener that will be called for every lifecycle
|
||||
@@ -136,6 +148,17 @@ func (m *Kit) GetExtensionContext() extensions.Context {
|
||||
return extensions.Context{}
|
||||
}
|
||||
|
||||
// UpdateExtensionContextModel updates the Model field on the extension
|
||||
// context so subsequent event handlers see the new model. This is a
|
||||
// targeted update that avoids replacing the entire Context struct.
|
||||
func (m *Kit) UpdateExtensionContextModel(model string) {
|
||||
if m.extRunner != nil {
|
||||
ctx := m.extRunner.GetContext()
|
||||
ctx.Model = model
|
||||
m.extRunner.SetContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// EmitSessionStart fires the SessionStart event for extensions.
|
||||
// No-op if extensions are disabled or no handlers are registered.
|
||||
func (m *Kit) EmitSessionStart() {
|
||||
@@ -263,6 +286,472 @@ func (m *Kit) GetExtensionEditor() *extensions.EditorConfig {
|
||||
return m.extRunner.GetEditor()
|
||||
}
|
||||
|
||||
// SetExtensionUIVisibility stores extension-provided UI visibility overrides.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionUIVisibility(v extensions.UIVisibility) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetUIVisibility(v)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionUIVisibility returns extension-provided UI visibility overrides,
|
||||
// or nil if none have been set. Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionUIVisibility() *extensions.UIVisibility {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetUIVisibility()
|
||||
}
|
||||
|
||||
// GetSessionMessages returns the conversation messages on the current branch
|
||||
// as extension-facing SessionMessage structs, ordered root to leaf.
|
||||
func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
branch := m.treeSession.GetBranch("")
|
||||
var msgs []extensions.SessionMessage
|
||||
for _, entry := range branch {
|
||||
me, ok := entry.(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg, err := me.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Flatten content parts into a single text string.
|
||||
var content string
|
||||
for _, p := range msg.Parts {
|
||||
switch pt := p.(type) {
|
||||
case message.TextContent:
|
||||
content += pt.Text
|
||||
case message.ReasoningContent:
|
||||
content += pt.Thinking
|
||||
case message.ToolCall:
|
||||
content += fmt.Sprintf("[tool_call: %s(%s)]", pt.Name, pt.Input)
|
||||
case message.ToolResult:
|
||||
content += fmt.Sprintf("[tool_result: %s]", pt.Content)
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, extensions.SessionMessage{
|
||||
ID: me.ID,
|
||||
ParentID: me.ParentID,
|
||||
Role: string(msg.Role),
|
||||
Content: content,
|
||||
Model: msg.Model,
|
||||
Provider: msg.Provider,
|
||||
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// GetSessionFilePath returns the JSONL file path of the current session.
|
||||
func (m *Kit) GetSessionFilePath() string {
|
||||
if m.treeSession == nil {
|
||||
return ""
|
||||
}
|
||||
return m.treeSession.GetFilePath()
|
||||
}
|
||||
|
||||
// AppendExtensionEntry persists custom extension data in the session tree.
|
||||
func (m *Kit) AppendExtensionEntry(extType, data string) (string, error) {
|
||||
if m.treeSession == nil {
|
||||
return "", fmt.Errorf("no session available")
|
||||
}
|
||||
return m.treeSession.AppendExtensionData(extType, data)
|
||||
}
|
||||
|
||||
// GetExtensionEntries retrieves persisted extension data entries for a type.
|
||||
func (m *Kit) GetExtensionEntries(extType string) []extensions.ExtensionEntry {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
entries := m.treeSession.GetExtensionData(extType)
|
||||
result := make([]extensions.ExtensionEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
result = append(result, extensions.ExtensionEntry{
|
||||
ID: e.ID,
|
||||
EntryType: e.ExtType,
|
||||
Data: e.Data,
|
||||
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionStatus places or updates a keyed status bar entry.
|
||||
func (m *Kit) SetExtensionStatus(entry extensions.StatusBarEntry) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetStatusEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionStatus removes a keyed status bar entry.
|
||||
func (m *Kit) RemoveExtensionStatus(key string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveStatusEntry(key)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionStatusEntries returns all extension status bar entries sorted by priority.
|
||||
func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetStatusEntries()
|
||||
}
|
||||
|
||||
// GetExtensionShortcuts returns a map of key bindings to handler functions
|
||||
// from all loaded extensions. Returns nil if no shortcuts are registered or
|
||||
// extensions are disabled. Handlers are closures that capture the runner's
|
||||
// current context, so they can call Print/SetStatus/etc.
|
||||
func (m *Kit) GetExtensionShortcuts() map[string]func() {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
entries := m.extRunner.GetShortcuts()
|
||||
if entries == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]func(), len(entries))
|
||||
for key, entry := range entries {
|
||||
h := entry.Handler
|
||||
r := m.extRunner
|
||||
result[key] = func() {
|
||||
ctx := r.GetContext()
|
||||
h(ctx)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetExtensionToolInfos returns information about all tools available to the
|
||||
// agent, including enabled/disabled status from SetActiveTools. Each tool is
|
||||
// categorized by source: "core", "mcp", or "extension".
|
||||
func (m *Kit) GetExtensionToolInfos() []extensions.ToolInfo {
|
||||
agentTools := m.agent.GetTools()
|
||||
coreCount := m.agent.GetCoreToolCount()
|
||||
mcpCount := m.agent.GetMCPToolCount()
|
||||
|
||||
result := make([]extensions.ToolInfo, 0, len(agentTools))
|
||||
for i, t := range agentTools {
|
||||
info := t.Info()
|
||||
source := "core"
|
||||
if i >= coreCount && i < coreCount+mcpCount {
|
||||
source = "mcp"
|
||||
} else if i >= coreCount+mcpCount {
|
||||
source = "extension"
|
||||
}
|
||||
enabled := true
|
||||
if m.extRunner != nil && m.extRunner.IsToolDisabled(info.Name) {
|
||||
enabled = false
|
||||
}
|
||||
result = append(result, extensions.ToolInfo{
|
||||
Name: info.Name,
|
||||
Description: info.Description,
|
||||
Source: source,
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionActiveTools restricts the tool set to the named tools. All
|
||||
// other tools are blocked from execution. Pass nil to re-enable all tools.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionActiveTools(names []string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetActiveTools(names)
|
||||
}
|
||||
}
|
||||
|
||||
// SetModel changes the active model at runtime. The existing tools, system
|
||||
// prompt, and session are preserved. The model string should be in
|
||||
// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
|
||||
// Returns an error if the model string is invalid or the provider cannot
|
||||
// be created.
|
||||
func (m *Kit) SetModel(ctx context.Context, modelString string) error {
|
||||
// Validate the model string first.
|
||||
if _, _, err := ParseModelString(modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a provider config from current settings, overriding the model.
|
||||
config := &models.ProviderConfig{
|
||||
ModelString: modelString,
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
}
|
||||
temperature := float32(viper.GetFloat64("temperature"))
|
||||
config.Temperature = &temperature
|
||||
topP := float32(viper.GetFloat64("top-p"))
|
||||
config.TopP = &topP
|
||||
topK := int32(viper.GetInt("top-k"))
|
||||
config.TopK = &topK
|
||||
|
||||
if err := m.agent.SetModel(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.modelString = modelString
|
||||
|
||||
// Update extension context's Model field.
|
||||
if m.extRunner != nil {
|
||||
extCtx := m.extRunner.GetContext()
|
||||
extCtx.Model = modelString
|
||||
m.extRunner.SetContext(extCtx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAvailableModels returns a list of known models from the registry. Each
|
||||
// entry includes provider, model ID, context limit, and whether the model
|
||||
// supports reasoning. This is an advisory list — models not in the registry
|
||||
// can still be used by specifying their provider/model string.
|
||||
func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
|
||||
registry := models.GetGlobalRegistry()
|
||||
var result []extensions.ModelInfoEntry
|
||||
for _, providerID := range registry.GetFantasyProviders() {
|
||||
modelsMap, err := registry.GetModelsForProvider(providerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for modelID, info := range modelsMap {
|
||||
result = append(result, extensions.ModelInfoEntry{
|
||||
Provider: providerID,
|
||||
ModelID: modelID,
|
||||
Name: info.Name,
|
||||
ContextLimit: info.Limit.Context,
|
||||
OutputLimit: info.Limit.Output,
|
||||
Reasoning: info.Reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetExtensionOption resolves a named extension option value.
|
||||
func (m *Kit) GetExtensionOption(name string) string {
|
||||
if m.extRunner == nil {
|
||||
return ""
|
||||
}
|
||||
return m.extRunner.GetOption(name)
|
||||
}
|
||||
|
||||
// SetExtensionOption stores a runtime override for a named extension option.
|
||||
func (m *Kit) SetExtensionOption(name, value string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetOption(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// EmitModelChange fires the ModelChange event for extensions.
|
||||
// No-op if extensions are disabled or no handlers are registered.
|
||||
func (m *Kit) EmitModelChange(newModel, previousModel, source string) {
|
||||
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.ModelChange) {
|
||||
_, _ = m.extRunner.Emit(extensions.ModelChangeEvent{
|
||||
NewModel: newModel,
|
||||
PreviousModel: previousModel,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// EmitExtensionCustomEvent dispatches a named event to all extension handlers.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) EmitExtensionCustomEvent(name, data string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.EmitCustomEvent(name, data)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionMessageRenderer returns the named message renderer, or nil
|
||||
// if no extension registered a renderer with that name.
|
||||
func (m *Kit) GetExtensionMessageRenderer(name string) *extensions.MessageRendererConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetMessageRenderer(name)
|
||||
}
|
||||
|
||||
// ReloadExtensions hot-reloads all extensions from disk. Event handlers,
|
||||
// commands, renderers, and shortcuts update immediately. Extension-defined
|
||||
// tools are NOT updated (they are baked into the agent at creation time).
|
||||
func (m *Kit) ReloadExtensions() error {
|
||||
if m.extRunner == nil {
|
||||
return fmt.Errorf("no extensions loaded")
|
||||
}
|
||||
|
||||
// Emit shutdown to old extensions.
|
||||
if m.extRunner.HasHandlers(extensions.SessionShutdown) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionShutdownEvent{})
|
||||
}
|
||||
|
||||
// Re-load from disk.
|
||||
extraPaths := viper.GetStringSlice("extension")
|
||||
loaded, err := extensions.LoadExtensions(extraPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reloading extensions: %w", err)
|
||||
}
|
||||
|
||||
// Swap extensions on the runner (clears dynamic state).
|
||||
m.extRunner.Reload(loaded)
|
||||
|
||||
// Re-set context and emit SessionStart.
|
||||
ctx := m.extRunner.GetContext()
|
||||
m.extRunner.SetContext(ctx)
|
||||
if m.extRunner.HasHandlers(extensions.SessionStart) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{SessionID: ctx.SessionID})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCompletion makes a standalone LLM completion call for extensions.
|
||||
// When req.Model is empty the current agent model is reused (no provider
|
||||
// creation overhead). When req.Model is set a temporary provider is created,
|
||||
// used, and closed.
|
||||
func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
var (
|
||||
llmModel fantasy.LanguageModel
|
||||
closer func()
|
||||
usedModel string
|
||||
)
|
||||
|
||||
if req.Model == "" {
|
||||
// Reuse the active agent's model.
|
||||
llmModel = m.agent.GetModel()
|
||||
usedModel = m.modelString
|
||||
closer = func() {} // nothing to clean up
|
||||
} else {
|
||||
// Create a temporary provider for the requested model.
|
||||
config := &models.ProviderConfig{
|
||||
ModelString: req.Model,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
config.MaxTokens = req.MaxTokens
|
||||
}
|
||||
providerResult, err := models.CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("create provider for %q: %w", req.Model, err)
|
||||
}
|
||||
llmModel = providerResult.Model
|
||||
usedModel = req.Model
|
||||
closer = func() {
|
||||
if providerResult.Closer != nil {
|
||||
_ = providerResult.Closer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
defer closer()
|
||||
|
||||
// Build fantasy agent options (no tools — just a simple completion).
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if req.System != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(req.System))
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(req.MaxTokens)))
|
||||
}
|
||||
|
||||
completionAgent := fantasy.NewAgent(llmModel, agentOpts...)
|
||||
|
||||
// Convert extension SessionMessage history to fantasy.Message slice.
|
||||
var messages []fantasy.Message
|
||||
for _, sm := range req.Messages {
|
||||
messages = append(messages, fantasy.Message{
|
||||
Role: fantasy.MessageRole(sm.Role),
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: sm.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Streaming path.
|
||||
if req.OnChunk != nil {
|
||||
result, err := completionAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: req.Prompt,
|
||||
Messages: messages,
|
||||
OnTextDelta: func(_, text string) error {
|
||||
req.OnChunk(text)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("streaming completion: %w", err)
|
||||
}
|
||||
return extensions.CompleteResponse{
|
||||
Text: result.Response.Content.Text(),
|
||||
InputTokens: int(result.Response.Usage.InputTokens),
|
||||
OutputTokens: int(result.Response.Usage.OutputTokens),
|
||||
Model: usedModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Non-streaming path.
|
||||
result, err := completionAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: req.Prompt,
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("completion: %w", err)
|
||||
}
|
||||
return extensions.CompleteResponse{
|
||||
Text: result.Response.Content.Text(),
|
||||
InputTokens: int(result.Response.Usage.InputTokens),
|
||||
OutputTokens: int(result.Response.Usage.OutputTokens),
|
||||
Model: usedModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EmitBeforeFork emits a BeforeFork event to extensions and returns
|
||||
// whether the fork was cancelled and the reason. No-op if extensions are
|
||||
// disabled (returns false, "").
|
||||
func (m *Kit) EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string) {
|
||||
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeFork) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := m.extRunner.Emit(extensions.BeforeForkEvent{
|
||||
TargetID: targetID,
|
||||
IsUserMessage: isUserMsg,
|
||||
UserText: userText,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeForkResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
if reason == "" {
|
||||
reason = "Fork cancelled by extension."
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// EmitBeforeSessionSwitch emits a BeforeSessionSwitch event to extensions
|
||||
// and returns whether the switch was cancelled and the reason. No-op if
|
||||
// extensions are disabled (returns false, "").
|
||||
func (m *Kit) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
|
||||
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := m.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
|
||||
Reason: switchReason,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
if reason == "" {
|
||||
reason = "Session switch cancelled by extension."
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// HasExtensions returns true if the extension runner is configured and active.
|
||||
func (m *Kit) HasExtensions() bool {
|
||||
return m.extRunner != nil
|
||||
@@ -401,8 +890,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
}
|
||||
|
||||
// Always compose the system prompt with runtime context: base prompt +
|
||||
// AGENTS.md context + skills metadata + date/cwd. This matches Pi's
|
||||
// buildSystemPrompt() convention.
|
||||
// AGENTS.md context + skills metadata + date/cwd.
|
||||
{
|
||||
basePrompt := viper.GetString("system-prompt")
|
||||
pb := skills.NewPromptBuilder(basePrompt)
|
||||
@@ -445,6 +933,8 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
afterToolResult := newHookRegistry[AfterToolResultHook, AfterToolResultResult]()
|
||||
beforeTurn := newHookRegistry[BeforeTurnHook, BeforeTurnResult]()
|
||||
afterTurn := newHookRegistry[AfterTurnHook, AfterTurnResult]()
|
||||
contextPrepare := newHookRegistry[ContextPrepareHook, ContextPrepareResult]()
|
||||
beforeCompact := newHookRegistry[BeforeCompactHook, BeforeCompactResult]()
|
||||
|
||||
// Build agent setup options, pulling CLI-specific fields when available.
|
||||
setupOpts := kitsetup.AgentSetupOptions{
|
||||
@@ -488,6 +978,8 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
afterToolResult: afterToolResult,
|
||||
beforeTurn: beforeTurn,
|
||||
afterTurn: afterTurn,
|
||||
contextPrepare: contextPrepare,
|
||||
beforeCompact: beforeCompact,
|
||||
}
|
||||
|
||||
// Bridge extension events to SDK hooks.
|
||||
@@ -535,7 +1027,7 @@ func loadContextFiles(cwd string) []*ContextFile {
|
||||
// so, re-reads the skill file, strips its YAML frontmatter, wraps the body in
|
||||
// a <skill> block with baseDir metadata, and appends any trailing user args.
|
||||
// Returns the original text unchanged when the prefix is absent or the skill is
|
||||
// not found. This matches Pi's _expandSkillCommand() convention.
|
||||
// not found.
|
||||
func (m *Kit) expandSkillCommand(prompt string) string {
|
||||
if !strings.HasPrefix(prompt, "/skill:") {
|
||||
return prompt
|
||||
@@ -763,11 +1255,19 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
// Auto-compact if enabled and conversation is near the context limit.
|
||||
if m.autoCompact && m.ShouldCompact() {
|
||||
_, _ = m.Compact(ctx, m.compactionOpts, "") // best-effort
|
||||
_, _ = m.compactInternal(ctx, m.compactionOpts, "", true) // best-effort, automatic
|
||||
}
|
||||
|
||||
// Build context from the tree so only the current branch is sent.
|
||||
messages := m.treeSession.GetFantasyMessages()
|
||||
|
||||
// Run ContextPrepare hooks — extensions can filter, reorder, or inject messages.
|
||||
if m.contextPrepare.hasHooks() {
|
||||
if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil {
|
||||
messages = hookResult.Messages
|
||||
}
|
||||
}
|
||||
|
||||
sentCount := len(messages)
|
||||
|
||||
m.events.emit(TurnStartEvent{Prompt: promptLabel})
|
||||
@@ -785,16 +1285,28 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
responseText := result.FinalResponse.Content.Text()
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
|
||||
// Persist new messages (tool calls, tool results, assistant response).
|
||||
// Persist new messages (tool calls, tool results, assistant response)
|
||||
// BEFORE emitting events so that extension handlers calling
|
||||
// GetContextStats() see up-to-date token counts.
|
||||
if len(result.ConversationMessages) > sentCount {
|
||||
for _, msg := range result.ConversationMessages[sentCount:] {
|
||||
_, _ = m.treeSession.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Store the API-reported token count so GetContextStats() matches the
|
||||
// built-in status bar (which uses input + output tokens). The
|
||||
// text-based heuristic misses system prompts, tool definitions, etc.
|
||||
if result.FinalResponse != nil {
|
||||
u := result.FinalResponse.Usage
|
||||
m.lastInputTokensMu.Lock()
|
||||
m.lastInputTokens = int(u.InputTokens) + int(u.OutputTokens)
|
||||
m.lastInputTokensMu.Unlock()
|
||||
}
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
|
||||
// Run AfterTurn hooks.
|
||||
if m.afterTurn.hasHooks() {
|
||||
m.afterTurn.run(AfterTurnHook{Response: responseText})
|
||||
|
||||
Reference in New Issue
Block a user