Compare commits

..

32 Commits

Author SHA1 Message Date
Ed Zynda 51c70b63a7 feat: add @file autocomplete and context attachment
Type @ in the input to trigger a fuzzy file picker popup. Files are
discovered via git ls-files (with os.ReadDir fallback), scored by
fuzzy match, and displayed in the existing autocomplete popup.

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

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

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

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

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

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

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

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

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

- Gap 1: Session Management API (GetMessages, GetSessionPath) — read-only
  access to conversation history from extensions
- Gap 2: Session Persistence (AppendEntry, GetEntries) — custom extension
  data survives across session restarts via new ExtensionDataEntry type
- Gap 10: SetEditorText — extensions can pre-fill the input editor
- Gap M3: Keyed Status Bar (SetStatus, RemoveStatus) — multiple extensions
  can place independent entries in the TUI status bar, ordered by priority
2026-03-02 01:33:56 +03:00
Ed Zynda dc59cfc81e feat: add --json output mode for --prompt and update subagent extensions
Add a --json flag that outputs structured JSON (response, model, usage,
messages with typed parts) when used with --prompt. Update kit-kit and
subagent-widget extensions to use --json for cleaner subprocess output
parsing instead of raw text heuristics.
2026-03-01 21:16:34 +03:00
Ed Zynda 8407d924b9 feat: add UIVisibility, GetContextStats APIs and compact tool renderers
- Add ctx.SetUIVisibility() to toggle built-in TUI chrome (startup
  message, status bar, separator, input hint) from extensions
- Add ctx.GetContextStats() returning accurate API-reported token counts
  instead of text-based heuristic; fix event ordering so extension
  handlers see up-to-date conversation state
- Add compact tool body renderers for compact mode: Read/Edit/Write/Ls
  show one-line summaries, Bash shows first 3 lines instead of full
  20-line syntax-highlighted output
- Add minimal.go example extension using UIVisibility + GetContextStats
2026-03-01 15:24:48 +03:00
Ed Zynda 91474af503 fix: remove line-number gutter from ls tool output
Ls output is a plain file list with no line numbers, so the empty
gutter column was wasted space. Give ls its own renderer that shows
a clean list with just the code background.
2026-03-01 13:41:35 +03:00
Ed Zynda e252791b3a ci: move discord notification after both goreleaser and npm publish 2026-03-01 02:15:37 +03:00
Ed Zynda 1880523422 feat: add subagent-widget extension example
Port of Pi's subagent-widget.ts — spawns background Kit subprocesses
with live widgets, conversation continuation, and LLM-accessible tools.
2026-02-28 19:30:25 +03:00
Ed Zynda eeecd5a843 feat: add kit-kit meta-agent extension example
Port of Pi Pi (meta-agent with parallel expert subprocesses) to Kit's
extension system. Includes expert grid widget, query_experts tool,
custom footer, tool renderer, and orchestrator system prompt injection.

Also updates AGENTS.md with Yaegi gotchas, BubbleTea patterns, testing
recipes, and extension architecture notes.

Fixes golangci-lint issues: modernize min/max in overlay.go, replace
deprecated GetExtRunner() with new GetExtensionContext() SDK method,
remove broken --model flag from expert subprocess.
2026-02-28 19:01:07 +03:00
Ed Zynda 7747fc2033 fix: keep vim interceptor active in both modes so Esc returns to normal
The interceptor now stays installed for the entire vim session, handling
both normal and insert modes. Esc switches from insert back to normal,
and /vim toggles the entire interceptor on/off.
2026-02-28 17:54:28 +03:00
Ed Zynda 864230bd0a feat: add editor interceptor system for extensions
Extensions can now intercept key events and wrap the editor's rendered
output via ctx.SetEditor/ctx.ResetEditor, enabling vim-like modal
editing, custom key bindings, and visual decorators.

Key fixes during development:
- Yaegi requires closure wrappers for struct function fields (bare
  function references return zero values across the interpreter boundary)
- SetEditor/ResetEditor use async NotifyWidgetUpdate to avoid deadlocking
  BubbleTea's event loop when called from HandleKey callbacks
- distributeHeight now uses renderInput() to account for interceptor
  Render wrapper in height calculations
2026-02-28 17:46:41 +03:00
Ed Zynda 0de0040e63 feat: add modal overlay dialog system for extensions
Add ctx.ShowOverlay() API that displays modal dialogs with optional
scrollable content, markdown rendering, action buttons, and configurable
positioning. Follows the same channel-based blocking pattern as prompts,
with full Yaegi compatibility via concrete structs.
2026-02-28 16:48:09 +03:00
Ed Zynda 98efaae960 feat: add custom tool rendering for extensions
Extensions can now override how tool calls are displayed in the TUI via
API.RegisterToolRenderer(). Supports custom display name, border color,
background color, header parameter formatting, body rendering, and
optional markdown processing of custom body output.
2026-02-28 16:23:45 +03:00
Ed Zynda 53ae47a1bd feat: add custom header/footer regions for extensions
Extensions can now place persistent header (above stream) and footer
(below status bar) regions via ctx.SetHeader/SetFooter. Single-instance
per slot, reuses WidgetContent/WidgetStyle types and WidgetUpdateEvent
for notifications. Includes thread-safe Runner storage, SDK methods,
UI rendering with height distribution, and example extension.
2026-02-28 14:11:52 +03:00
Ed Zynda 584b215803 feat: add interactive prompt system for extensions (select, confirm, input)
Extensions can now show modal prompts to the user via ctx.PromptSelect,
ctx.PromptConfirm, and ctx.PromptInput. Prompts render inline below the
separator (replacing the input area) and use channel-based sync so the
extension blocks until the user responds. Extension slash commands run in
dedicated goroutines to avoid stalling BubbleTea's Cmd scheduler.
2026-02-28 13:54:00 +03:00
Ed Zynda 3009b5530b feat: add extension widget slots for persistent TUI content
Add a declarative widget system that lets extensions place persistent
content above or below the input area. Widgets survive across agent
turns and are updated via ctx.SetWidget/ctx.RemoveWidget from any
event handler.

