Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/agent/command_shape.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func commandFromResponse(text string) (string, bool) {
return "", false
}
if looksLikeCommand(text) {
return text, true
return bareCommandLine(text), true
}

lines := responseLines(text)
Expand Down
1 change: 1 addition & 0 deletions internal/agent/command_shape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ func TestCommandFromResponse(t *testing.T) {
ok bool
}{
{"bare command", "find . -name '*.go'", "find . -name '*.go'", true},
{"inline-code bare command", "`cat nope.txt`", "cat nope.txt", true},
{"lead-in then indented command", "The command is:\n find . -name '*.go' -mtime 0", "find . -name '*.go' -mtime 0", true},
{"lead-in with sentences then command", "No context here. The request: find Go files. A single bare command:\nfind . -name '*.go' -mtime 0", "find . -name '*.go' -mtime 0", true},
{"lead-in then fenced command", "A single bare command:\n```bash\nfind . -name '*.go' -mtime 0\n```", "find . -name '*.go' -mtime 0", true},
Expand Down
9 changes: 9 additions & 0 deletions internal/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,15 @@ func (e *Editor) SetInitialText(text string) {
e.state.Cursor.MoveTo(0, len(e.state.Buffer.Line(0)))
}

// PrintSubmitted leaves text on screen the way Run leaves a submitted line,
// without reading any input. The shell uses it to show a command it runs on
// the user's behalf, so scrollback reads as if the user had typed it.
func (e *Editor) PrintSubmitted(text string) {
e.display.SetGutter(e.config.Gutter)
e.display.SetPrompt(e.config.Prompt)
e.display.Finalize(NewBufferFromString(text))
}

// Run starts the editor and blocks until submit or cancel.
func (e *Editor) Run(ctx context.Context) (Result, error) {
// Enable raw mode if we have a terminal
Expand Down
24 changes: 24 additions & 0 deletions internal/editor/print_submitted_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package editor

import (
"bytes"
"strings"
"testing"
)

// PrintSubmitted leaves a line on screen the way Run does after Enter, so a
// command the shell runs on the user's behalf reads like one they typed.
func TestPrintSubmitted_RendersLikeASubmittedLine(t *testing.T) {
var out bytes.Buffer
ed := New(Config{Gutter: true, Prompt: "❯ "}, strings.NewReader(""), &out)

ed.PrintSubmitted("echo hi")

got := out.String()
if want := "│❯ \x1b[1mecho hi\x1b[0m"; !strings.Contains(got, want) {
t.Errorf("PrintSubmitted() = %q, want it to contain %q", got, want)
}
if !strings.HasSuffix(got, "\r\n") {
t.Errorf("PrintSubmitted() = %q, want it to end the line", got)
}
}
8 changes: 8 additions & 0 deletions internal/history/search_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,14 @@ func (ui *SearchUI) View() tea.View {
b.WriteString(normalStyle.Render(cmd.Command))
b.WriteString("\n")

// The question behind a command the agent produced
if ui.store != nil {
if question, ok := ui.store.AgentPromptForCommand(cmd.ID); ok {
b.WriteString(dimStyle.Render("?? " + question))
b.WriteString("\n")
}
}

// Metadata line
meta := fmt.Sprintf("%s │ %s", cmd.Timestamp.Format("2006-01-02 15:04"), cmd.Cwd)
if cmd.GitBranch != "" {
Expand Down
23 changes: 23 additions & 0 deletions internal/history/search_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,26 @@ func TestSearchUI_PreviewPane(t *testing.T) {
t.Error("Preview should show full command")
}
}

// A command that came from a ?? turn shows the question under it in the
// preview, so the picker recalls what was asked as well as what ran.
func TestSearchUI_PreviewShowsTheQuestionBehindACommand(t *testing.T) {
store, _ := NewStore(":memory:")
defer store.Close()

id, err := store.Add(Command{Command: "find . -name '*.go' | xargs wc -l", Cwd: "/tmp", Timestamp: time.Now()})
if err != nil {
t.Fatalf("Add: %v", err)
}
if _, err := store.AddAgentInteraction(AgentInteraction{Prompt: "count lines in go files", Accepted: true, CommandID: id, Timestamp: time.Now()}); err != nil {
t.Fatalf("AddAgentInteraction: %v", err)
}

ui := NewSearchUI(store, prompt.DefaultPalette())
ui.searchNow()

view := ui.View().Content
if !strings.Contains(view, "?? count lines in go files") {
t.Errorf("preview should show the question behind the command, got:\n%s", view)
}
}
20 changes: 20 additions & 0 deletions internal/history/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_commands_timestamp ON commands(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_commands_command ON commands(command);
CREATE INDEX IF NOT EXISTS idx_commands_is_sudo ON commands(is_sudo);
CREATE INDEX IF NOT EXISTS idx_agent_interactions_command ON agent_interactions(command_id);
`

_, err := s.db.Exec(schema)
Expand Down Expand Up @@ -285,6 +286,25 @@ func (s *Store) GetAgentInteractions(prompt string, limit int) ([]AgentInteracti
return interactions, rows.Err()
}

// AgentPromptForCommand returns what the user asked the agent when the given
// command came from a ?? turn, so a recalled command carries its question.
func (s *Store) AgentPromptForCommand(commandID int64) (string, bool) {
if commandID == 0 {
return "", false
}
var prompt string
err := s.db.QueryRow(`
SELECT prompt FROM agent_interactions
WHERE command_id = ?
ORDER BY timestamp DESC
LIMIT 1
`, commandID).Scan(&prompt)
if err != nil || prompt == "" {
return "", false
}
return prompt, true
}

// SearchByPrefix returns the most recent successful commands matching a prefix.
// The results are deduplicated (by command text) and ordered by most recent first.
// Returns nil for empty prefix or if no matches are found.
Expand Down
30 changes: 30 additions & 0 deletions internal/history/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,33 @@ func TestSearchByPrefix_NoMatch(t *testing.T) {
t.Errorf("got %d results, want 0", len(results))
}
}

func TestAgentPromptForCommand_FindsTheTurnThatProducedIt(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()

id, err := store.Add(Command{Command: "find . -name '*.go' | xargs wc -l", Timestamp: time.Now()})
if err != nil {
t.Fatalf("Add: %v", err)
}
if _, err := store.AddAgentInteraction(AgentInteraction{
Prompt: "count lines in go files",
Response: "find . -name '*.go' | xargs wc -l",
Accepted: true,
CommandID: id,
Timestamp: time.Now(),
}); err != nil {
t.Fatalf("AddAgentInteraction: %v", err)
}

prompt, ok := store.AgentPromptForCommand(id)
if !ok || prompt != "count lines in go files" {
t.Errorf("AgentPromptForCommand(%d) = %q, %v; want the question that produced it", id, prompt, ok)
}
if _, ok := store.AgentPromptForCommand(id + 1); ok {
t.Error("a command no turn produced should have no prompt")
}
}
198 changes: 198 additions & 0 deletions internal/shell/agent_command_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package shell

import (
"bytes"
"context"
"io"
"path/filepath"
"strings"
"testing"

"github.com/tfcace/hash/internal/agent"
"github.com/tfcace/hash/internal/config"
"github.com/tfcace/hash/internal/history"
"github.com/tfcace/hash/internal/parser"
)

func newShellForAgentCommand(t *testing.T) *Shell {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
cfg := config.Default()
cfg.History.Path = filepath.Join(t.TempDir(), "history.db")
cfg.Agent.Command = "hash-test-no-such-adapter" // nothing here should reach an agent
sh, err := New(cfg)
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(func() { sh.Close() }) //nolint:errcheck
sh.responseUI = NewResponseUI(io.Discard)
return sh
}

// A pipe-mode turn has no confirmation step, and is still a ?? exchange
// worth recalling.
func TestHandleAgentFullStreaming_PipeTurnIsRecorded(t *testing.T) {
store, err := history.NewStore(":memory:")
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
mock := agent.NewMockTransport(agent.Response{Type: agent.ResponseTypeExplanation, Explanation: "Two files changed."})
sh := &Shell{
config: config.Default(),
agentHandler: NewAgentHandler(agent.NewClient(mock)),
responseUI: NewResponseUI(io.Discard),
agentOutput: NewAgentOutputCoordinator(io.Discard),
history: store,
}

sh.handleAgentFullStreaming(context.Background(), parser.ParseResult{
Type: parser.CommandTypeAgentPipe,
Command: "git diff",
AgentPrompt: "summarize",
}, "test-model")

turns, err := store.GetAgentInteractions("", 10)
if err != nil || len(turns) != 1 {
t.Fatalf("GetAgentInteractions() = %v, %v; want the pipe turn recorded", turns, err)
}
if turns[0].Prompt != "summarize" || !turns[0].Accepted || turns[0].CommandID != 0 {
t.Errorf("recorded turn = %+v, want the prompt, accepted, no command", turns[0])
}
}

// A command reply is accepted only once a command ran. A blank command
// stands in for an edit the user abandons: nothing ran, so nothing was
// accepted.
func TestHandleAgentConfirmAction_CommandTurnWithoutARunIsNotAccepted(t *testing.T) {
sh := newShellForAgentCommand(t)

sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand,
agent.Response{Type: agent.ResponseTypeCommand, Command: " "}, agentTurn{prompt: "noop", response: " "}, 1)

turns, err := sh.history.GetAgentInteractions("", 10)
if err != nil || len(turns) != 1 {
t.Fatalf("GetAgentInteractions() = %v, %v; want one recorded turn", turns, err)
}
if turns[0].Accepted {
t.Errorf("turn recorded as accepted although no command ran: %+v", turns[0])
}
}

// A command the agent hands back runs like a typed line, so when it fails
// the shell's last error names it, and a bare ?? explains the right thing.
func TestHandleAgentConfirmAction_FailedCommandBecomesLastError(t *testing.T) {
sh := newShellForAgentCommand(t)
sh.lastCommand = "echo hello" // the line typed before the ?? turn
missing := filepath.Join(t.TempDir(), "nope.txt")
cmd := "cat " + missing

sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand,
agent.Response{Type: agent.ResponseTypeCommand, Command: cmd}, agentTurn{response: cmd}, 1)

if sh.lastCommand != cmd {
t.Errorf("lastCommand = %q, want the agent command %q", sh.lastCommand, cmd)
}
if sh.lastExitCode != 1 {
t.Errorf("lastExitCode = %d, want 1", sh.lastExitCode)
}
if !strings.Contains(sh.lastStderr, "No such file") {
t.Errorf("lastStderr = %q, want cat's error text", sh.lastStderr)
}
}

// The agent's reply is a shell command, never a new ?? request: a ?? inside
// it (a glob, a grep pattern) runs as shell syntax instead of going back to
// the agent.
func TestHandleAgentConfirmAction_CommandContainingDoubleQuestionMarkRunsAsShell(t *testing.T) {
sh := newShellForAgentCommand(t)
cmd := "echo a??b"

sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand,
agent.Response{Type: agent.ResponseTypeCommand, Command: cmd}, agentTurn{response: cmd}, 1)

if sh.lastCommand != cmd {
t.Errorf("lastCommand = %q, want %q to have run as a shell line", sh.lastCommand, cmd)
}
if sh.lastExitCode != 0 {
t.Errorf("lastExitCode = %d, want 0", sh.lastExitCode)
}
}

// An agent that hands back "exit" ends the shell on Enter, as a typed exit would.
func TestHandleAgentConfirmAction_ExitEndsTheShell(t *testing.T) {
sh := newShellForAgentCommand(t)

err := sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand,
agent.Response{Type: agent.ResponseTypeCommand, Command: "exit"}, agentTurn{response: "exit"}, 1)

if err != errExit {
t.Fatalf("handleAgentConfirmAction() error = %v, want errExit", err)
}
}

// Running the agent's command links the ?? turn to that command in history,
// so the question and what it produced can be recalled together.
func TestHandleAgentConfirmAction_RunLinksTheTurnToItsCommand(t *testing.T) {
sh := newShellForAgentCommand(t)
turn := agentTurn{prompt: "print linked", response: "echo linked"}

sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand,
agent.Response{Type: agent.ResponseTypeCommand, Command: "echo linked"}, turn, 1)

recent, err := sh.history.GetRecent(1)
if err != nil || len(recent) != 1 || recent[0].Command != "echo linked" {
t.Fatalf("GetRecent() = %v, %v; want the agent command recorded", recent, err)
}
turns, err := sh.history.GetAgentInteractions("", 10)
if err != nil || len(turns) != 1 {
t.Fatalf("GetAgentInteractions() = %v, %v; want one recorded turn", turns, err)
}
got := turns[0]
if got.Prompt != "print linked" || got.Response != "echo linked" {
t.Errorf("recorded turn = %+v, want the prompt and response", got)
}
if !got.Accepted {
t.Error("a turn whose command ran should be recorded as accepted")
}
if got.CommandID != recent[0].ID {
t.Errorf("CommandID = %d, want the history id of the command that ran (%d)", got.CommandID, recent[0].ID)
}
}

// A turn the user cancels is still remembered, as declined and with no command.
func TestHandleAgentConfirmAction_CancelRecordsTheTurnAsDeclined(t *testing.T) {
sh := newShellForAgentCommand(t)
sh.responseUI = NewResponseUI(io.Discard)
turn := agentTurn{prompt: "delete everything", response: "rm -rf /"}

sh.handleAgentConfirmAction(context.Background(), ConfirmCancel, ConfirmTypeCommand,
agent.Response{Type: agent.ResponseTypeCommand, Command: "rm -rf /"}, turn, 1)

turns, err := sh.history.GetAgentInteractions("", 10)
if err != nil || len(turns) != 1 {
t.Fatalf("GetAgentInteractions() = %v, %v; want one recorded turn", turns, err)
}
if turns[0].Accepted || turns[0].CommandID != 0 {
t.Errorf("declined turn recorded as %+v, want not accepted and no command", turns[0])
}
}

// Running the agent's command replaces the confirmation hint with the command
// on its own input line, so scrollback reads as if the user had typed it.
func TestHandleAgentConfirmAction_RunShowsTheCommandAsTyped(t *testing.T) {
sh := newShellForAgentCommand(t)
var out bytes.Buffer
sh.responseUI = NewResponseUI(&out)

sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand,
agent.Response{Type: agent.ResponseTypeCommand, Command: "true"}, agentTurn{response: "true"}, 1)

got := out.String()
if !strings.HasPrefix(got, "\x1b[A\x1b[K\x1b[A\x1b[K") {
t.Errorf("want the hint line and the blank line after it cleared first, got %q", got)
}
if !strings.Contains(got, "\x1b[1mtrue\x1b[0m") {
t.Errorf("want the command shown as a submitted input line, got %q", got)
}
}
Loading
Loading