Skip to content

Shell Execution Feature Technical Design

Overview

harness9's Shell execution feature lets users run Bash commands directly inside the TUI conversation box, without switching to a separate terminal. Command output is appended to the conversation stream in real time, and is automatically injected as context the next time a message is sent to the LLM, allowing the Agent to reason using the command results.

Trigger: type ! as the first character of the input box to enter Shell mode, press Enter to execute, Esc to cancel.


Design Principles

PrincipleImplementation
Does not interrupt the conversation flowCommand output is appended inline to the Scrollback, no new page is opened
LLM-aware resultsOutput is buffered in pendingShellOutput and prepended on the next dispatch
Non-blocking TUIExecuted asynchronously via tea.Cmd; the main goroutine does not wait
Security interceptionKnown interactive commands (vim/ssh, etc.) are rejected outright, with a prompt to run them in a separate terminal
Bounded memoryStorage-side truncation by byte count, preventing large output from occupying memory long-term

Interaction Flow

User input "!git status"


tea.KeyEnter triggers Update()


strings.HasPrefix(raw, "!") → true


dispatchShellCommand("git status")
    ├── empty command → return directly, no-op
    ├── isInteractiveCmd → reject, show error message
    └── normal command


        lines append "$ git status" (shellCmdStyle)


        return runShellCmd(workDir, cmd) as a tea.Cmd

            ▼  [async goroutine, 30s timeout]
        exec.CommandContext("bash", "-c", "git status")


        shellResultMsg{cmd, output, isErr, dur}


    case shellResultMsg: handled in Update()
        ├── Display side: truncateUTF8(output, 4096) appended line by line (shellOutputStyle)
        ├── Status line: ✓ done / ✗ non-zero exit + duration
        └── Storage side: truncateUTF8(output, 2048) appended to pendingShellOutput

Visual State Switching

The input box checks in real time whether it starts with !, switching the Shell mode visual indicator:

Normal mode                    Shell mode
┌──────────────────────┐        ┌──────────────────────────────────┐
│  ›  Enter a task...   │        │  [SHELL]  $  !git status█        │
└──────────────────────┘        └──────────────────────────────────┘

Status bar: dark gray #235       Status bar: dark green #22 (shellStatusBarStyle)
Footer: normal shortcuts         Footer: enter to execute / esc to cancel / ctrl+c to quit

Style Variables Involved (tui.go)

