mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-18 05:18:35 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2e2e5e9b3 | |||
| 2c05280150 |
@@ -69,6 +69,9 @@ func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Cont
|
||||
}
|
||||
appInstance.RunWithFiles(text, parts)
|
||||
}
|
||||
ec.NewSession = func(prompt string) error {
|
||||
return appInstance.RequestNewSessionFromExtension(prompt)
|
||||
}
|
||||
ec.GetSessionUsage = func() extensions.SessionUsage {
|
||||
if usageTracker == nil {
|
||||
return extensions.SessionUsage{}
|
||||
|
||||
+8
-5
@@ -670,13 +670,16 @@ func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, strin
|
||||
|
||||
// beforeSessionSwitchProviderForUI returns a callback that emits a
|
||||
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
|
||||
// if extensions are disabled — the UI treats nil as "no hook".
|
||||
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
|
||||
// if extensions are disabled — the UI treats nil as "no hook". The
|
||||
// initialPrompt argument is forwarded to the event so extensions can
|
||||
// inspect the prompt that will be submitted as the first turn of the
|
||||
// new session.
|
||||
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(switchReason, initialPrompt string) (bool, string) {
|
||||
if !k.Extensions().HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func(switchReason string) (bool, string) {
|
||||
return k.Extensions().EmitBeforeSessionSwitch(switchReason)
|
||||
return func(switchReason, initialPrompt string) (bool, string) {
|
||||
return k.Extensions().EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1487,7 +1490,7 @@ type runModeDeps struct {
|
||||
getUIVisibility func() *ui.UIVisibility
|
||||
getStatusBarEntries func() []ui.StatusBarEntryData
|
||||
emitBeforeFork func(string, bool, string) (bool, string)
|
||||
emitBeforeSessionSwitch func(string) (bool, string)
|
||||
emitBeforeSessionSwitch func(string, string) (bool, string)
|
||||
getGlobalShortcuts func() map[string]func()
|
||||
getExtensionCommands func() []commands.ExtensionCommand
|
||||
setModel func(string) error
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build ignore
|
||||
|
||||
// phase-handoff.go demonstrates ctx.NewSession by automating the multi-phase
|
||||
// workflow pattern: the agent works through a spec, writes a HANDOFF.md at
|
||||
// the end of each phase, then a fresh session picks up where the last one
|
||||
// left off.
|
||||
//
|
||||
// Two trigger modes are provided:
|
||||
//
|
||||
// 1. Automatic — when an assistant message ends with the sentinel
|
||||
// "<HANDOFF_READY>", the extension starts a new session and pre-loads
|
||||
// HANDOFF.md as the first prompt. Use this when you want the agent to
|
||||
// hand off control to itself with no user intervention.
|
||||
//
|
||||
// 2. Manual — the /handoff slash command starts a new session immediately
|
||||
// with the same handoff prompt. Useful when you finish a phase by hand
|
||||
// and want to clear the context window before the next one starts.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/phase-handoff.go
|
||||
//
|
||||
// Have your spec-driving agent write a HANDOFF.md at the end of each phase
|
||||
// and finish its message with the literal string `<HANDOFF_READY>`. The
|
||||
// next session boots automatically and reads HANDOFF.md as @file context.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// HANDOFFSentinel is the marker the agent appends to its last message to
|
||||
// request an automatic session switch. Change this to whatever fits your
|
||||
// workflow.
|
||||
const HANDOFFSentinel = "<HANDOFF_READY>"
|
||||
|
||||
// HANDOFFPrompt is the first prompt the new session receives. The leading
|
||||
// "@HANDOFF.md" triggers Kit's @file expansion, inlining the handoff file's
|
||||
// contents as XML-wrapped context.
|
||||
const HANDOFFPrompt = "Read @HANDOFF.md and continue with the next phase."
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Automatic trigger: detect the sentinel at the end of an agent turn.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
msgs := ctx.GetMessages()
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
last := msgs[len(msgs)-1]
|
||||
if last.Role != "assistant" || !strings.Contains(last.Content, HANDOFFSentinel) {
|
||||
return
|
||||
}
|
||||
|
||||
// NewSession blocks until the TUI completes the switch; run it in
|
||||
// a goroutine so the agent's turn-end pipeline isn't stalled.
|
||||
go func() {
|
||||
if err := ctx.NewSession(HANDOFFPrompt); err != nil {
|
||||
ctx.PrintError("phase-handoff: " + err.Error())
|
||||
return
|
||||
}
|
||||
ctx.PrintInfo("phase-handoff: started a fresh session from HANDOFF.md")
|
||||
}()
|
||||
})
|
||||
|
||||
// Manual trigger: /handoff [optional override prompt]
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "handoff",
|
||||
Description: "Start a new session, optionally with a custom prompt",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
prompt := strings.TrimSpace(args)
|
||||
if prompt == "" {
|
||||
prompt = HANDOFFPrompt
|
||||
}
|
||||
if err := ctx.NewSession(prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Optional safeguard: surface the next prompt so the user can confirm
|
||||
// before the auto-handoff proceeds. Set kit option "handoff.confirm=1"
|
||||
// to enable.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
if ctx.GetOption("handoff.confirm") != "1" {
|
||||
return nil
|
||||
}
|
||||
if e.InitialPrompt == "" {
|
||||
return nil
|
||||
}
|
||||
resp := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Start a new session with prompt:\n " + e.InitialPrompt + "\n\nProceed?",
|
||||
DefaultValue: true,
|
||||
})
|
||||
if resp.Cancelled || !resp.Value {
|
||||
return &ext.BeforeSessionSwitchResult{
|
||||
Cancel: true,
|
||||
Reason: "handoff cancelled by user",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -96,6 +96,9 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
|
||||
// Message injection — no-ops for now; ACP clients drive prompts.
|
||||
ec.SendMessage = func(string) {}
|
||||
ec.CancelAndSend = func(string) {}
|
||||
ec.NewSession = func(string) error {
|
||||
return fmt.Errorf("new session not available in ACP mode")
|
||||
}
|
||||
ec.Exit = func() {}
|
||||
|
||||
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
|
||||
|
||||
@@ -1230,6 +1230,30 @@ func (a *App) SetEditorTextFromExtension(text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RequestNewSessionFromExtension sends a NewSessionRequestEvent to the TUI
|
||||
// to end the current session and start a fresh one. If initialPrompt is
|
||||
// non-empty it is submitted as the first user turn of the new session.
|
||||
// Returns an error when running headless (no TUI attached), when the agent
|
||||
// is busy, or when a BeforeSessionSwitch extension hook cancels the switch.
|
||||
//
|
||||
// This is the implementation behind ctx.NewSession(prompt) for the
|
||||
// interactive TUI. It blocks the caller until the TUI processes the
|
||||
// switch, so it must be invoked from a goroutine outside Update().
|
||||
func (a *App) RequestNewSessionFromExtension(initialPrompt string) error {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog == nil {
|
||||
return fmt.Errorf("new session unavailable: no interactive TUI attached")
|
||||
}
|
||||
if a.IsBusy() {
|
||||
return fmt.Errorf("cannot start new session while agent is busy")
|
||||
}
|
||||
ch := make(chan error, 1)
|
||||
prog.Send(NewSessionRequestEvent{InitialPrompt: initialPrompt, ResponseCh: ch})
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
|
||||
// the model name in the status bar and message attribution.
|
||||
func (a *App) NotifyModelChanged(provider, model string) {
|
||||
|
||||
@@ -247,6 +247,21 @@ type EditorTextSetEvent struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// NewSessionRequestEvent is sent when an extension calls ctx.NewSession to
|
||||
// end the current session and start a fresh one. The TUI routes this into
|
||||
// the same /new code path (including the BeforeSessionSwitch hook and any
|
||||
// @file expansion in InitialPrompt). ResponseCh, when non-nil, receives a
|
||||
// single result so the extension goroutine can observe success or failure.
|
||||
type NewSessionRequestEvent struct {
|
||||
// InitialPrompt, when non-empty, is the first user turn to submit
|
||||
// after the session switch. @file references are expanded.
|
||||
InitialPrompt string
|
||||
// ResponseCh receives the outcome (nil error on success). Must be
|
||||
// buffered (cap >= 1) so the TUI never blocks. May be nil if the
|
||||
// caller does not need the result.
|
||||
ResponseCh chan<- error
|
||||
}
|
||||
|
||||
// ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo,
|
||||
// ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate
|
||||
// renderer and tea.Println (scrollback); the CLI handler uses
|
||||
|
||||
+11
-10
@@ -227,16 +227,17 @@ type GenerationParams struct {
|
||||
// or other custom/ prefixed models. These models are loaded from the config file
|
||||
// and merged into the custom provider in the model registry.
|
||||
type CustomModelConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
APIModelName string `json:"apiModelName,omitempty" yaml:"apiModelName,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
|
||||
// Generation parameter defaults for this model.
|
||||
// These are applied when the user hasn't explicitly set the corresponding
|
||||
|
||||
@@ -124,6 +124,32 @@ type Context struct {
|
||||
// })
|
||||
SendMultimodalMessage func(text string, files []FilePart)
|
||||
|
||||
// NewSession ends the current session and starts a fresh one (matching
|
||||
// the /new slash command). When prompt is non-empty it is submitted as
|
||||
// the first user turn of the new session, with @file references
|
||||
// expanded the same way they are for normal user input. Pass an empty
|
||||
// string to start an empty session.
|
||||
//
|
||||
// Returns an error if the agent is currently busy, if a registered
|
||||
// BeforeSessionSwitch handler cancels the switch, or if the new
|
||||
// session file cannot be created. In non-interactive (ACP / headless)
|
||||
// mode this is a no-op that returns an error.
|
||||
//
|
||||
// Typical pattern — start a fresh session at the end of a phase by
|
||||
// reading a handoff file:
|
||||
//
|
||||
// api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// msgs := ctx.GetMessages()
|
||||
// if len(msgs) == 0 {
|
||||
// return
|
||||
// }
|
||||
// last := msgs[len(msgs)-1].Content
|
||||
// if strings.Contains(last, "<HANDOFF_READY>") {
|
||||
// _ = ctx.NewSession("Read @HANDOFF.md and continue the next phase.")
|
||||
// }
|
||||
// })
|
||||
NewSession func(prompt string) error
|
||||
|
||||
// GetSessionUsage returns aggregated token usage and cost statistics
|
||||
// for the current session. This includes total input/output tokens,
|
||||
// cache read/write tokens, total cost, and request count.
|
||||
@@ -2296,6 +2322,12 @@ type BeforeSessionSwitchEvent struct {
|
||||
// Reason describes why the switch is happening: "new" for /new command,
|
||||
// "clear" for /clear command.
|
||||
Reason string
|
||||
// InitialPrompt, when non-empty, is the prompt that will be submitted
|
||||
// as the first user turn of the new session. Set when /new is invoked
|
||||
// with an argument (e.g. "/new continue from HANDOFF.md") or when an
|
||||
// extension calls ctx.NewSession(prompt). Extensions may inspect this
|
||||
// to decide whether to allow the switch.
|
||||
InitialPrompt string
|
||||
}
|
||||
|
||||
func (e BeforeSessionSwitchEvent) Type() EventType { return BeforeSessionSwitch }
|
||||
|
||||
@@ -192,6 +192,9 @@ func normalizeContext(ctx Context) Context {
|
||||
if ctx.SendMultimodalMessage == nil {
|
||||
ctx.SendMultimodalMessage = func(string, []FilePart) {}
|
||||
}
|
||||
if ctx.NewSession == nil {
|
||||
ctx.NewSession = func(string) error { return fmt.Errorf("new session not available") }
|
||||
}
|
||||
if ctx.GetSessionUsage == nil {
|
||||
ctx.GetSessionUsage = func() SessionUsage { return SessionUsage{} }
|
||||
}
|
||||
|
||||
+20
-18
@@ -44,13 +44,14 @@ func loadCustomModelsFrom(v *viper.Viper) map[string]ModelInfo {
|
||||
// modelConfigToModelInfo converts a CustomModelConfig to a ModelInfo.
|
||||
func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
|
||||
info := ModelInfo{
|
||||
ID: modelID,
|
||||
Name: cfg.Name,
|
||||
Attachment: cfg.Attachment,
|
||||
Reasoning: cfg.Reasoning,
|
||||
Temperature: cfg.Temperature,
|
||||
BaseURL: cfg.BaseURL,
|
||||
APIKey: cfg.APIKey,
|
||||
ID: modelID,
|
||||
Name: cfg.Name,
|
||||
Attachment: cfg.Attachment,
|
||||
Reasoning: cfg.Reasoning,
|
||||
Temperature: cfg.Temperature,
|
||||
BaseURL: cfg.BaseURL,
|
||||
APIKey: cfg.APIKey,
|
||||
APIModelName: cfg.APIModelName,
|
||||
Cost: Cost{
|
||||
Input: cfg.Cost.Input,
|
||||
Output: cfg.Cost.Output,
|
||||
@@ -287,17 +288,18 @@ type GenerationParams struct {
|
||||
// CustomModelConfig defines a custom model configuration loaded from the config file.
|
||||
// This is a duplicate here to avoid circular dependencies with internal/config.
|
||||
type CustomModelConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
APIModelName string `json:"apiModelName,omitempty" yaml:"apiModelName,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
|
||||
}
|
||||
|
||||
// GenerationParamsConfig is the JSON/YAML-serializable form of generation
|
||||
|
||||
@@ -1533,7 +1533,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return nil, wrapProviderErr("custom", "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
apiModelName := modelName
|
||||
if modelInfo != nil && modelInfo.APIModelName != "" {
|
||||
apiModelName = modelInfo.APIModelName
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, apiModelName)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr("custom", "model", err)
|
||||
}
|
||||
|
||||
+12
-11
@@ -16,17 +16,18 @@ var embeddedModelsJSON []byte
|
||||
|
||||
// ModelInfo represents information about a specific model.
|
||||
type ModelInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Family string // Model family (e.g., "claude", "gpt", "gemini")
|
||||
Attachment bool
|
||||
Reasoning bool
|
||||
Temperature bool
|
||||
Cost Cost
|
||||
Limit Limit
|
||||
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
|
||||
BaseURL string // Per-model base URL override (custom models only)
|
||||
APIKey string // Per-model API key override (custom models only)
|
||||
ID string
|
||||
Name string
|
||||
Family string // Model family (e.g., "claude", "gpt", "gemini")
|
||||
Attachment bool
|
||||
Reasoning bool
|
||||
Temperature bool
|
||||
Cost Cost
|
||||
Limit Limit
|
||||
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
|
||||
BaseURL string // Per-model base URL override (custom models only)
|
||||
APIKey string // Per-model API key override (custom models only)
|
||||
APIModelName string // Per-model API model name override (custom models only)
|
||||
|
||||
// Params holds per-model generation parameter defaults. These are applied
|
||||
// when the user hasn't explicitly set the corresponding CLI flag or global
|
||||
|
||||
@@ -146,9 +146,10 @@ var SlashCommands = []SlashCommand{
|
||||
},
|
||||
{
|
||||
Name: "/new",
|
||||
Description: "Start a new session",
|
||||
Description: "Start a new session (optionally with an initial prompt)",
|
||||
Category: "Navigation",
|
||||
Aliases: []string{"/n"},
|
||||
HasArgs: true,
|
||||
},
|
||||
{
|
||||
Name: "/name",
|
||||
|
||||
+119
-17
@@ -445,9 +445,12 @@ type AppModelOptions struct {
|
||||
EmitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
|
||||
|
||||
// EmitBeforeSessionSwitch, if non-nil, is called before switching
|
||||
// to a new session branch (e.g. /new, /clear). Returns (cancelled,
|
||||
// reason). May be nil if no extensions are loaded.
|
||||
EmitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
// to a new session branch (e.g. /new, /clear). reason is the trigger
|
||||
// ("new", "clear", "extension"); initialPrompt is the user prompt
|
||||
// that will run as the first turn of the new session (empty when
|
||||
// /new is called without arguments). Returns (cancelled, reason).
|
||||
// May be nil if no extensions are loaded.
|
||||
EmitBeforeSessionSwitch func(reason, initialPrompt string) (bool, string)
|
||||
|
||||
// GetGlobalShortcuts, if non-nil, returns extension-registered global
|
||||
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
|
||||
@@ -575,6 +578,13 @@ type AppModel struct {
|
||||
// flushed first, preserving chronological order.
|
||||
pendingUserPrints []string
|
||||
|
||||
// newSessionResultCh, when non-nil, receives the outcome of an
|
||||
// in-flight extension-triggered NewSession request. Set when an
|
||||
// app.NewSessionRequestEvent arrives; cleared (with a result sent)
|
||||
// in performNewSession success/failure paths or in the
|
||||
// beforeSessionSwitchResultMsg cancellation path.
|
||||
newSessionResultCh chan<- error
|
||||
|
||||
// canceling tracks whether the user has pressed ESC once during stateWorking.
|
||||
// A second ESC within 2 seconds will cancel the current step.
|
||||
canceling bool
|
||||
@@ -677,7 +687,7 @@ type AppModel struct {
|
||||
|
||||
// emitBeforeSessionSwitch emits a before-session-switch event to extensions.
|
||||
// Returns (cancelled, reason). May be nil if no extensions are loaded.
|
||||
emitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
emitBeforeSessionSwitch func(reason, initialPrompt string) (bool, string)
|
||||
|
||||
// thinkingLevel is the current extended thinking level.
|
||||
thinkingLevel string
|
||||
@@ -2192,6 +2202,25 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
|
||||
case app.NewSessionRequestEvent:
|
||||
// Extension wants to end the current session and start a fresh
|
||||
// one (with an optional initial prompt). Stash the response
|
||||
// channel so performNewSession (or the before-hook cancellation
|
||||
// path) can signal completion, then run the same /new pipeline
|
||||
// the user would trigger.
|
||||
if msg.ResponseCh != nil {
|
||||
// Only one new-session request in flight at a time. If a
|
||||
// previous response channel is still pending, fail it before
|
||||
// replacing it so the prior extension goroutine unblocks.
|
||||
if m.newSessionResultCh != nil {
|
||||
m.newSessionResultCh <- fmt.Errorf("superseded by a newer NewSession request")
|
||||
}
|
||||
m.newSessionResultCh = msg.ResponseCh
|
||||
}
|
||||
if cmd := m.handleNewCommand(msg.InitialPrompt); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.PasswordPromptEvent:
|
||||
// Sudo password prompt - show a modal input prompt
|
||||
// If already in prompt state, cancel the new request
|
||||
@@ -2397,8 +2426,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// session reset if the hook did not cancel.
|
||||
if msg.cancelled {
|
||||
m.printSystemMessage(msg.reason)
|
||||
m.signalNewSessionResult(fmt.Errorf("session switch cancelled: %s", msg.reason))
|
||||
} else {
|
||||
cmds = append(cmds, m.performNewSession())
|
||||
cmds = append(cmds, m.performNewSession(msg.initialPrompt))
|
||||
}
|
||||
|
||||
case beforeForkResultMsg:
|
||||
@@ -3241,7 +3271,7 @@ func (m *AppModel) handleSlashCommand(sc *commands.SlashCommand, args string) te
|
||||
case "/fork":
|
||||
return m.handleForkCommand()
|
||||
case "/new":
|
||||
return m.handleNewCommand()
|
||||
return m.handleNewCommand(args)
|
||||
case "/name":
|
||||
return m.handleNameCommand(args)
|
||||
case "/resume":
|
||||
@@ -3672,7 +3702,7 @@ func (m *AppModel) printHelpMessage() {
|
||||
"**Navigation:**\n" +
|
||||
"- `/tree`: Navigate session tree (switch branches)\n" +
|
||||
"- `/fork`: Branch from an earlier message\n" +
|
||||
"- `/new`: Start a new session (discards context, saves old session)\n" +
|
||||
"- `/new [prompt]`: Start a new session (discards context, saves old session). With a prompt, runs it as the first message; supports `@file` attachments.\n" +
|
||||
"- `/resume`: Open session picker to switch sessions\n" +
|
||||
"- `/name <name>`: Set a display name for this session\n\n" +
|
||||
"**System:**\n" +
|
||||
@@ -4368,7 +4398,12 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
|
||||
|
||||
// handleNewCommand starts a completely new session (Pi-style /new behavior).
|
||||
// Creates a new session file, discarding all context from the previous conversation.
|
||||
func (m *AppModel) handleNewCommand() tea.Cmd {
|
||||
// If initialPrompt is non-empty it is submitted as the first user turn of the
|
||||
// new session, with @file references expanded the same way they are for
|
||||
// regular user input.
|
||||
func (m *AppModel) handleNewCommand(initialPrompt string) tea.Cmd {
|
||||
initialPrompt = strings.TrimSpace(initialPrompt)
|
||||
|
||||
// Emit before-session-switch event in a goroutine so that extension
|
||||
// handlers can call blocking operations (e.g. ctx.PromptConfirm) without
|
||||
// deadlocking the BubbleTea event loop.
|
||||
@@ -4376,23 +4411,25 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
|
||||
emit := m.emitBeforeSessionSwitch
|
||||
ctrl := m.appCtrl
|
||||
go func() {
|
||||
cancelled, reason := emit("new")
|
||||
cancelled, reason := emit("new", initialPrompt)
|
||||
ctrl.SendEvent(beforeSessionSwitchResultMsg{
|
||||
cancelled: cancelled,
|
||||
reason: reason,
|
||||
cancelled: cancelled,
|
||||
reason: reason,
|
||||
initialPrompt: initialPrompt,
|
||||
})
|
||||
}()
|
||||
return noopCmd
|
||||
}
|
||||
|
||||
return m.performNewSession()
|
||||
return m.performNewSession(initialPrompt)
|
||||
}
|
||||
|
||||
// performNewSession performs the actual session reset. Called either directly
|
||||
// (when no before-hook exists) or after the async hook completes.
|
||||
// Matches Pi behavior: creates a completely new session file, discarding all
|
||||
// context from the previous conversation.
|
||||
func (m *AppModel) performNewSession() tea.Cmd {
|
||||
// context from the previous conversation. If initialPrompt is non-empty it
|
||||
// is submitted as the first user turn (with @file expansion).
|
||||
func (m *AppModel) performNewSession(initialPrompt string) tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
// No tree session — just clear messages.
|
||||
@@ -4406,13 +4443,16 @@ func (m *AppModel) performNewSession() tea.Cmd {
|
||||
// Clear the ScrollList so the new session starts fresh.
|
||||
m.messages = []MessageItem{}
|
||||
m.printSystemMessage("Conversation cleared. Starting fresh.")
|
||||
return nil
|
||||
cmd := m.submitInitialPrompt(initialPrompt)
|
||||
m.signalNewSessionResult(nil)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Create a brand new session file (Pi-style /new behavior)
|
||||
newTs, err := session.CreateTreeSession(m.cwd)
|
||||
if err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to create new session: %v", err))
|
||||
m.signalNewSessionResult(fmt.Errorf("create new session: %w", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4425,6 +4465,67 @@ func (m *AppModel) performNewSession() tea.Cmd {
|
||||
// Clear the ScrollList so the new session starts fresh.
|
||||
m.messages = []MessageItem{}
|
||||
m.printSystemMessage("New session started. Previous conversation saved.")
|
||||
cmd := m.submitInitialPrompt(initialPrompt)
|
||||
m.signalNewSessionResult(nil)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// signalNewSessionResult delivers the outcome of an extension-triggered
|
||||
// NewSession request (if one is in flight) and clears the response channel.
|
||||
// Safe to call when no request is pending.
|
||||
func (m *AppModel) signalNewSessionResult(err error) {
|
||||
if m.newSessionResultCh == nil {
|
||||
return
|
||||
}
|
||||
ch := m.newSessionResultCh
|
||||
m.newSessionResultCh = nil
|
||||
// Channel is buffered (cap >= 1) by contract — send is non-blocking.
|
||||
ch <- err
|
||||
}
|
||||
|
||||
// submitInitialPrompt is the shared submission path used by /new <prompt>
|
||||
// and ctx.NewSession(prompt). It mirrors the SubmitMsg handler: @file
|
||||
// references are expanded via fileutil.ProcessFileAttachments and the
|
||||
// resulting prompt is forwarded to AppController.Run / RunWithFiles.
|
||||
// Returns nil when prompt is empty.
|
||||
func (m *AppModel) submitInitialPrompt(prompt string) tea.Cmd {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" || m.appCtrl == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
processedText := prompt
|
||||
var fileParts []kit.LLMFilePart
|
||||
if m.cwd != "" {
|
||||
result := fileutil.ProcessFileAttachments(prompt, m.cwd, m.mcpResourceReader)
|
||||
processedText = result.ProcessedText
|
||||
for _, fp := range result.FileParts {
|
||||
fileParts = append(fileParts, kit.LLMFilePart{
|
||||
Filename: fp.Filename,
|
||||
Data: fp.Data,
|
||||
MediaType: fp.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
displayText := prompt
|
||||
if len(fileParts) > 0 {
|
||||
displayText = fmt.Sprintf("%s\n[%d file(s) attached]", prompt, len(fileParts))
|
||||
}
|
||||
|
||||
var qLen int
|
||||
if len(fileParts) > 0 {
|
||||
qLen = m.appCtrl.RunWithFiles(processedText, fileParts)
|
||||
} else {
|
||||
qLen = m.appCtrl.Run(processedText)
|
||||
}
|
||||
if qLen > 0 {
|
||||
m.queuedMessages = append(m.queuedMessages, displayText)
|
||||
m.layoutDirty = true
|
||||
} else {
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
|
||||
m.flushStreamAndPendingUserMessages()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5133,8 +5234,9 @@ type mcpPromptResultMsg struct {
|
||||
// executed before-session-switch hook. The hook runs in a goroutine so that
|
||||
// blocking operations like ctx.PromptConfirm() do not deadlock the TUI.
|
||||
type beforeSessionSwitchResultMsg struct {
|
||||
cancelled bool
|
||||
reason string
|
||||
cancelled bool
|
||||
reason string
|
||||
initialPrompt string
|
||||
}
|
||||
|
||||
// beforeForkResultMsg carries the result of an asynchronously executed
|
||||
|
||||
@@ -1144,3 +1144,128 @@ func TestRenderQueuedMessages_truncatesLongMessages(t *testing.T) {
|
||||
t.Fatalf("expected truncated output to be ≤10 lines, got %d lines", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// /new <prompt> and ctx.NewSession
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestNewCommand_noPrompt verifies that /new without an argument resets the
|
||||
// session (clears messages, prints the system message) and does NOT submit
|
||||
// any prompt to the controller.
|
||||
func TestNewCommand_noPrompt(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
_ = m.handleNewCommand("")
|
||||
|
||||
if len(ctrl.runCalls) != 0 {
|
||||
t.Fatalf("expected no Run calls for empty prompt, got %v", ctrl.runCalls)
|
||||
}
|
||||
if ctrl.clearMsgCalled == 0 {
|
||||
t.Fatal("expected ClearMessages to be called when no tree session is active")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCommand_withPrompt verifies that /new <prompt> submits the prompt
|
||||
// to AppController.Run after clearing the session.
|
||||
func TestNewCommand_withPrompt(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
_ = m.handleNewCommand("continue from where we left off")
|
||||
|
||||
if len(ctrl.runCalls) != 1 {
|
||||
t.Fatalf("expected exactly 1 Run call, got %d (%v)", len(ctrl.runCalls), ctrl.runCalls)
|
||||
}
|
||||
if ctrl.runCalls[0] != "continue from where we left off" {
|
||||
t.Fatalf("unexpected prompt submitted: %q", ctrl.runCalls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCommand_whitespacePromptIsEmpty verifies that an all-whitespace
|
||||
// prompt is treated as empty (no Run call).
|
||||
func TestNewCommand_whitespacePromptIsEmpty(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
_ = m.handleNewCommand(" \n\t ")
|
||||
|
||||
if len(ctrl.runCalls) != 0 {
|
||||
t.Fatalf("expected no Run calls for whitespace-only prompt, got %v", ctrl.runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewSessionRequestEvent_signalsResponseCh verifies that
|
||||
// app.NewSessionRequestEvent runs the same /new pipeline and delivers a
|
||||
// nil error to the response channel on success.
|
||||
func TestNewSessionRequestEvent_signalsResponseCh(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
ch := make(chan error, 1)
|
||||
m = sendMsg(m, app.NewSessionRequestEvent{
|
||||
InitialPrompt: "hello from extension",
|
||||
ResponseCh: ch,
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-ch:
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on success, got %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected ResponseCh to receive a value")
|
||||
}
|
||||
if len(ctrl.runCalls) != 1 || ctrl.runCalls[0] != "hello from extension" {
|
||||
t.Fatalf("expected prompt to be submitted to Run, got %v", ctrl.runCalls)
|
||||
}
|
||||
if m.newSessionResultCh != nil {
|
||||
t.Fatal("expected newSessionResultCh to be cleared after signaling")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewSessionRequestEvent_cancelledByExtension verifies that when the
|
||||
// before-session-switch hook cancels, the response channel receives an
|
||||
// error.
|
||||
func TestNewSessionRequestEvent_cancelledByExtension(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
m.emitBeforeSessionSwitch = func(reason, prompt string) (bool, string) {
|
||||
return true, "vetoed by test"
|
||||
}
|
||||
|
||||
ch := make(chan error, 1)
|
||||
m = sendMsg(m, app.NewSessionRequestEvent{
|
||||
InitialPrompt: "should be cancelled",
|
||||
ResponseCh: ch,
|
||||
})
|
||||
// The before-hook runs in a goroutine, which sends back a
|
||||
// beforeSessionSwitchResultMsg. Pump that synchronously by reading
|
||||
// the SendEvent call indirectly: SendEvent on stub is a no-op so we
|
||||
// need to dispatch the message ourselves to simulate the round trip.
|
||||
sendMsg(m, beforeSessionSwitchResultMsg{
|
||||
cancelled: true,
|
||||
reason: "vetoed by test",
|
||||
initialPrompt: "should be cancelled",
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-ch:
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error on cancellation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "vetoed by test") {
|
||||
t.Fatalf("expected error to mention the veto reason, got %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected ResponseCh to receive a value")
|
||||
}
|
||||
if len(ctrl.runCalls) != 0 {
|
||||
t.Fatalf("expected no Run calls when cancelled, got %v", ctrl.runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ type ExtensionAPI interface {
|
||||
EmitCustomEvent(name, data string)
|
||||
EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string)
|
||||
EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string)
|
||||
EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt string) (cancelled bool, reason string)
|
||||
|
||||
// Commands
|
||||
Commands() []ExtensionCommandDef
|
||||
@@ -567,11 +568,20 @@ func (e *extensionAPI) EmitBeforeFork(targetID string, isUserMsg bool, userText
|
||||
}
|
||||
|
||||
func (e *extensionAPI) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
|
||||
return e.EmitBeforeSessionSwitchWithPrompt(switchReason, "")
|
||||
}
|
||||
|
||||
// EmitBeforeSessionSwitchWithPrompt is like EmitBeforeSessionSwitch but also
|
||||
// supplies the initial user prompt (if any) that will be submitted as the
|
||||
// first turn of the new session. Extensions inspecting BeforeSessionSwitchEvent
|
||||
// see this value in the event's InitialPrompt field.
|
||||
func (e *extensionAPI) EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt string) (cancelled bool, reason string) {
|
||||
if e.kit.extRunner == nil || !e.kit.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := e.kit.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
|
||||
Reason: switchReason,
|
||||
Reason: switchReason,
|
||||
InitialPrompt: initialPrompt,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
|
||||
@@ -151,6 +151,7 @@ customModels:
|
||||
name: "My Custom Model"
|
||||
baseUrl: "http://localhost:8080/v1"
|
||||
apiKey: "my-secret-key"
|
||||
apiModelName: "gpt-4-turbo"
|
||||
reasoning: true
|
||||
temperature: true
|
||||
cost:
|
||||
@@ -168,6 +169,7 @@ customModels:
|
||||
| `name` | string | Yes | Display name for the model |
|
||||
| `baseUrl` | string | No | Per-model base URL override; when set, `--provider-url` is not required |
|
||||
| `apiKey` | string | No | Per-model API key override |
|
||||
| `apiModelName` | string | No | Overrides the model identifier sent in API requests; defaults to the config key |
|
||||
| `reasoning` | bool | No | Whether the model supports reasoning/thinking |
|
||||
| `temperature` | bool | No | Whether the model supports temperature adjustment |
|
||||
| `cost.input` | float | No | Cost per 1K input tokens |
|
||||
|
||||
Reference in New Issue
Block a user