From 37e82781b141e3eacdcdc310ba639424c58ed0e3 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Mon, 2 Mar 2026 14:49:51 +0300 Subject: [PATCH] feat: add OnModelChange event and ctx.Exit(); remove Gap/Pi references from comments --- cmd/root.go | 7 ++++ examples/extensions/auto-commit.go | 3 +- examples/extensions/bookmark.go | 2 +- examples/extensions/inline-bash.go | 2 +- examples/extensions/minimal.go | 2 +- examples/extensions/notify.go | 3 +- examples/extensions/permission-gate.go | 2 +- examples/extensions/pirate.go | 2 +- examples/extensions/plan-mode.go | 3 +- examples/extensions/project-rules.go | 2 +- examples/extensions/protected-paths.go | 2 +- examples/extensions/summarize.go | 2 +- internal/app/app.go | 16 ++++++++ internal/compaction/compaction.go | 30 +++++++------- internal/compaction/compaction_test.go | 4 +- internal/core/tools.go | 8 ++-- internal/extensions/api.go | 54 ++++++++++++++++++-------- internal/extensions/events.go | 6 ++- internal/extensions/events_test.go | 5 ++- internal/extensions/loader.go | 6 +++ internal/extensions/runner.go | 2 +- internal/extensions/symbols.go | 1 + internal/extensions/wrapper.go | 3 +- internal/session/entry.go | 4 +- internal/session/store.go | 2 +- internal/session/tree_manager.go | 6 +-- internal/skills/skills.go | 2 +- internal/ui/model.go | 4 +- internal/ui/tree_selector.go | 3 +- pkg/kit/compaction.go | 4 +- pkg/kit/config.go | 2 +- pkg/kit/kit.go | 28 +++++++++++-- 32 files changed, 147 insertions(+), 75 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 9a27cbb2..2689263d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -616,6 +616,7 @@ func runNormalMode(ctx context.Context) error { 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() @@ -746,6 +747,8 @@ func runNormalMode(ctx context.Context) error { 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 @@ -753,6 +756,10 @@ func runNormalMode(ctx context.Context) error { // 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 { diff --git a/examples/extensions/auto-commit.go b/examples/extensions/auto-commit.go index ccab929f..39e7765c 100644 --- a/examples/extensions/auto-commit.go +++ b/examples/extensions/auto-commit.go @@ -10,8 +10,7 @@ import ( ) // Init automatically commits staged changes when the session shuts down, -// using the last assistant message as the commit message. Inspired by -// Pi's auto-commit-on-exit.ts. +// using the last assistant message as the commit message. // // Only commits if: // - There are staged changes (git diff --cached is non-empty) diff --git a/examples/extensions/bookmark.go b/examples/extensions/bookmark.go index 75fd6ef2..ef4e50a2 100644 --- a/examples/extensions/bookmark.go +++ b/examples/extensions/bookmark.go @@ -13,7 +13,7 @@ import ( // Init adds bookmark commands for marking and recalling important points in // a conversation. Bookmarks are persisted in the session tree and survive -// restarts. Inspired by Pi's bookmark.ts. +// restarts. // // Commands: // diff --git a/examples/extensions/inline-bash.go b/examples/extensions/inline-bash.go index a9c6f94a..52e4c7f5 100644 --- a/examples/extensions/inline-bash.go +++ b/examples/extensions/inline-bash.go @@ -12,7 +12,7 @@ import ( // 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. Inspired by Pi's inline-bash.ts. +// stdout. // // Examples: // diff --git a/examples/extensions/minimal.go b/examples/extensions/minimal.go index 416eaba5..bef59efd 100644 --- a/examples/extensions/minimal.go +++ b/examples/extensions/minimal.go @@ -10,7 +10,7 @@ import ( "kit/ext" ) -// Init demonstrates a minimal-chrome extension — a port of Pi's minimal.ts. +// 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: // diff --git a/examples/extensions/notify.go b/examples/extensions/notify.go index 56bd92d3..c399ecfa 100644 --- a/examples/extensions/notify.go +++ b/examples/extensions/notify.go @@ -11,8 +11,7 @@ import ( // Init sends a desktop notification when the agent finishes responding. // Useful for long-running tasks — get notified without watching the terminal. -// Inspired by Pi's notify.ts. -// + // Supports: Linux (notify-send), macOS (osascript). // // Usage: kit -e examples/extensions/notify.go diff --git a/examples/extensions/permission-gate.go b/examples/extensions/permission-gate.go index 5d17ddc2..773bb058 100644 --- a/examples/extensions/permission-gate.go +++ b/examples/extensions/permission-gate.go @@ -10,7 +10,7 @@ import ( ) // Init intercepts potentially dangerous bash commands and asks the user for -// confirmation before allowing execution. Inspired by Pi's permission-gate.ts. +// confirmation before allowing execution. // // Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/ // diff --git a/examples/extensions/pirate.go b/examples/extensions/pirate.go index ff49f42a..45f98591 100644 --- a/examples/extensions/pirate.go +++ b/examples/extensions/pirate.go @@ -6,7 +6,7 @@ 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. Inspired by Pi's pirate.ts. +// injection. // // Usage: kit -e examples/extensions/pirate.go func Init(api ext.API) { diff --git a/examples/extensions/plan-mode.go b/examples/extensions/plan-mode.go index c1c1dacf..fd8e2ef7 100644 --- a/examples/extensions/plan-mode.go +++ b/examples/extensions/plan-mode.go @@ -10,8 +10,7 @@ import ( // 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). -// Inspired by Pi's plan-mode extension. -// + // 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. diff --git a/examples/extensions/project-rules.go b/examples/extensions/project-rules.go index 30f0f10d..aaa51c52 100644 --- a/examples/extensions/project-rules.go +++ b/examples/extensions/project-rules.go @@ -14,7 +14,7 @@ import ( // 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. Inspired by Pi's claude-rules.ts. +// main system prompt. // // Place rule files in: // diff --git a/examples/extensions/protected-paths.go b/examples/extensions/protected-paths.go index 2cc630b3..497cbe2e 100644 --- a/examples/extensions/protected-paths.go +++ b/examples/extensions/protected-paths.go @@ -10,7 +10,7 @@ import ( ) // Init blocks tool calls that attempt to write, edit, or delete files in -// protected paths. Inspired by Pi's protected-paths.ts. +// protected paths. // // Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key // diff --git a/examples/extensions/summarize.go b/examples/extensions/summarize.go index 5fface19..c2ef3b7f 100644 --- a/examples/extensions/summarize.go +++ b/examples/extensions/summarize.go @@ -11,7 +11,7 @@ import ( // Init adds a /summarize command that generates a concise summary of the // current conversation using a direct LLM completion. Demonstrates the -// ctx.Complete API (Gap 17). Inspired by Pi's summarize.ts. +// ctx.Complete API. // // The summary is displayed in a styled block and can optionally be saved // to the session via AppendEntry for later retrieval. diff --git a/internal/app/app.go b/internal/app/app.go index 08acb460..6eda0d30 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -516,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 diff --git a/internal/compaction/compaction.go b/internal/compaction/compaction.go index fec92423..b64c3f42 100644 --- a/internal/compaction/compaction.go +++ b/internal/compaction/compaction.go @@ -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. diff --git a/internal/compaction/compaction_test.go b/internal/compaction/compaction_test.go index d54b4ed9..d75e8b6f 100644 --- a/internal/compaction/compaction_test.go +++ b/internal/compaction/compaction_test.go @@ -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) { diff --git a/internal/core/tools.go b/internal/core/tools.go index 6336a616..5edcb300 100644 --- a/internal/core/tools.go +++ b/internal/core/tools.go @@ -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...), diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 7e4f204f..5ba8bd0e 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -245,8 +245,6 @@ type Context struct { // fmt.Sprintf("[%s%s] %d%%", strings.Repeat("#", pct/10), strings.Repeat("-", 10-pct/10), pct) GetContextStats func() ContextStats - // --- Session Management (Gap 1) --- - // GetMessages returns the conversation messages on the current branch, // ordered from root to leaf. This is a read-only view; extensions // cannot modify messages directly. @@ -265,8 +263,6 @@ type Context struct { // file. Returns empty string for in-memory (ephemeral) sessions. GetSessionPath func() string - // --- Session Persistence (Gap 2) --- - // AppendEntry persists custom extension data in the session tree. // The data survives across session restarts and can be retrieved via // GetEntries. Use entryType to namespace your data (e.g. "myext:state"). @@ -299,8 +295,6 @@ type Context struct { // ctx.SetEditorText("Please review the changes in src/main.go") SetEditorText func(text string) - // --- Keyed Status Bar (Gap M3) --- - // SetStatus places or updates a keyed entry in the TUI status bar. // Multiple entries from different extensions coexist; each is identified // by a unique key. Lower priority values render further left. @@ -314,8 +308,6 @@ type Context struct { // does not exist. RemoveStatus func(key string) - // --- Extension Options (Gap 7) --- - // GetOption returns the value of a named extension option. Options are // resolved in priority order: // 1. Runtime override (via SetOption) @@ -338,8 +330,6 @@ type Context struct { // persisting user choices during a session. SetOption func(name string, value string) - // --- Model Management (Gap 2) --- - // SetModel changes the active LLM model at runtime. The model string // should be in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929"). // Existing tools, system prompt, and session are preserved. Returns an @@ -365,8 +355,6 @@ type Context struct { // } GetAvailableModels func() []ModelInfoEntry - // --- Inter-Extension Event Bus (Gap 13) --- - // EmitCustomEvent publishes a named event that other extensions can // subscribe to via api.OnCustomEvent(). Data is an arbitrary string // (JSON-encode complex payloads). Handlers run synchronously in @@ -377,8 +365,6 @@ type Context struct { // ctx.EmitCustomEvent("plan-mode:toggled", `{"active":true}`) EmitCustomEvent func(name string, data string) - // --- Tool Management (Gap 3) --- - // GetAllTools returns information about all tools available to the agent, // including core tools (bash, read, write, etc.), MCP server tools, and // extension-registered tools. Each entry includes the tool's enabled status. @@ -402,7 +388,21 @@ type Context struct { // ctx.SetActiveTools([]string{"Read", "Glob", "Grep", "LS"}) SetActiveTools func(names []string) - // --- Direct LLM Completion (Gap 17) --- + // Exit triggers a graceful application shutdown. In interactive mode + // this sends a quit signal to the TUI; in non-interactive mode it + // cancels the current operation. Safe to call from any goroutine. + // + // Example: + // + // api.RegisterCommand(ext.CommandDef{ + // Name: "quit", + // Description: "Exit the application", + // Execute: func(args string, ctx ext.Context) (string, error) { + // ctx.Exit() + // return "", nil + // }, + // }) + Exit func() // Complete makes a standalone LLM completion call, bypassing the agent // tool loop. Use this for summarisation, question extraction, or any @@ -579,6 +579,7 @@ type API struct { registerToolFn func(ToolDef) registerCmdFn func(CommandDef) registerToolRendererFn func(ToolRenderConfig) + onModelChange func(func(ModelChangeEvent, Context)) onCustomEvent func(name string, handler func(string)) registerOption func(OptionDef) } @@ -651,6 +652,13 @@ func (a *API) OnSessionShutdown(handler func(SessionShutdownEvent, Context)) { a.onSessionShutdown(handler) } +// OnModelChange registers a handler that fires after the active model is +// changed via ctx.SetModel(). The handler receives the new and previous model +// strings plus the source of the change. +func (a *API) OnModelChange(handler func(ModelChangeEvent, Context)) { + a.onModelChange(handler) +} + // RegisterTool adds a custom tool that the LLM can invoke. func (a *API) RegisterTool(tool ToolDef) { a.registerToolFn(tool) @@ -1126,8 +1134,7 @@ type EditorKeyAction struct { // submit) and/or modify the rendered output (add mode indicators, apply visual // effects). // -// This follows Pi's extension editor pattern (modal editor, rainbow editor) -// but uses concrete function fields instead of interfaces for Yaegi safety. +// Uses concrete function fields instead of interfaces for Yaegi safety. // // IMPORTANT (Yaegi limitation): Function fields MUST be set using anonymous // function literals (closures), NOT bare function references. Yaegi does not @@ -1289,4 +1296,17 @@ func (e SessionStartEvent) Type() EventType { return SessionStart } // SessionShutdownEvent fires when the application is closing. type SessionShutdownEvent struct{} +// ModelChangeEvent fires after the active model is changed via ctx.SetModel(). +type ModelChangeEvent struct { + // NewModel is the model string that was set (e.g. "anthropic/claude-sonnet-4-5-20250929"). + NewModel string + // PreviousModel is the model string before the change. + PreviousModel string + // Source indicates what triggered the change: "extension" for ctx.SetModel(), + // "user" for interactive model selection. + Source string +} + func (e SessionShutdownEvent) Type() EventType { return SessionShutdown } + +func (e ModelChangeEvent) Type() EventType { return ModelChange } diff --git a/internal/extensions/events.go b/internal/extensions/events.go index 75190812..f8d82c24 100644 --- a/internal/extensions/events.go +++ b/internal/extensions/events.go @@ -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,9 @@ 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" ) // AllEventTypes returns every supported event type. @@ -57,6 +60,7 @@ func AllEventTypes() []EventType { Input, BeforeAgentStart, AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd, SessionStart, SessionShutdown, + ModelChange, } } diff --git a/internal/extensions/events_test.go b/internal/extensions/events_test.go index d8c8973e..ede280fe 100644 --- a/internal/extensions/events_test.go +++ b/internal/extensions/events_test.go @@ -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) != 14 { + t.Fatalf("expected 14 event types, got %d", len(all)) } } @@ -50,6 +50,7 @@ func TestEventType_TypeMethod(t *testing.T) { {MessageEndEvent{Content: "done"}, MessageEnd}, {SessionStartEvent{SessionID: "abc"}, SessionStart}, {SessionShutdownEvent{}, SessionShutdown}, + {ModelChangeEvent{NewModel: "a/b"}, ModelChange}, } for _, tt := range tests { diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index 78199879..c22fe052 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -283,6 +283,12 @@ 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 + }) + }, registerToolFn: func(tool ToolDef) { ext.Tools = append(ext.Tools, tool) }, diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index b6b8d50f..1149158e 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -12,7 +12,7 @@ import ( ) // 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 diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 46bc290b..e302140d 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -110,6 +110,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)), }, } } diff --git a/internal/extensions/wrapper.go b/internal/extensions/wrapper.go index 68cd9952..e581e97e 100644 --- a/internal/extensions/wrapper.go +++ b/internal/extensions/wrapper.go @@ -9,8 +9,7 @@ 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 { diff --git a/internal/session/entry.go b/internal/session/entry.go index 46dd39b2..ae41f490 100644 --- a/internal/session/entry.go +++ b/internal/session/entry.go @@ -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 ( diff --git a/internal/session/store.go b/internal/session/store.go index 8468a3a3..506948de 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -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 diff --git a/internal/session/tree_manager.go b/internal/session/tree_manager.go index fc4f65dd..b140f699 100644 --- a/internal/session/tree_manager.go +++ b/internal/session/tree_manager.go @@ -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 @@ -717,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/----/ +// Convention: ~/.kit/sessions/----/ func DefaultSessionDir(cwd string) string { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 1eae7bb6..a653554a 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -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 "" diff --git a/internal/ui/model.go b/internal/ui/model.go index 4a45f898..846623e0 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1722,7 +1722,7 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd { // and on step completion. // // After flushing, a ClearScreen is issued to force a full terminal redraw. -// This is the bubbletea equivalent of pi's "clearOnShrink" mechanism: when +// When // the stream content is moved to scrollback the view height shrinks, and // bubbletea's inline renderer doesn't clear the orphaned terminal rows // below the managed region. ClearScreen ensures a clean redraw. @@ -1935,7 +1935,7 @@ func (m *AppModel) handleNameCommand() tea.Cmd { } // For now, prompt user to provide name via input. We print instructions // and the next non-command input starting with "name:" will be captured. - // TODO: inline input dialog like pi's implementation. + // TODO: inline input dialog. currentName := ts.GetSessionName() if currentName != "" { return m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name ` (not yet implemented — use the session file directly).", currentName)) diff --git a/internal/ui/tree_selector.go b/internal/ui/tree_selector.go index 9d84cde4..5bbb49b1 100644 --- a/internal/ui/tree_selector.go +++ b/internal/ui/tree_selector.go @@ -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 diff --git a/pkg/kit/compaction.go b/pkg/kit/compaction.go index b2061399..08e26386 100644 --- a/pkg/kit/compaction.go +++ b/pkg/kit/compaction.go @@ -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() diff --git a/pkg/kit/config.go b/pkg/kit/config.go index 0363e2e6..3b180a69 100644 --- a/pkg/kit/config.go +++ b/pkg/kit/config.go @@ -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: diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index bbd55b9d..0b7c03c6 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -146,6 +146,17 @@ func (m *Kit) GetExtensionContext() extensions.Context { return extensions.Context{} } +// UpdateExtensionContextModel updates the Model field on the extension +// context so subsequent event handlers see the new model. This is a +// targeted update that avoids replacing the entire Context struct. +func (m *Kit) UpdateExtensionContextModel(model string) { + if m.extRunner != nil { + ctx := m.extRunner.GetContext() + ctx.Model = model + m.extRunner.SetContext(ctx) + } +} + // EmitSessionStart fires the SessionStart event for extensions. // No-op if extensions are disabled or no handlers are registered. func (m *Kit) EmitSessionStart() { @@ -513,6 +524,18 @@ func (m *Kit) SetExtensionOption(name, value string) { } } +// 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) { @@ -757,8 +780,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) @@ -891,7 +913,7 @@ func loadContextFiles(cwd string) []*ContextFile { // so, re-reads the skill file, strips its YAML frontmatter, wraps the body in // a 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