mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-21 14:39:38 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84298a0743 | |||
| 393074447b | |||
| 879723fe90 | |||
| 57250a3a3d | |||
| 7e1686e572 | |||
| 4a8b10cde7 | |||
| cc5611eff7 |
@@ -83,9 +83,9 @@ tmux kill-session -t kittest # cleanup
|
||||
### Non-Interactive Kit (Subprocess Spawning)
|
||||
Extensions can spawn Kit as a subprocess for sub-agent patterns:
|
||||
```bash
|
||||
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
|
||||
kit --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model "question"
|
||||
```
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
|
||||
Positional args are the prompt. `@file` args attach file content. Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
|
||||
|
||||
## External Repo Research
|
||||
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
|
||||
|
||||
@@ -23,7 +23,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
|
||||
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
|
||||
- **Session Management**: Tree-based conversation history with branching support
|
||||
- **Non-Interactive Mode**: Script-friendly `--prompt` mode with JSON output
|
||||
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
|
||||
- **Go SDK**: Embed Kit in your own applications
|
||||
|
||||
## Installation
|
||||
@@ -57,7 +57,10 @@ go build -o kit ./cmd/kit
|
||||
kit
|
||||
|
||||
# Run a one-off prompt
|
||||
kit --prompt "List files in src/"
|
||||
kit "List files in src/"
|
||||
|
||||
# Attach files as context
|
||||
kit @main.go @test.go "Review these files"
|
||||
|
||||
# Continue the most recent session
|
||||
kit --continue
|
||||
@@ -70,13 +73,13 @@ kit --model anthropic/claude-sonnet-4-5-20250929
|
||||
|
||||
```bash
|
||||
# Get JSON output for scripting
|
||||
kit --prompt "Explain main.go" --json
|
||||
kit "Explain main.go" --json
|
||||
|
||||
# Quiet mode (final response only)
|
||||
kit --quiet --prompt "Run tests"
|
||||
kit "Run tests" --quiet
|
||||
|
||||
# Ephemeral mode (no session file)
|
||||
kit --prompt "Quick question" --no-session
|
||||
kit "Quick question" --no-session
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -142,11 +145,10 @@ mcpServers:
|
||||
--resume, -r Interactive session picker
|
||||
--no-session Ephemeral mode, no persistence
|
||||
|
||||
# Behavior
|
||||
--prompt, -p Run in non-interactive mode with given prompt
|
||||
--quiet Suppress all output (only with --prompt)
|
||||
--json Output response as JSON (only with --prompt)
|
||||
--no-exit Continue to interactive mode after --prompt
|
||||
# Behavior (non-interactive: pass prompt as positional arg)
|
||||
--quiet Suppress all output (non-interactive only)
|
||||
--json Output response as JSON (non-interactive only)
|
||||
--no-exit Enter interactive mode after prompt completes
|
||||
--max-steps Maximum agent steps (0 for unlimited)
|
||||
--stream Enable streaming output (default: true)
|
||||
--compact Enable compact output mode
|
||||
@@ -378,7 +380,7 @@ host.ClearSession()
|
||||
Spawn Kit as a subprocess for multi-agent orchestration:
|
||||
|
||||
```bash
|
||||
kit --prompt "Analyze codebase" \
|
||||
kit "Analyze codebase" \
|
||||
--json \
|
||||
--no-session \
|
||||
--no-extensions \
|
||||
|
||||
+84
-19
@@ -29,7 +29,7 @@ var (
|
||||
providerURL string
|
||||
providerAPIKey string
|
||||
debugMode bool
|
||||
promptFlag string
|
||||
positionalPrompt string // set by processPositionalArgs from CLI positional args
|
||||
quietFlag bool
|
||||
jsonFlag bool
|
||||
noExitFlag bool
|
||||
@@ -101,10 +101,16 @@ func (a *kitUIAdapter) GetExtensionToolCount() int {
|
||||
// an interface to interact with various AI models through a unified interface
|
||||
// with support for MCP servers and tool integration.
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "kit",
|
||||
Use: "kit [@file...] [prompt]",
|
||||
Short: "Chat with AI models through a unified interface",
|
||||
Long: `KIT (Knowledge Inference Tool) — A lightweight AI agent for coding`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Parse positional args: @-prefixed args are file attachments,
|
||||
// remaining args form the prompt (like Pi: kit @code.ts "Review this").
|
||||
if len(args) > 0 {
|
||||
processPositionalArgs(args)
|
||||
}
|
||||
return runKit(context.Background())
|
||||
},
|
||||
}
|
||||
@@ -202,14 +208,13 @@ func init() {
|
||||
"model to use (format: provider/model)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&debugMode, "debug", false, "enable debug logging")
|
||||
|
||||
rootCmd.PersistentFlags().
|
||||
StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt")
|
||||
BoolVar(&quietFlag, "quiet", false, "suppress all output (non-interactive mode only)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)")
|
||||
BoolVar(&jsonFlag, "json", false, "output response as JSON (non-interactive mode only)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&jsonFlag, "json", false, "output response as JSON (only works with --prompt)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&noExitFlag, "no-exit", false, "prevent non-interactive mode from exiting, show input prompt instead")
|
||||
BoolVar(&noExitFlag, "no-exit", false, "enter interactive mode after non-interactive prompt completes")
|
||||
rootCmd.PersistentFlags().
|
||||
IntVar(&maxSteps, "max-steps", 0, "maximum number of agent steps (0 for unlimited)")
|
||||
rootCmd.PersistentFlags().
|
||||
@@ -252,7 +257,6 @@ func init() {
|
||||
_ = viper.BindPFlag("system-prompt", rootCmd.PersistentFlags().Lookup("system-prompt"))
|
||||
_ = viper.BindPFlag("model", rootCmd.PersistentFlags().Lookup("model"))
|
||||
_ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
|
||||
_ = viper.BindPFlag("prompt", rootCmd.PersistentFlags().Lookup("prompt"))
|
||||
_ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
|
||||
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
|
||||
_ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact"))
|
||||
@@ -277,6 +281,62 @@ func init() {
|
||||
rootCmd.AddCommand(authCmd)
|
||||
}
|
||||
|
||||
// processPositionalArgs separates positional CLI arguments into @file
|
||||
// attachments and prompt text. File content is read and prepended to
|
||||
// positionalPrompt so the agent receives it. Positional args are the primary
|
||||
// way to run non-interactive mode:
|
||||
//
|
||||
// kit "Explain this codebase"
|
||||
// kit @code.ts @test.ts "Review these files"
|
||||
func processPositionalArgs(args []string) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
var fileTokens []string
|
||||
var promptParts []string
|
||||
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "@") && len(arg) > 1 {
|
||||
fileTokens = append(fileTokens, arg)
|
||||
} else {
|
||||
promptParts = append(promptParts, arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Build file content prefix from @file arguments.
|
||||
var fileContent strings.Builder
|
||||
for _, token := range fileTokens {
|
||||
expanded := ui.ProcessFileAttachments(token, cwd)
|
||||
if expanded != token {
|
||||
// File was resolved — add it.
|
||||
fileContent.WriteString(expanded)
|
||||
fileContent.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Combine: positional prompt text is appended to any existing --prompt
|
||||
// value (for backward compat with subprocess invocations).
|
||||
if len(promptParts) > 0 {
|
||||
extra := strings.Join(promptParts, " ")
|
||||
if positionalPrompt != "" {
|
||||
positionalPrompt = positionalPrompt + " " + extra
|
||||
} else {
|
||||
positionalPrompt = extra
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend file content to the prompt.
|
||||
if fileContent.Len() > 0 {
|
||||
if positionalPrompt == "" {
|
||||
positionalPrompt = strings.TrimSpace(fileContent.String())
|
||||
} else {
|
||||
positionalPrompt = strings.TrimSpace(fileContent.String()) + "\n\n" + positionalPrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runKit(ctx context.Context) error {
|
||||
return runNormalMode(ctx)
|
||||
}
|
||||
@@ -521,17 +581,17 @@ func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
|
||||
|
||||
func runNormalMode(ctx context.Context) error {
|
||||
// Validate flag combinations
|
||||
if quietFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
if quietFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--quiet requires a prompt (e.g. kit \"your question\" --quiet)")
|
||||
}
|
||||
if jsonFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--json flag can only be used with --prompt/-p")
|
||||
if jsonFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--json requires a prompt (e.g. kit \"your question\" --json)")
|
||||
}
|
||||
if jsonFlag && noExitFlag {
|
||||
return fmt.Errorf("--json and --no-exit flags cannot be used together")
|
||||
}
|
||||
if noExitFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--no-exit flag can only be used with --prompt/-p")
|
||||
if noExitFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--no-exit requires a prompt (e.g. kit \"your question\" --no-exit)")
|
||||
}
|
||||
|
||||
// Set up logging
|
||||
@@ -598,7 +658,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
|
||||
// Create CLI for non-interactive mode only.
|
||||
var cli *ui.CLI
|
||||
if promptFlag != "" {
|
||||
if positionalPrompt != "" {
|
||||
cli, err = SetupCLIForNonInteractive(kitInstance)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup CLI: %v", err)
|
||||
@@ -645,7 +705,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: promptFlag == "",
|
||||
Interactive: positionalPrompt == "",
|
||||
Print: func(text string) { appInstance.PrintFromExtension("", text) },
|
||||
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
@@ -903,13 +963,13 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if promptFlag != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
if positionalPrompt != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
if quietFlag {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
return fmt.Errorf("--quiet requires a prompt")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
@@ -926,6 +986,11 @@ func runNormalMode(ctx context.Context) error {
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
// JSON mode: no intermediate display, structured JSON output.
|
||||
result, err := appInstance.RunOnceResult(ctx, prompt)
|
||||
|
||||
@@ -488,11 +488,11 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
|
||||
// Build subprocess arguments. Use --json for structured output parsing.
|
||||
// Don't pass --model; the subprocess inherits the same config/env default.
|
||||
args := []string{
|
||||
"--prompt", question,
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--system-prompt", tmpFile.Name(),
|
||||
question,
|
||||
}
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
|
||||
@@ -209,10 +209,10 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--prompt", prompt,
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
prompt,
|
||||
}
|
||||
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
|
||||
@@ -197,6 +197,22 @@ func (a *App) GetTreeSession() *session.TreeManager {
|
||||
return a.opts.TreeSession
|
||||
}
|
||||
|
||||
// AddContextMessage adds a user-role message to the conversation history
|
||||
// without triggering an LLM response. Used by the ! shell command prefix
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
// subsequent turns.
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) AddContextMessage(text string) {
|
||||
msg := fantasy.NewUserMessage(text)
|
||||
a.store.Add(msg)
|
||||
|
||||
// Persist to tree session if active.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
_, _ = ts.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// CompactConversation summarises older messages to free context space. It
|
||||
// returns an error synchronously if compaction cannot start (agent busy or
|
||||
// app closed). The actual compaction runs in a background goroutine and
|
||||
|
||||
@@ -130,7 +130,7 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
}
|
||||
|
||||
// Truncate from tail (keep last N lines, most relevant for bash)
|
||||
tr := truncateTail(output, defaultMaxLines, defaultMaxBytes)
|
||||
tr := TruncateTail(output, defaultMaxLines, defaultMaxBytes)
|
||||
|
||||
if exitCode != 0 {
|
||||
return fantasy.NewTextErrorResponse(tr.Content), nil
|
||||
|
||||
@@ -9,6 +9,11 @@ const (
|
||||
defaultMaxLines = 2000
|
||||
defaultMaxBytes = 50 * 1024 // 50KB
|
||||
grepMaxLineLen = 500
|
||||
|
||||
// DefaultMaxLines is the exported default line limit for truncation.
|
||||
DefaultMaxLines = defaultMaxLines
|
||||
// DefaultMaxBytes is the exported default byte limit for truncation.
|
||||
DefaultMaxBytes = defaultMaxBytes
|
||||
)
|
||||
|
||||
// TruncationResult describes how output was truncated.
|
||||
@@ -20,9 +25,9 @@ type TruncationResult struct {
|
||||
Kept int // lines kept after truncation
|
||||
}
|
||||
|
||||
// truncateTail keeps the last maxLines lines and at most maxBytes bytes.
|
||||
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
|
||||
// Used for bash output where the tail is most relevant.
|
||||
func truncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
if maxLines <= 0 {
|
||||
maxLines = defaultMaxLines
|
||||
}
|
||||
|
||||
@@ -28,3 +28,33 @@ type TreeNodeSelectedMsg struct {
|
||||
|
||||
// TreeCancelledMsg is sent when the user cancels the tree selector (ESC).
|
||||
type TreeCancelledMsg struct{}
|
||||
|
||||
// shellCommandMsg is sent by the InputComponent when the user submits a
|
||||
// ! or !! prefixed command. The parent model intercepts this to execute
|
||||
// the shell command directly instead of forwarding to the LLM.
|
||||
//
|
||||
// Matching pi's behavior:
|
||||
// - !cmd → run shell command, output INCLUDED in LLM context
|
||||
// - !!cmd → run shell command, output EXCLUDED from LLM context
|
||||
type shellCommandMsg struct {
|
||||
// Command is the shell command to execute (prefix stripped).
|
||||
Command string
|
||||
// ExcludeFromContext is true for !! (output excluded from LLM context),
|
||||
// false for ! (output included in LLM context).
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
|
||||
// shellCommandResultMsg carries the result of a shell command execution
|
||||
// back to the parent model for display.
|
||||
type shellCommandResultMsg struct {
|
||||
// Command is the original shell command that was executed.
|
||||
Command string
|
||||
// Output is the combined stdout/stderr output.
|
||||
Output string
|
||||
// ExitCode is the process exit code (0 = success).
|
||||
ExitCode int
|
||||
// Err is non-nil if the command failed to start or timed out.
|
||||
Err error
|
||||
// ExcludeFromContext mirrors the flag from shellCommandMsg.
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
|
||||
@@ -282,12 +282,34 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// handleSubmit processes the submitted text. Slash commands that affect app
|
||||
// state are executed here; /quit returns tea.Quit; everything else returns a
|
||||
// submitMsg tea.Cmd for the parent to forward to app.Run().
|
||||
//
|
||||
// Shell command prefixes (matching pi's behavior):
|
||||
// - !cmd → execute shell command, output INCLUDED in LLM context
|
||||
// - !!cmd → execute shell command, output EXCLUDED from LLM context
|
||||
func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for shell command prefixes before slash commands. Test !! first
|
||||
// (more specific) to avoid matching the single-! case for double-bang.
|
||||
if strings.HasPrefix(trimmed, "!!") {
|
||||
cmd := strings.TrimSpace(trimmed[2:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: true}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(trimmed, "!") {
|
||||
cmd := strings.TrimSpace(trimmed[1:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve via canonical command lookup so aliases are handled uniformly.
|
||||
// Only /quit and /clear are handled locally — /clear-queue must go
|
||||
// through the parent model so it can update queueCount directly
|
||||
|
||||
+176
-1
@@ -1,14 +1,18 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/core"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
@@ -72,6 +76,11 @@ type AppController interface {
|
||||
// results back to the TUI without going through tea.Cmd (which can stall
|
||||
// when the goroutine blocks on interactive prompts).
|
||||
SendEvent(tea.Msg)
|
||||
// AddContextMessage adds a user-role message to the conversation history
|
||||
// without triggering an LLM response. Used by the ! shell command prefix
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
// subsequent turns.
|
||||
AddContextMessage(text string)
|
||||
}
|
||||
|
||||
// SkillItem holds display metadata about a loaded skill for the startup
|
||||
@@ -941,6 +950,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.state = stateWorking
|
||||
}
|
||||
|
||||
// ── Shell command (! / !!) ───────────────────────────────────────────────
|
||||
case shellCommandMsg:
|
||||
// Execute the shell command asynchronously so the TUI stays responsive.
|
||||
cmds = append(cmds, m.executeShellCommand(msg))
|
||||
|
||||
case shellCommandResultMsg:
|
||||
cmds = append(cmds, m.handleShellCommandResult(msg))
|
||||
|
||||
// ── App layer events ─────────────────────────────────────────────────────
|
||||
|
||||
case app.SpinnerEvent:
|
||||
@@ -1733,7 +1750,10 @@ func (m *AppModel) printHelpMessage() tea.Cmd {
|
||||
help += skillHelp.String()
|
||||
}
|
||||
|
||||
help += "**Keys:**\n" +
|
||||
help += "**Shell Commands:**\n" +
|
||||
"- `!command`: Run shell command, output included in LLM context\n" +
|
||||
"- `!!command`: Run shell command, output excluded from LLM context\n\n" +
|
||||
"**Keys:**\n" +
|
||||
"- `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."
|
||||
@@ -2331,3 +2351,158 @@ func (m *AppModel) resolveOverlay(resp app.OverlayResponse) {
|
||||
m.overlay = nil
|
||||
m.state = m.preOverlayState
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Shell command execution (! and !!)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// shellCommandTimeout is the maximum duration for a user shell command.
|
||||
const shellCommandTimeout = 120 * time.Second
|
||||
|
||||
// executeShellCommand runs a shell command asynchronously and returns the
|
||||
// result as a shellCommandResultMsg. This is launched from Update() as a
|
||||
// tea.Cmd so the TUI stays responsive during execution.
|
||||
func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
|
||||
command := msg.Command
|
||||
excludeFromContext := msg.ExcludeFromContext
|
||||
cwd := m.cwd
|
||||
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shellCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", command)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
// Non-zero exit is reported via exitCode, not as an error.
|
||||
err = nil
|
||||
} else if ctx.Err() == context.DeadlineExceeded {
|
||||
return shellCommandResultMsg{
|
||||
Command: command,
|
||||
Output: fmt.Sprintf("command timed out after %v", shellCommandTimeout),
|
||||
ExitCode: -1,
|
||||
Err: fmt.Errorf("command timed out after %v", shellCommandTimeout),
|
||||
ExcludeFromContext: excludeFromContext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine stdout + stderr.
|
||||
var combined strings.Builder
|
||||
if stdout.Len() > 0 {
|
||||
combined.WriteString(stdout.String())
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
if combined.Len() > 0 {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
combined.WriteString(stderr.String())
|
||||
}
|
||||
|
||||
return shellCommandResultMsg{
|
||||
Command: command,
|
||||
Output: combined.String(),
|
||||
ExitCode: exitCode,
|
||||
Err: err,
|
||||
ExcludeFromContext: excludeFromContext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleShellCommandResult processes the result of a shell command execution.
|
||||
// It prints the output to scrollback and optionally injects it into the
|
||||
// conversation context (for ! commands) so the LLM can see it.
|
||||
func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
|
||||
theme := GetTheme()
|
||||
|
||||
// Build the display header.
|
||||
var header string
|
||||
if msg.ExcludeFromContext {
|
||||
header = fmt.Sprintf("$ %s (excluded from context)", msg.Command)
|
||||
} else {
|
||||
header = fmt.Sprintf("$ %s", msg.Command)
|
||||
}
|
||||
|
||||
// Build the output content.
|
||||
var content strings.Builder
|
||||
content.WriteString(header)
|
||||
|
||||
// Display-level truncation: show first maxShellDisplayLines lines with a
|
||||
// "...(N more lines)" hint, matching the tool result renderer behavior.
|
||||
const maxShellDisplayLines = 20
|
||||
|
||||
displayOutput := msg.Output
|
||||
var displayHiddenCount int
|
||||
if displayOutput != "" {
|
||||
lines := strings.Split(displayOutput, "\n")
|
||||
if len(lines) > maxShellDisplayLines {
|
||||
displayHiddenCount = len(lines) - maxShellDisplayLines
|
||||
displayOutput = strings.Join(lines[:maxShellDisplayLines], "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Err != nil {
|
||||
content.WriteString(fmt.Sprintf("\n\nError: %v", msg.Err))
|
||||
} else if displayOutput != "" {
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(displayOutput)
|
||||
if displayHiddenCount > 0 {
|
||||
content.WriteString(fmt.Sprintf("\n\n...(%d more lines)", displayHiddenCount))
|
||||
}
|
||||
} else {
|
||||
content.WriteString("\n\n(no output)")
|
||||
}
|
||||
|
||||
if msg.ExitCode != 0 {
|
||||
content.WriteString(fmt.Sprintf("\n\nExit code: %d", msg.ExitCode))
|
||||
}
|
||||
|
||||
// Choose border color: dim for excluded, accent for included.
|
||||
borderClr := theme.Accent
|
||||
if msg.ExcludeFromContext {
|
||||
borderClr = theme.Muted
|
||||
}
|
||||
|
||||
rendered := renderContentBlock(
|
||||
content.String(),
|
||||
m.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderClr),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, tea.Println(rendered))
|
||||
|
||||
// For ! (included in context): inject the command output into the
|
||||
// conversation as a user message so the LLM can reference it on the
|
||||
// next turn. This does NOT trigger an LLM response — it only adds
|
||||
// to the conversation history.
|
||||
if !msg.ExcludeFromContext && m.appCtrl != nil {
|
||||
// Truncate context output with the same limits as display.
|
||||
contextOutput := msg.Output
|
||||
if contextOutput != "" {
|
||||
tr := core.TruncateTail(contextOutput, core.DefaultMaxLines, core.DefaultMaxBytes)
|
||||
contextOutput = tr.Content
|
||||
} else {
|
||||
contextOutput = "(no output)"
|
||||
}
|
||||
contextMsg := fmt.Sprintf("<shell_command>\n<command>%s</command>\n<output>\n%s</output>\n<exit_code>%d</exit_code>\n</shell_command>",
|
||||
msg.Command, contextOutput, msg.ExitCode)
|
||||
m.appCtrl.AddContextMessage(contextMsg)
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ func (s *stubAppController) SendEvent(_ tea.Msg) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
func (s *stubAppController) AddContextMessage(_ string) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Stub child components
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user