All types are concrete structs (Yaegi-safe, no interfaces cross the
interpreter boundary). Widget state lives on the Runner with mutex
protection, and WidgetUpdateEvent triggers BubbleTea re-renders.
2026-02-28 12:16:20 +03:00
Ed Zynda 1309c4bd12 fix: clear orphaned terminal rows after flushing stream content
Issue ClearScreen after tea.Println in flushStreamContent() to force a
full terminal redraw when the view height shrinks. Mirrors pi's
clearOnShrink mechanism — bubbletea's inline renderer doesn't clear
rows below the managed region when the frame gets shorter.
2026-02-28 02:06:39 +03:00
Ed Zynda 2a829fb98f cleanup 2026-02-28 01:01:19 +03:00
Ed Zynda ad07086900 cleanup 2026-02-28 01:01:12 +03:00
Ed Zynda 596eeede2f docs: fix Discord badge to use static label instead of placeholder server ID 2026-02-27 18:51:46 +03:00
Ed Zynda 879ec65609 docs: add CI, release, npm, Go, license, and Discord badges 2026-02-27 18:49:34 +03:00
Ed Zynda 2fce8731e1 docs: strip README to title and logo, mark body as TBD 2026-02-27 18:48:43 +03:00
90 changed files with 11359 additions and 5267 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 }}
+47
View File
@@ -39,6 +39,53 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- Multi-provider LLM support via `llm.Provider` interface
- MCP client-server for tool integration
- Builtin servers: bash, fetch, todo, fs
- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors
- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel``InputComponent`, `StreamComponent`, etc.)
- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly
## Key Patterns
### Yaegi (Extension Interpreter) Gotchas
- **No interfaces across boundary**: All extension-facing API types must be concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
- **Function field bug**: Named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
```go
// WRONG: ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
// RIGHT: ctx.SetEditor(ext.EditorConfig{HandleKey: func(k, t string) ext.EditorKeyAction { return myHandler(k, t) }})
```
- **Symbol exports**: Every new type exposed to extensions must be added to `internal/extensions/symbols.go`
### BubbleTea Integration
- **No `prog.Send()` from inside `Update()`**: Calling `prog.Send()` synchronously within a BubbleTea `Update()` handler deadlocks the event loop. Use `go appInstance.NotifyWidgetUpdate()` (async goroutine) instead.
- **Height measurement**: `distributeHeight()` in `model.go` must measure using the same render path as `View()`. If an interceptor wraps rendering, measure with the wrapper too, or layout will mismatch.
- **Channel-based prompts**: Extension prompt calls (PromptSelect, etc.) block on a `chan PromptResponse`. Extension slash commands run in dedicated goroutines (not `tea.Cmd`) to avoid stalling BubbleTea's Cmd scheduler.
### Extension State Management
- **Thread-safe maps on Runner**: Widget/header/footer/editor state lives on the Runner with `sync.RWMutex`, queried by UI via callbacks
- **Context function fields**: The `Context` struct uses function fields (`Print func(string)`, `SetWidget func(WidgetConfig)`) wired by closures in `cmd/root.go`
- **Package-level vars in extensions**: Yaegi supports package-level variables captured in closures — this is how extensions maintain state across event callbacks
### Unicode in Widget Text
- Widget content renders through `lipgloss.Style.Render()` which preserves ANSI escape codes
- Use rune-based width calculations (`len([]rune(s))`) not byte length (`len(s)`) when aligning box-drawing characters or multi-byte symbols
## Testing
### Interactive TUI Testing with tmux
Use tmux to test Kit interactively without blocking the agent:
```bash
tmux new-session -d -s kittest -x 120 -y 40 "output/kit -e examples/extensions/my-ext.go --no-session 2>kit_stderr.log"
sleep 3
tmux capture-pane -t kittest -p # read screen
tmux send-keys -t kittest '/command' Enter # send input
tmux kill-session -t kittest # cleanup
```
### Non-Interactive Kit (Subprocess Spawning)
Extensions can spawn Kit as a subprocess for sub-agent patterns:
```bash
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
## External Repo Research
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
+414 -744
View File
File diff suppressed because it is too large Load Diff
+622 -80
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -10,10 +11,10 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/agent"
"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"
@@ -30,6 +31,7 @@ var (
debugMode bool
promptFlag string
quietFlag bool
jsonFlag bool
noExitFlag bool
maxSteps int
streamFlag bool // Enable streaming output
@@ -63,34 +65,35 @@ var (
tlsSkipVerify bool
)
// agentUIAdapter adapts agent.Agent to ui.AgentInterface
type agentUIAdapter struct {
agent *agent.Agent
// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer
// can display tool/server metadata without importing internal types.
type kitUIAdapter struct {
kit *kit.Kit
}
func (a *agentUIAdapter) GetLoadingMessage() string {
return a.agent.GetLoadingMessage()
func (a *kitUIAdapter) GetLoadingMessage() string {
return a.kit.GetLoadingMessage()
}
func (a *agentUIAdapter) GetTools() []any {
tools := a.agent.GetTools()
result := make([]any, len(tools))
for i, tool := range tools {
result[i] = tool
func (a *kitUIAdapter) GetTools() []any {
names := a.kit.GetToolNames()
result := make([]any, len(names))
for i, name := range names {
result[i] = name
}
return result
}
func (a *agentUIAdapter) GetLoadedServerNames() []string {
return a.agent.GetLoadedServerNames()
func (a *kitUIAdapter) GetLoadedServerNames() []string {
return a.kit.GetLoadedServerNames()
}
func (a *agentUIAdapter) GetMCPToolCount() int {
return a.agent.GetMCPToolCount()
func (a *kitUIAdapter) GetMCPToolCount() int {
return a.kit.GetMCPToolCount()
}
func (a *agentUIAdapter) GetExtensionToolCount() int {
return a.agent.GetExtensionToolCount()
func (a *kitUIAdapter) GetExtensionToolCount() int {
return a.kit.GetExtensionToolCount()
}
// rootCmd represents the base command when called without any subcommands.
@@ -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().
@@ -280,11 +285,8 @@ func runKit(ctx context.Context) error {
// ui.ExtensionCommand type used by the interactive TUI. Command names are
// normalised to start with "/" so they integrate with the slash-command
// autocomplete and dispatch pipeline.
func extensionCommandsForUI(runner *extensions.Runner) []ui.ExtensionCommand {
if runner == nil {
return nil
}
defs := runner.RegisteredCommands()
func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
defs := k.ExtensionCommands()
if len(defs) == 0 {
return nil
}
@@ -294,22 +296,240 @@ func extensionCommandsForUI(runner *extensions.Runner) []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, runner.GetContext())
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
}
// widgetProviderForUI returns a function that converts extension widgets to
// ui.WidgetData for the given placement. Returns nil if extensions are
// disabled, which is safe — the UI treats a nil GetWidgets as "no widgets".
func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
if !k.HasExtensions() {
return nil
}
return func(placement string) []ui.WidgetData {
configs := k.GetExtensionWidgets(extensions.WidgetPlacement(placement))
if len(configs) == 0 {
return nil
}
widgets := make([]ui.WidgetData, len(configs))
for i, c := range configs {
widgets[i] = ui.WidgetData{
Text: c.Content.Text,
Markdown: c.Content.Markdown,
BorderColor: c.Style.BorderColor,
NoBorder: c.Style.NoBorder,
}
}
return widgets
}
}
// headerProviderForUI returns a function that converts the extension header
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
// which is safe — the UI treats a nil GetHeader as "no header".
func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
if !k.HasExtensions() {
return nil
}
return func() *ui.WidgetData {
config := k.GetExtensionHeader()
if config == nil {
return nil
}
return &ui.WidgetData{
Text: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
NoBorder: config.Style.NoBorder,
}
}
}
// toolRendererProviderForUI returns a function that converts extension tool
// renderers to ui.ToolRendererData for the TUI. Returns nil if extensions are
// disabled, which is safe — the UI treats a nil GetToolRenderer as "no
// custom renderers".
func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData {
if !k.HasExtensions() {
return nil
}
return func(toolName string) *ui.ToolRendererData {
config := k.GetExtensionToolRenderer(toolName)
if config == nil {
return nil
}
return &ui.ToolRendererData{
DisplayName: config.DisplayName,
BorderColor: config.BorderColor,
Background: config.Background,
BodyMarkdown: config.BodyMarkdown,
RenderHeader: config.RenderHeader,
RenderBody: config.RenderBody,
}
}
}
// editorInterceptorProviderForUI returns a function that converts the
// extension editor interceptor to a *ui.EditorInterceptor for the TUI.
// Returns nil if extensions are disabled, which is safe — the UI treats a
// nil GetEditorInterceptor as "no interceptor".
func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
if !k.HasExtensions() {
return nil
}
return func() *ui.EditorInterceptor {
config := k.GetExtensionEditor()
if config == nil {
return nil
}
var handleKey func(string, string) ui.EditorKeyAction
if config.HandleKey != nil {
extHandleKey := config.HandleKey
handleKey = func(key, text string) ui.EditorKeyAction {
r := extHandleKey(key, text)
return ui.EditorKeyAction{
Type: ui.EditorKeyActionType(r.Type),
RemappedKey: r.RemappedKey,
SubmitText: r.SubmitText,
}
}
}
var render func(int, string) string
if config.Render != nil {
extRender := config.Render
render = func(width int, defaultContent string) string {
return extRender(width, defaultContent)
}
}
return &ui.EditorInterceptor{
HandleKey: handleKey,
Render: render,
}
}
}
// 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".
func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
if !k.HasExtensions() {
return nil
}
return func() *ui.WidgetData {
config := k.GetExtensionFooter()
if config == nil {
return nil
}
return &ui.WidgetData{
Text: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
NoBorder: config.Style.NoBorder,
}
}
}
// 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")
}
@@ -346,16 +566,18 @@ func runNormalMode(ctx context.Context) error {
// Build Kit options from CLI flags and create the SDK instance.
// kit.New() handles: config → skills → agent → session → extension bridge.
kitOpts := &kit.Options{
MCPConfig: mcpConfig,
ShowSpinner: true,
SpinnerFunc: spinnerFunc,
UseBufferedLogger: true,
Quiet: quietFlag,
Debug: debugMode,
NoSession: noSessionFlag,
Continue: continueFlag,
SessionPath: sessionPath,
AutoCompact: autoCompactFlag,
Quiet: quietFlag,
Debug: debugMode,
NoSession: noSessionFlag,
Continue: continueFlag,
SessionPath: sessionPath,
AutoCompact: autoCompactFlag,
CLI: &kit.CLIOptions{
MCPConfig: mcpConfig,
ShowSpinner: true,
SpinnerFunc: spinnerFunc,
UseBufferedLogger: true,
},
}
if resumeFlag {
// TODO: TUI session picker.
@@ -371,27 +593,23 @@ func runNormalMode(ctx context.Context) error {
}
defer func() { _ = kitInstance.Close() }()
// Extract agent + metadata for display and app options.
mcpAgent := kitInstance.GetAgent()
parsedProvider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount := CollectAgentMetadata(mcpAgent, mcpConfig)
// Extract metadata for display and app options.
parsedProvider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount := CollectAgentMetadata(kitInstance, mcpConfig)
// Create CLI for non-interactive mode only.
var cli *ui.CLI
if promptFlag != "" {
cli, err = SetupCLIForNonInteractive(mcpAgent)
cli, err = SetupCLIForNonInteractive(kitInstance)
if err != nil {
return fmt.Errorf("failed to setup CLI: %v", err)
}
// Display buffered debug messages if any (non-interactive path only).
if bl := kitInstance.GetBufferedLogger(); bl != nil && cli != nil {
msgs := bl.GetMessages()
if len(msgs) > 0 {
cli.DisplayDebugMessage(strings.Join(msgs, "\n "))
}
if msgs := kitInstance.GetBufferedDebugMessages(); len(msgs) > 0 && cli != nil {
cli.DisplayDebugMessage(strings.Join(msgs, "\n "))
}
DisplayDebugConfig(cli, mcpAgent, mcpConfig, parsedProvider)
DisplayDebugConfig(cli, kitInstance, mcpConfig, parsedProvider)
}
// Load existing messages from resumed/continued sessions.
@@ -402,7 +620,6 @@ func runNormalMode(ctx context.Context) error {
}
// Create the app.App instance.
extRunner := kitInstance.GetExtRunner()
appOpts := BuildAppOptions(mcpConfig, modelName, serverNames, toolNames)
appOpts.Kit = kitInstance
appOpts.TreeSession = treeSession
@@ -423,25 +640,233 @@ func runNormalMode(ctx context.Context) error {
defer appInstance.Close()
// Set up extension context and emit SessionStart.
if extRunner != nil {
if kitInstance.HasExtensions() {
cwd, _ := os.Getwd()
extRunner.SetContext(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) },
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) },
CancelAndSend: func(text string) { appInstance.Steer(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
appInstance.NotifyWidgetUpdate()
},
RemoveWidget: func(id string) {
kitInstance.RemoveExtensionWidget(id)
appInstance.NotifyWidgetUpdate()
},
SetHeader: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionHeader(config)
appInstance.NotifyWidgetUpdate()
},
RemoveHeader: func() {
kitInstance.RemoveExtensionHeader()
appInstance.NotifyWidgetUpdate()
},
SetFooter: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionFooter(config)
appInstance.NotifyWidgetUpdate()
},
RemoveFooter: func() {
kitInstance.RemoveExtensionFooter()
appInstance.NotifyWidgetUpdate()
},
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "select",
Message: config.Message,
Options: config.Options,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptSelectResult{Cancelled: true}
}
return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index}
},
PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
ch := make(chan app.PromptResponse, 1)
def := "false"
if config.DefaultValue {
def = "true"
}
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "confirm",
Message: config.Message,
Default: def,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptConfirmResult{Cancelled: true}
}
return extensions.PromptConfirmResult{Value: resp.Confirmed}
},
PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "input",
Message: config.Message,
Placeholder: config.Placeholder,
Default: config.Default,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptInputResult{Cancelled: true}
}
return extensions.PromptInputResult{Value: resp.Value}
},
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
// called from within an editor HandleKey callback, which runs
// synchronously inside BubbleTea's Update(). Calling prog.Send()
// directly from Update() deadlocks the event loop.
go appInstance.NotifyWidgetUpdate()
},
ResetEditor: func() {
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{
Title: config.Title,
Content: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
Background: config.Style.Background,
Width: config.Width,
MaxHeight: config.MaxHeight,
Anchor: string(config.Anchor),
Actions: config.Actions,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.OverlayResult{Cancelled: true, Index: -1}
}
return extensions.OverlayResult{
Action: resp.Action,
Index: resp.Index,
}
},
})
if extRunner.HasHandlers(extensions.SessionStart) {
_, _ = extRunner.Emit(extensions.SessionStartEvent{})
}
kitInstance.EmitSessionStart()
}
// Convert extension commands to UI-layer type for the interactive TUI.
extCommands := extensionCommandsForUI(extRunner)
extCommands := extensionCommandsForUI(kitInstance)
// Build context/skills display metadata for the startup banner.
var contextPaths []string
@@ -462,9 +887,24 @@ func runNormalMode(ctx context.Context) error {
})
}
// Build extension UI providers once (shared between both modes).
getWidgets := widgetProviderForUI(kitInstance)
getHeader := headerProviderForUI(kitInstance)
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, mcpAgent.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems)
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
@@ -472,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, mcpAgent.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems)
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,
@@ -485,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) 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
@@ -511,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)
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:
@@ -528,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) 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 {
@@ -536,21 +1065,34 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
termHeight = 24
}
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
ContextPaths: contextPaths,
SkillItems: skillItems,
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
ContextPaths: contextPaths,
SkillItems: skillItems,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
GetUIVisibility: getUIVisibility,
GetStatusBarEntries: getStatusBarEntries,
EmitBeforeFork: emitBeforeFork,
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
GetGlobalShortcuts: getGlobalShortcuts,
GetExtensionCommands: getExtensionCommands,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
+9 -14
View File
@@ -3,7 +3,6 @@ package cmd
import (
"strings"
"github.com/mark3labs/kit/internal/agent"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/ui"
@@ -12,9 +11,9 @@ import (
)
// CollectAgentMetadata extracts model display info and tool/server name lists
// from the agent, used to populate app.Options and UI setup.
// from the Kit instance, used to populate app.Options and UI setup.
// It also returns the number of MCP tools and extension tools separately.
func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) {
func CollectAgentMetadata(k *kit.Kit, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) {
modelString := viper.GetString("model")
provider, modelName, _ = kit.ParseModelString(modelString)
if modelName == "" {
@@ -25,13 +24,9 @@ func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (prov
serverNames = append(serverNames, name)
}
for _, tool := range mcpAgent.GetTools() {
info := tool.Info()
toolNames = append(toolNames, info.Name)
}
mcpToolCount = mcpAgent.GetMCPToolCount()
extensionToolCount = mcpAgent.GetExtensionToolCount()
toolNames = k.GetToolNames()
mcpToolCount = k.GetMCPToolCount()
extensionToolCount = k.GetExtensionToolCount()
return provider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount
}
@@ -52,7 +47,7 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to
// DisplayDebugConfig builds and displays the debug configuration map through
// the CLI for non-interactive mode.
func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Config, provider string) {
func DisplayDebugConfig(cli *ui.CLI, k *kit.Kit, mcpConfig *config.Config, provider string) {
if quietFlag || cli == nil || !viper.GetBool("debug") {
return
}
@@ -89,7 +84,7 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co
if len(mcpConfig.MCPServers) > 0 {
mcpServers := make(map[string]any)
loadedServerSet := make(map[string]bool)
for _, name := range mcpAgent.GetLoadedServerNames() {
for _, name := range k.GetLoadedServerNames() {
loadedServerSet[name] = true
}
@@ -130,8 +125,8 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co
// SetupCLIForNonInteractive creates the CLI display layer for non-interactive
// mode (--prompt). Returns nil when quiet mode is active.
func SetupCLIForNonInteractive(mcpAgent *agent.Agent) (*ui.CLI, error) {
agentAdapter := &agentUIAdapter{agent: mcpAgent}
func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) {
agentAdapter := &kitUIAdapter{kit: k}
return ui.SetupCLI(&ui.CLISetupOptions{
Agent: agentAdapter,
ModelString: viper.GetString("model"),
+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
},
})
}
+137
View File
@@ -0,0 +1,137 @@
//go:build ignore
package main
import (
"fmt"
"strings"
"kit/ext"
)
// vimActive tracks whether the vim interceptor is installed at all.
// normalMode tracks whether we are in normal mode (true) or insert mode (false).
var vimActive bool
var normalMode bool
// Init demonstrates the editor interceptor system. Extensions can intercept
// key events before they reach the built-in editor and wrap the editor's
// rendered output. This example implements a simple vim-like modal editor
// with normal/insert mode switching.
//
// Slash commands:
// - /vim — toggle vim mode on/off
// - /vim-info — show current editor mode
func Init(api ext.API) {
// /vim — toggle the vim interceptor on/off.
api.RegisterCommand(ext.CommandDef{
Name: "vim",
Description: "Toggle vim-like modal editing",
Execute: func(args string, ctx ext.Context) (string, error) {
if vimActive {
// Turn off vim mode entirely.
vimActive = false
normalMode = false
ctx.ResetEditor()
return "Vim mode OFF. Default editor restored.", nil
}
// Turn on vim mode, start in normal mode.
vimActive = true
normalMode = true
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
return handleVimKey(key, currentText)
},
Render: func(width int, defaultContent string) string {
return renderVimMode(width, defaultContent)
},
})
return "Vim mode ON (NORMAL). Press 'i' to insert, Esc to return to normal, h/j/k/l to navigate.", nil
},
})
// /vim-info — show the current editor mode.
api.RegisterCommand(ext.CommandDef{
Name: "vim-info",
Description: "Show current vim mode",
Execute: func(args string, ctx ext.Context) (string, error) {
if !vimActive {
return "Vim mode is OFF (default editor).", nil
}
if normalMode {
return "Vim mode ON — NORMAL mode", nil
}
return "Vim mode ON — INSERT mode (Esc to return to normal)", nil
},
})
}
// handleVimKey processes keys for both normal and insert modes.
// The interceptor stays active in both modes so Esc can switch back.
func handleVimKey(key string, currentText string) ext.EditorKeyAction {
if !normalMode {
// ── Insert mode: pass everything through except Esc ──
if key == "esc" {
normalMode = true
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
// ── Normal mode ──
switch key {
// Navigation: remap hjkl to arrow keys.
case "h":
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "left"}
case "j":
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "down"}
case "k":
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}
case "l":
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "right"}
// Mode switching.
case "i":
normalMode = false
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
// Editing shortcuts.
case "x":
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "delete"}
case "0":
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "home"}
case "$":
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "end"}
// Submission.
case "enter":
if strings.TrimSpace(currentText) != "" {
return ext.EditorKeyAction{Type: ext.EditorKeySubmit}
}
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
// Block most printable keys in normal mode.
default:
// Let control sequences and special keys through (e.g., ctrl+c).
if len(key) > 1 && key != "space" {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
}
}
// renderVimMode wraps the default editor rendering with a mode indicator.
func renderVimMode(width int, defaultContent string) string {
mode := "-- NORMAL --"
if !normalMode {
mode = "-- INSERT --"
}
indicator := fmt.Sprintf(" %s", mode)
padding := width - len(indicator)
if padding > 0 {
indicator += strings.Repeat(" ", padding)
}
return indicator + "\n" + defaultContent
}
+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))
})
}
+120
View File
@@ -0,0 +1,120 @@
//go:build ignore
package main
import (
"fmt"
"time"
"kit/ext"
)
// Init demonstrates the custom header/footer system. The header shows
// project context (branch, CWD) and the footer shows a running summary
// of agent activity. Slash commands toggle them on/off.
func Init(api ext.API) {
var turnCount int
var lastResponse string
// Show a custom header with project context when the session starts.
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
},
Style: ext.WidgetStyle{
BorderColor: "#89b4fa",
},
})
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{
Text: "Ready | 0 turns",
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1",
},
})
})
// Update footer after each agent turn with activity summary.
api.OnAgentEnd(func(ae ext.AgentEndEvent, ctx ext.Context) {
turnCount++
lastResponse = ae.Response
if len(lastResponse) > 60 {
lastResponse = lastResponse[:57] + "..."
}
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{
Text: fmt.Sprintf("Turns: %d | Last: %s | %s",
turnCount, ae.StopReason, time.Now().Format("15:04:05")),
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1",
},
})
})
// /header-off — remove the custom header.
api.RegisterCommand(ext.CommandDef{
Name: "header-off",
Description: "Remove the custom header",
Execute: func(_ string, ctx ext.Context) (string, error) {
ctx.RemoveHeader()
return "Header removed.", nil
},
})
// /header-on — restore the custom header.
api.RegisterCommand(ext.CommandDef{
Name: "header-on",
Description: "Restore the custom header",
Execute: func(_ string, ctx ext.Context) (string, error) {
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
},
Style: ext.WidgetStyle{
BorderColor: "#89b4fa",
},
})
return "Header restored.", nil
},
})
// /footer-off — remove the custom footer.
api.RegisterCommand(ext.CommandDef{
Name: "footer-off",
Description: "Remove the custom footer",
Execute: func(_ string, ctx ext.Context) (string, error) {
ctx.RemoveFooter()
return "Footer removed.", nil
},
})
// /footer-on — restore the custom footer.
api.RegisterCommand(ext.CommandDef{
Name: "footer-on",
Description: "Restore the custom footer",
Execute: func(_ string, ctx ext.Context) (string, error) {
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{
Text: fmt.Sprintf("Turns: %d | %s", turnCount, time.Now().Format("15:04:05")),
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1",
},
})
return "Footer restored.", nil
},
})
// Clean up on shutdown.
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
ctx.RemoveHeader()
ctx.RemoveFooter()
})
}
+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
},
})
}
@@ -0,0 +1,36 @@
---
name: ext-expert
description: Kit extensions — tools, events, commands, widgets, editor interceptors
tools: read,grep,glob
---
You are an expert on Kit's extension system. Your job is to research and answer questions about how Kit extensions work.
## Key Files
- `internal/extensions/api.go` — Extension API surface, Context struct, all types
- `internal/extensions/runner.go` — Event dispatch, extension registry, widget/header/footer storage
- `internal/extensions/loader.go` — Yaegi interpreter setup, extension loading
- `internal/extensions/symbols.go` — Yaegi symbol exports
- `internal/extensions/events.go` — Event type definitions
- `examples/extensions/` — Example extensions demonstrating all features
## Architecture
Kit extensions are Go files interpreted at runtime by Yaegi. Each extension exports `func Init(api ext.API)` and uses the API to register:
- **Event handlers**: OnSessionStart, OnToolCall, OnToolResult, OnInput, OnAgentEnd, etc.
- **Custom tools**: ToolDef with name, description, JSON Schema parameters, Execute function
- **Slash commands**: CommandDef with name, description, Execute function (receives Context)
- **Tool renderers**: ToolRenderConfig with custom RenderHeader/RenderBody
- **Widgets**: ctx.SetWidget/RemoveWidget for persistent UI elements
- **Headers/Footers**: ctx.SetHeader/SetFooter for chrome customization
- **Editor interceptors**: ctx.SetEditor for key interception and render wrapping
- **Prompts/Overlays**: ctx.PromptSelect/PromptConfirm/PromptInput/ShowOverlay
## Critical Yaegi Limitations
- All function fields in structs must be anonymous closures, NOT named function references
- No interfaces exported to extensions — only concrete structs
- Extensions run in isolated interpreters with stdlib + os/exec access
When answering, cite specific file paths and line numbers. Provide concrete code examples.
@@ -0,0 +1,34 @@
---
name: llm-expert
description: Kit LLM system — providers, streaming, agent loop, tool execution
tools: read,grep,glob
---
You are an expert on Kit's LLM integration and agent system. Your job is to research and answer questions about how Kit communicates with language models and runs the agent loop.
## Key Files
- `internal/llm/provider.go` — Provider interface definition
- `internal/llm/anthropic/` — Anthropic Claude provider
- `internal/llm/openai/` — OpenAI-compatible provider (also used for Ollama)
- `internal/llm/google/` — Google Gemini provider
- `internal/agent/agent.go` — Agent loop: prompt -> LLM -> tool calls -> repeat
- `internal/agent/tools.go` — Tool registry, built-in tool definitions
- `internal/app/app.go` — App layer: RunOnce, RunOnceWithDisplay, event routing
- `pkg/kit/kit.go` — SDK: New(), configuration, extension management
## Architecture
Kit supports multiple LLM providers through the `llm.Provider` interface. The model flag format is `provider/model-name` (e.g., `anthropic/claude-sonnet-4-5`).
The agent loop in `internal/agent/` follows a standard ReAct pattern:
1. Send conversation history + system prompt to LLM
2. LLM responds with text and/or tool calls
3. Execute tool calls (MCP servers + extension tools)
4. Append tool results to conversation
5. Repeat until LLM produces a final text response (no tool calls)
Tool execution goes through MCP (Model Context Protocol) client-server architecture. Built-in MCP servers provide bash, file system, fetch, and todo tools.
The App layer (`internal/app/`) manages the lifecycle: creating the agent, routing events to the UI or CLI renderer, handling cancellation, and coordinating with extensions.
When answering, cite specific file paths and line numbers. Provide concrete code examples.
@@ -0,0 +1,27 @@
---
name: orchestrator
description: Kit Kit orchestrator system prompt template
---
You are Kit Kit, an orchestrator agent with {{EXPERT_COUNT}} domain experts: {{EXPERT_NAMES}}.
Your role is to coordinate these experts to research Kit's codebase and then synthesize their findings into working implementations.
## Available Experts
{{EXPERT_CATALOG}}
## Workflow
1. **Analyze** the user's request to identify which domains are relevant.
2. **Query** the relevant experts IN PARALLEL using the `query_experts` tool. Ask specific, targeted questions.
3. **Synthesize** the expert findings into a coherent understanding.
4. **Implement** — you are the ONLY agent that writes files. Experts are read-only researchers.
## Rules
- ALWAYS query experts before implementing. Never guess about Kit internals.
- Ask SPECIFIC questions: "How does SetWidget update the UI?" beats "Tell me about widgets."
- Query MULTIPLE experts in a single tool call when the task spans domains (they run in parallel).
- If an expert's answer is insufficient, query again with a more targeted question.
- Cite the file paths and patterns from expert responses in your implementation.
- When writing Kit extensions, remember the Yaegi closure wrapper pattern for all function fields.
@@ -0,0 +1,38 @@
---
name: tui-expert
description: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
tools: read,grep,glob
---
You are an expert on Kit's terminal user interface. Your job is to research and answer questions about how Kit's TUI works.
## Key Files
- `internal/ui/model.go` — AppModel root component, View(), Update(), key handling, layout
- `internal/ui/input.go` — InputComponent wrapping textarea + autocomplete
- `internal/ui/overlay.go` — Modal overlay dialogs
- `internal/ui/prompt.go` — Interactive prompt overlays (select, confirm, input)
- `internal/ui/messages.go` — MessageRenderer for streaming messages
- `internal/ui/compact_renderer.go` — CompactRenderer for compact mode
- `internal/ui/block_renderer.go` — renderContentBlock() with functional options
- `internal/ui/theme.go` — Catppuccin-based theming (GetTheme)
- `internal/ui/commands.go` — ExtensionCommand type, slash command registry
- `internal/ui/model_test.go` — Tests with stubAppController mock
## Architecture
Kit uses Bubble Tea v2 for the TUI. The component hierarchy:
- **AppModel** — root component managing layout, key routing, and child components
- **InputComponent** — text area with autocomplete popup
- **StreamComponent** — streaming message display
- **TreeSelectorComponent** — session/model picker
- **promptOverlay** — interactive prompts (select, confirm, input)
- **overlayDialog** — modal overlay dialogs
Layout (top to bottom): header, stream, separator, widgets-above, input, widgets-below, footer, status bar.
Rendering uses lipgloss for styling with the Catppuccin Mocha color palette. Content blocks use `renderContentBlock()` with functional options for border, padding, background, and alignment.
Extension widgets integrate via callback functions (getWidgets, getHeader, getFooter) that query the extension runner through the SDK layer, keeping the UI decoupled from extensions.
When answering, cite specific file paths and line numbers. Provide concrete code examples.
+870
View File
@@ -0,0 +1,870 @@
//go:build ignore
// Kit Kit — Meta-agent that builds Kit agents
//
// A team of domain-specific research experts operate IN PARALLEL to gather
// documentation and patterns. The primary agent synthesizes their findings
// and WRITES the actual files.
//
// Each expert runs as a separate `kit` subprocess with a domain-specific
// system prompt. Experts are read-only researchers; the primary agent is
// the only writer.
//
// Commands:
//
// /experts — list available experts and their status
// /experts-grid N — set dashboard column count (default 3)
//
// Usage: kit -e examples/extensions/kit-kit.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"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
// ---------------------------------------------------------------------------
type expertDef struct {
Name string
Description string
Tools string
System string // system prompt body
File string
}
type expertState struct {
Def expertDef
Status string // "idle", "researching", "done", "error"
Question string
Elapsed time.Duration
LastLine string
QueryCount int
mu sync.Mutex
}
func (s *expertState) set(status, question, lastLine string, elapsed time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
if status != "" {
s.Status = status
}
if question != "" {
s.Question = question
}
if lastLine != "" {
s.LastLine = lastLine
}
if elapsed > 0 {
s.Elapsed = elapsed
}
}
func (s *expertState) snapshot() (string, string, string, time.Duration, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.Status, s.Question, s.LastLine, s.Elapsed, s.QueryCount
}
// ---------------------------------------------------------------------------
// Package-level state
// ---------------------------------------------------------------------------
var (
mu sync.Mutex
experts = map[string]*expertState{}
gridCols = 3
latestCtx ext.Context
hasCtx bool
kitBinary string // resolved path to kit executable
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func displayName(name string) string {
parts := strings.Split(name, "-")
for i, w := range parts {
if len(w) > 0 {
parts[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(parts, " ")
}
func runeWidth(s string) int {
return len([]rune(s))
}
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
if max < 4 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}
func pad(s string, width int) string {
w := runeWidth(s)
if w >= width {
return string([]rune(s)[:width])
}
return s + strings.Repeat(" ", width-w)
}
// parseAgentFile reads a .md file with YAML-like frontmatter.
//
// ---
// name: ext-expert
// description: Extensions documentation
// tools: read,grep,glob
// ---
// System prompt body here ...
func parseAgentFile(path string) *expertDef {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
text := string(raw)
// Must start with "---\n"
if !strings.HasPrefix(text, "---\n") {
return nil
}
rest := text[4:]
idx := strings.Index(rest, "\n---\n")
if idx < 0 {
return nil
}
frontmatter := rest[:idx]
body := strings.TrimSpace(rest[idx+5:])
fm := map[string]string{}
for _, line := range strings.Split(frontmatter, "\n") {
i := strings.Index(line, ":")
if i > 0 {
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
}
}
if fm["name"] == "" {
return nil
}
return &expertDef{
Name: fm["name"],
Description: fm["description"],
Tools: fm["tools"],
System: body,
File: path,
}
}
func loadExperts(cwd string) {
mu.Lock()
defer mu.Unlock()
experts = map[string]*expertState{}
dir := filepath.Join(cwd, ".kit", "agents", "kit-kit")
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
if e.Name() == "orchestrator.md" {
continue
}
def := parseAgentFile(filepath.Join(dir, e.Name()))
if def == nil {
continue
}
key := strings.ToLower(def.Name)
experts[key] = &expertState{
Def: *def,
Status: "idle",
}
}
}
func expertList() []*expertState {
mu.Lock()
defer mu.Unlock()
list := make([]*expertState, 0, len(experts))
for _, s := range experts {
list = append(list, s)
}
return list
}
func expertNames() string {
list := expertList()
names := make([]string, len(list))
for i, s := range list {
names[i] = displayName(s.Def.Name)
}
return strings.Join(names, ", ")
}
// ---------------------------------------------------------------------------
// Widget grid rendering
// ---------------------------------------------------------------------------
func renderCard(s *expertState, w int) []string {
status, question, lastLine, elapsed, queryCount := s.snapshot()
inner := w - 2 // inside the box-drawing borders
// Name line
name := truncate(displayName(s.Def.Name), inner-1)
// Status line
var icon string
switch status {
case "idle":
icon = "○"
case "researching":
icon = "◉"
case "done":
icon = "✓"
default:
icon = "✗"
}
statusText := icon + " " + status
if status != "idle" {
statusText += fmt.Sprintf(" %ds", int(elapsed.Seconds()))
}
if queryCount > 0 {
statusText += fmt.Sprintf(" (%d)", queryCount)
}
statusText = truncate(statusText, inner-1)
// Work line (question or description)
work := question
if work == "" {
work = s.Def.Description
}
work = truncate(work, inner-1)
// Last output line
last := lastLine
if last == "" {
last = "—"
}
last = truncate(last, inner-1)
// Build card (use rune width for box-drawing alignment)
topBar := "─ " + name + " "
if runeWidth(topBar) < inner {
topBar += strings.Repeat("─", inner-runeWidth(topBar))
}
return []string{
"┌" + truncate(topBar, inner) + "┐",
"│ " + pad(statusText, inner-1) + "│",
"│ " + pad(work, inner-1) + "│",
"│ " + pad(last, inner-1) + "│",
"└" + strings.Repeat("─", inner) + "┘",
}
}
func buildGrid() string {
list := expertList()
if len(list) == 0 {
return "No experts found. Add agent .md files to .kit/agents/kit-kit/"
}
cols := gridCols
if cols > len(list) {
cols = len(list)
}
// Card width: aim for ~28 chars per card
cardWidth := 28
gap := 1
var lines []string
for i := 0; i < len(list); i += cols {
end := i + cols
if end > len(list) {
end = len(list)
}
row := list[i:end]
// Render each card in this row
cards := make([][]string, len(row))
maxHeight := 0
for j, s := range row {
cards[j] = renderCard(s, cardWidth)
if len(cards[j]) > maxHeight {
maxHeight = len(cards[j])
}
}
// Merge columns line by line
for line := 0; line < maxHeight; line++ {
var parts []string
for _, card := range cards {
if line < len(card) {
parts = append(parts, card[line])
} else {
parts = append(parts, strings.Repeat(" ", cardWidth))
}
}
lines = append(lines, strings.Join(parts, strings.Repeat(" ", gap)))
}
}
return strings.Join(lines, "\n")
}
func updateWidget() {
mu.Lock()
ctx := latestCtx
ok := hasCtx
mu.Unlock()
if !ok {
return
}
ctx.SetWidget(ext.WidgetConfig{
ID: "kit-kit:grid",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Text: buildGrid(),
},
Style: ext.WidgetStyle{
NoBorder: true,
BorderColor: "",
},
Priority: 10,
})
}
func updateFooter() {
mu.Lock()
ctx := latestCtx
ok := hasCtx
mu.Unlock()
if !ok {
return
}
list := expertList()
active := 0
done := 0
for _, s := range list {
st, _, _, _, _ := s.snapshot()
switch st {
case "researching":
active++
case "done":
done++
}
}
var mid string
if active > 0 {
mid = fmt.Sprintf(" ◉ %d researching", active)
} else if done > 0 {
mid = fmt.Sprintf(" ✓ %d done", done)
}
text := fmt.Sprintf("%s | Kit Kit%s", ctx.Model, mid)
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: text},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
// ---------------------------------------------------------------------------
// Kit binary resolution
// ---------------------------------------------------------------------------
func findKitBinary() string {
// Try the current process executable first.
if exe, err := os.Executable(); err == nil {
if _, err := os.Stat(exe); err == nil {
return exe
}
}
// Fall back to PATH lookup.
if p, err := exec.LookPath("kit"); err == nil {
return p
}
return "kit"
}
// ---------------------------------------------------------------------------
// Expert query (subprocess)
// ---------------------------------------------------------------------------
func queryExpert(name, question string) (output string, exitCode int, elapsed time.Duration) {
mu.Lock()
state, ok := experts[strings.ToLower(name)]
mu.Unlock()
if !ok {
return fmt.Sprintf("Expert %q not found.", name), 1, 0
}
// Mark as researching.
state.mu.Lock()
if state.Status == "researching" {
state.mu.Unlock()
return fmt.Sprintf("Expert %q is already researching.", displayName(name)), 1, 0
}
state.Status = "researching"
state.Question = question
state.Elapsed = 0
state.LastLine = ""
state.QueryCount++
state.mu.Unlock()
updateWidget()
start := time.Now()
// Timer goroutine: update widget every second while researching.
done := make(chan struct{})
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
state.set("", "", "", time.Since(start))
updateWidget()
updateFooter()
}
}
}()
// Write system prompt to temp file.
tmpFile, err := os.CreateTemp("", "kit-kit-*.txt")
if err != nil {
close(done)
state.set("error", "", "temp file error: "+err.Error(), time.Since(start))
updateWidget()
updateFooter()
return "Error creating temp file: " + err.Error(), 1, time.Since(start)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(state.Def.System); err != nil {
tmpFile.Close()
close(done)
state.set("error", "", "write error: "+err.Error(), time.Since(start))
updateWidget()
updateFooter()
return "Error writing system prompt: " + err.Error(), 1, time.Since(start)
}
tmpFile.Close()
// 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,
"--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
err = cmd.Run()
close(done)
elapsed = time.Since(start)
if err != nil {
// 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]
}
state.set("error", "", truncate(strings.TrimSpace(errLine), 80), elapsed)
updateWidget()
updateFooter()
code := 1
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
}
return errText, code, elapsed
}
// 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-- {
if strings.TrimSpace(lines[i]) != "" {
lastLine = lines[i]
break
}
}
state.set("done", "", truncate(lastLine, 60), elapsed)
updateWidget()
updateFooter()
return result, 0, elapsed
}
// ---------------------------------------------------------------------------
// Orchestrator system prompt
// ---------------------------------------------------------------------------
func buildOrchestratorPrompt(cwd string) string {
orchPath := filepath.Join(cwd, ".kit", "agents", "kit-kit", "orchestrator.md")
raw, err := os.ReadFile(orchPath)
if err != nil {
// Fallback: generate a basic orchestrator prompt.
return buildDefaultOrchestratorPrompt()
}
text := string(raw)
// Strip frontmatter if present.
if strings.HasPrefix(text, "---\n") {
if idx := strings.Index(text[4:], "\n---\n"); idx >= 0 {
text = strings.TrimSpace(text[4+idx+5:])
}
}
list := expertList()
catalog := buildExpertCatalog(list)
names := make([]string, len(list))
for i, s := range list {
names[i] = displayName(s.Def.Name)
}
text = strings.ReplaceAll(text, "{{EXPERT_COUNT}}", fmt.Sprintf("%d", len(list)))
text = strings.ReplaceAll(text, "{{EXPERT_NAMES}}", strings.Join(names, ", "))
text = strings.ReplaceAll(text, "{{EXPERT_CATALOG}}", catalog)
return text
}
func buildExpertCatalog(list []*expertState) string {
var sb strings.Builder
for _, s := range list {
fmt.Fprintf(&sb, "### %s\n", displayName(s.Def.Name))
fmt.Fprintf(&sb, "**Query as:** `%s`\n", s.Def.Name)
fmt.Fprintf(&sb, "%s\n\n", s.Def.Description)
}
return sb.String()
}
func buildDefaultOrchestratorPrompt() string {
list := expertList()
names := make([]string, len(list))
for i, s := range list {
names[i] = displayName(s.Def.Name)
}
catalog := buildExpertCatalog(list)
return fmt.Sprintf(`You are Kit Kit, an orchestrator agent with %d domain experts: %s.
Use the query_experts tool to consult experts IN PARALLEL before writing code.
Always query multiple experts at once when the task spans multiple domains.
## Available Experts
%s
## Workflow
1. Analyze the user's request to identify which domains are relevant.
2. Use query_experts to ask specific questions of the relevant experts.
3. Synthesize the expert findings into a coherent implementation.
4. Write the actual code/files — you are the only agent that writes.
## Rules
- ALWAYS query experts before implementing. Never guess.
- Ask SPECIFIC questions. "How does X work?" is better than "Tell me about X".
- Query multiple experts in a single call when possible (they run in parallel).
- If an expert returns insufficient info, query again with a more specific question.
`, len(list), strings.Join(names, ", "), catalog)
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
func Init(api ext.API) {
kitBinary = findKitBinary()
// ── Session Start: load experts, show grid ──
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
loadExperts(ctx.CWD)
updateWidget()
updateFooter()
names := expertNames()
n := len(expertList())
if n > 0 {
ctx.PrintInfo(fmt.Sprintf(
"Kit Kit loaded — %d experts: %s\n\n"+
"/experts List experts and status\n"+
"/experts-grid N Set grid columns (1-5)\n\n"+
"Ask me to build any Kit component!",
n, names))
} else {
ctx.PrintInfo(
"Kit Kit loaded — no experts found.\n\n" +
"Add agent .md files to .kit/agents/kit-kit/ to get started.\n" +
"See examples/extensions/kit-kit-agents/ for samples.")
}
})
// ── Before Agent Start: inject orchestrator system prompt ──
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
mu.Lock()
latestCtx = ctx
mu.Unlock()
prompt := buildOrchestratorPrompt(ctx.CWD)
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
// ── Agent End: update footer ──
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
updateFooter()
})
// ── Session Shutdown: cleanup ──
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
ctx.RemoveWidget("kit-kit:grid")
ctx.RemoveFooter()
})
// ── Tool: query_experts ──
api.RegisterTool(ext.ToolDef{
Name: "query_experts",
Description: `Query one or more Kit domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses.
Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together.
Available experts are loaded from .kit/agents/kit-kit/*.md at session start. The default set includes:
- ext-expert: Kit extensions — tools, events, commands, widgets, editor interceptors
- tui-expert: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
- llm-expert: Kit LLM system — providers, streaming, agent loop, tool execution
Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`,
Parameters: `{
"type": "object",
"properties": {
"queries": {
"type": "array",
"description": "Array of expert queries to run in parallel",
"items": {
"type": "object",
"properties": {
"expert": {
"type": "string",
"description": "Expert name (e.g. ext-expert, tui-expert, llm-expert)"
},
"question": {
"type": "string",
"description": "Specific question about what you need to build"
}
},
"required": ["expert", "question"]
}
}
},
"required": ["queries"]
}`,
Execute: func(input string) (string, error) {
var params struct {
Queries []struct {
Expert string `json:"expert"`
Question string `json:"question"`
} `json:"queries"`
}
if err := json.Unmarshal([]byte(input), &params); err != nil {
return "", fmt.Errorf("invalid parameters: %w", err)
}
if len(params.Queries) == 0 {
return "No queries provided.", nil
}
// Launch all experts in parallel.
type result struct {
Expert string
Question string
Output string
ExitCode int
Elapsed time.Duration
}
results := make([]result, len(params.Queries))
var wg sync.WaitGroup
for i, q := range params.Queries {
wg.Add(1)
go func(idx int, expert, question string) {
defer wg.Done()
out, code, elapsed := queryExpert(expert, question)
results[idx] = result{
Expert: expert,
Question: question,
Output: out,
ExitCode: code,
Elapsed: elapsed,
}
}(i, q.Expert, q.Question)
}
wg.Wait()
// Build combined response.
var sb strings.Builder
for _, r := range results {
icon := "✓"
if r.ExitCode != 0 {
icon = "✗"
}
fmt.Fprintf(&sb, "## [%s] %s (%ds)\n\n",
icon, displayName(r.Expert), int(r.Elapsed.Seconds()))
out := r.Output
if len(out) > 12000 {
out = out[:12000] + "\n\n... [truncated — ask follow-up for more]"
}
sb.WriteString(out)
sb.WriteString("\n\n---\n\n")
}
return sb.String(), nil
},
})
// ── Tool Renderer: query_experts ──
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "query_experts",
DisplayName: "Query Experts",
BorderColor: "#89b4fa",
RenderHeader: func(toolArgs string, width int) string {
var args struct {
Queries []struct {
Expert string `json:"expert"`
} `json:"queries"`
}
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
names := make([]string, len(args.Queries))
for i, q := range args.Queries {
names[i] = displayName(q.Expert)
}
header := fmt.Sprintf("%d experts in parallel: %s",
len(args.Queries), strings.Join(names, ", "))
return truncate(header, width)
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "" // fall back to default
}
// Show compact summary: extract ## headers with status
var lines []string
for _, line := range strings.Split(toolResult, "\n") {
if strings.HasPrefix(line, "## [") {
lines = append(lines, line[3:]) // strip "## "
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, " · ")
},
})
// ── Command: /experts ──
api.RegisterCommand(ext.CommandDef{
Name: "experts",
Description: "List available Kit Kit experts and their status",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
list := expertList()
if len(list) == 0 {
return "No experts loaded. Add agent .md files to .kit/agents/kit-kit/", nil
}
var sb strings.Builder
for _, s := range list {
status, _, _, _, qc := s.snapshot()
fmt.Fprintf(&sb, "%s (%s, queries: %d): %s\n",
displayName(s.Def.Name), status, qc, s.Def.Description)
}
return sb.String(), nil
},
})
// ── Command: /experts-grid ──
api.RegisterCommand(ext.CommandDef{
Name: "experts-grid",
Description: "Set expert grid columns: /experts-grid <1-5>",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
args = strings.TrimSpace(args)
n := 0
if _, err := fmt.Sscanf(args, "%d", &n); err != nil || n < 1 || n > 5 {
return "Usage: /experts-grid <1-5>", nil
}
mu.Lock()
gridCols = n
mu.Unlock()
updateWidget()
return fmt.Sprintf("Grid set to %d columns.", n), nil
},
})
}
+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()
}
}
+123
View File
@@ -0,0 +1,123 @@
//go:build ignore
package main
import (
"fmt"
"strings"
"kit/ext"
)
// Init demonstrates the overlay dialog system. Extensions can show modal
// overlay dialogs that block until the user dismisses them or selects an
// action. Four slash commands illustrate different overlay use cases.
func Init(api ext.API) {
// /overlay-info — simple information dialog (no actions, dismissed with Enter or ESC).
api.RegisterCommand(ext.CommandDef{
Name: "overlay-info",
Description: "Show an info overlay dialog",
Execute: func(args string, ctx ext.Context) (string, error) {
content := "This is a simple informational overlay.\n\n" +
"Overlays are modal dialogs that appear over the TUI.\n" +
"They can display plain text or markdown content.\n\n" +
"Press Enter or ESC to dismiss."
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Information",
Content: ext.WidgetContent{Text: content},
Style: ext.OverlayStyle{BorderColor: "#89b4fa"},
})
if result.Cancelled {
return "Info dialog cancelled.", nil
}
return "Info dialog dismissed.", nil
},
})
// /overlay-actions — overlay with action buttons.
api.RegisterCommand(ext.CommandDef{
Name: "overlay-actions",
Description: "Show an overlay with action buttons",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Deploy to Production?",
Content: ext.WidgetContent{
Text: "You are about to deploy the following changes:\n\n" +
" - Updated API handlers (3 files)\n" +
" - New database migration (v42)\n" +
" - Config change: increased rate limit\n\n" +
"All tests are passing. Last deploy: 2 hours ago.",
},
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
Width: 65,
Actions: []string{"Deploy", "Cancel", "Show Diff"},
})
if result.Cancelled {
return "Deployment cancelled (ESC).", nil
}
return fmt.Sprintf("Selected action: %q (index %d)", result.Action, result.Index), nil
},
})
// /overlay-markdown — overlay with markdown content.
api.RegisterCommand(ext.CommandDef{
Name: "overlay-md",
Description: "Show an overlay with markdown content",
Execute: func(args string, ctx ext.Context) (string, error) {
md := "## Build Report\n\n" +
"| Component | Status | Duration |\n" +
"|-----------|--------|----------|\n" +
"| Frontend | Pass | 12.3s |\n" +
"| Backend | Pass | 8.7s |\n" +
"| E2E Tests | Pass | 45.1s |\n\n" +
"**Total time:** 66.1s\n\n" +
"All checks passed. Ready to merge."
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Build Report",
Content: ext.WidgetContent{Text: md, Markdown: true},
Style: ext.OverlayStyle{BorderColor: "#a6e3a1"},
Width: 70,
Actions: []string{"Merge", "Close"},
})
if result.Cancelled {
return "Build report closed.", nil
}
return fmt.Sprintf("Build report action: %q", result.Action), nil
},
})
// /overlay-scroll — overlay with long scrollable content.
api.RegisterCommand(ext.CommandDef{
Name: "overlay-scroll",
Description: "Show an overlay with scrollable content",
Execute: func(args string, ctx ext.Context) (string, error) {
var lines []string
lines = append(lines, "This overlay has a lot of content to demonstrate scrolling.")
lines = append(lines, "Use j/k or arrow keys to scroll through the content.")
lines = append(lines, "")
for i := 1; i <= 50; i++ {
lines = append(lines, fmt.Sprintf(" Line %02d: The quick brown fox jumps over the lazy dog.", i))
}
lines = append(lines, "")
lines = append(lines, "End of content. Press Enter to dismiss or ESC to cancel.")
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Log Output (50 lines)",
Content: ext.WidgetContent{Text: strings.Join(lines, "\n")},
Style: ext.OverlayStyle{BorderColor: "#fab387"},
MaxHeight: 20,
Actions: []string{"OK", "Copy to Clipboard"},
})
if result.Cancelled {
return "Log viewer cancelled.", nil
}
return fmt.Sprintf("Log viewer action: %q", result.Action), nil
},
})
}
+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,
}
})
}
+113
View File
@@ -0,0 +1,113 @@
//go:build ignore
package main
import (
"fmt"
"strings"
"kit/ext"
)
// Init demonstrates the interactive prompt system. It registers three slash
// commands that show each prompt type (select, confirm, input), plus a
// combined workflow command that chains prompts together.
func Init(api ext.API) {
// /demo-select — shows a selection list.
api.RegisterCommand(ext.CommandDef{
Name: "demo-select",
Description: "Demo: pick from a list",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Choose your deployment target:",
Options: []string{"local", "staging", "production"},
})
if result.Cancelled {
return "Selection cancelled.", nil
}
return fmt.Sprintf("Selected: %s (index %d)", result.Value, result.Index), nil
},
})
// /demo-confirm — shows a yes/no confirmation.
api.RegisterCommand(ext.CommandDef{
Name: "demo-confirm",
Description: "Demo: yes/no confirmation",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure you want to deploy?",
DefaultValue: false,
})
if result.Cancelled {
return "Confirmation cancelled.", nil
}
if result.Value {
return "Confirmed! Deploying...", nil
}
return "Declined. Deployment aborted.", nil
},
})
// /demo-input — shows a text input.
api.RegisterCommand(ext.CommandDef{
Name: "demo-input",
Description: "Demo: free-form text input",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter the release tag:",
Placeholder: "v1.0.0",
})
if result.Cancelled {
return "Input cancelled.", nil
}
return fmt.Sprintf("Release tag: %s", result.Value), nil
},
})
// /demo-workflow — chains multiple prompts into a workflow.
api.RegisterCommand(ext.CommandDef{
Name: "demo-workflow",
Description: "Demo: chained prompt workflow",
Execute: func(args string, ctx ext.Context) (string, error) {
// Step 1: select environment
env := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Step 1/3: Select environment:",
Options: []string{"development", "staging", "production"},
})
if env.Cancelled {
return "Workflow cancelled at step 1.", nil
}
// Step 2: enter version tag
tag := ctx.PromptInput(ext.PromptInputConfig{
Message: "Step 2/3: Enter the version tag:",
Placeholder: "v1.0.0",
})
if tag.Cancelled {
return "Workflow cancelled at step 2.", nil
}
// Step 3: confirm
confirm := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: fmt.Sprintf(
"Step 3/3: Deploy %s to %s?",
tag.Value, env.Value),
DefaultValue: false,
})
if confirm.Cancelled {
return "Workflow cancelled at step 3.", nil
}
if !confirm.Value {
return "Deployment declined.", nil
}
var summary strings.Builder
summary.WriteString("Deployment summary:\n")
fmt.Fprintf(&summary, " Environment: %s\n", env.Value)
fmt.Fprintf(&summary, " Version: %s\n", tag.Value)
summary.WriteString(" Status: initiated")
return summary.String(), nil
},
})
}
+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
})
}
+807
View File
@@ -0,0 +1,807 @@
//go:build ignore
// Subagent Widget — /sub, /subclear, /subrm, /subcont commands with live widgets
//
// Each /sub spawns a background Kit subagent as a subprocess with its own
// live widget showing status, task, elapsed time, and last output line.
// /subcont continues a finished subagent by passing conversation history.
//
// Commands:
//
// /sub <task> — spawn a new subagent
// /subcont <id> <prompt> — continue subagent #<id>'s conversation
// /subrm <id> — remove subagent #<id> widget
// /subclear — clear all subagent widgets
//
// The LLM can also use tools: subagent_create, subagent_continue,
// subagent_remove, subagent_list.
//
// Ported from https://github.com/disler/pi-vs-claude-code extensions/subagent-widget.ts
//
// Usage: kit -e examples/extensions/subagent-widget.go
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"kit/ext"
)
// subJSONOutput matches the JSON envelope produced by `kit --json`.
type subJSONOutput struct {
Response string `json:"response"`
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type subState struct {
ID int
Status string // "running", "done", "error"
Task string
Chunks []string // accumulated output chunks
Elapsed time.Duration
TurnCount int
History string // conversation history for /subcont
Proc *os.Process // active process for killing
Removed bool // set when /subrm or /subclear removes this agent
mu sync.Mutex
}
func (s *subState) appendChunk(chunk string) {
s.mu.Lock()
defer s.mu.Unlock()
s.Chunks = append(s.Chunks, chunk)
}
func (s *subState) setElapsed(d time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
s.Elapsed = d
}
func (s *subState) setProc(p *os.Process) {
s.mu.Lock()
defer s.mu.Unlock()
s.Proc = p
}
func (s *subState) snapshot() (int, string, string, string, time.Duration, int) {
s.mu.Lock()
defer s.mu.Unlock()
fullText := strings.Join(s.Chunks, "")
return s.ID, s.Status, s.Task, fullText, s.Elapsed, s.TurnCount
}
// ---------------------------------------------------------------------------
// Package-level state
// ---------------------------------------------------------------------------
var (
mu sync.Mutex
latestCtx ext.Context
hasCtx bool
agents = map[int]*subState{}
nextID = 1
kitBinary string
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func findKitBinary() string {
if exe, err := os.Executable(); err == nil {
if _, err := os.Stat(exe); err == nil {
return exe
}
}
if p, err := exec.LookPath("kit"); err == nil {
return p
}
return "kit"
}
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
if max < 4 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}
func lastNonEmptyLine(text string) string {
lines := strings.Split(text, "\n")
for i := len(lines) - 1; i >= 0; i-- {
trimmed := strings.TrimSpace(lines[i])
if trimmed != "" {
return trimmed
}
}
return ""
}
// ---------------------------------------------------------------------------
// Widget rendering
// ---------------------------------------------------------------------------
func updateWidgets() {
mu.Lock()
ctx := latestCtx
ok := hasCtx
agentsCopy := make([]*subState, 0, len(agents))
for _, s := range agents {
agentsCopy = append(agentsCopy, s)
}
mu.Unlock()
if !ok {
return
}
for _, state := range agentsCopy {
id, status, task, fullText, elapsed, turnCount := state.snapshot()
var icon, color string
switch status {
case "running":
icon = "●"
color = "#89b4fa" // blue
case "done":
icon = "✓"
color = "#a6e3a1" // green
default:
icon = "✗"
color = "#f38ba8" // red
}
taskPreview := truncate(task, 40)
turnLabel := ""
if turnCount > 1 {
turnLabel = fmt.Sprintf(" · Turn %d", turnCount)
}
header := fmt.Sprintf("%s Subagent #%d%s %s (%ds)",
icon, id, turnLabel, taskPreview, int(elapsed.Seconds()))
lastLine := truncate(lastNonEmptyLine(fullText), 80)
text := header
if lastLine != "" {
text += "\n " + lastLine
}
ctx.SetWidget(ext.WidgetConfig{
ID: fmt.Sprintf("subagent:%d", id),
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: text},
Style: ext.WidgetStyle{BorderColor: color},
Priority: id,
})
}
}
// ---------------------------------------------------------------------------
// Subprocess spawning
// ---------------------------------------------------------------------------
func spawnAgent(state *subState) {
prompt := state.Task
state.mu.Lock()
history := state.History
state.mu.Unlock()
if history != "" {
prompt = "Previous conversation:\n" + history + "\n\nNew instruction: " + state.Task
}
args := []string{
"--prompt", prompt,
"--json",
"--no-session",
"--no-extensions",
}
cmd := exec.Command(kitBinary, args...)
cmd.Env = os.Environ()
stdout, err := cmd.StdoutPipe()
if err != nil {
state.mu.Lock()
state.Status = "error"
state.Chunks = append(state.Chunks, "Pipe error: "+err.Error())
state.mu.Unlock()
updateWidgets()
return
}
stderr, err := cmd.StderrPipe()
if err != nil {
state.mu.Lock()
state.Status = "error"
state.Chunks = append(state.Chunks, "Pipe error: "+err.Error())
state.mu.Unlock()
updateWidgets()
return
}
start := time.Now()
if err := cmd.Start(); err != nil {
state.mu.Lock()
state.Status = "error"
state.Chunks = append(state.Chunks, "Start error: "+err.Error())
state.mu.Unlock()
updateWidgets()
return
}
state.setProc(cmd.Process)
// Timer goroutine: update widget every second with elapsed time.
doneCh := make(chan struct{})
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-doneCh:
return
case <-ticker.C:
state.setElapsed(time.Since(start))
updateWidgets()
}
}
}()
// Read stderr in background goroutine (live widget updates).
var readWg sync.WaitGroup
readWg.Add(1)
go func() {
defer readWg.Done()
scanner := bufio.NewScanner(stderr)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) != "" {
state.appendChunk(line + "\n")
updateWidgets()
}
}
}()
// 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() {
stdoutBuf.WriteString(scanner.Text() + "\n")
}
// Wait for all pipe readers, then the process.
readWg.Wait()
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
if waitErr != nil {
state.Status = "error"
} else {
state.Status = "done"
}
// Save history for /subcont continuations (cap at 16 KB).
state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n",
state.TurnCount, state.Task, result)
if len(state.History) > 16000 {
state.History = state.History[len(state.History)-16000:]
}
removed := state.Removed
id := state.ID
elapsed := state.Elapsed
turnCount := state.TurnCount
task := state.Task
state.mu.Unlock()
updateWidgets()
// Don't deliver follow-up for agents removed via /subrm or /subclear.
if removed {
return
}
// Deliver result as a follow-up message so the LLM can act on it.
mu.Lock()
ctx := latestCtx
ok := hasCtx
mu.Unlock()
if ok {
resultText := result
if len(resultText) > 8000 {
resultText = resultText[:8000] + "\n\n... [truncated]"
}
turnSuffix := ""
if turnCount > 1 {
turnSuffix = fmt.Sprintf(" (Turn %d)", turnCount)
}
ctx.SendMessage(fmt.Sprintf(
"Subagent #%d%s finished \"%s\" in %ds.\n\nResult:\n%s",
id, turnSuffix, task, int(elapsed.Seconds()), resultText,
))
}
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
func Init(api ext.API) {
kitBinary = findKitBinary()
// ── Session Start: reset state, show help ──
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
// Kill lingering agents from previous session.
mu.Lock()
for id, state := range agents {
state.mu.Lock()
if state.Proc != nil && state.Status == "running" {
state.Proc.Kill()
}
state.mu.Unlock()
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
}
agents = map[int]*subState{}
nextID = 1
mu.Unlock()
ctx.PrintInfo(
"Subagent Widget loaded\n\n" +
"/sub <task> Spawn a new subagent\n" +
"/subcont <id> <prompt> Continue a finished subagent\n" +
"/subrm <id> Remove a subagent\n" +
"/subclear Clear all subagents\n\n" +
"The LLM can also spawn subagents with the subagent_create tool.")
})
// ── Agent End: keep context fresh ──
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
})
// ── Session Shutdown: cleanup ──
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
mu.Lock()
defer mu.Unlock()
for id, state := range agents {
state.mu.Lock()
if state.Proc != nil && state.Status == "running" {
state.Proc.Kill()
}
state.mu.Unlock()
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
}
agents = map[int]*subState{}
})
// ── Tool: subagent_create ──
api.RegisterTool(ext.ToolDef{
Name: "subagent_create",
Description: `Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results are delivered as a follow-up message when the subagent finishes.
Each subagent runs as a separate Kit subprocess with full tool access. Use this to delegate independent subtasks that can run in parallel with your main work.`,
Parameters: `{
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The complete task description for the subagent to perform"
}
},
"required": ["task"]
}`,
Execute: func(input string) (string, error) {
var params struct {
Task string `json:"task"`
}
if err := json.Unmarshal([]byte(input), &params); err != nil {
return "", fmt.Errorf("invalid parameters: %w", err)
}
if strings.TrimSpace(params.Task) == "" {
return "", fmt.Errorf("task is required")
}
mu.Lock()
id := nextID
nextID++
state := &subState{
ID: id,
Status: "running",
Task: params.Task,
TurnCount: 1,
}
agents[id] = state
mu.Unlock()
updateWidgets()
go spawnAgent(state)
return fmt.Sprintf("Subagent #%d spawned and running in background.", id), nil
},
})
// ── Tool: subagent_continue ──
api.RegisterTool(ext.ToolDef{
Name: "subagent_continue",
Description: `Continue an existing subagent's conversation with a follow-up prompt. The subagent receives its previous conversation history as context. Use this to refine or extend a finished subagent's work.`,
Parameters: `{
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the subagent to continue"
},
"prompt": {
"type": "string",
"description": "The follow-up prompt or new instructions"
}
},
"required": ["id", "prompt"]
}`,
Execute: func(input string) (string, error) {
var params struct {
ID int `json:"id"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal([]byte(input), &params); err != nil {
return "", fmt.Errorf("invalid parameters: %w", err)
}
mu.Lock()
state, ok := agents[params.ID]
mu.Unlock()
if !ok {
return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil
}
state.mu.Lock()
if state.Status == "running" {
state.mu.Unlock()
return fmt.Sprintf("Error: Subagent #%d is still running.", params.ID), nil
}
state.Status = "running"
state.Task = params.Prompt
state.Chunks = nil
state.Elapsed = 0
state.TurnCount++
turn := state.TurnCount
state.mu.Unlock()
updateWidgets()
go spawnAgent(state)
return fmt.Sprintf("Subagent #%d continuing conversation in background (Turn %d).", params.ID, turn), nil
},
})
// ── Tool: subagent_remove ──
api.RegisterTool(ext.ToolDef{
Name: "subagent_remove",
Description: "Remove a specific subagent. Kills it if currently running.",
Parameters: `{
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the subagent to remove"
}
},
"required": ["id"]
}`,
Execute: func(input string) (string, error) {
var params struct {
ID int `json:"id"`
}
if err := json.Unmarshal([]byte(input), &params); err != nil {
return "", fmt.Errorf("invalid parameters: %w", err)
}
mu.Lock()
state, ok := agents[params.ID]
if !ok {
mu.Unlock()
return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil
}
delete(agents, params.ID)
mu.Unlock()
state.mu.Lock()
state.Removed = true
if state.Proc != nil && state.Status == "running" {
state.Proc.Kill()
}
state.mu.Unlock()
mu.Lock()
ctx := latestCtx
ok2 := hasCtx
mu.Unlock()
if ok2 {
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", params.ID))
}
return fmt.Sprintf("Subagent #%d removed.", params.ID), nil
},
})
// ── Tool: subagent_list ──
api.RegisterTool(ext.ToolDef{
Name: "subagent_list",
Description: "List all active and finished subagents with their IDs, tasks, and status.",
Parameters: `{"type": "object", "properties": {}}`,
Execute: func(input string) (string, error) {
mu.Lock()
agentsCopy := make([]*subState, 0, len(agents))
for _, s := range agents {
agentsCopy = append(agentsCopy, s)
}
mu.Unlock()
if len(agentsCopy) == 0 {
return "No active subagents.", nil
}
var sb strings.Builder
sb.WriteString("Subagents:\n")
for _, s := range agentsCopy {
id, status, task, _, _, turnCount := s.snapshot()
fmt.Fprintf(&sb, "#%d [%s] (Turn %d) — %s\n",
id, strings.ToUpper(status), turnCount, task)
}
return sb.String(), nil
},
})
// ── Tool Renderers ──
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "subagent_create",
DisplayName: "Spawn Subagent",
BorderColor: "#89b4fa",
RenderHeader: func(toolArgs string, width int) string {
var args struct {
Task string `json:"task"`
}
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
return truncate(args.Task, width)
},
RenderBody: func(toolResult string, isError bool, width int) string {
return truncate(toolResult, width)
},
})
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "subagent_continue",
DisplayName: "Continue Subagent",
BorderColor: "#cba6f7",
RenderHeader: func(toolArgs string, width int) string {
var args struct {
ID int `json:"id"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
return truncate(fmt.Sprintf("#%d: %s", args.ID, args.Prompt), width)
},
RenderBody: func(toolResult string, isError bool, width int) string {
return truncate(toolResult, width)
},
})
// ── Command: /sub <task> ──
api.RegisterCommand(ext.CommandDef{
Name: "sub",
Description: "Spawn a subagent with live widget: /sub <task>",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
task := strings.TrimSpace(args)
if task == "" {
return "Usage: /sub <task>", nil
}
mu.Lock()
id := nextID
nextID++
state := &subState{
ID: id,
Status: "running",
Task: task,
TurnCount: 1,
}
agents[id] = state
mu.Unlock()
updateWidgets()
go spawnAgent(state)
return fmt.Sprintf("Subagent #%d spawned: %s", id, truncate(task, 60)), nil
},
})
// ── Command: /subcont <id> <prompt> ──
api.RegisterCommand(ext.CommandDef{
Name: "subcont",
Description: "Continue subagent conversation: /subcont <id> <prompt>",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
trimmed := strings.TrimSpace(args)
spaceIdx := strings.IndexByte(trimmed, ' ')
if spaceIdx < 0 {
return "Usage: /subcont <id> <prompt>", nil
}
num, err := strconv.Atoi(trimmed[:spaceIdx])
if err != nil {
return "Usage: /subcont <id> <prompt>", nil
}
prompt := strings.TrimSpace(trimmed[spaceIdx+1:])
if prompt == "" {
return "Usage: /subcont <id> <prompt>", nil
}
mu.Lock()
state, ok := agents[num]
mu.Unlock()
if !ok {
return fmt.Sprintf("No subagent #%d found. Use /sub to create one.", num), nil
}
state.mu.Lock()
if state.Status == "running" {
state.mu.Unlock()
return fmt.Sprintf("Subagent #%d is still running — wait for it to finish.", num), nil
}
state.Status = "running"
state.Task = prompt
state.Chunks = nil
state.Elapsed = 0
state.TurnCount++
turn := state.TurnCount
state.mu.Unlock()
updateWidgets()
go spawnAgent(state)
return fmt.Sprintf("Continuing subagent #%d (Turn %d): %s", num, turn, truncate(prompt, 50)), nil
},
})
// ── Command: /subrm <id> ──
api.RegisterCommand(ext.CommandDef{
Name: "subrm",
Description: "Remove a subagent widget: /subrm <id>",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil {
return "Usage: /subrm <id>", nil
}
mu.Lock()
state, ok := agents[num]
if !ok {
mu.Unlock()
return fmt.Sprintf("No subagent #%d found.", num), nil
}
delete(agents, num)
mu.Unlock()
state.mu.Lock()
state.Removed = true
killed := false
if state.Proc != nil && state.Status == "running" {
state.Proc.Kill()
killed = true
}
state.mu.Unlock()
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", num))
if killed {
return fmt.Sprintf("Subagent #%d killed and removed.", num), nil
}
return fmt.Sprintf("Subagent #%d removed.", num), nil
},
})
// ── Command: /subclear ──
api.RegisterCommand(ext.CommandDef{
Name: "subclear",
Description: "Clear all subagent widgets",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
hasCtx = true
agentsCopy := make(map[int]*subState, len(agents))
for k, v := range agents {
agentsCopy[k] = v
}
agents = map[int]*subState{}
nextID = 1
mu.Unlock()
killed := 0
total := len(agentsCopy)
for id, state := range agentsCopy {
state.mu.Lock()
state.Removed = true
if state.Proc != nil && state.Status == "running" {
state.Proc.Kill()
killed++
}
state.mu.Unlock()
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
}
if total == 0 {
return "No subagents to clear.", nil
}
msg := fmt.Sprintf("Cleared %d subagent", total)
if total != 1 {
msg += "s"
}
if killed > 0 {
msg += fmt.Sprintf(" (%d killed)", killed)
}
msg += "."
return msg, nil
},
})
}
+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
},
})
}
+116
View File
@@ -0,0 +1,116 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"strings"
"kit/ext"
)
// Init demonstrates the custom tool rendering system. It registers
// renderers that override how specific tools display their headers,
// result bodies, display names, border colors, and backgrounds.
//
// Usage:
//
// kit -e examples/extensions/tool-renderer-demo.go
//
// Then ask the agent to read a file or run a bash command to see
// the custom rendering in action.
func Init(api ext.API) {
// Custom renderer for the "read" tool: custom display name,
// blue border, compact filename-only header.
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "read",
DisplayName: "File",
BorderColor: "#89b4fa", // Catppuccin blue
RenderHeader: func(toolArgs string, width int) string {
var args map[string]interface{}
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
path, _ := args["path"].(string)
if path == "" {
return "" // fall back to default
}
// Show just the filename, not the full path.
parts := strings.Split(path, "/")
name := parts[len(parts)-1]
// Include offset/limit if present.
var extras []string
if offset, ok := args["offset"]; ok {
extras = append(extras, fmt.Sprintf("from line %v", offset))
}
if limit, ok := args["limit"]; ok {
extras = append(extras, fmt.Sprintf("max %v lines", limit))
}
result := name
if len(extras) > 0 {
result += " (" + strings.Join(extras, ", ") + ")"
}
if len(result) > width {
return result[:width-3] + "..."
}
return result
},
// RenderBody is nil — fall back to the builtin read renderer
// which already provides syntax-highlighted code blocks.
})
// Custom renderer for the "bash" tool: renamed to "Shell",
// dark background, custom header with $ prefix.
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell",
Background: "#1e1e2e", // Dark background
BorderColor: "#a6e3a1", // Catppuccin green
RenderHeader: func(toolArgs string, width int) string {
var args map[string]interface{}
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
cmd, _ := args["command"].(string)
if cmd == "" {
return "" // fall back to default
}
// Show first line of command with a $ prefix.
lines := strings.SplitN(cmd, "\n", 2)
display := "$ " + lines[0]
if len(lines) > 1 {
display += " ..."
}
if len(display) > width {
return display[:width-3] + "..."
}
return display
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "" // fall back to default error rendering
}
// Count lines and show a summary at the end.
lines := strings.Split(toolResult, "\n")
lineCount := len(lines)
// Show the first few lines of output.
maxShow := 10
if lineCount <= maxShow {
return toolResult
}
shown := strings.Join(lines[:maxShow], "\n")
return fmt.Sprintf("%s\n\n[%d lines total, showing first %d]",
shown, lineCount, maxShow)
},
})
}
+90
View File
@@ -0,0 +1,90 @@
//go:build ignore
package main
import (
"fmt"
"time"
"kit/ext"
)
// Init demonstrates the widget system by showing a persistent status
// widget above the input area. The widget updates on each agent turn
// to show a running count of tool calls and the last tool used.
func Init(api ext.API) {
var toolCallCount int
var lastToolName string
// Show initial status widget when session starts.
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetWidget(ext.WidgetConfig{
ID: "widget-status:info",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Text: fmt.Sprintf("Session started | CWD: %s | Model: %s", ctx.CWD, ctx.Model),
},
Style: ext.WidgetStyle{
BorderColor: "#89b4fa",
},
})
})
// Update the widget after each tool call with a running count.
api.OnToolResult(func(tr ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
toolCallCount++
lastToolName = tr.ToolName
status := "ok"
if tr.IsError {
status = "error"
}
ctx.SetWidget(ext.WidgetConfig{
ID: "widget-status:info",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Text: fmt.Sprintf(
"Tools: %d calls | Last: %s (%s) | %s",
toolCallCount, lastToolName, status,
time.Now().Format("15:04:05"),
),
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1",
},
})
return nil
})
// "!widget-off" — removes the status widget.
// "!widget-on" — restores the status widget.
api.OnInput(func(ie ext.InputEvent, ctx ext.Context) *ext.InputResult {
switch ie.Text {
case "!widget-off":
ctx.RemoveWidget("widget-status:info")
ctx.PrintInfo("Status widget removed.")
return &ext.InputResult{Action: "handled"}
case "!widget-on":
ctx.SetWidget(ext.WidgetConfig{
ID: "widget-status:info",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Text: fmt.Sprintf("Tools: %d calls | %s", toolCallCount, time.Now().Format("15:04:05")),
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1",
},
})
ctx.PrintInfo("Status widget restored.")
return &ext.InputResult{Action: "handled"}
}
return nil
})
// Clean up widget on shutdown.
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
ctx.RemoveWidget("widget-status:info")
})
}
+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
+162
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,110 @@ 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).
func (a *App) NotifyWidgetUpdate() {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(WidgetUpdateEvent{})
}
}
// SendEvent sends a tea.Msg to the registered program. Safe to call from
// any goroutine. No-op when no program is registered.
//
// Satisfies ui.AppController.
func (a *App) SendEvent(msg tea.Msg) {
a.sendEvent(msg)
}
// SendPromptRequest sends a PromptRequestEvent to the TUI so the user can
// respond interactively. In non-interactive mode (no program registered) it
// immediately responds with a cancelled result via the channel, ensuring the
// calling extension goroutine never blocks indefinitely.
func (a *App) SendPromptRequest(evt PromptRequestEvent) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(evt)
return
}
// Non-interactive fallback: immediately cancel.
if evt.ResponseCh != nil {
evt.ResponseCh <- PromptResponse{Cancelled: true}
}
}
// SendOverlayRequest sends an OverlayRequestEvent to the TUI so the user
// can interact with a modal overlay dialog. In non-interactive mode (no
// program registered) it immediately responds with a cancelled result via the
// channel, ensuring the calling extension goroutine never blocks indefinitely.
func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(evt)
return
}
// Non-interactive fallback: immediately cancel.
if evt.ResponseCh != nil {
evt.ResponseCh <- OverlayResponse{Cancelled: true}
}
}
// 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()
+108
View File
@@ -113,6 +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
@@ -132,3 +154,89 @@ type ExtensionPrintEvent struct {
// Subtitle is optional muted text below the content for Level="block".
Subtitle string
}
// PromptResponse carries the user's answer to an interactive prompt. The TUI
// sends exactly one PromptResponse through the channel embedded in
// PromptRequestEvent when the user completes or cancels the prompt.
type PromptResponse struct {
// Value is the response text — the selected option (select), or the
// entered text (input). Unused for confirm prompts.
Value string
// Index is the zero-based index of the selected option (select only).
Index int
// Confirmed is the boolean answer for confirm prompts.
Confirmed bool
// Cancelled is true if the user dismissed the prompt (ESC) or the
// prompt could not be shown (e.g. app shutting down).
Cancelled bool
}
// PromptRequestEvent is sent when an extension requests an interactive
// prompt from the user (select, confirm, or text input). The TUI enters a
// modal prompt state, renders the prompt, and sends a single PromptResponse
// through ResponseCh when the user completes or cancels.
//
// The extension goroutine blocks on the read side of ResponseCh until the
// TUI sends a response. The channel must have buffer size >= 1.
type PromptRequestEvent struct {
// PromptType is "select", "confirm", or "input".
PromptType string
// Message is the question displayed to the user.
Message string
// Options lists the choices for select prompts.
Options []string
// Default is the pre-filled value: "true"/"false" for confirm prompts,
// or the initial text for input prompts.
Default string
// Placeholder is the ghost text for input prompts.
Placeholder string
// ResponseCh receives the user's answer. The TUI must send exactly one
// value. The channel must be buffered (cap >= 1) so sending never
// blocks inside Update().
ResponseCh chan<- PromptResponse
}
// OverlayResponse carries the user's answer to a modal overlay dialog. The
// TUI sends exactly one OverlayResponse through the channel embedded in
// OverlayRequestEvent when the user completes or cancels the overlay.
type OverlayResponse struct {
// Action is the text of the selected action button, or "" if no actions
// were configured or the dialog was dismissed without selection.
Action string
// Index is the zero-based index of the selected action, or -1 if no
// action was selected.
Index int
// Cancelled is true if the user dismissed the overlay (ESC) or the
// overlay could not be shown (e.g. non-interactive mode).
Cancelled bool
}
// OverlayRequestEvent is sent when an extension requests a modal overlay
// dialog. The TUI enters an overlay state, renders the dialog, and sends a
// single OverlayResponse through ResponseCh when the user dismisses or
// selects an action.
//
// The extension goroutine blocks on the read side of ResponseCh until the
// TUI sends a response. The channel must have buffer size >= 1.
type OverlayRequestEvent struct {
// Title is displayed at the top of the dialog. Empty means no title.
Title string
// Content is the text to render inside the dialog body.
Content string
// Markdown, when true, renders Content as styled markdown.
Markdown bool
// BorderColor is a hex color for the dialog border. Empty uses default.
BorderColor string
// Background is a hex color for the dialog background. Empty = none.
Background string
// Width is the dialog width in columns. 0 = auto (60% of terminal).
Width int
// MaxHeight limits dialog height. 0 = auto (80% of terminal).
MaxHeight int
// Anchor is the vertical positioning: "center", "top-center", "bottom-center".
Anchor string
// Actions lists the action button labels. Empty = simple dismiss dialog.
Actions []string
// ResponseCh receives the user's response. Must have buffer size >= 1.
ResponseCh chan<- OverlayResponse
}
+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...),
+1321 -16
View File
File diff suppressed because it is too large Load Diff
+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 {
+62 -1
View File
@@ -41,7 +41,8 @@ func LoadExtensions(extraPaths []string) ([]LoadedExtension, error) {
log.Debug("loaded extension", "path", p,
"handlers", countHandlers(ext),
"tools", len(ext.Tools),
"commands", len(ext.Commands))
"commands", len(ext.Commands),
"tool_renderers", len(ext.ToolRenderers))
}
return loaded, nil
}
@@ -282,12 +283,72 @@ 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)
},
registerCmdFn: func(cmd CommandDef) {
ext.Commands = append(ext.Commands, cmd)
},
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.
+492 -16
View File
@@ -2,28 +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
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 or commands the extension provided.
// tools, commands, or tool renderers the extension provided.
type LoadedExtension struct {
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
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.
@@ -39,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 {
@@ -115,18 +146,457 @@ 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
}
// ---------------------------------------------------------------------------
// Widget management
// ---------------------------------------------------------------------------
// SetWidget places or updates a persistent widget. The widget is identified
// by config.ID; calling SetWidget with the same ID replaces the previous
// content. Thread-safe.
func (r *Runner) SetWidget(config WidgetConfig) {
r.mu.Lock()
defer r.mu.Unlock()
if r.widgets == nil {
r.widgets = make(map[string]WidgetConfig)
}
r.widgets[config.ID] = config
}
// RemoveWidget removes a widget by ID. No-op if the ID does not exist.
// Thread-safe.
func (r *Runner) RemoveWidget(id string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.widgets, id)
}
// GetWidgets returns all widgets matching the given placement, sorted by
// priority (ascending). Thread-safe.
func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
r.mu.RLock()
defer r.mu.RUnlock()
var result []WidgetConfig
for _, w := range r.widgets {
if w.Placement == placement {
result = append(result, w)
}
}
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].ID < result[j].ID // stable tie-break
})
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
// ---------------------------------------------------------------------------
// SetHeader places or replaces the custom header. Thread-safe.
func (r *Runner) SetHeader(config HeaderFooterConfig) {
r.mu.Lock()
defer r.mu.Unlock()
r.header = &config
}
// RemoveHeader removes the custom header. No-op if none is set. Thread-safe.
func (r *Runner) RemoveHeader() {
r.mu.Lock()
defer r.mu.Unlock()
r.header = nil
}
// GetHeader returns the current custom header, or nil if none is set.
// Thread-safe.
func (r *Runner) GetHeader() *HeaderFooterConfig {
r.mu.RLock()
defer r.mu.RUnlock()
if r.header == nil {
return nil
}
// Return a copy to avoid races on the caller side.
h := *r.header
return &h
}
// SetFooter places or replaces the custom footer. Thread-safe.
func (r *Runner) SetFooter(config HeaderFooterConfig) {
r.mu.Lock()
defer r.mu.Unlock()
r.footer = &config
}
// RemoveFooter removes the custom footer. No-op if none is set. Thread-safe.
func (r *Runner) RemoveFooter() {
r.mu.Lock()
defer r.mu.Unlock()
r.footer = nil
}
// GetFooter returns the current custom footer, or nil if none is set.
// Thread-safe.
func (r *Runner) GetFooter() *HeaderFooterConfig {
r.mu.RLock()
defer r.mu.RUnlock()
if r.footer == nil {
return nil
}
// Return a copy to avoid races on the caller side.
f := *r.footer
return &f
}
// ---------------------------------------------------------------------------
// Editor interceptor management
// ---------------------------------------------------------------------------
// SetEditor installs an editor interceptor that wraps the built-in input
// editor. Only one interceptor is active at a time; calling SetEditor replaces
// any previous interceptor. Thread-safe.
func (r *Runner) SetEditor(config EditorConfig) {
r.mu.Lock()
defer r.mu.Unlock()
r.customEditor = &config
}
// ResetEditor removes the active editor interceptor and restores the default
// built-in editor behavior. No-op if no interceptor is set. Thread-safe.
func (r *Runner) ResetEditor() {
r.mu.Lock()
defer r.mu.Unlock()
r.customEditor = nil
}
// GetEditor returns the current editor interceptor, or nil if none is set.
// Thread-safe. Returns a shallow copy — function fields are reference types
// so the copy is safe.
func (r *Runner) GetEditor() *EditorConfig {
r.mu.RLock()
defer r.mu.RUnlock()
if r.customEditor == nil {
return nil
}
e := *r.customEditor
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
// ---------------------------------------------------------------------------
// GetToolRenderer returns the custom renderer for the named tool, or nil if
// no extension registered a renderer for it. If multiple extensions register
// renderers for the same tool, the last one (by load order) wins. Thread-safe
// (extensions are immutable after loading).
func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
// Walk extensions in reverse so last-registered wins.
for i := len(r.extensions) - 1; i >= 0; i-- {
for j := len(r.extensions[i].ToolRenderers) - 1; j >= 0; j-- {
if r.extensions[i].ToolRenderers[j].ToolName == toolName {
config := r.extensions[i].ToolRenderers[j]
return &config
}
}
}
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
// ---------------------------------------------------------------------------
@@ -148,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
}
+85
View File
@@ -23,9 +23,93 @@ 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)),
"WidgetStyle": reflect.ValueOf((*WidgetStyle)(nil)),
"WidgetPlacement": reflect.ValueOf((*WidgetPlacement)(nil)),
"WidgetAbove": reflect.ValueOf(WidgetAbove),
"WidgetBelow": reflect.ValueOf(WidgetBelow),
// 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),
"OverlayTopCenter": reflect.ValueOf(OverlayTopCenter),
"OverlayBottomCenter": reflect.ValueOf(OverlayBottomCenter),
"OverlayStyle": reflect.ValueOf((*OverlayStyle)(nil)),
"OverlayConfig": reflect.ValueOf((*OverlayConfig)(nil)),
"OverlayResult": reflect.ValueOf((*OverlayResult)(nil)),
// 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),
"EditorKeyConsumed": reflect.ValueOf(EditorKeyConsumed),
"EditorKeyRemap": reflect.ValueOf(EditorKeyRemap),
"EditorKeySubmit": reflect.ValueOf(EditorKeySubmit),
"EditorKeyAction": reflect.ValueOf((*EditorKeyAction)(nil)),
"EditorConfig": reflect.ValueOf((*EditorConfig)(nil)),
// Prompt types
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
// 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)),
@@ -44,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,4 +1,7 @@
package kit
// Package kitsetup contains agent creation logic used by both the CLI binary
// and the SDK's kit.New(). It is internal — external SDK consumers should use
// kit.New() which delegates here.
package kitsetup
import (
"context"
@@ -183,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 ""
+38 -101
View File
@@ -10,7 +10,7 @@ import (
type blockRenderer struct {
align *lipgloss.Position
borderColor *color.Color
bgColor *color.Color
background *color.Color
fullWidth bool
noBorder bool
paddingTop int
@@ -34,14 +34,6 @@ func WithFullWidth() renderingOption {
}
}
// WithBackground returns a renderingOption that sets a background color
// for the entire block.
func WithBackground(c color.Color) renderingOption {
return func(br *blockRenderer) {
br.bgColor = &c
}
}
// WithNoBorder returns a renderingOption that disables all borders on the
// block, rendering content with only padding.
func WithNoBorder() renderingOption {
@@ -122,6 +114,15 @@ func WithPaddingBottom(padding int) renderingOption {
}
}
// WithBackground returns a renderingOption that sets the background color
// for the entire block. The color parameter accepts any color.Color value,
// typically a lipgloss hex color (e.g. lipgloss.Color("#1e1e2e")).
func WithBackground(c color.Color) renderingOption {
return func(br *blockRenderer) {
br.background = &c
}
}
// WithWidth returns a renderingOption that sets a specific width for the block
// in characters. This overrides the default container width and allows precise
// control over the block's horizontal dimensions.
@@ -165,104 +166,40 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
}
theme := GetTheme()
hasBg := renderer.bgColor != nil
if hasBg {
// When a background color is set we use a three-phase render so
// the border extends the full block height including padding:
// 1. Render content with bg + horizontal padding (no border,
// no vertical padding).
// 2. Use Place() to add vertical padding with uniform bg fill.
// 3. Apply the border to the padded block.
// Single-pass render: padding, border, and foreground in one style.
style := lipgloss.NewStyle().
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
Foreground(theme.Text)
// Phase 1 — content with background + horizontal padding.
innerStyle := lipgloss.NewStyle().
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
Foreground(theme.Text).
Background(*renderer.bgColor)
if hasBorder {
style = style.BorderStyle(lipgloss.ThickBorder())
if renderer.fullWidth {
innerStyle = innerStyle.Width(renderer.width - borderChars)
switch borderAlign {
case lipgloss.Right:
style = style.
BorderRight(true).
BorderRightForeground(borderColor)
default:
style = style.
BorderLeft(true).
BorderLeftForeground(borderColor)
}
content = innerStyle.Render(content)
// Phase 2 — vertical padding via Place() with bg-filled whitespace.
if renderer.paddingTop > 0 || renderer.paddingBottom > 0 {
renderedH := lipgloss.Height(content)
renderedW := lipgloss.Width(content)
totalH := renderedH + renderer.paddingTop + renderer.paddingBottom
bgStyle := lipgloss.NewStyle().Background(*renderer.bgColor)
// Determine vertical position so padding distributes correctly.
vPos := lipgloss.Center
switch {
case renderer.paddingTop > 0 && renderer.paddingBottom == 0:
vPos = lipgloss.Bottom
case renderer.paddingBottom > 0 && renderer.paddingTop == 0:
vPos = lipgloss.Top
}
content = lipgloss.Place(
renderedW, totalH,
lipgloss.Left, vPos,
content,
lipgloss.WithWhitespaceStyle(bgStyle),
)
}
// Phase 3 — apply border to the full-height block.
if hasBorder {
borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder())
switch borderAlign {
case lipgloss.Right:
borderStyle = borderStyle.
BorderRight(true).
BorderRightForeground(borderColor)
default:
borderStyle = borderStyle.
BorderLeft(true).
BorderLeftForeground(borderColor)
}
content = borderStyle.Render(content)
}
} else {
// No background — PaddingTop/PaddingBottom work fine (no visible
// banding), so render everything in a single style pass.
style := lipgloss.NewStyle().
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
Foreground(theme.Text)
if hasBorder {
style = style.BorderStyle(lipgloss.ThickBorder())
switch borderAlign {
case lipgloss.Right:
style = style.
BorderRight(true).
BorderRightForeground(borderColor)
default:
style = style.
BorderLeft(true).
BorderLeftForeground(borderColor)
}
}
if renderer.fullWidth {
style = style.Width(renderer.width - borderChars)
}
content = style.Render(content)
}
if renderer.background != nil {
style = style.Background(*renderer.background)
}
if renderer.fullWidth {
style = style.Width(renderer.width - borderChars)
}
content = style.Render(content)
// Add margins
if renderer.marginTop > 0 {
for range renderer.marginTop {
+25 -109
View File
@@ -15,15 +15,12 @@ import (
// display modes, handles streaming responses, tracks token usage, and manages the
// overall conversation flow between the user and AI assistants.
type CLI struct {
messageRenderer *MessageRenderer
compactRenderer *CompactRenderer
messageContainer *MessageContainer
usageTracker *UsageTracker
width int
height int
compactMode bool
debug bool
modelName string
renderer Renderer
usageTracker *UsageTracker
width int
compactMode bool
debug bool
modelName string
}
// NewCLI creates and initializes a new CLI instance with the specified display modes.
@@ -36,9 +33,11 @@ func NewCLI(debug bool, compact bool) (*CLI, error) {
debug: debug,
}
cli.updateSize()
cli.messageRenderer = NewMessageRenderer(cli.width, debug)
cli.compactRenderer = NewCompactRenderer(cli.width, debug)
cli.messageContainer = NewMessageContainer(cli.width, cli.height-4, compact) // Pass compact mode
if compact {
cli.renderer = NewCompactRenderer(cli.width, debug)
} else {
cli.renderer = NewMessageRenderer(cli.width, debug)
}
return cli, nil
}
@@ -71,9 +70,6 @@ func (c *CLI) GetDebugLogger() *CLIDebugLogger {
// This name is displayed in message headers to indicate which model is responding.
func (c *CLI) SetModelName(modelName string) {
c.modelName = modelName
if c.messageContainer != nil {
c.messageContainer.SetModelName(modelName)
}
}
// ShowSpinner displays an animated spinner while executing the provided action
@@ -94,14 +90,7 @@ func (c *CLI) ShowSpinner(action func() error) error {
// formatting based on the current display mode (standard or compact). The message
// is timestamped and styled according to the active theme.
func (c *CLI) DisplayUserMessage(message string) {
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderUserMessage(message, time.Now())
} else {
msg = c.messageRenderer.RenderUserMessage(message, time.Now())
}
c.messageContainer.AddMessage(msg)
c.displayContainer()
fmt.Println(c.renderer.RenderUserMessage(message, time.Now()).Content)
}
// DisplayAssistantMessage renders and displays an AI assistant's response message
@@ -115,14 +104,7 @@ func (c *CLI) DisplayAssistantMessage(message string) error {
// with the specified model name shown in the message header. The message is
// formatted according to the current display mode and includes timestamp information.
func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error {
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderAssistantMessage(message, time.Now(), modelName)
} else {
msg = c.messageRenderer.RenderAssistantMessage(message, time.Now(), modelName)
}
c.messageContainer.AddMessage(msg)
c.displayContainer()
fmt.Println(c.renderer.RenderAssistantMessage(message, time.Now(), modelName).Content)
return nil
}
@@ -137,44 +119,21 @@ func (c *CLI) DisplayToolCallMessage(toolName, toolArgs string) {
// including the tool name, arguments, and result. The isError parameter determines
// whether the result should be displayed as an error or success message.
func (c *CLI) DisplayToolMessage(toolName, toolArgs, toolResult string, isError bool) {
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError)
} else {
msg = c.messageRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError)
}
// Always display immediately - spinner management is handled externally
c.messageContainer.AddMessage(msg)
c.displayContainer()
fmt.Println(c.renderer.RenderToolMessage(toolName, toolArgs, toolResult, isError).Content)
}
// DisplayError renders and displays an error message with distinctive formatting
// to ensure visibility. The error is timestamped and styled according to the
// current display mode's error theme.
func (c *CLI) DisplayError(err error) {
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderErrorMessage(err.Error(), time.Now())
} else {
msg = c.messageRenderer.RenderErrorMessage(err.Error(), time.Now())
}
c.messageContainer.AddMessage(msg)
c.displayContainer()
fmt.Println(c.renderer.RenderErrorMessage(err.Error(), time.Now()).Content)
}
// DisplayInfo renders and displays an informational system message. These messages
// are typically used for status updates, notifications, or other non-error system
// communications to the user.
func (c *CLI) DisplayInfo(message string) {
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderSystemMessage(message, time.Now())
} else {
msg = c.messageRenderer.RenderSystemMessage(message, time.Now())
}
c.messageContainer.AddMessage(msg)
c.displayContainer()
fmt.Println(c.renderer.RenderSystemMessage(message, time.Now()).Content)
}
// DisplayExtensionBlock renders a custom styled block with the given border
@@ -195,7 +154,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
rendered := renderContentBlock(
content,
c.messageRenderer.width,
c.width,
WithAlign(lipgloss.Left),
WithBorderColor(borderClr),
WithMarginBottom(1),
@@ -206,14 +165,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
// DisplayCancellation displays a system message indicating that the current
// AI generation has been cancelled by the user (typically via ESC key).
func (c *CLI) DisplayCancellation() {
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now())
} else {
msg = c.messageRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now())
}
c.messageContainer.AddMessage(msg)
c.displayContainer()
fmt.Println(c.renderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now()).Content)
}
// DisplayDebugMessage renders and displays a debug message if debug mode is enabled.
@@ -223,42 +175,14 @@ func (c *CLI) DisplayDebugMessage(message string) {
if !c.debug {
return
}
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderDebugMessage(message, time.Now())
} else {
msg = c.messageRenderer.RenderDebugMessage(message, time.Now())
}
c.messageContainer.AddMessage(msg)
c.displayContainer()
fmt.Println(c.renderer.RenderDebugMessage(message, time.Now()).Content)
}
// DisplayDebugConfig renders and displays configuration settings in a formatted
// debug message. The config parameter should contain key-value pairs representing
// configuration options that will be displayed for debugging purposes.
func (c *CLI) DisplayDebugConfig(config map[string]any) {
var msg UIMessage
if c.compactMode {
msg = c.compactRenderer.RenderDebugConfigMessage(config, time.Now())
} else {
msg = c.messageRenderer.RenderDebugConfigMessage(config, time.Now())
}
c.messageContainer.AddMessage(msg)
c.displayContainer()
}
// displayContainer renders and displays the message container for one-shot
// (non-streaming) messages. Output matches the interactive TUI's tea.Println
// path — no extra padding or width wrapping is applied so both modes produce
// identical visual output.
func (c *CLI) displayContainer() {
content := c.messageContainer.Render()
if content != "" {
fmt.Println(content)
}
// Clear messages after display; one-shot messages don't need to persist.
c.messageContainer.messages = nil
fmt.Println(c.renderer.RenderDebugConfigMessage(config, time.Now()).Content)
}
// UpdateUsageFromResponse records token usage using metadata from the fantasy
@@ -309,27 +233,19 @@ func (c *CLI) DisplayUsageAfterResponse() {
// updateSize updates the CLI size based on terminal dimensions
func (c *CLI) updateSize() {
width, height, err := term.GetSize(int(os.Stdout.Fd()))
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
c.width = 80 // Fallback width
c.height = 24 // Fallback height
c.width = 80 // Fallback width
return
}
// Add left and right padding (4 characters total: 2 on each side)
paddingTotal := 4
c.width = width - paddingTotal
c.height = height
// Update renderers if they exist
if c.messageRenderer != nil {
c.messageRenderer.SetWidth(c.width)
}
if c.compactRenderer != nil {
c.compactRenderer.SetWidth(c.width)
}
if c.messageContainer != nil {
c.messageContainer.SetSize(c.width, c.height-4)
// Update renderer if it exists
if c.renderer != nil {
c.renderer.SetWidth(c.width)
}
if c.usageTracker != nil {
c.usageTracker.SetWidth(c.width)
+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
+50 -78
View File
@@ -14,6 +14,12 @@ import (
type CompactRenderer struct {
width int
debug bool
// getToolRenderer returns extension-provided rendering overrides for a
// specific tool. May be nil if no extensions are loaded. Used in
// RenderToolMessage to check for custom header/body formatting before
// falling back to builtin renderers.
getToolRenderer func(toolName string) *ToolRendererData
}
// NewCompactRenderer creates and initializes a new CompactRenderer with the specified
@@ -141,6 +147,12 @@ func (r *CompactRenderer) RenderToolCallMessage(toolName, toolArgs string, times
func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
theme := getTheme()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
if r.getToolRenderer != nil {
extRd = r.getToolRenderer(toolName)
}
// Status icon
var icon string
iconColor := theme.Success
@@ -152,12 +164,23 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
}
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
// Extension can override display name.
displayName := toolDisplayName(toolName)
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
// Format params
// Format params — check extension renderer first.
paramBudget := max(r.width-10-len(displayName), 20)
params := formatToolParams(toolArgs, paramBudget)
var params string
if extRd != nil && extRd.RenderHeader != nil {
params = extRd.RenderHeader(toolArgs, paramBudget)
}
if params == "" {
params = formatToolParams(toolArgs, paramBudget)
}
// Build header line
header := iconStr + " " + nameStr
@@ -165,18 +188,28 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Format body: try tool-specific renderer, then fall back to default
// Format body: check extension renderer first, then compact builtin, then default.
var body string
if isError {
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
if body == "" {
formatted := r.formatToolResult(toolResult)
if formatted == "" {
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
} else {
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-4)
// Apply markdown rendering if requested and body is non-empty.
if body != "" && extRd.BodyMarkdown {
body = strings.TrimSuffix(toMarkdown(body, r.width-4), "\n")
}
}
if body == "" {
if isError {
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
} else {
// 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 == "" {
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
} else {
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
}
}
}
}
@@ -443,70 +476,9 @@ func (r *CompactRenderer) formatToolResult(result string) string {
return strings.Join(lines, "\n")
}
// formatBashOutput formats bash command output by removing stdout/stderr tags and styling appropriately
// formatBashOutput formats bash command output by removing stdout/stderr tags
// and styling appropriately. Delegates tag parsing to the shared parseBashOutput
// helper.
func (r *CompactRenderer) formatBashOutput(result string) string {
theme := getTheme()
// Replace tag pairs with styled content
var formattedResult strings.Builder
remaining := result
for {
// Find stderr tags
stderrStart := strings.Index(remaining, "<stderr>")
stderrEnd := strings.Index(remaining, "</stderr>")
// Find stdout tags
stdoutStart := strings.Index(remaining, "<stdout>")
stdoutEnd := strings.Index(remaining, "</stdout>")
// Process whichever comes first
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
(stdoutStart == -1 || stderrStart < stdoutStart) {
// Process stderr
// Add content before the tag
if stderrStart > 0 {
formattedResult.WriteString(remaining[:stderrStart])
}
// Extract and style stderr content
stderrContent := remaining[stderrStart+8 : stderrEnd]
// Trim leading/trailing newlines but preserve internal ones
stderrContent = strings.Trim(stderrContent, "\n")
if len(stderrContent) > 0 {
// Style stderr content with error color, same as non-compact mode
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
formattedResult.WriteString(styledContent)
}
// Continue with remaining content
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
// Process stdout
// Add content before the tag
if stdoutStart > 0 {
formattedResult.WriteString(remaining[:stdoutStart])
}
// Extract stdout content (no special styling needed)
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
// Trim leading/trailing newlines but preserve internal ones
stdoutContent = strings.Trim(stdoutContent, "\n")
if len(stdoutContent) > 0 {
formattedResult.WriteString(stdoutContent)
}
// Continue with remaining content
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
} else {
// No more tags, add remaining content
formattedResult.WriteString(remaining)
break
}
}
// Trim any leading/trailing whitespace from the final result
return strings.TrimSpace(formattedResult.String())
return parseBashOutput(result, getTheme())
}
+1 -8
View File
@@ -68,14 +68,7 @@ func (l *CLIDebugLogger) LogDebug(message string) {
}
// Use the CLI's debug message rendering
var msg UIMessage
if l.cli.compactMode {
msg = l.cli.compactRenderer.RenderDebugMessage(formattedMessage, time.Now())
} else {
msg = l.cli.messageRenderer.RenderDebugMessage(formattedMessage, time.Now())
}
l.cli.messageContainer.AddMessage(msg)
l.cli.displayContainer()
fmt.Println(l.cli.renderer.RenderDebugMessage(formattedMessage, time.Now()).Content)
}
// IsDebugEnabled checks whether debug logging is currently active. Returns true
-7
View File
@@ -14,13 +14,6 @@ import (
// isDarkBg caches the terminal background detection result at package init.
var isDarkBg = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
// colorHex returns the hex string representation of a color.Color by
// converting its RGBA values.
func colorHex(c color.Color) string {
r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
// AdaptiveColor picks between a light-mode and dark-mode hex color string
// based on the detected terminal background. This replaces the old
// lipgloss.AdaptiveColor{Light: ..., Dark: ...} pattern from v1.
+129
View File
@@ -0,0 +1,129 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// fileTokenPattern matches @file references in user text. Supports:
// - @"path with spaces.txt" (quoted)
// - @path/to/file.txt (unquoted, no spaces)
var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`)
// ProcessFileAttachments scans the user's input text for @file references,
// reads each referenced file, and returns the text with @tokens replaced by
// XML-wrapped file content. Non-file @ tokens (like email addresses) are left
// unchanged.
//
// Returns the original text unchanged if no valid @file references are found.
func ProcessFileAttachments(text string, cwd string) string {
tokens := fileTokenPattern.FindAllString(text, -1)
if len(tokens) == 0 {
return text
}
result := text
for _, token := range tokens {
path := tokenToPath(token)
if path == "" {
continue
}
absPath, err := resolvePath(path, cwd)
if err != nil {
// Not a valid file reference — leave the token as-is.
// This handles cases like email addresses (@user) gracefully.
continue
}
info, err := os.Stat(absPath)
if err != nil {
continue
}
// Skip directories — we only attach file content.
if info.IsDir() {
continue
}
// Skip empty files.
if info.Size() == 0 {
continue
}
content, err := os.ReadFile(absPath)
if err != nil {
continue
}
// Build the XML-wrapped replacement.
wrapped := wrapFileContent(absPath, content)
result = strings.Replace(result, token, wrapped, 1)
}
return result
}
// tokenToPath strips the @ prefix and optional quotes from a token,
// returning the raw file path. Returns "" for invalid tokens.
func tokenToPath(token string) string {
if !strings.HasPrefix(token, "@") {
return ""
}
path := token[1:]
// Strip quotes.
if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
path = path[1 : len(path)-1]
}
// Reject obviously non-file tokens (e.g. bare @ or @-flags).
if path == "" || strings.HasPrefix(path, "-") {
return ""
}
return path
}
// resolvePath resolves a potentially relative file path to an absolute path.
// Supports ~/ expansion and relative paths. No CWD restriction — the user
// can reference any file they have read access to.
func resolvePath(path string, cwd string) (string, error) {
// Expand ~/
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot expand ~: %w", err)
}
path = filepath.Join(home, path[2:])
}
// Resolve relative to cwd.
if !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
// Clean and resolve symlinks for consistent paths.
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Resolve symlinks so the displayed path is canonical.
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
// EvalSymlinks fails if the file doesn't exist — fall back to
// the cleaned absolute path and let the caller's Stat handle it.
return absPath, nil
}
return resolved, nil
}
// wrapFileContent wraps file content in XML tags for LLM consumption.
func wrapFileContent(absPath string, content []byte) string {
return fmt.Sprintf("<file path=\"%s\">\n%s\n</file>", absPath, string(content))
}
+389
View File
@@ -0,0 +1,389 @@
package ui
import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// FileSuggestion represents a single file or directory suggestion for the @
// autocomplete popup.
type FileSuggestion struct {
// RelPath is the path relative to the search base (e.g. "cmd/kit/main.go").
RelPath string
// IsDir is true when the entry is a directory.
IsDir bool
// Score is the fuzzy match score (higher is better).
Score int
}
// maxFileSuggestions is the maximum number of file suggestions returned.
const maxFileSuggestions = 20
// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol.
// It returns:
// - hasAt: true if a valid @ trigger was found
// - prefix: the text after @ (possibly empty) that the user has typed so far
// - startIdx: byte offset of the @ character in the line
//
// The @ must appear at the start of the line or after whitespace. Quoted paths
// are supported: @"path with spaces" — the returned prefix strips quotes.
func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, startIdx int) {
if cursorCol > len(line) {
cursorCol = len(line)
}
// Walk backwards from cursorCol to find the @ character.
text := line[:cursorCol]
// Find the last @ that is preceded by whitespace or is at position 0.
atIdx := -1
for i := len(text) - 1; i >= 0; i-- {
if text[i] == '@' {
// Must be at start of line or preceded by whitespace.
if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' {
atIdx = i
break
}
}
// Stop scanning if we hit a space — the @ we want must be in the
// current "word".
if text[i] == ' ' || text[i] == '\t' {
break
}
}
if atIdx < 0 {
return false, "", 0
}
raw := text[atIdx+1:]
// Handle quoted paths: @"some path" — strip leading quote.
if strings.HasPrefix(raw, `"`) {
raw = strings.TrimPrefix(raw, `"`)
raw = strings.TrimSuffix(raw, `"`)
}
return true, raw, atIdx
}
// GetFileSuggestions returns file/directory suggestions matching the given
// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
// falls back to a simple directory walk.
//
// If prefix contains a path separator the search is scoped to that
// subdirectory. For example, prefix "cmd/k" searches inside "cmd/" for
// entries matching "k".
func GetFileSuggestions(prefix string, cwd string) []FileSuggestion {
// Resolve the base directory and filter query from the prefix.
baseDir, query := splitPrefixPath(prefix)
searchDir := cwd
if baseDir != "" {
candidate := resolveSearchDir(baseDir, cwd)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
searchDir = candidate
} else {
return nil // invalid base directory
}
}
files := listFiles(searchDir, cwd)
if len(files) == 0 {
return nil
}
// Prepend baseDir so results display as "cmd/main.go" not just "main.go".
if baseDir != "" {
for i := range files {
files[i].RelPath = baseDir + files[i].RelPath
}
}
return fuzzyFilterFiles(files, prefix, query)
}
// splitPrefixPath separates a prefix like "cmd/kit/m" into
// baseDir="cmd/kit/" and query="m". If there is no separator the
// baseDir is empty and query is the full prefix.
func splitPrefixPath(prefix string) (baseDir, query string) {
// Handle ~ expansion display (we keep it in the prefix for display
// but resolve it when actually searching).
idx := strings.LastIndex(prefix, "/")
if idx < 0 {
return "", prefix
}
return prefix[:idx+1], prefix[idx+1:]
}
// resolveSearchDir converts a baseDir from the prefix into an absolute path.
// Supports ~/, ../, and absolute paths.
func resolveSearchDir(baseDir, cwd string) string {
// Expand ~/
if strings.HasPrefix(baseDir, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, baseDir[2:])
}
}
// Absolute paths
if filepath.IsAbs(baseDir) {
return filepath.Clean(baseDir)
}
// Relative to cwd
return filepath.Join(cwd, baseDir)
}
// listFiles returns files and directories within searchDir, relative to that
// directory. Uses `git ls-files` when inside a git repo for speed and
// .gitignore awareness, otherwise falls back to os.ReadDir.
func listFiles(searchDir, cwd string) []FileSuggestion {
// Try git ls-files first (fast, respects .gitignore).
if files := listFilesGit(searchDir, cwd); files != nil {
return files
}
return listFilesReadDir(searchDir)
}
// listFilesGit uses `git ls-files` and `git ls-files --others --exclude-standard`
// to list tracked and untracked-but-not-ignored files.
func listFilesGit(searchDir, cwd string) []FileSuggestion {
// Check if we're in a git repo.
check := exec.Command("git", "rev-parse", "--show-toplevel")
check.Dir = cwd
if err := check.Run(); err != nil {
return nil
}
seen := make(map[string]bool)
var results []FileSuggestion
// Tracked files.
cmd := exec.Command("git", "ls-files")
cmd.Dir = searchDir
out, err := cmd.Output()
if err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
// Normalize separators.
line = filepath.ToSlash(line)
addFileEntries(&results, seen, line, searchDir)
}
}
// Untracked, non-ignored files.
cmd2 := exec.Command("git", "ls-files", "--others", "--exclude-standard")
cmd2.Dir = searchDir
out2, err := cmd2.Output()
if err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(out2)), "\n") {
if line == "" {
continue
}
line = filepath.ToSlash(line)
addFileEntries(&results, seen, line, searchDir)
}
}
if len(results) == 0 {
return nil
}
return results
}
// addFileEntries adds the file and any intermediate directory entries to
// results if not already seen. Paths are stored with forward slashes.
func addFileEntries(results *[]FileSuggestion, seen map[string]bool, relPath string, searchDir string) {
// Add intermediate directories as suggestions (first component only).
parts := strings.SplitN(relPath, "/", 2)
if len(parts) > 1 {
dir := parts[0] + "/"
if !seen[dir] {
seen[dir] = true
*results = append(*results, FileSuggestion{RelPath: dir, IsDir: true})
}
}
// Add the file itself.
if !seen[relPath] {
seen[relPath] = true
*results = append(*results, FileSuggestion{RelPath: relPath, IsDir: false})
}
}
// listFilesReadDir is the fallback when git is not available. Lists immediate
// children of dir via os.ReadDir, skipping hidden dirs and common noise.
func listFilesReadDir(dir string) []FileSuggestion {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
skip := map[string]bool{
".git": true, "node_modules": true, ".kit": true,
"__pycache__": true, ".venv": true, "vendor": true,
}
var results []FileSuggestion
for _, e := range entries {
name := e.Name()
if skip[name] {
continue
}
// Skip hidden files/dirs (except common config files).
if strings.HasPrefix(name, ".") && name != ".env" && name != ".gitignore" {
continue
}
if e.IsDir() {
results = append(results, FileSuggestion{RelPath: name + "/", IsDir: true})
} else {
results = append(results, FileSuggestion{RelPath: name, IsDir: false})
}
}
return results
}
// fuzzyFilterFiles scores and filters file suggestions against the query,
// returning the top maxFileSuggestions results sorted by score descending.
// Directories are boosted slightly so they appear near the top.
func fuzzyFilterFiles(files []FileSuggestion, fullPrefix, query string) []FileSuggestion {
if query == "" && fullPrefix == "" {
// No filter — return all (capped).
if len(files) > maxFileSuggestions {
files = files[:maxFileSuggestions]
}
return files
}
// When there's a base dir but no query (e.g. "cmd/"), show everything
// in that directory.
if query == "" {
var filtered []FileSuggestion
for i := range files {
if strings.HasPrefix(files[i].RelPath, fullPrefix) {
// Only show direct children of the base directory.
rest := files[i].RelPath[len(fullPrefix):]
if rest == "" {
continue
}
filtered = append(filtered, files[i])
}
}
if len(filtered) > maxFileSuggestions {
filtered = filtered[:maxFileSuggestions]
}
return filtered
}
var scored []FileSuggestion
queryLower := strings.ToLower(query)
for i := range files {
path := files[i].RelPath
// When we have a fullPrefix with a dir component, only consider
// files under that directory.
if fullPrefix != query && !strings.HasPrefix(path, fullPrefix[:len(fullPrefix)-len(query)]) {
continue
}
score := scoreFilePath(queryLower, path)
if score <= 0 {
continue
}
// Boost directories so they appear near the top for navigation.
if files[i].IsDir {
score += 10
}
files[i].Score = score
scored = append(scored, files[i])
}
// Sort by score descending.
sort.Slice(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
if len(scored) > maxFileSuggestions {
scored = scored[:maxFileSuggestions]
}
return scored
}
// scoreFilePath scores a file path against a fuzzy query. Higher is better.
// Returns 0 if there is no match.
func scoreFilePath(query, path string) int {
pathLower := strings.ToLower(path)
baseName := filepath.Base(strings.TrimSuffix(path, "/"))
baseNameLower := strings.ToLower(baseName)
// Exact basename match.
if baseNameLower == query {
return 1000
}
// Basename starts with query.
if strings.HasPrefix(baseNameLower, query) {
return 800 - len(baseName) + len(query)
}
// Basename contains query as substring.
if strings.Contains(baseNameLower, query) {
return 500 - len(baseName) + len(query)
}
// Full path contains query as substring.
if strings.Contains(pathLower, query) {
return 300 - len(path) + len(query)
}
// Fuzzy character match on basename.
if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
return score
}
// Fuzzy character match on full path.
if score := fuzzyCharMatch(query, pathLower); score > 0 {
return score - 50
}
return 0
}
// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a
// positive score if all query characters appear in order in the target.
func fuzzyCharMatch(query, target string) int {
if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) {
return 0
}
qRunes := []rune(query)
tRunes := []rune(target)
qi := 0
score := 100
consecutive := 0
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
if tRunes[ti] == qRunes[qi] {
qi++
consecutive++
score += consecutive * 5
} else {
consecutive = 0
score -= 2
}
}
if qi < len(qRunes) {
return 0
}
return score
}
+81
View File
@@ -0,0 +1,81 @@
package ui
import (
"strings"
"time"
"charm.land/lipgloss/v2"
)
// Renderer is the interface satisfied by both MessageRenderer and
// CompactRenderer. It allows model.go and cli.go to call rendering methods
// without branching on compact mode.
type Renderer interface {
RenderUserMessage(content string, timestamp time.Time) UIMessage
RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage
RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage
RenderSystemMessage(content string, timestamp time.Time) UIMessage
RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage
RenderDebugMessage(message string, timestamp time.Time) UIMessage
RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage
SetWidth(width int)
}
// Compile-time checks that both renderers satisfy the Renderer interface.
var _ Renderer = (*MessageRenderer)(nil)
var _ Renderer = (*CompactRenderer)(nil)
// parseBashOutput parses <stdout>/<stderr> tagged output from bash tool
// results, styling stderr with the theme's error color. Returns the
// combined, styled output string with tags stripped.
//
// Shared by both MessageRenderer and CompactRenderer.
func parseBashOutput(result string, theme Theme) string {
var formattedResult strings.Builder
remaining := result
for {
// Find stderr tags
stderrStart := strings.Index(remaining, "<stderr>")
stderrEnd := strings.Index(remaining, "</stderr>")
// Find stdout tags
stdoutStart := strings.Index(remaining, "<stdout>")
stdoutEnd := strings.Index(remaining, "</stdout>")
// Process whichever comes first
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
(stdoutStart == -1 || stderrStart < stdoutStart) {
// Process stderr
if stderrStart > 0 {
formattedResult.WriteString(remaining[:stderrStart])
}
stderrContent := remaining[stderrStart+8 : stderrEnd]
stderrContent = strings.Trim(stderrContent, "\n")
if len(stderrContent) > 0 {
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
formattedResult.WriteString(styledContent)
}
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
// Process stdout
if stdoutStart > 0 {
formattedResult.WriteString(remaining[:stdoutStart])
}
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
stdoutContent = strings.Trim(stdoutContent, "\n")
if len(stdoutContent) > 0 {
formattedResult.WriteString(stdoutContent)
}
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
} else {
// No more tags, add remaining content
formattedResult.WriteString(remaining)
break
}
}
return strings.TrimSpace(formattedResult.String())
}
+229 -23
View File
@@ -36,9 +36,31 @@ type InputComponent struct {
title string
submitNext bool // defer submit one tick so popup dismisses cleanly
// Argument completion state. When the user types "/cmd " followed by
// a partial argument and the command has a Complete function, the popup
// switches to argument-completion mode showing suggestions from Complete.
argMode bool // true when showing arg completions
argCommand string // command prefix for arg mode (e.g. "/bookmark")
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
// File completion state. When the user types @ followed by a partial
// file path, the popup shows file/directory suggestions from the cwd.
fileMode bool // true when showing @file completions
filePrefix string // current text after @ being matched
fileAtStartIdx int // byte offset of @ in the textarea value
fileSuggestions []FileSuggestion // backing storage for file entries
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
// cwd is the working directory used for @file path resolution and
// autocomplete suggestions. Set by the parent via SetCwd.
cwd string
// appCtrl is used for slash commands that mutate app state.
// May be nil in tests; nil-safe.
appCtrl AppController
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
hideHint bool
}
// NewInputComponent creates a new InputComponent with the given width, title,
@@ -80,6 +102,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
}
}
// SetCwd sets the working directory used for @file autocomplete suggestions
// and path resolution. Should be called by the parent after construction.
func (s *InputComponent) SetCwd(cwd string) {
s.cwd = cwd
}
// Init implements tea.Model. Starts the cursor blink animation.
func (s *InputComponent) Init() tea.Cmd {
return textarea.Blink
@@ -138,17 +166,35 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
if s.fileMode {
s.applyFileCompletion(s.selected)
} else if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
}
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
// Populate textarea with selected command and submit on next tick.
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
if s.fileMode {
// Apply file completion but don't submit.
s.applyFileCompletion(s.selected)
s.textarea.CursorEnd()
return s, nil
}
// Populate textarea with selected item and submit on next tick.
if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
}
s.textarea.CursorEnd()
s.showPopup = false
s.selected = 0
@@ -172,12 +218,57 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if value != s.lastValue {
s.lastValue = value
lines := strings.Split(value, "\n")
if len(lines) == 1 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") {
s.showPopup = true
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 0
line := lines[len(lines)-1] // current line (last line for multi-line)
// Check for @file trigger first.
cursorCol := len(line) // approximate: cursor is at end after typing
if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt && s.cwd != "" {
suggestions := GetFileSuggestions(prefix, s.cwd)
if len(suggestions) > 0 {
s.showPopup = true
s.fileMode = true
s.argMode = false
s.filePrefix = prefix
s.fileAtStartIdx = atIdx
s.fileSuggestions = suggestions
s.fileSynthCmds = make([]SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, fs := range suggestions {
name := fs.RelPath
desc := ""
if fs.IsDir {
desc = "directory"
}
s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
}
s.selected = 0
} else {
s.showPopup = false
s.fileMode = false
}
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
s.fileMode = false
if !strings.Contains(lines[0], " ") {
// Command name completion.
s.showPopup = true
s.argMode = false
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 0
} else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 {
// Argument completion for a command with a Complete function.
s.showPopup = true
// s.argMode, s.argCommand, s.argSynthCmds, s.filtered
// are set by completeArgs.
s.selected = 0
} else {
s.showPopup = false
s.argMode = false
}
} else {
s.showPopup = false
s.argMode = false
s.fileMode = false
}
}
return s, cmd
@@ -254,13 +345,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()))
}
@@ -301,16 +394,32 @@ func (s *InputComponent) renderPopup() string {
descStyle = descStyle.Foreground(lipgloss.Color("250"))
}
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
if s.fileMode {
// File mode: use full width for the path, show description
// (e.g. "directory") inline after a gap.
maxNameLen := s.width - 24
displayName := sc.Name
if len(displayName) > maxNameLen && maxNameLen > 3 {
displayName = displayName[:maxNameLen-3] + "..."
}
name := nameStyle.Render(displayName)
if sc.Description != "" {
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
} else {
items = append(items, indicator+name)
}
} else {
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
desc := sc.Description
maxDescLen := s.width - nameWidth - 14
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
desc := sc.Description
maxDescLen := s.width - nameWidth - 14
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
}
items = append(items, indicator+name+descStyle.Render(desc))
}
items = append(items, indicator+name+descStyle.Render(desc))
}
if startIdx > 0 {
@@ -326,3 +435,100 @@ 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
}
// applyFileCompletion replaces the @prefix in the textarea with the selected
// file suggestion. For directories, it keeps the popup open for further
// drilling. For files, it closes the popup and adds a trailing space.
func (s *InputComponent) applyFileCompletion(idx int) {
if idx >= len(s.fileSuggestions) {
return
}
suggestion := s.fileSuggestions[idx]
value := s.textarea.Value()
// Build the replacement text. The @ and everything after it up to the
// cursor should be replaced with @<selected path>.
// Find the current line's contribution.
lines := strings.Split(value, "\n")
lastLine := lines[len(lines)-1]
// Reconstruct: everything before the @ on the last line + @<path>
beforeAt := lastLine[:s.fileAtStartIdx]
needsQuote := strings.Contains(suggestion.RelPath, " ")
var replacement string
if needsQuote {
replacement = `@"` + suggestion.RelPath + `"`
} else {
replacement = "@" + suggestion.RelPath
}
// For files, add a trailing space. For directories, don't — allow
// continued drilling into the directory.
if !suggestion.IsDir {
replacement += " "
}
newLastLine := beforeAt + replacement
// Reconstruct the full value with the updated last line.
lines[len(lines)-1] = newLastLine
newValue := strings.Join(lines, "\n")
s.textarea.SetValue(newValue)
s.textarea.CursorEnd()
if suggestion.IsDir {
// Keep popup open — trigger a refresh for the new directory.
s.lastValue = "" // force re-evaluation on next update tick
} else {
s.showPopup = false
s.fileMode = false
s.selected = 0
}
}
+68 -317
View File
@@ -146,6 +146,12 @@ func formatToolParams(toolArgs string, maxWidth int) string {
type MessageRenderer struct {
width int
debug bool
// getToolRenderer returns extension-provided rendering overrides for a
// specific tool. May be nil if no extensions are loaded. Used in
// RenderToolMessage to check for custom header/body formatting before
// falling back to builtin renderers.
getToolRenderer func(toolName string) *ToolRendererData
}
// getSystemUsername returns the current system username, fallback to "User"
@@ -193,10 +199,7 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
theme := getTheme()
// Render the message content with the user-message background so that
// glamour-rendered markdown inherits the highlight color.
bgHex := colorHex(theme.Highlight)
messageContent := r.renderMarkdownWithBg(content, r.width-8, bgHex) // Account for padding and borders
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
// Create info line
info := fmt.Sprintf(" %s (%s)", username, timeStr)
@@ -205,13 +208,12 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
// Use the new block renderer
// Use the block renderer — left border with Primary color, no background.
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Primary),
WithBackground(theme.Highlight),
WithMarginBottom(1),
)
@@ -527,6 +529,12 @@ func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, times
func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
theme := getTheme()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
if r.getToolRenderer != nil {
extRd = r.getToolRenderer(toolName)
}
// --- Header: [icon] [name] [params] ---
var icon string
borderColor := theme.Success
@@ -539,29 +547,55 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
icon = "✓"
}
// Extension can override border color (applies to both success and error).
if extRd != nil && extRd.BorderColor != "" {
borderColor = lipgloss.Color(extRd.BorderColor)
}
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
// Extension can override display name.
displayName := toolDisplayName(toolName)
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
// Format params with width budget for the header line
// Format params with width budget for the header line.
// Check extension renderer for custom header params first.
paramBudget := max(r.width-10-len(displayName), 20)
params := formatToolParams(toolArgs, paramBudget)
var params string
if extRd != nil && extRd.RenderHeader != nil {
params = extRd.RenderHeader(toolArgs, paramBudget)
}
if params == "" {
params = formatToolParams(toolArgs, paramBudget)
}
header := iconStr + " " + nameStr
if params != "" {
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// --- Body: try tool-specific renderer first, then fall back ---
// --- Body: check extension renderer first, then builtin, then default ---
var body string
if isError {
body = lipgloss.NewStyle().
Foreground(theme.Error).
Render(toolResult)
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
if body == "" {
body = r.formatToolResult(toolName, toolResult, r.width-8)
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-8)
// Apply markdown rendering if requested and body is non-empty.
if body != "" && extRd.BodyMarkdown {
body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n")
}
}
if body == "" {
if isError {
body = lipgloss.NewStyle().
Foreground(theme.Error).
Render(toolResult)
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
if body == "" {
body = r.formatToolResult(toolName, toolResult, r.width-8)
}
}
}
@@ -572,15 +606,23 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
Render("(no output)")
}
// Combine header + body into a single block
// Combine header + body into a single block.
fullContent := header + "\n\n" + strings.TrimSuffix(body, "\n")
// Build rendering options; extension can override background.
blockOpts := []renderingOption{
WithAlign(lipgloss.Left),
WithBorderColor(borderColor),
WithMarginBottom(1),
}
if extRd != nil && extRd.Background != "" {
blockOpts = append(blockOpts, WithBackground(lipgloss.Color(extRd.Background)))
}
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(borderColor),
WithMarginBottom(1),
blockOpts...,
)
return UIMessage{
@@ -653,75 +695,14 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s
Render(result)
}
// formatBashOutput formats bash command output with proper section handling
// formatBashOutput formats bash command output with proper section handling.
// Delegates tag parsing to the shared parseBashOutput helper.
func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string {
baseStyle := lipgloss.NewStyle()
// Replace tag pairs with styled content
var formattedResult strings.Builder
remaining := result
for {
// Find stderr tags
stderrStart := strings.Index(remaining, "<stderr>")
stderrEnd := strings.Index(remaining, "</stderr>")
// Find stdout tags
stdoutStart := strings.Index(remaining, "<stdout>")
stdoutEnd := strings.Index(remaining, "</stdout>")
// Process whichever comes first
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
(stdoutStart == -1 || stderrStart < stdoutStart) {
// Process stderr
// Add content before the tag
if stderrStart > 0 {
formattedResult.WriteString(remaining[:stderrStart])
}
// Extract and style stderr content
stderrContent := remaining[stderrStart+8 : stderrEnd]
// Trim leading/trailing newlines but preserve internal ones
stderrContent = strings.Trim(stderrContent, "\n")
if len(stderrContent) > 0 {
styledContent := baseStyle.Foreground(theme.Error).Render(stderrContent)
formattedResult.WriteString(styledContent)
}
// Continue with remaining content
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
// Process stdout
// Add content before the tag
if stdoutStart > 0 {
formattedResult.WriteString(remaining[:stdoutStart])
}
// Extract stdout content (no special styling needed)
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
// Trim leading/trailing newlines but preserve internal ones
stdoutContent = strings.Trim(stdoutContent, "\n")
if len(stdoutContent) > 0 {
formattedResult.WriteString(stdoutContent)
}
// Continue with remaining content
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
} else {
// No more tags, add remaining content
formattedResult.WriteString(remaining)
break
}
}
// Trim any leading/trailing whitespace from the final result
finalResult := strings.TrimSpace(formattedResult.String())
return baseStyle.
parsed := parseBashOutput(result, theme)
return lipgloss.NewStyle().
Width(width).
Foreground(theme.Muted).
Render(finalResult)
Render(parsed)
}
// renderMarkdown renders markdown content using glamour
@@ -729,233 +710,3 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
rendered := toMarkdown(content, width)
return strings.TrimSuffix(rendered, "\n")
}
// renderMarkdownWithBg renders markdown content using glamour with a background
// color applied to every element so the output blends with a colored block.
func (r *MessageRenderer) renderMarkdownWithBg(content string, width int, bgHex string) string {
rendered := toMarkdownWithBg(content, width, bgHex)
return strings.TrimSuffix(rendered, "\n")
}
// MessageContainer manages a collection of UI messages, handling their display,
// updates, and layout within the terminal. It supports both standard and compact
// display modes and maintains state for streaming message updates.
type MessageContainer struct {
messages []UIMessage
width int
height int
compactMode bool // Add compact mode flag
modelName string // Store current model name
wasCleared bool // Track if container was explicitly cleared
}
// NewMessageContainer creates and initializes a new MessageContainer with the
// specified dimensions and display mode. The container starts empty and will
// display a welcome message until the first message is added.
func NewMessageContainer(width, height int, compact bool) *MessageContainer {
return &MessageContainer{
messages: make([]UIMessage, 0),
width: width,
height: height,
compactMode: compact,
}
}
// AddMessage appends a new UIMessage to the container's collection and resets
// the cleared state flag. Messages are displayed in the order they were added.
func (c *MessageContainer) AddMessage(msg UIMessage) {
c.messages = append(c.messages, msg)
c.wasCleared = false // Reset the cleared flag when adding messages
}
// SetModelName updates the AI model name used for rendering assistant messages.
// This name is displayed in message headers to indicate which model is responding.
func (c *MessageContainer) SetModelName(modelName string) {
c.modelName = modelName
}
// UpdateLastMessage efficiently updates the content of the most recent message
// in the container. This is primarily used for streaming responses where the
// assistant's message is progressively built. Only works for assistant messages.
func (c *MessageContainer) UpdateLastMessage(content string) {
if len(c.messages) == 0 {
return
}
lastIdx := len(c.messages) - 1
lastMsg := &c.messages[lastIdx]
// Only re-render if content actually changed and it's an assistant message
if lastMsg.Type == AssistantMessage {
// Create appropriate renderer based on compact mode
var newMsg UIMessage
if c.compactMode {
compactRenderer := NewCompactRenderer(c.width, false)
newMsg = compactRenderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName)
} else {
renderer := NewMessageRenderer(c.width, false)
newMsg = renderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName)
}
newMsg.Streaming = lastMsg.Streaming // Preserve streaming state
c.messages[lastIdx] = newMsg
}
}
// Clear removes all messages from the container and sets a flag to prevent
// showing the welcome screen. Used when starting a fresh conversation.
func (c *MessageContainer) Clear() {
c.messages = make([]UIMessage, 0)
c.wasCleared = true
}
// SetSize updates the container's dimensions, typically called when the terminal
// is resized. This affects how messages are wrapped and displayed.
func (c *MessageContainer) SetSize(width, height int) {
c.width = width
c.height = height
}
// Render generates the complete visual representation of all messages in the
// container. Returns an empty state display if no messages exist, or formats
// all messages according to the current display mode (standard or compact).
func (c *MessageContainer) Render() string {
if len(c.messages) == 0 {
// Don't show welcome box if explicitly cleared
if c.wasCleared {
return ""
}
if c.compactMode {
return c.renderCompactEmptyState()
}
return c.renderEmptyState()
}
if c.compactMode {
return c.renderCompactMessages()
}
var parts []string
for i, msg := range c.messages {
// Center each message horizontally
centeredMsg := lipgloss.PlaceHorizontal(
c.width,
lipgloss.Center,
msg.Content,
)
parts = append(parts, centeredMsg)
// Add spacing between messages (except after the last one)
if i < len(c.messages)-1 {
parts = append(parts, "")
}
}
style := lipgloss.NewStyle().
Width(c.width)
// No padding needed between messages
return style.Render(
lipgloss.JoinVertical(lipgloss.Top, parts...),
)
}
// renderEmptyState renders an enhanced initial empty state
func (c *MessageContainer) renderEmptyState() string {
baseStyle := lipgloss.NewStyle()
// Create a welcome box with border
theme := getTheme()
welcomeBox := baseStyle.
Width(c.width-4).
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.System).
Padding(2, 4).
Align(lipgloss.Center)
// Main title
title := baseStyle.
Foreground(theme.System).
Bold(true).
Render("KIT")
// Subtitle with better typography
subtitle := baseStyle.
Foreground(theme.Primary).
Bold(true).
MarginTop(1).
Render("AI Assistant with MCP Tools")
// Feature highlights
features := []string{
"Natural language conversations",
"Powerful tool integrations",
"Multi-provider LLM support",
"Usage tracking & analytics",
}
var featureList []string
for _, feature := range features {
featureList = append(featureList, baseStyle.
Foreground(theme.Muted).
MarginLeft(2).
Render("• "+feature))
}
// Getting started prompt
prompt := baseStyle.
Foreground(theme.Accent).
Italic(true).
MarginTop(2).
Render("Start by typing your message below or use /help for commands")
// Combine all elements
content := lipgloss.JoinVertical(
lipgloss.Center,
title,
subtitle,
"",
lipgloss.JoinVertical(lipgloss.Left, featureList...),
"",
prompt,
)
welcomeContent := welcomeBox.Render(content)
// Center the welcome box vertically
return baseStyle.
Width(c.width).
Height(c.height).
Align(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(welcomeContent)
}
// renderCompactMessages renders messages in compact format
func (c *MessageContainer) renderCompactMessages() string {
var lines []string
for _, msg := range c.messages {
lines = append(lines, msg.Content)
}
return strings.Join(lines, "\n")
}
// renderCompactEmptyState renders a simple empty state for compact mode
func (c *MessageContainer) renderCompactEmptyState() string {
theme := getTheme()
// Simple compact welcome
welcome := lipgloss.NewStyle().
Foreground(theme.System).
Bold(true).
Render("KIT - AI Assistant with MCP Tools")
help := lipgloss.NewStyle().
Foreground(theme.Muted).
Render("Type your message or /help for commands")
return fmt.Sprintf("%s\n%s\n\n", welcome, help)
}
+1056 -116
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -53,6 +53,10 @@ func (s *stubAppController) GetTreeSession() *session.TreeManager {
return nil
}
func (s *stubAppController) SendEvent(_ tea.Msg) {
// no-op in tests
}
// --------------------------------------------------------------------------
// Stub child components
// --------------------------------------------------------------------------
@@ -101,7 +105,6 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
stream: stream,
input: input,
renderer: NewMessageRenderer(80, false),
compactRdr: NewCompactRenderer(80, false),
compactMode: false,
modelName: "test-model",
width: 80,
+316
View File
@@ -0,0 +1,316 @@
package ui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ---------------------------------------------------------------------------
// Overlay dialog — modal overlay rendered by AppModel when active
// ---------------------------------------------------------------------------
// overlayResult carries the synchronous outcome of an overlay dialog update.
// A non-nil value means the overlay is done (completed or cancelled); nil
// means the overlay is still active.
type overlayResult struct {
completed bool
cancelled bool
action string
index int
}
// overlayDialog holds the state of an active modal overlay dialog. It is
// created when an OverlayRequestEvent arrives and destroyed when the user
// completes or cancels. The AppModel owns the overlay and routes messages
// to it while in stateOverlay.
type overlayDialog struct {
title string
content string
markdown bool
borderColor string
background string
actions []string
selAction int // selected action index
scrollOff int // scroll offset for content body
totalLines int // total body lines (computed on render)
width int // terminal width
height int // terminal height
dialogWidth int // configured dialog width (0 = auto)
maxHeight int // configured max height (0 = auto)
anchor string
}
// newOverlayDialog creates an overlay dialog from an OverlayRequestEvent's
// parameters.
func newOverlayDialog(title, content string, markdown bool, borderColor, background string, width, maxHeight int, anchor string, actions []string, termWidth, termHeight int) *overlayDialog {
return &overlayDialog{
title: title,
content: content,
markdown: markdown,
borderColor: borderColor,
background: background,
actions: actions,
dialogWidth: width,
maxHeight: maxHeight,
anchor: anchor,
width: termWidth,
height: termHeight,
}
}
// Init returns the initial command for the overlay. Currently no-op.
func (o *overlayDialog) Init() tea.Cmd {
return nil
}
// Update handles messages for the overlay dialog. It returns a non-nil
// *overlayResult when the user completes or cancels. The returned tea.Cmd
// is always nil (overlays don't produce async commands).
func (o *overlayDialog) Update(msg tea.Msg) (*overlayResult, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
o.width = msg.Width
o.height = msg.Height
return nil, nil
case tea.KeyPressMsg:
return o.handleKey(msg)
}
return nil, nil
}
func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd) {
switch msg.String() {
case "esc":
return &overlayResult{cancelled: true}, nil
case "enter":
if len(o.actions) > 0 {
action := ""
if o.selAction < len(o.actions) {
action = o.actions[o.selAction]
}
return &overlayResult{completed: true, action: action, index: o.selAction}, nil
}
// No actions — Enter dismisses (not cancelled).
return &overlayResult{completed: true, action: "", index: -1}, nil
// Content scrolling
case "up", "k":
if o.scrollOff > 0 {
o.scrollOff--
}
case "down", "j":
// Clamped in Render; allow incrementing freely.
o.scrollOff++
case "home", "g":
o.scrollOff = 0
case "end", "G":
// Set to a large value; Render will clamp.
o.scrollOff = o.totalLines
// Action navigation
case "left", "h":
if len(o.actions) > 0 && o.selAction > 0 {
o.selAction--
}
case "right", "l":
if len(o.actions) > 0 && o.selAction < len(o.actions)-1 {
o.selAction++
}
case "tab":
if len(o.actions) > 0 {
o.selAction = (o.selAction + 1) % len(o.actions)
}
}
return nil, nil
}
// Render returns the overlay dialog as a styled string for full-view
// composition. The dialog is a bordered box centered (or anchored)
// horizontally within the terminal width.
func (o *overlayDialog) Render() string {
theme := GetTheme()
// Calculate dialog dimensions.
dw := o.dialogWidth
if dw == 0 {
dw = o.width * 60 / 100
}
if dw < 30 {
dw = 30
}
if dw > o.width-4 {
dw = o.width - 4
}
mh := o.maxHeight
if mh == 0 {
mh = o.height * 80 / 100
}
if mh < 8 {
mh = 8
}
if mh > o.height-2 {
mh = o.height - 2
}
// Inner width accounts for border (2) + horizontal padding (2 left + 1 right).
innerWidth := max(dw-5, 10)
// Render body text (potentially as markdown).
bodyText := o.content
if o.markdown {
bodyText = toMarkdown(bodyText, innerWidth)
}
bodyText = strings.TrimRight(bodyText, "\n")
bodyLines := strings.Split(bodyText, "\n")
o.totalLines = len(bodyLines)
// Calculate available height for the scrollable body.
// Chrome: border(2) + padTop(1) + padBottom(1) + hintLine(1) = 5
chromeLines := 5
if o.title != "" {
chromeLines += 2 // title line + separator line
}
if len(o.actions) > 0 {
chromeLines += 2 // separator line + action bar
}
maxBodyLines := max(mh-chromeLines, 1)
scrollable := len(bodyLines) > maxBodyLines
if scrollable {
// Clamp scroll offset.
maxOff := len(bodyLines) - maxBodyLines
if o.scrollOff > maxOff {
o.scrollOff = maxOff
}
if o.scrollOff < 0 {
o.scrollOff = 0
}
bodyLines = bodyLines[o.scrollOff : o.scrollOff+maxBodyLines]
} else {
o.scrollOff = 0
}
// Build the content to render inside the border.
var parts []string
// Title + separator.
if o.title != "" {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Text)
parts = append(parts, titleStyle.Render(o.title))
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(repeatRune('─', innerWidth)))
}
// Body content.
parts = append(parts, "")
parts = append(parts, strings.Join(bodyLines, "\n"))
// Scroll indicator.
if scrollable {
indicator := fmt.Sprintf("(%d%d of %d lines)",
o.scrollOff+1,
min(o.scrollOff+maxBodyLines, o.totalLines),
o.totalLines)
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Render(indicator))
} else {
parts = append(parts, "")
}
// Action bar.
if len(o.actions) > 0 {
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(repeatRune('─', innerWidth)))
var actionParts []string
for i, a := range o.actions {
if i == o.selAction {
actionParts = append(actionParts,
lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).Render("> "+a))
} else {
actionParts = append(actionParts,
lipgloss.NewStyle().Foreground(theme.Text).Render(" "+a))
}
}
parts = append(parts, strings.Join(actionParts, " "))
}
innerContent := strings.Join(parts, "\n")
// Resolve border color.
borderClr := lipgloss.Color("#89b4fa") // default blue
if o.borderColor != "" {
borderClr = lipgloss.Color(o.borderColor)
}
// Build the dialog box style.
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderClr).
Width(dw-2). // -2 for border chars
Padding(1, 1, 1, 2).
Foreground(theme.Text)
if o.background != "" {
dialogStyle = dialogStyle.Background(lipgloss.Color(o.background))
}
dialog := dialogStyle.Render(innerContent)
// Key hints below the dialog.
var hints []string
if scrollable {
hints = append(hints, "↑/↓ scroll")
}
if len(o.actions) > 0 {
hints = append(hints, "←/→ switch")
hints = append(hints, "Enter select")
} else {
hints = append(hints, "Enter dismiss")
}
hints = append(hints, "Esc cancel")
hintText := lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" " + strings.Join(hints, " "))
full := lipgloss.JoinVertical(lipgloss.Left, dialog, hintText)
// Center horizontally within the terminal width.
centered := lipgloss.PlaceHorizontal(o.width, lipgloss.Center, full)
// Apply vertical positioning based on anchor.
// Calculate how many lines we have and how many we need.
contentHeight := lipgloss.Height(centered)
if contentHeight < o.height {
switch o.anchor {
case "top-center":
// Add one blank line at top for breathing room.
centered = "\n" + centered
case "bottom-center":
// Pad from the top so the dialog sits near the bottom.
topPad := o.height - contentHeight - 1
if topPad > 0 {
centered = strings.Repeat("\n", topPad) + centered
}
default: // "center"
// Vertically center within available height.
topPad := (o.height - contentHeight) / 2
if topPad > 0 {
centered = strings.Repeat("\n", topPad) + centered
}
}
}
return centered
}
+286
View File
@@ -0,0 +1,286 @@
package ui
import (
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ---------------------------------------------------------------------------
// Prompt overlay — modal prompt rendered by AppModel when active
// ---------------------------------------------------------------------------
// promptMode indicates the type of interactive prompt being displayed.
type promptMode string
const (
promptModeSelect promptMode = "select"
promptModeConfirm promptMode = "confirm"
promptModeInput promptMode = "input"
)
// promptResult carries the synchronous outcome of a prompt overlay update.
// A non-nil value means the prompt is done (completed or cancelled); nil
// means the overlay is still active.
type promptResult struct {
completed bool
cancelled bool
value string
index int
confirmed bool
}
// promptOverlay holds the state of an active interactive prompt. It is
// created when a PromptRequestEvent arrives and destroyed when the user
// completes or cancels. The AppModel owns the overlay and routes messages
// to it while in statePrompt.
type promptOverlay struct {
mode promptMode
message string
options []string // select: available choices
selected int // select: currently highlighted index
confirmed bool // confirm: current yes/no value
inputTA textarea.Model // input: text editor
width int
height int
}
// newSelectPrompt creates a prompt overlay for a selection list.
func newSelectPrompt(message string, options []string, width, height int) *promptOverlay {
return &promptOverlay{
mode: promptModeSelect,
message: message,
options: options,
width: width,
height: height,
}
}
// newConfirmPrompt creates a prompt overlay for a yes/no confirmation.
func newConfirmPrompt(message string, defaultValue bool, width, height int) *promptOverlay {
return &promptOverlay{
mode: promptModeConfirm,
message: message,
confirmed: defaultValue,
width: width,
height: height,
}
}
// newInputPrompt creates a prompt overlay for free-form text input.
func newInputPrompt(message, placeholder, defaultValue string, width, height int) *promptOverlay {
ta := textarea.New()
ta.Placeholder = placeholder
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 1000
ta.SetWidth(width - 12) // account for border + padding
ta.SetHeight(1)
ta.Focus()
// Prevent Enter from inserting a newline — we intercept it for submit.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "alt+enter"),
)
if defaultValue != "" {
ta.SetValue(defaultValue)
ta.CursorEnd()
}
return &promptOverlay{
mode: promptModeInput,
message: message,
inputTA: ta,
width: width,
height: height,
}
}
// Init returns the initial command for the prompt overlay. For input mode
// this starts the cursor blink animation.
func (p *promptOverlay) Init() tea.Cmd {
if p.mode == promptModeInput {
return textarea.Blink
}
return nil
}
// Update handles messages for the prompt overlay. It returns a non-nil
// *promptResult when the user completes or cancels the prompt. The returned
// tea.Cmd is for textarea blink ticks (input mode only).
func (p *promptOverlay) Update(msg tea.Msg) (*promptResult, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width = msg.Width
p.height = msg.Height
if p.mode == promptModeInput {
p.inputTA.SetWidth(p.width - 12)
}
return nil, nil
case tea.KeyPressMsg:
switch p.mode {
case promptModeSelect:
return p.updateSelect(msg)
case promptModeConfirm:
return p.updateConfirm(msg)
case promptModeInput:
return p.updateInput(msg)
}
}
// Pass non-key messages to textarea for blink animation.
if p.mode == promptModeInput {
var cmd tea.Cmd
p.inputTA, cmd = p.inputTA.Update(msg)
return nil, cmd
}
return nil, nil
}
func (p *promptOverlay) updateSelect(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
switch msg.String() {
case "up", "k":
if p.selected > 0 {
p.selected--
}
case "down", "j":
if p.selected < len(p.options)-1 {
p.selected++
}
case "home":
p.selected = 0
case "end":
if len(p.options) > 0 {
p.selected = len(p.options) - 1
}
case "enter":
value := ""
if p.selected < len(p.options) {
value = p.options[p.selected]
}
return &promptResult{completed: true, value: value, index: p.selected}, nil
case "esc":
return &promptResult{cancelled: true}, nil
}
return nil, nil
}
func (p *promptOverlay) updateConfirm(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
switch msg.String() {
case "left", "h", "y", "Y":
p.confirmed = true
case "right", "l", "n", "N":
p.confirmed = false
case "tab":
p.confirmed = !p.confirmed
case "enter":
return &promptResult{completed: true, confirmed: p.confirmed}, nil
case "esc":
return &promptResult{cancelled: true}, nil
}
return nil, nil
}
func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
switch msg.String() {
case "enter":
return &promptResult{completed: true, value: p.inputTA.Value()}, nil
case "esc":
return &promptResult{cancelled: true}, nil
default:
// Delegate character input, backspace, cursor movement, etc.
var cmd tea.Cmd
p.inputTA, cmd = p.inputTA.Update(msg)
return nil, cmd
}
}
// Render returns the prompt as a styled string for inline composition in the
// AppModel layout. The prompt replaces the normal input area (below the
// separator and above the status bar) rather than taking over the full screen.
func (p *promptOverlay) Render() string {
theme := GetTheme()
var content string
switch p.mode {
case promptModeSelect:
content = p.viewSelect(theme)
case promptModeConfirm:
content = p.viewConfirm(theme)
case promptModeInput:
content = p.viewInput(theme)
}
return renderContentBlock(content, p.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Accent),
WithPaddingTop(0),
WithPaddingBottom(0),
)
}
func (p *promptOverlay) viewSelect(theme Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
for i, opt := range p.options {
if i == p.selected {
cursor := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render("> ")
label := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render(opt)
lines = append(lines, " "+cursor+label)
} else {
lines = append(lines, " "+lipgloss.NewStyle().Foreground(theme.Text).Render(opt))
}
}
lines = append(lines, "")
lines = append(lines, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" up/down navigate Enter select Esc cancel"))
return strings.Join(lines, "\n")
}
func (p *promptOverlay) viewConfirm(theme Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
yesStyle := lipgloss.NewStyle().Foreground(theme.Text)
noStyle := lipgloss.NewStyle().Foreground(theme.Text)
if p.confirmed {
yesStyle = yesStyle.Bold(true).Foreground(theme.Accent)
} else {
noStyle = noStyle.Bold(true).Foreground(theme.Accent)
}
yes := yesStyle.Render("[Yes]")
no := noStyle.Render("[No]")
lines = append(lines, " "+yes+" "+no)
lines = append(lines, "")
lines = append(lines, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" left/right switch y/n Enter confirm Esc cancel"))
return strings.Join(lines, "\n")
}
func (p *promptOverlay) viewInput(theme Theme) string {
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
lines = append(lines, "")
lines = append(lines, p.inputTA.View())
lines = append(lines, "")
lines = append(lines, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" Enter submit Esc cancel"))
return strings.Join(lines, "\n")
}
+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()))
+14 -9
View File
@@ -15,18 +15,20 @@ import (
// The KITT-style frames are generated by knightRiderFrames() in stream.go
// (same package) and use the active theme colors.
type Spinner struct {
frames []string
fps time.Duration
done chan struct{}
once sync.Once
frames []string
fps time.Duration
done chan struct{}
finished chan struct{} // closed by run() after cleanup
once sync.Once
}
// NewSpinner creates a new animated KITT-style spinner using theme colors.
func NewSpinner() *Spinner {
return &Spinner{
frames: knightRiderFrames(),
fps: time.Second / 14,
done: make(chan struct{}),
frames: knightRiderFrames(),
fps: time.Second / 14,
done: make(chan struct{}),
finished: make(chan struct{}),
}
}
@@ -36,14 +38,17 @@ func (s *Spinner) Start() {
go s.run()
}
// Stop halts the spinner animation and cleans up. This method blocks until
// the animation goroutine has exited and the line is cleared.
// Stop halts the spinner animation and blocks until the animation goroutine
// has exited and the line is cleared. Safe to call multiple times.
func (s *Spinner) Stop() {
s.once.Do(func() { close(s.done) })
<-s.finished
}
// run is the animation loop that renders spinner frames to stderr.
func (s *Spinner) run() {
defer close(s.finished) // unblock Stop()
ticker := time.NewTicker(s.fps)
defer ticker.Stop()
+9 -45
View File
@@ -23,15 +23,9 @@ func BaseStyle() lipgloss.Style {
// GetMarkdownRenderer creates and returns a configured glamour.TermRenderer for
// rendering markdown content with syntax highlighting and proper formatting. The
// renderer is customized with our theme colors and adapted to the specified width.
// An optional background color hex string (e.g. "#45475a") can be provided so
// that the rendered markdown inherits the background color.
func GetMarkdownRenderer(width int, bgHex ...string) *glamour.TermRenderer {
var bg string
if len(bgHex) > 0 {
bg = bgHex[0]
}
func GetMarkdownRenderer(width int) *glamour.TermRenderer {
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(generateMarkdownStyleConfig(bg)),
glamour.WithStyles(generateMarkdownStyleConfig()),
glamour.WithWordWrap(width),
)
return r
@@ -100,32 +94,15 @@ func resolveColorScheme() colorScheme {
}
// generateMarkdownStyleConfig creates an ansi.StyleConfig for markdown rendering.
// An optional background color hex string can be provided; when non-empty it is
// applied to the Document, Paragraph, List, and BlockQuote elements so that
// glamour-rendered content inherits the background uniformly.
func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
func generateMarkdownStyleConfig() ansi.StyleConfig {
cs := resolveColorScheme()
// Background color for indent/whitespace tokens inside glamour.
// When empty the tokens are transparent.
bgColor := ""
if len(bgHex) > 0 && bgHex[0] != "" {
bgColor = bgHex[0]
}
// Document-level background (propagates to child elements).
var docBg *string
if bgColor != "" {
docBg = &bgColor
}
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "",
BlockSuffix: "",
Color: &cs.text,
BackgroundColor: docBg,
BlockPrefix: "",
BlockSuffix: "",
Color: &cs.text,
},
Margin: uintPtr(0), // Remove margin to prevent spacing
},
@@ -135,13 +112,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
Italic: new(true),
Prefix: "┃ ",
},
Indent: uintPtr(1),
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
Indent: uintPtr(1),
},
List: ansi.StyleList{
LevelIndent: 0, // Remove list indentation
StyleBlock: ansi.StyleBlock{
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
StylePrimitive: ansi.StylePrimitive{
Color: &cs.text,
},
@@ -316,13 +291,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
Color: &cs.link,
},
Text: ansi.StylePrimitive{
Color: &cs.text,
BackgroundColor: docBg,
Color: &cs.text,
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: &cs.text,
BackgroundColor: docBg,
Color: &cs.text,
},
},
}
@@ -334,12 +307,3 @@ func toMarkdown(content string, width int) string {
rendered, _ := r.Render(content)
return rendered
}
// toMarkdownWithBg renders markdown content using glamour with a background
// color applied to all elements so the rendered text blends with the block's
// background.
func toMarkdownWithBg(content string, width int, bgHex string) string {
r := GetMarkdownRenderer(width, bgHex)
rendered, _ := r.Render(content)
return rendered
}
+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:
+84 -1
View File
@@ -1,6 +1,11 @@
package kit
import "github.com/mark3labs/kit/internal/extensions"
import (
"strings"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
)
// bridgeExtensions registers extension event handlers as SDK hooks and
// subscribes to SDK observation events to forward them to the extension runner.
@@ -97,4 +102,82 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
}
})
}
// --- Context filtering hook ---
// Extension ContextPrepare → SDK ContextPrepare hook.
if runner.HasHandlers(extensions.ContextPrepare) {
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
// Convert fantasy.Message slice to extension ContextMessage slice.
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
var text strings.Builder
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
text.WriteString(tp.Text)
}
}
extMsgs[i] = extensions.ContextMessage{
Index: i,
Role: string(msg.Role),
Content: text.String(),
}
}
result, _ := runner.Emit(extensions.ContextPrepareEvent{Messages: extMsgs})
r, ok := result.(extensions.ContextPrepareResult)
if !ok || r.Messages == nil {
return nil
}
// Rebuild fantasy.Message slice from extension result.
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
for _, cm := range r.Messages {
if cm.Index >= 0 && cm.Index < len(h.Messages) {
// Reuse original message (preserves tool calls, reasoning, etc.)
rebuilt = append(rebuilt, h.Messages[cm.Index])
} else {
// New message injected by extension.
role := fantasy.MessageRoleUser
switch cm.Role {
case "assistant":
role = fantasy.MessageRoleAssistant
case "system":
role = fantasy.MessageRoleSystem
case "tool":
role = fantasy.MessageRoleTool
}
rebuilt = append(rebuilt, fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: cm.Content},
},
})
}
}
return &ContextPrepareResult{Messages: rebuilt}
})
}
// --- Compaction hook ---
// Extension BeforeCompact → SDK BeforeCompact hook.
if runner.HasHandlers(extensions.BeforeCompact) {
m.OnBeforeCompact(HookPriorityNormal, func(h BeforeCompactHook) *BeforeCompactResult {
result, _ := runner.Emit(extensions.BeforeCompactEvent{
EstimatedTokens: h.EstimatedTokens,
ContextLimit: h.ContextLimit,
UsagePercent: h.UsagePercent,
MessageCount: h.MessageCount,
IsAutomatic: h.IsAutomatic,
})
if r, ok := result.(extensions.BeforeCompactResult); ok && r.Cancel {
return &BeforeCompactResult{
Cancel: true,
Reason: r.Reason,
}
}
return nil
})
}
}
+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
// ---------------------------------------------------------------------------
+763 -28
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"charm.land/fantasy"
@@ -13,6 +14,9 @@ import (
"github.com/mark3labs/kit/internal/agent"
"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"
@@ -47,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
@@ -57,15 +70,693 @@ func (m *Kit) Subscribe(listener EventListener) func() {
}
// GetExtRunner returns the extension runner (nil if extensions are disabled).
//
// Deprecated: Use SetExtensionContext and EmitSessionStart instead. GetExtRunner
// leaks the internal extensions.Runner type across the SDK boundary.
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
//
// Deprecated: Use GetBufferedDebugMessages instead.
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
// GetAgent returns the underlying agent. Callers that need the raw agent
// (e.g. for GetTools(), GetLoadingMessage()) can use this.
// GetAgent returns the underlying agent.
//
// Deprecated: Use GetToolNames, GetLoadingMessage, GetLoadedServerNames,
// GetMCPToolCount, GetExtensionToolCount instead.
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
// --------------------------------------------------------------------------
// Narrow accessors — prefer these over GetAgent/GetExtRunner/GetBufferedLogger
// --------------------------------------------------------------------------
// GetToolNames returns the names of all tools available to the agent.
func (m *Kit) GetToolNames() []string {
agentTools := m.agent.GetTools()
names := make([]string, len(agentTools))
for i, t := range agentTools {
names[i] = t.Info().Name
}
return names
}
// GetLoadingMessage returns the agent's startup info message (e.g. GPU
// fallback info), or empty string if none.
func (m *Kit) GetLoadingMessage() string {
return m.agent.GetLoadingMessage()
}
// GetLoadedServerNames returns the names of successfully loaded MCP servers.
func (m *Kit) GetLoadedServerNames() []string {
return m.agent.GetLoadedServerNames()
}
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
func (m *Kit) GetMCPToolCount() int {
return m.agent.GetMCPToolCount()
}
// GetExtensionToolCount returns the number of tools registered by extensions.
func (m *Kit) GetExtensionToolCount() int {
return m.agent.GetExtensionToolCount()
}
// GetBufferedDebugMessages returns any debug messages that were buffered
// during initialization, then clears the buffer. Returns nil if no messages
// were buffered or if buffered logging was not configured.
func (m *Kit) GetBufferedDebugMessages() []string {
if m.bufferedLogger == nil {
return nil
}
return m.bufferedLogger.GetMessages()
}
// SetExtensionContext configures the extension runner with the given context
// functions. No-op if extensions are disabled.
func (m *Kit) SetExtensionContext(ctx extensions.Context) {
if m.extRunner != nil {
m.extRunner.SetContext(ctx)
}
}
// GetExtensionContext returns the current extension runtime context.
// Returns a zero Context if extensions are disabled.
func (m *Kit) GetExtensionContext() extensions.Context {
if m.extRunner != nil {
return m.extRunner.GetContext()
}
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() {
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionStart) {
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{})
}
}
// ExtensionCommands returns the slash commands registered by extensions.
// Returns nil if extensions are disabled or no commands are registered.
func (m *Kit) ExtensionCommands() []extensions.CommandDef {
if m.extRunner == nil {
return nil
}
return m.extRunner.RegisteredCommands()
}
// SetExtensionWidget places or updates a persistent extension widget.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionWidget(config extensions.WidgetConfig) {
if m.extRunner != nil {
m.extRunner.SetWidget(config)
}
}
// RemoveExtensionWidget removes a previously placed extension widget by ID.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) RemoveExtensionWidget(id string) {
if m.extRunner != nil {
m.extRunner.RemoveWidget(id)
}
}
// GetExtensionWidgets returns extension widgets matching the given placement.
// Returns nil if extensions are disabled or no widgets match.
func (m *Kit) GetExtensionWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetWidgets(placement)
}
// SetExtensionHeader places or replaces the custom header from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionHeader(config extensions.HeaderFooterConfig) {
if m.extRunner != nil {
m.extRunner.SetHeader(config)
}
}
// RemoveExtensionHeader removes the custom extension header.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) RemoveExtensionHeader() {
if m.extRunner != nil {
m.extRunner.RemoveHeader()
}
}
// GetExtensionHeader returns the current custom header, or nil if none is set.
// Returns nil if extensions are disabled.
func (m *Kit) GetExtensionHeader() *extensions.HeaderFooterConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetHeader()
}
// SetExtensionFooter places or replaces the custom footer from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionFooter(config extensions.HeaderFooterConfig) {
if m.extRunner != nil {
m.extRunner.SetFooter(config)
}
}
// RemoveExtensionFooter removes the custom extension footer.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) RemoveExtensionFooter() {
if m.extRunner != nil {
m.extRunner.RemoveFooter()
}
}
// GetExtensionFooter returns the current custom footer, or nil if none is set.
// Returns nil if extensions are disabled.
func (m *Kit) GetExtensionFooter() *extensions.HeaderFooterConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetFooter()
}
// GetExtensionToolRenderer returns the custom renderer for the named tool, or
// nil if no extension registered a renderer for it. Returns nil if extensions
// are disabled.
func (m *Kit) GetExtensionToolRenderer(toolName string) *extensions.ToolRenderConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetToolRenderer(toolName)
}
// SetExtensionEditor installs an editor interceptor from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionEditor(config extensions.EditorConfig) {
if m.extRunner != nil {
m.extRunner.SetEditor(config)
}
}
// ResetExtensionEditor removes the active editor interceptor from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) ResetExtensionEditor() {
if m.extRunner != nil {
m.extRunner.ResetEditor()
}
}
// GetExtensionEditor returns the current editor interceptor, or nil if none
// is set. Returns nil if extensions are disabled.
func (m *Kit) GetExtensionEditor() *extensions.EditorConfig {
if m.extRunner == nil {
return nil
}
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 strings.Builder
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content.WriteString(pt.Text)
case message.ReasoningContent:
content.WriteString(pt.Thinking)
case message.ToolCall:
fmt.Fprintf(&content, "[tool_call: %s(%s)]", pt.Name, pt.Input)
case message.ToolResult:
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
}
}
msgs = append(msgs, extensions.SessionMessage{
ID: me.ID,
ParentID: me.ParentID,
Role: string(msg.Role),
Content: content.String(),
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
}
// Options configures Kit creation with optional overrides for model,
// prompts, configuration, and behavior settings. All fields are optional
// and will use CLI defaults if not specified.
@@ -93,12 +784,25 @@ type Options struct {
AutoCompact bool // Auto-compact when near context limit
CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults)
// CLI-specific fields (ignored by programmatic SDK users)
MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set)
ShowSpinner bool // Show loading spinner for Ollama models
SpinnerFunc SpinnerFunc // Spinner implementation (nil = no spinner)
UseBufferedLogger bool // Buffer debug messages for later display
Debug bool // Enable debug logging
// Debug enables debug logging for the SDK.
Debug bool
// CLI is optional CLI-specific configuration. SDK users leave this nil.
CLI *CLIOptions
}
// CLIOptions holds fields only relevant to the CLI binary. SDK users should
// not need these; they are separated to keep the main Options struct clean.
type CLIOptions struct {
// MCPConfig is a pre-loaded MCP config. When set, LoadAndValidateConfig
// is skipped during Kit creation.
MCPConfig *config.Config
// ShowSpinner shows a loading spinner for Ollama models.
ShowSpinner bool
// SpinnerFunc provides the spinner implementation (nil = no spinner).
SpinnerFunc SpinnerFunc
// UseBufferedLogger buffers debug messages for later display.
UseBufferedLogger bool
}
// InitTreeSession creates or opens a tree session based on the given options.
@@ -186,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)
@@ -211,8 +914,11 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
viper.Set("system-prompt", pb.Build())
}
// Load MCP configuration. Use pre-loaded config if provided.
mcpConfig := opts.MCPConfig
// Load MCP configuration. Use pre-loaded config if provided via CLI options.
var mcpConfig *config.Config
if opts.CLI != nil {
mcpConfig = opts.CLI.MCPConfig
}
if mcpConfig == nil {
mcpConfig, err = config.LoadAndValidateConfig()
if err != nil {
@@ -227,18 +933,25 @@ 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{
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
CoreTools: opts.Tools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
}
if opts.CLI != nil {
setupOpts.ShowSpinner = opts.CLI.ShowSpinner
setupOpts.SpinnerFunc = opts.CLI.SpinnerFunc
setupOpts.UseBufferedLogger = opts.CLI.UseBufferedLogger
}
// Create agent using shared setup with the hook tool wrapper.
agentResult, err := SetupAgent(ctx, AgentSetupOptions{
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
ShowSpinner: opts.ShowSpinner,
SpinnerFunc: opts.SpinnerFunc,
UseBufferedLogger: opts.UseBufferedLogger,
CoreTools: opts.Tools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
})
agentResult, err := kitsetup.SetupAgent(ctx, setupOpts)
if err != nil {
return nil, err
}
@@ -265,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.
@@ -312,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
@@ -540,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})
@@ -562,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})
-503
View File
@@ -1,503 +0,0 @@
# Plan 00: Create `pkg/kit` SDK Package & Extract Init from `cmd`
**Priority**: P0
**Effort**: Medium-High
**Goal**: Create `pkg/kit` as the canonical SDK package; extract shared logic from `cmd/` so both the CLI and external users consume the same API
## Background
Currently the SDK lives in `sdk/` and imports `cmd/` to access `InitConfig`, `SetupAgent`, etc. This creates a circular dependency problem: if the CLI app wants to consume the SDK, `cmd` would import `sdk` which imports `cmd`.
The fix is two-fold:
1. Move the SDK to `pkg/kit/` (idiomatic Go for public library packages)
2. Extract configuration/agent-setup logic from `cmd/` into `pkg/kit/` so both the CLI and SDK share the same code path without circular deps
### Architecture Before
```
main.go → cmd/ → internal/agent, internal/session, internal/config, ...
sdk/kit.go → cmd.InitConfig() ← SDK depends on cmd (problem!)
→ cmd.SetupAgent()
→ internal/session
```
### Architecture After
```
cmd/kit/main.go → cmd/ → pkg/kit/ → internal/agent, internal/session, ...
← CLI consumes SDK
pkg/kit/ → internal/agent, internal/session, internal/config, ...
← External users consume SDK
internal/app/ → pkg/kit/ ← App consumes SDK (gradual migration)
→ internal/ui/ ← App owns UI only
```
## Prerequisites
- None. This is the foundation for all other plans.
## Step-by-Step
### Step 1: Create `pkg/kit/` directory
```bash
mkdir -p pkg/kit
```
### Step 2: Extract config-loading logic from `cmd/root.go` into `pkg/kit/config.go`
The two functions `InitConfig()` and `LoadConfigWithEnvSubstitution()` currently live in `cmd/root.go` and depend on package-level variables (`configFile`, `debugMode`). Extract them as pure functions that accept parameters.
**File**: Create `pkg/kit/config.go`
```go
package kit
import (
"fmt"
"os"
"strings"
"github.com/mark3labs/kit/internal/config"
"github.com/spf13/viper"
)
// InitConfig initializes the viper configuration system.
// It searches for config files in standard locations and loads them with
// environment variable substitution.
//
// configFile: explicit config file path (empty = search defaults)
// debug: if true, print warnings about missing configs
func InitConfig(configFile string, debug bool) error {
if configFile != "" {
return LoadConfigWithEnvSubstitution(configFile)
}
// Ensure a config file exists (create default if none found)
if err := config.EnsureConfigExists(); err != nil {
if debug {
fmt.Fprintf(os.Stderr, "Warning: Could not create default config file: %v\n", err)
}
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("error finding home directory: %w", err)
}
viper.AddConfigPath(".")
viper.AddConfigPath(home)
configNames := []string{".kit"}
configLoaded := false
for _, name := range configNames {
viper.SetConfigName(name)
if err := viper.ReadInConfig(); err == nil {
configPath := viper.ConfigFileUsed()
if err := LoadConfigWithEnvSubstitution(configPath); err != nil {
if strings.Contains(err.Error(), "environment variable substitution failed") {
return fmt.Errorf("error reading config file '%s': %w", configPath, err)
}
continue
}
configLoaded = true
break
}
}
if !configLoaded && debug {
fmt.Fprintf(os.Stderr, "No config file found in current directory or home directory\n")
}
viper.SetEnvPrefix("KIT")
viper.AutomaticEnv()
return nil
}
// LoadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion.
func LoadConfigWithEnvSubstitution(configPath string) error {
rawContent, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
substituter := &config.EnvSubstituter{}
processedContent, err := substituter.SubstituteEnvVars(string(rawContent))
if err != nil {
return fmt.Errorf("config env substitution failed: %w", err)
}
configType := "yaml"
if strings.HasSuffix(configPath, ".json") {
configType = "json"
}
config.SetConfigPath(configPath)
viper.SetConfigType(configType)
return viper.ReadConfig(strings.NewReader(processedContent))
}
```
**Source**: Extracted from `cmd/root.go:119-213`
### Step 3: Extract agent setup logic from `cmd/setup.go` into `pkg/kit/setup.go`
Move `BuildProviderConfig`, `AgentSetupOptions`, `AgentSetupResult`, `SetupAgent`, and `setupExtensions` to the SDK. The key change: replace the `quietFlag` package-level variable dependency with an explicit `Quiet` field on `AgentSetupOptions`.
**File**: Create `pkg/kit/setup.go`
```go
package kit
import (
"context"
"fmt"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/agent"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/hooks"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/tools"
"github.com/spf13/viper"
)
// AgentSetupOptions configures agent creation.
type AgentSetupOptions struct {
MCPConfig *config.Config
ShowSpinner bool
SpinnerFunc agent.SpinnerFunc
UseBufferedLogger bool
Quiet bool // Replaces cmd's quietFlag package var
}
// AgentSetupResult contains the created agent and related components.
type AgentSetupResult struct {
Agent *agent.Agent
BufferedLogger *tools.BufferedDebugLogger
ExtRunner *extensions.Runner
}
// BuildProviderConfig creates a ProviderConfig from the current viper state.
func BuildProviderConfig() (*models.ProviderConfig, string, error) {
systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt"))
if err != nil {
return nil, "", fmt.Errorf("failed to load system prompt: %w", err)
}
temperature := float32(viper.GetFloat64("temperature"))
topP := float32(viper.GetFloat64("top-p"))
topK := int32(viper.GetInt("top-k"))
numGPU := int32(viper.GetInt("num-gpu-layers"))
mainGPU := int32(viper.GetInt("main-gpu"))
cfg := &models.ProviderConfig{
ModelString: viper.GetString("model"),
SystemPrompt: systemPrompt,
ProviderAPIKey: viper.GetString("provider-api-key"),
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
Temperature: &temperature,
TopP: &topP,
TopK: &topK,
StopSequences: viper.GetStringSlice("stop-sequences"),
NumGPU: &numGPU,
MainGPU: &mainGPU,
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
}
return cfg, systemPrompt, nil
}
// SetupAgent creates an agent from the current configuration state.
func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) {
modelConfig, systemPrompt, err := BuildProviderConfig()
if err != nil {
return nil, err
}
var debugLogger tools.DebugLogger
var bufferedLogger *tools.BufferedDebugLogger
if viper.GetBool("debug") {
if opts.UseBufferedLogger {
bufferedLogger = tools.NewBufferedDebugLogger(true)
debugLogger = bufferedLogger
} else {
debugLogger = tools.NewSimpleDebugLogger(true)
}
}
var extRunner *extensions.Runner
var extOpts extensionCreationOpts
if !viper.GetBool("no-extensions") {
var extErr error
extRunner, extOpts, extErr = loadExtensions()
if extErr != nil {
fmt.Printf("Warning: Failed to load extensions: %v\n", extErr)
}
}
a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{
ModelConfig: modelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: systemPrompt,
MaxSteps: viper.GetInt("max-steps"),
StreamingEnabled: viper.GetBool("stream"),
ShowSpinner: opts.ShowSpinner,
Quiet: opts.Quiet,
SpinnerFunc: opts.SpinnerFunc,
DebugLogger: debugLogger,
ToolWrapper: extOpts.toolWrapper,
ExtraTools: extOpts.extraTools,
})
if err != nil {
return nil, fmt.Errorf("failed to create agent: %w", err)
}
return &AgentSetupResult{
Agent: a,
ExtRunner: extRunner,
BufferedLogger: bufferedLogger,
}, nil
}
// unexported helpers
type extensionCreationOpts struct {
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
extraTools []fantasy.AgentTool
}
func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
extraPaths := viper.GetStringSlice("extension")
loaded, err := extensions.LoadExtensions(extraPaths)
if err != nil {
return nil, extensionCreationOpts{}, err
}
hooksCfg, _ := hooks.LoadHooksConfig()
if hooksCfg != nil && len(hooksCfg.Hooks) > 0 {
compat := extensions.HooksAsExtension(hooksCfg)
if compat != nil {
loaded = append([]extensions.LoadedExtension{*compat}, loaded...)
}
}
if len(loaded) == 0 {
return nil, extensionCreationOpts{}, nil
}
runner := extensions.NewRunner(loaded)
wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool {
return extensions.WrapToolsWithExtensions(tools, runner)
}
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools())
return runner, extensionCreationOpts{
toolWrapper: wrapper,
extraTools: extTools,
}, nil
}
```
**Source**: Extracted from `cmd/setup.go:28-185`
### Step 4: Move SDK core (`sdk/kit.go`, `sdk/types.go`) into `pkg/kit/`
Move the files and update them to import from the local package (no more `cmd` import):
**File**: Move `sdk/kit.go` to `pkg/kit/kit.go`
Key changes:
- `package sdk``package kit`
- Remove `import "github.com/mark3labs/kit/cmd"` entirely
- Replace `cmd.InitConfig()``InitConfig(...)` (same package)
- Replace `cmd.LoadConfigWithEnvSubstitution(...)``LoadConfigWithEnvSubstitution(...)` (same package)
- Replace `cmd.SetupAgent(...)``SetupAgent(...)` (same package)
- Replace `cmd.AgentSetupOptions{...}``AgentSetupOptions{...}` (same package)
**File**: Move `sdk/types.go` to `pkg/kit/types.go`
Key change: `package sdk``package kit`
### Step 5: Move `main.go` to `cmd/kit/main.go`
**File**: Create `cmd/kit/main.go` with the current `main.go` contents
```go
package main
import (
"context"
"fmt"
"os"
"github.com/charmbracelet/fang"
"github.com/mark3labs/kit/cmd"
)
var version = "dev"
func main() {
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
fmt.Println(version)
os.Exit(0)
}
ctx := context.Background()
rootCmd := cmd.GetRootCommand(version)
if err := fang.Execute(ctx, rootCmd); err != nil {
os.Exit(1)
}
}
```
Delete root `main.go`.
### Step 6: Update `cmd/root.go` to delegate to `pkg/kit`
**File**: `cmd/root.go`
Replace the `InitConfig` function body with a call to the SDK:
```go
import kit "github.com/mark3labs/kit/pkg/kit"
func InitConfig() {
if err := kit.InitConfig(configFile, debugMode); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
```
Keep `LoadConfigWithEnvSubstitution` as a thin wrapper or remove it entirely (callers use `kit.LoadConfigWithEnvSubstitution` directly).
### Step 7: Update `cmd/setup.go` to delegate to `pkg/kit`
**File**: `cmd/setup.go`
Replace `BuildProviderConfig`, `SetupAgent`, etc. with thin wrappers that inject CLI-specific state:
```go
import kit "github.com/mark3labs/kit/pkg/kit"
// BuildProviderConfig delegates to the SDK.
func BuildProviderConfig() (*models.ProviderConfig, string, error) {
return kit.BuildProviderConfig()
}
// SetupAgent delegates to the SDK, injecting CLI-specific quiet flag.
func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) {
result, err := kit.SetupAgent(ctx, kit.AgentSetupOptions{
MCPConfig: opts.MCPConfig,
ShowSpinner: opts.ShowSpinner,
SpinnerFunc: opts.SpinnerFunc,
UseBufferedLogger: opts.UseBufferedLogger,
Quiet: quietFlag, // Inject CLI package-level state
})
if err != nil {
return nil, err
}
// Map SDK result back to cmd types (or make cmd use SDK types directly)
return &AgentSetupResult{
Agent: result.Agent,
BufferedLogger: result.BufferedLogger,
ExtRunner: result.ExtRunner,
}, nil
}
```
**Alternative (cleaner)**: Remove `cmd` wrapper types entirely and have all callers in `cmd/` use `kit.AgentSetupOptions` and `kit.AgentSetupResult` directly. This is the app-as-consumer pattern.
### Step 8: Update `.goreleaser.yaml`
Add `main: ./cmd/kit`:
```yaml
builds:
- id: kit
main: ./cmd/kit
binary: kit
ldflags:
- -s -w -X main.version={{.Version}}
```
### Step 9: Update examples and tests
**Move**: `sdk/examples/``pkg/kit/examples/`
Update all imports:
- `"github.com/mark3labs/kit/sdk"``kit "github.com/mark3labs/kit/pkg/kit"`
- All `sdk.``kit.`
**Move**: `sdk/kit_test.go``pkg/kit/kit_test.go`
- `package sdk_test``package kit_test`
- Update import path
### Step 10: Clean up old `sdk/` directory
Remove `sdk/` entirely after all files are moved.
### Step 11: Update documentation
- `README.md`: Update import paths to `"github.com/mark3labs/kit/pkg/kit"`
- Move `sdk/README.md``pkg/kit/README.md` with updated paths
### Step 12: Verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
```
Confirm no remaining imports of `"github.com/mark3labs/kit/sdk"` or `"github.com/mark3labs/kit/cmd"` from `pkg/kit/`.
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| CREATE | `pkg/kit/config.go` | Extracted InitConfig, LoadConfigWithEnvSubstitution |
| CREATE | `pkg/kit/setup.go` | Extracted BuildProviderConfig, SetupAgent, AgentSetupOptions/Result |
| MOVE | `sdk/kit.go``pkg/kit/kit.go` | Change package, remove cmd import |
| MOVE | `sdk/types.go``pkg/kit/types.go` | Change package |
| MOVE | `sdk/kit_test.go``pkg/kit/kit_test.go` | Change package and imports |
| MOVE | `sdk/examples/``pkg/kit/examples/` | Update imports |
| CREATE | `cmd/kit/main.go` | New CLI entrypoint |
| DELETE | `main.go` | Moved to cmd/kit/ |
| EDIT | `cmd/root.go` | Delegate InitConfig to pkg/kit |
| EDIT | `cmd/setup.go` | Delegate SetupAgent to pkg/kit (or use SDK types directly) |
| EDIT | `.goreleaser.yaml` | Add `main: ./cmd/kit` |
| DELETE | `sdk/` | Entire directory after moves |
## Dependency Graph After
```
cmd/kit/main.go → cmd/
cmd/ → pkg/kit/ (CLI uses SDK)
→ internal/app/ (CLI uses app for TUI)
→ internal/ui/ (CLI uses UI)
pkg/kit/ → internal/agent, internal/session, internal/config, ...
(SDK uses internals, never cmd)
internal/app/ → pkg/kit/ (App uses SDK — gradual migration)
→ internal/ui/ (App owns TUI)
```
**No circular dependencies.**
## Verification Checklist
- [ ] `go build -o output/kit ./cmd/kit` succeeds
- [ ] `go test -race ./...` passes
- [ ] `go vet ./...` clean
- [ ] No `pkg/kit/` file imports `cmd/`
- [ ] `cmd/` files import `pkg/kit/` for shared logic
- [ ] No remaining references to `"github.com/mark3labs/kit/sdk"`
- [ ] Examples compile with new import path
- [ ] `.goreleaser.yaml` builds from `./cmd/kit`
- [ ] CI passes (`go test ./...`)
-253
View File
@@ -1,253 +0,0 @@
# Plan 01: Export Tools and Tool Factories
**Priority**: P0
**Effort**: Medium
**Goal**: Expose built-in tools as public APIs with pre-built instances and factory functions. The Kit CLI app should also consume these exports instead of reaching into `internal/core` directly.
## Background
Pi SDK exports individual tools and tool factories:
- Pre-built: `readTool`, `bashTool`, `editTool`, etc.
- Factories: `createReadTool(cwd)`, `createBashTool(cwd)`, etc.
- Bundles: `allTools`, `codingTools`, `readOnlyTools`
Kit currently keeps all tools internal (`internal/core/`). The agent setup in `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this plan, both SDK users AND the agent use the same public tool constructors.
## Prerequisites
- Plan 00 (Create `pkg/kit/` package)
## Architecture
```
pkg/kit/
├── kit.go # Kit struct, New(), Prompt(), etc.
├── types.go # Type aliases
├── tools.go # NEW: Public tool exports, factories, bundles
├── config.go # Extracted from cmd
├── setup.go # Extracted from cmd
internal/core/
├── tools.go # MODIFY: Add WithWorkDir option
├── read.go # MODIFY: Accept workdir param
├── write.go # MODIFY: Accept workdir param
├── bash.go # MODIFY: Accept workdir param + cmd.Dir
├── edit.go # MODIFY: Accept workdir param
├── grep.go # MODIFY: Accept workdir param + cmd.Dir
├── find.go # MODIFY: Accept workdir param + cmd.Dir
├── ls.go # MODIFY: Accept workdir param
├── truncate.go # Unchanged
internal/agent/
├── agent.go # MODIFY: Use public constructors via core package
```
## Step-by-Step
### Step 1: Add ToolOption pattern to `internal/core/tools.go`
**File**: `internal/core/tools.go`
Add a functional options pattern for tool creation:
```go
// ToolOption configures tool behavior.
type ToolOption func(*toolConfig)
type toolConfig struct {
workDir string
}
// WithWorkDir sets the working directory for file-based tools.
// If empty, os.Getwd() is used at execution time.
func WithWorkDir(dir string) ToolOption {
return func(c *toolConfig) {
c.workDir = dir
}
}
func applyOptions(opts []ToolOption) toolConfig {
var cfg toolConfig
for _, o := range opts {
o(&cfg)
}
return cfg
}
```
Update all collection functions to accept variadic options:
```go
func CodingTools(opts ...ToolOption) []fantasy.AgentTool { ... }
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool { ... }
func AllTools(opts ...ToolOption) []fantasy.AgentTool { ... }
```
### Step 2: Update path resolution to accept workDir
**File**: `internal/core/read.go`
Replace `resolvePath()` at line 134-144 with configurable version:
```go
func resolvePathWithWorkDir(path, workDir string) (string, error) {
if filepath.IsAbs(path) {
return filepath.Clean(path), nil
}
baseDir := workDir
if baseDir == "" {
var err error
baseDir, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
}
return filepath.Clean(filepath.Join(baseDir, path)), nil
}
// Backward-compat wrapper
func resolvePath(path string) (string, error) {
return resolvePathWithWorkDir(path, "")
}
```
### Steps 3-9: Update each tool constructor
For each tool (`read.go`, `write.go`, `edit.go`, `bash.go`, `grep.go`, `find.go`, `ls.go`):
- Change `NewXxxTool()` to `NewXxxTool(opts ...ToolOption)`
- Apply `cfg := applyOptions(opts)` in the constructor
- Pass `cfg.workDir` to path resolution or `cmd.Dir`
- For bash/grep/find (subprocess tools): set `cmd.Dir = cfg.workDir` on `exec.CommandContext`
- Existing callers pass no args, so they get default behavior (backward compatible)
### Step 10: Create `pkg/kit/tools.go`
**File**: `pkg/kit/tools.go`
```go
package kit
import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/core"
)
// Tool is the interface that all Kit tools implement.
type Tool = fantasy.AgentTool
// ToolOption configures tool behavior.
type ToolOption = core.ToolOption
// WithWorkDir sets the working directory for file-based tools.
var WithWorkDir = core.WithWorkDir
// Individual tool constructors
func NewReadTool(opts ...ToolOption) Tool { return core.NewReadTool(opts...) }
func NewWriteTool(opts ...ToolOption) Tool { return core.NewWriteTool(opts...) }
func NewEditTool(opts ...ToolOption) Tool { return core.NewEditTool(opts...) }
func NewBashTool(opts ...ToolOption) Tool { return core.NewBashTool(opts...) }
func NewGrepTool(opts ...ToolOption) Tool { return core.NewGrepTool(opts...) }
func NewFindTool(opts ...ToolOption) Tool { return core.NewFindTool(opts...) }
func NewLsTool(opts ...ToolOption) Tool { return core.NewLsTool(opts...) }
// Tool bundles
func AllTools(opts ...ToolOption) []Tool { return core.AllTools(opts...) }
func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) }
func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) }
```
### Step 11: Add GetTools() to Kit struct
**File**: `pkg/kit/kit.go`
```go
// GetTools returns all tools available to the agent (core + MCP + extensions).
func (m *Kit) GetTools() []Tool {
return m.agent.GetTools()
}
```
### Step 12: App-as-Consumer — Agent uses SDK tool constructors
This is the key "dog-fooding" step. Currently `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this change, the agent setup should get its tool list from the caller (via `AgentConfig.Tools`) rather than hardcoding `core.AllTools()`.
**File**: `internal/agent/agent.go`
Change the `AgentConfig` struct to accept tools explicitly:
```go
type AgentConfig struct {
// ... existing fields ...
CoreTools []fantasy.AgentTool // NEW: if empty, defaults to core.AllTools()
}
```
In `NewAgent()` at line 96-97, change:
```go
// Before:
coreTools := core.AllTools()
// After:
coreTools := agentConfig.CoreTools
if len(coreTools) == 0 {
coreTools = core.AllTools() // Default fallback
}
```
Then in `pkg/kit/setup.go`, the `SetupAgent` function passes tools from the SDK:
```go
a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{
// ... existing fields ...
CoreTools: core.AllTools(), // Explicit — could be customized via Options
})
```
And in `pkg/kit/kit.go`, the `Options` struct gets a `Tools` field:
```go
type Options struct {
// ... existing fields ...
Tools []Tool // Custom tool set. If empty, AllTools() is used.
}
```
This allows SDK users to pass custom tools:
```go
k, _ := kit.New(ctx, &kit.Options{
Tools: kit.CodingTools(kit.WithWorkDir("/my/project")),
})
```
### Step 13: Write tests and verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| EDIT | `internal/core/tools.go` | Add ToolOption, WithWorkDir, update collection funcs |
| EDIT | `internal/core/read.go` | resolvePathWithWorkDir, accept opts |
| EDIT | `internal/core/write.go` | Accept opts |
| EDIT | `internal/core/edit.go` | Accept opts |
| EDIT | `internal/core/bash.go` | Accept opts, set cmd.Dir |
| EDIT | `internal/core/grep.go` | Accept opts, set cmd.Dir |
| EDIT | `internal/core/find.go` | Accept opts, set cmd.Dir |
| EDIT | `internal/core/ls.go` | Accept opts |
| CREATE | `pkg/kit/tools.go` | Public tool exports and factories |
| EDIT | `pkg/kit/kit.go` | Add GetTools(), Tools option |
| EDIT | `internal/agent/agent.go` | Accept CoreTools in config instead of hardcoding |
| EDIT | `pkg/kit/setup.go` | Pass tools through to agent creation |
## Verification Checklist
- [ ] `go build -o output/kit ./cmd/kit` succeeds
- [ ] `go test -race ./...` passes (agent still gets default tools)
- [ ] Tools with `WithWorkDir("/tmp")` resolve paths relative to `/tmp`
- [ ] Tools with no options use `os.Getwd()` (backward compatible)
- [ ] SDK users can pass custom tool sets via `kit.Options{Tools: ...}`
- [ ] Agent accepts injected tools instead of hardcoding `core.AllTools()`
-196
View File
@@ -1,196 +0,0 @@
# Plan 02: Richer Type Exports
**Priority**: P0
**Effort**: Low
**Goal**: Export 40+ internal types so SDK users and the CLI app share the same type surface
## Background
Currently only 3 type aliases are exported: `Message`, `ToolCall`, `ToolResult`. Pi exports 50+ types. SDK users and the CLI app both need access to messages, sessions, config, agents, models, and callback types. By exporting from `pkg/kit`, both external consumers and the CLI share the same types — no parallel definitions.
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
## Key Principle: Shared Types
After this plan, `cmd/` should progressively adopt types from `pkg/kit/` instead of importing from `internal/` directly. For example:
- `cmd/setup.go` should reference `kit.ProviderConfig` rather than `models.ProviderConfig`
- `cmd/root.go` session setup should use `kit.SessionInfo` rather than `session.SessionInfo`
This is a gradual migration — the type aliases make this zero-cost since `kit.ProviderConfig = models.ProviderConfig` (same underlying type).
## Step-by-Step
### Step 1: Expand `pkg/kit/types.go` with all type groups
**File**: `pkg/kit/types.go`
```go
package kit
import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/agent"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
)
// ==== Message Types (internal/message/content.go) ====
type Message = message.Message
type MessageRole = message.MessageRole
const (
RoleUser = message.RoleUser
RoleAssistant = message.RoleAssistant
RoleTool = message.RoleTool
RoleSystem = message.RoleSystem
)
type ContentPart = message.ContentPart
type TextContent = message.TextContent
type ReasoningContent = message.ReasoningContent
type ToolCall = message.ToolCall
type ToolResult = message.ToolResult
type Finish = message.Finish
// ==== Session Types (internal/session/) ====
type Session = session.Session
type SessionMetadata = session.Metadata
type SessionManager = session.Manager
type SessionInfo = session.SessionInfo
type TreeManager = session.TreeManager
type SessionHeader = session.SessionHeader
type MessageEntry = session.MessageEntry
// ==== Config Types (internal/config/) ====
type Config = config.Config
type MCPServerConfig = config.MCPServerConfig
// ==== Agent Types (internal/agent/) ====
type AgentConfig = agent.AgentConfig
type GenerateResult = agent.GenerateWithLoopResult
type (
ToolCallHandler = agent.ToolCallHandler
ToolExecutionHandler = agent.ToolExecutionHandler
ToolResultHandler = agent.ToolResultHandler
ResponseHandler = agent.ResponseHandler
StreamingResponseHandler = agent.StreamingResponseHandler
ToolCallContentHandler = agent.ToolCallContentHandler
)
// ==== Provider & Model Types (internal/models/) ====
type ProviderConfig = models.ProviderConfig
type ProviderResult = models.ProviderResult
type ModelInfo = models.ModelInfo
type ModelCost = models.Cost
type ModelLimit = models.Limit
type ProviderInfo = models.ProviderInfo
type ModelsRegistry = models.ModelsRegistry
// ==== Fantasy Types (re-exported) ====
type FantasyMessage = fantasy.Message
type FantasyUsage = fantasy.Usage
type FantasyResponse = fantasy.Response
// ==== Constructor & Helper Functions ====
var (
NewSession = session.NewSession
NewSessionManager = session.NewManager
ListSessions = session.ListSessions
ListAllSessions = session.ListAllSessions
ParseModelString = models.ParseModelString
CreateProvider = models.CreateProvider
GetGlobalRegistry = models.GetGlobalRegistry
LoadSystemPrompt = config.LoadSystemPrompt
)
// ==== Conversion Helpers ====
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
return msg.ToFantasyMessages()
}
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
return message.FromFantasyMessage(msg)
}
```
### Step 2: App-as-Consumer — Migrate `cmd/` to use SDK types
After this plan, start migrating `cmd/` callers to use `kit.*` types. Since these are aliases, this is purely cosmetic and zero-cost, but it establishes the pattern:
**Example in `cmd/setup.go`**:
```go
// Before:
import "github.com/mark3labs/kit/internal/models"
cfg := &models.ProviderConfig{...}
// After (preferred, gradual migration):
import kit "github.com/mark3labs/kit/pkg/kit"
cfg := &kit.ProviderConfig{...}
```
This is not blocking — both work simultaneously due to Go type aliases.
### Step 3: Write a compilation test
**File**: `pkg/kit/types_test.go`
```go
package kit_test
import (
"testing"
kit "github.com/mark3labs/kit/pkg/kit"
)
func TestTypeExports(t *testing.T) {
if kit.RoleUser != "user" { t.Error("RoleUser") }
if kit.RoleAssistant != "assistant" { t.Error("RoleAssistant") }
msg := kit.Message{
Role: kit.RoleUser,
Parts: []kit.ContentPart{
kit.TextContent{Text: "hello"},
},
}
if msg.Content() != "hello" { t.Error("message content") }
s := kit.NewSession()
if s == nil { t.Error("NewSession") }
}
```
### Step 4: Verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| EDIT | `pkg/kit/types.go` | Add ~40 type aliases, constants, constructors |
| CREATE | `pkg/kit/types_test.go` | Compilation test |
## Verification Checklist
- [ ] `go build -o output/kit ./cmd/kit` succeeds
- [ ] `go test -race ./...` passes
- [ ] No circular import errors
- [ ] Type aliases are interchangeable with internal types
- [ ] `cmd/` can import and use `kit.*` types alongside internal types
-348
View File
@@ -1,348 +0,0 @@
# Plan 03: Event/Subscriber System
**Priority**: P1
**Effort**: High
**Goal**: Create a unified event system in the SDK that replaces the three parallel event systems currently in the codebase
## Background
Kit currently has **three separate event systems** that overlap:
1. **SDK callbacks** (`sdk/kit.go`) — 3 function pointers on `PromptWithCallbacks`
2. **Extension events** (`internal/extensions/events.go`) — 13 typed events dispatched via `Runner.Emit()`
3. **App/TUI events** (`internal/app/events.go`) — 13 `tea.Msg` structs for BubbleTea UI updates
Pi uses a single `session.subscribe(listener)` pattern. This plan creates a unified event system in `pkg/kit/` that:
- Replaces SDK callbacks
- Becomes the canonical event layer that extensions and the app emit through
- The TUI adapts SDK events into `tea.Msg` for rendering (TUI-specific concern stays in `internal/ui/`)
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
- Plan 02 (Richer type exports)
## Design Decisions
1. **Single source of truth** — events are defined in `pkg/kit/`, not scattered across packages
2. **Multiple subscribers** supported with unsubscribe
3. **Thread-safe** emission
4. **App subscribes to SDK events** — the TUI layer adapts them to `tea.Msg`
5. **Extensions emit through SDK** — the extension runner emits SDK events, not its own types
## Step-by-Step
### Step 1: Define public event types
**File**: `pkg/kit/events.go` (new)
```go
package kit
import "sync"
// EventType identifies the kind of event.
type EventType string
const (
EventTurnStart EventType = "turn_start"
EventTurnEnd EventType = "turn_end"
EventMessageStart EventType = "message_start"
EventMessageUpdate EventType = "message_update"
EventMessageEnd EventType = "message_end"
EventToolCall EventType = "tool_call"
EventToolExecutionStart EventType = "tool_execution_start"
EventToolExecutionEnd EventType = "tool_execution_end"
EventToolResult EventType = "tool_result"
EventToolCallContent EventType = "tool_call_content"
EventResponse EventType = "response"
EventSessionStart EventType = "session_start"
EventSessionShutdown EventType = "session_shutdown"
)
// Event is the interface for all event types.
type Event interface {
EventType() EventType
}
```
### Step 2: Define concrete event structs
These cover the union of all three current event systems:
```go
type TurnStartEvent struct{ Prompt string }
func (e TurnStartEvent) EventType() EventType { return EventTurnStart }
type TurnEndEvent struct{ Response string; Error error }
func (e TurnEndEvent) EventType() EventType { return EventTurnEnd }
type MessageStartEvent struct{}
func (e MessageStartEvent) EventType() EventType { return EventMessageStart }
type MessageUpdateEvent struct{ Chunk string }
func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate }
type MessageEndEvent struct{ Content string }
func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }
type ToolCallEvent struct{ ToolName string; ToolArgs string }
func (e ToolCallEvent) EventType() EventType { return EventToolCall }
type ToolExecutionStartEvent struct{ ToolName string }
func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecutionStart }
type ToolExecutionEndEvent struct{ ToolName string }
func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecutionEnd }
type ToolResultEvent struct{ ToolName, ToolArgs, Result string; IsError bool }
func (e ToolResultEvent) EventType() EventType { return EventToolResult }
type ToolCallContentEvent struct{ Content string }
func (e ToolCallContentEvent) EventType() EventType { return EventToolCallContent }
type ResponseEvent struct{ Content string }
func (e ResponseEvent) EventType() EventType { return EventResponse }
```
### Step 3: Implement EventBus
```go
type EventListener func(event Event)
type eventBus struct {
mu sync.RWMutex
listeners map[int]EventListener
nextID int
}
func newEventBus() *eventBus {
return &eventBus{listeners: make(map[int]EventListener)}
}
func (eb *eventBus) subscribe(listener EventListener) func() {
eb.mu.Lock()
id := eb.nextID
eb.nextID++
eb.listeners[id] = listener
eb.mu.Unlock()
return func() {
eb.mu.Lock()
delete(eb.listeners, id)
eb.mu.Unlock()
}
}
func (eb *eventBus) emit(event Event) {
eb.mu.RLock()
snapshot := make([]EventListener, 0, len(eb.listeners))
for _, l := range eb.listeners {
snapshot = append(snapshot, l)
}
eb.mu.RUnlock()
for _, l := range snapshot {
l(event)
}
}
```
### Step 4: Wire EventBus into Kit struct
**File**: `pkg/kit/kit.go`
```go
type Kit struct {
agent *agent.Agent
sessionMgr *session.Manager
modelString string
events *eventBus
}
func (m *Kit) Subscribe(listener EventListener) func() {
return m.events.subscribe(listener)
}
```
### Step 5: Wire all agent callbacks to emit events
Update `Prompt()` and `PromptWithCallbacks()` to emit events at every stage of the agent generation flow. Events fire at these points (matching the lifecycle in `internal/app/app.go:364-520`):
1. Before generation: `TurnStartEvent`, `MessageStartEvent`
2. During streaming: `MessageUpdateEvent` per chunk
3. On tool call: `ToolCallEvent`, `ToolExecutionStartEvent`
4. On tool result: `ToolExecutionEndEvent`, `ToolResultEvent`
5. On response: `ResponseEvent`
6. After generation: `MessageEndEvent`, `TurnEndEvent`
Extract shared callback helpers to avoid duplication:
```go
func (m *Kit) makeToolCallHandler() agent.ToolCallHandler {
return func(name, args string) {
m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args})
}
}
// ... similar for all callback types
```
### Step 6: App-as-Consumer — TUI subscribes to SDK events
This is the critical refactor. Currently `internal/app/app.go:executeStep()` emits TUI events directly via `sendFn(StreamChunkEvent{...})`. After this change:
1. The SDK's `Prompt()` emits SDK events
2. The app subscribes to SDK events and converts them to `tea.Msg`
**File**: `internal/app/app.go` (migration pattern)
```go
// In App initialization, subscribe to SDK events and bridge to TUI
func (a *App) setupEventBridge() {
a.kit.Subscribe(func(e kit.Event) {
switch ev := e.(type) {
case kit.MessageUpdateEvent:
a.sendToTUI(StreamChunkEvent{Content: ev.Chunk})
case kit.ToolCallEvent:
a.sendToTUI(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolResultEvent:
a.sendToTUI(ToolResultEvent{
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
Result: ev.Result, IsError: ev.IsError,
})
case kit.ResponseEvent:
a.sendToTUI(ResponseCompleteEvent{Content: ev.Content})
// ... etc
}
})
}
```
**Migration steps**:
1. First: app subscribes to SDK events AND keeps its own emission (dual-emit phase)
2. Then: remove direct emission from `executeStep()`, rely solely on SDK events
3. Finally: remove `internal/app/events.go` types that are now redundant
### Step 7: Extension events bridge to SDK events
The extension `Runner` should emit through the SDK event bus rather than its own parallel system. This can be bridged:
```go
// In Kit initialization, bridge extension events to SDK events
func (m *Kit) bridgeExtensionEvents(runner *extensions.Runner) {
// When extensions emit events, forward them as SDK events
// This is done by having the Runner call back into the SDK
runner.SetEventForwarder(func(event extensions.Event) {
switch e := event.(type) {
case extensions.ToolCallEvent:
m.events.emit(ToolCallEvent{ToolName: e.ToolName, ToolArgs: e.Input})
// ... etc
}
})
}
```
**Note**: This is a gradual migration. The extension Runner keeps its typed events for Yaegi compatibility, but forwards them to the SDK bus. Eventually the extension system could be refactored to emit SDK events natively.
### Step 8: Typed convenience subscribers
```go
func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() {
return m.Subscribe(func(e Event) {
if tc, ok := e.(ToolCallEvent); ok { handler(tc) }
})
}
func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
return m.Subscribe(func(e Event) {
if tr, ok := e.(ToolResultEvent); ok { handler(tr) }
})
}
func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
return m.Subscribe(func(e Event) {
if mu, ok := e.(MessageUpdateEvent); ok { handler(mu) }
})
}
```
### Step 9: Write tests and verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| CREATE | `pkg/kit/events.go` | Event types, EventBus, Subscribe() |
| EDIT | `pkg/kit/kit.go` | Add eventBus field, Subscribe(), callback helpers |
| EDIT | `internal/app/app.go` | Subscribe to SDK events (gradual migration) |
| EDIT | `internal/extensions/runner.go` | Optional: event forwarding to SDK bus |
## Event Flow After This Plan
```
Agent.GenerateWithLoopAndStreaming()
↓ fantasy callbacks
pkg/kit/kit.go (SDK Prompt method)
↓ emits SDK events
EventBus
↓ dispatches to all subscribers
├── External SDK user's listener
├── App TUI bridge → tea.Msg → BubbleTea Update()
└── Extension bridge → Runner.Emit() → Yaegi handlers
```
**Single source of truth**: The SDK EventBus is the only event dispatcher.
## Verification Checklist
- [x] `go build -o output/kit ./cmd/kit` succeeds
- [x] `go test -race ./...` passes
- [x] Events fire in correct order: TurnStart → MessageStart → updates → ToolCall → ToolResult → MessageEnd → TurnEnd
- [x] Multiple subscribers receive all events
- [x] Unsubscribe removes listener
- [ ] App TUI still renders correctly via event bridge (deferred — see below)
- [x] Thread-safe under concurrent calls
## Implemented (Steps 1-5, 8-9)
Core event system is complete:
- Event types, concrete structs, EventBus in `pkg/kit/events.go`
- `Kit.Subscribe()` + typed helpers (`OnToolCall`, `OnToolResult`, `OnStreaming`, `OnResponse`, `OnTurnStart`, `OnTurnEnd`)
- `Prompt()` and `PromptWithCallbacks()` emit full lifecycle events
- 10 tests covering subscribe/unsubscribe, ordering, concurrency, self-unsubscribe
- Example updated to use `Subscribe` API; `PromptWithCallbacks` marked deprecated
## Deferred (Steps 6-7)
### Step 6: App TUI bridge — app subscribes to SDK events
The app (`internal/app/app.go`) currently owns an `AgentRunner` interface (not a `*Kit`),
and emits `tea.Msg` events directly from `executeStep()` callbacks. To bridge through the
SDK EventBus:
1. The app needs a `*Kit` reference (or at minimum an `*eventBus` / `Subscribe` func)
2. `executeStep()` would stop emitting `tea.Msg` directly and instead let the SDK emit
SDK events, with a subscriber in the app that converts them to `tea.Msg`
3. Dual-emit phase first (both old and new), then remove direct emission
**Why deferred**: The app doesn't have a `Kit` reference — it receives an `AgentRunner`.
Changing this requires restructuring `internal/app/options.go` and `cmd/root.go` where
the app is created. This is better done as part of the gradual "app consumes SDK" migration
(tracked in the README architecture diagram).
### Step 7: Extension events bridge — Runner emits through SDK EventBus
The extension `Runner` (`internal/extensions/runner.go`) has its own typed events. To
forward them through the SDK bus:
1. Add `SetEventForwarder(func(extensions.Event))` to Runner
2. In Kit initialization, bridge extension events to SDK events
3. Extensions keep their typed events for Yaegi compatibility but forward to SDK bus
**Why deferred**: Same dependency as Step 6 — requires the Kit instance to be wired
into the extension runner initialization path. Plan 09 (Extension hook system) is the
natural place to complete this bridge.
-298
View File
@@ -1,298 +0,0 @@
# Plan 04: Enhanced Session Management
**Priority**: P1
**Effort**: High
**Goal**: Expose session management in the SDK; CLI session flags map to SDK options
## Background
Kit has rich session infrastructure internally (`store.go`, `tree_manager.go`) but none of it is in the SDK. The CLI handles sessions in `cmd/root.go:479-557` with flags like `--continue`, `--resume`, `--session`, `--no-session`. After this plan, both the CLI and external users configure sessions through `kit.Options`.
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
- Plan 02 (Richer type exports)
## Key Principle
The CLI should NOT have its own session setup logic. Instead:
1. CLI parses `--continue`, `--session`, etc. into `kit.Options` fields
2. `kit.New()` handles all session initialization
3. The CLI gets back a `*Kit` with the session already configured
## Step-by-Step
### Step 1: Add session options to Kit Options
**File**: `pkg/kit/kit.go`
```go
type Options struct {
// ... existing fields (Model, SystemPrompt, ConfigFile, etc.) ...
// Session configuration
SessionDir string // Base directory for session discovery (default: cwd)
SessionPath string // Open a specific session file
Continue bool // Continue most recent session for SessionDir
NoSession bool // Ephemeral mode — no persistence
}
```
### Step 2: Add tree session to Kit struct
```go
type Kit struct {
agent *agent.Agent
sessionMgr *session.Manager
treeSession *session.TreeManager
modelString string
events *eventBus
}
```
### Step 3: Initialize tree session in New()
```go
func New(ctx context.Context, opts *Options) (*Kit, error) {
// ... existing config + agent setup ...
cwd, _ := os.Getwd()
sessionDir := cwd
if opts != nil && opts.SessionDir != "" {
sessionDir = opts.SessionDir
}
var treeSession *session.TreeManager
if opts != nil && opts.NoSession {
treeSession = session.InMemoryTreeSession(sessionDir)
} else if opts != nil && opts.Continue {
ts, err := session.ContinueRecent(sessionDir)
if err != nil {
ts, err = session.CreateTreeSession(sessionDir)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
}
treeSession = ts
} else if opts != nil && opts.SessionPath != "" {
ts, err := session.OpenTreeSession(opts.SessionPath)
if err != nil {
return nil, fmt.Errorf("failed to open session: %w", err)
}
treeSession = ts
} else {
ts, err := session.CreateTreeSession(sessionDir)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
treeSession = ts
}
return &Kit{
agent: setupResult.Agent,
sessionMgr: sessionMgr,
treeSession: treeSession,
modelString: modelString,
events: newEventBus(),
}, nil
}
```
### Step 4: Wire Prompt() to use tree session
```go
func (m *Kit) Prompt(ctx context.Context, message string) (string, error) {
var messages []fantasy.Message
if m.treeSession != nil {
msgs, _, _ := m.treeSession.BuildContext()
messages = msgs
} else {
messages = m.sessionMgr.GetMessages()
}
// ... generation ...
// Persist to tree session
if m.treeSession != nil {
m.treeSession.AppendFantasyMessage(userMsg)
for _, msg := range result.Messages {
m.treeSession.AppendMessage(msg)
}
}
// Keep legacy manager in sync
_ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages)
return response, nil
}
```
### Step 5: Add session management methods
**File**: `pkg/kit/sessions.go` (new)
```go
package kit
import (
"fmt"
"os"
"github.com/mark3labs/kit/internal/session"
)
// Package-level session operations (don't require a Kit instance)
func ListSessions(dir string) ([]SessionInfo, error) {
if dir == "" {
var err error
dir, err = os.Getwd()
if err != nil { return nil, err }
}
return session.ListSessions(dir)
}
func ListAllSessions() ([]SessionInfo, error) {
return session.ListAllSessions()
}
func DeleteSession(path string) error {
return session.DeleteSession(path)
}
// Instance methods
func (m *Kit) GetTreeSession() *TreeManager { return m.treeSession }
func (m *Kit) GetSessionPath() string {
if m.treeSession != nil { return m.treeSession.GetFilePath() }
return ""
}
func (m *Kit) GetSessionID() string {
if m.treeSession != nil { return m.treeSession.GetSessionID() }
return ""
}
func (m *Kit) Branch(entryID string) error {
if m.treeSession == nil {
return fmt.Errorf("branching requires tree session")
}
m.treeSession.Branch(entryID)
msgs, _, _ := m.treeSession.BuildContext()
return m.sessionMgr.ReplaceAllMessages(msgs)
}
func (m *Kit) SetSessionName(name string) error {
if m.treeSession == nil {
return fmt.Errorf("session naming requires tree session")
}
m.treeSession.AppendSessionInfo(name)
return nil
}
func (m *Kit) ClearSession() {
m.sessionMgr = session.NewManager("")
if m.treeSession != nil {
m.treeSession.ResetLeaf()
}
}
```
### Step 6: App-as-Consumer — CLI delegates session setup to SDK
This is the critical step. Currently `cmd/root.go:479-557` has its own session setup logic with if/else chains for each flag. Replace it with `kit.Options`:
**File**: `cmd/root.go` (migration)
```go
// Before (cmd/root.go:479-557):
// Complex if/else chain checking noSessionFlag, continueFlag, resumeFlag, sessionPath
// After:
import kit "github.com/mark3labs/kit/pkg/kit"
func buildKitOptions() *kit.Options {
opts := &kit.Options{
Model: modelFlag,
ConfigFile: configFile,
Quiet: quietFlag,
}
// Map CLI flags to SDK options
if noSessionFlag {
opts.NoSession = true
} else if continueFlag {
opts.Continue = true
} else if sessionPath != "" {
opts.SessionPath = sessionPath
}
// resumeFlag: handled by listing sessions then picking one
// (call kit.ListSessions first, then set opts.SessionPath)
return opts
}
// The Kit instance handles all session init internally:
k, err := kit.New(ctx, buildKitOptions())
```
**For --resume** (currently half-implemented with a TODO for TUI picker):
```go
if resumeFlag {
sessions, err := kit.ListSessions("")
if err != nil || len(sessions) == 0 {
// Fall back to new session
} else {
// TODO: Show TUI picker. For now, pick most recent.
opts.SessionPath = sessions[0].Path
}
}
```
### Step 7: App uses Kit's session instead of creating its own TreeManager
Currently `internal/app/app.go` receives a `TreeSession` via its `Options`. After migration, the app receives a `*Kit` instance and uses its tree session:
```go
// Before:
type Options struct {
TreeSession *session.TreeManager
// ...
}
// After (gradual):
type Options struct {
Kit *kit.Kit // The SDK instance
// ...
}
// App gets messages:
msgs := a.opts.Kit.GetTreeSession().GetFantasyMessages()
```
### Step 8: Verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| EDIT | `pkg/kit/kit.go` | Add treeSession, session Options fields, wire Prompt |
| CREATE | `pkg/kit/sessions.go` | ListSessions, Branch, SetSessionName, etc. |
| EDIT | `cmd/root.go` | Replace session setup logic with kit.Options mapping |
| EDIT | `internal/app/app.go` | Accept Kit instance for session access (gradual) |
## Verification Checklist
- [ ] `go build -o output/kit ./cmd/kit` succeeds
- [ ] `go test -race ./...` passes
- [ ] `kit.New(ctx, &kit.Options{Continue: true})` resumes recent session
- [ ] `kit.New(ctx, &kit.Options{NoSession: true})` creates ephemeral session
- [ ] `kit.ListSessions("")` returns sessions
- [ ] CLI `--continue` flag maps to `kit.Options{Continue: true}`
- [ ] CLI `--no-session` flag maps to `kit.Options{NoSession: true}`
- [ ] CLI no longer has its own session initialization logic
-276
View File
@@ -1,276 +0,0 @@
# Plan 05: Additional Prompt Modes
**Priority**: P1
**Effort**: Medium
**Goal**: Add `Steer()`, `FollowUp()`, `PromptWithOptions()` methods; app's `executeStep()` should call SDK methods
## Background
Pi has `session.prompt()`, `session.steer()`, `session.followUp()`, `session.compact()`. Kit only has `Prompt()` and `PromptWithCallbacks()`. The Kit CLI app implements its own agent loop in `internal/app/app.go:executeStep()` which duplicates SDK logic. After this plan, both the app and SDK users call the same methods.
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
- Plan 03 (Event subscriber system)
## Step-by-Step
### Step 1: Extract shared callback helpers
To avoid duplicating callback wiring across `Prompt`, `Steer`, `FollowUp`, etc., extract internal helpers:
**File**: `pkg/kit/kit.go`
```go
func (m *Kit) makeToolCallHandler() agent.ToolCallHandler {
return func(name, args string) {
m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args})
}
}
func (m *Kit) makeToolExecutionHandler() agent.ToolExecutionHandler {
return func(name string, isStarting bool) {
if isStarting {
m.events.emit(ToolExecutionStartEvent{ToolName: name})
} else {
m.events.emit(ToolExecutionEndEvent{ToolName: name})
}
}
}
func (m *Kit) makeToolResultHandler() agent.ToolResultHandler {
return func(name, args, result string, isError bool) {
m.events.emit(ToolResultEvent{ToolName: name, ToolArgs: args, Result: result, IsError: isError})
}
}
func (m *Kit) makeResponseHandler() agent.ResponseHandler {
return func(content string) { m.events.emit(ResponseEvent{Content: content}) }
}
func (m *Kit) makeStreamingHandler() agent.StreamingResponseHandler {
return func(chunk string) { m.events.emit(MessageUpdateEvent{Chunk: chunk}) }
}
// getMessages retrieves conversation history from the best available source.
func (m *Kit) getMessages() []fantasy.Message {
if m.treeSession != nil {
msgs, _, _ := m.treeSession.BuildContext()
return msgs
}
return m.sessionMgr.GetMessages()
}
// updateSession persists generation results.
func (m *Kit) updateSession(userMsg fantasy.Message, result *agent.GenerateWithLoopResult) {
if m.treeSession != nil {
m.treeSession.AppendFantasyMessage(userMsg)
for _, msg := range result.Messages {
m.treeSession.AppendMessage(msg)
}
}
_ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages)
}
// generate is the shared generation path for all prompt modes.
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
return m.agent.GenerateWithLoopAndStreaming(
ctx, messages,
m.makeToolCallHandler(),
m.makeToolExecutionHandler(),
m.makeToolResultHandler(),
m.makeResponseHandler(),
nil, // onToolCallContent
m.makeStreamingHandler(),
)
}
```
### Step 2: Refactor Prompt() to use shared helpers
```go
func (m *Kit) Prompt(ctx context.Context, msg string) (string, error) {
messages := m.getMessages()
userMsg := fantasy.NewUserMessage(msg)
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: msg})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("generation failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
```
### Step 3: Add Steer()
```go
// Steer injects a system message and triggers a new agent turn.
// Use for dynamically adjusting behavior without a visible user message.
func (m *Kit) Steer(ctx context.Context, instruction string) (string, error) {
messages := m.getMessages()
sysMsg := fantasy.NewSystemMessage(instruction)
messages = append(messages, sysMsg)
userMsg := fantasy.NewUserMessage("Please acknowledge and follow the above instruction.")
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: "[steer] " + instruction})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("steer failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
```
### Step 4: Add FollowUp()
```go
// FollowUp continues the conversation without new user input.
func (m *Kit) FollowUp(ctx context.Context) (string, error) {
messages := m.getMessages()
if len(messages) == 0 {
return "", fmt.Errorf("cannot follow up: no previous messages")
}
userMsg := fantasy.NewUserMessage("Continue.")
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: "[follow-up]"})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("follow-up failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
```
### Step 5: Add PromptWithOptions()
```go
type PromptOptions struct {
SystemMessage string // Injected before the prompt
MaxSteps int // Override max steps for this call (0 = default)
}
func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOptions) (string, error) {
messages := m.getMessages()
if opts.SystemMessage != "" {
messages = append(messages, fantasy.NewSystemMessage(opts.SystemMessage))
}
userMsg := fantasy.NewUserMessage(msg)
messages = append(messages, userMsg)
m.events.emit(TurnStartEvent{Prompt: msg})
m.events.emit(MessageStartEvent{})
result, err := m.generate(ctx, messages)
if err != nil {
m.events.emit(TurnEndEvent{Error: err})
return "", fmt.Errorf("generation failed: %w", err)
}
m.updateSession(userMsg, result)
response := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: response})
m.events.emit(TurnEndEvent{Response: response})
return response, nil
}
```
### Step 6: App-as-Consumer — Refactor `executeStep()` to use SDK
Currently `internal/app/app.go:executeStep()` (lines 364-520) contains a full agent loop with extension events, message building, and session persistence. It should be replaced by SDK method calls.
**File**: `internal/app/app.go` (migration)
```go
// Before: 150+ lines of agent loop logic in executeStep()
// After: executeStep delegates to the Kit SDK
func (a *App) executeStep(ctx context.Context, prompt string, sendFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) {
// Extension Input hook (stays in app — it's a pre-SDK concern)
if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.Input) {
result, _ := a.opts.Extensions.Emit(extensions.InputEvent{Text: prompt})
if r, ok := result.(extensions.InputResult); ok && r.Action == "handled" {
return nil, nil
}
if r, ok := result.(extensions.InputResult); ok && r.Text != "" {
prompt = r.Text
}
}
sendFn(SpinnerEvent{Show: true})
// Use SDK prompt — events handled by subscriber bridge (Plan 03)
response, err := a.kit.Prompt(ctx, prompt)
if err != nil {
sendFn(StepErrorEvent{Err: err})
return nil, err
}
sendFn(SpinnerEvent{Show: false})
sendFn(StepCompleteEvent{})
_ = response
return nil, nil // Result comes through events
}
```
**Note**: This is a simplification. The real migration needs to handle:
- Extension `BeforeAgentStart` events (map to Plan 09 hooks)
- Spinner show/hide
- The fact that `executeStep` returns `*GenerateWithLoopResult` for further processing
The migration is gradual:
1. **Phase 1**: App calls `kit.Prompt()` for simple cases
2. **Phase 2**: Extension events bridge through SDK hooks (Plan 09)
3. **Phase 3**: `executeStep()` becomes a thin adapter
### Step 7: Verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| EDIT | `pkg/kit/kit.go` | Steer(), FollowUp(), PromptWithOptions(), shared helpers |
| EDIT | `internal/app/app.go` | Gradual migration of executeStep to use SDK |
## Verification Checklist
- [ ] `Steer()` injects system message and triggers response
- [ ] `FollowUp()` continues without user message
- [ ] `PromptWithOptions()` accepts per-call system message
- [ ] All methods emit events via EventBus
- [ ] Shared helpers eliminate callback duplication
- [ ] App's `executeStep()` uses SDK (at least for simple paths)
-192
View File
@@ -1,192 +0,0 @@
# Plan 06: Auth & Model Management APIs
**Priority**: P2
**Effort**: Medium
**Goal**: Expose provider management, model validation, and API key handling in the SDK; CLI auth commands consume SDK APIs
## Background
Pi exports `AuthStorage`, `ModelRegistry`, `SettingsManager` for programmatic auth/model management. Kit has this internally (`internal/models/registry.go`, `internal/auth/credentials.go`, `internal/models/providers.go`) but none is exposed through the SDK.
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
- Plan 02 (Richer type exports)
## Step-by-Step
### Step 1: Export model registry functions
**File**: `pkg/kit/models.go` (new)
```go
package kit
import (
"fmt"
"github.com/mark3labs/kit/internal/models"
)
// LookupModel returns information about a model, or nil if unknown.
func LookupModel(provider, modelID string) *ModelInfo {
return models.GetGlobalRegistry().LookupModel(provider, modelID)
}
// GetSupportedProviders returns all known provider names.
func GetSupportedProviders() []string {
return models.GetGlobalRegistry().GetSupportedProviders()
}
// GetModelsForProvider returns all known models for a provider.
func GetModelsForProvider(provider string) (map[string]ModelInfo, error) {
return models.GetGlobalRegistry().GetModelsForProvider(provider)
}
// GetProviderInfo returns information about a provider (env vars, API URL, etc.).
func GetProviderInfo(provider string) *ProviderInfo {
return models.GetGlobalRegistry().GetProviderInfo(provider)
}
// ValidateEnvironment checks if required API keys are set for a provider.
func ValidateEnvironment(provider string, apiKey string) error {
return models.GetGlobalRegistry().ValidateEnvironment(provider, apiKey)
}
// SuggestModels returns model names similar to an invalid model string.
func SuggestModels(provider, invalidModel string) []string {
return models.GetGlobalRegistry().SuggestModels(provider, invalidModel)
}
// RefreshModelRegistry reloads the model database from models.dev.
func RefreshModelRegistry() {
models.ReloadGlobalRegistry()
}
// ParseModelString splits a "provider/model" string into components.
func ParseModelString(modelString string) (provider, model string, err error) {
return models.ParseModelString(modelString)
}
// CheckProviderReady validates that a provider is properly configured.
func CheckProviderReady(provider string) error {
info := models.GetGlobalRegistry().GetProviderInfo(provider)
if info == nil {
return fmt.Errorf("unknown provider: %s", provider)
}
return models.GetGlobalRegistry().ValidateEnvironment(provider, "")
}
```
### Step 2: Add model info to Kit instance
**File**: `pkg/kit/kit.go`
```go
// GetModel returns the current model string (e.g., "anthropic/claude-sonnet-4-5-20250929").
func (m *Kit) GetModel() string {
return m.modelString
}
// GetModelInfo returns detailed information about the current model.
// Returns nil if the model is not in the registry.
func (m *Kit) GetModelInfo() *ModelInfo {
provider, modelID, err := models.ParseModelString(m.modelString)
if err != nil {
return nil
}
return models.GetGlobalRegistry().LookupModel(provider, modelID)
}
```
### Step 3: Export auth credential management
**File**: `pkg/kit/auth.go` (new)
```go
package kit
import "github.com/mark3labs/kit/internal/auth"
// CredentialManager manages API keys and OAuth credentials.
type CredentialManager = auth.CredentialManager
// NewCredentialManager creates a credential manager.
func NewCredentialManager() (*CredentialManager, error) {
return auth.NewCredentialManager()
}
// HasAnthropicCredentials checks if Anthropic credentials are stored.
func HasAnthropicCredentials() bool {
cm, err := auth.NewCredentialManager()
if err != nil {
return false
}
return cm.GetAnthropicCredentials() != nil
}
// GetAnthropicAPIKey resolves the Anthropic API key using the standard
// resolution order: stored credentials -> ANTHROPIC_API_KEY env var.
func GetAnthropicAPIKey() string {
key, err := auth.GetAnthropicAPIKey("")
if err != nil {
return ""
}
return key
}
```
### Step 4: App-as-Consumer — CLI commands use SDK APIs
Currently CLI commands like `kit models`, `kit update-models`, and provider validation logic directly import `internal/models` and `internal/auth`. They should use `pkg/kit` functions instead.
**File**: `cmd/root.go` or wherever model validation happens
```go
// Before:
import "github.com/mark3labs/kit/internal/models"
registry := models.GetGlobalRegistry()
info := registry.LookupModel(provider, model)
// After:
import kit "github.com/mark3labs/kit/pkg/kit"
info := kit.LookupModel(provider, model)
```
**File**: `cmd/` auth-related commands
```go
// Before:
import "github.com/mark3labs/kit/internal/auth"
cm, _ := auth.NewCredentialManager()
// After:
import kit "github.com/mark3labs/kit/pkg/kit"
cm, _ := kit.NewCredentialManager()
```
Since these are type aliases, existing code continues to work during gradual migration.
### Step 5: Write tests and verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
go vet ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| CREATE | `pkg/kit/models.go` | Model registry, parsing, validation, suggestions |
| CREATE | `pkg/kit/auth.go` | Credential management exports |
| EDIT | `pkg/kit/kit.go` | Add GetModel(), GetModelInfo() |
| EDIT | `cmd/` | Migrate to use pkg/kit functions (gradual) |
## Verification Checklist
- [ ] `ParseModelString` handles "provider/model" format
- [ ] `GetSupportedProviders` returns provider list
- [ ] `LookupModel` returns info for known models
- [ ] `CheckProviderReady` gives clear error messages
- [ ] CLI commands use SDK functions instead of internal imports
-166
View File
@@ -1,166 +0,0 @@
# Plan 07: Compaction APIs
**Priority**: P2
**Effort**: Medium
**Goal**: Add context window management with token estimation, compaction triggers, and summarization. CLI `--compact` flag should use the SDK.
## Background
Pi exports `compact()`, `generateBranchSummary()`, `shouldCompact()`, `calculateContextTokens()`. Kit has no compaction — only `len(text)/4` estimation in `ui/usage_tracker.go:69` for display. This plan adds compaction from scratch, designed SDK-first so the CLI consumes it.
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
- Plan 03 (Event subscriber system)
- Plan 04 (Enhanced session management — tree sessions for branch summaries)
## Step-by-Step
### Step 1: Create `internal/compaction/` package
**File**: `internal/compaction/compaction.go` (new)
```go
package compaction
// EstimateTokens provides a rough token count (~4 chars per token).
func EstimateTokens(text string) int {
return len(text) / 4
}
// EstimateMessageTokens estimates tokens for a slice of fantasy messages.
func EstimateMessageTokens(messages []fantasy.Message) int { ... }
// ShouldCompact checks if conversation exceeds threshold percentage of limit.
func ShouldCompact(messages []fantasy.Message, contextLimit int, thresholdPct float64) bool { ... }
// CompactionResult contains statistics from a compaction.
type CompactionResult struct {
Summary string
OriginalTokens int
CompactedTokens int
MessagesRemoved int
}
// CompactionOptions configures compaction behavior.
type CompactionOptions struct {
ContextLimit int // Model's context window (tokens)
ThresholdPct float64 // Trigger threshold (0.0-1.0), default 0.8
PreserveRecent int // Recent messages to keep, default 10
SummaryPrompt string // Custom summary prompt (empty = default)
}
// FindCutPoint determines where to cut for compaction.
func FindCutPoint(messages []fantasy.Message, preserveRecent int) int { ... }
// Compact summarizes older messages using the LLM.
func Compact(ctx context.Context, model fantasy.LanguageModel, messages []fantasy.Message, opts CompactionOptions) (*CompactionResult, []fantasy.Message, error) { ... }
```
Full implementations as described in the original plan (summarize messages before cut point using LLM, return summary + preserved recent messages).
### Step 2: Export compaction in SDK
**File**: `pkg/kit/types.go` — add type aliases:
```go
type CompactionResult = compaction.CompactionResult
type CompactionOptions = compaction.CompactionOptions
```
### Step 3: Add Compact() and context methods to Kit
**File**: `pkg/kit/kit.go`
```go
// Compact summarizes older messages to reduce context usage.
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions) (*CompactionResult, error) { ... }
// EstimateContextTokens returns estimated token count of current conversation.
func (m *Kit) EstimateContextTokens() int { ... }
// ShouldCompact checks if conversation is near the context limit.
func (m *Kit) ShouldCompact() bool { ... }
// ContextStats returns current context usage statistics.
type ContextStats struct {
EstimatedTokens int
ContextLimit int
UsagePercent float64
MessageCount int
}
func (m *Kit) GetContextStats() ContextStats { ... }
```
### Step 4: Add auto-compaction option
```go
type Options struct {
// ... existing fields ...
AutoCompact bool // Auto-compact when near limit
CompactionOptions *CompactionOptions // Config for auto-compact
}
```
In `Prompt()`, check before generation:
```go
if m.autoCompact && m.ShouldCompact() {
m.Compact(ctx, m.compactionOpts) // best-effort
}
```
### Step 5: App-as-Consumer — CLI `--compact` flag uses SDK
Currently `cmd/root.go` has a `compactMode` flag (line 37) but compaction is not implemented. After this plan:
**File**: `cmd/root.go`
```go
// Map --compact flag to SDK option
if compactMode {
kitOpts.AutoCompact = true
}
```
The CLI could also expose a `/compact` slash command in interactive mode that calls `kit.Compact()`:
```go
// In interactive command handler:
case "/compact":
result, err := k.Compact(ctx, nil)
if err != nil {
fmt.Printf("Compaction failed: %v\n", err)
} else {
fmt.Printf("Compacted: %d messages removed, %d -> %d tokens\n",
result.MessagesRemoved, result.OriginalTokens, result.CompactedTokens)
}
```
The usage tracker in `internal/ui/usage_tracker.go` should also use `kit.EstimateContextTokens()` instead of its own `len(text)/4` heuristic — single source of truth.
### Step 6: Write tests and verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| CREATE | `internal/compaction/compaction.go` | Core compaction logic |
| EDIT | `pkg/kit/types.go` | Export CompactionResult, CompactionOptions |
| EDIT | `pkg/kit/kit.go` | Compact(), ShouldCompact(), GetContextStats(), auto-compact |
| EDIT | `cmd/root.go` | Map --compact to SDK option |
| EDIT | `internal/ui/usage_tracker.go` | Use SDK token estimation |
## Verification Checklist
- [ ] Token estimation is reasonable
- [ ] `ShouldCompact()` triggers near context limit
- [ ] `Compact()` reduces message count and tokens
- [ ] Auto-compaction triggers before prompts
- [ ] CLI `--compact` flag maps to `kit.Options{AutoCompact: true}`
- [ ] Usage tracker uses SDK estimation
-133
View File
@@ -1,133 +0,0 @@
# Plan 08: Skills & Prompts System
**Priority**: P2
**Effort**: Medium
**Goal**: Expose skills loading, prompt templates, and dynamic system prompt management. CLI and SDK share the same skills infrastructure.
## Background
Pi exports `loadSkills()`, `formatSkillsForPrompt()`, `PromptTemplate`, `expandPromptTemplate()`. Kit has an extension system but no "skills" concept (markdown-based instruction files) or prompt template system. This plan introduces a skills layer designed SDK-first.
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
- Plan 02 (Richer type exports)
## Step-by-Step
### Step 1: Create `internal/skills/` package
**File**: `internal/skills/skills.go` — Skill loading and parsing
```go
type Skill struct {
Name string
Description string
Content string
Path string
Tags []string
When string // "always", "on-demand", "file:*.go"
}
func LoadSkill(path string) (*Skill, error) { ... } // Markdown with YAML frontmatter
func LoadSkillsFromDir(dir string) ([]*Skill, error) { ... } // .md/.txt files + SKILL.md subdirs
func LoadSkills(cwd string) ([]*Skill, error) { ... } // Auto-discover .kit/skills/ + ~/.config/kit/skills/
func FormatForPrompt(skills []*Skill) string { ... } // Format for system prompt
```
**File**: `internal/skills/templates.go` — Prompt templates
```go
type PromptTemplate struct {
Name string
Content string
Variables []string
}
func NewPromptTemplate(name, content string) *PromptTemplate { ... }
func LoadPromptTemplate(path string) (*PromptTemplate, error) { ... }
func (t *PromptTemplate) Expand(values map[string]string) string { ... }
func (t *PromptTemplate) ExpandStrict(values map[string]string) (string, error) { ... }
```
**File**: `internal/skills/prompt_builder.go` — System prompt composition
```go
type PromptBuilder struct { ... }
func NewPromptBuilder(basePrompt string) *PromptBuilder { ... }
func (pb *PromptBuilder) WithSkills(skills []*Skill) *PromptBuilder { ... }
func (pb *PromptBuilder) WithSection(name, content string) *PromptBuilder { ... }
func (pb *PromptBuilder) Build() string { ... }
```
### Step 2: Export in SDK
**File**: `pkg/kit/skills.go` (new)
```go
package kit
import "github.com/mark3labs/kit/internal/skills"
type Skill = skills.Skill
type PromptTemplate = skills.PromptTemplate
type PromptBuilder = skills.PromptBuilder
func LoadSkill(path string) (*Skill, error) { return skills.LoadSkill(path) }
func LoadSkillsFromDir(dir string) ([]*Skill, error) { return skills.LoadSkillsFromDir(dir) }
func LoadSkills(cwd string) ([]*Skill, error) { return skills.LoadSkills(cwd) }
func FormatSkillsForPrompt(s []*Skill) string { return skills.FormatForPrompt(s) }
func NewPromptTemplate(name, content string) *PromptTemplate { return skills.NewPromptTemplate(name, content) }
func LoadPromptTemplate(path string) (*PromptTemplate, error) { return skills.LoadPromptTemplate(path) }
func NewPromptBuilder(basePrompt string) *PromptBuilder { return skills.NewPromptBuilder(basePrompt) }
```
### Step 3: Integrate skills into Kit Options
```go
type Options struct {
// ... existing fields ...
Skills []string // Skill files/dirs to load (empty = auto-discover)
SkillsDir string // Override default skills directory
}
```
In `New()`, load skills and compose system prompt via `PromptBuilder`.
### Step 4: App-as-Consumer — CLI uses SDK for skills
Currently Kit's extension loader (`internal/extensions/loader.go`) discovers extensions from `.kit/extensions/` and `~/.config/kit/extensions/`. The skills system follows the same pattern but for instruction files.
The CLI should:
1. Use `kit.LoadSkills(cwd)` to discover skills
2. Pass them via `kit.Options{Skills: ...}` or let auto-discovery handle it
3. A `/skills` slash command in interactive mode could list loaded skills
The existing `.agents/skills/` directory in the repo (used by btca) aligns with this convention. The SDK auto-discovers from `.kit/skills/` to avoid conflict with the `.agents/` convention used by other tools.
### Step 5: Write tests and verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| CREATE | `internal/skills/skills.go` | Skill loading/parsing |
| CREATE | `internal/skills/templates.go` | PromptTemplate |
| CREATE | `internal/skills/prompt_builder.go` | System prompt composition |
| CREATE | `pkg/kit/skills.go` | Public SDK exports |
| EDIT | `pkg/kit/kit.go` | Skills option, auto-loading |
## Verification Checklist
- [ ] Skills with YAML frontmatter parse correctly
- [ ] Skills without frontmatter load (name from filename)
- [ ] PromptTemplate expansion works
- [ ] PromptBuilder composes multi-section prompts
- [ ] Auto-discovery finds skills in standard directories
- [ ] CLI uses SDK for skill loading
-275
View File
@@ -1,275 +0,0 @@
# Plan 09: Extension Hook System
**Priority**: P3
**Effort**: High
**Goal**: Expose Go-native interception hooks in the SDK. The Kit CLI app registers its own extension handlers as SDK hooks, proving the API is complete.
## Background
Pi has 20+ lifecycle hooks. Kit already has an internal extension system (`internal/extensions/`) with 13 event types, a `Runner` for dispatch, and tool wrapping. But none of this is accessible through the SDK.
This plan exposes hooks in the SDK and migrates the app's extension dispatch to use them — making the CLI the proof that the hook API is production-ready.
## Prerequisites
- Plan 00 (Create `pkg/kit/`)
- Plan 01 (Export tools — for custom tool registration)
- Plan 02 (Richer type exports)
- Plan 03 (Event subscriber system — observation layer)
## Design: Events vs Hooks
| | Events (Plan 03) | Hooks (This Plan) |
|--|------------------|-------------------|
| Purpose | **Observe** | **Intercept** |
| Can block? | No | Yes (BeforeToolCall) |
| Can modify? | No | Yes (AfterToolResult) |
| Pattern | `Subscribe(func(Event))` | `OnBeforeToolCall(func(Hook) *Result)` |
| Priority | N/A | High/Normal/Low ordering |
Both coexist — events fire regardless; hooks run before/after and can alter execution.
## Step-by-Step
### Step 1: Define hook input/result types
**File**: `pkg/kit/hooks.go` (new)
```go
package kit
type HookPriority int
const (
HookPriorityHigh HookPriority = 0
HookPriorityNormal HookPriority = 50
HookPriorityLow HookPriority = 100
)
// BeforeToolCall — can block tool execution
type BeforeToolCallHook struct {
ToolName string
ToolArgs string
}
type BeforeToolCallResult struct {
Block bool
Reason string
}
// AfterToolResult — can modify tool output
type AfterToolResultHook struct {
ToolName string
ToolArgs string
Result string
IsError bool
}
type AfterToolResultResult struct {
Result *string // non-nil overrides
IsError *bool // non-nil overrides
}
// BeforeTurn — can modify prompt, inject context
type BeforeTurnHook struct {
Prompt string
}
type BeforeTurnResult struct {
Prompt *string // override prompt
SystemPrompt *string // prepend system message
InjectText *string // prepend user message (context)
}
// AfterTurn — observe completion
type AfterTurnHook struct {
Response string
Error error
}
```
### Step 2: Implement generic hook registry with priority ordering
```go
type hookRegistry[In any, Out any] struct {
mu sync.RWMutex
hooks []hookEntry[In, Out]
next int
}
type hookEntry[In any, Out any] struct {
id int
priority HookPriority
handler func(In) *Out
}
func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func() { ... }
func (hr *hookRegistry[In, Out]) run(input In) *Out { ... } // first non-nil result wins
```
### Step 3: Add registries to Kit struct and expose registration methods
```go
type Kit struct {
// ... existing fields ...
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult]
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult]
afterTurn *hookRegistry[AfterTurnHook, struct{}]
}
func (m *Kit) OnBeforeToolCall(p HookPriority, h func(BeforeToolCallHook) *BeforeToolCallResult) func() { ... }
func (m *Kit) OnAfterToolResult(p HookPriority, h func(AfterToolResultHook) *AfterToolResultResult) func() { ... }
func (m *Kit) OnBeforeTurn(p HookPriority, h func(BeforeTurnHook) *BeforeTurnResult) func() { ... }
func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() { ... }
```
### Step 4: Wire hooks into Prompt flow
In `Prompt()`:
1. Run `beforeTurn` hooks — can modify prompt, inject system/context messages
2. Wrap tools with `hookedTool` that runs `beforeToolCall` (can block) and `afterToolResult` (can modify)
3. Run `afterTurn` hooks after generation
### Step 5: Tool wrapping via hooks
```go
type hookedTool struct {
inner fantasy.AgentTool
kit *Kit
}
func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
// 1. BeforeToolCall hook — can block
result := h.kit.beforeToolCall.run(BeforeToolCallHook{...})
if result != nil && result.Block { return error }
// 2. Execute actual tool
resp, err := h.inner.Run(ctx, call)
// 3. AfterToolResult hook — can modify
after := h.kit.afterToolResult.run(AfterToolResultHook{...})
if after != nil { /* apply overrides */ }
return resp, err
}
```
The hook wrapper composes with the existing extension wrapper:
```go
// Extension wrapper runs first (inner), SDK hooks run outside (outer)
tools = extensionWrapper(tools) // extensions wrap
tools = m.wrapToolsWithHooks(tools) // SDK hooks wrap on top
```
### Step 6: App-as-Consumer — Extension system registers as SDK hooks
This is the payoff step. The app's extension `Runner` currently dispatches events directly in `internal/app/app.go:executeStep()`. After this plan, extensions register as SDK hooks during initialization:
**File**: `pkg/kit/setup.go` or a new `pkg/kit/extensions_bridge.go`
```go
// bridgeExtensions registers extension handlers as SDK hooks.
// This makes the extension system a consumer of the SDK hook API.
func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// Extension BeforeAgentStart → SDK BeforeTurn hook
if runner.HasHandlers(extensions.BeforeAgentStart) {
m.OnBeforeTurn(HookPriorityNormal, func(h BeforeTurnHook) *BeforeTurnResult {
result, _ := runner.Emit(extensions.BeforeAgentStartEvent{Prompt: h.Prompt})
if r, ok := result.(extensions.BeforeAgentStartResult); ok {
return &BeforeTurnResult{
SystemPrompt: r.SystemPrompt,
InjectText: r.InjectText,
}
}
return nil
})
}
// Extension Input → SDK BeforeTurn hook (higher priority, runs first)
if runner.HasHandlers(extensions.Input) {
m.OnBeforeTurn(HookPriorityHigh, func(h BeforeTurnHook) *BeforeTurnResult {
result, _ := runner.Emit(extensions.InputEvent{Text: h.Prompt})
if r, ok := result.(extensions.InputResult); ok {
if r.Action == "transform" {
return &BeforeTurnResult{Prompt: &r.Text}
}
}
return nil
})
}
// Extension ToolCall → SDK BeforeToolCall hook
// (Already handled by extensions.WrapToolsWithExtensions, but could also
// be bridged here for SDK-only consumers)
}
```
Called during `Kit.New()`:
```go
if setupResult.ExtRunner != nil {
k.bridgeExtensions(setupResult.ExtRunner)
}
```
**Migration path**:
1. **Phase 1** (this plan): Bridge existing extensions as SDK hooks
2. **Phase 2** (future): `executeStep()` in app.go uses only SDK hooks, removes direct runner calls
3. **Phase 3** (future): Extension runner emits SDK events/hooks natively instead of its own types
### Step 7: Custom tool registration via Options
```go
type Options struct {
// ... existing fields ...
ExtraTools []Tool // Additional tools for the agent
}
```
### Step 8: Write tests and verify
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
```
## Files Changed Summary
| Action | File | Change |
|--------|------|--------|
| CREATE | `pkg/kit/hooks.go` | Hook types, registry, registration methods |
| EDIT | `pkg/kit/kit.go` | Hook registries, tool wrapper, Prompt hook invocation |
| CREATE | `pkg/kit/extensions_bridge.go` | Bridge extension events to SDK hooks |
| EDIT | `internal/app/app.go` | Gradual migration to use SDK hooks |
## API Surface After This Plan
```go
// Block dangerous tool calls
k.OnBeforeToolCall(kit.HookPriorityHigh, func(h kit.BeforeToolCallHook) *kit.BeforeToolCallResult {
if h.ToolName == "bash" && isDangerous(h.ToolArgs) {
return &kit.BeforeToolCallResult{Block: true, Reason: "dangerous"}
}
return nil
})
// Modify tool results
k.OnAfterToolResult(kit.HookPriorityNormal, func(h kit.AfterToolResultHook) *kit.AfterToolResultResult {
sanitized := redact(h.Result)
return &kit.AfterToolResultResult{Result: &sanitized}
})
// Inject context before each turn
k.OnBeforeTurn(kit.HookPriorityNormal, func(h kit.BeforeTurnHook) *kit.BeforeTurnResult {
ctx := loadProjectContext()
return &kit.BeforeTurnResult{InjectText: &ctx}
})
```
## Verification Checklist
- [ ] BeforeToolCall hooks can block tool calls
- [ ] AfterToolResult hooks can modify results
- [ ] BeforeTurn hooks can modify prompts and inject context
- [ ] Priority ordering works correctly
- [ ] Unregister removes hooks
- [ ] Extension system bridges to SDK hooks
- [ ] Hooks compose with existing extension wrapper
- [ ] Thread-safe under concurrent access
-714
View File
@@ -1,714 +0,0 @@
# Plan 10: App-as-SDK-Consumer — Complete Integration
**Priority**: P4
**Effort**: High
**Goal**: Make the CLI app a full consumer of the SDK. `cmd/root.go` creates a `*Kit` via `kit.New()`. The app receives `*Kit`, calls `kit.PromptResult()`, subscribes to SDK events for TUI rendering, and extension observation events route through the SDK EventBus. This closes all deferred work from Plans 03, 05, and 09.
## Background
Plans 0009 built the SDK surface (`pkg/kit/`) but the CLI app still bypasses it for the critical path:
- `cmd/root.go` calls `SetupAgent()` directly instead of `kit.New()`
- `internal/app/app.go:executeStep()` calls `agent.GenerateWithLoopAndStreaming()` directly with 150+ lines of manual callback wiring, extension event dispatch, and session persistence — all of which the SDK already handles in `runTurn()`
- Extension observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `executeStep()`, not from the SDK
- The app receives an `AgentRunner` interface, not a `*Kit`
After this plan, `executeStep()` becomes a thin wrapper around `kit.PromptResult()`, and extension events flow through the SDK's EventBus.
### Deferred Items Resolved
| Source | What | How |
|--------|------|-----|
| Plan 03 Step 6 | App TUI subscribes to SDK events | Step 5 |
| Plan 03 Step 7 | Extension observation events forward to SDK EventBus | Step 4 |
| Plan 05 Step 6 | `executeStep()` delegates to SDK `Prompt()` | Step 6 |
| Plan 09 Phase 2 | App uses only SDK hooks, no direct runner calls | Step 6 |
## Prerequisites
- Plans 0009 (all complete)
## Step-by-Step
### Step 1: Extend Kit for CLI consumption
**Files**: `pkg/kit/kit.go`, `pkg/kit/setup.go`
The CLI needs fields that the programmatic SDK doesn't: spinner for Ollama loading, buffered debug logger, pre-loaded MCP config. Add these to `Options` and expose results via getters.
**1a. Add CLI fields to `Options`** (`pkg/kit/kit.go:48-71`):
```go
type Options struct {
// ... existing fields ...
// CLI-specific fields (ignored by programmatic SDK users)
MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set)
ShowSpinner bool // Show loading spinner for Ollama models
SpinnerFunc agent.SpinnerFunc // Spinner implementation (nil = no spinner)
UseBufferedLogger bool // Buffer debug messages for later display
Debug bool // Enable debug logging
}
```
**1b. Add fields and getters to `Kit` struct** (`pkg/kit/kit.go:22-36`):
```go
type Kit struct {
// ... existing fields ...
extRunner *extensions.Runner
bufferedLogger *tools.BufferedDebugLogger
}
```
Getters:
```go
// GetExtRunner returns the extension runner (nil if extensions are disabled).
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
// GetAgent returns the underlying agent. Callers that need the raw agent
// (e.g. for GetTools(), GetLoadingMessage()) can use this.
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
// GetTreeSession returns the current tree session manager.
// (Already exists as a method — verify it's public.)
```
**1c. Update `New()`** (`pkg/kit/kit.go:111-204`):
- If `opts.MCPConfig != nil`, skip `config.LoadAndValidateConfig()` and use it directly
- If `opts.Debug`, set `viper.Set("debug", true)`
- Pass `ShowSpinner`, `SpinnerFunc`, `UseBufferedLogger` through to `SetupAgent()`
- Store `agentResult.ExtRunner` and `agentResult.BufferedLogger` on the Kit struct
```go
// In New(), replace lines 152-176:
mcpConfig := opts.MCPConfig
if mcpConfig == nil {
var err error
mcpConfig, err = config.LoadAndValidateConfig()
if err != nil {
return nil, fmt.Errorf("failed to load MCP config: %w", err)
}
}
agentResult, err := SetupAgent(ctx, AgentSetupOptions{
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
ShowSpinner: opts.ShowSpinner,
SpinnerFunc: opts.SpinnerFunc,
UseBufferedLogger: opts.UseBufferedLogger,
CoreTools: opts.Tools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
})
// Store on Kit struct:
k := &Kit{
// ... existing fields ...
extRunner: agentResult.ExtRunner,
bufferedLogger: agentResult.BufferedLogger,
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
Existing behavior is unchanged — the new fields default to zero values.
---
### Step 2: Add TurnResult and PromptResult method
**File**: `pkg/kit/kit.go`
The current `Prompt()` returns `(string, error)`, which is fine for simple SDK usage but the app needs usage stats and conversation messages. Add a richer return path.
**2a. Define TurnResult** (new, in `pkg/kit/kit.go`):
```go
// TurnResult contains the full result of a prompt turn, including usage
// statistics and the updated conversation. Use PromptResult() instead of
// Prompt() when you need access to this data.
type TurnResult struct {
// Response is the assistant's final text response.
Response string
// TotalUsage is the aggregate token usage across all steps in the turn
// (includes tool-calling loop iterations). Nil if the provider didn't
// report usage.
TotalUsage *FantasyUsage
// FinalUsage is the token usage from the last API call only. Use this
// for context window fill estimation (InputTokens + OutputTokens ≈
// current context size). Nil if unavailable.
FinalUsage *FantasyUsage
// Messages is the full updated conversation after the turn, including
// any tool call/result messages added during the agent loop.
Messages []FantasyMessage
}
```
**2b. Modify `runTurn()` to return `*TurnResult`** (`pkg/kit/kit.go:319`):
Change signature from:
```go
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (string, error)
```
To:
```go
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (*TurnResult, error)
```
Build and return `TurnResult` from the `agent.GenerateWithLoopResult`:
```go
responseText := result.FinalResponse.Content.Text()
turnResult := &TurnResult{
Response: responseText,
Messages: result.ConversationMessages,
}
if result.TotalUsage != nil {
turnResult.TotalUsage = result.TotalUsage
}
if result.FinalResponse != nil {
turnResult.FinalUsage = &result.FinalResponse.Usage
}
// ... existing event emission and persistence ...
return turnResult, nil
```
On the error path, return `nil, err` (as before, but with `*TurnResult` instead of `""`).
**2c. Update all prompt methods** to extract the string from `TurnResult`:
```go
func (m *Kit) Prompt(ctx context.Context, message string) (string, error) {
result, err := m.runTurn(ctx, message, message, []fantasy.Message{
fantasy.NewUserMessage(message),
})
if err != nil {
return "", err
}
return result.Response, nil
}
```
Same pattern for `Steer()`, `FollowUp()`, `PromptWithOptions()`, `PromptWithCallbacks()`.
**2d. Add `PromptResult()` method**:
```go
// PromptResult sends a message and returns the full turn result including
// usage statistics and conversation messages. Use this instead of Prompt()
// when you need more than just the response text.
func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, error) {
return m.runTurn(ctx, message, message, []fantasy.Message{
fantasy.NewUserMessage(message),
})
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
Existing `Prompt()` callers (examples, tests) are unaffected.
---
### Step 3: Migrate cmd/root.go to use kit.New()
**Files**: `cmd/root.go`, `cmd/setup.go`
Replace the manual `SetupAgent()``InitTreeSession()``BuildAppOptions()` chain with a single `kit.New()` call.
**3a. Replace agent creation** in `runNormalMode()` (`cmd/root.go:336-362`):
Before:
```go
agentResult, err := SetupAgent(ctx, AgentSetupOptions{...})
mcpAgent := agentResult.Agent
defer func() { _ = mcpAgent.Close() }()
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
```
After:
```go
// Build Kit options from CLI flags.
kitOpts := &kit.Options{
MCPConfig: mcpConfig,
ShowSpinner: true,
SpinnerFunc: spinnerFunc,
UseBufferedLogger: true,
Quiet: quietFlag,
Debug: debugMode,
NoSession: noSessionFlag,
Continue: continueFlag,
SessionPath: sessionPath,
AutoCompact: autoCompactFlag,
}
if resumeFlag {
sessions, _ := kit.ListSessions("")
if len(sessions) > 0 {
kitOpts.SessionPath = sessions[0].Path
}
}
kitInstance, err := kit.New(ctx, kitOpts)
if err != nil {
return err
}
defer kitInstance.Close()
```
**3b. Extract metadata from Kit instead of raw agent**:
```go
mcpAgent := kitInstance.GetAgent()
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
```
**3c. Get buffered logger and tree session from Kit**:
```go
bufferedLogger := kitInstance.GetBufferedLogger()
// ... display buffered debug messages ...
treeSession := kitInstance.GetTreeSession()
var messages []fantasy.Message
if treeSession != nil {
messages = treeSession.GetFantasyMessages()
}
```
**3d. Build app options using Kit**:
```go
appOpts := BuildAppOptions(mcpAgent, mcpConfig, modelName, serverNames, toolNames, kitInstance.GetExtRunner())
appOpts.TreeSession = treeSession
appOpts.Kit = kitInstance // NEW — added in Step 5
```
**3e. Extension context setup** — use Kit's extension runner:
```go
extRunner := kitInstance.GetExtRunner()
if extRunner != nil {
extRunner.SetContext(extensions.Context{...})
// Emit SessionStart
}
```
**3f. Remove the separate `kit.InitTreeSession()` call** — Kit.New() handles session creation.
**3g. Remove the `defer func() { _ = mcpAgent.Close() }()`** — `kitInstance.Close()` handles cleanup.
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
# Manual: run `kit -p "hello"` to verify non-interactive mode
# Manual: run `kit` to verify interactive mode
```
The app still uses its own `executeStep()` at this point — that migrates in Step 6.
---
### Step 4: Bridge extension observation events through SDK EventBus
**File**: `pkg/kit/extensions_bridge.go`
Currently `bridgeExtensions()` only bridges `Input` and `BeforeAgentStart` (hook events). The observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `app.executeStep()` directly to the extension runner. After this step, the SDK emits them from `runTurn()`/`generate()` and the bridge forwards to extensions.
**4a. Subscribe to SDK events and forward to extension runner**:
Add to `bridgeExtensions()` (`pkg/kit/extensions_bridge.go:16`):
```go
func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// ... existing Input and BeforeAgentStart hooks ...
// Forward SDK observation events to extension runner.
// These events are emitted by runTurn()/generate() and forwarded here
// so extensions see them without the app having to emit them manually.
if runner.HasHandlers(extensions.AgentStart) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnStartEvent); ok {
runner.Emit(extensions.AgentStartEvent{Prompt: ev.Prompt})
}
})
}
if runner.HasHandlers(extensions.MessageStart) {
m.Subscribe(func(e Event) {
if _, ok := e.(MessageStartEvent); ok {
runner.Emit(extensions.MessageStartEvent{})
}
})
}
if runner.HasHandlers(extensions.MessageUpdate) {
m.Subscribe(func(e Event) {
if ev, ok := e.(MessageUpdateEvent); ok {
runner.Emit(extensions.MessageUpdateEvent{Chunk: ev.Chunk})
}
})
}
if runner.HasHandlers(extensions.MessageEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(MessageEndEvent); ok {
runner.Emit(extensions.MessageEndEvent{Content: ev.Content})
}
})
}
if runner.HasHandlers(extensions.AgentEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnEndEvent); ok {
stopReason := "completed"
response := ev.Response
if ev.Error != nil {
stopReason = "error"
response = ""
}
runner.Emit(extensions.AgentEndEvent{
Response: response,
StopReason: stopReason,
})
}
})
}
}
```
**4b. Add SessionShutdown to Kit.Close()**:
In `pkg/kit/kit.go:Close()`:
```go
func (m *Kit) Close() error {
// Emit SessionShutdown for extensions.
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionShutdown) {
m.extRunner.Emit(extensions.SessionShutdownEvent{})
}
if m.treeSession != nil {
_ = m.treeSession.Close()
}
return m.agent.Close()
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
At this point, extension observation events will fire from BOTH `executeStep()` (app) and the SDK bridge. This is intentional for the transition — Step 6 removes the app-side emission.
---
### Step 5: Wire app to Kit — add Kit field and SDK event → tea.Msg bridge
**Files**: `internal/app/options.go`, `internal/app/app.go`
Give the app a `*Kit` reference so it can call SDK prompt methods and subscribe to events.
**5a. Add Kit field to `app.Options`** (`internal/app/options.go:50`):
```go
import kit "github.com/mark3labs/kit/pkg/kit"
type Options struct {
// Kit is the SDK instance. When set, executeStep() delegates to
// kit.PromptResult() and events flow through SDK subscriptions.
Kit *kit.Kit
// Agent is the agent used to run the agentic loop. Required when Kit
// is nil. When Kit is set, this field is ignored (Kit owns the agent).
Agent AgentRunner
// ... rest unchanged ...
}
```
**5b. Create SDK event → tea.Msg bridge function** (`internal/app/app.go`):
```go
// subscribeSDKEvents registers temporary SDK event subscribers that convert
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
// unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
k := a.opts.Kit
var unsubs []func()
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
switch ev := e.(type) {
case kit.ToolCallEvent:
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolExecutionStartEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
case kit.ToolExecutionEndEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
case kit.ToolResultEvent:
sendFn(ToolResultEvent{
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
Result: ev.Result, IsError: ev.IsError,
})
case kit.ToolCallContentEvent:
sendFn(ToolCallContentEvent{Content: ev.Content})
case kit.ResponseEvent:
sendFn(ResponseCompleteEvent{Content: ev.Content})
case kit.MessageUpdateEvent:
sendFn(StreamChunkEvent{Content: ev.Chunk})
}
}))
return func() {
for _, unsub := range unsubs {
unsub()
}
}
}
```
**5c. Pass Kit in `cmd/root.go`**:
In the `BuildAppOptions` call or directly after:
```go
appOpts.Kit = kitInstance
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
The bridge function exists but is not called yet. Step 6 wires it in.
---
### Step 6: Migrate executeStep() to use kit.PromptResult()
**File**: `internal/app/app.go`
Replace the 150+ line `executeStep()` with a thin wrapper around `kit.PromptResult()`.
**6a. Rewrite executeStep()**:
The new `executeStep()` when `opts.Kit` is set:
```go
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) {
if a.opts.Kit == nil {
return a.executeStepLegacy(ctx, prompt, eventFn)
}
sendFn := func(msg tea.Msg) {
if eventFn != nil {
eventFn(msg)
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
defer unsub()
// Show spinner while the agent works.
sendFn(SpinnerEvent{Show: true})
result, err := a.opts.Kit.PromptResult(ctx, prompt)
if err != nil {
return nil, err
}
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker.
a.updateUsageFromTurnResult(result, prompt)
return &agent.GenerateWithLoopResult{
ConversationMessages: result.Messages,
}, nil
}
```
**6b. Rename existing executeStep to executeStepLegacy**:
Keep the old implementation as `executeStepLegacy()` so the transition is safe. It remains as a fallback when `opts.Kit == nil` (e.g. in tests that supply a stub `AgentRunner`).
**6c. Add `updateUsageFromTurnResult` helper**:
```go
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
if a.opts.UsageTracker == nil || result == nil {
return
}
if result.TotalUsage != nil {
inputTokens := int(result.TotalUsage.InputTokens)
outputTokens := int(result.TotalUsage.OutputTokens)
if inputTokens > 0 && outputTokens > 0 {
cacheReadTokens := int(result.TotalUsage.CacheReadTokens)
cacheWriteTokens := int(result.TotalUsage.CacheCreationTokens)
a.opts.UsageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
} else {
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
return
}
}
if result.FinalUsage != nil {
if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 {
a.opts.UsageTracker.SetContextTokens(ct)
}
}
}
```
**6d. Remove extension event emission from `executeStepLegacy()`**:
Since the SDK bridge (Step 4) now forwards extension observation events, remove these direct calls from `executeStepLegacy()`:
- `extensions.AgentStart` emission (line 432-434)
- `extensions.MessageStart` emission (line 440-442)
- `extensions.MessageUpdate` emission (line 473-475)
- `extensions.MessageEnd` emission (line 496-498)
- `extensions.AgentEnd` emission (lines 482-487, 501-506)
The `Input` and `BeforeAgentStart` extensions are already handled by the SDK hooks (bridged in Plan 09). Remove those too from `executeStepLegacy()`:
- `extensions.Input` emission (lines 372-387)
- `extensions.BeforeAgentStart` emission (lines 414-429)
What remains in `executeStepLegacy()` is just the core generation call — which is now essentially the same as calling `kit.PromptResult()`.
**6e. Remove SessionShutdown from `app.Close()`**:
Since `Kit.Close()` now handles SessionShutdown (Step 4b), remove:
```go
// In app.Close() — remove:
if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.SessionShutdown) {
_, _ = a.opts.Extensions.Emit(extensions.SessionShutdownEvent{})
}
```
**Verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
# Manual: run `kit -p "list files in the current directory"` — verify tool calls render
# Manual: run `kit` in interactive mode — verify streaming, tool results, spinner
# Manual: create a .kit/extensions/ extension with AgentStart handler — verify it fires
```
---
### Step 7: Clean up dead code
**Files**: `internal/app/app.go`, `internal/app/options.go`, `internal/app/events.go`, `cmd/setup.go`
**7a. Remove `executeStepLegacy()`**:
Once confident the SDK path works, delete `executeStepLegacy()` entirely. Update `executeStep()` to remove the `if a.opts.Kit == nil` guard.
**7b. Remove `AgentRunner` interface**:
`internal/app/options.go:17-28` — delete `AgentRunner`. The `Agent AgentRunner` field is no longer used when `Kit` is set. Remove the `Agent` field from `Options`.
**7c. Remove `Extensions` field from `app.Options`**:
`internal/app/options.go:94-98` — the app no longer calls `a.opts.Extensions.Emit()` directly. Extension dispatch goes through SDK hooks/events. Remove the field and all `a.opts.Extensions` references in `app.go`.
**7d. Simplify `BuildAppOptions()` in `cmd/setup.go`**:
Remove the `mcpAgent` and `extRunner` parameters since the app gets these from `Kit`:
```go
func BuildAppOptions(kitInstance *kit.Kit, mcpConfig *config.Config,
modelName string, serverNames, toolNames []string) app.Options {
return app.Options{
Kit: kitInstance,
MCPConfig: mcpConfig,
ModelName: modelName,
ServerNames: serverNames,
ToolNames: toolNames,
StreamingEnabled: viper.GetBool("stream"),
Quiet: quietFlag,
Debug: viper.GetBool("debug"),
CompactMode: viper.GetBool("compact"),
}
}
```
**7e. Remove `updateUsage()` from `app.go`** (`app.go:596-627`):
Replaced by `updateUsageFromTurnResult()` which works with `TurnResult` instead of raw `GenerateWithLoopResult`.
**7f. Simplify `SessionStart` emission**:
Move SessionStart from `cmd/root.go:448` into `Kit.New()` or a new `Kit.EmitSessionStart()` method called by the CLI after extension context is configured.
**7g. Remove `inputSource()` helper** (`app.go:524-532`):
Only used by the now-removed Input extension emission.
**7h. Run final verification**:
```bash
go build -o output/kit ./cmd/kit
go test -race ./...
golangci-lint run ./...
```
Confirm no references to removed types/functions. Confirm no unused imports.
---
## Verification Checklist
- [ ] `go build -o output/kit ./cmd/kit` succeeds
- [ ] `go test -race ./...` passes
- [ ] `golangci-lint run ./...` — 0 issues
- [ ] `kit.New()` creates agent, session, extensions in one call
- [ ] `cmd/root.go` no longer calls `SetupAgent()` directly
- [ ] `executeStep()` delegates to `kit.PromptResult()`
- [ ] SDK events drive TUI rendering (tool calls, streaming, results)
- [ ] Extension observation events (AgentStart/End, MessageStart/Update/End) fire via SDK bridge
- [ ] Extension interception events (Input, BeforeAgentStart, ToolCall, ToolResult) still work
- [ ] Usage tracker receives correct token counts
- [ ] Session persistence works (tree session)
- [ ] `--continue` / `--no-session` / `--session` flags work
- [ ] Spinner shows/hides correctly
- [ ] Interactive mode (BubbleTea) works
- [ ] Non-interactive mode (`-p "..."`) works
- [ ] Extension SessionShutdown fires on close
- [ ] No remaining direct `extensions.Emit()` calls in `app.go`
- [ ] `AgentRunner` interface removed
- [ ] `app.Options.Extensions` field removed
-104
View File
@@ -1,104 +0,0 @@
# SDK Revamp Plans
## Core Architectural Principle
**The Kit CLI app is the primary consumer of the SDK.**
The SDK is not a thin wrapper for external users. The CLI is built on top of it:
1. `pkg/kit/` defines the canonical API for agents, sessions, events, and hooks
2. `cmd/` parses CLI flags, maps them to `kit.Options`, and calls `kit.New()`
3. `internal/app/` subscribes to SDK events for TUI rendering and uses SDK prompt methods
4. If the app needs a capability, it is added to the SDK first, then consumed by the app
5. External users get the exact same API the CLI uses
### Architecture
```
cmd/kit/main.go
|
v
cmd/ Parses flags, maps to kit.Options
|
v
pkg/kit/ Canonical SDK: New(), Prompt(), Subscribe(), hooks
|
+---> internal/agent/ Agent creation, generation loop
+---> internal/session/ Session persistence, tree manager
+---> internal/config/ Config loading, MCP server config
+---> internal/core/ Built-in tools (read, write, bash, etc.)
+---> internal/models/ Provider registry, model validation
+---> internal/auth/ Credential management, OAuth
+---> internal/compaction/ Context summarization (Plan 07)
+---> internal/skills/ Skill loading, templates (Plan 08)
+---> internal/extensions/ Yaegi extension runtime
internal/app/ TUI/interactive mode — subscribes to SDK events
|
+---> pkg/kit/ Uses SDK for prompts, sessions, tools
+---> internal/ui/ Owns BubbleTea rendering only
```
**No circular dependencies.** `pkg/kit/` never imports `cmd/`. `cmd/` imports `pkg/kit/`.
### Before vs After
| Concern | Before (Parallel) | After (SDK-First) |
|---------|-------------------|-------------------|
| Config init | `cmd.InitConfig()` called by both CLI and SDK | `kit.InitConfig()` in `pkg/kit/`, `cmd/` delegates |
| Agent creation | `cmd.SetupAgent()` called by both | `kit.SetupAgent()` in `pkg/kit/`, `cmd/` delegates |
| Session setup | `cmd/root.go` has 80-line if/else chain | `kit.Options{Continue: true}`, SDK handles it |
| Events | 3 parallel systems (SDK callbacks, extension events, TUI msgs) | Single SDK EventBus, TUI bridges via `Subscribe()` |
| Tool exposure | Internal only | `kit.AllTools()`, `kit.NewReadTool(kit.WithWorkDir(...))` |
| Hooks | Only via Yaegi extensions | `kit.OnBeforeToolCall()` — extensions bridge to SDK hooks |
## Plan Execution Order
| Plan | Priority | Description | Depends On |
|------|----------|-------------|------------|
| **00** | P0 | Create `pkg/kit/`, extract init from `cmd/` | None |
| **01** | P0 | Export tools and tool factories | 00 |
| **02** | P0 | Richer type exports (40+ types) | 00 |
| **03** | P1 | Unified event/subscriber system (core done; app/ext bridge deferred) | 00, 02 |
| **04** | P1 | Enhanced session management | 00, 02 |
| **05** | P1 | Additional prompt modes (Steer, FollowUp) | 00, 03 |
| **06** | P2 | Auth & model management APIs | 00, 02 |
| **07** | P2 | Compaction APIs | 00, 03, 04 |
| **08** | P2 | Skills & prompts system | 00, 02 |
| **09** | P3 | Extension hook system | 00, 01, 02, 03 |
| **10** | P4 | App-as-SDK-consumer — complete integration | 0009 |
### Recommended Batches
**Batch 1 — Foundation** (Plans 00, 01, 02):
Restructure package, expose tools and types. SDK is usable for basic programmatic access. CLI starts delegating to SDK.
**Batch 2 — Rich Interaction** (Plans 03, 04, 05):
Unified events, sessions, prompt modes. App migrates to SDK for event handling and session setup.
**Batch 3 — Management** (Plans 06, 07, 08):
Auth, compaction, skills. CLI commands use SDK functions.
**Batch 4 — Extensibility** (Plan 09):
Hook system with extension bridge. App's extension dispatch routes through SDK hooks.
**Batch 5 — Full Integration** (Plan 10):
CLI uses `kit.New()`, app calls `kit.PromptResult()`, extension events route through SDK EventBus. Closes all deferred items from Plans 03, 05, 09. Removes `AgentRunner` interface, `app.Options.Extensions`, and legacy `executeStep` code.
## Parity with Pi SDK
After all plans:
| Capability | Pi | Kit (After) |
|-----------|-----|-------------|
| Top-level package imports | Yes | `pkg/kit/` |
| Tool exports + factories | Yes | Plan 01 |
| Rich type surface (50+) | Yes | Plan 02 |
| Event subscriber system | Yes | Plan 03 |
| Session management (list/continue/branch) | Yes | Plan 04 |
| Multiple prompt modes | Yes | Plan 05 |
| Auth/model management | Yes | Plan 06 |
| Compaction APIs | Yes | Plan 07 |
| Skills/prompts system | Yes | Plan 08 |
| Extension hooks (20+ events) | Yes | Plan 09 |
| App built on SDK | Yes | Plan 10 (completes deferred work from 03, 05, 09) |