Compare commits

...

10 Commits

Author SHA1 Message Date
Ed Zynda 8840cbfabc feat: show spinner during shell command execution (! and !!)
Shell commands (! and !!) now display the KITT spinner animation while
running, matching the behavior of LLM agent steps and compaction.

On shellCommandMsg: transition to stateWorking and start the spinner.
On shellCommandResultMsg: stop the spinner and return to stateInput.
2026-03-18 17:48:15 +03:00
Ed Zynda a11b41cda4 fix: prevent duplicate spinner tick loops causing double-speed animation
The KITT spinner animation would sometimes run at 2x (or higher) speed
because multiple concurrent tick loops could accumulate in the Bubble Tea
command queue.

The problematic sequence: SpinnerEvent{Show:true} starts tick loop A,
SpinnerEvent{Show:false} sets spinning=false but tick A is still
in-flight, then ToolExecutionEvent restarts spinning and starts tick
loop B. When the stale tick A fires, spinning is true again so it
continues — now two loops advance spinnerFrame simultaneously.

Fix: add a generation counter (uint64) to StreamComponent. Each
streamSpinnerTickMsg carries the generation it was created for. The tick
handler only processes ticks matching the current generation — stale
ticks from previous start/stop cycles are silently discarded.

Generation is bumped on every spinner start, stop, and Reset() to
ensure at most one tick loop is ever active.
2026-03-18 17:44:11 +03:00
Ed Zynda 8b7be8b735 fix: use immediate parent dir for main.go extension names
deriveExtensionName was using the full directory path (e.g.
examples/extensions/kit-telegram) to derive the display name for
main.go extensions, producing verbose names like 'Examples Extensions
Kit Telegram Extension'. Now uses filepath.Base(dir) so only the
immediate parent directory is used, giving 'Kit Telegram Extension'.

Also fix TestLoadExtensions_SkipsBadFiles which was flaky when
globally-installed git packages existed — isolate the test from the
host environment by overriding XDG_CONFIG_HOME, XDG_DATA_HOME, and
the working directory.
2026-03-18 17:36:06 +03:00
Ed Zynda caa6d1c178 Add kit-telegram example extension
- Copy Telegram relay extension from ../kit-telegram
- Add README with quickstart, commands, API reference, and architecture
- Update examples/extensions/README.md with Integrations section and details
2026-03-18 17:14:47 +03:00
Ed Zynda 001156053d chore: untrack .agents and skills-lock.json, update skills SKILL.md 2026-03-18 17:05:03 +03:00
Ed Zynda 54717e32bc refactor: Auto-show multi-select when repo has multiple extensions
Remove --select flag. Multi-select now appears automatically when a repo
contains more than one extension. Add --all flag to skip selection.
2026-03-18 16:53:42 +03:00
Ed Zynda 5b214b9fdf refactor: Use huh for CLI prompts, fix extension discovery in mixed repos
- Replace custom multi-select with huh.NewMultiSelect for kit install --select
- Replace raw bufio prompts in cmd/auth.go with huh.NewConfirm and huh.NewInput
- Fix extension discovery to use opinionated conventions (only scan root,
  extensions/, ext/, examples/extensions/ directories, skip cmd/internal/pkg/)
- Fix loader to use same convention-based scanning for installed git repos
- Fix errcheck lint warning in loader.go
2026-03-18 16:49:48 +03:00
Ed Zynda c5e6ca6e4d feat: Add kit install command for git-based extension distribution
Add comprehensive extension installation system for Kit:

Features:
- kit install <git-url> - Install extensions from git repos
- kit install <url> --local - Install to project .kit/git/ directory
- kit install <url> --select - Interactive selection for multi-extension repos
- kit install <url> --update - Update installed extensions
- kit install <url> --uninstall - Remove installed extensions
- Version pinning via @ref (tags, branches, commits)
- Support multiple URL formats (shorthand, git:, https, ssh)

Implementation:
- internal/extensions/installer.go - Git clone, checkout, validation
- internal/extensions/manifest.go - Package tracking with Include filtering
- internal/extensions/loader.go - Respect Include field when loading
- cmd/install.go - Cobra command with interactive prompts
- PromptMultiSelectConfig API - Multi-select prompts for extensions

Storage:
- Global: ~/.local/share/kit/git/<host>/<owner>/<repo>/
- Project: .kit/git/<host>/<owner>/<repo>/
- Manifests: packages.json tracking installed packages

Examples:
- Reorganized examples/extensions/ with README.md
- Added status-tools/ multi-file extension example
- Created comprehensive install guide in SKILL.md

Testing:
- Added installer_test.go with 15+ test cases
- All tests pass, build clean

Closes #extension-distribution
2026-03-18 16:21:31 +03:00
Ed Zynda 419a139137 fix: make TUI responsive for terminal resizing at any dimension
Prevent layout corruption and visual breakage when the terminal is
resized to narrow or short dimensions:

- Status bar: progressively drops middle/right sections instead of
  wrapping to multiple lines (broke height calculation)
- Autocomplete popup: guard against negative widths, truncate names
  before rendering to prevent text wrapping inside fixed-width columns,
  adapt to name-only mode at very narrow widths
- Tree selector: use full height minus chrome instead of halved height,
  guard against negative width in node truncation
- Model selector: rune-aware name truncation preserving provider tags,
  width-adaptive entry rendering
- Overlay dialog: clamp dimensions to terminal bounds instead of using
  fixed minimums that could exceed the terminal
- Input hint, popup footer, and all help text: tiered adaptive variants
  for different terminal widths
- Queued messages: measure actual rendered height instead of fixed
  5-line-per-message estimate
2026-03-18 14:52:43 +03:00
Ed Zynda 7b963624c1 fix: ensure all message blocks appear below previous content in scrollback
tea.Println inserts above BubbleTea's managed region, but after
StepCompleteEvent the previous response stays in the stream component
(managed region). Any subsequent print (tool results, shell commands,
slash output, errors) would appear above that response — out of order.

