From 922e2460981ede6b165cf76c06c0d3baf3bbfcf1 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 15 May 2026 14:31:51 +0300 Subject: [PATCH] feat(prompts): auto-reload prompts and extensions from XDG config - Add prompts.GlobalDir() resolving $XDG_CONFIG_HOME/kit/prompts/ (default ~/.config/kit/prompts/) so prompt templates live alongside extensions and skills under the same XDG-aligned root. - LoadAll now discovers templates from both the legacy ~/.kit/prompts/ and the XDG location; existing legacy paths keep precedence. - Include GlobalDir() in the prompts/skills file watcher so edits under ~/.config/kit/prompts/ hot-reload automatically. - Surface a visible 'Extensions reloaded.' (or error) message when the extension watcher fires, matching /reload-ext feedback. - Restore examples/extensions/subagent-monitor.go alongside its test and update the test load path; previous move left the test broken. --- cmd/root.go | 4 + examples/extensions/subagent-monitor.go | 304 +++++++++++++++++++ examples/extensions/subagent-monitor_test.go | 10 +- internal/prompts/loader.go | 49 ++- 4 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 examples/extensions/subagent-monitor.go diff --git a/cmd/root.go b/cmd/root.go index 33081620..f9a284b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1206,7 +1206,10 @@ func runNormalMode(ctx context.Context) error { extWatcher, watchErr := extensions.NewWatcher(watchDirs, func() { if err := reloadExtensionsForUI(); err != nil { log.Printf("auto-reload extensions failed: %v", err) + appInstance.PrintFromExtension("error", fmt.Sprintf("Extension auto-reload failed: %v", err)) + return } + appInstance.PrintFromExtension("info", "Extensions reloaded.") }) if watchErr != nil { log.Printf("extension file watcher not started: %v", watchErr) @@ -1225,6 +1228,7 @@ func runNormalMode(ctx context.Context) error { promptDirs := watcher.CollectDirs( []string{ filepath.Join(homeDir, ".kit", "prompts"), + prompts.GlobalDir(), filepath.Join(cwd, ".kit", "prompts"), }, append(promptTemplatePaths, viper.GetStringSlice("prompts")...), diff --git a/examples/extensions/subagent-monitor.go b/examples/extensions/subagent-monitor.go new file mode 100644 index 00000000..f7e86d0c --- /dev/null +++ b/examples/extensions/subagent-monitor.go @@ -0,0 +1,304 @@ +//go:build ignore + +// subagent-monitor — live horizontal widget strip for spawned subagents +// +// Subscribes to subagents spawned by the main Kit agent and displays a +// single widget just above the input box. Each subagent occupies one column +// in a side-by-side horizontal layout. Columns show scrolling real-time +// output as the subagent works. When a subagent finishes its column is +// removed automatically. +// +// Yaegi-safe design notes: +// - No sync.Mutex (Yaegi has reflection issues with sync primitives) +// - No channels in maps (Yaegi panics on range over map[string]chan) +// - All ctx.* calls guarded with nil checks +// - Simple data structures only +package main + +import ( + "fmt" + "strings" + "time" + + "kit/ext" +) + +// --------------------------------------------------------------------------- +// Per-subagent state +// --------------------------------------------------------------------------- + +type submonEntry struct { + id int + callID string + task string + lines []string + started time.Time + elapsed time.Duration +} + +const ( + submonColWidth = 34 // visible character width per column + submonMaxLines = 5 // scrolling output lines per column + submonColGap = 2 // spaces between columns +) + +// --------------------------------------------------------------------------- +// Package-level state - all simple types +// --------------------------------------------------------------------------- + +var ( + submonCtx ext.Context + submonHasCtx bool + submonEntries []*submonEntry + submonNextID int +) + +func submonInit() { + submonEntries = nil + submonNextID = 1 +} + +// --------------------------------------------------------------------------- +// String helpers +// --------------------------------------------------------------------------- + +func submonPad(s string, w int) string { + r := []rune(s) + if len(r) >= w { + return string(r[:w]) + } + return s + strings.Repeat(" ", w-len(r)) +} + +func submonTrunc(s string, w int) string { + r := []rune(s) + if len(r) <= w { + return s + } + if w <= 1 { + return "…" + } + return string(r[:w-1]) + "…" +} + +// --------------------------------------------------------------------------- +// Widget rendering +// --------------------------------------------------------------------------- + +func submonRenderColumn(e *submonEntry) []string { + var rows []string + + // Calculate elapsed time on-demand to avoid race conditions with ticker + elapsed := e.elapsed + if elapsed == 0 && !e.started.IsZero() { + elapsed = time.Since(e.started) + } + secs := int(elapsed.Seconds()) + timeStr := fmt.Sprintf("%ds", secs) + taskMax := submonColWidth - len(timeStr) - 3 + taskPart := submonTrunc(e.task, taskMax) + header := fmt.Sprintf("#%d %s %s", e.id, taskPart, timeStr) + rows = append(rows, submonPad(header, submonColWidth)) + + display := e.lines + if len(display) > submonMaxLines { + display = display[len(display)-submonMaxLines:] + } + for _, l := range display { + rows = append(rows, submonPad(" "+submonTrunc(l, submonColWidth-2), submonColWidth)) + } + for len(rows) < submonMaxLines+1 { + if len(rows) == 1 && len(e.lines) == 0 { + rows = append(rows, submonPad(" waiting…", submonColWidth)) + } else { + rows = append(rows, strings.Repeat(" ", submonColWidth)) + } + } + return rows +} + +func submonBuildWidget() string { + if len(submonEntries) == 0 { + return "" + } + + numCols := len(submonEntries) + numRows := submonMaxLines + 1 + cols := make([][]string, numCols) + for i, e := range submonEntries { + rows := submonRenderColumn(e) + col := make([]string, numRows) + for j := 0; j < numRows; j++ { + if j < len(rows) { + col[j] = rows[j] + } else { + col[j] = strings.Repeat(" ", submonColWidth) + } + } + cols[i] = col + } + + gap := strings.Repeat(" ", submonColGap) + var sb strings.Builder + for row := 0; row < numRows; row++ { + for ci := range cols { + if ci > 0 { + sb.WriteString(gap) + } + sb.WriteString(cols[ci][row]) + } + if row < numRows-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func submonPushWidget() { + if !submonHasCtx { + return + } + if submonCtx.SetWidget == nil { + return + } + + text := submonBuildWidget() + if len(submonEntries) == 0 { + if submonCtx.RemoveWidget != nil { + submonCtx.RemoveWidget("submon") + } + return + } + submonCtx.SetWidget(ext.WidgetConfig{ + ID: "submon", + Placement: ext.WidgetAbove, + Content: ext.WidgetContent{Text: text}, + Style: ext.WidgetStyle{BorderColor: "#89b4fa"}, + Priority: 0, + }) +} + +func submonAppendLine(e *submonEntry, line string) { + line = strings.TrimRight(line, "\r\n") + if strings.TrimSpace(line) == "" { + return + } + e.lines = append(e.lines, line) +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +func Init(api ext.API) { + submonInit() + + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + submonCtx = ctx + submonHasCtx = true + submonInit() + if ctx.RemoveWidget != nil { + ctx.RemoveWidget("submon") + } + }) + + api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) { + submonCtx = ctx + submonHasCtx = true + }) + + // ── SubagentStart ──────────────────────────────────────────────────────── + api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) { + submonCtx = ctx + submonHasCtx = true + + id := submonNextID + submonNextID++ + entry := &submonEntry{ + id: id, + callID: e.ToolCallID, + task: e.Task, + started: time.Now(), + } + submonEntries = append(submonEntries, entry) + + submonPushWidget() + }) + + // ── SubagentChunk ──────────────────────────────────────────────────────── + api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) { + submonCtx = ctx + submonHasCtx = true + + var entry *submonEntry + for _, en := range submonEntries { + if en.callID == e.ToolCallID { + entry = en + break + } + } + if entry == nil { + return + } + + switch e.ChunkType { + case "text": + for _, line := range strings.Split(e.Content, "\n") { + submonAppendLine(entry, line) + } + case "tool_call": + submonAppendLine(entry, "→ "+e.ToolName) + case "tool_execution_start": + submonAppendLine(entry, "⚙ "+e.ToolName) + case "tool_result": + if e.IsError { + submonAppendLine(entry, "✗ "+e.ToolName) + } else { + submonAppendLine(entry, "✓ "+e.ToolName) + } + } + + submonPushWidget() + }) + + // ── SubagentEnd ────────────────────────────────────────────────────────── + api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) { + submonCtx = ctx + submonHasCtx = true + + var entry *submonEntry + for _, en := range submonEntries { + if en.callID == e.ToolCallID { + entry = en + break + } + } + if entry != nil { + entry.elapsed = time.Since(entry.started) + if e.ErrorMsg != "" { + submonAppendLine(entry, "✗ "+submonTrunc(e.ErrorMsg, submonColWidth-2)) + } + } + + submonPushWidget() + + // Remove the entry immediately (no goroutine to avoid races) + newEntries := submonEntries[:0] + for _, en := range submonEntries { + if en.callID != e.ToolCallID { + newEntries = append(newEntries, en) + } + } + submonEntries = newEntries + submonPushWidget() + }) + + // ── SessionShutdown ────────────────────────────────────────────────────── + api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) { + submonInit() + // Guard ctx access - may be nil during shutdown + if ctx.RemoveWidget != nil { + ctx.RemoveWidget("submon") + } + }) +} diff --git a/examples/extensions/subagent-monitor_test.go b/examples/extensions/subagent-monitor_test.go index 25a5fd28..b3f0e416 100644 --- a/examples/extensions/subagent-monitor_test.go +++ b/examples/extensions/subagent-monitor_test.go @@ -13,7 +13,7 @@ import ( // without panicking and properly guards nil ctx calls. func TestSubagentMonitor_SessionStart(t *testing.T) { harness := test.New(t) - harness.LoadFile("../../.kit/extensions/subagent-monitor.go") + harness.LoadFile("./subagent-monitor.go") // Emit SessionStart - should not panic even with nil ctx functions _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"}) @@ -26,7 +26,7 @@ func TestSubagentMonitor_SessionStart(t *testing.T) { // creates entries and emits widget updates. func TestSubagentMonitor_SubagentLifecycle(t *testing.T) { harness := test.New(t) - harness.LoadFile("../../.kit/extensions/subagent-monitor.go") + harness.LoadFile("./subagent-monitor.go") // Start session _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"}) @@ -84,7 +84,7 @@ func TestSubagentMonitor_SubagentLifecycle(t *testing.T) { // TestSubagentMonitor_MultipleSubagents verifies multiple parallel subagents. func TestSubagentMonitor_MultipleSubagents(t *testing.T) { harness := test.New(t) - harness.LoadFile("../../.kit/extensions/subagent-monitor.go") + harness.LoadFile("./subagent-monitor.go") _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"}) if err != nil { @@ -134,7 +134,7 @@ func TestSubagentMonitor_MultipleSubagents(t *testing.T) { // subagents emit events concurrently from different goroutines. func TestSubagentMonitor_ConcurrentSubagents(t *testing.T) { harness := test.New(t) - harness.LoadFile("../../.kit/extensions/subagent-monitor.go") + harness.LoadFile("./subagent-monitor.go") _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"}) if err != nil { @@ -186,7 +186,7 @@ func TestSubagentMonitor_ConcurrentSubagents(t *testing.T) { // even with nil ctx functions. func TestSubagentMonitor_SessionShutdown(t *testing.T) { harness := test.New(t) - harness.LoadFile("../../.kit/extensions/subagent-monitor.go") + harness.LoadFile("./subagent-monitor.go") // Start then shutdown _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"}) diff --git a/internal/prompts/loader.go b/internal/prompts/loader.go index 582ae8b7..b5b249ac 100644 --- a/internal/prompts/loader.go +++ b/internal/prompts/loader.go @@ -36,15 +36,17 @@ type Diagnostic struct { } // LoadAll discovers and loads all prompt templates from standard locations -// and any extra paths. Templates are loaded in order of precedence (lowest -// to highest), with later templates overriding earlier ones of the same name. +// and any extra paths. Templates are loaded in order of precedence (highest +// to lowest); the first source to define a given name wins, later definitions +// of the same name are dropped with a diagnostic. // // Discovery paths searched in order: // 1. Default templates (if IncludeDefaults) -// 2. ~/.kit/prompts/ (global user templates) -// 3. .kit/prompts/ (project-local templates) -// 4. ConfigPaths (from configuration) -// 5. ExtraPaths (explicit paths, highest precedence) +// 2. ~/.kit/prompts/ (legacy global) +// 3. $XDG_CONFIG_HOME/kit/prompts/ (XDG global, default ~/.config/kit/prompts/) +// 4. /.kit/prompts/ (project-local templates) +// 5. ConfigPaths (from configuration) +// 6. ExtraPaths (explicit paths, lowest precedence) func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) { if opts.Cwd == "" { opts.Cwd, _ = os.Getwd() @@ -88,13 +90,21 @@ func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) { addTemplates(defaults, "default") } - // 2. Global user templates: ~/.kit/prompts/ - globalDir := filepath.Join(opts.HomeDir, ".kit", "prompts") - if templates, err := LoadFromDir(globalDir); err == nil { + // 2. Legacy global user templates: ~/.kit/prompts/ + legacyGlobalDir := filepath.Join(opts.HomeDir, ".kit", "prompts") + if templates, err := LoadFromDir(legacyGlobalDir); err == nil { addTemplates(templates, "global") } - // 3. Project-local templates: .kit/prompts/ + // 3. XDG global user templates: $XDG_CONFIG_HOME/kit/prompts/ + // Default: ~/.config/kit/prompts/. Aligns with extensions and skills. + if xdgDir := GlobalDir(); xdgDir != "" && xdgDir != legacyGlobalDir { + if templates, err := LoadFromDir(xdgDir); err == nil { + addTemplates(templates, "global") + } + } + + // 4. Project-local templates: .kit/prompts/ localDir := filepath.Join(opts.Cwd, ".kit", "prompts") if templates, err := LoadFromDir(localDir); err == nil { addTemplates(templates, "local") @@ -186,3 +196,22 @@ func loadDefaultTemplates() []*PromptTemplate { // For now, return an empty slice - users can define their own templates return nil } + +// GlobalDir returns the XDG-aligned global prompts directory, respecting +// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/prompts/. Returns an empty +// string if the user's home directory cannot be resolved. +// +// This is the canonical location for user-wide prompt templates and aligns +// with the discovery paths used for extensions ($XDG_CONFIG_HOME/kit/extensions/) +// and skills ($XDG_CONFIG_HOME/kit/skills/). +func GlobalDir() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "kit", "prompts") +}