Compare commits

..

7 Commits

Author SHA1 Message Date
Ed Zynda 84298a0743 fix: add 20-line display truncation for shell command output
Match the tool result renderer behavior — show first 20 lines
with a '...(N more lines)' hint. Full output still goes to
context (with TruncateTail limits) for ! commands.
2026-03-05 19:31:22 +03:00
Ed Zynda 393074447b fix: truncate shell command output in TUI using same limits as core bash tool 2026-03-05 19:24:49 +03:00
Ed Zynda 879723fe90 feat: add ! and !! shell command prefixes (matching pi behavior)
! runs a shell command with output included in LLM context.
!! runs a shell command with output excluded from LLM context.
Adds AddContextMessage to AppController for injecting messages
without triggering an LLM turn.
2026-03-05 19:17:41 +03:00
Ed Zynda 57250a3a3d refactor: remove --prompt flag, positional args are the only way
Drop the --prompt/-p flag entirely. Non-interactive mode is now
triggered by passing positional arguments:

  kit "Explain this"
  kit @file.go "Review this" --json
  kit @a.go @b.go --quiet

Updated extension examples (kit-kit.go, subagent-widget.go) to pass
the prompt as a positional arg. Updated AGENTS.md and README.md.
2026-03-05 19:03:47 +03:00
Ed Zynda 7e1686e572 feat: positional args as primary non-interactive mode, hide --prompt
Positional args are now the main way to run non-interactive mode:

  kit "Explain this codebase"
  kit @code.ts @test.ts "Review these files"
  kit @go.mod "What module?" --quiet

--prompt is hidden but still works for subprocess compat (extensions
spawn kit with --prompt internally). Updated --quiet/--json/--no-exit
error messages to reference the new positional arg pattern.
2026-03-05 19:00:51 +03:00
Ed Zynda 4a8b10cde7 feat: support Pi-style positional @file args
Enables: kit @code.ts @test.ts "Review these files"

Positional args starting with @ are treated as file attachments —
their content is read and prepended to the prompt. Remaining
positional args are joined as the prompt text. Works alongside
--prompt flag (files prepended, extra text appended).
2026-03-05 18:57:00 +03:00
Ed Zynda cc5611eff7 feat: support @file references in non-interactive mode (--prompt) 2026-03-05 18:54:17 +03:00
12 changed files with 357 additions and 38 deletions
+2 -2
View File
@@ -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)
+13 -11
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -209,10 +209,10 @@ func spawnAgent(state *subState) {
}
args := []string{
"--prompt", prompt,
"--json",
"--no-session",
"--no-extensions",
prompt,
}
cmd := exec.Command(kitBinary, args...)
+16
View File
@@ -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
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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
}
+30
View File
@@ -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
}
+22
View File
@@ -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
View File
@@ -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...)
}
+4
View File
@@ -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
// --------------------------------------------------------------------------