From 9449f1fcdf5ca17e3dcd74775f583f85c6d6e401 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Mon, 2 Mar 2026 01:33:56 +0300 Subject: [PATCH] feat: add session management, persistence, editor text, and status bar APIs for extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- GAP_ANALYSIS.md | 599 +++++++++++++++++++++++++++++++ cmd/root.go | 64 +++- internal/app/app.go | 11 + internal/app/events.go | 7 + internal/extensions/api.go | 124 +++++++ internal/extensions/runner.go | 56 ++- internal/extensions/symbols.go | 7 + internal/session/entry.go | 25 ++ internal/session/tree_manager.go | 42 +++ internal/ui/model.go | 53 ++- pkg/kit/kit.go | 101 ++++++ 11 files changed, 1070 insertions(+), 19 deletions(-) create mode 100644 GAP_ANALYSIS.md diff --git a/GAP_ANALYSIS.md b/GAP_ANALYSIS.md new file mode 100644 index 00000000..aab713bd --- /dev/null +++ b/GAP_ANALYSIS.md @@ -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(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 | diff --git a/cmd/root.go b/cmd/root.go index c726408e..ddc6e68c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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. diff --git a/internal/app/app.go b/internal/app/app.go index c29db0f1..e30f59b2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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). diff --git a/internal/app/events.go b/internal/app/events.go index 2f345af7..069fde1a 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -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 diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 019955aa..54d6df7f 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -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. diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index 921285ec..fa6f4c2f 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -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 // --------------------------------------------------------------------------- diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 0a956ff4..b64b5e02 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -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)), diff --git a/internal/session/entry.go b/internal/session/entry.go index bbaa3f7f..46dd39b2 100644 --- a/internal/session/entry.go +++ b/internal/session/entry.go @@ -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) } diff --git a/internal/session/tree_manager.go b/internal/session/tree_manager.go index b40b3511..fc4f65dd 100644 --- a/internal/session/tree_manager.go +++ b/internal/session/tree_manager.go @@ -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 "" } diff --git a/internal/ui/model.go b/internal/ui/model.go index 6a426c71..f8dc547b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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. diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index c8147a2a..d55ce490 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -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