Compare commits

...

12 Commits

Author SHA1 Message Date
Ed Zynda aede76d807 feat: add TUI suspend, custom message rendering, and extension hot-reload
- ctx.SuspendTUI(callback): releases terminal for interactive subprocesses
  (vim, shell, htop), automatically restores TUI when callback returns.
  Uses BubbleTea v2 ReleaseTerminal/RestoreTerminal.

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

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

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

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

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

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

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

- Gap 1: Session Management API (GetMessages, GetSessionPath) — read-only
  access to conversation history from extensions
- Gap 2: Session Persistence (AppendEntry, GetEntries) — custom extension
  data survives across session restarts via new ExtensionDataEntry type
- Gap 10: SetEditorText — extensions can pre-fill the input editor
- Gap M3: Keyed Status Bar (SetStatus, RemoveStatus) — multiple extensions
  can place independent entries in the TUI status bar, ordered by priority
2026-03-02 01:33:56 +03:00
Ed Zynda dc59cfc81e feat: add --json output mode for --prompt and update subagent extensions
Add a --json flag that outputs structured JSON (response, model, usage,
messages with typed parts) when used with --prompt. Update kit-kit and
subagent-widget extensions to use --json for cleaner subprocess output
parsing instead of raw text heuristics.
2026-03-01 21:16:34 +03:00
Ed Zynda 8407d924b9 feat: add UIVisibility, GetContextStats APIs and compact tool renderers
- Add ctx.SetUIVisibility() to toggle built-in TUI chrome (startup
  message, status bar, separator, input hint) from extensions
- Add ctx.GetContextStats() returning accurate API-reported token counts
  instead of text-based heuristic; fix event ordering so extension
  handlers see up-to-date conversation state
- Add compact tool body renderers for compact mode: Read/Edit/Write/Ls
  show one-line summaries, Bash shows first 3 lines instead of full
  20-line syntax-highlighted output