Introduce a scrollback buffer: all print helpers now buffer rendered
content via appendScrollback(). At the end of each Update cycle,
drainScrollback() combines everything into a single tea.Println. If
the stream component has unflushed content it is auto-prepended, so
new messages always appear below the previous assistant response.
2026-03-18 14:16:37 +03:00
30 changed files with 5243 additions and 1162 deletions
-64
View File
@@ -1,64 +0,0 @@
---
name: btca-cli
description: Operate the btca CLI for local resources and source-first answers. Use when setting up btca in a project, connecting a provider, adding or managing resources, and asking questions via btca commands. Invoke this skill when the user says "use btca" or needs to do more detailed research on a specific library or framework.
---
# btca CLI
`btca` is a source-first research CLI. It hydrates resources (git, local, npm) into searchable context, then answers questions grounded in those sources. Use configured resources for ongoing work, or one-off anonymous resources directly in `btca ask`.
Full CLI reference: https://docs.btca.dev/guides/cli-reference
Add resources:
```bash
# Git resource
btca add -n svelte-dev https://github.com/sveltejs/svelte.dev
# Local directory
btca add -n my-docs -t local /absolute/path/to/docs
# npm package
btca add npm:@types/node@22.10.1 -n node-types -t npm
```
Verify resources:
```bash
btca resources
```
Ask a question:
```bash
btca ask -r svelte-dev -q "How do I define remote functions?"
```
## Common Tasks
- Ask with multiple resources:
```bash
btca ask -r react -r typescript -q "How do I type useState?"
```
- Ask with anonymous one-off resources (not saved to config):
```bash
# One-off git repo
btca ask -r https://github.com/sveltejs/svelte -q "Where is the implementation of writable stores?"
# One-off npm package
btca ask -r npm:react@19.0.0 -q "How is useTransition exported?"
```
## Config Overview
- Config lives in `btca.config.jsonc` (project) and `~/.config/btca/btca.config.jsonc` (global).
- Project config overrides global and controls provider/model and resources.
## Troubleshooting
- "No resources configured": add resources with `btca add ...` and re-run `btca resources`.
- "Provider not connected": run `btca connect` and follow the prompts.
- "Unknown resource": use `btca resources` for configured names, or pass a valid HTTPS git URL / `npm:<package>` as an anonymous one-off in `btca ask`.
@@ -1,3 +0,0 @@
interface:
display_name: "BTCA CLI"
short_description: "Help with BTCA CLI setup and usage workflows"
-853
View File
@@ -1,853 +0,0 @@
---
name: kit-extensions
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
---
# Kit Extensions Development Guide
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
## Extension Structure
Every extension must export a `package main` with an `Init(api ext.API)` function:
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
// Register event handlers, tools, commands, etc.
}
```
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
## Extension Locations
Extensions are auto-loaded from these directories:
- `~/.config/kit/extensions/*.go` (global, single files)
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
- `.kit/extensions/*.go` (project-local, single files)
- `.kit/extensions/*/main.go` (project-local, subdirectories)
Or loaded explicitly:
```bash
kit -e path/to/extension.go
kit --extension path/to/extension.go
```
## Import Path
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
## API Overview
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
---
## Lifecycle Events
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
### Session Events
```go
// Fired when session is loaded/created.
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
// e.SessionID string
})
// Fired when Kit is shutting down. Use for cleanup.
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
// No fields.
})
```
### Agent Turn Events
```go
// Before agent starts processing. Can inject system prompt or text.
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
// e.Prompt string
// Return nil to pass through.
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
return nil
})
// Agent loop has started.
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
// e.Prompt string
})
// Agent finished responding.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// e.Response string
// e.StopReason string — "completed", "cancelled", "error"
})
```
### Tool Events
```go
// Before a tool executes. Can block the call.
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
// e.ToolName string
// e.ToolCallID string
// e.Input string — JSON-encoded parameters
// e.Source string — "llm" or "user"
// Return nil to allow.
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
return nil
})
// Tool execution started (informational only).
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
// e.ToolName string
})
// Tool execution ended (informational only).
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
// e.ToolName string
})
// After a tool returns. Can modify the result.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
// e.ToolName string
// e.Input string
// e.Content string
// e.IsError bool
// Return nil to pass through.
// Return &ext.ToolResultResult{Content: &s} to replace content.
// Return &ext.ToolResultResult{IsError: &b} to change error status.
return nil
})
```
### Input Events
```go
// User submitted input. Can handle or transform it.
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
// e.Text string
// e.Source string — "interactive", "cli", "script", "queue"
// Return nil to pass through to agent.
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
return nil
})
```
### Streaming Events
```go
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
// e.Chunk string — streaming text chunk
})
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
// e.Content string — full message content
})
```
### Model Events
```go
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
// e.NewModel string
// e.PreviousModel string
// e.Source string — "extension" or "user"
})
```
### Context Filtering
```go
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
// e.Messages []ext.ContextMessage
// Each ContextMessage has: Index int, Role string, Content string
// Index -1 means a new injected message (not from session).
// Return nil to pass through.
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
return nil
})
```
### Session Control Events
```go
// Before forking the session tree. Can cancel.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
// e.TargetID string, e.IsUserMessage bool, e.UserText string
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
})
// Before switching/clearing session. Can cancel.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
// e.Reason string — "new" or "clear"
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
})
// Before context compaction. Can cancel.
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
// e.EstimatedTokens, e.ContextLimit int
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
})
```
### Custom Events
```go
// Subscribe to custom events emitted by other extensions.
api.OnCustomEvent("event-name", func(data string) {
// data is arbitrary string payload
})
// Emit from Context:
ctx.EmitCustomEvent("event-name", "payload")
```
---
## Registering Tools
Tools are functions the LLM can invoke:
```go
api.RegisterTool(ext.ToolDef{
Name: "current_time",
Description: "Get the current date and time",
Parameters: `{"type":"object","properties":{}}`,
Execute: func(input string) (string, error) {
return time.Now().Format(time.RFC3339), nil
},
})
```
For long-running tools with cancellation and progress:
```go
api.RegisterTool(ext.ToolDef{
Name: "slow_task",
Description: "A long-running task with progress reporting",
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
for i := 0; i < 10; i++ {
if tc.IsCancelled() {
return "cancelled", nil
}
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
time.Sleep(time.Second)
}
return "done", nil
},
})
```
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
---
## Registering Slash Commands
Commands are user-facing actions invoked with `/name` in the input:
```go
api.RegisterCommand(ext.CommandDef{
Name: "echo",
Description: "Echo back the provided text",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.PrintInfo("You said: " + args)
return "", nil
},
// Optional tab-completion:
Complete: func(prefix string, ctx ext.Context) []string {
return []string{"hello", "world"}
},
})
```
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
---
## Registering Keyboard Shortcuts
```go
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handler runs when shortcut is pressed
})
```
---
## Registering Options
Options are configurable values resolved from env vars, config, or defaults:
```go
api.RegisterOption(ext.OptionDef{
Name: "my-setting",
Description: "Controls something",
Default: "false",
})
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
val := ctx.GetOption("my-setting")
// Set at runtime:
ctx.SetOption("my-setting", "true")
```
---
## Context API Reference
The `ext.Context` struct provides runtime capabilities via function fields.
### Output
```go
ctx.Print("plain text") // plain output
ctx.PrintInfo("styled info block") // bordered info block
ctx.PrintError("styled error block") // red error block
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
Text: "content",
BorderColor: "#a6e3a1",
Subtitle: "my-ext",
})
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
```
### Message Injection
```go
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
```
### Widgets
Persistent UI elements displayed above or below the input area:
```go
ctx.SetWidget(ext.WidgetConfig{
ID: "my-widget",
Placement: ext.WidgetAbove, // or ext.WidgetBelow
Content: ext.WidgetContent{
Text: "Status: Active",
Markdown: false, // set true for markdown rendering
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1", // hex color
NoBorder: false,
},
Priority: 0, // lower values render first
})
ctx.RemoveWidget("my-widget")
```
### Header and Footer
```go
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Header"},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
ctx.RemoveHeader()
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Footer"},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
ctx.RemoveFooter()
```
### Status Bar
```go
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
ctx.RemoveStatus("key")
```
### Interactive Prompts
These block until the user responds (safe in slash commands and goroutines):
```go
// Selection list
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Pick one:",
Options: []string{"Option A", "Option B", "Option C"},
})
if !result.Cancelled {
// result.Value string, result.Index int
}
// Yes/No confirmation
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure?",
DefaultValue: false,
})
if !result.Cancelled {
// result.Value bool
}
// Text input
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter name:",
Placeholder: "my-project",
Default: "",
})
if !result.Cancelled {
// result.Value string
}
```
### Overlay Dialogs
Modal dialogs with optional action buttons:
```go
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Confirmation",
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
Width: 60, // 0 = 60% of terminal width
MaxHeight: 20, // 0 = 80% of terminal height
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
Actions: []string{"Confirm", "Cancel"},
})
if !result.Cancelled {
// result.Action string, result.Index int
}
```
### Editor Interceptor
Wrap the built-in text input with custom key handling and rendering:
```go
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
if key == "ctrl+s" {
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
Render: func(width int, defaultContent string) string {
return "[custom] " + defaultContent
},
})
ctx.ResetEditor() // remove interceptor
ctx.SetEditorText("prefilled") // set editor text content
```
**EditorKeyAction types:**
- `ext.EditorKeyPassthrough` — let the default editor handle the key
- `ext.EditorKeyConsumed` — swallow the key, do nothing
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
### UI Visibility
```go
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
```
### Session Data
```go
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
path := ctx.GetSessionPath() // file path of session JSONL
// Persist custom data in the session tree:
id, err := ctx.AppendEntry("my-type", "data string")
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
```
### Model Management
```go
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
```
### Tool Management
```go
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
ctx.SetActiveTools(nil) // re-enable all tools
```
### LLM Completions
Make standalone LLM calls (bypasses the agent tool loop):
```go
resp, err := ctx.Complete(ext.CompleteRequest{
Model: "", // empty = current model
System: "You are ...", // optional system prompt
Prompt: "Summarize...", // the prompt
MaxTokens: 1000, // 0 = provider default
OnChunk: func(chunk string) { /* streaming */ },
})
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
```
### TUI Suspension
Temporarily release the terminal for interactive subprocesses:
```go
ctx.SuspendTUI(func() {
cmd := exec.Command("vim", "file.go")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
```
### Application Control
```go
ctx.Exit() // graceful shutdown
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
```
### Context Fields
```go
ctx.SessionID // string
ctx.CWD // string — current working directory
ctx.Model // string — active model name
ctx.Interactive // bool — true if running in TUI mode
```
---
## Tool Renderers
Customize how tool calls are displayed in the TUI:
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell", // replaces auto-capitalized name
BorderColor: "#89b4fa",
Background: "",
BodyMarkdown: true, // render body through markdown
RenderHeader: func(toolArgs string, width int) string {
var args struct{ Command string `json:"command"` }
json.Unmarshal([]byte(toolArgs), &args)
return "$ " + args.Command
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "ERROR: " + toolResult
}
return toolResult
},
})
```
## Message Renderers
Define named output styles for `ctx.RenderMessage()`:
```go
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
return " " + content // green checkmark prefix
},
})
// Usage in handlers:
ctx.RenderMessage("success", "All tests passed")
```
---
## Critical Yaegi Constraints
### No Named Function References in Struct Fields
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
```go
// WRONG - will silently return zero values:
func myHandler(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
// CORRECT - use anonymous closure:
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
```
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
### No Interfaces Across the Boundary
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
### Package-Level Variables for State
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
```go
package main
import "kit/ext"
var callCount int
var lastTool string
func Init(api ext.API) {
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
callCount++
lastTool = e.ToolName
return nil
})
}
```
---
## Common Patterns
### Pattern: Tool Call Blocking
Block dangerous operations by intercepting tool calls:
```go
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "bash" {
var input struct{ Command string `json:"command"` }
json.Unmarshal([]byte(tc.Input), &input)
if strings.Contains(input.Command, "rm -rf") {
return &ext.ToolCallResult{
Block: true,
Reason: "Dangerous command blocked",
}
}
}
return nil
})
```
### Pattern: System Prompt Injection
Augment the agent's behavior by injecting instructions:
```go
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
prompt := "Always respond with bullet points."
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
```
### Pattern: Background Processing with SendMessage
Run work in a goroutine and inject results back:
```go
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command in the background",
Execute: func(args string, ctx ext.Context) (string, error) {
go func() {
out, err := exec.Command("sh", "-c", args).CombinedOutput()
if err != nil {
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
return
}
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
}()
return "Running in background...", nil
},
})
```
### Pattern: Ephemeral Context Injection
Inject information into every LLM turn without persisting in session history:
```go
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
data, err := os.ReadFile(".kit/context.md")
if err != nil {
return nil
}
injected := ext.ContextMessage{
Index: -1, // -1 = new message, not from session
Role: "system",
Content: string(data),
}
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
```
### Pattern: Live Widget Updates
Update a widget periodically from a goroutine:
```go
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetWidget(ext.WidgetConfig{
ID: "clock",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
}()
})
```
### Pattern: Spawning Kit as a Sub-Agent
Extensions can spawn Kit as a subprocess for delegation:
```bash
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
---
## Testing Extensions
```bash
# Validate syntax of all discovered extensions
kit extensions validate
# List loaded extensions
kit extensions list
# Run with a specific extension
kit -e path/to/extension.go
# Run with multiple extensions
kit -e ext1.go -e ext2.go
# Disable all extensions
kit --no-extensions
# Generate an example extension scaffold
kit extensions init
```
---
## Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
```go
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
func Init(api ext.API) {
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if strings.ToLower(ctx.GetOption("plan")) == "true" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
Focus on understanding, analysis, and generating plans.`
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
}
func applyMode(ctx ext.Context, active bool, tools []string) {
if active {
ctx.SetActiveTools(tools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON")
} else {
ctx.SetActiveTools(nil)
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF")
}
}
```
## Key Files for Reference
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
+2
View File
@@ -12,3 +12,5 @@ dist/
contribute/output/
CONTEXT.md
output/
.agents/
skills-lock.json
+25 -21
View File
@@ -1,11 +1,11 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"charm.land/huh/v2"
"github.com/mark3labs/kit/internal/auth"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
@@ -171,14 +171,15 @@ func loginAnthropic() error {
// Check if already authenticated
if hasAuth, err := cm.HasAnthropicCredentials(); err == nil && hasAuth {
fmt.Print("You are already authenticated with Anthropic. Do you want to re-authenticate? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
var reauth bool
err := huh.NewConfirm().
Title("You are already authenticated with Anthropic").
Description("Do you want to re-authenticate?").
Affirmative("Yes").
Negative("No").
Value(&reauth).
Run()
if err != nil || !reauth {
fmt.Println("Authentication cancelled.")
return nil
}
@@ -204,10 +205,13 @@ func loginAnthropic() error {
// Wait for user to complete OAuth flow
fmt.Println("After authorizing the application, you'll receive an authorization code.")
fmt.Print("Please enter the authorization code: ")
reader := bufio.NewReader(os.Stdin)
code, err := reader.ReadString('\n')
var code string
err = huh.NewInput().
Title("Authorization code").
Description("Paste the code from your browser").
Value(&code).
Run()
if err != nil {
return fmt.Errorf("failed to read authorization code: %w", err)
}
@@ -255,15 +259,15 @@ func logoutAnthropic() error {
}
// Confirm logout
fmt.Print("Are you sure you want to remove your Anthropic credentials? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
var confirm bool
err = huh.NewConfirm().
Title("Remove Anthropic credentials").
Description("Are you sure you want to remove your stored credentials?").
Affirmative("Yes").
Negative("No").
Value(&confirm).
Run()
if err != nil || !confirm {
fmt.Println("Logout cancelled.")
return nil
}
+225
View File
@@ -0,0 +1,225 @@
package cmd
import (
"fmt"
"os/exec"
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extensions"
"github.com/spf13/cobra"
)
var (
installLocalFlag bool
installUpdateFlag bool
installUninstallFlag bool
installAllFlag bool
)
var installCmd = &cobra.Command{
Use: "install <git-url>",
Short: "Install extensions from git repositories",
Long: `Install extensions from git repositories.
The install command downloads and installs Kit extensions from git repositories.
Extensions are stored in the global extensions directory by default, or in the
project's .kit/git/ directory when using the --local flag.
When a repo contains multiple extensions, an interactive multi-select is shown
so you can choose which to install. Use --all to skip selection and install everything.
Supported URL formats:
- github.com/user/repo (shorthand, defaults to HTTPS)
- git:github.com/user/repo
- https://github.com/user/repo
- ssh://git@github.com/user/repo
- git@github.com:user/repo
You can pin to a specific version, tag, or commit using @:
- github.com/user/repo@v1.0.0
- github.com/user/repo@main
- github.com/user/repo@abc1234
Examples:
kit install github.com/user/my-extension
kit install github.com/user/my-extension@v1.0.0
kit install github.com/user/my-extension --local
kit install github.com/user/collection --all`,
Args: cobra.ExactArgs(1),
RunE: runInstall,
}
func init() {
installCmd.Flags().BoolVarP(&installLocalFlag, "local", "l", false, "Install to project-local .kit/git/ directory")
installCmd.Flags().BoolVarP(&installUpdateFlag, "update", "u", false, "Update an already-installed package")
installCmd.Flags().BoolVar(&installUninstallFlag, "uninstall", false, "Remove an installed package")
installCmd.Flags().BoolVar(&installAllFlag, "all", false, "Install all extensions without prompting")
rootCmd.AddCommand(installCmd)
}
func runInstall(cmd *cobra.Command, args []string) error {
sourceStr := args[0]
// Check that git is available
if _, err := exec.LookPath("git"); err != nil {
return fmt.Errorf("git is not installed or not in PATH")
}
// Parse the source
source, err := extensions.ParseGitSource(sourceStr)
if err != nil {
return fmt.Errorf("invalid source: %w", err)
}
// Determine scope
scope := extensions.ScopeGlobal
if installLocalFlag {
scope = extensions.ScopeProject
}
installer := extensions.NewInstaller(".")
// Handle uninstall
if installUninstallFlag {
return runUninstall(installer, source, scope)
}
// Handle update
if installUpdateFlag {
return runUpdate(installer, source, scope)
}
// Handle install
return runInstallPackage(installer, source, scope)
}
func runInstallPackage(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Check if already installed
existingScope, installed := installer.IsInstalled(source)
if installed {
return fmt.Errorf("extension already installed (scope: %s). Use --update to update or --uninstall to remove", existingScope)
}
// Preview extensions to decide if we need multi-select
previews, tempDir, err := installer.PreviewExtensions(source)
if err != nil {
return fmt.Errorf("previewing extensions: %w", err)
}
defer extensions.CleanupTempDir(tempDir)
if len(previews) == 0 {
return fmt.Errorf("no extensions found in %s", source.String())
}
scopeStr := "globally"
if scope == extensions.ScopeProject {
scopeStr = "locally in .kit/git/"
}
// Single extension or --all flag: install everything directly
if len(previews) == 1 || installAllFlag {
if err := installer.Install(source, scope); err != nil {
return fmt.Errorf("install failed: %w", err)
}
if source.Pinned {
fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr)
} else {
fmt.Printf("Installed %d extension(s) from %s %s\n", len(previews), source.String(), scopeStr)
}
log.Info("extension installed", "source", source.String(), "scope", scope)
return nil
}
// Multiple extensions: show interactive selection
includePaths, err := multiSelectForInstall(previews)
if err != nil {
if err.Error() == "selection cancelled" || err.Error() == "no extensions selected" {
fmt.Println("Install cancelled.")
return nil
}
return fmt.Errorf("selection failed: %w", err)
}
if err := installer.InstallWithInclude(source, scope, includePaths); err != nil {
return fmt.Errorf("install failed: %w", err)
}
fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr)
for _, path := range includePaths {
fmt.Printf(" - %s\n", path)
}
log.Info("extension installed", "source", source.String(), "scope", scope, "selected", len(includePaths))
return nil
}
func runUpdate(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Find the installed package
existingScope, installed := installer.IsInstalled(source)
if !installed {
// Try to find with wildcard (no version)
entry, foundScope, err := extensions.FindInManifest(source.Identity())
if err != nil || entry == nil {
return fmt.Errorf("extension not installed: %s", source.Identity())
}
// Parse the found entry's source
foundSource, err := extensions.ParseGitSource(entry.Source)
if err != nil {
return fmt.Errorf("failed to parse installed source: %w", err)
}
existingScope = foundScope
source = foundSource
}
// Override scope if specified
if installLocalFlag && scope != existingScope {
return fmt.Errorf("extension installed in %s scope, cannot update with --local flag", existingScope)
}
scope = existingScope
// Check if pinned
if source.Pinned {
fmt.Printf("Skipping %s (pinned at %s)\n", source.Identity(), source.Ref)
return nil
}
// Update
if err := installer.Update(source, scope); err != nil {
return fmt.Errorf("update failed: %w", err)
}
fmt.Printf("Updated %s\n", source.Identity())
log.Info("extension updated", "source", source.Identity(), "scope", scope)
return nil
}
func runUninstall(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
// Find where it's installed (ignore scope flag for uninstall - remove from wherever it exists)
existingScope, installed := installer.IsInstalled(source)
if !installed {
// Try to find in manifests
entry, foundScope, err := extensions.FindInManifest(source.Identity())
if err != nil || entry == nil {
return fmt.Errorf("extension not installed: %s", source.Identity())
}
existingScope = foundScope
// Parse the found entry's source
foundSource, err := extensions.ParseGitSource(entry.Source)
if err != nil {
return fmt.Errorf("failed to parse installed source: %w", err)
}
source = foundSource
}
// Uninstall from the scope where it's installed
if err := installer.Uninstall(source, existingScope); err != nil {
return fmt.Errorf("uninstall failed: %w", err)
}
fmt.Printf("Uninstalled %s from %s scope\n", source.Identity(), existingScope)
log.Info("extension uninstalled", "source", source.Identity(), "scope", existingScope)
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package cmd
import (
"fmt"
"os"
"charm.land/huh/v2"
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extensions"
)
// multiSelectForInstall runs a multi-select prompt for extension selection.
// Returns the selected extension paths, or an error if cancelled.
func multiSelectForInstall(previews []extensions.ExtensionPreview) ([]string, error) {
if len(previews) == 0 {
return nil, fmt.Errorf("no extensions to select")
}
// Non-interactive: select all
if !isInteractive() {
log.Info("Non-interactive mode, selecting all extensions")
paths := make([]string, len(previews))
for i, p := range previews {
paths[i] = p.Path
}
return paths, nil
}
// Single extension: just return it
if len(previews) == 1 {
return []string{previews[0].Path}, nil
}
// Build options for huh MultiSelect
options := make([]huh.Option[string], len(previews))
for i, p := range previews {
label := fmt.Sprintf("%s %s", p.Name, p.Path)
options[i] = huh.NewOption(label, p.Path).Selected(true)
}
var selected []string
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Select extensions to install").
Options(options...).
Value(&selected),
),
)
if err := form.Run(); err != nil {
return nil, fmt.Errorf("selection cancelled")
}
if len(selected) == 0 {
return nil, fmt.Errorf("no extensions selected")
}
return selected, nil
}
// isInteractive checks if the terminal is interactive.
func isInteractive() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
+191
View File
@@ -0,0 +1,191 @@
# Kit Extension Examples
A collection of example extensions demonstrating various Kit capabilities. These can be installed individually or as a complete collection.
## Installation
### Install all examples
```bash
kit install github.com/mark3labs/kit/examples/extensions
```
### Install with interactive selection
```bash
kit install github.com/mark3labs/kit/examples/extensions --select
```
### Install locally in your project
```bash
kit install github.com/mark3labs/kit/examples/extensions --local
```
## Extension Index
### Core Concepts
| Extension | Description | Key API |
|-----------|-------------|---------|
| `minimal.go` | Minimal viable extension | Basic `Init()` function |
| `plan-mode.go` | Restrict agent to read-only tools | `OnBeforeAgentStart`, `SetActiveTools` |
| `tool-logger.go` | Log all tool calls to file | `OnToolCall`, `OnToolResult` |
| `notify.go` | Display notifications | `PrintInfo`, `PrintBlock` |
### UI & Widgets
| Extension | Description | Key API |
|-----------|-------------|---------|
| `widget-status.go` | Persistent status widget | `SetWidget`, `RemoveWidget` |
| `header-footer-demo.go` | Custom header/footer | `SetHeader`, `SetFooter` |
| `overlay-demo.go` | Modal overlay dialogs | `ShowOverlay` |
| `compact-notify.go` | Compact mode notifications | `PrintBlock` |
| `branded-output.go` | Custom styled output | `PrintBlock` with colors |
### Input & Editor
| Extension | Description | Key API |
|-----------|-------------|---------|
| `custom-editor-demo.go` | Custom key handling | `SetEditor`, `EditorKeyAction` |
| `pirate.go` | Transform user input | `OnInput`, `InputResult` |
| `interactive-shell.go` | Custom command input | Slash commands with prompts |
| `inline-bash.go` | Execute bash inline | Input handling, `exec` |
### Session & Context
| Extension | Description | Key API |
|-----------|-------------|---------|
| `context-inject.go` | Inject context into prompts | `OnContextPrepare` |
| `bookmark.go` | Bookmark messages | `AppendEntry`, `GetEntries` |
| `project-rules.go` | Project-specific rules | Session data, file reading |
| `protected-paths.go` | Block dangerous operations | `OnToolCall` with blocking |
| `permission-gate.go` | Confirm destructive actions | `OnToolCall` with confirmation |
### Tools & Commands
| Extension | Description | Key API |
|-----------|-------------|---------|
| `auto-commit.go` | Auto-commit changes | Custom tool, git operations |
| `summarize.go` | Summarize conversation | Custom tool with parameters |
| `confirm-destructive.go` | Confirm destructive commands | `OnToolCall` blocking |
| `lsp-diagnostics.go` | LSP integration | Complex extension, external process |
### Subagents & Background Tasks
| Extension | Description | Key API |
|-----------|-------------|---------|
| `kit-kit.go` | Spawn Kit as subagent | Subagent spawning |
| `subagent-test.go` | Test subagent functionality | `SpawnSubagent` |
| `subagent-widget.go` | Widget with subagent updates | Goroutines + widgets |
| `dev-reload.go` | Hot reload extensions | `ReloadExtensions` |
### Integrations
| Extension | Description | Key API |
|-----------|-------------|---------|
| `kit-telegram/` | Telegram relay for remote monitoring & control | `RegisterCommand`, `OnAgentStart/End`, `SetStatus`, `SendMessage` |
### Rendering
| Extension | Description | Key API |
|-----------|-------------|---------|
| `tool-renderer-demo.go` | Custom tool output styling | `RegisterToolRenderer` |
| `prompt-demo.go` | Interactive prompts | `PromptSelect`, `PromptConfirm` |
## Extension Details
### minimal.go
The bare minimum extension showing the required structure:
- Package `main`
- Import `kit/ext`
- Export `Init(api ext.API)` function
### plan-mode.go
A complete example demonstrating:
- Slash command (`/plan`)
- Keyboard shortcut (`ctrl+alt+p`)
- Option registration
- Status bar indicators
- System prompt injection
- Tool filtering
### widget-status.go
Shows how to create persistent UI elements:
- Create widgets with `SetWidget`
- Update content dynamically
- Remove when done
- Handle session lifecycle
### context-inject.go
Advanced context manipulation:
- Read project files
- Inject into LLM context
- Filter messages
- Use negative indices for ephemeral content
### lsp-diagnostics.go
Complex real-world example:
- Multi-file extension
- External process management (LSP server)
- File watching
- Diagnostics aggregation
### kit-telegram/
Full-featured Telegram integration:
- Slash command with subcommands and tab completion
- Interactive guided setup flow with prompts
- Background long-polling goroutine
- Progress message rendering edited in place
- Message queue with edit-before-dispatch
- Remote command handling from Telegram
- Status bar and widget updates
- Config persistence with atomic writes
## Multi-File Extension Example
The `kit-kit-agents/` directory demonstrates the multi-file pattern:
```
kit-kit-agents/
├── main.go # Entry point with Init()
├── agent.go # Agent configuration
├── manager.go # Agent lifecycle management
└── README.md # Documentation
```
When the repo is installed, all files in subdirectories with `main.go` are loaded as separate extensions.
## Testing & Validation
After installing, test the extensions:
```bash
# List all loaded extensions
kit extensions list
# Validate all extensions
kit extensions validate
# Run with a specific extension
kit -e ~/.local/share/kit/git/github.com/mark3labs/kit/examples/extensions/plan-mode.go
```
## Creating Your Own
1. Copy `minimal.go` as a starting point
2. Modify the `Init()` function to register your handlers
3. Use the other examples for reference on specific APIs
4. Test with `kit -e your-extension.go`
5. Share by pushing to a git repository!
## Update
To get the latest examples:
```bash
kit install github.com/mark3labs/kit/examples/extensions --update
```
## See Also
- [Kit Extensions Guide](https://github.com/mark3labs/kit/blob/main/.agents/skills/kit-extensions/SKILL.md)
- [API Reference](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go)
- [Example Extensions Source](https://github.com/mark3labs/kit/tree/main/examples/extensions)
+111
View File
@@ -0,0 +1,111 @@
# kit-telegram
A Kit extension that relays all Kit agent runs to Telegram and lets approved Telegram users reply back into Kit.
## What it does
- Relays **all Kit runs** to one Telegram chat while connected
- Edits one Telegram progress message in place during a run
- Lets approved Telegram users send normal text replies back into Kit
- Shows `Telegram Connected` or `Telegram Disconnected` in the status bar
- Shows a small spinner animation as `⠋ Telegram Connecting` only while the relay is still connecting
- On startup with an already validated enabled config, sends a short Telegram connection message to confirm the relay is up
## Requirements
- `kit` installed and working
- A Telegram bot token from `@BotFather`
- Either:
- A Telegram chat where you can message the bot, or
- A numeric Telegram chat id you want to enter manually
- For group chats, one or more allowed Telegram user ids
## Quickstart
### 1. Install the extension
```bash
kit install github.com/mark3labs/kit/examples/extensions/kit-telegram
```
Or run directly:
```bash
kit -e path/to/kit-telegram/main.go
```
### 2. Start Kit and connect Telegram
```bash
kit
```
Inside Kit, run:
```
/telegram connect
```
You will be prompted for:
- Bot token from `@BotFather`
- Whether to auto-detect the chat by messaging the bot or enter the chat id manually
- Allowed user ids when needed
### 3. Verify the relay
```
/telegram test
```
Reply in Telegram with the code from the test message.
## Commands
| Command | Description |
|---------|-------------|
| `/telegram` | Human-friendly overview and subcommand list |
| `/telegram status` | Raw deterministic relay state |
| `/telegram test` | Verify outbound and inbound relay |
| `/telegram toggle` | Enable or disable relay without deleting credentials |
| `/telegram logout` | Remove saved credentials and disconnect relay |
| `/telegram connect` | Run the setup flow again |
| `/telegram clear` | Clear Telegram status and working messages from the TUI |
## Remote commands (from Telegram)
| Command | Description |
|---------|-------------|
| `/telegram` | Sends the overview back to Telegram |
| `/telegram status` | Sends the deterministic state report to Telegram |
| `/telegram test` | Sends a reply-code test message from Telegram |
| `/telegram toggle` | Flips the enabled flag |
| `/telegram logout yes` | Logs out (requires `yes` confirmation) |
| `/telegram clear` | Clears the TUI footer and working messages |
## Key APIs Used
- `RegisterCommand` — Slash command with subcommands and tab completion
- `OnSessionStart` / `OnSessionShutdown` — Lifecycle management
- `OnAgentStart` / `OnAgentEnd` — Run tracking and progress rendering
- `OnToolCall` / `OnToolResult` — Action tracking
- `OnMessageEnd` — Capture assistant responses
- `OnInput` — Mirror local messages to Telegram
- `SetStatus` / `RemoveStatus` — Status bar indicators
- `SetWidget` / `RemoveWidget` — Working message display
- `PromptInput` / `PromptSelect` / `PromptConfirm` — Interactive setup flow
- `SendMessage` — Inject Telegram replies as Kit prompts
## Architecture
Single Go file interpreted by Yaegi at runtime. Core components:
- **Telegram Bot API client** — HTTP calls via `net/http` for getMe, getChat, getChatMember, getUpdates (long-polling), sendMessage, editMessageText
- **Config persistence** — JSON file at `.kit/kit-telegram.json` with atomic writes
- **Long-polling goroutine** — Background polling for Telegram updates with warmup poll, retry, and client-side timeouts
- **Message queue** — In-memory FIFO queue for Telegram prompt input with edit-before-dispatch support
- **Progress rendering** — `⏳ elapsed · step N` with action lines, edited in place
- **Final rendering** — `✅/❌ elapsed` with response text, split into chunks for long output
## Debug mode
Set environment variable `KIT_TELEGRAM_DEBUG=1` to enable verbose debug logging.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
//go:build ignore
package main
import (
"fmt"
"kit/ext"
)
// Helper functions for the status-tools extension
// These are used by main.go but kept in a separate file
// to demonstrate the multi-file extension pattern.
// formatMemory converts bytes to human-readable format
func formatMemory(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}
// showMemoryStatus displays memory usage (placeholder)
func showMemoryStatus(ctx ext.Context) {
// This is a placeholder that would show memory stats
// In a real extension, you'd integrate with system metrics
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "Memory status monitoring not yet implemented",
BorderColor: "#f9e2af",
Subtitle: "Memory",
})
}
+49
View File
@@ -0,0 +1,49 @@
//go:build ignore
package main
import (
"fmt"
"time"
"kit/ext"
)
// Init registers the status tools extension.
// This extension provides multiple status-related utilities as a
// multi-file extension example.
func Init(api ext.API) {
// Register a status bar widget that shows time
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetStatus("clock", time.Now().Format("15:04:05"), 5)
}
}()
})
// Register a /status command
api.RegisterCommand(ext.CommandDef{
Name: "status",
Description: "Show system status information",
Execute: func(args string, ctx ext.Context) (string, error) {
stats := ctx.GetContextStats()
info := fmt.Sprintf(
"Model: %s\nTokens: %d/%d (%.1f%%)\nMessages: %d",
ctx.Model,
stats.EstimatedTokens,
stats.ContextLimit,
stats.UsagePercent*100,
stats.MessageCount,
)
ctx.PrintBlock(ext.PrintBlockOpts{
Text: info,
BorderColor: "#89b4fa",
Subtitle: "System Status",
})
return "", nil
},
})
}
+10 -4
View File
@@ -4,11 +4,11 @@ go 1.26.0
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.1
charm.land/bubbletea/v2 v2.0.2
charm.land/fantasy v0.11.1
charm.land/lipgloss/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.1
github.com/alecthomas/chroma/v2 v2.23.1
github.com/aymanbagabas/go-udiff v0.4.0
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/log v0.4.2
github.com/mark3labs/mcp-go v0.44.1
@@ -20,6 +20,7 @@ require (
)
require (
charm.land/huh/v2 v2.0.3 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
@@ -45,6 +46,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
@@ -53,7 +55,9 @@ require (
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -61,6 +65,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/coder/acp-go-sdk v0.6.3 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
@@ -84,6 +89,7 @@ require (
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.3.0 // indirect
github.com/muesli/mango-pflag v0.2.0 // indirect
@@ -138,6 +144,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
+20
View File
@@ -2,10 +2,16 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk=
charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
@@ -66,12 +72,16 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
@@ -98,8 +108,12 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 h1:bloHJLweYZeIkBVgi8AF94DrTdx3eoEB57VOpFuFi3U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -123,6 +137,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
@@ -198,6 +214,8 @@ github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjc
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
@@ -300,6 +318,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+39
View File
@@ -174,6 +174,22 @@ type Context struct {
// }
PromptInput func(PromptInputConfig) PromptInputResult
// PromptMultiSelect shows a multi-selection list to the user, allowing
// them to toggle options with spacebar and confirm with enter. In
// non-interactive mode, returns all options as selected.
//
// Example:
//
// result := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{
// Message: "Select extensions to install:",
// Options: []string{"git", "todo", "weather"},
// DefaultSelected: []int{0, 1, 2}, // All selected by default
// })
// if !result.Cancelled {
// fmt.Println("Selected:", result.Values)
// }
PromptMultiSelect func(PromptMultiSelectConfig) PromptMultiSelectResult
// ShowOverlay displays a modal overlay dialog that blocks until the
// user dismisses it or selects an action. The overlay renders as a
// centered (or anchored) bordered box over the TUI. Returns a
@@ -1000,6 +1016,29 @@ type PromptInputResult struct {
Cancelled bool
}
// PromptMultiSelectConfig configures a multi-selection prompt that allows
// the user to toggle multiple options and confirm their selection.
type PromptMultiSelectConfig struct {
// Message is the question or instruction displayed to the user.
Message string
// Options is the list of choices the user can select from.
Options []string
// DefaultSelected contains indices of options that should be
// pre-selected when the prompt appears. If nil, all options are selected.
DefaultSelected []int
}
// PromptMultiSelectResult is the response from a multi-selection prompt.
type PromptMultiSelectResult struct {
// Values contains the text of selected options.
Values []string
// Indices contains the zero-based indices of selected options.
Indices []int
// Cancelled is true if the user dismissed the prompt (ESC) or
// the prompt was unavailable (non-interactive mode).
Cancelled bool
}
// ---------------------------------------------------------------------------
// Header/Footer types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
+537
View File
@@ -0,0 +1,537 @@
package extensions
import (
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// InstallScope defines where a package should be installed.
type InstallScope string
const (
ScopeGlobal InstallScope = "global"
ScopeProject InstallScope = "project"
)
// GitSource represents a parsed git repository URL.
type GitSource struct {
Repo string // Clone URL (e.g., https://github.com/user/repo.git)
Host string // Host (e.g., github.com)
Path string // Path (e.g., user/repo)
Ref string // Optional ref (tag, branch, commit)
Pinned bool // Whether a specific ref is pinned
}
// String returns the canonical string representation.
func (g GitSource) String() string {
if g.Pinned {
return fmt.Sprintf("git:%s/%s@%s", g.Host, g.Path, g.Ref)
}
return fmt.Sprintf("git:%s/%s", g.Host, g.Path)
}
// Identity returns a normalized identity string for deduplication.
func (g GitSource) Identity() string {
return fmt.Sprintf("%s/%s", g.Host, g.Path)
}
// ParseGitSource parses a git source string into a GitSource.
// Supports formats like:
// - git:github.com/user/repo
// - git:github.com/user/repo@v1.0.0
// - https://github.com/user/repo
// - https://github.com/user/repo@v1.0.0
// - ssh://git@github.com/user/repo
// - git@github.com:user/repo
// - github.com/user/repo (shorthand, defaults to https)
func ParseGitSource(source string) (*GitSource, error) {
source = strings.TrimSpace(source)
// Check for @ref suffix
ref := ""
pinned := false
if atIdx := strings.LastIndex(source, "@"); atIdx > 0 {
// Make sure it's not part of the protocol (e.g., @ in ssh://git@)
after := source[atIdx+1:]
if !strings.Contains(after, "/") && !strings.Contains(after, ":") {
ref = after
pinned = true
source = source[:atIdx]
}
}
// Handle git: prefix
source, _ = strings.CutPrefix(source, "git:")
var repo, host, path string
// Handle explicit URLs
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
u, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
host = u.Host
path = strings.TrimPrefix(u.Path, "/")
path, _ = strings.CutSuffix(path, ".git")
repo = source
if !strings.HasSuffix(repo, ".git") {
repo += ".git"
}
} else if strings.HasPrefix(source, "ssh://") {
u, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid SSH URL: %w", err)
}
host = u.Host
path = strings.TrimPrefix(u.Path, "/")
path, _ = strings.CutSuffix(path, ".git")
repo = source
} else if strings.HasPrefix(source, "git@") {
// SSH shorthand: git@github.com:user/repo
parts := strings.SplitN(source, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid SSH shorthand format")
}
host = strings.TrimPrefix(parts[0], "git@")
path = parts[1]
path, _ = strings.CutSuffix(path, ".git")
repo = source
} else if strings.HasPrefix(source, "github.com/") || strings.HasPrefix(source, "gitlab.com/") || strings.HasPrefix(source, "bitbucket.org/") {
// Shorthand for known hosts: host/path
parts := strings.SplitN(source, "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid shorthand format, expected host/path")
}
host = parts[0]
path = parts[1]
repo = fmt.Sprintf("https://%s/%s.git", host, path)
} else if strings.HasPrefix(source, ".") || strings.HasPrefix(source, "/") || strings.HasPrefix(source, "~") {
// Local paths are not supported
return nil, fmt.Errorf("local paths not supported, use explicit extension path with -e flag")
} else {
// Generic shorthand: host/user/repo (3+ path segments)
parts := strings.Split(source, "/")
if len(parts) >= 3 {
host = parts[0]
path = strings.Join(parts[1:], "/")
repo = fmt.Sprintf("https://%s/%s.git", host, path)
} else {
return nil, fmt.Errorf("unrecognized source format: %s", source)
}
}
return &GitSource{
Repo: repo,
Host: host,
Path: path,
Ref: ref,
Pinned: pinned,
}, nil
}
// Installer handles installing, updating, and removing git-based extensions.
type Installer struct {
// Global packages root: $XDG_DATA_HOME/kit/git/ (default ~/.local/share/kit/git/)
globalGitRoot string
// Project packages root: .kit/git/
projectGitRoot string
}
// NewInstaller creates a new Installer.
func NewInstaller(projectDir string) *Installer {
return &Installer{
globalGitRoot: globalGitInstallRoot(),
projectGitRoot: filepath.Join(projectDir, ".kit", "git"),
}
}
// Install clones a git repository to the appropriate scope.
func (i *Installer) Install(source *GitSource, scope InstallScope) error {
targetDir := i.getInstallPath(source, scope)
// Check if already installed
if _, err := os.Stat(targetDir); err == nil {
return fmt.Errorf("extension already installed at %s", targetDir)
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetDir), 0755); err != nil {
return fmt.Errorf("creating parent directory: %w", err)
}
// Clone the repository
cmd := exec.Command("git", "clone", "--depth=1", source.Repo, targetDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git clone failed: %w\n%s", err, string(output))
}
// Checkout specific ref if pinned
if source.Pinned && source.Ref != "" {
checkoutCmd := exec.Command("git", "checkout", source.Ref)
checkoutCmd.Dir = targetDir
if output, err := checkoutCmd.CombinedOutput(); err != nil {
// Clean up on failed checkout
_ = os.RemoveAll(targetDir)
return fmt.Errorf("git checkout failed: %w\n%s", err, string(output))
}
}
// Validate that the package contains valid extensions
if err := i.validatePackage(targetDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("validation failed: %w", err)
}
// Add to manifest
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: source.Ref,
Pinned: source.Pinned,
Scope: scope,
Installed: time.Now(),
}
if err := i.addToManifest(entry, scope); err != nil {
// Don't fail the install, just log the error
// The package is installed, manifest update failed
return fmt.Errorf("installed but failed to update manifest: %w", err)
}
return nil
}
// Uninstall removes an installed package.
func (i *Installer) Uninstall(source *GitSource, scope InstallScope) error {
targetDir := i.getInstallPath(source, scope)
if _, err := os.Stat(targetDir); err != nil {
return fmt.Errorf("extension not found at %s", targetDir)
}
// Remove the directory
if err := os.RemoveAll(targetDir); err != nil {
return fmt.Errorf("removing extension directory: %w", err)
}
// Remove from manifest
if err := i.removeFromManifest(source.Identity(), scope); err != nil {
return fmt.Errorf("removed but failed to update manifest: %w", err)
}
return nil
}
// Update fetches and resets a git package to the latest.
// For pinned packages, this does nothing.
func (i *Installer) Update(source *GitSource, scope InstallScope) error {
if source.Pinned {
return nil // Don't update pinned packages
}
targetDir := i.getInstallPath(source, scope)
if _, err := os.Stat(targetDir); err != nil {
return i.Install(source, scope)
}
// Fetch latest
fetchCmd := exec.Command("git", "fetch", "--prune", "origin")
fetchCmd.Dir = targetDir
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch failed: %w\n%s", err, string(output))
}
// Reset to tracking branch or origin/HEAD
resetCmd := exec.Command("git", "reset", "--hard", "@{upstream}")
resetCmd.Dir = targetDir
if _, err := resetCmd.CombinedOutput(); err != nil {
// Try alternative: set HEAD and reset to origin/HEAD
_ = exec.Command("git", "remote", "set-head", "origin", "-a").Run()
resetCmd = exec.Command("git", "reset", "--hard", "origin/HEAD")
resetCmd.Dir = targetDir
if output, err := resetCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git reset failed: %w\n%s", err, string(output))
}
}
// Clean untracked files
cleanCmd := exec.Command("git", "clean", "-fdx")
cleanCmd.Dir = targetDir
_ = cleanCmd.Run() // Ignore errors - clean is best effort
// Update manifest timestamp
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: "",
Pinned: false,
Scope: scope,
Installed: time.Now(),
Updated: time.Now(),
}
_ = i.addToManifest(entry, scope) // Best effort - don't fail update if manifest fails
return nil
}
// getInstallPath returns the target directory for a source.
func (i *Installer) getInstallPath(source *GitSource, scope InstallScope) string {
root := i.globalGitRoot
if scope == ScopeProject {
root = i.projectGitRoot
}
return filepath.Join(root, source.Host, source.Path)
}
// validatePackage checks that the cloned repo contains valid .go extension files.
func (i *Installer) validatePackage(dir string) error {
// Find all .go files in the directory
var goFiles []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") {
goFiles = append(goFiles, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walking directory: %w", err)
}
if len(goFiles) == 0 {
return fmt.Errorf("no .go files found in package")
}
// Try to load the first .go file to validate it's a valid extension
// We don't fail if validation fails - the extension might be fine but
// have dependencies that aren't available during install time
_, err = loadSingleExtension(goFiles[0])
if err != nil {
// Log but don't fail - the extension might need runtime deps
// User can use `kit extensions validate` to check later
return nil
}
return nil
}
// addToManifest adds an entry to the manifest.
func (i *Installer) addToManifest(entry ManifestEntry, scope InstallScope) error {
manifest, err := i.loadManifest(scope)
if err != nil {
return err
}
// Remove any existing entry with same identity
identity := entry.Host + "/" + entry.Path
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Host+"/"+p.Path != identity {
filtered = append(filtered, p)
}
}
filtered = append(filtered, entry)
manifest.Packages = filtered
return i.saveManifest(manifest, scope)
}
// removeFromManifest removes an entry from the manifest by identity.
func (i *Installer) removeFromManifest(identity string, scope InstallScope) error {
manifest, err := i.loadManifest(scope)
if err != nil {
return err
}
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Host+"/"+p.Path != identity {
filtered = append(filtered, p)
}
}
manifest.Packages = filtered
return i.saveManifest(manifest, scope)
}
// loadManifest loads the manifest for the given scope.
func (i *Installer) loadManifest(scope InstallScope) (*Manifest, error) {
path := i.manifestPath(scope)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Manifest{Packages: []ManifestEntry{}}, nil
}
return nil, err
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &manifest, nil
}
// saveManifest saves the manifest for the given scope.
func (i *Installer) saveManifest(manifest *Manifest, scope InstallScope) error {
path := i.manifestPath(scope)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// manifestPath returns the path to the manifest file.
func (i *Installer) manifestPath(scope InstallScope) string {
if scope == ScopeProject {
return filepath.Join(i.projectGitRoot, "packages.json")
}
return filepath.Join(i.globalGitRoot, "packages.json")
}
// globalGitInstallRoot returns the global git install root.
func globalGitInstallRoot() string {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "kit", "git")
}
// GetInstalledPackages returns all installed packages from both scopes.
func (i *Installer) GetInstalledPackages() ([]ManifestEntry, error) {
var all []ManifestEntry
global, err := i.loadManifest(ScopeGlobal)
if err != nil {
return nil, fmt.Errorf("loading global manifest: %w", err)
}
all = append(all, global.Packages...)
project, err := i.loadManifest(ScopeProject)
if err != nil {
return nil, fmt.Errorf("loading project manifest: %w", err)
}
all = append(all, project.Packages...)
return all, nil
}
// IsInstalled checks if a package is installed in either scope.
// Returns (scope, true) if installed, ("", false) otherwise.
func (i *Installer) IsInstalled(source *GitSource) (InstallScope, bool) {
globalPath := i.getInstallPath(source, ScopeGlobal)
if _, err := os.Stat(globalPath); err == nil {
return ScopeGlobal, true
}
projectPath := i.getInstallPath(source, ScopeProject)
if _, err := os.Stat(projectPath); err == nil {
return ScopeProject, true
}
return "", false
}
// PreviewExtensions clones a repo to a temporary directory and scans for extensions.
// Returns the preview list and the temp directory path (caller should clean up).
func (i *Installer) PreviewExtensions(source *GitSource) ([]ExtensionPreview, string, error) {
// Create temp directory
tempDir, err := os.MkdirTemp("", "kit-install-preview-*")
if err != nil {
return nil, "", fmt.Errorf("creating temp directory: %w", err)
}
// Clone to temp
cloneDir := filepath.Join(tempDir, "repo")
cmd := exec.Command("git", "clone", "--depth=1", source.Repo, cloneDir)
if output, err := cmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("git clone failed: %w\n%s", err, string(output))
}
// Checkout specific ref if pinned
if source.Pinned && source.Ref != "" {
checkoutCmd := exec.Command("git", "checkout", source.Ref)
checkoutCmd.Dir = cloneDir
if output, err := checkoutCmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("git checkout failed: %w\n%s", err, string(output))
}
}
// Scan for extensions
previews, err := ScanForExtensions(cloneDir)
if err != nil {
_ = os.RemoveAll(tempDir)
return nil, "", fmt.Errorf("scanning extensions: %w", err)
}
return previews, tempDir, nil
}
// InstallWithInclude clones a repo and installs only the specified extensions.
// includePaths are relative paths like "./git/main.go" - if empty, installs all.
func (i *Installer) InstallWithInclude(source *GitSource, scope InstallScope, includePaths []string) error {
// First, do a regular install
if err := i.Install(source, scope); err != nil {
return err
}
// If specific includes were requested, update the manifest
if len(includePaths) > 0 {
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: source.Ref,
Pinned: source.Pinned,
Scope: scope,
Include: includePaths,
}
if err := addEntryToManifest(entry, scope); err != nil {
return fmt.Errorf("updating manifest with includes: %w", err)
}
}
return nil
}
// CleanupTempDir removes a temporary directory used for preview.
func CleanupTempDir(tempDir string) {
if tempDir != "" {
_ = os.RemoveAll(tempDir)
}
}
+392
View File
@@ -0,0 +1,392 @@
package extensions
import (
"os"
"path/filepath"
"testing"
)
func TestParseGitSource(t *testing.T) {
tests := []struct {
name string
source string
wantRepo string
wantHost string
wantPath string
wantRef string
wantPinned bool
wantErr bool
}{
{
name: "github shorthand",
source: "github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "github shorthand with version",
source: "github.com/user/repo@v1.0.0",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "v1.0.0",
wantPinned: true,
},
{
name: "git prefix shorthand",
source: "git:github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "https URL",
source: "https://github.com/user/repo",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "https URL with .git suffix",
source: "https://github.com/user/repo.git",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "ssh shorthand",
source: "git@github.com:user/repo",
wantRepo: "git@github.com:user/repo",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "ssh URL",
source: "ssh://git@github.com/user/repo",
wantRepo: "ssh://git@github.com/user/repo",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "gitlab shorthand",
source: "gitlab.com/user/repo",
wantRepo: "https://gitlab.com/user/repo.git",
wantHost: "gitlab.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "bitbucket shorthand",
source: "bitbucket.org/user/repo",
wantRepo: "https://bitbucket.org/user/repo.git",
wantHost: "bitbucket.org",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "generic host",
source: "gitea.example.com/user/repo",
wantRepo: "https://gitea.example.com/user/repo.git",
wantHost: "gitea.example.com",
wantPath: "user/repo",
wantRef: "",
wantPinned: false,
},
{
name: "with branch ref",
source: "github.com/user/repo@main",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "main",
wantPinned: true,
},
{
name: "with commit ref",
source: "github.com/user/repo@abc1234",
wantRepo: "https://github.com/user/repo.git",
wantHost: "github.com",
wantPath: "user/repo",
wantRef: "abc1234",
wantPinned: true,
},
{
name: "local path should error",
source: "./local/path",
wantErr: true,
},
{
name: "absolute path should error",
source: "/absolute/path",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseGitSource(tt.source)
if (err != nil) != tt.wantErr {
t.Errorf("ParseGitSource() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
if got.Repo != tt.wantRepo {
t.Errorf("ParseGitSource() Repo = %v, want %v", got.Repo, tt.wantRepo)
}
if got.Host != tt.wantHost {
t.Errorf("ParseGitSource() Host = %v, want %v", got.Host, tt.wantHost)
}
if got.Path != tt.wantPath {
t.Errorf("ParseGitSource() Path = %v, want %v", got.Path, tt.wantPath)
}
if got.Ref != tt.wantRef {
t.Errorf("ParseGitSource() Ref = %v, want %v", got.Ref, tt.wantRef)
}
if got.Pinned != tt.wantPinned {
t.Errorf("ParseGitSource() Pinned = %v, want %v", got.Pinned, tt.wantPinned)
}
})
}
}
func TestGitSourceIdentity(t *testing.T) {
source := &GitSource{
Host: "github.com",
Path: "user/repo",
}
if got := source.Identity(); got != "github.com/user/repo" {
t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo")
}
}
func TestGitSourceString(t *testing.T) {
tests := []struct {
name string
source GitSource
want string
}{
{
name: "unpinned",
source: GitSource{
Host: "github.com",
Path: "user/repo",
Pinned: false,
},
want: "git:github.com/user/repo",
},
{
name: "pinned",
source: GitSource{
Host: "github.com",
Path: "user/repo",
Ref: "v1.0.0",
Pinned: true,
},
want: "git:github.com/user/repo@v1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.source.String(); got != tt.want {
t.Errorf("String() = %v, want %v", got, tt.want)
}
})
}
}
func TestInstallerGetInstallPath(t *testing.T) {
tempDir := t.TempDir()
installer := NewInstaller(tempDir)
source := &GitSource{
Host: "github.com",
Path: "user/repo",
}
// Test global scope
globalPath := installer.getInstallPath(source, ScopeGlobal)
if !filepath.IsAbs(globalPath) {
t.Error("Global install path should be absolute")
}
// Test project scope
projectPath := installer.getInstallPath(source, ScopeProject)
expectedProjectPath := filepath.Join(tempDir, ".kit", "git", "github.com", "user", "repo")
if projectPath != expectedProjectPath {
t.Errorf("Project path = %v, want %v", projectPath, expectedProjectPath)
}
}
func TestManifestEntryIdentity(t *testing.T) {
entry := ManifestEntry{
Host: "github.com",
Path: "user/repo",
}
if got := entry.Identity(); got != "github.com/user/repo" {
t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo")
}
}
func TestLoadAndSaveManifest(t *testing.T) {
tempDir := t.TempDir()
manifestPath := filepath.Join(tempDir, "packages.json")
// Test loading non-existent manifest
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected empty packages, got %d", len(manifest.Packages))
}
// Create a manifest
manifest = &Manifest{
Packages: []ManifestEntry{
{
Source: "git:github.com/user/repo",
Repo: "https://github.com/user/repo.git",
Host: "github.com",
Path: "user/repo",
Pinned: false,
Scope: ScopeGlobal,
},
},
}
// Save it
err = saveManifestToPath(manifest, manifestPath)
if err != nil {
t.Fatalf("saveManifestToPath() error = %v", err)
}
// Load it back
loaded, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(loaded.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(loaded.Packages))
}
if loaded.Packages[0].Host != "github.com" {
t.Errorf("Expected host github.com, got %s", loaded.Packages[0].Host)
}
}
func TestAddAndRemoveFromManifest(t *testing.T) {
tempDir := t.TempDir()
// Set up environment for manifest path
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// The manifest path when XDG_DATA_HOME is set
manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json")
// Add an entry
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Verify it was added
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(manifest.Packages))
}
// Remove it
err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal)
if err != nil {
t.Fatalf("removeEntryFromManifest() error = %v", err)
}
// Verify it was removed
manifest, err = loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected 0 packages, got %d", len(manifest.Packages))
}
}
func TestFindInManifest(t *testing.T) {
tempDir := t.TempDir()
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// Add an entry to global manifest
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Find it
found, scope, err := FindInManifest("github.com/user/repo")
if err != nil {
t.Fatalf("FindInManifest() error = %v", err)
}
if found == nil {
t.Fatal("Expected to find entry, got nil")
}
if scope != ScopeGlobal {
t.Errorf("Expected scope global, got %s", scope)
}
// Try to find non-existent
notFound, _, err := FindInManifest("github.com/other/repo")
if err != nil {
t.Fatalf("FindInManifest() error = %v", err)
}
if notFound != nil {
t.Error("Expected nil for non-existent entry")
}
}
+225
View File
@@ -71,12 +71,24 @@ func discoverExtensionPaths(extraPaths []string) []string {
add(p)
}
// Global installed git packages: $XDG_DATA_HOME/kit/git/
globalGitDir := globalGitInstallRoot()
for _, p := range findExtensionsInGitPackages(globalGitDir) {
add(p)
}
// Project-local extensions: .kit/extensions/
localDir := filepath.Join(".kit", "extensions")
for _, p := range findExtensionsInDir(localDir) {
add(p)
}
// Project-local installed git packages: .kit/git/
projectGitDir := filepath.Join(".kit", "git")
for _, p := range findExtensionsInGitPackages(projectGitDir) {
add(p)
}
// Explicit paths (highest precedence)
for _, p := range extraPaths {
info, err := os.Stat(p)
@@ -123,6 +135,219 @@ func findExtensionsInDir(dir string) []string {
return results
}
// findExtensionsInRepo scans a git repository for extensions using opinionated conventions.
// Extensions are ONLY recognized in:
// 1. Root-level *.go files
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
// 3. Files in any top-level ext/ directory
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
//
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
func findExtensionsInRepo(repoPath string) []string {
var results []string
multiFileDirs := make(map[string]bool)
_ = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(repoPath, path)
relPath = filepath.ToSlash(relPath)
// Skip directories we know don't contain extensions
if info.IsDir() {
switch info.Name() {
case ".git", ".github", "node_modules", "vendor", "dist", "build":
return filepath.SkipDir
}
// Skip internal code directories
if strings.HasPrefix(relPath, "internal/") ||
strings.HasPrefix(relPath, "cmd/") ||
strings.HasPrefix(relPath, "pkg/") ||
strings.HasPrefix(relPath, "test/") ||
strings.HasPrefix(relPath, "tests/") {
return filepath.SkipDir
}
// Root directory - scan it
if relPath == "." {
return nil
}
base := info.Name()
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
if !isExtDir && !isExamplesSubdir {
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if relPath == base { // Top-level directory
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
}
return filepath.SkipDir
}
// Check for main.go
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
return nil
}
// It's a file
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
if info.Name() == "main.go" {
return nil
}
parentDir := filepath.Dir(relPath)
if parentDir == "." {
// Root-level .go file - valid extension
results = append(results, path)
return nil
}
// Must be in valid extension directory
isValidExtDir := false
if strings.HasPrefix(parentDir, "examples/extensions/") ||
parentDir == "examples/extensions" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
parentDir == "examples/ext" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "ext/") ||
parentDir == "ext" {
isValidExtDir = true
} else if strings.Contains(parentDir, "-extensions/") ||
strings.HasSuffix(parentDir, "-extensions") {
isValidExtDir = true
} else if strings.Contains(parentDir, "-ext/") ||
strings.HasSuffix(parentDir, "-ext") {
isValidExtDir = true
}
if !isValidExtDir {
return nil
}
results = append(results, path)
return nil
})
return results
}
// Each git package is stored at <gitRoot>/<host>/<owner>/<repo>/ and can contain
// .go files or a main.go in subdirectories.
// If a package has a manifest with Include field, only those paths are loaded.
func findExtensionsInGitPackages(gitRoot string) []string {
info, err := os.Stat(gitRoot)
if err != nil || !info.IsDir() {
return nil
}
var results []string
// Load the manifest if it exists
manifestPath := filepath.Join(gitRoot, "packages.json")
manifest, _ := loadManifestFromPath(manifestPath)
// Build a map of package identity -> include list
includeMap := make(map[string][]string)
if manifest != nil {
for _, entry := range manifest.Packages {
if len(entry.Include) > 0 {
identity := fmt.Sprintf("%s/%s", entry.Host, entry.Path)
includeMap[identity] = entry.Include
}
}
}
// Walk through host directories (e.g., github.com/)
hosts, err := os.ReadDir(gitRoot)
if err != nil {
return nil
}
for _, host := range hosts {
if !host.IsDir() {
continue
}
hostPath := filepath.Join(gitRoot, host.Name())
// Walk through owner directories (e.g., github.com/user/)
owners, err := os.ReadDir(hostPath)
if err != nil {
continue
}
for _, owner := range owners {
if !owner.IsDir() {
continue
}
ownerPath := filepath.Join(hostPath, owner.Name())
// Walk through repo directories (e.g., github.com/user/repo/)
repos, err := os.ReadDir(ownerPath)
if err != nil {
continue
}
for _, repo := range repos {
if !repo.IsDir() {
continue
}
repoPath := filepath.Join(ownerPath, repo.Name())
// Check if there's an include filter for this package
identity := fmt.Sprintf("%s/%s/%s", host.Name(), owner.Name(), repo.Name())
includes, hasFilter := includeMap[identity]
if hasFilter {
// Only include specific paths
for _, include := range includes {
// Convert relative path to absolute
include = strings.TrimPrefix(include, "./")
fullPath := filepath.Join(repoPath, filepath.FromSlash(include))
if _, err := os.Stat(fullPath); err == nil {
results = append(results, fullPath)
}
}
} else {
// Find all extensions within this repo using convention-based scanning
results = append(results, findExtensionsInRepo(repoPath)...)
}
}
}
}
return results
}
// globalExtensionsDir returns the global extensions directory, respecting
// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/extensions.
func globalExtensionsDir() string {
+9
View File
@@ -304,6 +304,15 @@ func Init(api ext.API) {
func TestLoadExtensions_SkipsBadFiles(t *testing.T) {
dir := t.TempDir()
// Isolate from host environment so globally-installed extensions
// are not discovered alongside the test fixtures.
isolated := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, "config"))
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, "data"))
origWd, _ := os.Getwd()
_ = os.Chdir(isolated)
t.Cleanup(func() { _ = os.Chdir(origWd) })
// Good extension
good := `package main
import "kit/ext"
+398
View File
@@ -0,0 +1,398 @@
package extensions
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Manifest tracks installed git packages.
type Manifest struct {
Packages []ManifestEntry `json:"packages"`
}
// ManifestEntry represents a single installed package.
type ManifestEntry struct {
// Source is the canonical string representation (e.g., "git:github.com/user/repo@v1.0.0")
Source string `json:"source"`
// Repo is the clone URL
Repo string `json:"repo"`
// Host is the git host (e.g., github.com)
Host string `json:"host"`
// Path is the path on the host (e.g., user/repo)
Path string `json:"path"`
// Ref is the optional pinned ref (tag/branch/commit)
Ref string `json:"ref,omitempty"`
// Pinned indicates if the ref is pinned
Pinned bool `json:"pinned"`
// Scope is where the package is installed (global or project)
Scope InstallScope `json:"scope"`
// Installed is when the package was first installed
Installed time.Time `json:"installed"`
// Updated is when the package was last updated (only for unpinned, zero time means never updated)
Updated time.Time `json:"updated,omitzero"`
// Include is a list of relative paths to extensions that should be loaded.
// If empty, all extensions in the package are loaded.
// Paths are relative to the package root (e.g., "./git/main.go", "./weather.go")
Include []string `json:"include,omitempty"`
}
// Identity returns the normalized identity for deduplication.
func (e ManifestEntry) Identity() string {
return fmt.Sprintf("%s/%s", e.Host, e.Path)
}
// loadManifest loads the manifest from the given scope.
func loadManifestFromScope(scope InstallScope) (*Manifest, error) {
path := manifestPathForScope(scope)
return loadManifestFromPath(path)
}
// loadManifestFromPath loads a manifest from a specific file path.
func loadManifestFromPath(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Manifest{Packages: []ManifestEntry{}}, nil
}
return nil, fmt.Errorf("reading manifest: %w", err)
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &manifest, nil
}
// saveManifestToScope saves the manifest to the given scope.
func saveManifestToScope(manifest *Manifest, scope InstallScope) error {
path := manifestPathForScope(scope)
return saveManifestToPath(manifest, path)
}
// saveManifestToPath saves a manifest to a specific file path.
func saveManifestToPath(manifest *Manifest, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// manifestPathForScope returns the manifest file path for a scope.
func manifestPathForScope(scope InstallScope) string {
if scope == ScopeProject {
return filepath.Join(".kit", "git", "packages.json")
}
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "kit", "git", "packages.json")
}
// GetGlobalManifest returns the global manifest.
func GetGlobalManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeGlobal)
}
// GetProjectManifest returns the project manifest.
func GetProjectManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeProject)
}
// addEntryToManifest adds or replaces an entry in the manifest for a scope.
func addEntryToManifest(entry ManifestEntry, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
// Remove any existing entry with same identity
identity := entry.Identity()
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
filtered = append(filtered, entry)
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// removeEntryFromManifest removes an entry by identity from the manifest for a scope.
func removeEntryFromManifest(identity string, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// FindInManifest finds an entry by identity in either global or project manifest.
// Returns the entry and its scope, or nil if not found.
func FindInManifest(identity string) (*ManifestEntry, InstallScope, error) {
global, err := loadManifestFromScope(ScopeGlobal)
if err != nil {
return nil, "", fmt.Errorf("loading global manifest: %w", err)
}
for _, p := range global.Packages {
if p.Identity() == identity {
return &p, ScopeGlobal, nil
}
}
project, err := loadManifestFromScope(ScopeProject)
if err != nil {
return nil, "", fmt.Errorf("loading project manifest: %w", err)
}
for _, p := range project.Packages {
if p.Identity() == identity {
return &p, ScopeProject, nil
}
}
return nil, "", nil
}
// ExtensionPreview represents a discovered extension in a package before installation.
type ExtensionPreview struct {
// Path is the relative path from the package root (e.g., "./git/main.go")
Path string `json:"path"`
// Name is a display name for the extension (derived from path or metadata)
Name string `json:"name"`
// Description is an optional description (could be extracted from comments)
Description string `json:"description,omitempty"`
// IsMain indicates if this is a main.go in a subdirectory
IsMain bool `json:"is_main"`
}
// ScanForExtensions discovers all extensions in a directory using opinionated conventions.
// Extensions are ONLY recognized in these specific locations:
// 1. Root-level *.go files
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
// 3. Files in any top-level ext/ directory
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
//
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return nil, fmt.Errorf("not a directory: %s", dir)
}
var previews []ExtensionPreview
multiFileDirs := make(map[string]bool)
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(dir, path)
relPath = filepath.ToSlash(relPath)
// Skip directories we know don't contain extensions
if info.IsDir() {
// Never scan these directories
switch info.Name() {
case ".git", ".github", "node_modules", "vendor", "dist", "build":
return filepath.SkipDir
}
// Skip internal code directories
if strings.HasPrefix(relPath, "internal/") ||
strings.HasPrefix(relPath, "cmd/") ||
strings.HasPrefix(relPath, "pkg/") ||
strings.HasPrefix(relPath, "test/") ||
strings.HasPrefix(relPath, "tests/") {
return filepath.SkipDir
}
// Root directory - scan it
if relPath == "." {
return nil
}
// Check if this directory is an extension location by name
// Pattern: must be named "extensions", "ext", or end with those
base := info.Name()
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
// Or check if it's a subdirectory of examples/ that might contain extensions
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
if !isExtDir && !isExamplesSubdir {
// Check for main.go before skipping
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
// This is a package with main.go at root level
if relPath == base { // Top-level directory
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
// Inside a valid extensions directory
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
}
// Not an extension location
return filepath.SkipDir
}
// Check for main.go in this directory
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
// Scan this extensions directory
return nil
}
// It's a file - check if it's a valid extension
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
if info.Name() == "main.go" {
return nil // Already handled above
}
// Check if parent is a valid extension location
parentDir := filepath.Dir(relPath)
if parentDir == "." {
// Root-level .go file - valid extension
previews = append(previews, ExtensionPreview{
Path: "./" + relPath,
Name: deriveExtensionName(relPath, false),
IsMain: false,
})
return nil
}
// Check if we're in a valid extension directory
// Valid locations are:
// - examples/extensions/*
// - examples/ext/*
// - ext/* (top-level)
// - Any *-extensions/* or *-ext/* directory
isValidExtDir := false
if strings.HasPrefix(parentDir, "examples/extensions/") ||
parentDir == "examples/extensions" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
parentDir == "examples/ext" {
isValidExtDir = true
} else if strings.HasPrefix(parentDir, "ext/") ||
parentDir == "ext" {
isValidExtDir = true
} else if strings.Contains(parentDir, "-extensions/") ||
strings.HasSuffix(parentDir, "-extensions") {
isValidExtDir = true
} else if strings.Contains(parentDir, "-ext/") ||
strings.HasSuffix(parentDir, "-ext") {
isValidExtDir = true
}
if !isValidExtDir {
return nil
}
previews = append(previews, ExtensionPreview{
Path: "./" + relPath,
Name: deriveExtensionName(relPath, false),
IsMain: false,
})
return nil
})
if err != nil {
return nil, err
}
return previews, nil
}
// deriveExtensionName creates a display name from a file path.
func deriveExtensionName(relPath string, isMain bool) string {
// Convert path to a readable name
// e.g., "git/main.go" -> "Git Extension"
// e.g., "weather.go" -> "Weather"
dir := filepath.Dir(relPath)
base := filepath.Base(relPath)
if isMain && dir != "." {
// Use immediate parent directory name for main.go files
name := filepath.Base(dir)
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, "-", " ")
return cases.Title(language.English).String(name) + " Extension"
}
// Use filename without extension
name := strings.TrimSuffix(base, ".go")
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, "-", " ")
return cases.Title(language.English).String(name)
}
+8 -6
View File
@@ -90,12 +90,14 @@ func Symbols() interp.Exports {
"EditorConfig": reflect.ValueOf((*EditorConfig)(nil)),
// Prompt types
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
"PromptMultiSelectConfig": reflect.ValueOf((*PromptMultiSelectConfig)(nil)),
"PromptMultiSelectResult": reflect.ValueOf((*PromptMultiSelectResult)(nil)),
// Context filtering types
"ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),
+40 -2
View File
@@ -560,9 +560,10 @@ func TestStreamComponent_SpinnerTick_AdvancesFrame(t *testing.T) {
// Start spinning first.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
initialFrame := c.spinnerFrame
gen := c.spinnerGeneration
// Send a tick.
_, cmd := c.Update(streamSpinnerTickMsg{})
// Send a tick with the current generation.
_, cmd := c.Update(streamSpinnerTickMsg{generation: gen})
if c.spinnerFrame != initialFrame+1 {
t.Fatalf("expected spinnerFrame=%d, got %d", initialFrame+1, c.spinnerFrame)
@@ -583,3 +584,40 @@ func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning(t *testing.T)
t.Fatal("expected no tick reschedule when not spinning")
}
}
// TestStreamComponent_StaleTick_Discarded verifies that a tick from a previous
// spinner generation is silently discarded, preventing duplicate concurrent
// tick loops that would double the animation speed.
func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
c := newTestStream()
// Start spinner → generation 1.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
staleGen := c.spinnerGeneration
// Stop spinner → generation bumped to 2.
c = sendStreamMsg(c, app.SpinnerEvent{Show: false})
// Restart spinner → generation bumped to 3.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
currentGen := c.spinnerGeneration
frameBefore := c.spinnerFrame
// Simulate a stale tick from the first spinner session arriving.
_, cmd := c.Update(streamSpinnerTickMsg{generation: staleGen})
if c.spinnerFrame != frameBefore {
t.Fatalf("stale tick should not advance frame: expected %d, got %d", frameBefore, c.spinnerFrame)
}
if cmd != nil {
t.Fatal("stale tick should not reschedule")
}
// A tick from the current generation should still work.
_, cmd = c.Update(streamSpinnerTickMsg{generation: currentGen})
if c.spinnerFrame != frameBefore+1 {
t.Fatalf("current-gen tick should advance frame: expected %d, got %d", frameBefore+1, c.spinnerFrame)
}
if cmd == nil {
t.Fatal("current-gen tick should reschedule")
}
}
+61 -13
View File
@@ -419,7 +419,18 @@ func (s *InputComponent) View() tea.View {
MarginTop(1).
PaddingLeft(3)
hint := "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
// Adapt hint text to available width (accounting for left padding of 3).
var hint string
availableHintWidth := s.width - 3
if availableHintWidth >= 67 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
} else if availableHintWidth >= 40 {
hint = "↵ submit • ctrl+j newline • ctrl+v image"
} else if availableHintWidth >= 20 {
hint = "↵ submit • ctrl+j"
} else {
hint = "↵ submit"
}
view.WriteString("\n")
view.WriteString(helpStyle.Render(hint))
}
@@ -429,13 +440,17 @@ func (s *InputComponent) View() tea.View {
// renderPopup renders the autocomplete popup for slash command suggestions.
func (s *InputComponent) renderPopup() string {
popupWidth := max(s.width-4, 20)
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("236")).
Padding(1, 2).
Width(s.width - 4).
Width(popupWidth).
MarginLeft(0)
// Inner content width: popup minus border (2) and horizontal padding (4).
innerWidth := max(popupWidth-6, 10)
var items []string
visibleItems := min(len(s.filtered), s.popupHeight)
@@ -466,28 +481,51 @@ func (s *InputComponent) renderPopup() string {
if s.fileMode {
// File mode: use full width for the path, show description
// (e.g. "directory") inline after a gap.
maxNameLen := s.width - 24
maxNameLen := max(innerWidth-16, 8)
displayName := sc.Name
if len(displayName) > maxNameLen && maxNameLen > 3 {
displayName = displayName[:maxNameLen-3] + "..."
}
name := nameStyle.Render(displayName)
if sc.Description != "" {
if sc.Description != "" && innerWidth > 30 {
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
} else {
items = append(items, indicator+name)
}
} else {
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
// Line layout: indicator(2) + name(nameWidth-2 visual) + desc.
if innerWidth < 20 {
// Very narrow: show truncated name only, no fixed column.
displayName := sc.Name
maxName := max(innerWidth-2, 3)
if len(displayName) > maxName {
displayName = displayName[:maxName-1] + "…"
}
items = append(items, indicator+nameStyle.Render(displayName))
} else {
nameWidth := 15
if innerWidth < 25 {
nameWidth = max(innerWidth*2/5+1, 8)
}
maxNameChars := nameWidth - 2
displayName := sc.Name
if len(displayName) > maxNameChars {
displayName = displayName[:maxNameChars-1] + "…"
}
name := nameStyle.Width(maxNameChars).Render(displayName)
desc := sc.Description
maxDescLen := s.width - nameWidth - 14
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
// Description gets remaining space.
maxDescLen := max(innerWidth-nameWidth, 0)
desc := sc.Description
if maxDescLen < 4 {
items = append(items, indicator+name)
} else {
if len(desc) > maxDescLen {
desc = desc[:maxDescLen-3] + "..."
}
items = append(items, indicator+name+descStyle.Render(desc))
}
}
items = append(items, indicator+name+descStyle.Render(desc))
}
}
@@ -499,8 +537,18 @@ func (s *InputComponent) renderPopup() string {
}
content := strings.Join(items, "\n")
// Adapt footer text to available width.
var footerText string
if innerWidth >= 50 {
footerText = "↑↓ navigate • tab complete • ↵ select • esc dismiss"
} else if innerWidth >= 30 {
footerText = "↑↓ nav • tab • ↵ select • esc"
} else {
footerText = "↑↓ tab ↵ esc"
}
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Italic(true).
Render("↑↓ navigate • tab complete • ↵ select • esc dismiss")
Render(footerText)
return popupStyle.Render(content + "\n\n" + footer)
}
+242 -132
View File
@@ -402,6 +402,14 @@ type AppModel struct {
// flushed first, preserving chronological order.
pendingUserPrints []string
// scrollbackBuf collects rendered content during a single Update() call.
// All print helpers append here instead of returning tea.Println directly.
// The buffer is drained into a single atomic tea.Println at the end of
// each Update call via drainScrollback(). If the stream component has
// unflushed content, it is automatically prepended so that new messages
// always appear below the previous assistant response.
scrollbackBuf []string
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
canceling bool
@@ -835,7 +843,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.setModel != nil {
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(msg.ModelString); err != nil {
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err)))
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
} else {
// Update display state directly — we cannot use
// NotifyModelChanged (prog.Send) from inside Update()
@@ -845,7 +853,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.providerName = parts[0]
m.modelName = parts[1]
}
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString)))
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
if m.emitModelChange != nil {
emit := m.emitModelChange
newModel := msg.ModelString
@@ -854,6 +862,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case ModelSelectorCancelledMsg:
@@ -1024,6 +1033,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleSlashCommand(sc); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1037,16 +1047,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/model":
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/thinking":
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
@@ -1103,13 +1116,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
// Started immediately. Flush any leftover stream content
// from the previous step first, then print the user
// message — combined in a single tea.Println so
// message — combined via the scrollback buffer so
// scrollback stays in chronological order.
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
m.flushStreamAndPendingUserMessages()
}
} else {
cmds = append(cmds, m.printUserMessage(displayText))
m.printUserMessage(displayText)
}
if m.state != stateWorking {
m.state = stateWorking
@@ -1117,10 +1130,22 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Shell command (! / !!) ───────────────────────────────────────────────
case shellCommandMsg:
// Show spinner while the shell command runs.
m.state = stateWorking
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
cmds = append(cmds, cmd)
}
// Execute the shell command asynchronously so the TUI stays responsive.
cmds = append(cmds, m.executeShellCommand(msg))
case shellCommandResultMsg:
// Stop spinner now that the command has finished.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
cmds = append(cmds, cmd)
}
m.state = stateInput
cmds = append(cmds, m.handleShellCommandResult(msg))
// ── App layer events ─────────────────────────────────────────────────────
@@ -1130,10 +1155,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// freshly or from the queue after a previous step completed). Flush
// any leftover stream content from the previous step to scrollback
// before starting the new one, followed by any pending user messages
// from the queue. Everything is emitted in a single tea.Println to
// guarantee chronological ordering in scrollback.
// from the queue. Everything goes through the scrollback buffer to
// guarantee chronological ordering.
if msg.Show {
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
m.flushStreamAndPendingUserMessages()
m.state = stateWorking
m.distributeHeight()
}
@@ -1159,7 +1184,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// always completes before tool calls fire). The tool call itself is
// NOT printed here — a unified block (header + result) will be
// rendered when the ToolResultEvent arrives.
cmds = append(cmds, m.flushStreamContent())
m.flushStreamContent()
case app.ToolExecutionEvent:
// Pass to stream component for execution spinner display.
@@ -1169,8 +1194,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case app.ToolResultEvent:
// Print tool result immediately to scrollback.
cmds = append(cmds, m.printToolResult(msg))
// Buffer tool result for scrollback.
m.printToolResult(msg)
// Start spinner again while waiting for the next LLM response.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
@@ -1190,7 +1215,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// In non-streaming mode (no stream content accumulated), print the text.
hasStreamContent := m.stream != nil && m.stream.GetRenderedContent() != ""
if !hasStreamContent && msg.Content != "" {
cmds = append(cmds, m.printAssistantMessage(msg.Content))
m.printAssistantMessage(msg.Content)
if m.stream != nil {
m.stream.Reset()
}
@@ -1244,7 +1269,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
if msg.Err != nil {
cmds = append(cmds, m.printErrorResponse(msg))
m.printErrorResponse(msg)
}
m.state = stateInput
m.canceling = false
@@ -1254,14 +1279,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stream.Reset()
}
m.state = stateInput
cmds = append(cmds, m.printCompactResult(msg))
m.printCompactResult(msg)
case app.CompactErrorEvent:
if m.stream != nil {
m.stream.Reset()
}
m.state = stateInput
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err)))
m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err))
case app.ModelChangedEvent:
// Extension changed the model — update display name in status bar
@@ -1369,17 +1394,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case extensionCmdResultMsg:
// Async extension slash command completed. Render output/error.
if msg.err != nil {
cmds = append(cmds, m.printSystemMessage(
fmt.Sprintf("Command %s error: %v", msg.name, msg.err)))
m.printSystemMessage(fmt.Sprintf("Command %s error: %v", msg.name, msg.err))
} else if msg.output != "" {
cmds = append(cmds, m.printSystemMessage(msg.output))
m.printSystemMessage(msg.output)
}
case beforeSessionSwitchResultMsg:
// Async before-session-switch hook completed. Proceed with the
// session reset if the hook did not cancel.
if msg.cancelled {
cmds = append(cmds, m.printSystemMessage(msg.reason))
m.printSystemMessage(msg.reason)
} else {
cmds = append(cmds, m.performNewSession())
}
@@ -1388,7 +1412,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Async before-fork hook completed. Proceed with the fork if the
// hook did not cancel.
if msg.cancelled {
cmds = append(cmds, m.printSystemMessage(msg.reason))
m.printSystemMessage(msg.reason)
} else {
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
}
@@ -1397,15 +1421,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Extension output — route through styled renderers when a level is set.
switch msg.Level {
case "info":
cmds = append(cmds, m.printSystemMessage(msg.Text))
m.printSystemMessage(msg.Text)
case "error":
cmds = append(cmds, m.printErrorResponse(app.StepErrorEvent{
m.printErrorResponse(app.StepErrorEvent{
Err: fmt.Errorf("%s", msg.Text),
}))
})
case "block":
cmds = append(cmds, m.printExtensionBlock(msg))
m.printExtensionBlock(msg)
default:
cmds = append(cmds, tea.Println(msg.Text))
m.appendScrollback(msg.Text)
}
default:
@@ -1420,6 +1444,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1593,10 +1618,31 @@ func (m *AppModel) renderStatusBar() string {
rightSide := strings.Join(rightParts, " ")
// 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)
// Progressive truncation to keep the status bar on one line.
// When content exceeds terminal width, drop sections in order:
// middle (extensions/thinking) → usage stats → model label → right side.
leftW := lipgloss.Width(leftSide)
middleW := lipgloss.Width(middleSide)
rightW := lipgloss.Width(rightSide)
// Need at least 1 space gap between left+middle and right.
if leftW+middleW+rightW+1 > m.width {
// Drop middle section first (extensions/thinking status).
middleSide = ""
middleW = 0
}
if leftW+rightW+1 > m.width && len(rightParts) > 1 {
// Drop usage stats, keep model label.
rightSide = rightParts[0]
rightW = lipgloss.Width(rightSide)
}
if leftW+rightW+1 > m.width {
// Drop right side entirely.
rightSide = ""
rightW = 0
}
gap := max(m.width-leftW-middleW-rightW, 1)
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
}
@@ -1765,30 +1811,28 @@ func (m *AppModel) renderQueuedMessages() string {
// Print helpers — emit content to scrollback via tea.Println
// --------------------------------------------------------------------------
// printUserMessage renders a user message and emits it above the BT region.
func (m *AppModel) printUserMessage(text string) tea.Cmd {
return tea.Println(m.renderer.RenderUserMessage(text, time.Now()).Content)
// printUserMessage renders a user message into the scrollback buffer.
func (m *AppModel) printUserMessage(text string) {
m.appendScrollback(m.renderer.RenderUserMessage(text, time.Now()).Content)
}
// printAssistantMessage renders an assistant message and emits it above the BT region.
func (m *AppModel) printAssistantMessage(text string) tea.Cmd {
if text == "" {
return nil
// printAssistantMessage renders an assistant message into the scrollback buffer.
func (m *AppModel) printAssistantMessage(text string) {
if text != "" {
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
}
return tea.Println(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
}
// printToolResult renders a tool result message and emits it above the BT region.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) tea.Cmd {
return tea.Println(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
// printToolResult renders a tool result message into the scrollback buffer.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
m.appendScrollback(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
}
// printErrorResponse renders an error message and emits it above the BT region.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd {
if evt.Err == nil {
return nil
// printErrorResponse renders an error message into the scrollback buffer.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
if evt.Err != nil {
m.appendScrollback(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
}
return tea.Println(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
}
// --------------------------------------------------------------------------
@@ -1803,15 +1847,15 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/quit":
return tea.Quit
case "/help":
return m.printHelpMessage()
m.printHelpMessage()
case "/tools":
return m.printToolsMessage()
m.printToolsMessage()
case "/servers":
return m.printServersMessage()
m.printServersMessage()
case "/usage":
return m.printUsageMessage()
m.printUsageMessage()
case "/reset-usage":
return m.printResetUsage()
m.printResetUsage()
case "/model":
return m.handleModelCommand("")
case "/thinking":
@@ -1822,14 +1866,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
return m.printSystemMessage("Conversation cleared. Starting fresh.")
m.printSystemMessage("Conversation cleared. Starting fresh.")
case "/clear-queue":
if m.appCtrl != nil {
m.appCtrl.ClearQueue()
}
m.queuedMessages = m.queuedMessages[:0]
m.distributeHeight()
return nil
case "/tree":
return m.handleTreeCommand()
@@ -1843,18 +1886,19 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
return m.handleSessionInfoCommand()
default:
return m.printSystemMessage(fmt.Sprintf("Unknown command: %s", sc.Name))
m.printSystemMessage(fmt.Sprintf("Unknown command: %s", sc.Name))
}
return nil
}
// printSystemMessage renders a system-level message and emits it above the BT region.
func (m *AppModel) printSystemMessage(text string) tea.Cmd {
return tea.Println(m.renderer.RenderSystemMessage(text, time.Now()).Content)
// printSystemMessage renders a system-level message into the scrollback buffer.
func (m *AppModel) printSystemMessage(text string) {
m.appendScrollback(m.renderer.RenderSystemMessage(text, time.Now()).Content)
}
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle, then emits it to scrollback.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) tea.Cmd {
// caller-chosen border color and optional subtitle into the scrollback buffer.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
theme := GetTheme()
// Resolve border color: use the extension's hex value, fall back to theme accent.
@@ -1877,7 +1921,7 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) tea.Cmd {
WithBorderColor(borderClr),
WithMarginBottom(1),
)
return tea.Println(rendered)
m.appendScrollback(rendered)
}
// handleExtensionCommand checks if the submitted text matches an extension-
@@ -1928,7 +1972,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
}
// printHelpMessage renders the help text listing all available slash commands.
func (m *AppModel) printHelpMessage() tea.Cmd {
func (m *AppModel) printHelpMessage() {
help := "## Available Commands\n\n" +
"**Info:**\n" +
"- `/help`: Show this help message\n" +
@@ -1978,11 +2022,11 @@ func (m *AppModel) printHelpMessage() tea.Cmd {
"- `Ctrl+C`: Exit at any time\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
"You can also just type your message to chat with the AI assistant."
return m.printSystemMessage(help)
m.printSystemMessage(help)
}
// printToolsMessage renders the list of available tools.
func (m *AppModel) printToolsMessage() tea.Cmd {
func (m *AppModel) printToolsMessage() {
var content string
content = "## Available Tools\n\n"
if len(m.toolNames) == 0 {
@@ -1992,11 +2036,11 @@ func (m *AppModel) printToolsMessage() tea.Cmd {
content += fmt.Sprintf("%d. `%s`\n", i+1, tool)
}
}
return m.printSystemMessage(content)
m.printSystemMessage(content)
}
// printServersMessage renders the list of configured MCP servers.
func (m *AppModel) printServersMessage() tea.Cmd {
func (m *AppModel) printServersMessage() {
var content string
content = "## Configured MCP Servers\n\n"
if len(m.serverNames) == 0 {
@@ -2006,13 +2050,14 @@ func (m *AppModel) printServersMessage() tea.Cmd {
content += fmt.Sprintf("%d. `%s`\n", i+1, server)
}
}
return m.printSystemMessage(content)
m.printSystemMessage(content)
}
// printUsageMessage renders token usage statistics.
func (m *AppModel) printUsageMessage() tea.Cmd {
func (m *AppModel) printUsageMessage() {
if m.usageTracker == nil {
return m.printSystemMessage("Usage tracking is not available for this model.")
m.printSystemMessage("Usage tracking is not available for this model.")
return
}
sessionStats := m.usageTracker.GetSessionStats()
@@ -2026,16 +2071,17 @@ func (m *AppModel) printUsageMessage() tea.Cmd {
content += fmt.Sprintf("**Session Total:** %d input + %d output tokens = $%.6f (%d requests)\n",
sessionStats.TotalInputTokens, sessionStats.TotalOutputTokens, sessionStats.TotalCost, sessionStats.RequestCount)
return m.printSystemMessage(content)
m.printSystemMessage(content)
}
// printResetUsage resets usage statistics and prints a confirmation.
func (m *AppModel) printResetUsage() tea.Cmd {
func (m *AppModel) printResetUsage() {
if m.usageTracker == nil {
return m.printSystemMessage("Usage tracking is not available for this model.")
m.printSystemMessage("Usage tracking is not available for this model.")
return
}
m.usageTracker.Reset()
return m.printSystemMessage("Usage statistics have been reset.")
m.printSystemMessage("Usage statistics have been reset.")
}
// handleCompactCommand starts an async compaction. It returns a tea.Cmd that
@@ -2045,23 +2091,26 @@ func (m *AppModel) printResetUsage() tea.Cmd {
// prompt (e.g. "Focus on the API design decisions").
func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
if m.appCtrl == nil {
return m.printSystemMessage("Compaction is not available.")
m.printSystemMessage("Compaction is not available.")
return nil
}
if err := m.appCtrl.CompactConversation(customInstructions); err != nil {
return m.printSystemMessage(fmt.Sprintf("Cannot compact: %v", err))
m.printSystemMessage(fmt.Sprintf("Cannot compact: %v", err))
return nil
}
// Transition to working state so the spinner shows while compaction runs.
m.state = stateWorking
m.printSystemMessage("Compacting conversation...")
var spinnerCmd tea.Cmd
if m.stream != nil {
_, spinnerCmd = m.stream.Update(app.SpinnerEvent{Show: true})
}
return tea.Batch(m.printSystemMessage("Compacting conversation..."), spinnerCmd)
return spinnerCmd
}
// printCompactResult renders the compaction summary in a styled block with
// a distinct border color and a stats subtitle.
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
// a distinct border color and a stats subtitle into the scrollback buffer.
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
theme := GetTheme()
saved := evt.OriginalTokens - evt.CompactedTokens
@@ -2083,62 +2132,89 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
WithBorderColor(theme.Secondary),
WithMarginBottom(1),
)
return tea.Println(rendered)
m.appendScrollback(rendered)
}
// flushStreamContent gets the rendered content from the stream component,
// emits it above the BT region via tea.Println, and resets the stream. This
// is called before printing tool calls (streaming completes before tools fire)
// and on step completion.
//
// After flushing, a ClearScreen is issued to force a full terminal redraw.
// When the stream content is moved to scrollback the view height shrinks, and
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
// below the managed region. ClearScreen ensures a clean redraw.
func (m *AppModel) flushStreamContent() tea.Cmd {
// flushStreamContent moves rendered content from the stream component into the
// scrollback buffer and resets the stream. Called before tool calls (streaming
// completes before tools fire). The actual tea.Println is deferred to
// drainScrollback() at the end of the Update cycle.
func (m *AppModel) flushStreamContent() {
if m.stream == nil {
return nil
return
}
content := m.stream.GetRenderedContent()
if content == "" {
return nil
return
}
m.stream.Reset()
return tea.Sequence(
tea.Println(content),
func() tea.Msg { return tea.ClearScreen() },
)
m.appendScrollback(content)
}
// flushStreamAndPendingUserMessages flushes the previous assistant response
// and any pending queued user messages to scrollback in a single tea.Println
// call, guaranteeing chronological order. Called from SpinnerEvent{Show: true}
// where all previous stream chunks are guaranteed to have been processed.
func (m *AppModel) flushStreamAndPendingUserMessages() tea.Cmd {
var parts []string
// flushStreamAndPendingUserMessages moves the previous assistant response and
// any pending queued user messages into the scrollback buffer. Called from
// SpinnerEvent{Show: true} where all previous stream chunks are guaranteed to
// have been processed. The actual tea.Println is deferred to drainScrollback().
func (m *AppModel) flushStreamAndPendingUserMessages() {
// 1. Flush previous stream content (assistant response).
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
m.appendScrollback(content)
}
}
// 2. Render pending user messages from the queue.
for _, text := range m.pendingUserPrints {
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
parts = append(parts, rendered)
m.appendScrollback(rendered)
}
m.pendingUserPrints = nil
}
if len(parts) == 0 {
// appendScrollback adds rendered content to the scrollback buffer. The content
// will be emitted via tea.Println when drainScrollback is called at the end of
// the current Update cycle.
func (m *AppModel) appendScrollback(content string) {
if content != "" {
m.scrollbackBuf = append(m.scrollbackBuf, content)
}
}
// drainScrollback flushes the scrollback buffer into a single tea.Println. If
// the stream component has unflushed content, it is automatically prepended so
// that new messages always appear below the previous assistant response. When
// stream content is flushed a ClearScreen follows to clean up orphaned terminal
// rows left after the view height shrinks. Returns nil if there is nothing to
// print.
func (m *AppModel) drainScrollback() tea.Cmd {
if len(m.scrollbackBuf) == 0 {
return nil
}
return tea.Sequence(
tea.Println(strings.Join(parts, "\n")),
func() tea.Msg { return tea.ClearScreen() },
)
var parts []string
needsClear := false
// Auto-flush any stream content so it appears before new messages.
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
needsClear = true
}
}
parts = append(parts, m.scrollbackBuf...)
m.scrollbackBuf = m.scrollbackBuf[:0]
printCmd := tea.Println(strings.Join(parts, "\n"))
if needsClear {
return tea.Sequence(
printCmd,
func() tea.Msg { return tea.ClearScreen() },
)
}
return printCmd
}
// distributeHeight recalculates child component heights after a window resize,
@@ -2151,7 +2227,7 @@ func (m *AppModel) flushStreamAndPendingUserMessages() tea.Cmd {
// stream region = total - header - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) - footer
// separator = 1 line
// above widgets = measured dynamically
// queued msgs = ~5 lines per message (padding + text + badge + padding)
// queued msgs = measured dynamically via lipgloss.Height()
// input region = measured dynamically via lipgloss.Height()
// below widgets = measured dynamically
// status bar = 1 line (always present)
@@ -2167,8 +2243,12 @@ func (m *AppModel) distributeHeight() {
if vis.HideStatusBar {
statusBarLines = 0
}
const linesPerQueuedMsg = 5
queuedLines := len(m.queuedMessages) * linesPerQueuedMsg
// Measure actual queued message height instead of using a fixed estimate,
// since text wrapping at different widths changes the rendered line count.
var queuedLines int
if queuedView := m.renderQueuedMessages(); queuedView != "" {
queuedLines = lipgloss.Height(queuedView)
}
// Propagate hint visibility before measuring input height.
if ic, ok := m.input.(*InputComponent); ok {
@@ -2215,6 +2295,17 @@ func (m *AppModel) distributeHeight() {
}
}
// clamp constrains v to the range [lo, hi].
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// repeatRune returns a string consisting of n repetitions of r.
func repeatRune(r rune, n int) string {
if n <= 0 {
@@ -2284,7 +2375,8 @@ func remapKey(name string) (tea.KeyPressMsg, bool) {
// to that model directly.
func (m *AppModel) handleModelCommand(args string) tea.Cmd {
if m.setModel == nil {
return m.printSystemMessage("Model switching is not available.")
m.printSystemMessage("Model switching is not available.")
return nil
}
if args == "" {
@@ -2298,7 +2390,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
// Direct model switch with the provided model string.
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(args); err != nil {
return m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
return nil
}
// Update display state directly (cannot use prog.Send from Update).
@@ -2315,7 +2408,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
go emit(newModel, prev, "user")
}
return m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
return nil
}
// --------------------------------------------------------------------------
@@ -2327,7 +2421,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
// minimal, low, medium, high) it switches to that level.
func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
if !m.isReasoningModel {
return m.printSystemMessage("Current model does not support thinking/reasoning.")
m.printSystemMessage("Current model does not support thinking/reasoning.")
return nil
}
if args == "" {
@@ -2342,13 +2437,15 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
lines = append(lines, fmt.Sprintf("%s%s — %s", marker, l, models.ThinkingLevelDescription(l)))
}
header := fmt.Sprintf("Current thinking level: %s\n\nAvailable levels:", m.thinkingLevel)
return m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
return nil
}
// Parse and validate the level.
level := models.ParseThinkingLevel(args)
if string(level) != strings.ToLower(args) {
return m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
return nil
}
// Apply the change.
@@ -2358,7 +2455,8 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
_ = m.setThinkingLevel(string(level))
}()
}
return m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
return nil
}
// --------------------------------------------------------------------------
@@ -2369,10 +2467,12 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
func (m *AppModel) handleTreeCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
return nil
}
if ts.EntryCount() == 0 {
return m.printSystemMessage("No entries in session yet.")
m.printSystemMessage("No entries in session yet.")
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
@@ -2385,10 +2485,12 @@ func (m *AppModel) handleTreeCommand() tea.Cmd {
func (m *AppModel) handleForkCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
return nil
}
if ts.EntryCount() == 0 {
return m.printSystemMessage("No entries to fork from.")
m.printSystemMessage("No entries to fork from.")
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
@@ -2426,14 +2528,16 @@ func (m *AppModel) performNewSession() tea.Cmd {
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
return m.printSystemMessage("Conversation cleared. Starting fresh.")
m.printSystemMessage("Conversation cleared. Starting fresh.")
return nil
}
ts.ResetLeaf()
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
return m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
return nil
}
// performFork performs the actual tree branch. Called either directly (when no
@@ -2441,7 +2545,8 @@ func (m *AppModel) performNewSession() tea.Cmd {
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active.")
m.printSystemMessage("No tree session active.")
return nil
}
_ = ts.Branch(targetID)
@@ -2455,7 +2560,7 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
}
return m.printSystemMessage(
m.printSystemMessage(
fmt.Sprintf("Navigated to branch point. %s",
func() string {
if isUser {
@@ -2463,29 +2568,34 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
return "Continue from this point."
}()))
return nil
}
// handleNameCommand sets a display name for the current session.
func (m *AppModel) handleNameCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active.")
m.printSystemMessage("No tree session active.")
return nil
}
// For now, prompt user to provide name via input. We print instructions
// and the next non-command input starting with "name:" will be captured.
// TODO: inline input dialog.
currentName := ts.GetSessionName()
if currentName != "" {
return m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
return nil
}
return m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
return nil
}
// handleSessionInfoCommand shows session statistics.
func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active.")
m.printSystemMessage("No tree session active.")
return nil
}
header := ts.GetHeader()
@@ -2510,7 +2620,8 @@ func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
info += fmt.Sprintf("- **Name:** %s\n", name)
}
return m.printSystemMessage(info)
m.printSystemMessage(info)
return nil
}
// --------------------------------------------------------------------------
@@ -2821,8 +2932,7 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
WithMarginBottom(1),
)
var cmds []tea.Cmd
cmds = append(cmds, tea.Println(rendered))
m.appendScrollback(rendered)
// For ! (included in context): inject the command output into the
// conversation as a user message so the LLM can reference it on the
@@ -2842,5 +2952,5 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
m.appCtrl.AddContextMessage(contextMsg)
}
return tea.Batch(cmds...)
return nil
}
+50 -6
View File
@@ -208,9 +208,20 @@ func (ms *ModelSelectorComponent) View() tea.View {
// Header.
b.WriteString(headerStyle.Render("Model Selector"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
// Adapt help text to terminal width.
if ms.width >= 56 {
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
} else if ms.width >= 35 {
b.WriteString(helpStyle.Render("↑↓ move ↵ select esc type"))
} else {
b.WriteString(helpStyle.Render("↑↓ ↵ esc"))
}
b.WriteString("\n")
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
if ms.width >= 48 {
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
} else {
b.WriteString(infoStyle.Render("Models with API keys"))
}
b.WriteString("\n")
// Search input.
@@ -281,9 +292,9 @@ func (ms *ModelSelectorComponent) IsActive() bool {
// --- Internal helpers ---
func (ms *ModelSelectorComponent) visibleHeight() int {
// Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7
h := max(ms.height-7, 5)
return h
// Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7.
// Minimum 3 entries so the selector is still usable on short terminals.
return max(ms.height-7, 3)
}
func (ms *ModelSelectorComponent) rebuildFiltered() {
@@ -396,8 +407,37 @@ func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) s
// Active model checkmark.
var active string
activeWidth := 0
if entry.Provider+"/"+entry.ModelID == ms.currentModel {
active = lipgloss.NewStyle().Foreground(theme.Success).Render(" \u2713")
activeWidth = 2 // " ✓"
}
// Truncate model ID and provider tag to fit terminal width.
// Layout: cursor(3) + model + " " + provider + active.
// Use rune length for display-width accuracy (the "…" suffix is 1 rune / 1 column).
const cursorWidth = 3
available := max(ms.width-cursorWidth-activeWidth-1, 10) // 1 for space between model and provider
provDisplayLen := len([]rune(providerStr))
modelDisplayLen := len([]rune(modelStr))
if modelDisplayLen+1+provDisplayLen > available {
// Prioritize model name — truncate it, but keep provider visible.
maxModel := max(available-provDisplayLen-1, 6)
if maxModel < modelDisplayLen {
if maxModel > 3 {
runes := []rune(modelStr)
modelStr = string(runes[:maxModel-1]) + "…"
} else {
runes := []rune(modelStr)
modelStr = string(runes[:maxModel])
}
}
// If provider itself is too long, drop it.
modelDisplayLen = len([]rune(modelStr))
if modelDisplayLen+1+provDisplayLen > available {
providerStr = ""
}
}
// Style the model ID.
@@ -409,5 +449,9 @@ func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) s
// Style the provider tag.
providerStyle := lipgloss.NewStyle().Foreground(theme.Muted)
return cursor + modelStyle.Render(modelStr) + " " + providerStyle.Render(providerStr) + active
result := cursor + modelStyle.Render(modelStr)
if providerStr != "" {
result += " " + providerStyle.Render(providerStr)
}
return result + active
}
+27 -25
View File
@@ -135,31 +135,24 @@ func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd)
func (o *overlayDialog) Render() string {
theme := GetTheme()
// Calculate dialog dimensions.
// Calculate dialog dimensions, clamped to terminal bounds.
termW := max(o.width, 10)
termH := max(o.height, 5)
dw := o.dialogWidth
if dw == 0 {
dw = o.width * 60 / 100
}
if dw < 30 {
dw = 30
}
if dw > o.width-4 {
dw = o.width - 4
dw = termW * 60 / 100
}
dw = clamp(dw, min(24, termW), termW-2)
mh := o.maxHeight
if mh == 0 {
mh = o.height * 80 / 100
}
if mh < 8 {
mh = 8
}
if mh > o.height-2 {
mh = o.height - 2
mh = termH * 80 / 100
}
mh = clamp(mh, min(6, termH), termH)
// Inner width accounts for border (2) + horizontal padding (2 left + 1 right).
innerWidth := max(dw-5, 10)
innerWidth := max(dw-5, 6)
// Render body text (potentially as markdown).
bodyText := o.content
@@ -268,18 +261,27 @@ func (o *overlayDialog) Render() string {
dialog := dialogStyle.Render(innerContent)
// Key hints below the dialog.
// Key hints below the dialog, adapted to width.
var hints []string
if scrollable {
hints = append(hints, "↑/↓ scroll")
}
if len(o.actions) > 0 {
hints = append(hints, "←/→ switch")
hints = append(hints, "Enter select")
if termW >= 50 {
if scrollable {
hints = append(hints, "↑/↓ scroll")
}
if len(o.actions) > 0 {
hints = append(hints, "←/→ switch")
hints = append(hints, "Enter select")
} else {
hints = append(hints, "Enter dismiss")
}
hints = append(hints, "Esc cancel")
} else {
hints = append(hints, "Enter dismiss")
if len(o.actions) > 0 {
hints = append(hints, "↵ select")
} else {
hints = append(hints, "↵ ok")
}
hints = append(hints, "esc")
}
hints = append(hints, "Esc cancel")
hintText := lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" " + strings.Join(hints, " "))
+35 -10
View File
@@ -59,14 +59,20 @@ func knightRiderFrames() []string {
}
// streamSpinnerTickMsg is the internal tick message that drives the KITT-style
// spinner animation inside StreamComponent.
type streamSpinnerTickMsg struct{}
// spinner animation inside StreamComponent. The generation field ties each tick
// to the spinner session that created it so that stale ticks from a previous
// start/stop cycle are silently discarded instead of creating a second
// concurrent tick loop (which doubles the animation speed).
type streamSpinnerTickMsg struct {
generation uint64
}
// streamSpinnerTickCmd returns a tea.Cmd that fires streamSpinnerTickMsg at the
// KITT animation frame rate (14 fps).
func streamSpinnerTickCmd() tea.Cmd {
// KITT animation frame rate (14 fps). The generation parameter is embedded in
// the message so the receiver can verify it matches the current spinner session.
func streamSpinnerTickCmd(generation uint64) tea.Cmd {
return tea.Tick(time.Second/14, func(_ time.Time) tea.Msg {
return streamSpinnerTickMsg{}
return streamSpinnerTickMsg{generation: generation}
})
}
@@ -128,6 +134,15 @@ type StreamComponent struct {
// remains visible alongside streaming text until Reset().
spinning bool
// spinnerGeneration is incremented each time a new spinner tick loop
// is started. Tick messages carry the generation they were created for;
// if a tick's generation doesn't match the current one, it is a stale
// tick from a previous start/stop cycle and is silently discarded.
// This prevents multiple concurrent tick loops from accumulating when
// the spinner is rapidly stopped and restarted (e.g. SpinnerEvent
// hide → ToolExecutionEvent start before the old tick fires).
spinnerGeneration uint64
// spinnerFrames are the pre-rendered KITT animation frames.
spinnerFrames []string
@@ -233,6 +248,7 @@ func (s *StreamComponent) SetHeight(h int) {
func (s *StreamComponent) Reset() {
s.phase = streamPhaseIdle
s.spinning = false
s.spinnerGeneration++ // invalidate any in-flight tick commands
s.spinnerFrame = 0
s.activeTools = nil
s.streamContent.Reset()
@@ -313,11 +329,15 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.renderDirty = true
case streamSpinnerTickMsg:
if s.spinning {
// Only continue the tick loop if this tick belongs to the current
// spinner session. Stale ticks from a previous start/stop cycle
// are silently dropped, preventing duplicate concurrent tick loops
// that would double (or worse) the animation speed.
if s.spinning && msg.generation == s.spinnerGeneration {
s.spinnerFrame++
return s, streamSpinnerTickCmd()
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
// Spinning stopped; let the tick loop die naturally.
// Spinning stopped or generation mismatch; let the tick loop die.
// ── App-layer events ──────────────────────────────────────────────────
@@ -325,13 +345,17 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Show && !s.spinning {
s.phase = streamPhaseActive
s.spinning = true
s.spinnerGeneration++ // new session; invalidate any stale ticks
s.spinnerFrame = 0
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
return s, streamSpinnerTickCmd()
return s, streamSpinnerTickCmd(s.spinnerGeneration)
} else if !msg.Show && s.spinning {
s.spinning = false
// Bump generation so any in-flight tick from this session is
// discarded if spinning is restarted before it fires.
s.spinnerGeneration++
}
case streamFlushTickMsg:
@@ -376,7 +400,8 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !s.spinning {
s.phase = streamPhaseActive
s.spinning = true
return s, streamSpinnerTickCmd()
s.spinnerGeneration++ // new session; invalidate stale ticks
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
} else {
// Tool finished — remove from active list but keep spinning if others remain.
+17 -8
View File
@@ -217,7 +217,14 @@ func (ts *TreeSelectorComponent) View() tea.View {
// Header.
b.WriteString(headerStyle.Render("Session Tree"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter"))
// Adapt help text to terminal width.
if ts.width >= 70 {
b.WriteString(helpStyle.Render("↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter"))
} else if ts.width >= 45 {
b.WriteString(helpStyle.Render("↑↓ move ↵ select esc cancel ^O filter"))
} else {
b.WriteString(helpStyle.Render("↑↓ ↵ esc ^O"))
}
b.WriteString("\n")
if ts.search != "" {
@@ -269,9 +276,10 @@ func (ts *TreeSelectorComponent) IsActive() bool {
// --- Internal helpers ---
func (ts *TreeSelectorComponent) visibleHeight() int {
// Reserve lines for header(3) + search(1) + separator(1) + footer(2).
h := max(ts.height/2-7, 5)
return h
// Chrome: header(1) + help(1) + separator(1) + entries + separator(1) + footer(1) = 5 fixed.
// Optional search line adds 1 more. Use 7 as a safe estimate.
const chromeLines = 7
return max(ts.height-chromeLines, 3)
}
func (ts *TreeSelectorComponent) rebuildFlatList() {
@@ -389,7 +397,7 @@ func (ts *TreeSelectorComponent) passesFilter(node *session.TreeNode) bool {
func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool) string {
theme := GetTheme()
maxWidth := ts.width - 4
maxWidth := max(ts.width-4, 10)
// Cursor indicator.
var cursor string
@@ -401,9 +409,10 @@ func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool
// Role-colored content.
text := ts.entryDisplayText(node.Entry)
if len(text) > maxWidth-len(node.Prefix)-10 {
trimLen := maxWidth - len(node.Prefix) - 13
if trimLen > 0 && trimLen < len(text) {
available := maxWidth - len(node.Prefix) - 10
if available > 3 && len(text) > available {
trimLen := max(available-3, 1)
if trimLen < len(text) {
text = text[:trimLen] + "..."
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"version": 1,
"skills": {
"btca-cli": {
"source": "davis7dotsh/better-context",
"sourceType": "github",
"computedHash": "99bc5301f4f839a6f3be99d98955f32f1cd576c218731fa05fa54a003bd20e9b"
},
"kit-extensions": {
"source": "mark3labs/kit",
"sourceType": "github",
"computedHash": "9347a88bec46dd52727a672b6c8d058955f9f50dfe98708e0c63b85e0779ba96"
}
}
}
+153
View File
@@ -7,6 +7,8 @@ description: Guide for creating Kit extensions. Use when the user asks to build,
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
Extensions can be distributed via git repositories using `kit install`. Repos can contain single extensions or collections of multiple extensions.
## Extension Structure
Every extension must export a `package main` with an `Init(api ext.API)` function:
@@ -772,6 +774,157 @@ kit extensions init
---
## Distributing Extensions via Git Repositories
Extensions can be distributed and installed from git repositories using `kit install`. This enables sharing extensions with others and maintaining versioned collections.
### Repository Structure
Extensions support two organization patterns within a repo:
**Single-file extensions** (simple, standalone):
```
my-extension-repo/
├── weather.go # Single extension file
├── todo.go # Another extension
└── README.md # Installation and usage docs
```
**Multi-file extensions** (with `main.go` entry point):
```
my-extension-repo/
├── git-tools/
│ ├── main.go # Entry point
│ ├── helpers.go # Supporting code
│ └── config.go # Configuration
├── todo/
│ ├── main.go # Entry point
│ └── storage.go # Storage logic
└── README.md
```
**Hybrid approach** (single files + subdirectories with main.go):
```
my-extensions/
├── weather.go # Single file extension
├── calculator.go # Single file extension
├── git-tools/
│ ├── main.go # Multi-file extension
│ └── utils.go
└── README.md
```
### Installing from Git
Users install extensions using the `kit install` command:
```bash
# Install from GitHub (latest)
kit install github.com/user/repo
# Pin to a specific version/tag
kit install github.com/user/repo@v1.0.0
kit install github.com/user/repo@main
kit install github.com/user/repo@abc1234
# Install locally in project (./.kit/git/)
kit install github.com/user/repo --local
# Interactive selection for repos with multiple extensions
kit install github.com/user/collection --select
```
Supported URL formats:
- `github.com/user/repo` — Shorthand (defaults to HTTPS)
- `git:github.com/user/repo` — Git prefix format
- `https://github.com/user/repo` — HTTPS URL
- `ssh://git@github.com/user/repo` — SSH URL
- `git@github.com:user/repo` — SSH shorthand
### Managing Installed Extensions
```bash
# Update an installed extension (skips pinned versions)
kit install github.com/user/repo --update
# Remove an installed extension
kit install github.com/user/repo --uninstall
# List all loaded extensions
kit extensions list
# Validate all extensions
kit extensions validate
```
### Extension Selection
For repos containing multiple extensions, users can select which to install:
```bash
# Interactive selection
kit install github.com/user/collection --select
```
This prompts the user to choose which extensions to install. Selected extensions are recorded in the manifest, and only those are loaded at runtime (others in the repo are ignored).
### README Template for Extension Repos
Include this in your extension repo's README.md:
```markdown
# My Kit Extensions
A collection of extensions for [Kit](https://github.com/mark3labs/kit).
## Installation
### Install all extensions
\`\`\`bash
kit install github.com/username/repo
\`\`\`
### Install specific extensions
\`\`\`bash
kit install github.com/username/repo --select
\`\`\`
### Install locally in a project
\`\`\`bash
kit install github.com/username/repo --local
\`\`\`
## Extensions
### Extension Name
Description of what it does.
- **Path**: `./ext-name/main.go` or `./ext-name.go`
- **Commands**: `/command-name`
- **Tools**: `tool_name`
## Requirements
- Kit vX.Y.Z+
- Any other dependencies
## Update
\`\`\`bash
kit install github.com/username/repo --update
\`\`\`
```
### Storage Locations
Installed extensions are stored at:
- **Global**: `~/.local/share/kit/git/<host>/<owner>/<repo>/`
- **Project-local**: `./.kit/git/<host>/<owner>/<repo>/`
- **Manifest**: `packages.json` in respective directories
---
## Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection: