diff --git a/internal/agent/command_shape.go b/internal/agent/command_shape.go index a0b3c15..3d1fde7 100644 --- a/internal/agent/command_shape.go +++ b/internal/agent/command_shape.go @@ -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) diff --git a/internal/agent/command_shape_test.go b/internal/agent/command_shape_test.go index c41f56a..831283a 100644 --- a/internal/agent/command_shape_test.go +++ b/internal/agent/command_shape_test.go @@ -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}, diff --git a/internal/editor/editor.go b/internal/editor/editor.go index e17a439..7c07dbf 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -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 diff --git a/internal/editor/print_submitted_test.go b/internal/editor/print_submitted_test.go new file mode 100644 index 0000000..ec19400 --- /dev/null +++ b/internal/editor/print_submitted_test.go @@ -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) + } +} diff --git a/internal/history/search_ui.go b/internal/history/search_ui.go index 01c95a4..aef5b34 100644 --- a/internal/history/search_ui.go +++ b/internal/history/search_ui.go @@ -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 != "" { diff --git a/internal/history/search_ui_test.go b/internal/history/search_ui_test.go index 762fa7c..c16ed61 100644 --- a/internal/history/search_ui_test.go +++ b/internal/history/search_ui_test.go @@ -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) + } +} diff --git a/internal/history/store.go b/internal/history/store.go index 21b4055..4021868 100644 --- a/internal/history/store.go +++ b/internal/history/store.go @@ -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) @@ -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. diff --git a/internal/history/store_test.go b/internal/history/store_test.go index 54f7595..9d56bf8 100644 --- a/internal/history/store_test.go +++ b/internal/history/store_test.go @@ -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") + } +} diff --git a/internal/shell/agent_command_path_test.go b/internal/shell/agent_command_path_test.go new file mode 100644 index 0000000..2f271ed --- /dev/null +++ b/internal/shell/agent_command_path_test.go @@ -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) + } +} diff --git a/internal/shell/agent_stream_error_test.go b/internal/shell/agent_stream_error_test.go index f19db52..356374b 100644 --- a/internal/shell/agent_stream_error_test.go +++ b/internal/shell/agent_stream_error_test.go @@ -23,7 +23,7 @@ func TestHandleAgentStreamError_NoResponseStartupShowsTroubleshooting(t *testing sh.config.Agent.Transport = "stdio" sh.config.Agent.Command = "claude-agent-acp" - handled := sh.handleAgentStreamError( + err := sh.handleAgentStreamError( context.Background(), parser.ParseResult{}, "claude-agent-acp", @@ -32,8 +32,8 @@ func TestHandleAgentStreamError_NoResponseStartupShowsTroubleshooting(t *testing 0, ) - if !handled { - t.Fatal("expected error to be handled") + if err != nil { + t.Fatalf("handleAgentStreamError() error = %v, want nil once handled", err) } output := out.String() @@ -53,7 +53,7 @@ func TestHandleAgentStreamError_NoResponseTimeoutDoesNotShowTroubleshooting(t *t agentOutput: NewAgentOutputCoordinator(&out), } - handled := sh.handleAgentStreamError( + err := sh.handleAgentStreamError( context.Background(), parser.ParseResult{}, "claude-agent-acp", @@ -62,8 +62,8 @@ func TestHandleAgentStreamError_NoResponseTimeoutDoesNotShowTroubleshooting(t *t 0, ) - if !handled { - t.Fatal("expected error to be handled") + if err != nil { + t.Fatalf("handleAgentStreamError() error = %v, want nil once handled", err) } output := out.String() @@ -83,7 +83,7 @@ func TestHandleAgentStreamError_NoOutputUsesUserFacingRetry(t *testing.T) { agentOutput: NewAgentOutputCoordinator(&out), } - handled := sh.handleAgentStreamError( + err := sh.handleAgentStreamError( context.Background(), parser.ParseResult{}, "claude-agent-acp", @@ -92,8 +92,8 @@ func TestHandleAgentStreamError_NoOutputUsesUserFacingRetry(t *testing.T) { 0, ) - if !handled { - t.Fatal("expected error to be handled") + if err != nil { + t.Fatalf("handleAgentStreamError() error = %v, want nil once handled", err) } output := out.String() @@ -116,7 +116,7 @@ func TestHandleAgentStreamError_ModelErrorPointsAtModelBuiltin(t *testing.T) { agentOutput: NewAgentOutputCoordinator(&out), } - handled := sh.handleAgentStreamError( + err := sh.handleAgentStreamError( context.Background(), parser.ParseResult{}, "claude-agent-acp", @@ -125,8 +125,8 @@ func TestHandleAgentStreamError_ModelErrorPointsAtModelBuiltin(t *testing.T) { 0, ) - if !handled { - t.Fatal("expected error to be handled") + if err != nil { + t.Fatalf("handleAgentStreamError() error = %v, want nil once handled", err) } output := out.String() if !strings.Contains(output, "model --list") { diff --git a/internal/shell/error_handler.go b/internal/shell/error_handler.go index 25bfbaf..3f279a1 100644 --- a/internal/shell/error_handler.go +++ b/internal/shell/error_handler.go @@ -9,6 +9,10 @@ import ( "github.com/tfcace/hash/internal/learning" ) +// acceptFooter closes a banner whose suggestion is waiting as ghost text at +// the next prompt. +const acceptFooter = " \033[90m└─ → to accept at prompt · esc to dismiss · ?? to explain\033[0m\n" + // ErrorHandler renders error and learned-fix banners. type ErrorHandler struct { out io.Writer @@ -20,7 +24,9 @@ func NewErrorHandler() *ErrorHandler { } // HandleCommandNotFound displays a command-not-found error with suggestions. -func (h *ErrorHandler) HandleCommandNotFound(cmd string, suggestions []string, installHint string) { +// offerAccept says the first suggestion will be waiting as ghost text at the +// next prompt, so the footer may teach the accept key. +func (h *ErrorHandler) HandleCommandNotFound(cmd string, suggestions []string, installHint string, offerAccept bool) { out := h.out if out == nil { out = os.Stderr @@ -40,6 +46,10 @@ func (h *ErrorHandler) HandleCommandNotFound(cmd string, suggestions []string, i } // Footer + if offerAccept { + fmt.Fprint(out, acceptFooter) + return + } fmt.Fprintf(out, " \033[90m└─ ?? to explain\033[0m\n") } @@ -64,7 +74,7 @@ func (h *ErrorHandler) showDidYouMean(suggestion string) { fmt.Fprintf(out, "\n\033[33m✗ Did you mean\033[0m\n") fmt.Fprintf(out, "\n\033[32m→\033[0m %s\n", suggestion) - fmt.Fprintf(out, " \033[90m└─ → to accept at prompt · esc to dismiss · ?? to explain\033[0m\n") + fmt.Fprint(out, acceptFooter) } func (h *ErrorHandler) showLearnedFix(fix learning.Fix, highConfidence bool) { @@ -84,5 +94,5 @@ func (h *ErrorHandler) showLearnedFix(fix learning.Fix, highConfidence bool) { fmt.Fprintf(out, "\n\033[33m?\033[0m %s \033[90m(tried %d×, worked %d×)\033[0m\n", fix.Fix, fix.SuccessCount+fix.FailureCount, fix.SuccessCount) } - fmt.Fprintf(out, " \033[90m└─ → to accept at prompt · esc to dismiss · ?? to explain\033[0m\n") + fmt.Fprint(out, acceptFooter) } diff --git a/internal/shell/error_handler_test.go b/internal/shell/error_handler_test.go index f80abdf..acb2294 100644 --- a/internal/shell/error_handler_test.go +++ b/internal/shell/error_handler_test.go @@ -11,7 +11,7 @@ func TestErrorHandler_HandleCommandNotFound(t *testing.T) { h := NewErrorHandler() h.out = &buf - h.HandleCommandNotFound("jq", []string{"jp", "jj"}, "brew install jq") + h.HandleCommandNotFound("jq", []string{"jp", "jj"}, "brew install jq", true) output := buf.String() @@ -48,7 +48,7 @@ func TestErrorHandler_HandleCommandNotFound_NoSuggestions(t *testing.T) { h := NewErrorHandler() h.out = &buf - h.HandleCommandNotFound("xyz123", nil, "") + h.HandleCommandNotFound("xyz123", nil, "", false) output := buf.String() diff --git a/internal/shell/fix_tracker.go b/internal/shell/fix_tracker.go index a792df9..d0c0f38 100644 --- a/internal/shell/fix_tracker.go +++ b/internal/shell/fix_tracker.go @@ -58,6 +58,11 @@ func (t *fixTracker) Observe(command, stderr string, exitCode int) (learning.Fix return fix, true } +// active reports whether the tracker can offer suggestions at the prompt. +func (t *fixTracker) active() bool { + return t != nil && t.store != nil +} + // SuggestedFix returns the fix suggested for the pending failure, or "". func (t *fixTracker) SuggestedFix() string { if t == nil || t.pending == nil { @@ -80,6 +85,8 @@ func (t *fixTracker) SetSuggested(fix string) { // shows the best suggestion for a failure: a deterministic did-you-mean // correction when one exists, else the learned fix from the store. func (s *Shell) observeCommandOutcome(line string) { + typoFix := s.pendingTypoFix + s.pendingTypoFix = "" if s.fixes == nil { return } @@ -102,6 +109,12 @@ func (s *Shell) observeCommandOutcome(line string) { h.showDidYouMean(suggestion) return } + // The command-not-found banner already named the correction; the + // corrected line is what the prompt offers. + if typoFix != "" { + s.fixes.SetSuggested(typoFix) + return + } } if !found { diff --git a/internal/shell/shell.go b/internal/shell/shell.go index dce0de5..70abf95 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -72,6 +72,9 @@ type Shell struct { agentReplyInputHook func(context.Context) (string, error) lastExitCode int lastDuration time.Duration + lastCommandID int64 // history id of the last recorded command, 0 if none + runningLine string // line the executor is running, for refusals raised mid-line + pendingTypoFix string // corrected line for a mistyped command name, until the learning loop offers it lastCommand string // Last executed command lastStderr string // Stderr from last command (truncated) lastCwd string // Working directory of last command @@ -699,8 +702,11 @@ func (s *Shell) executeRegularCommand(ctx context.Context, line string) error { // Capture stderr for issue reporting stderrCap := newStderrCapture(os.Stderr) - // Execute external command + // Execute external command. The line is kept while it runs so a refusal + // raised mid-line (command not found) can correct the line as typed. + s.runningLine = line result, err := s.executor.Execute(ctx, line, os.Stdout, stderrCap) + s.runningLine = "" s.handleExecutionResult(line, result, err, stderrCap) return nil } @@ -759,7 +765,10 @@ func (s *Shell) handleExecutionResult(line string, result *executor.Result, err } } -// handleExecutionError handles errors from command execution. +// handleExecutionError handles errors from command execution. A mistyped +// command name with a close match leaves the corrected line for the learning +// loop to offer at the next prompt, and the banner promises the accept key +// only when that offer will be made. func (s *Shell) handleExecutionError(err error) { var nex *executor.CommandNotExecutableError if errors.As(err, &nex) { @@ -788,7 +797,12 @@ func (s *Shell) handleExecutionError(err error) { if handler == nil { handler = NewErrorHandler() } - handler.HandleCommandNotFound(cnf.Command, suggestions, installHint) + fix := "" + if len(suggestions) > 0 && s.fixes.active() { + fix = typoFixLine(s.runningLine, cnf.Command, suggestions[0]) + } + handler.HandleCommandNotFound(cnf.Command, suggestions, installHint, fix != "") + s.pendingTypoFix = fix s.lastExitCode = 127 } else { fmt.Fprintf(os.Stderr, "hash: %v\n", err) @@ -1225,12 +1239,12 @@ func (s *Shell) handleAgentRequestUnified(ctx context.Context, parsed parser.Par } // Full ?? and pipe modes: streaming with confirmation UI - s.handleAgentFullStreaming(ctx, parsed, modelName) - return nil + return s.handleAgentFullStreaming(ctx, parsed, modelName) } // handleAgentFullStreaming handles full ?? and pipe modes with streaming. -func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.ParseResult, modelName string) { +func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.ParseResult, modelName string) error { + started := time.Now() requestCtx, timeoutCancel := context.WithTimeout(ctx, s.agentRequestTimeout()) defer timeoutCancel() @@ -1263,22 +1277,20 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars s.responseUI.ClearLine() fmt.Fprintln(os.Stderr, "hash: request canceled") s.lastExitCode = 1 - return + return nil } s.agentOutput.EndStreaming() if streamResult.streamErr != nil { - if s.handleAgentStreamError( + return s.handleAgentStreamError( ctx, parsed, modelName, streamResult.streamErr, len(streamResult.responseText), streamResult.lineCount, - ) { - return - } + ) } responseText := strings.TrimSpace(streamResult.responseText) @@ -1286,8 +1298,9 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars s.responseUI.ClearLine() s.responseUI.ShowError(emptyAgentResponseMessage) s.lastExitCode = 1 - return + return nil } + turn := agentTurn{prompt: parsed.AgentPrompt, response: responseText, latency: time.Since(started)} // Success path - add newline after response and clear spinner fmt.Println() @@ -1306,7 +1319,8 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars confirmType, needsConfirmation := confirmationTypeForAgentResponse(resp, allowReply) if !needsConfirmation { s.responseUI.StopProgress() - return + s.recordAgentTurn(turn, true, 0) + return nil } s.agentOutput.EnterConfirming() @@ -1319,13 +1333,11 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars s.responseUI.StopProgress() if action == ConfirmReply { - s.runAgentConversationLoop(ctx, modelName, transcript) - return + s.recordAgentTurn(turn, true, 0) + return s.runAgentConversationLoop(ctx, modelName, transcript) } - if s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) { - return - } + return s.handleAgentConfirmAction(ctx, action, confirmType, resp, turn, lineCount) } func (s *Shell) presentAgentToolUpdate(update agent.ToolCallUpdate, prefix string) { @@ -1375,27 +1387,30 @@ func (s *Shell) initialAgentConversationTranscript(parsed parser.ParseResult, re return transcript } -func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, transcript []agentConversationMessage) { +func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, transcript []agentConversationMessage) error { openRail := true for { reply, ok := s.readAgentConversationReply(ctx, openRail) if !ok { - return + return nil } openRail = false if agentConversationReplyEndsConversation(reply) { - return + return nil } priorTranscript := append([]agentConversationMessage(nil), transcript...) + started := time.Now() resp, responseText, lineCount, ok := s.streamAgentFollowUpTurn(ctx, reply, priorTranscript) transcript = append(transcript, agentConversationMessage{Role: "user", Text: reply}) if !ok { - return + return nil } transcript = append(transcript, agentConversationMessage{Role: "assistant", Text: responseText}) + turn := agentTurn{prompt: reply, response: responseText, latency: time.Since(started)} if agentTurnShouldPromptForReply(parser.CommandTypeAgent, resp, responseText) { + s.recordAgentTurn(turn, true, 0) continue } @@ -1404,7 +1419,8 @@ func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, agentTurnAllowsReply(parser.CommandTypeAgent, resp), ) if !needsConfirmation { - return + s.recordAgentTurn(turn, true, 0) + return nil } s.agentOutput.EnterConfirming() @@ -1416,10 +1432,10 @@ func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, s.responseUI.StopProgress() if action == ConfirmReply { + s.recordAgentTurn(turn, true, 0) continue } - s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) - return + return s.handleAgentConfirmAction(ctx, action, confirmType, resp, turn, lineCount) } } @@ -1527,23 +1543,61 @@ func confirmationTypeForAgentResponse(resp agent.Response, allowExplanationReply return 0, false } -// handleAgentConfirmAction processes the user's confirmation choice. -// Returns true if the caller should return early (e.g., for edit mode). -func (s *Shell) handleAgentConfirmAction(ctx context.Context, action ConfirmAction, confirmType ConfirmationType, resp agent.Response, responseText string, lineCount int) bool { +// agentTurn is what the shell remembers about one ?? exchange once the user +// has acted on the reply. +type agentTurn struct { + prompt string // what the user asked, as typed after ?? + response string // the agent's reply text + latency time.Duration // request to end of reply +} + +// recordAgentTurn stores a ?? exchange in history, linked to the command it +// produced when one ran, so the question and its outcome can be recalled +// together. +func (s *Shell) recordAgentTurn(turn agentTurn, accepted bool, commandID int64) { + if s.history == nil { + return + } + agentName := "" + if s.agentHandler != nil { + agentName = s.agentHandler.CurrentModel() + } + if agentName == "" && s.config != nil { + agentName = s.config.EffectiveAgent().Command + } + _, _ = s.history.AddAgentInteraction(history.AgentInteraction{ + Prompt: turn.prompt, + Response: turn.response, + Accepted: accepted, + CommandID: commandID, + LatencyMs: turn.latency.Milliseconds(), + Agent: agentName, + Timestamp: time.Now(), + }) +} + +// handleAgentConfirmAction processes the user's confirmation choice. A +// command the user runs or edits takes the same path as a typed line, so +// the error it returns (such as exit) is the shell's to act on. +func (s *Shell) handleAgentConfirmAction(ctx context.Context, action ConfirmAction, confirmType ConfirmationType, resp agent.Response, turn agentTurn, lineCount int) error { + var commandID int64 + var err error switch action { case ConfirmRun: if confirmType == ConfirmTypeCommand { - s.executeAgentCommand(ctx, resp.Command) + s.showAgentCommandAsTyped(resp.Command) + commandID, err = s.runAgentCommand(ctx, resp.Command) } // For explanations, ConfirmRun just dismisses case ConfirmEdit: if confirmType == ConfirmTypeCommand { - s.handleEditCommand(ctx, resp.Command) - return true + s.clearConfirmationHint() // the editor takes the hint's place + commandID, err = s.handleEditCommand(ctx, resp.Command) + break } // Copy explanation to system clipboard - if err := copyToSystemClipboard(responseText); err != nil { - fmt.Fprintf(os.Stderr, "\033[90mCould not copy: %v\033[0m\n", err) + if copyErr := copyToSystemClipboard(turn.response); copyErr != nil { + fmt.Fprintf(os.Stderr, "\033[90mCould not copy: %v\033[0m\n", copyErr) } else { fmt.Fprintf(os.Stdout, "\033[90mCopied to clipboard\033[0m\n") } @@ -1552,25 +1606,54 @@ func (s *Shell) handleAgentConfirmAction(ctx context.Context, action ConfirmActi // +1 for confirmation hint line, +1 for the blank line after fmt.Println() s.responseUI.ClearLines(lineCount + 2) } - return false + accepted := action != ConfirmCancel + if confirmType == ConfirmTypeCommand { + // A command reply is accepted once a command ran (or ended the + // shell); an edit the user abandons is not acceptance. + accepted = commandID != 0 || err == errExit + } + s.recordAgentTurn(turn, accepted, commandID) + return err } -// executeAgentCommand executes a command generated by the agent. -func (s *Shell) executeAgentCommand(ctx context.Context, command string) { - result, err := s.executor.Execute(ctx, command, os.Stdout, os.Stderr) - if err != nil { - fmt.Fprintf(os.Stderr, "hash: %v\n", err) - s.lastExitCode = 1 - } else { - s.lastExitCode = result.ExitCode - s.lastDuration = result.Duration +// clearConfirmationHint removes the hint line and the blank line printed +// after the user's keypress, so what follows takes their place. +func (s *Shell) clearConfirmationHint() { + s.responseUI.ClearLines(2) +} + +// showAgentCommandAsTyped replaces the confirmation hint with the command on +// its own input line, so scrollback reads as if the user had typed it. +func (s *Shell) showAgentCommandAsTyped(command string) { + s.clearConfirmationHint() + cfg := s.editorCfg + cfg.Prompt = s.currentPromptLine() + editor.New(cfg, strings.NewReader(""), s.responseUI.out).PrintSubmitted(command) +} + +// runAgentCommand runs a command the agent handed back exactly as if the +// user had typed it: builtins, history, learning, prediction and stderr +// capture included, so a failure becomes the context a bare ?? explains. +// The reply is a shell line, never a new request: a ?? inside it (a glob, a +// grep pattern) is shell syntax, so it bypasses the ?? dispatch. +// It reports the history id of the command that ran, 0 when none was recorded. +func (s *Shell) runAgentCommand(ctx context.Context, command string) (commandID int64, err error) { + command = strings.TrimSpace(command) + if command == "" { + return 0, nil + } + before := s.lastCommandID + err = s.executeRegularCommand(ctx, command) + if s.lastCommandID != before { + commandID = s.lastCommandID } - s.recordCommand(command, s.lastExitCode, s.lastDuration) + return commandID, err } -// handleAgentStreamError handles errors during agent streaming. -// Returns true if the caller should return (error was fully handled). -func (s *Shell) handleAgentStreamError(ctx context.Context, parsed parser.ParseResult, modelName string, streamErr error, responseLen, lineCount int) bool { +// handleAgentStreamError handles errors during agent streaming. The caller +// returns its result: nil once the error is handled, or the outcome of a +// retried turn. +func (s *Shell) handleAgentStreamError(ctx context.Context, parsed parser.ParseResult, modelName string, streamErr error, responseLen, lineCount int) error { s.responseUI.ClearLine() // Stop spinner and clear the line s.responseUI.ShowError(agentStreamErrorMessage(streamErr)) @@ -1580,7 +1663,7 @@ func (s *Shell) handleAgentStreamError(ctx context.Context, parsed parser.ParseR if agent.IsModelSelectionError(streamErr) { s.responseUI.ShowModelHint() s.lastExitCode = 1 - return true + return nil } if agent.IsStartupError(streamErr) { @@ -1590,7 +1673,7 @@ func (s *Shell) handleAgentStreamError(ctx context.Context, parsed parser.ParseR s.config.Agent.URL, ) s.lastExitCode = 1 - return true + return nil } if agent.IsRetryableError(streamErr) { @@ -1600,14 +1683,14 @@ func (s *Shell) handleAgentStreamError(ctx context.Context, parsed parser.ParseR s.agentOutput.ExitConfirming() fmt.Println() if action == ConfirmRun { // Retry - s.handleAgentFullStreaming(ctx, parsed, modelName) + return s.handleAgentFullStreaming(ctx, parsed, modelName) } s.lastExitCode = 1 - return true + return nil } s.lastExitCode = 1 - return true + return nil } // Mid-stream error with partial response - offer retry @@ -1617,13 +1700,12 @@ func (s *Shell) handleAgentStreamError(ctx context.Context, parsed parser.ParseR s.agentOutput.ExitConfirming() fmt.Println() if action == ConfirmRun { // Retry - s.handleAgentFullStreaming(ctx, parsed, modelName) - return true + return s.handleAgentFullStreaming(ctx, parsed, modelName) } // Cancel: clear error + any partial response // lineCount + error line + confirmation line + blank line s.responseUI.ClearLines(lineCount + 3) - return true + return nil } func agentStreamErrorMessage(err error) string { @@ -1643,8 +1725,8 @@ func writeAgentNotConfiguredHint(w io.Writer) { fmt.Fprintf(w, " See docs/config-reference.md for options.\n") } -// handleEditCommand opens editor with command for editing. -func (s *Shell) handleEditCommand(ctx context.Context, command string) { +// handleEditCommand opens the editor on the command and runs the edited line. +func (s *Shell) handleEditCommand(ctx context.Context, command string) (int64, error) { s.historyIndex = -1 s.historySavedLine = "" @@ -1658,26 +1740,14 @@ func (s *Shell) handleEditCommand(ctx context.Context, command string) { if err != nil { fmt.Fprintf(os.Stderr, "hash: editor error: %v\n", err) s.lastExitCode = 1 - return + return 0, nil } if result.Canceled || result.EOF { - return + return 0, nil } - // Execute edited command - editedCmd := strings.TrimSpace(result.Text) - if editedCmd != "" { - execResult, err := s.executor.Execute(ctx, editedCmd, os.Stdout, os.Stderr) - if err != nil { - fmt.Fprintf(os.Stderr, "hash: %v\n", err) - s.lastExitCode = 1 - } else { - s.lastExitCode = execResult.ExitCode - s.lastDuration = execResult.Duration - } - s.recordCommand(editedCmd, s.lastExitCode, s.lastDuration) - } + return s.runAgentCommand(ctx, result.Text) } func (s *Shell) updatePrompt() { @@ -1845,7 +1915,9 @@ func (s *Shell) recordCommand(line string, exitCode int, duration time.Duration) RawCommand: sudoResult.RawCommand, } - _, _ = s.history.Add(cmd) + if id, err := s.history.Add(cmd); err == nil { + s.lastCommandID = id + } } // detectGitBranch returns the current git branch, or empty string if not in a git repo. diff --git a/internal/shell/shell_additional_test.go b/internal/shell/shell_additional_test.go index 43bc3fb..76d7906 100644 --- a/internal/shell/shell_additional_test.go +++ b/internal/shell/shell_additional_test.go @@ -130,7 +130,7 @@ func TestErrorHandler_HandleCommandNotFound_AllCases(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var buf bytes.Buffer h := &ErrorHandler{out: &buf} - h.HandleCommandNotFound(tt.cmd, tt.suggestions, tt.installHint) + h.HandleCommandNotFound(tt.cmd, tt.suggestions, tt.installHint, false) output := buf.String() for _, want := range tt.wantContain { diff --git a/internal/shell/typo_ghost_test.go b/internal/shell/typo_ghost_test.go new file mode 100644 index 0000000..211c647 --- /dev/null +++ b/internal/shell/typo_ghost_test.go @@ -0,0 +1,82 @@ +package shell + +import ( + "bytes" + "context" + "io" + "strings" + "testing" +) + +// newShellForTypo builds a real shell whose only known command is git, with +// the error banner captured, so a mistyped "gti" is corrected deterministically. +func newShellForTypo(t *testing.T, out io.Writer) *Shell { + t.Helper() + sh := newShellForAgentCommand(t) + sh.errors = &ErrorHandler{out: out} + suggestor := &CommandSuggestor{pathCache: []string{"git"}} + suggestor.pathCacheReady.Store(true) + sh.suggestor = suggestor + return sh +} + +// A did-you-mean correction for a mistyped command name becomes the ghost +// text at the next prompt, with the same accept affordance as a learned fix, +// so the user does not retype the line. +func TestCommandNotFound_SuggestionBecomesGhost(t *testing.T) { + var out bytes.Buffer + sh := newShellForTypo(t, &out) + + _ = sh.executeRegularCommand(context.Background(), "gti status") + + if got := sh.promptGhost(); got != "git status" { + t.Errorf("promptGhost() = %q, want the corrected line", got) + } + if !strings.Contains(out.String(), "→ to accept") { + t.Errorf("banner should teach the accept key, got:\n%s", out.String()) + } +} + +// The correction applies when the mistyped name touches a shell operator. +func TestCommandNotFound_CorrectionCrossesShellOperators(t *testing.T) { + var out bytes.Buffer + sh := newShellForTypo(t, &out) + + _ = sh.executeRegularCommand(context.Background(), "echo x&>i status") + + if got := sh.promptGhost(); got != "echo x&&git status" { + t.Errorf("promptGhost() = %q, want the corrected line", got) + } +} + +// The banner promises the accept key only when a ghost will actually be +// offered: without a learning loop there is nothing to accept. +func TestCommandNotFound_NoAcceptHintWithoutALearningLoop(t *testing.T) { + var out bytes.Buffer + sh := newShellForTypo(t, &out) + sh.fixes = newFixTracker(nil) + + _ = sh.executeRegularCommand(context.Background(), "gti status") + + if !strings.Contains(out.String(), "did you mean") { + t.Errorf("banner should still name the correction, got:\n%s", out.String()) + } + if strings.Contains(out.String(), "→ to accept") { + t.Errorf("banner should not offer an accept key it cannot honor, got:\n%s", out.String()) + } +} + +// Without a suggestion there is nothing to accept, so the banner must not +// promise it. +func TestCommandNotFound_NoSuggestionNoAcceptHint(t *testing.T) { + var out bytes.Buffer + sh := newShellForTypo(t, &out) + sh.suggestor = &CommandSuggestor{} + sh.suggestor.pathCacheReady.Store(true) + + _ = sh.executeRegularCommand(context.Background(), "xyzzy") + + if strings.Contains(out.String(), "→ to accept") { + t.Errorf("banner should not offer an accept key with no suggestion, got:\n%s", out.String()) + } +} diff --git a/internal/shell/typo_suggest.go b/internal/shell/typo_suggest.go index de8e6c0..e3643d1 100644 --- a/internal/shell/typo_suggest.go +++ b/internal/shell/typo_suggest.go @@ -48,6 +48,40 @@ func gitDidYouMean(command, stderr string, listBranches func() []string) string return strings.Join(fields, " ") } +// isShellWordBoundary reports whether b can end a command name: whitespace, +// an operator, a grouping character, or a quote. +func isShellWordBoundary(b byte) bool { + switch b { + case ' ', '\t', '\n', ';', '|', '&', '(', ')', '<', '>', '`', '\'', '"': + return true + } + return false +} + +// typoFixLine returns line with its first whole-word occurrence of typo +// replaced by correction (e.g. "gti status" -> "git status"), or "" when the +// typo does not appear as a word, so a correction never lands mid-token. +func typoFixLine(line, typo, correction string) string { + if typo == "" { + return "" + } + for start := 0; start <= len(line)-len(typo); { + idx := strings.Index(line[start:], typo) + if idx < 0 { + return "" + } + idx += start + end := idx + len(typo) + before := idx == 0 || isShellWordBoundary(line[idx-1]) + after := end == len(line) || isShellWordBoundary(line[end]) + if before && after { + return line[:idx] + correction + line[end:] + } + start = idx + 1 + } + return "" +} + // gitBranches lists local branch names in the current directory's repository. func gitBranches() []string { out, err := exec.Command("git", "for-each-ref", "--format=%(refname:short)", "refs/heads").Output()