- Add minimal.go example extension using UIVisibility + GetContextStats
2026-03-01 15:24:48 +03:00
Ed Zynda 91474af503 fix: remove line-number gutter from ls tool output
Ls output is a plain file list with no line numbers, so the empty
gutter column was wasted space. Give ls its own renderer that shows
a clean list with just the code background.
2026-03-01 13:41:35 +03:00
Ed Zynda e252791b3a ci: move discord notification after both goreleaser and npm publish 2026-03-01 02:15:37 +03:00
53 changed files with 5243 additions and 221 deletions
+33 -29
View File
@@ -39,12 +39,42 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
npm-publish:
runs-on: ubuntu-latest
needs: goreleaser
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Set version from tag
working-directory: npm
run: |
TAG=${{ inputs.tag || github.ref_name }}
VERSION=${TAG#v}
echo "Setting npm version to $VERSION"
npm version $VERSION --no-git-tag-version
- name: Publish to npm
working-directory: npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
notify:
runs-on: ubuntu-latest
needs: [goreleaser, npm-publish]
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') && (needs.npm-publish.result == 'success') }}
steps:
- name: Send Discord Notification
if: success()
env:
DISCORD_WEBHOOK: ${{ secrets.RELEASES_WEBHOOK }}
TAG_NAME: ${{ github.ref_name }}
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}
TAG_NAME: ${{ inputs.tag || github.ref_name }}
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
run: |
curl -H "Content-Type: application/json" \
-X POST \
@@ -73,29 +103,3 @@ jobs:
}]
}" \
$DISCORD_WEBHOOK
npm-publish:
runs-on: ubuntu-latest
needs: goreleaser
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Set version from tag
working-directory: npm
run: |
TAG=${{ inputs.tag || github.ref_name }}
VERSION=${TAG#v}
echo "Setting npm version to $VERSION"
npm version $VERSION --no-git-tag-version
- name: Publish to npm
working-directory: npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+599
View File
@@ -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
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/ui"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
@@ -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.
+71
View File
@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"os/exec"
"strings"
"kit/ext"
)
// Init automatically commits staged changes when the session shuts down,
// using the last assistant message as the commit message.
//
// Only commits if:
// - There are staged changes (git diff --cached is non-empty)
// - There is at least one assistant message to use as commit message
//
// The commit message is derived from the last assistant response, trimmed
// to the first paragraph (max 72 chars for the subject line).
//
// Usage: kit -e examples/extensions/auto-commit.go
func Init(api ext.API) {
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
// Check for staged changes.
diff, err := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput()
_ = diff
if err == nil {
return // exit code 0 means no staged changes
}
// Get the last assistant message.
msgs := ctx.GetMessages()
var lastAssistant string
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "assistant" {
lastAssistant = msgs[i].Content
break
}
}
if lastAssistant == "" {
return
}
// Build commit message: first paragraph, subject line max 72 chars.
subject := firstParagraph(lastAssistant)
if len(subject) > 72 {
subject = subject[:69] + "..."
}
// Commit.
cmd := exec.Command("git", "commit", "-m", subject)
output, err := cmd.CombinedOutput()
if err != nil {
ctx.PrintError("Auto-commit failed: " + string(output))
return
}
ctx.PrintInfo("Auto-committed: " + subject)
})
}
// firstParagraph returns the first non-empty paragraph of text.
func firstParagraph(text string) string {
text = strings.TrimSpace(text)
// Split on double newlines (paragraph breaks).
parts := strings.SplitN(text, "\n\n", 2)
line := strings.TrimSpace(parts[0])
// Collapse to single line.
line = strings.ReplaceAll(line, "\n", " ")
return line
}
+101
View File
@@ -0,0 +1,101 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
"kit/ext"
)
// Init adds bookmark commands for marking and recalling important points in
// a conversation. Bookmarks are persisted in the session tree and survive
// restarts.
//
// Commands:
//
// /bookmark <label> — bookmark the current point with a label
// /bookmarks — list all bookmarks in this session
//
// Usage: kit -e examples/extensions/bookmark.go
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "bookmark",
Description: "Bookmark the current point in the conversation",
Execute: func(args string, ctx ext.Context) (string, error) {
label := strings.TrimSpace(args)
if label == "" {
label = time.Now().Format("15:04:05")
}
// Count existing messages to record position.
msgs := ctx.GetMessages()
data, _ := json.Marshal(map[string]any{
"label": label,
"messages": len(msgs),
})
_, err := ctx.AppendEntry("bookmark", string(data))
if err != nil {
ctx.PrintError("Failed to save bookmark: " + err.Error())
return "", nil
}
ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs)))
return "", nil
},
Complete: func(prefix string, ctx ext.Context) []string {
// Suggest existing bookmark labels so the user can quickly
// re-bookmark at the same label.
entries := ctx.GetEntries("bookmark")
var labels []string
seen := map[string]bool{}
for _, e := range entries {
var data map[string]any
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
continue
}
label, _ := data["label"].(string)
if label == "" || seen[label] {
continue
}
if prefix == "" || strings.HasPrefix(strings.ToLower(label), strings.ToLower(prefix)) {
labels = append(labels, label)
seen[label] = true
}
}
return labels
},
})
api.RegisterCommand(ext.CommandDef{
Name: "bookmarks",
Description: "List all bookmarks in this session",
Execute: func(args string, ctx ext.Context) (string, error) {
entries := ctx.GetEntries("bookmark")
if len(entries) == 0 {
ctx.PrintInfo("No bookmarks yet. Use /bookmark <label> to create one.")
return "", nil
}
var lines []string
for i, e := range entries {
var data map[string]any
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
continue
}
label, _ := data["label"].(string)
msgCount, _ := data["messages"].(float64)
lines = append(lines, fmt.Sprintf(" %d. %s (msg %d, %s)",
i+1, label, int(msgCount), e.Timestamp[:19]))
}
ctx.PrintInfo("Bookmarks:\n" + strings.Join(lines, "\n"))
return "", nil
},
})
}
+76
View File
@@ -0,0 +1,76 @@
//go:build ignore
// branded-output.go — Custom Message Rendering example extension for Kit.
//
// Demonstrates api.RegisterMessageRenderer() and ctx.RenderMessage() which
// let extensions define reusable visual styles for output. Each renderer has
// a name and a render function that receives content and terminal width.
//
// This extension registers three renderers:
// "success" — green-bordered block for success messages
// "warning" — yellow-bordered block for warnings
// "metric" — compact key=value display for metrics
//
// Commands:
// /demo-render — shows all three renderers in action
package main
import (
"fmt"
"strings"
"time"
ext "kit/ext"
)
func Init(api ext.API) {
// Register a "success" renderer — green-accented block.
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
maxW := width - 6
if maxW < 20 {
maxW = 20
}
bar := strings.Repeat("─", maxW)
return fmt.Sprintf(" \033[32m┌%s┐\033[0m\n \033[32m│\033[0m \033[1;32m%s\033[0m\n \033[32m└%s┘\033[0m",
bar, content, bar)
},
})
// Register a "warning" renderer — yellow-accented block.
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "warning",
Render: func(content string, width int) string {
maxW := width - 6
if maxW < 20 {
maxW = 20
}
bar := strings.Repeat("─", maxW)
return fmt.Sprintf(" \033[33m┌%s┐\033[0m\n \033[33m│\033[0m \033[1;33m%s\033[0m\n \033[33m└%s┘\033[0m",
bar, content, bar)
},
})
// Register a "metric" renderer — compact label: value format.
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "metric",
Render: func(content string, width int) string {
return fmt.Sprintf(" \033[36m▸\033[0m %s", content)
},
})
api.RegisterCommand(ext.CommandDef{
Name: "demo-render",
Description: "Demonstrate custom message renderers",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.RenderMessage("success", "All 42 tests passed in 3.2s")
ctx.RenderMessage("warning", "3 deprecation warnings detected")
ctx.RenderMessage("metric", fmt.Sprintf("build_time=%.1fs tests=42 coverage=87%% timestamp=%s",
3.2, time.Now().Format("15:04:05")))
return "Rendered three message styles.", nil
},
})
}
+56
View File
@@ -0,0 +1,56 @@
//go:build ignore
package main
import (
"fmt"
"kit/ext"
)
// Init registers a before-compact hook that notifies the user when
// compaction is about to happen and optionally blocks automatic compaction.
//
// When automatic compaction is triggered (via --auto-compact), the extension
// asks for user confirmation. Manual /compact commands are always allowed.
//
// This demonstrates the OnBeforeCompact event which allows extensions to
// inspect context usage stats and gate the compaction process.
//
// Usage: kit -e examples/extensions/compact-notify.go --auto-compact
func Init(api ext.API) {
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
pct := int(e.UsagePercent * 100)
summary := fmt.Sprintf("Context: %dk/%dk tokens (%d%%), %d messages",
e.EstimatedTokens/1000, e.ContextLimit/1000, pct, e.MessageCount)
if e.IsAutomatic {
// Auto-compaction: ask user first.
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "Auto-compaction triggered.\n" + summary,
BorderColor: "#f9e2af",
Subtitle: "compact-notify",
})
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Allow automatic compaction?",
DefaultValue: true,
})
if result.Cancelled || !result.Value {
return &ext.BeforeCompactResult{
Cancel: true,
Reason: "Auto-compaction skipped by user.",
}
}
} else {
// Manual /compact: just notify.
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "Compacting conversation...\n" + summary,
BorderColor: "#89b4fa",
Subtitle: "compact-notify",
})
}
return nil // allow compaction
})
}
@@ -0,0 +1,72 @@
//go:build ignore
package main
import (
"os/exec"
"strings"
"kit/ext"
)
// Init registers before-hooks for destructive session operations:
// - Forks: Asks for confirmation before branching to a different tree node.
// - New sessions: Checks for uncommitted git changes and warns before
// starting a new branch if the working tree is dirty.
//
// This demonstrates the OnBeforeFork and OnBeforeSessionSwitch events
// which allow extensions to cancel session lifecycle operations.
//
// Usage: kit -e examples/extensions/confirm-destructive.go --continue
func Init(api ext.API) {
// Gate /new command: warn if there are uncommitted git changes.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
if !isGitDirty() {
return nil // clean repo, allow switch
}
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Working tree has uncommitted changes. Start new session anyway?",
})
if result.Cancelled || !result.Value {
return &ext.BeforeSessionSwitchResult{
Cancel: true,
Reason: "Session switch cancelled: uncommitted git changes.",
}
}
return nil // user approved
})
// Gate fork: ask for confirmation before branching.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
msg := "Branch to this point in the conversation?"
if e.IsUserMessage && e.UserText != "" {
// Show a preview of the user message being forked to.
preview := e.UserText
if len(preview) > 80 {
preview = preview[:77] + "..."
}
msg = "Fork and edit: " + preview + "\n\nContinue?"
}
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: msg,
})
if result.Cancelled || !result.Value {
return &ext.BeforeForkResult{
Cancel: true,
Reason: "Fork cancelled by user.",
}
}
return nil // user approved
})
}
// isGitDirty returns true if the git working tree has uncommitted changes.
func isGitDirty() bool {
out, err := exec.Command("git", "status", "--porcelain").Output()
if err != nil {
return false // not a git repo or git not available
}
return len(strings.TrimSpace(string(out))) > 0
}
+89
View File
@@ -0,0 +1,89 @@
//go:build ignore
// context-inject.go — Injects context from a local file into every LLM turn.
//
// Reads a context file (default: .kit/context.md) and prepends it as a system
// message to every LLM context window via OnContextPrepare. This is useful for
// injecting project-specific knowledge, coding standards, or RAG results that
// should always be visible to the model — without cluttering the session history.
//
// The injected message does NOT persist in the session tree (it's ephemeral,
// added at query time only). This means:
// - Changing the context file immediately affects future turns
// - No session bloat from repeated context injection
// - The model always sees the latest version of the context
//
// Configuration:
//
// KIT_OPT_CONTEXT_FILE — path to context file (default: .kit/context.md)
//
// Usage:
//
// kit -e examples/extensions/context-inject.go
// echo "Always use error wrapping with fmt.Errorf" > .kit/context.md
package main
import (
"fmt"
"os"
"strings"
ext "kit/ext"
)
func Init(api ext.API) {
api.RegisterOption(ext.OptionDef{
Name: "context-file",
Description: "Path to the context file to inject into every turn",
Default: ".kit/context.md",
})
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
path := ctx.GetOption("context-file")
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
// File doesn't exist or can't be read — skip silently.
return nil
}
content := strings.TrimSpace(string(data))
if content == "" {
return nil
}
// Prepend a system message with the context file contents.
injected := ext.ContextMessage{
Index: -1,
Role: "system",
Content: fmt.Sprintf("[Project Context from %s]\n\n%s", path, content),
}
msgs := make([]ext.ContextMessage, 0, len(e.Messages)+1)
msgs = append(msgs, injected)
msgs = append(msgs, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
api.RegisterCommand(ext.CommandDef{
Name: "context",
Description: "Show or edit the injected context file path",
Execute: func(args string, ctx ext.Context) (string, error) {
path := ctx.GetOption("context-file")
data, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("Context file: %s (not found or unreadable)", path), nil
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
preview := strings.Join(lines, "\n")
if len(lines) > 10 {
preview = strings.Join(lines[:10], "\n") + "\n..."
}
return fmt.Sprintf("Context file: %s (%d lines)\n\n%s", path, len(lines), preview), nil
},
})
}
+56
View File
@@ -0,0 +1,56 @@
//go:build ignore
// dev-reload.go — Extension Hot-Reload example extension for Kit.
//
// Demonstrates ctx.ReloadExtensions() which hot-reloads all extensions
// from disk without restarting Kit. This is invaluable during extension
// development: edit your extension source, then type /reload to pick up
// changes immediately.
//
// Event handlers, slash commands, tool renderers, message renderers, and
// keyboard shortcuts update immediately. Extension-defined tools are NOT
// updated (they are baked into the agent at creation time and require a
// restart).
//
// Commands:
// /reload — hot-reload all extensions from disk
package main
import (
"fmt"
"time"
ext "kit/ext"
)
var loadedAt string
func Init(api ext.API) {
loadedAt = time.Now().Format("15:04:05")
api.RegisterCommand(ext.CommandDef{
Name: "reload",
Description: "Hot-reload all extensions from disk",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.Print("Reloading extensions...")
err := ctx.ReloadExtensions()
if err != nil {
return "", fmt.Errorf("reload failed: %w", err)
}
return "Extensions reloaded successfully.", nil
},
})
api.RegisterCommand(ext.CommandDef{
Name: "load-time",
Description: "Show when this extension was loaded",
Execute: func(args string, ctx ext.Context) (string, error) {
return fmt.Sprintf("This extension was loaded at %s", loadedAt), nil
},
})
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
ctx.Print(fmt.Sprintf("[dev-reload] Extension loaded at %s", loadedAt))
})
}
+52
View File
@@ -0,0 +1,52 @@
//go:build ignore
package main
import (
"os/exec"
"regexp"
"strings"
"kit/ext"
)
// Init expands inline bash expressions in user prompts before they reach the
// LLM. Text like !{git branch --show-current} is replaced with the command's
// stdout.
//
// Examples:
//
// "Fix the tests on !{git branch --show-current}"
// → "Fix the tests on main"
//
// "The current directory is !{pwd}"
// → "The current directory is /home/user/project"
//
// Usage: kit -e examples/extensions/inline-bash.go
func Init(api ext.API) {
// Matches !{...} with non-greedy content.
re := regexp.MustCompile(`!\{([^}]+)\}`)
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
if !re.MatchString(ev.Text) {
return nil
}
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
// Extract the command between !{ and }.
cmd := re.FindStringSubmatch(match)[1]
cmd = strings.TrimSpace(cmd)
out, err := exec.Command("bash", "-c", cmd).Output()
if err != nil {
return match // keep original on error
}
return strings.TrimSpace(string(out))
})
return &ext.InputResult{
Action: "transform",
Text: expanded,
}
})
}
+123
View File
@@ -0,0 +1,123 @@
//go:build ignore
// interactive-shell.go — TUI Suspend example extension for Kit.
//
// Demonstrates ctx.SuspendTUI() which temporarily releases the terminal
// from the TUI so interactive subprocesses can run with full terminal
// control. The TUI is automatically restored when the callback returns.
//
// Commands:
// /edit <file> — opens $EDITOR (or vi) to edit a file
// /shell — drops into an interactive shell session
// /run <cmd> — runs a command with full terminal I/O (no TUI capture)
package main
import (
"fmt"
"os"
"os/exec"
"strings"
ext "kit/ext"
)
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "edit",
Description: "Open $EDITOR to edit a file (TUI suspends)",
Execute: func(args string, ctx ext.Context) (string, error) {
file := strings.TrimSpace(args)
if file == "" {
return "", fmt.Errorf("usage: /edit <file>")
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
ctx.Print(fmt.Sprintf("Opening %s in %s...", file, editor))
err := ctx.SuspendTUI(func() {
cmd := exec.Command(editor, file)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
if err != nil {
return "", fmt.Errorf("editor session failed: %w", err)
}
return fmt.Sprintf("Finished editing %s", file), nil
},
Complete: func(prefix string, ctx ext.Context) []string {
// Suggest files in the current directory.
entries, err := os.ReadDir(".")
if err != nil {
return nil
}
var results []string
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, prefix) {
results = append(results, name)
}
}
return results
},
})
api.RegisterCommand(ext.CommandDef{
Name: "shell",
Description: "Drop into an interactive shell (TUI suspends)",
Execute: func(args string, ctx ext.Context) (string, error) {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
ctx.Print(fmt.Sprintf("Starting %s... (type 'exit' to return to Kit)", shell))
err := ctx.SuspendTUI(func() {
cmd := exec.Command(shell)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
if err != nil {
return "", fmt.Errorf("shell session failed: %w", err)
}
return "Shell session ended, TUI restored.", nil
},
})
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command with full terminal I/O (TUI suspends)",
Execute: func(args string, ctx ext.Context) (string, error) {
cmdStr := strings.TrimSpace(args)
if cmdStr == "" {
return "", fmt.Errorf("usage: /run <command>")
}
ctx.Print(fmt.Sprintf("Running: %s", cmdStr))
err := ctx.SuspendTUI(func() {
cmd := exec.Command("sh", "-c", cmdStr)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
if err != nil {
return "", fmt.Errorf("command failed: %w", err)
}
return "Command finished, TUI restored.", nil
},
})
}
+34 -9
View File
@@ -19,6 +19,7 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
@@ -31,6 +32,16 @@ import (
"kit/ext"
)
// kitJSONOutput matches the JSON envelope produced by `kit --json`.
type kitJSONOutput struct {
Response string `json:"response"`
Model string `json:"model"`
Usage *struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage,omitempty"`
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -474,27 +485,33 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
}
tmpFile.Close()
// Build subprocess arguments. Don't pass --model; the subprocess
// inherits the same config/env and will use the same default.
// Build subprocess arguments. Use --json for structured output parsing.
// Don't pass --model; the subprocess inherits the same config/env default.
args := []string{
"--prompt", question,
"--quiet",
"--json",
"--no-session",
"--no-extensions",
"--system-prompt", tmpFile.Name(),
}
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-- {
+71
View File
@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"fmt"
"math"
"strings"
"kit/ext"
)
// Init demonstrates a minimal-chrome extension.
// Hides the startup banner, status bar, separator, and input hint, replacing
// them with a compact footer showing model name and a context usage bar:
//
// claude-sonnet-4-5-20250929 [###-------] 30% (3.9K/200K tokens)
//
// Usage: kit -e examples/extensions/minimal.go
func Init(api ext.API) {
// updateFooter builds the footer text from current context stats.
updateFooter := func(ctx ext.Context) {
stats := ctx.GetContextStats()
pct := stats.UsagePercent * 100
if pct > 100 {
pct = 100
}
filled := int(math.Round(pct)) / 10
bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)
// Format token counts like the built-in status bar (e.g. "3.9K/200K").
fmtTokens := func(n int) string {
if n >= 1000 {
return fmt.Sprintf("%.1fK", float64(n)/1000)
}
return fmt.Sprintf("%d", n)
}
text := fmt.Sprintf("%s [%s] %d%%", ctx.Model, bar, int(math.Round(pct)))
if stats.ContextLimit > 0 {
text += fmt.Sprintf(" (%s/%s tokens)",
fmtTokens(stats.EstimatedTokens), fmtTokens(stats.ContextLimit))
}
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: text},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
}
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
// Strip built-in chrome for a minimal look.
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
updateFooter(ctx)
})
// Refresh after each agent turn — context usage changes here.
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
updateFooter(ctx)
})
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
ctx.RemoveFooter()
})
}
+34
View File
@@ -0,0 +1,34 @@
//go:build ignore
package main
import (
"os/exec"
"runtime"
"kit/ext"
)
// Init sends a desktop notification when the agent finishes responding.
// Useful for long-running tasks — get notified without watching the terminal.
// Supports: Linux (notify-send), macOS (osascript).
//
// Usage: kit -e examples/extensions/notify.go
func Init(api ext.API) {
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
sendNotification("Kit", "Agent finished responding")
})
}
func sendNotification(title, body string) {
switch runtime.GOOS {
case "linux":
// Uses notify-send (libnotify) — available on most Linux desktops.
_ = exec.Command("notify-send", "-a", "Kit", title, body).Start()
case "darwin":
// Uses macOS built-in osascript for native notifications.
script := `display notification "` + body + `" with title "` + title + `"`
_ = exec.Command("osascript", "-e", script).Start()
}
}
+64
View File
@@ -0,0 +1,64 @@
//go:build ignore
package main
import (
"encoding/json"
"strings"
"kit/ext"
)
// Init intercepts potentially dangerous bash commands and asks the user for
// confirmation before allowing execution.
//
// Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/
//
// Usage: kit -e examples/extensions/permission-gate.go
func Init(api ext.API) {
// Patterns that require user confirmation.
dangerousPatterns := []string{
"rm -rf",
"rm -r /",
"sudo ",
"chmod 777",
"chmod -R 777",
"mkfs",
"dd if=",
"> /dev/",
":(){ :|:& };:",
}
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName != "Bash" {
return nil
}
// Extract the command from the tool input JSON.
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
cmd := strings.ToLower(input.Command)
// Check for dangerous patterns.
for _, pattern := range dangerousPatterns {
if strings.Contains(cmd, strings.ToLower(pattern)) {
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Dangerous command detected: " + input.Command + "\n\nAllow execution?",
})
if result.Cancelled || !result.Value {
return &ext.ToolCallResult{
Block: true,
Reason: "User denied execution of dangerous command: " + input.Command,
}
}
return nil // user approved
}
}
return nil
})
}
+28
View File
@@ -0,0 +1,28 @@
//go:build ignore
package main
import "kit/ext"
// Init injects a pirate persona into the system prompt, causing the LLM to
// respond in pirate-speak. Demonstrates OnBeforeAgentStart system prompt
// injection.
//
// Usage: kit -e examples/extensions/pirate.go
func Init(api ext.API) {
piratePrompt := `
You are a pirate! You must:
- Start every response with "Ahoy!"
- Use pirate slang (ye, matey, arr, landlubber, etc.)
- Refer to files as "scrolls" and directories as "treasure chests"
- Call errors "cursed mishaps" and bugs "sea monsters"
- End responses with a pirate saying
Despite the pirate persona, your technical advice must remain accurate and helpful.`
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
return &ext.BeforeAgentStartResult{
SystemPrompt: &piratePrompt,
}
})
}
+96
View File
@@ -0,0 +1,96 @@
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
// Init implements a plan/explore mode that restricts the agent to read-only
// tools. Toggle with /plan (or start in plan mode via KIT_OPT_PLAN=true).
// In plan mode the agent can only use read, grep, find, and ls — it cannot
// write files, run bash, or make edits. This is useful for exploring a
// codebase, reviewing architecture, or generating plans before executing.
//
// The status bar shows the current mode and the system prompt is augmented
// with planning instructions when active.
//
// Usage: kit -e examples/extensions/plan-mode.go
//
// Start in plan mode: KIT_OPT_PLAN=true kit -e examples/extensions/plan-mode.go
func Init(api ext.API) {
// Read-only tool set (matches core.ReadOnlyTools).
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
// Register "plan" option so users can start in plan mode via env/config.
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
// ctrl+alt+p — global shortcut to toggle plan mode.
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
// /plan — toggle plan mode on or off.
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode (ctrl+alt+p)",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
// Check option at session start to enable plan mode from env/config.
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
opt := strings.ToLower(ctx.GetOption("plan"))
if opt == "true" || opt == "1" || opt == "yes" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
// Inject planning instructions into the system prompt when active.
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only exploration).
You can ONLY read, search, and explore the codebase. You CANNOT write files,
run commands, or make edits. Focus on:
- Understanding the codebase structure and architecture
- Identifying relevant files and patterns
- Generating detailed plans and recommendations
- Answering questions about how the code works
When the user is ready to execute, they will exit plan mode with /plan.`
return &ext.BeforeAgentStartResult{
SystemPrompt: &prompt,
}
})
}
func applyMode(ctx ext.Context, active bool, readOnlyTools []string) {
if active {
ctx.SetActiveTools(readOnlyTools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON — agent restricted to read-only tools (read, grep, find, ls).\nUse /plan to toggle off.")
} else {
ctx.SetActiveTools(nil) // re-enable all tools
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF — all tools re-enabled.")
}
}
+71
View File
@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"kit/ext"
)
// Init loads project-specific rules from .kit/rules/ into the system prompt.
// Each .md file in the rules directory is injected as additional context,
// giving projects a way to customise LLM behaviour without editing the
// main system prompt.
//
// Place rule files in:
//
// .kit/rules/code-style.md
// .kit/rules/testing.md
// .kit/rules/security.md
//
// Usage: kit -e examples/extensions/project-rules.go
func Init(api ext.API) {
var rules string
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
rulesDir := filepath.Join(ctx.CWD, ".kit", "rules")
entries, err := os.ReadDir(rulesDir)
if err != nil {
return // no rules directory, nothing to do
}
var parts []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".txt") {
continue
}
data, err := os.ReadFile(filepath.Join(rulesDir, name))
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content != "" {
parts = append(parts, "## "+strings.TrimSuffix(name, filepath.Ext(name))+"\n\n"+content)
}
}
if len(parts) == 0 {
return
}
rules = "# Project Rules\n\n" + strings.Join(parts, "\n\n---\n\n")
ctx.PrintInfo(fmt.Sprintf("[project-rules] Loaded %d rule file(s) from .kit/rules/", len(parts)))
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if rules == "" {
return nil
}
return &ext.BeforeAgentStartResult{
SystemPrompt: &rules,
}
})
}
+114
View File
@@ -0,0 +1,114 @@
//go:build ignore
package main
import (
"encoding/json"
"strings"
"kit/ext"
)
// Init blocks tool calls that attempt to write, edit, or delete files in
// protected paths.
//
// Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key
//
// Usage: kit -e examples/extensions/protected-paths.go
func Init(api ext.API) {
// Tools that modify files.
writeTools := map[string]bool{
"Write": true,
"Edit": true,
"Bash": true,
}
// Path patterns to protect (checked against the file_path / filePath field).
protectedPatterns := []string{
".env",
".git/",
"secrets/",
"credentials",
".pem",
".key",
"id_rsa",
"id_ed25519",
}
// Bash commands that could modify protected files.
bashWritePatterns := []string{
"rm ", "mv ", "cp ", "> ",
"cat >", "echo >", "tee ",
"chmod ", "chown ",
}
isProtected := func(path string) bool {
lower := strings.ToLower(path)
for _, p := range protectedPatterns {
if strings.Contains(lower, p) {
return true
}
}
return false
}
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if !writeTools[tc.ToolName] {
return nil
}
// For Write/Edit: check the file_path / filePath field.
if tc.ToolName == "Write" || tc.ToolName == "Edit" {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
// Try both naming conventions.
filePath, _ := input["file_path"].(string)
if filePath == "" {
filePath, _ = input["filePath"].(string)
}
if isProtected(filePath) {
return &ext.ToolCallResult{
Block: true,
Reason: "Blocked: writing to protected path: " + filePath,
}
}
return nil
}
// For Bash: check if the command references protected paths.
if tc.ToolName == "Bash" {
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
// Only check bash commands that look like file mutations.
isMutation := false
for _, pat := range bashWritePatterns {
if strings.Contains(input.Command, pat) {
isMutation = true
break
}
}
if !isMutation {
return nil
}
// Check if any protected pattern appears in the command.
for _, p := range protectedPatterns {
if strings.Contains(input.Command, p) {
return &ext.ToolCallResult{
Block: true,
Reason: "Blocked: bash command references protected path (" + p + "): " + input.Command,
}
}
}
}
return nil
})
}
+21 -6
View File
@@ -35,6 +35,11 @@ import (
"kit/ext"
)
// subJSONOutput matches the JSON envelope produced by `kit --json`.
type subJSONOutput struct {
Response string `json:"response"`
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -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",
+93
View File
@@ -0,0 +1,93 @@
//go:build ignore
package main
import (
"fmt"
"strings"
"kit/ext"
)
// Init adds a /summarize command that generates a concise summary of the
// current conversation using a direct LLM completion. Demonstrates the
// ctx.Complete API.
//
// The summary is displayed in a styled block and can optionally be saved
// to the session via AppendEntry for later retrieval.
//
// Usage: kit -e examples/extensions/summarize.go
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "summarize",
Description: "Summarize the current conversation",
Execute: func(args string, ctx ext.Context) (string, error) {
msgs := ctx.GetMessages()
if len(msgs) == 0 {
ctx.PrintInfo("Nothing to summarize — no messages yet.")
return "", nil
}
// Build a text representation of the conversation.
var parts []string
for _, m := range msgs {
content := m.Content
if len(content) > 2000 {
content = content[:1997] + "..."
}
parts = append(parts, fmt.Sprintf("[%s]: %s", m.Role, content))
}
conversation := strings.Join(parts, "\n\n")
ctx.PrintInfo("Generating summary...")
resp, err := ctx.Complete(ext.CompleteRequest{
System: `You are a concise summarization assistant. Summarize the conversation below in 3-5 bullet points. Focus on:
- What was discussed or requested
- Key decisions or outcomes
- Any pending action items
Be concise. Use plain text, no markdown headers.`,
Prompt: conversation,
})
if err != nil {
ctx.PrintError("Summary failed: " + err.Error())
return "", nil
}
summary := strings.TrimSpace(resp.Text)
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#89b4fa",
Subtitle: fmt.Sprintf("Summary (%d messages, %d tokens used)", len(msgs), resp.InputTokens+resp.OutputTokens),
})
// Persist the summary in the session for later retrieval.
ctx.AppendEntry("summary", summary)
return "", nil
},
})
// /summaries — list all saved summaries.
api.RegisterCommand(ext.CommandDef{
Name: "summaries",
Description: "List saved conversation summaries",
Execute: func(args string, ctx ext.Context) (string, error) {
entries := ctx.GetEntries("summary")
if len(entries) == 0 {
ctx.PrintInfo("No summaries saved yet. Use /summarize to create one.")
return "", nil
}
for i, e := range entries {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: e.Data,
BorderColor: "#89b4fa",
Subtitle: fmt.Sprintf("Summary #%d (%s)", i+1, e.Timestamp[:19]),
})
}
return "", nil
},
})
}
+70
View File
@@ -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
+106
View File
@@ -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()
+17
View File
@@ -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
+15 -15
View File
@@ -1,7 +1,7 @@
// Package compaction provides context window management with token estimation,
// compaction triggers, and LLM-based conversation summarization.
//
// The algorithm mirrors Pi's approach: preserve a token budget of recent
// The algorithm preserves a token budget of recent
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
// count. Auto-compaction fires when estimated context usage exceeds
// contextWindow ReserveTokens.
@@ -50,8 +50,8 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
// Auto-compact trigger
// ---------------------------------------------------------------------------
// ShouldCompact reports whether auto-compaction should fire. It uses Pi's
// formula: contextTokens > contextWindow reserveTokens.
// ShouldCompact reports whether auto-compaction should fire.
// Formula: contextTokens > contextWindow reserveTokens.
func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens int) bool {
if contextWindow <= 0 || reserveTokens <= 0 {
return false
@@ -72,8 +72,8 @@ type CompactionResult struct {
MessagesRemoved int // Number of messages replaced by the summary
}
// CompactionOptions configures compaction behaviour. Pi-style token-based
// defaults are applied for zero-value fields.
// CompactionOptions configures compaction behaviour. Token-based defaults
// are applied for zero-value fields.
type CompactionOptions struct {
ContextWindow int // Model's context window size (tokens)
ReserveTokens int // Tokens to reserve for LLM response, default 16384
@@ -81,7 +81,7 @@ type CompactionOptions struct {
SummaryPrompt string // Custom summary prompt (empty = use default)
}
// defaults fills zero-value fields with sensible Pi-style defaults.
// defaults fills zero-value fields with sensible defaults.
func (o *CompactionOptions) defaults() {
if o.ReserveTokens <= 0 {
o.ReserveTokens = 16384
@@ -92,13 +92,13 @@ func (o *CompactionOptions) defaults() {
}
// defaultSystemPrompt is the system prompt sent to the summarisation LLM.
// Matches Pi's compaction system prompt.
const defaultSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
// defaultSummaryPrompt is the user prompt appended after the serialised
// conversation. Matches Pi's initial-compaction format.
// conversation.
const defaultSummaryPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
@@ -133,7 +133,7 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`
// ---------------------------------------------------------------------------
// Cut point (token-based, Pi-style)
// Cut point (token-based)
// ---------------------------------------------------------------------------
// isValidCutPoint returns true if the message at index i is a valid place to
@@ -208,11 +208,11 @@ func forceCutPoint(messages []fantasy.Message) int {
}
// ---------------------------------------------------------------------------
// Message serialisation (Pi-style)
// Message serialisation
// ---------------------------------------------------------------------------
// roleLabel returns a human-readable label for a fantasy message role,
// matching Pi's serialisation format.
func roleLabel(role fantasy.MessageRole) string {
switch role {
case fantasy.MessageRoleUser:
@@ -230,7 +230,7 @@ func roleLabel(role fantasy.MessageRole) string {
// serializeMessages converts a slice of fantasy messages into a plain-text
// representation suitable for sending to the summarisation LLM. The format
// mirrors Pi's compaction serialisation.
func serializeMessages(messages []fantasy.Message) string {
var sb strings.Builder
for _, msg := range messages {
@@ -277,8 +277,8 @@ func Compact(
cutPoint := FindCutPoint(messages, opts.KeepRecentTokens)
if cutPoint == 0 {
// All messages fit within the keep budget. Force a cut that
// keeps only the last non-tool message — matching Pi, which
// always compacts when the user explicitly requests it.
// keeps only the last non-tool message — always compact when
// the user explicitly requests it.
cutPoint = forceCutPoint(messages)
if cutPoint == 0 {
return nil, messages, nil
@@ -289,7 +289,7 @@ func Compact(
recentMessages := messages[cutPoint:]
originalTokens := EstimateMessageTokens(messages)
// Serialise old messages to text, matching Pi's format.
// Serialise old messages to text.
conversationText := serializeMessages(oldMessages)
// Build the user-facing prompt: conversation text + summary instructions.
+2 -2
View File
@@ -63,7 +63,7 @@ func TestEstimateMessageTokens_Empty(t *testing.T) {
}
// ---------------------------------------------------------------------------
// ShouldCompact (Pi-style: contextTokens > contextWindow - reserveTokens)
// ShouldCompact (contextTokens > contextWindow - reserveTokens)
// ---------------------------------------------------------------------------
func TestShouldCompact(t *testing.T) {
@@ -94,7 +94,7 @@ func TestShouldCompact(t *testing.T) {
}
// ---------------------------------------------------------------------------
// FindCutPoint (token-based, Pi-style)
// FindCutPoint (token-based)
// ---------------------------------------------------------------------------
func TestFindCutPoint_TokenBased(t *testing.T) {
+4 -4
View File
@@ -1,7 +1,7 @@
// Package core provides the built-in core tools for KIT's coding agent.
// These tools are direct fantasy.AgentTool implementations — no MCP layer,
// no JSON-RPC, no serialization overhead. They match the pi coding agent's
// core tool set: bash, read, write, edit, grep, find, ls.
// no JSON-RPC, no serialization overhead. Core tool set: bash, read, write,
// edit, grep, find, ls.
package core
import (
@@ -65,7 +65,7 @@ func parseArgs(input string, target any) error {
}
// CodingTools returns the default set of core tools for a coding agent:
// bash, read, write, edit. This matches pi's codingTools collection.
// bash, read, write, edit.
func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
NewBashTool(opts...),
@@ -76,7 +76,7 @@ func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
}
// ReadOnlyTools returns tools for read-only exploration:
// read, grep, find, ls. This matches pi's readOnlyTools collection.
// read, grep, find, ls.
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
NewReadTool(opts...),
+802 -19
View File
@@ -64,6 +64,19 @@ type Context struct {
// }()
SendMessage func(string)
// CancelAndSend cancels the current agent turn (if running), clears
// the message queue, and sends a new message that executes as soon as
// cancellation completes. If the agent is idle, the message executes
// immediately. This is the "steer" delivery mode.
//
// Use this for directive changes that should interrupt the current
// operation, e.g. switching modes or redirecting the agent.
//
// Example:
//
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
CancelAndSend func(string)
// SetWidget places or updates a persistent widget in the TUI. Widgets
// remain visible across agent turns until explicitly removed. The
// widget is identified by WidgetConfig.ID; calling SetWidget with the
@@ -206,6 +219,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.01.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.01.0).
UsagePercent float64
// MessageCount is the number of messages in the conversation.
MessageCount int
// IsAutomatic is true when compaction was triggered automatically
// (as opposed to manual /compact command).
IsAutomatic bool
}
func (e BeforeCompactEvent) Type() EventType { return BeforeCompact }
// BeforeCompactResult controls whether compaction proceeds. Return
// Cancel=true with an optional Reason to block compaction.
type BeforeCompactResult struct {
// Cancel, when true, prevents compaction from proceeding.
Cancel bool
// Reason is a human-readable explanation shown to the user when
// Cancel is true. Empty string uses a default message.
Reason string
}
func (BeforeCompactResult) isResult() {}
+23 -1
View File
@@ -1,4 +1,4 @@
// Package extensions implements a Pi-style in-process extension system for KIT.
// Package extensions implements an in-process extension system for KIT.
// Extensions are plain Go files loaded at runtime via Yaegi (a Go interpreter).
// They register event handlers using an API object, enabling tool interception,
// input transformation, and lifecycle observation — all without recompilation.
@@ -48,6 +48,26 @@ const (
// SessionShutdown fires when the application is closing.
SessionShutdown EventType = "session_shutdown"
// ModelChange fires after the active model is changed via ctx.SetModel().
ModelChange EventType = "model_change"
// ContextPrepare fires after context is built from the session tree and
// before the messages are sent to the LLM. Handlers can filter, reorder,
// or inject messages into the context window.
ContextPrepare EventType = "context_prepare"
// BeforeFork fires before the session tree is branched to a different
// entry point. Handlers can cancel the fork by returning Cancel=true.
BeforeFork EventType = "before_fork"
// BeforeSessionSwitch fires before the session is switched to a new
// branch (e.g. /new command). Handlers can cancel by returning Cancel=true.
BeforeSessionSwitch EventType = "before_session_switch"
// BeforeCompact fires before context compaction runs. Handlers can
// cancel compaction by returning Cancel=true.
BeforeCompact EventType = "before_compact"
)
// AllEventTypes returns every supported event type.
@@ -57,6 +77,8 @@ func AllEventTypes() []EventType {
Input, BeforeAgentStart, AgentStart, AgentEnd,
MessageStart, MessageUpdate, MessageEnd,
SessionStart, SessionShutdown,
ModelChange, ContextPrepare,
BeforeFork, BeforeSessionSwitch, BeforeCompact,
}
}
+7 -2
View File
@@ -4,8 +4,8 @@ import "testing"
func TestAllEventTypes_Count(t *testing.T) {
all := AllEventTypes()
if len(all) != 13 {
t.Fatalf("expected 13 event types, got %d", len(all))
if len(all) != 18 {
t.Fatalf("expected 18 event types, got %d", len(all))
}
}
@@ -50,6 +50,11 @@ func TestEventType_TypeMethod(t *testing.T) {
{MessageEndEvent{Content: "done"}, MessageEnd},
{SessionStartEvent{SessionID: "abc"}, SessionStart},
{SessionShutdownEvent{}, SessionShutdown},
{ModelChangeEvent{NewModel: "a/b"}, ModelChange},
{ContextPrepareEvent{Messages: []ContextMessage{{Index: 0, Role: "user", Content: "hi"}}}, ContextPrepare},
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
}
for _, tt := range tests {
+57
View File
@@ -283,6 +283,48 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
return nil
})
},
onModelChange: func(h func(ModelChangeEvent, Context)) {
reg(ModelChange, func(e Event, c Context) Result {
h(e.(ModelChangeEvent), c)
return nil
})
},
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
reg(ContextPrepare, func(e Event, c Context) Result {
r := h(e.(ContextPrepareEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
reg(BeforeFork, func(e Event, c Context) Result {
r := h(e.(BeforeForkEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
r := h(e.(BeforeSessionSwitchEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
reg(BeforeCompact, func(e Event, c Context) Result {
r := h(e.(BeforeCompactEvent), c)
if r == nil {
return nil
}
return *r
})
},
registerToolFn: func(tool ToolDef) {
ext.Tools = append(ext.Tools, tool)
},
@@ -292,6 +334,21 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
registerToolRendererFn: func(config ToolRenderConfig) {
ext.ToolRenderers = append(ext.ToolRenderers, config)
},
registerMessageRendererFn: func(config MessageRendererConfig) {
ext.MessageRenderers = append(ext.MessageRenderers, config)
},
onCustomEvent: func(name string, handler func(string)) {
if ext.CustomEventHandlers == nil {
ext.CustomEventHandlers = make(map[string][]func(string))
}
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
},
registerOption: func(opt OptionDef) {
ext.Options = append(ext.Options, opt)
},
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
},
}
// Call Init — the extension registers its handlers, tools, commands.
+333 -20
View File
@@ -2,34 +2,52 @@ package extensions
import (
"fmt"
"os"
"sort"
"strings"
"sync"
"github.com/charmbracelet/log"
"github.com/spf13/viper"
)
// Runner manages loaded extensions and dispatches events to their handlers
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
// sequentially. Handlers execute in extension
// load order; for cancellable events the first blocking result wins.
type Runner struct {
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
mu sync.RWMutex
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
statusEntries map[string]StatusBarEntry // keyed by status key
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
uiVisibility *UIVisibility // nil = show everything (default)
disabledTools map[string]bool // nil = all tools enabled
customEventSubs map[string][]func(string) // inter-extension event bus
optionOverrides map[string]string // runtime option overrides
mu sync.RWMutex
}
// ShortcutEntry pairs a shortcut definition with its handler.
type ShortcutEntry struct {
Def ShortcutDef
Handler func(Context)
}
// LoadedExtension represents a single extension that has been discovered,
// loaded, and initialised. It holds the registered handlers and any custom
// tools, commands, or tool renderers the extension provided.
type LoadedExtension struct {
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
ToolRenderers []ToolRenderConfig
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
ToolRenderers []ToolRenderConfig
MessageRenderers []MessageRendererConfig // named message renderers
CustomEventHandlers map[string][]func(string) // inter-extension event bus
Options []OptionDef // registered configuration options
Shortcuts []ShortcutEntry // global keyboard shortcuts
}
// NewRunner creates a Runner from a set of loaded extensions.
@@ -45,6 +63,13 @@ func (r *Runner) SetContext(ctx Context) {
r.ctx = ctx
}
// GetContext returns a snapshot of the current runtime context. Thread-safe.
func (r *Runner) GetContext() Context {
r.mu.RLock()
defer r.mu.RUnlock()
return r.ctx
}
// HasHandlers returns true if any loaded extension has at least one handler
// registered for the given event type.
func (r *Runner) HasHandlers(event EventType) bool {
@@ -121,13 +146,6 @@ func (r *Runner) RegisteredCommands() []CommandDef {
return cmds
}
// GetContext returns the current runtime context. Thread-safe.
func (r *Runner) GetContext() Context {
r.mu.RLock()
defer r.mu.RUnlock()
return r.ctx
}
// Extensions returns the loaded extensions for inspection (e.g. CLI list).
func (r *Runner) Extensions() []LoadedExtension {
return r.extensions
@@ -177,6 +195,45 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
return result
}
// ---------------------------------------------------------------------------
// Status bar management
// ---------------------------------------------------------------------------
// SetStatusEntry places or updates a keyed status bar entry. Thread-safe.
func (r *Runner) SetStatusEntry(entry StatusBarEntry) {
r.mu.Lock()
defer r.mu.Unlock()
if r.statusEntries == nil {
r.statusEntries = make(map[string]StatusBarEntry)
}
r.statusEntries[entry.Key] = entry
}
// RemoveStatusEntry removes a status bar entry by key. Thread-safe.
func (r *Runner) RemoveStatusEntry(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.statusEntries, key)
}
// GetStatusEntries returns all status bar entries, sorted by priority
// (ascending). Thread-safe.
func (r *Runner) GetStatusEntries() []StatusBarEntry {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]StatusBarEntry, 0, len(r.statusEntries))
for _, e := range r.statusEntries {
result = append(result, e)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Priority != result[j].Priority {
return result[i].Priority < result[j].Priority
}
return result[i].Key < result[j].Key
})
return result
}
// ---------------------------------------------------------------------------
// Header/Footer management
// ---------------------------------------------------------------------------
@@ -269,6 +326,29 @@ func (r *Runner) GetEditor() *EditorConfig {
return &e
}
// ---------------------------------------------------------------------------
// UI visibility management
// ---------------------------------------------------------------------------
// SetUIVisibility updates the UI visibility overrides. Thread-safe.
func (r *Runner) SetUIVisibility(v UIVisibility) {
r.mu.Lock()
defer r.mu.Unlock()
r.uiVisibility = &v
}
// GetUIVisibility returns the current UI visibility overrides, or nil if
// none have been set (meaning show everything). Thread-safe.
func (r *Runner) GetUIVisibility() *UIVisibility {
r.mu.RLock()
defer r.mu.RUnlock()
if r.uiVisibility == nil {
return nil
}
v := *r.uiVisibility
return &v
}
// ---------------------------------------------------------------------------
// Tool renderer management
// ---------------------------------------------------------------------------
@@ -290,6 +370,233 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
return nil
}
// ---------------------------------------------------------------------------
// Message renderer management
// ---------------------------------------------------------------------------
// GetMessageRenderer returns the named message renderer, or nil if no
// extension registered a renderer with that name. If multiple extensions
// register the same name, the last one (by load order) wins.
func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
for i := len(r.extensions) - 1; i >= 0; i-- {
for j := len(r.extensions[i].MessageRenderers) - 1; j >= 0; j-- {
if r.extensions[i].MessageRenderers[j].Name == name {
config := r.extensions[i].MessageRenderers[j]
return &config
}
}
}
return nil
}
// ---------------------------------------------------------------------------
// Hot-reload
// ---------------------------------------------------------------------------
// Reload replaces the loaded extensions with a fresh set and clears all
// dynamic state (widgets, status, header/footer, editor, visibility,
// disabled tools, custom event subscriptions). Option overrides are
// preserved across reloads since they represent user intent.
//
// The caller is responsible for emitting SessionShutdown before calling
// Reload and SessionStart after.
func (r *Runner) Reload(exts []LoadedExtension) {
r.mu.Lock()
defer r.mu.Unlock()
r.extensions = exts
r.widgets = nil
r.statusEntries = nil
r.header = nil
r.footer = nil
r.customEditor = nil
r.uiVisibility = nil
r.disabledTools = nil
r.customEventSubs = nil
// optionOverrides are intentionally preserved.
}
// ---------------------------------------------------------------------------
// Inter-extension event bus
// ---------------------------------------------------------------------------
// SubscribeCustomEvent registers a handler for a named custom event. Handlers
// execute in registration order when EmitCustomEvent is called. Thread-safe.
func (r *Runner) SubscribeCustomEvent(name string, handler func(string)) {
r.mu.Lock()
defer r.mu.Unlock()
if r.customEventSubs == nil {
r.customEventSubs = make(map[string][]func(string))
}
r.customEventSubs[name] = append(r.customEventSubs[name], handler)
}
// EmitCustomEvent dispatches a named event to all subscribed handlers.
// Handlers run synchronously in extension load order. Panics are recovered
// and logged. Thread-safe.
func (r *Runner) EmitCustomEvent(name, data string) {
// Collect handlers: extension-registered (Init-time) + dynamic subs.
r.mu.RLock()
dynamicHandlers := r.customEventSubs[name]
r.mu.RUnlock()
safeInvoke := func(h func(string)) {
defer func() {
if rec := recover(); rec != nil {
log.Warn("custom event handler panicked",
"event", name,
"err", fmt.Sprintf("%v", rec))
}
}()
h(data)
}
// Extension-registered handlers first (in load order).
for i := range r.extensions {
for _, h := range r.extensions[i].CustomEventHandlers[name] {
safeInvoke(h)
}
}
// Then dynamic subscriptions.
for _, h := range dynamicHandlers {
safeInvoke(h)
}
}
// ---------------------------------------------------------------------------
// Tool management
// ---------------------------------------------------------------------------
// SetActiveTools restricts the tool set to the named tools. All tools not in
// the list are disabled. Passing nil or an empty slice re-enables all tools.
// Thread-safe.
func (r *Runner) SetActiveTools(names []string) {
r.mu.Lock()
defer r.mu.Unlock()
if len(names) == 0 {
r.disabledTools = nil
return
}
active := make(map[string]bool, len(names))
for _, n := range names {
active[n] = true
}
r.disabledTools = active // non-nil = only these tools are allowed
}
// IsToolDisabled returns true if the tool has been disabled via SetActiveTools.
// Thread-safe.
func (r *Runner) IsToolDisabled(toolName string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if r.disabledTools == nil {
return false // no filter = all enabled
}
return !r.disabledTools[toolName]
}
// ---------------------------------------------------------------------------
// Extension options
// ---------------------------------------------------------------------------
// GetOption resolves a named option value in priority order:
// 1. Runtime override (via SetOption)
// 2. Environment variable: KIT_OPT_<NAME> (uppercased, dashes → underscores)
// 3. Viper config: options.<name>
// 4. Default value from RegisterOption
//
// Returns empty string if the option was never registered.
// Thread-safe.
func (r *Runner) GetOption(name string) string {
// 1. Runtime override.
r.mu.RLock()
if v, ok := r.optionOverrides[name]; ok {
r.mu.RUnlock()
return v
}
r.mu.RUnlock()
// 2. Environment variable: KIT_OPT_<NAME>
envKey := "KIT_OPT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
if v := os.Getenv(envKey); v != "" {
return v
}
// 3. Viper config: options.<name>
configKey := "options." + name
if v := viper.GetString(configKey); v != "" {
return v
}
// 4. Default from registered option defs.
for i := range r.extensions {
for _, opt := range r.extensions[i].Options {
if opt.Name == name {
return opt.Default
}
}
}
return ""
}
// SetOption stores a runtime override for a named option. This takes highest
// priority over env vars, config, and defaults. Thread-safe.
func (r *Runner) SetOption(name, value string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.optionOverrides == nil {
r.optionOverrides = make(map[string]string)
}
r.optionOverrides[name] = value
}
// RegisteredOptions returns all option definitions from all loaded extensions.
func (r *Runner) RegisteredOptions() []OptionDef {
var opts []OptionDef
for i := range r.extensions {
opts = append(opts, r.extensions[i].Options...)
}
return opts
}
// ---------------------------------------------------------------------------
// Keyboard shortcuts
// ---------------------------------------------------------------------------
// GetShortcuts returns all registered keyboard shortcuts as a map of
// key binding → handler. If multiple extensions register the same key,
// the last registration wins. Thread-safe (reads extension list which is
// immutable after loading).
func (r *Runner) GetShortcuts() map[string]ShortcutEntry {
result := make(map[string]ShortcutEntry)
for i := range r.extensions {
for _, sc := range r.extensions[i].Shortcuts {
result[sc.Def.Key] = sc
}
}
if len(result) == 0 {
return nil
}
return result
}
// RegisteredShortcuts returns all shortcut definitions from all loaded
// extensions. Used for help/listing commands.
func (r *Runner) RegisteredShortcuts() []ShortcutDef {
var defs []ShortcutDef
seen := make(map[string]bool)
// Iterate in reverse so last registration for a key wins.
for i := len(r.extensions) - 1; i >= 0; i-- {
for _, sc := range r.extensions[i].Shortcuts {
if !seen[sc.Def.Key] {
seen[sc.Def.Key] = true
defs = append(defs, sc.Def)
}
}
}
return defs
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -311,6 +618,12 @@ func isBlocking(result Result) bool {
return r.Block
case InputResult:
return r.Action == "handled"
case BeforeForkResult:
return r.Cancel
case BeforeSessionSwitchResult:
return r.Cancel
case BeforeCompactResult:
return r.Cancel
}
return false
}
+45
View File
@@ -23,9 +23,31 @@ func Symbols() interp.Exports {
"API": reflect.ValueOf((*API)(nil)),
"Context": reflect.ValueOf((*Context)(nil)),
"ToolDef": reflect.ValueOf((*ToolDef)(nil)),
"ToolContext": reflect.ValueOf((*ToolContext)(nil)),
"ShortcutDef": reflect.ValueOf((*ShortcutDef)(nil)),
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
// Session types
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
// Option types
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
// Model info types
"ModelInfoEntry": reflect.ValueOf((*ModelInfoEntry)(nil)),
// Tool info types
"ToolInfo": reflect.ValueOf((*ToolInfo)(nil)),
// LLM completion types
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
// Status bar types
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
// Widget types
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
@@ -37,6 +59,12 @@ func Symbols() interp.Exports {
// Header/Footer types
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
// UI visibility
"UIVisibility": reflect.ValueOf((*UIVisibility)(nil)),
// Context stats
"ContextStats": reflect.ValueOf((*ContextStats)(nil)),
// Overlay types
"OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)),
"OverlayCenter": reflect.ValueOf(OverlayCenter),
@@ -49,6 +77,9 @@ func Symbols() interp.Exports {
// Tool renderer types
"ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)),
// Message renderer types
"MessageRendererConfig": reflect.ValueOf((*MessageRendererConfig)(nil)),
// Editor interceptor types
"EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)),
"EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough),
@@ -66,6 +97,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)),
},
}
}
+43 -11
View File
@@ -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
}
+121 -5
View File
@@ -48,8 +48,13 @@ func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
}))
tools := []fantasy.AgentTool{newMockTool("test")}
result := WrapToolsWithExtensions(tools, r)
if result[0] != tools[0] {
t.Error("expected original tool when no tool handlers exist")
// Tools are always wrapped now (for SetActiveTools support),
// but Info() should pass through correctly.
if result[0] == tools[0] {
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
}
if result[0].Info().Name != "test" {
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
}
}
@@ -102,6 +107,22 @@ func TestWrappedTool_NormalExecution(t *testing.T) {
}
}
func TestWrappedTool_SourceField(t *testing.T) {
var gotSource string
r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result {
gotSource = e.(ToolCallEvent).Source
return nil
}},
}))
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r)
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
if gotSource != "llm" {
t.Errorf("expected Source='llm', got %q", gotSource)
}
}
func TestWrappedTool_BlockExecution(t *testing.T) {
var toolRan bool
r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{
@@ -181,7 +202,7 @@ func TestExtensionToolsAsFantasy(t *testing.T) {
},
}
tools := ExtensionToolsAsFantasy(defs)
tools := ExtensionToolsAsFantasy(defs, nil)
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
@@ -211,7 +232,7 @@ func TestExtensionTool_Error(t *testing.T) {
},
}
tools := ExtensionToolsAsFantasy(defs)
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
if err == nil {
t.Error("expected error")
@@ -221,9 +242,104 @@ func TestExtensionTool_Error(t *testing.T) {
}
}
func TestExtensionTool_ExecuteWithContext(t *testing.T) {
var gotCancelled bool
var gotProgress []string
defs := []ToolDef{
{
Name: "rich",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
gotCancelled = tc.IsCancelled()
tc.OnProgress("step 1")
tc.OnProgress("step 2")
return "done: " + input, nil
},
},
}
// Without runner, OnProgress is a no-op.
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "done: test" {
t.Errorf("expected 'done: test', got %q", resp.Content)
}
if gotCancelled {
t.Error("expected IsCancelled=false for non-cancelled context")
}
// With runner, OnProgress routes through Print.
runner := NewRunner(nil)
runner.SetContext(Context{
Print: func(text string) { gotProgress = append(gotProgress, text) },
})
defs2 := []ToolDef{
{
Name: "rich2",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
tc.OnProgress("hello")
return "ok", nil
},
},
}
tools2 := ExtensionToolsAsFantasy(defs2, runner)
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(gotProgress) != 1 || gotProgress[0] != "hello" {
t.Errorf("expected [hello], got %v", gotProgress)
}
}
func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
// When both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
defs := []ToolDef{
{
Name: "both",
Execute: func(input string) (string, error) { return "simple", nil },
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
return "rich", nil
},
},
}
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "rich" {
t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content)
}
}
func TestExtensionTool_CancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
var sawCancelled bool
defs := []ToolDef{
{
Name: "checkcancel",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
sawCancelled = tc.IsCancelled()
return "ok", nil
},
},
}
tools := ExtensionToolsAsFantasy(defs, nil)
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
if !sawCancelled {
t.Error("expected IsCancelled=true for cancelled context")
}
}
func TestExtensionTool_ProviderOptions(t *testing.T) {
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
tools := ExtensionToolsAsFantasy(defs)
tools := ExtensionToolsAsFantasy(defs, nil)
// Initially nil.
opts := tools[0].ProviderOptions()
+1 -1
View File
@@ -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,
+27 -2
View File
@@ -11,8 +11,8 @@ import (
)
// EntryType identifies the kind of entry stored in a JSONL session file.
// Following pi's design, sessions are append-only JSONL files where each line
// is a typed entry linked by id/parent_id to form a tree structure.
// Sessions are append-only JSONL files where each line is a typed entry
// linked by id/parent_id to form a tree structure.
type EntryType string
const (
@@ -22,6 +22,7 @@ const (
EntryTypeBranchSummary EntryType = "branch_summary"
EntryTypeLabel EntryType = "label"
EntryTypeSessionInfo EntryType = "session_info"
EntryTypeExtensionData EntryType = "extension_data"
)
// CurrentVersion is the session format version for JSONL tree sessions.
@@ -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)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
// SessionInfo contains metadata about a discovered session, used for listing
// and session picker display. Follows pi's SessionInfo design.
// and session picker display.
type SessionInfo struct {
// Path is the absolute path to the JSONL session file.
Path string
+45 -3
View File
@@ -16,7 +16,7 @@ import (
)
// TreeNode represents a node in the session tree for display purposes.
// It mirrors pi's SessionTreeNode design.
type TreeNode struct {
Entry any // the underlying entry (*MessageEntry, *ModelChangeEntry, etc.)
ID string // entry ID
@@ -25,7 +25,7 @@ type TreeNode struct {
}
// TreeManager manages a tree-structured JSONL session. It is the replacement
// for the linear session.Manager, following pi's design decisions:
// for the linear session.Manager:
//
// - JSONL append-only format (one JSON object per line)
// - Tree structure via id/parent_id on every entry
@@ -283,6 +283,44 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
return entry.ID, nil
}
// AppendExtensionData adds an extension data entry to the tree and persists it.
// Extensions use this to store custom state that survives across session restarts.
func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error) {
tm.mu.Lock()
defer tm.mu.Unlock()
entry := NewExtensionDataEntry(tm.leafID, extType, data)
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
tm.leafID = entry.ID
return entry.ID, nil
}
// GetExtensionData returns all extension data entries matching the given type,
// walking the current branch from root to leaf. If extType is empty, all
// extension data entries on the branch are returned.
func (tm *TreeManager) GetExtensionData(extType string) []*ExtensionDataEntry {
tm.mu.RLock()
defer tm.mu.RUnlock()
if tm.leafID == "" {
return nil
}
branch := tm.getBranchLocked(tm.leafID)
var results []*ExtensionDataEntry
for _, entry := range branch {
if e, ok := entry.(*ExtensionDataEntry); ok {
if extType == "" || e.ExtType == extType {
results = append(results, e)
}
}
}
return results
}
// --- Tree navigation ---
// Branch moves the leaf pointer to the given entry ID, creating a branch
@@ -601,6 +639,8 @@ func (tm *TreeManager) entryID(entry any) string {
return e.ID
case *SessionInfoEntry:
return e.ID
case *ExtensionDataEntry:
return e.ID
default:
return ""
}
@@ -619,6 +659,8 @@ func (tm *TreeManager) entryParentID(entry any) string {
return e.ParentID
case *SessionInfoEntry:
return e.ParentID
case *ExtensionDataEntry:
return e.ParentID
default:
return ""
}
@@ -675,7 +717,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
// --- Path conventions ---
// DefaultSessionDir returns the default session storage directory for a cwd.
// Following pi's convention: ~/.kit/sessions/--<cwd-path>--/
// Convention: ~/.kit/sessions/--<cwd-path>--/
func DefaultSessionDir(cwd string) string {
home, err := os.UserHomeDir()
if err != nil {
+1 -1
View File
@@ -202,7 +202,7 @@ func LoadSkills(cwd string) ([]*Skill, error) {
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
// system prompt. Only the name, description, and file location are included;
// the agent reads the full skill file on demand using the read tool. This
// matches the Pi SDK's formatSkillsForPrompt convention.
func FormatForPrompt(skills []*Skill) string {
if len(skills) == 0 {
return ""
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+12 -9
View File
@@ -26,6 +26,7 @@ type SlashCommandInput struct {
value string
submitNext bool // Flag to submit on next update
renderedLines int // Track how many lines were rendered
hideHint bool // Suppress the "enter submit · ctrl+j..." hint
}
// NewSlashCommandInput creates and initializes a new slash command input field with
@@ -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()))
+208 -1
View File
@@ -32,7 +32,11 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderEditBody(toolArgs, toolResult, width); body != "" {
return body
}
case toolName == "read" || toolName == "ls":
case toolName == "ls":
if body := renderLsBody(toolResult, width); body != "" {
return body
}
case toolName == "read":
if body := renderReadBody(toolArgs, toolResult, width); body != "" {
return body
}
@@ -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)
}
+1 -2
View File
@@ -52,8 +52,7 @@ type FlatNode struct {
}
// TreeSelectorComponent is a Bubble Tea component that renders the session
// tree as an ASCII art list with navigation and selection. It follows pi's
// tree selector design.
// tree as an ASCII art list with navigation and selection.
type TreeSelectorComponent struct {
tm *session.TreeManager
flatNodes []FlatNode
+41 -3
View File
@@ -23,8 +23,8 @@ func (m *Kit) EstimateContextTokens() int {
}
// ShouldCompact reports whether the conversation is near the model's context
// limit and should be compacted. Uses Pi's formula:
// contextTokens > contextWindow reserveTokens.
// limit and should be compacted.
// Formula: contextTokens > contextWindow reserveTokens.
// Returns false if the model's context limit is unknown.
func (m *Kit) ShouldCompact() bool {
info := m.GetModelInfo()
@@ -43,9 +43,23 @@ func (m *Kit) ShouldCompact() bool {
// GetContextStats returns current context usage statistics including
// estimated token count, context limit, usage percentage, and message count.
//
// When API-reported token counts are available (after at least one turn),
// EstimatedTokens uses the real input token count from the most recent API
// response. This is significantly more accurate than the text-based heuristic
// because it includes system prompts, tool definitions, and other overhead
// that the heuristic cannot account for.
func (m *Kit) GetContextStats() ContextStats {
messages := m.treeSession.GetFantasyMessages()
estimated := compaction.EstimateMessageTokens(messages)
// Prefer the real API-reported input token count when available.
m.lastInputTokensMu.RLock()
estimated := m.lastInputTokens
m.lastInputTokensMu.RUnlock()
if estimated == 0 {
// Fall back to heuristic before first turn completes.
estimated = compaction.EstimateMessageTokens(messages)
}
stats := ContextStats{
EstimatedTokens: estimated,
@@ -72,6 +86,12 @@ func (m *Kit) GetContextStats() ContextStats {
// After compaction, the tree session is cleared and replaced with the
// compacted messages (summary + preserved recent messages).
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstructions string) (*CompactionResult, error) {
return m.compactInternal(ctx, opts, customInstructions, false)
}
// compactInternal is the shared compaction implementation. The isAutomatic
// flag distinguishes auto-triggered compaction from manual /compact.
func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
if opts == nil {
if m.compactionOpts != nil {
opts = m.compactionOpts
@@ -92,6 +112,24 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
}
// Run before-compact hook — extensions can cancel compaction.
if m.beforeCompact.hasHooks() {
stats := m.GetContextStats()
if hookResult := m.beforeCompact.run(BeforeCompactHook{
EstimatedTokens: stats.EstimatedTokens,
ContextLimit: stats.ContextLimit,
UsagePercent: stats.UsagePercent,
MessageCount: stats.MessageCount,
IsAutomatic: isAutomatic,
}); hookResult != nil && hookResult.Cancel {
reason := hookResult.Reason
if reason == "" {
reason = "compaction cancelled by extension"
}
return nil, fmt.Errorf("%s", reason)
}
}
model := m.agent.GetModel()
result, newMessages, err := compaction.Compact(ctx, model, messages, *opts, customInstructions)
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// defaultSystemPrompt is the built-in system prompt used when no custom
// prompt is configured. It describes the available core tools and provides
// usage guidelines, matching the Pi SDK's default prompt style.
// usage guidelines.
const defaultSystemPrompt = `You are an expert coding assistant operating inside kit, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
+82 -1
View File
@@ -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
})
}
}
+54
View File
@@ -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.01.0).
UsagePercent float64
// MessageCount is the number of messages in the conversation.
MessageCount int
// IsAutomatic is true when compaction was triggered automatically.
IsAutomatic bool
}
// BeforeCompactResult controls whether compaction proceeds.
type BeforeCompactResult struct {
// Cancel, when true, prevents compaction from proceeding.
Cancel bool
// Reason is a human-readable explanation when Cancel is true.
Reason string
}
// ---------------------------------------------------------------------------
// Generic hook registry with priority ordering
// ---------------------------------------------------------------------------
@@ -181,6 +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
View File
@@ -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})