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
This commit is contained in:
Ed Zynda
2026-03-02 01:33:56 +03:00
parent dc59cfc81e
commit 9449f1fcdf
11 changed files with 1070 additions and 19 deletions
+599
View File
@@ -0,0 +1,599 @@
# Kit vs Pi Extension System: Comprehensive Gap Analysis
> Generated: 2026-03-01
> Source: [pi-mono extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions)
---
## Executive Summary
Pi's extension ecosystem contains **57+ example extensions** spanning safety guards, git integration, custom providers, session management, resource discovery, and advanced UI patterns. Kit has **10 example extensions** and a solid foundation with 13 lifecycle events and a rich widget/overlay/editor system, but lacks several critical API surfaces that Pi exposes. The gaps fall into three tiers:
- **Critical (17 gaps)**: Missing API capabilities that block entire categories of extensions
- **Moderate (7 gaps)**: Capabilities that exist but lack depth compared to Pi
- **Covered (14 areas)**: Capabilities where Kit has parity or near-parity
---
## Pi Extension Inventory (57 extensions)
### Safety & Lifecycle (5)
| Extension | Description |
|---|---|
| `permission-gate.ts` | Confirms dangerous bash commands (rm -rf, sudo) |
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/) |
| `confirm-destructive.ts` | Confirms destructive session actions (clear, fork) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git |
| `sandbox/` | OS-level sandboxing via `@anthropic-ai/sandbox-runtime` |
### Git Integration (2)
| Extension | Description |
|---|---|
| `git-checkpoint.ts` | Git stash checkpoints per turn, restore on fork |
| `auto-commit-on-exit.ts` | Auto-commits using assistant message as commit msg |
### Custom Tools (11)
| Extension | Description |
|---|---|
| `hello.ts` | Minimal custom tool |
| `question.ts` | Agent-initiated user questions with custom UI |
| `questionnaire.ts` | Multi-question tabbed tool |
| `tool-override.ts` | Override built-in tools |
| `built-in-tool-renderer.ts` | Custom compact rendering for built-in tools |
| `minimal-mode.ts` | Override all tool rendering for minimal display |
| `truncated-tool.ts` | Ripgrep with output truncation |
| `antigravity-image-gen.ts` | Image generation with external API |
| `ssh.ts` | Delegate tools to remote via SSH |
| `subagent/` | Delegate to specialized subagents |
| `todo.ts` | Todo list tool with state persistence |
### Commands & UI (25)
| Extension | Description |
|---|---|
| `preset.ts` | Named presets (model, thinking, tools, instructions) |
| `plan-mode/` | Read-only exploration with /plan command |
| `tools.ts` | Interactive /tools to enable/disable tools |
| `handoff.ts` | Transfer context to new session |
| `qna.ts` | Extract questions from response into editor |
| `commands.ts` | /commands with introspection and tab completion |
| `model-status.ts` | Model change notifications in status bar |
| `send-user-message.ts` | Programmatic message injection (3 modes) |
| `timed-confirm.ts` | Auto-dismissing dialogs with AbortSignal |
| `rpc-demo.ts` | Full UI method catalog |
| `modal-editor.ts` | Vim-like modal editor (full editor replacement) |
| `rainbow-editor.ts` | Animated rainbow editor |
| `notify.ts` | Desktop OS notifications |
| `titlebar-spinner.ts` | Terminal title animations |
| `summarize.ts` | Conversation summarization |
| `custom-footer.ts` | Custom footer with git branch + token stats |
| `custom-header.ts` | Custom ASCII art header |
| `overlay-test.ts` | Custom overlay with Focusable components |
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests |
| `doom-overlay/` | DOOM game running as overlay |
| `shutdown-command.ts` | /quit command via ctx.shutdown() |
| `reload-runtime.ts` | Hot reload extensions at runtime |
| `interactive-shell.ts` | Full terminal takeover for vim/htop |
| `inline-bash.ts` | !{command} expansion in prompts |
| `snake.ts` | Snake game with custom UI |
### System Prompt & Compaction (4)
| Extension | Description |
|---|---|
| `pirate.ts` | System prompt append |
| `claude-rules.ts` | Project rules loader from .claude/rules/ |
| `custom-compaction.ts` | Custom compaction with cross-model summarization |
| `trigger-compact.ts` | Auto-trigger compaction at token threshold |
### System Integration (1)
| Extension | Description |
|---|---|
| `mac-system-theme.ts` | Syncs theme with macOS dark/light mode |
### Resources (1)
| Extension | Description |
|---|---|
| `dynamic-resources/` | Dynamic skill/prompt/theme loading |
### Messages & Communication (2)
| Extension | Description |
|---|---|
| `message-renderer.ts` | Custom message rendering with expandable details |
| `event-bus.ts` | Inter-extension pub/sub communication |
### Session Metadata (2)
| Extension | Description |
|---|---|
| `session-name.ts` | Name sessions for selector |
| `bookmark.ts` | Bookmark entries with labels for /tree |
### Custom Providers (3)
| Extension | Description |
|---|---|
| `custom-provider-anthropic/` | Custom Anthropic provider with OAuth |
| `custom-provider-gitlab-duo/` | GitLab Duo provider |
| `custom-provider-qwen-cli/` | Qwen CLI provider |
### External Dependencies (1)
| Extension | Description |
|---|---|
| `with-deps/` | Extension with own package.json |
---
## Kit Extension Inventory (10 extensions)
| Extension | Description |
|---|---|
| `minimal.go` | UI visibility, footer, context stats polling |
| `widget-status.go` | Persistent widgets, OnToolResult, input commands |
| `tool-logger.go` | Tool event logging, PrintBlock with styling |
| `header-footer-demo.go` | Custom header/footer with slash commands |
| `prompt-demo.go` | All 3 prompt types + chained workflow |
| `overlay-demo.go` | Modal overlay dialogs (info, actions, markdown, scroll) |
| `custom-editor-demo.go` | Vim-like editor interceptor |
| `tool-renderer-demo.go` | Custom tool rendering for read/bash |
| `subagent-widget.go` | Background subprocess agents with live widgets |
| `kit-kit.go` | Meta-agent with parallel experts, grid widget |
---
## Gap Analysis: Critical Gaps (Missing API Capabilities)
### Gap 1: Session Management API
**Pi has:** `ctx.sessionManager` with full conversation access
- `getEntries()` / `getBranch()` -- Read conversation history
- `getLeafEntry()` -- Current leaf entry
- `getLabel(entryId)` / `pi.setLabel()` -- Entry metadata/labeling
- `getSessionFile()` -- Session file path
**Kit has:** Nothing. Extensions cannot read conversation history.
**Impact:** Blocks auto-commit (needs last assistant message), git checkpoints (needs entry IDs), handoff (needs full conversation), QnA extraction, state restoration on session resume, bookmark labeling.
**Implementation approach:**
- Add `GetMessages func() []MessageEntry` to `Context` returning conversation messages
- Add `GetCurrentEntryID func() string` for session tree position
- Add `SetLabel func(entryId, label string)` / `GetLabel func(entryId string) string`
- Wire in `cmd/root.go` via closures reading from the session store
---
### Gap 2: Model Management API
**Pi has:**
- `ctx.modelRegistry.find(provider, model)` -- Look up models
- `ctx.modelRegistry.getApiKey(model)` -- Get API keys
- `pi.setModel(model)` -- Change active model at runtime
- `pi.setThinkingLevel(level)` -- Set reasoning budget
**Kit has:** `ctx.Model string` (read-only model name)
**Impact:** Blocks preset system (model switching), custom compaction (cross-model calls), QnA extraction (direct LLM calls), any extension needing to invoke a different model.
**Implementation approach:**
- Add `SetModel func(provider, model string) error` to `Context`
- Add `GetAvailableModels func() []ModelInfo` returning provider/model/context info
- Add `GetAPIKey func(provider string) string` for credential access
- Add `SetThinkingLevel func(level string)` for reasoning budget control
- Wire through to the existing `llm.Provider` interface
---
### Gap 3: Tool Management API
**Pi has:**
- `pi.getAllTools()` -- List all registered tools
- `pi.setActiveTools(names)` -- Enable/disable specific tools
**Kit has:** Nothing for tool introspection or filtering.
**Impact:** Blocks plan-mode (restricts tools to read-only set), preset system (tool filtering), /tools interactive toggle, any policy-based tool restriction.
**Implementation approach:**
- Add `GetAllTools func() []ToolInfo` to `Context` with name, description, enabled status
- Add `SetActiveTools func(names []string)` to filter which tools the LLM can use
- Add `IsToolEnabled func(name string) bool` for individual checks
- Integrate with the existing tool wrapper pipeline in `wrapper.go`
---
### Gap 4: Session Lifecycle Events (Before-hooks with Cancel)
**Pi has:**
- `session_before_switch` -- Can cancel session switching
- `session_before_fork` -- Can cancel forking
- `session_switch` -- React to session changes
**Kit has:** `OnSessionStart` and `OnSessionShutdown` only. No before-hooks, no cancel capability, no fork/branch events.
**Impact:** Blocks dirty-repo-guard, confirm-destructive, git-checkpoint (restore on fork), any defensive workflow that needs to gate session operations.
**Implementation approach:**
- Add `OnSessionBeforeSwitch` event with `SessionBeforeSwitchResult{Cancel bool, Reason string}`
- Add `OnSessionBeforeFork` event with similar cancel capability
- Add `OnSessionSwitch` event for post-switch notifications
- Emit these from session management code before performing operations
---
### Gap 5: Compaction Events
**Pi has:** `session_before_compact` allowing custom compaction strategies (e.g., summarize entire conversation with a cheaper model instead of truncating).
**Kit has:** Nothing.
**Impact:** Blocks custom-compaction and trigger-compact patterns. Users cannot customize how context compaction works.
**Implementation approach:**
- Add `OnBeforeCompact` event with `BeforeCompactEvent{EstimatedTokens, ContextLimit int}`
- Result type: `BeforeCompactResult{Summary *string, FirstKeptEntryID *string}`
- If extension returns a summary, use it instead of default compaction
- Add `TriggerCompact func()` to `Context` for manual compaction triggers
---
### Gap 6: Custom Provider Registration
**Pi has:** `pi.registerProvider()` allowing extensions to register complete LLM providers with streaming, OAuth, and model definitions.
**Kit has:** No extension-facing provider registration. Providers are compiled-in via the `llm.Provider` interface.
**Impact:** Blocks custom-provider-anthropic, custom-provider-gitlab-duo, custom-provider-qwen-cli patterns. Users cannot add new LLM backends via extensions.
**Implementation approach:**
- Add `RegisterProvider(ProviderDef)` to `API` with:
- `Name string`, `Models []ModelDef`
- `Stream func(model, messages, options) StreamResult`
- This is a large undertaking. The `ProviderDef` would need to bridge to the compiled `llm.Provider` interface.
- **Yaegi limitation:** Complex streaming interfaces may hit Yaegi's interface generation bugs. May need concrete struct wrappers.
- **Priority:** Lower -- this is architecturally complex and has narrow use cases.
---
### Gap 7: CLI Flag Registration
**Pi has:** `pi.registerFlag("preset", {description, type})` and `pi.getFlag("preset")` allowing extensions to add CLI flags.
**Kit has:** Nothing. Extensions cannot influence CLI argument parsing.
**Impact:** Blocks preset system (--preset flag), plan-mode (--plan flag), sandbox (--no-sandbox flag).
**Implementation approach:**
- Add `RegisterFlag(FlagDef)` to `API` with `Name, Description, Type string, Default any`
- Add `GetFlag func(name string) any` to `Context`
- Parse extension flags after loading extensions but before `Init()`
- Store in a `map[string]any` on the Runner
---
### Gap 8: Keyboard Shortcut Registration
**Pi has:** `pi.registerShortcut(Key.ctrlShift("u"), {description, handler})` for global keyboard shortcuts.
**Kit has:** Nothing. Only editor interceptors can handle keys, and only when the editor has focus.
**Impact:** Blocks global shortcuts like Ctrl+Alt+P for plan mode toggle, Ctrl+Shift+U for preset switching.
**Implementation approach:**
- Add `RegisterShortcut(ShortcutDef)` to `API` with `Key string, Description string, Handler func(Context)`
- Bridge to BubbleTea's key handling in `model.go` Update() method
- Query registered shortcuts from Runner in the key dispatch path
---
### Gap 9: Custom Message Rendering
**Pi has:** `pi.registerMessageRenderer(customType, renderFn)` for custom visual rendering of specific message types (not just tool results).
**Kit has:** `RegisterToolRenderer` for tool-specific rendering only. No general message renderer.
**Impact:** Blocks status-update messages, extension-branded messages, and any custom message type that needs bespoke visual treatment.
**Implementation approach:**
- Add `RegisterMessageRenderer(MessageRenderConfig)` to `API` with:
- `CustomType string` -- message type to match
- `Render func(content, details string, expanded bool, width int) string`
- Integrate with the stream component's message rendering pipeline
---
### Gap 10: Programmatic Editor Control
**Pi has:**
- `ctx.ui.setEditorText(text)` -- Pre-fill the input editor
- `ctx.ui.setEditorComponent(factory)` -- Replace the entire editor
**Kit has:** `SetEditor(EditorConfig)` which is an interceptor (HandleKey/Render) but does NOT allow setting editor text or full replacement.
**Impact:** Blocks QnA (pre-fill editor with extracted questions), handoff (pre-fill with handoff prompt), and full editor replacement patterns.
**Implementation approach:**
- Add `SetEditorText func(text string)` to `Context` -- inserts text into the active editor
- Optionally add `SetEditorComponent func(EditorComponentConfig)` for full replacement (complex due to BubbleTea integration)
---
### Gap 11: Turn-Level Events
**Pi has:**
- `turn_start` -- Fires when a new LLM turn begins
- `turn_end` -- Fires when a turn completes
**Kit has:** `OnAgentStart`/`OnAgentEnd` which fire at the agent loop level (may span multiple turns), and `OnMessageStart`/`OnMessageEnd` for streaming. No dedicated turn boundary events.
**Impact:** Blocks git-checkpoint (create stash per turn), plan-mode (track done markers per turn), preset (persist state per turn), progress tracking.
**Implementation approach:**
- Add `OnTurnStart(func(TurnStartEvent, Context))` and `OnTurnEnd(func(TurnEndEvent, Context))`
- `TurnStartEvent{TurnNumber int, Prompt string}`
- `TurnEndEvent{TurnNumber int, Response string, StopReason string}`
- Emit from the agent loop between turns
---
### Gap 12: Context Filtering Event
**Pi has:** `pi.on("context", ...)` -- Lets extensions filter/modify messages before sending to the LLM. Returns `{messages: [...]}` to replace the context window.
**Kit has:** Nothing. Extensions cannot influence what messages the LLM sees.
**Impact:** Blocks plan-mode (filter stale messages), any extension needing to manage context window content, RAG-style context injection.
**Implementation approach:**
- Add `OnContextPrepare(func(ContextPrepareEvent, Context) *ContextPrepareResult)`
- `ContextPrepareEvent{Messages []MessageEntry}`
- `ContextPrepareResult{Messages []MessageEntry}` -- return filtered/modified set
- Emit just before sending messages to the LLM provider
---
### Gap 13: Inter-Extension Event Bus
**Pi has:** `pi.events.on(name, handler)` / `pi.events.emit(name, data)` for decoupled inter-extension communication.
**Kit has:** Nothing. Extensions are isolated; they cannot communicate with each other.
**Impact:** Blocks coordinated multi-extension workflows (e.g., theme extension reacting to mode changes from another extension).
**Implementation approach:**
- Add `OnCustomEvent func(name string, handler func(data string))` to `API`
- Add `EmitCustomEvent func(name, data string)` to `Context`
- Store handlers in Runner's event map, dispatch via `Emit`
---
### Gap 14: Session Persistence for Extensions
**Pi has:** `pi.appendEntry(customType, data)` -- Persists extension-specific data in the session journal. Survives across session resume.
**Kit has:** Nothing. Extension state is ephemeral (package-level vars lost on restart).
**Impact:** Blocks preset state restoration, plan-mode progress persistence, todo list persistence across sessions, any extension needing durable state.
**Implementation approach:**
- Add `AppendEntry func(entryType string, data string)` to `Context`
- Add `GetEntries func(entryType string) []string` to `Context` for retrieval
- Store in session file as custom entry types
- Emit entries during `OnSessionStart` for restoration
---
### Gap 15: Resource Discovery System
**Pi has:** `resources_discover` event where extensions can dynamically register skills, prompts, and themes by returning file paths.
**Kit has:** Nothing. No concept of dynamic resource loading.
**Impact:** Blocks dynamic-resources pattern. Extensions cannot contribute prompts, skills, or themes at runtime.
**Implementation approach:**
- Add `OnResourceDiscovery(func(ResourceDiscoveryEvent, Context) *ResourceDiscoveryResult)`
- `ResourceDiscoveryResult{SkillPaths, PromptPaths, ThemePaths []string}`
- Integrate with any future resource/skill loading system
---
### Gap 16: Programmatic Shutdown and Reload
**Pi has:**
- `ctx.shutdown()` -- Programmatically quit the application
- `ctx.reload()` -- Hot-reload all extensions at runtime
**Kit has:** Neither capability.
**Impact:** Blocks shutdown-command, reload-runtime patterns. Extensions cannot control app lifecycle.
**Implementation approach:**
- Add `Shutdown func()` to `Context` -- triggers graceful shutdown
- Add `Reload func() error` to `Context` -- reloads all extensions
- Wire via BubbleTea Quit msg and loader re-initialization
---
### Gap 17: Direct LLM Completion from Extensions
**Pi has:** Extensions can call `complete()` from `@mariozechner/pi-ai` to make LLM calls outside the main agent loop (e.g., summarization, question extraction, handoff generation).
**Kit has:** No way for extensions to invoke LLM completions directly. Extensions can only spawn Kit subprocesses.
**Impact:** Blocks in-process LLM calls for summarization, QnA extraction, context transfer. The subprocess pattern works but is heavier.
**Implementation approach:**
- Add `Complete func(CompleteRequest) (string, error)` to `Context`
- `CompleteRequest{Model, SystemPrompt string, Messages []SimpleMessage}`
- Wire through to existing `llm.Provider.Complete()` method
- Consider rate limiting and cost awareness
---
## Gap Analysis: Moderate Gaps (Partial Coverage)
### Gap M1: Tool Registration Depth
**Pi has:** `renderCall(args, theme)`, `renderResult(result, {expanded, isPartial}, theme)` directly on tool definition. Also `onUpdate` streaming callback, `AbortSignal`, and TypeBox schemas.
**Kit has:** Separate `RegisterToolRenderer()` and simpler `RegisterTool()` with JSON schema string and basic execute handler.
**Implementation approach:** Enhance `ToolDef` with optional `RenderHeader`/`RenderBody` fields. Add `onUpdate func(string)` to execute handler for streaming tool progress. Add abort/cancel context.
---
### Gap M2: Command Tab Completion
**Pi has:** `getArgumentCompletions(prefix)` on command registration for tab-completing command arguments.
**Kit has:** `RegisterCommand()` without completion support.
**Implementation approach:** Add optional `Complete func(prefix string) []string` to `CommandDef`.
---
### Gap M3: Keyed Status Bar Entries
**Pi has:** `ctx.ui.setStatus(key, text)` for multiple independent status bar indicators.
**Kit has:** `SetFooter(HeaderFooterConfig)` as a single custom footer, not keyed status entries.
**Implementation approach:** Add `SetStatus func(key, text string)` / `RemoveStatus func(key string)` to `Context`. Render all keyed entries in the status bar region.
---
### Gap M4: Full Custom TUI Components
**Pi has:** `ctx.ui.custom<T>(factory)` where factory receives `(tui, theme, keybindings, done)` and returns a `Focusable` component. Supports overlays and full TUI takeover (including `tui.stop()`/`tui.start()` for subprocess terminal sharing).
**Kit has:** `ShowOverlay(OverlayConfig)` with text content and action buttons. No way to render completely custom interactive components or suspend the TUI.
**Implementation approach:**
- This is architecturally complex with Yaegi. A simpler approach: add `SuspendTUI func(callback func())` to `Context` that stops BubbleTea, runs the callback (allowing raw terminal use), then restarts.
- For custom overlays: enhance `OverlayConfig` with a `RenderFunc` option for custom content rendering.
---
### Gap M5: SendMessage Delivery Modes
**Pi has:** Three modes:
- `pi.sendUserMessage(text)` -- Normal (triggers turn)
- `pi.sendUserMessage(text, {deliverAs: "steer"})` -- Interrupts current stream
- `pi.sendUserMessage(text, {deliverAs: "followUp"})` -- Queues after current stream
**Kit has:** `ctx.SendMessage(string)` which queues if agent is busy (similar to followUp), but no steering/interrupt mode and no structured content.
**Implementation approach:**
- Add `SendMessageOpts{DeliverAs string}` parameter to `SendMessage`
- Support `"steer"` (cancel current + send) and `"followUp"` (queue) modes
- Add `SendStructuredMessage func(content []ContentBlock, opts SendMessageOpts)` for multi-part messages
---
### Gap M6: Model Change Event
**Pi has:** `model_select` event with `event.model`, `event.previousModel`, `event.source`.
**Kit has:** No model change notification.
**Implementation approach:** Add `OnModelChange(func(ModelChangeEvent, Context))` with `NewModel, PreviousModel, Source string`.
---
### Gap M7: User Bash Hook
**Pi has:** `user_bash` event for intercepting user-initiated `!command` invocations, separate from tool-initiated bash. Can return custom `result` to override execution.
**Kit has:** No distinction between user-initiated and tool-initiated bash. `OnToolCall` catches both.
**Implementation approach:** Add `OnUserBash(func(UserBashEvent, Context) *UserBashResult)` or tag `ToolCallEvent` with a `Source` field (`"user"` vs `"tool"`).
---
## Capabilities with Parity (Covered)
| Capability | Kit | Pi | Status |
|---|---|---|---|
| Session start/shutdown events | `OnSessionStart`, `OnSessionShutdown` | `session_start`, `session_shutdown` | Parity |
| Before agent start (system prompt injection) | `OnBeforeAgentStart` returns `InjectText`, `SystemPrompt` | `before_agent_start` returns `systemPrompt`, `message` | Parity |
| Agent lifecycle events | `OnAgentStart`, `OnAgentEnd` | `agent_start`, `agent_end` | Parity |
| Message streaming events | `OnMessageStart`, `OnMessageUpdate`, `OnMessageEnd` | N/A (Pi uses `turn_start`/`turn_end` instead) | Kit advantage |
| Tool call interception (blocking) | `OnToolCall` returns `Block`, `Reason` | `tool_call` returns `block`, `reason` | Parity |
| Tool result modification | `OnToolResult` returns modified `Content`, `IsError` | `tool_result` returns modified content | Parity |
| Tool execution timing | `OnToolExecutionStart`, `OnToolExecutionEnd` | N/A | Kit advantage |
| Input interception/transform | `OnInput` returns `Action` (continue/transform/handled) | `input` returns `action` (continue/transform/handled) | Parity |
| Custom tool registration | `RegisterTool(ToolDef)` | `pi.registerTool({...})` | Parity (Pi richer) |
| Custom command registration | `RegisterCommand(CommandDef)` | `pi.registerCommand(name, {...})` | Parity |
| Widget system | `SetWidget`/`RemoveWidget` with placement, priority | `setWidget(key, lines)` | Parity |
| Header/Footer | `SetHeader`/`SetFooter` with content/style | `setHeader`/`setFooter` with factory | Parity (different models) |
| Overlay dialogs | `ShowOverlay` with actions, scrolling, markdown | `ctx.ui.custom({overlay: true})` | Pi richer |
| Interactive prompts | `PromptSelect`, `PromptConfirm`, `PromptInput` | `ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input` | Parity |
| Editor interceptor | `SetEditor(EditorConfig)` with HandleKey/Render | `setEditorComponent()` for full replacement | Pi richer |
| Tool renderer customization | `RegisterToolRenderer(ToolRenderConfig)` | `renderCall`/`renderResult` on tool def | Parity |
| UI visibility control | `SetUIVisibility(UIVisibility)` | N/A (Pi uses direct component replacement) | Kit advantage |
| Context stats | `GetContextStats()` returns tokens, limit, usage% | Token data via `sessionManager.getBranch()` | Kit advantage (dedicated API) |
| Print functions | `Print`, `PrintInfo`, `PrintError`, `PrintBlock` | `ctx.ui.notify(msg, level)` | Different models, both adequate |
| Subprocess spawning | `os/exec` via Yaegi stdlib access | `pi.exec()` abstracted API | Parity (different approach) |
---
## Priority-Ordered Implementation Roadmap
### Phase 1: High-Impact, Lower Complexity
These gaps block the most important extension patterns and are relatively straightforward to implement.
1. **Session Management API** (Gap 1) -- Enables git integration, state restoration, bookmarks
2. **Turn-Level Events** (Gap 11) -- Enables per-turn checkpoints and progress tracking
3. **Session Persistence** (Gap 14) -- Enables durable extension state across restarts
4. **Programmatic Editor Control** (Gap 10) -- Enables QnA and handoff patterns
5. **Keyed Status Bar** (Gap M3) -- Enables richer status display
### Phase 2: Medium Impact, Medium Complexity
6. **Tool Management API** (Gap 3) -- Enables plan-mode and tool filtering
7. **Model Management API** (Gap 2) -- Enables presets and model switching
8. **CLI Flag Registration** (Gap 7) -- Enables --preset, --plan flags
9. **Inter-Extension Event Bus** (Gap 13) -- Enables cross-extension coordination
10. **SendMessage Delivery Modes** (Gap M5) -- Enables steering and follow-up patterns
### Phase 3: High Impact, High Complexity
11. **Session Lifecycle Before-Hooks** (Gap 4) -- Enables safety guards with cancel
12. **Context Filtering Event** (Gap 12) -- Enables context management
13. **Compaction Events** (Gap 5) -- Enables custom compaction strategies
14. **Direct LLM Completion** (Gap 17) -- Enables in-process sub-agent calls
15. **Full Custom TUI Components** (Gap M4) -- Enables interactive-shell, games
### Phase 4: Specialized / Lower Priority
16. **Keyboard Shortcut Registration** (Gap 8) -- Nice-to-have for power users
17. **Custom Message Rendering** (Gap 9) -- Nice-to-have for branded messages
18. **Custom Provider Registration** (Gap 6) -- Architecturally complex, narrow use cases
19. **Resource Discovery** (Gap 15) -- Depends on future skill/resource system
20. **Programmatic Shutdown/Reload** (Gap 16) -- Nice-to-have lifecycle control
21. **Model Change Event** (Gap M6) -- Nice-to-have notification
22. **User Bash Hook** (Gap M7) -- Nice-to-have distinction
23. **Command Tab Completion** (Gap M2) -- Nice-to-have UX improvement
24. **Tool Registration Depth** (Gap M1) -- Incremental improvement
---
## Extension Ecosystem Gap: Example Extensions We Should Build
Beyond API gaps, Pi simply has more example extensions demonstrating real-world patterns. Extensions we should create (once APIs exist):
| Extension | Pi Equivalent | Required API Additions |
|---|---|---|
| Permission gate (dangerous command confirmation) | `permission-gate.ts` | None (works today with OnToolCall) |
| Protected paths (block writes to .env, .git/) | `protected-paths.ts` | None (works today with OnToolCall) |
| Auto-commit on exit | `auto-commit-on-exit.ts` | Gap 1 (session messages) |
| Git checkpoints per turn | `git-checkpoint.ts` | Gaps 1, 4, 11 |
| Desktop notifications | `notify.ts` | None (works today with OnAgentEnd + os/exec) |
| Inline bash expansion (!{cmd}) | `inline-bash.ts` | None (works today with OnInput transform) |
| Plan mode (read-only exploration) | `plan-mode/` | Gaps 3, 7, 11, 12, 14 |
| Preset system | `preset.ts` | Gaps 2, 3, 7, 8, 14 |
| Dirty repo guard | `dirty-repo-guard.ts` | Gap 4 |
| QnA extraction | `qna.ts` | Gaps 1, 10, 17 |
| Handoff to new session | `handoff.ts` | Gaps 1, 10, 17, 22 (newSession) |
| Custom compaction | `custom-compaction.ts` | Gaps 2, 5 |
| Interactive shell (vim/htop) | `interactive-shell.ts` | Gap M4 (TUI suspend) |
| Event bus | `event-bus.ts` | Gap 13 |
### Extensions Buildable Today (No API Changes Needed)
These can be built right now with Kit's existing extension API:
1. **Permission gate** -- Use `OnToolCall` to intercept bash with `rm -rf`, return `Block: true`
2. **Protected paths** -- Use `OnToolCall` to check write/edit tool paths against deny-list
3. **Desktop notifications** -- Use `OnAgentEnd` + `os/exec` for OSC 777 or `notify-send`
4. **Inline bash expansion** -- Use `OnInput` with `Action: "transform"` to expand `!{cmd}`
5. **Pirate mode** -- Use `OnBeforeAgentStart` to append to system prompt
6. **Project rules loader** -- Use `OnSessionStart` to scan, `OnBeforeAgentStart` to inject
7. **Titlebar spinner** -- Use `OnAgentStart`/`OnAgentEnd` + `os.Stdout` for OSC sequences
8. **File trigger** -- Use `OnSessionStart` to set up `fsnotify` watcher, `SendMessage` to inject
---
## Summary Statistics
| Metric | Pi | Kit | Gap |
|---|---|---|---|
| Example extensions | 57+ | 10 | -47 |
| Lifecycle events | 16+ | 13 | -3+ |
| API methods on context | 35+ | 22 | -13+ |
| Custom providers | 3 | 0 | -3 |
| Session management APIs | 6 | 0 | -6 |
| Model management APIs | 4 | 1 (read-only) | -3 |
| Tool management APIs | 2 | 0 | -2 |
| Critical API gaps | -- | -- | 17 |
| Moderate API gaps | -- | -- | 7 |
| Extensions buildable today | -- | -- | 8 |
+59 -5
View File
@@ -457,6 +457,31 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
}
}
// statusBarProviderForUI returns a function that fetches extension status bar
// entries and converts them to ui.StatusBarEntryData for the TUI. Returns nil
// if extensions are disabled, which is safe — the TUI treats a nil
// GetStatusBarEntries as "no extension entries".
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
if !k.HasExtensions() {
return nil
}
return func() []ui.StatusBarEntryData {
entries := k.GetExtensionStatusEntries()
if len(entries) == 0 {
return nil
}
result := make([]ui.StatusBarEntryData, len(entries))
for i, e := range entries {
result[i] = ui.StatusBarEntryData{
Key: e.Key,
Text: e.Text,
Priority: e.Priority,
}
}
return result
}
}
func runNormalMode(ctx context.Context) error {
// Validate flag combinations
if quietFlag && promptFlag == "" {
@@ -685,6 +710,33 @@ func runNormalMode(ctx context.Context) error {
kitInstance.ResetExtensionEditor()
go appInstance.NotifyWidgetUpdate()
},
GetMessages: func() []extensions.SessionMessage {
return kitInstance.GetSessionMessages()
},
GetSessionPath: func() string {
return kitInstance.GetSessionFilePath()
},
AppendEntry: func(entryType string, data string) (string, error) {
return kitInstance.AppendExtensionEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.GetExtensionEntries(entryType)
},
SetEditorText: func(text string) {
appInstance.SetEditorTextFromExtension(text)
},
SetStatus: func(key string, text string, priority int) {
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
})
appInstance.NotifyWidgetUpdate()
},
RemoveStatus: func(key string) {
kitInstance.RemoveExtensionStatus(key)
appInstance.NotifyWidgetUpdate()
},
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
ch := make(chan app.OverlayResponse, 1)
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
@@ -741,10 +793,11 @@ func runNormalMode(ctx context.Context) error {
getToolRenderer := toolRendererProviderForUI(kitInstance)
getEditorInterceptor := editorInterceptorProviderForUI(kitInstance)
getUIVisibility := uiVisibilityProviderForUI(kitInstance)
getStatusBarEntries := statusBarProviderForUI(kitInstance)
// Check if running in non-interactive mode
if promptFlag != "" {
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)
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)
}
// Quiet mode is not allowed in interactive mode
@@ -752,7 +805,7 @@ func runNormalMode(ctx context.Context) error {
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
}
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -765,7 +818,7 @@ 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, 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) error {
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) error {
if jsonOutput {
// JSON mode: no intermediate display, structured JSON output.
result, err := appInstance.RunOnceResult(ctx, prompt)
@@ -803,7 +856,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// If --no-exit was requested, hand off to the interactive TUI.
if noExit {
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries)
}
return nil
@@ -897,7 +950,7 @@ func writeJSONError(err error) {
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, 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) 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) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -926,6 +979,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
GetUIVisibility: getUIVisibility,
GetStatusBarEntries: getStatusBarEntries,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
+11
View File
@@ -505,6 +505,17 @@ 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})
}
}
// 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).
+7
View File
@@ -118,6 +118,13 @@ type CompactErrorEvent struct {
// 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
+124
View File
@@ -231,6 +231,130 @@ type Context struct {
// pct := int(stats.UsagePercent * 100)
// 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.
//
// Example:
//
// msgs := ctx.GetMessages()
// for _, m := range msgs {
// if m.Role == "assistant" {
// lastResponse = m.Content
// }
// }
GetMessages func() []SessionMessage
// GetSessionPath returns the file path of the current session's JSONL
// file. Returns empty string for in-memory (ephemeral) sessions.
GetSessionPath func() string
// --- 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").
//
// Example:
//
// data, _ := json.Marshal(myState)
// ctx.AppendEntry("myext:state", string(data))
AppendEntry func(entryType string, data string) (string, error)
// GetEntries retrieves all persisted extension data entries matching
// the given type on the current branch, ordered root to leaf. Pass
// empty string to retrieve all extension data entries.
//
// Example — restore state on session resume:
//
// entries := ctx.GetEntries("myext:state")
// if len(entries) > 0 {
// last := entries[len(entries)-1]
// json.Unmarshal([]byte(last.Data), &myState)
// }
GetEntries func(entryType string) []ExtensionEntry
// SetEditorText sets the text content of the input editor. This can
// be used to pre-fill the editor with suggested text (e.g. extracted
// questions, handoff prompts). The cursor is moved to the end.
//
// Example:
//
// ctx.SetEditorText("Please review the changes in src/main.go")
SetEditorText func(text string)
// --- 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.
//
// Example:
//
// ctx.SetStatus("myext:branch", "main", 50)
SetStatus func(key string, text string, priority int)
// RemoveStatus removes a keyed status bar entry. No-op if the key
// does not exist.
RemoveStatus func(key string)
}
// ---------------------------------------------------------------------------
// Session types (exposed to Yaegi — concrete structs for session access)
// ---------------------------------------------------------------------------
// SessionMessage represents a conversation message exposed to extensions.
// This is a simplified, read-only view of the internal message structures.
type SessionMessage struct {
// ID is the unique entry identifier in the session tree.
ID string
// ParentID links this entry to its parent in the tree.
ParentID string
// Role is the message role: "user", "assistant", "tool", or "system".
Role string
// Content is the text content of the message (tool calls and results
// are serialized as text summaries).
Content string
// Model is the model that generated this message (empty for user messages).
Model string
// Provider is the provider used (empty for user messages).
Provider string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
}
// ExtensionEntry represents persisted extension data stored in the session.
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
// it on session resume.
type ExtensionEntry struct {
// ID is the unique entry identifier.
ID string
// EntryType is the extension-defined type string (e.g. "plan-mode:state").
EntryType string
// Data is the extension-defined payload (JSON or plain text).
Data string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
}
// ---------------------------------------------------------------------------
// Status bar types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// StatusBarEntry represents a keyed entry in the TUI status bar. Extensions
// can set multiple independent entries that render alongside the built-in
// model name and token usage display.
type StatusBarEntry struct {
// Key uniquely identifies this entry (e.g. "myext:git-branch").
Key string
// Text is the rendered content shown in the status bar.
Text string
// Priority controls ordering. Lower values render further left.
// Built-in entries (model, usage) have implicit priority 100-110.
Priority int
}
// PrintBlockOpts configures a custom styled block for PrintBlock.
+48 -8
View File
@@ -12,14 +12,15 @@ import (
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
// load order; for cancellable events the first blocking result wins.
type Runner struct {
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
uiVisibility *UIVisibility // nil = show everything (default)
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)
mu sync.RWMutex
}
// LoadedExtension represents a single extension that has been discovered,
@@ -178,6 +179,45 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
return result
}
// ---------------------------------------------------------------------------
// Status bar management
// ---------------------------------------------------------------------------
// SetStatusEntry places or updates a keyed status bar entry. Thread-safe.
func (r *Runner) SetStatusEntry(entry StatusBarEntry) {
r.mu.Lock()
defer r.mu.Unlock()
if r.statusEntries == nil {
r.statusEntries = make(map[string]StatusBarEntry)
}
r.statusEntries[entry.Key] = entry
}
// RemoveStatusEntry removes a status bar entry by key. Thread-safe.
func (r *Runner) RemoveStatusEntry(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.statusEntries, key)
}
// GetStatusEntries returns all status bar entries, sorted by priority
// (ascending). Thread-safe.
func (r *Runner) GetStatusEntries() []StatusBarEntry {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]StatusBarEntry, 0, len(r.statusEntries))
for _, e := range r.statusEntries {
result = append(result, e)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Priority != result[j].Priority {
return result[i].Priority < result[j].Priority
}
return result[i].Key < result[j].Key
})
return result
}
// ---------------------------------------------------------------------------
// Header/Footer management
// ---------------------------------------------------------------------------
+7
View File
@@ -26,6 +26,13 @@ func Symbols() interp.Exports {
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
// Session types
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
// Status bar types
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
// Widget types
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
+25
View File
@@ -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)
}
+42
View File
@@ -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 ""
}
+47 -6
View File
@@ -167,6 +167,15 @@ type WidgetData struct {
NoBorder bool
}
// StatusBarEntryData represents a keyed extension entry in the TUI status bar.
// Multiple entries from different extensions coexist, ordered by Priority
// (lower values render further left).
type StatusBarEntryData struct {
Key string // unique identifier (e.g. "myext:git-branch")
Text string // rendered content shown in the status bar
Priority int // lower = further left; built-in entries use 100-110
}
// UIVisibility controls which built-in TUI chrome elements are visible.
// The zero value shows everything (backward compatible).
type UIVisibility struct {
@@ -257,6 +266,12 @@ type AppModelOptions struct {
// Called during View() and PrintStartupInfo() to conditionally hide
// built-in chrome elements. May be nil if no extensions are loaded.
GetUIVisibility func() *UIVisibility
// GetStatusBarEntries returns extension-provided status bar entries,
// sorted by priority. Called during renderStatusBar() to inject
// extension entries alongside the built-in model/usage display.
// May be nil if no extensions are loaded.
GetStatusBarEntries func() []StatusBarEntryData
}
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
@@ -367,6 +382,9 @@ type AppModel struct {
// getUIVisibility returns extension-provided UI visibility overrides. May be nil.
getUIVisibility func() *UIVisibility
// getStatusBarEntries returns extension-provided status bar entries. May be nil.
getStatusBarEntries func() []StatusBarEntryData
// prompt holds the state of an active interactive prompt overlay. Nil
// when no prompt is active. Managed by updatePromptState().
prompt *promptOverlay
@@ -481,6 +499,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.getFooter = opts.GetFooter
m.getEditorInterceptor = opts.GetEditorInterceptor
m.getUIVisibility = opts.GetUIVisibility
m.getStatusBarEntries = opts.GetStatusBarEntries
// Store context/skills metadata and tool counts for startup display.
m.contextPaths = opts.ContextPaths
@@ -970,6 +989,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// latest widget state on the next render.
m.distributeHeight()
case app.EditorTextSetEvent:
// Extension wants to pre-fill the input editor with text.
if ic, ok := m.input.(*InputComponent); ok {
ic.textarea.SetValue(msg.Text)
ic.textarea.CursorEnd()
}
case app.PromptRequestEvent:
// Extension wants to show an interactive prompt. Enter prompt state.
// If already in prompt state (concurrent prompt from another
@@ -1176,7 +1202,8 @@ func (m *AppModel) renderStream() string {
}
// renderStatusBar renders a persistent single-line status bar below the input.
// Left side: spinner (when active). Right side: provider · model + usage stats.
// Left side: spinner (when active). Middle: extension status entries (sorted by
// priority). Right side: provider · model + usage stats.
// This bar is always present so its height is constant, eliminating layout
// shifts from spinner or usage info appearing/disappearing.
func (m *AppModel) renderStatusBar() string {
@@ -1187,7 +1214,21 @@ func (m *AppModel) renderStatusBar() string {
if m.stream != nil {
leftSide = m.stream.SpinnerView()
}
leftWidth := lipgloss.Width(leftSide)
// Middle: extension status bar entries (sorted by priority).
var middleParts []string
if m.getStatusBarEntries != nil {
entries := m.getStatusBarEntries()
for _, e := range entries {
middleParts = append(middleParts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(e.Text))
}
}
middleSide := strings.Join(middleParts, " ")
if middleSide != "" && leftSide != "" {
middleSide = " " + middleSide
}
// Right side: provider · model + usage stats.
var rightParts []string
@@ -1211,12 +1252,12 @@ func (m *AppModel) renderStatusBar() string {
}
rightSide := strings.Join(rightParts, " ")
rightWidth := lipgloss.Width(rightSide)
// Fill the gap between left and right with spaces.
gap := max(m.width-leftWidth-rightWidth, 1)
// Fill the gap between left+middle and right with spaces.
usedWidth := lipgloss.Width(leftSide) + lipgloss.Width(middleSide) + lipgloss.Width(rightSide)
gap := max(m.width-usedWidth, 1)
return leftSide + strings.Repeat(" ", gap) + rightSide
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
}
// renderSeparator renders the separator line with an optional queue count badge.
+101
View File
@@ -15,6 +15,7 @@ import (
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/kitsetup"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/skills"
"github.com/mark3labs/kit/internal/tools"
@@ -288,6 +289,106 @@ func (m *Kit) GetExtensionUIVisibility() *extensions.UIVisibility {
return m.extRunner.GetUIVisibility()
}
// GetSessionMessages returns the conversation messages on the current branch
// as extension-facing SessionMessage structs, ordered root to leaf.
func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
if m.treeSession == nil {
return nil
}
branch := m.treeSession.GetBranch("")
var msgs []extensions.SessionMessage
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
continue
}
msg, err := me.ToMessage()
if err != nil {
continue
}
// Flatten content parts into a single text string.
var content string
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content += pt.Text
case message.ReasoningContent:
content += pt.Thinking
case message.ToolCall:
content += fmt.Sprintf("[tool_call: %s(%s)]", pt.Name, pt.Input)
case message.ToolResult:
content += fmt.Sprintf("[tool_result: %s]", pt.Content)
}
}
msgs = append(msgs, extensions.SessionMessage{
ID: me.ID,
ParentID: me.ParentID,
Role: string(msg.Role),
Content: content,
Model: msg.Model,
Provider: msg.Provider,
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return msgs
}
// GetSessionFilePath returns the JSONL file path of the current session.
func (m *Kit) GetSessionFilePath() string {
if m.treeSession == nil {
return ""
}
return m.treeSession.GetFilePath()
}
// AppendExtensionEntry persists custom extension data in the session tree.
func (m *Kit) AppendExtensionEntry(extType, data string) (string, error) {
if m.treeSession == nil {
return "", fmt.Errorf("no session available")
}
return m.treeSession.AppendExtensionData(extType, data)
}
// GetExtensionEntries retrieves persisted extension data entries for a type.
func (m *Kit) GetExtensionEntries(extType string) []extensions.ExtensionEntry {
if m.treeSession == nil {
return nil
}
entries := m.treeSession.GetExtensionData(extType)
result := make([]extensions.ExtensionEntry, 0, len(entries))
for _, e := range entries {
result = append(result, extensions.ExtensionEntry{
ID: e.ID,
EntryType: e.ExtType,
Data: e.Data,
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return result
}
// SetExtensionStatus places or updates a keyed status bar entry.
func (m *Kit) SetExtensionStatus(entry extensions.StatusBarEntry) {
if m.extRunner != nil {
m.extRunner.SetStatusEntry(entry)
}
}
// RemoveExtensionStatus removes a keyed status bar entry.
func (m *Kit) RemoveExtensionStatus(key string) {
if m.extRunner != nil {
m.extRunner.RemoveStatusEntry(key)
}
}
// GetExtensionStatusEntries returns all extension status bar entries sorted by priority.
func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetStatusEntries()
}
// HasExtensions returns true if the extension runner is configured and active.
func (m *Kit) HasExtensions() bool {
return m.extRunner != nil