VariablePurpose
shellStatusBarStyleDark green background (#22) + light green text (#120) status bar, clearly distinguished from the default gray background and Plan Mode orange background
shellModeTagStyle[SHELL] badge in the input area: dark olive background (#58) + bright yellow text (#226)
shellModeAccentStyleBright green (#83) accent, replacing the default cyan
shellModePromptStyle$ prompt style, precomputed to avoid .Bold(true) allocation on every frame
shellModeLabelInBarStyleSHELL label inside the status bar, bright green bold
shellCmdStyleCommand line $ cmd, yellow bold (#33)
shellOutputStyleOutput line, light gray (#250)
shellOKStyle✓ done line, green (#34)
shellErrStyle✗ non-zero exit line, red (#160)

Color-switching logic is centralized in two methods in tui_view.go, so the View layer has no scattered if-statements:

go
func (m tuiModel) accentStyle() lipgloss.Style         // returns the accent color for the current mode
func (m tuiModel) activeStatusBarStyle() lipgloss.Style // returns the status bar container style for the current mode

Priority (high → low):

shellMode=true  →  dark green background #22 + bright green accent #83
Plan/AutoEdit   →  dark orange background #94 + amber accent #220
Default         →  dark gray background #235 + cyan accent #81

Core Data Flow

tuiModel Fields

go
pendingShellOutput []string  // Shell command records accumulated in this round, cleared on next dispatch
shellMode          bool      // true when the input box starts with "!", drives View-layer style switching

shellResultMsg Type

go
type shellResultMsg struct {
    cmd    string        // the original command string
    output string        // combined stdout + stderr output (CombinedOutput)
    isErr  bool          // exit code != 0
    dur    time.Duration // actual execution duration
}

LLM Context Injection

The next time the user presses Enter to send a message, dispatch() prepends the buffered command records to the prompt:

[Shell command records executed by the user]
$ git status
On branch main...

---
$ go build ./...
# github.com/harness9/cmd/harness9
...

[The user's actual question]

After injection, pendingShellOutput is cleared to avoid duplicate injection. Each record is independently truncated to maxShellContextLen (2048 bytes), with multiple records separated by ---.


Truncation Strategy

The Shell feature involves two truncation boundaries, both using truncateUTF8 to guarantee byte-level truncation does not corrupt multi-byte characters:

ScenarioConstantTruncation TimingTruncation Marker
TUI displaymaxShellDisplayLen = 4096before display in case shellResultMsg:...[output too long, truncated; consider re-running with head -n N]...
LLM contextmaxShellContextLen = 2048at storage time in case shellResultMsg:none (truncated directly; the LLM can perceive the content is incomplete)

truncateUTF8 Implementation

go
func truncateUTF8(s string, maxBytes int) string {
    if len(s) <= maxBytes {
        return s
    }
    s = s[:maxBytes]
    for len(s) > 0 {
        r, size := utf8.DecodeLastRuneInString(s)
        if r != utf8.RuneError || size > 1 {
            break
        }
        s = s[:len(s)-1]
    }
    return s
}

utf8.DecodeLastRuneInString returns (RuneError, 1) for an incomplete trailing sequence, backing off one byte at a time until the tail is a valid rune. It does not back off for a legitimately valid RuneError (U+FFFD, size > 1).


Asynchronous Execution Mechanism

Shell commands are executed asynchronously via Bubbletea's tea.Cmd mode, so the TUI main loop does not block:

Update() returns (m, runShellCmd(workDir, cmd))

    ▼  the Bubbletea runtime executes this Cmd in a separate goroutine
exec.CommandContext(ctx, "bash", "-c", cmd)  // 30s timeout

    ▼  the Cmd returns a tea.Msg
shellResultMsg → sent to the main message queue


Update() case shellResultMsg: handles the result

The working directory is fixed to tuiModel.workDir (the program's launch directory), injected via c.Dir = workDir. stdout and stderr are merged via CombinedOutput(), ensuring error messages are visible to the user.


Interactive Command Interception

Bubbletea runs in AltScreen mode, taking exclusive control of terminal input/output, so PTY-dependent programs cannot function correctly:

go
var interactiveCmds = map[string]bool{
    "vim": true, "vi": true, "nano": true, "emacs": true,
    "ssh": true, "top": true, "htop": true, "less": true,
    "man": true, "more": true, "watch": true, "tmux": true,
    "screen": true,
}

isInteractiveCmd extracts the filepath.Base of the first token on the command line (handling absolute paths such as /usr/bin/vim), and matches it against the interception list. On a match, it prints ✗ This command requires an interactive terminal; please run it in a separate terminal window and does not execute the command.


Keyboard Behavior

KeyBehavior
! (first character)Triggers the Shell mode visual switch (in real time, no need to press Enter)
EnterExecutes the command after !; the input box is unavailable until the command finishes executing
EscClears the input box, exits Shell mode (only while not executing)
Ctrl-CIf a command is running: cancels it (note: in the current implementation, cancellation only occurs when the 30s timeout expires); otherwise exits the program
Backspace (deleting !)Exits Shell mode in real time, restoring the normal input prompt

Code Location Index

ContentFileLocation
Shell style variables (shellCmdStyle, etc.)cmd/harness9/tui.govar (...) block, Shell mode style group
shellMode / pendingShellOutput fieldscmd/harness9/tui.gotuiModel struct
Constants maxShellDisplayLen / maxShellContextLencmd/harness9/tui_update.goconst (...) block
shellResultMsg typecmd/harness9/tui_update.gowhere the shellResultMsg struct is defined
Esc exits Shell modecmd/harness9/tui_update.gocase tea.KeyEsc:
Enter dispatches Shell commandcmd/harness9/tui_update.gocase tea.KeyEnter:, strings.HasPrefix(raw, "!") branch
case shellResultMsg: result handlingcmd/harness9/tui_update.gocase shellResultMsg: inside Update()
Shell mode real-time detectioncmd/harness9/tui_update.gotextinput fallthrough block at the end of Update()
dispatch() context injectioncmd/harness9/tui_update.gopendingShellOutput handling block in the dispatch() function
truncateUTF8cmd/harness9/tui_update.gotruncateUTF8 function
interactiveCmds / isInteractiveCmdcmd/harness9/tui_update.gointeractiveCmds var + isInteractiveCmd function
runShellCmdcmd/harness9/tui_update.gorunShellCmd function
dispatchShellCommandcmd/harness9/tui_update.godispatchShellCommand function
accentStyle() / activeStatusBarStyle()cmd/harness9/tui_view.gothe two methods at the top of the file
renderStatusBar() SHELL labelcmd/harness9/tui_view.gorenderStatusBar, modePart assignment branch
renderInput() Shell modecmd/harness9/tui_view.gofirst if m.shellMode branch in renderInput
renderFooter() Shell modecmd/harness9/tui_view.gofirst if m.shellMode branch in renderFooter
Unit testscmd/harness9/tui_test.goTestShell*, TestTruncateUTF8*

Released under the MIT License.