From 4ce3d9555ea56080515a7d2e25bee27ede0b5492 Mon Sep 17 00:00:00 2001 From: Assaf Schwartz Date: Fri, 9 Oct 2026 14:53:37 -0400 Subject: [PATCH 1/6] fix(agent): run a backticked one-line reply as the bare command --- internal/agent/command_shape.go | 2 +- internal/agent/command_shape_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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}, From 6a0baeb0d86b02c544d02cdc023a3dc5f66c6497 Mon Sep 17 00:00:00 2001 From: Assaf Schwartz Date: Fri, 9 Oct 2026 14:54:56 -0400 Subject: [PATCH 2/6] fix(shell): run an agent-suggested command through the same path as a typed line --- internal/shell/agent_command_path_test.go | 77 +++++++++++++++ internal/shell/agent_stream_error_test.go | 24 ++--- internal/shell/shell.go | 115 +++++++++------------- 3 files changed, 137 insertions(+), 79 deletions(-) create mode 100644 internal/shell/agent_command_path_test.go diff --git a/internal/shell/agent_command_path_test.go b/internal/shell/agent_command_path_test.go new file mode 100644 index 0000000..1394bf0 --- /dev/null +++ b/internal/shell/agent_command_path_test.go @@ -0,0 +1,77 @@ +package shell + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/tfcace/hash/internal/agent" + "github.com/tfcace/hash/internal/config" +) + +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 + return sh +} + +// 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}, 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}, 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"}, "exit", 1) + + if err != errExit { + t.Fatalf("handleAgentConfirmAction() error = %v, want errExit", err) + } +} 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/shell.go b/internal/shell/shell.go index dce0de5..e198bef 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -1225,12 +1225,11 @@ 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 { requestCtx, timeoutCancel := context.WithTimeout(ctx, s.agentRequestTimeout()) defer timeoutCancel() @@ -1263,22 +1262,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,7 +1283,7 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars s.responseUI.ClearLine() s.responseUI.ShowError(emptyAgentResponseMessage) s.lastExitCode = 1 - return + return nil } // Success path - add newline after response and clear spinner @@ -1306,7 +1303,7 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars confirmType, needsConfirmation := confirmationTypeForAgentResponse(resp, allowReply) if !needsConfirmation { s.responseUI.StopProgress() - return + return nil } s.agentOutput.EnterConfirming() @@ -1319,13 +1316,10 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars s.responseUI.StopProgress() if action == ConfirmReply { - s.runAgentConversationLoop(ctx, modelName, transcript) - return + return s.runAgentConversationLoop(ctx, modelName, transcript) } - if s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) { - return - } + return s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) } func (s *Shell) presentAgentToolUpdate(update agent.ToolCallUpdate, prefix string) { @@ -1375,23 +1369,23 @@ 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...) 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}) @@ -1404,7 +1398,7 @@ func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, agentTurnAllowsReply(parser.CommandTypeAgent, resp), ) if !needsConfirmation { - return + return nil } s.agentOutput.EnterConfirming() @@ -1418,8 +1412,7 @@ func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, if action == ConfirmReply { continue } - s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) - return + return s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) } } @@ -1527,19 +1520,19 @@ 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 { +// 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, responseText string, lineCount int) error { switch action { case ConfirmRun: if confirmType == ConfirmTypeCommand { - s.executeAgentCommand(ctx, resp.Command) + return s.runAgentCommand(ctx, resp.Command) } // For explanations, ConfirmRun just dismisses case ConfirmEdit: if confirmType == ConfirmTypeCommand { - s.handleEditCommand(ctx, resp.Command) - return true + return s.handleEditCommand(ctx, resp.Command) } // Copy explanation to system clipboard if err := copyToSystemClipboard(responseText); err != nil { @@ -1552,25 +1545,26 @@ 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 + return nil } -// 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 +// 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. +func (s *Shell) runAgentCommand(ctx context.Context, command string) error { + command = strings.TrimSpace(command) + if command == "" { + return nil } - s.recordCommand(command, s.lastExitCode, s.lastDuration) + return s.executeRegularCommand(ctx, command) } -// 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 +1574,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 +1584,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 +1594,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 +1611,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 +1636,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) error { s.historyIndex = -1 s.historySavedLine = "" @@ -1658,26 +1651,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 nil } if result.Canceled || result.EOF { - return + return 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() { From c3c3344131c87296e3493126cf6be32d06a22986 Mon Sep 17 00:00:00 2001 From: Assaf Schwartz Date: Fri, 9 Oct 2026 14:59:59 -0400 Subject: [PATCH 3/6] feat(history): record each ?? turn with the command it produced --- internal/shell/agent_command_path_test.go | 107 +++++++++++++++++++++- internal/shell/shell.go | 91 +++++++++++++++--- 2 files changed, 180 insertions(+), 18 deletions(-) diff --git a/internal/shell/agent_command_path_test.go b/internal/shell/agent_command_path_test.go index 1394bf0..5df678e 100644 --- a/internal/shell/agent_command_path_test.go +++ b/internal/shell/agent_command_path_test.go @@ -2,12 +2,15 @@ package shell import ( "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 { @@ -21,9 +24,60 @@ func newShellForAgentCommand(t *testing.T) *Shell { 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) { @@ -33,7 +87,7 @@ func TestHandleAgentConfirmAction_FailedCommandBecomesLastError(t *testing.T) { cmd := "cat " + missing sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand, - agent.Response{Type: agent.ResponseTypeCommand, Command: cmd}, cmd, 1) + 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) @@ -54,7 +108,7 @@ func TestHandleAgentConfirmAction_CommandContainingDoubleQuestionMarkRunsAsShell cmd := "echo a??b" sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand, - agent.Response{Type: agent.ResponseTypeCommand, Command: cmd}, cmd, 1) + 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) @@ -69,9 +123,56 @@ func TestHandleAgentConfirmAction_ExitEndsTheShell(t *testing.T) { sh := newShellForAgentCommand(t) err := sh.handleAgentConfirmAction(context.Background(), ConfirmRun, ConfirmTypeCommand, - agent.Response{Type: agent.ResponseTypeCommand, Command: "exit"}, "exit", 1) + 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]) + } +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index e198bef..e3239d1 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -72,6 +72,7 @@ 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 lastCommand string // Last executed command lastStderr string // Stderr from last command (truncated) lastCwd string // Working directory of last command @@ -1230,6 +1231,7 @@ func (s *Shell) handleAgentRequestUnified(ctx context.Context, parsed parser.Par // handleAgentFullStreaming handles full ?? and pipe modes with streaming. 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() @@ -1285,6 +1287,7 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars s.lastExitCode = 1 return nil } + turn := agentTurn{prompt: parsed.AgentPrompt, response: responseText, latency: time.Since(started)} // Success path - add newline after response and clear spinner fmt.Println() @@ -1303,6 +1306,7 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars confirmType, needsConfirmation := confirmationTypeForAgentResponse(resp, allowReply) if !needsConfirmation { s.responseUI.StopProgress() + s.recordAgentTurn(turn, true, 0) return nil } @@ -1316,10 +1320,11 @@ func (s *Shell) handleAgentFullStreaming(ctx context.Context, parsed parser.Pars s.responseUI.StopProgress() if action == ConfirmReply { + s.recordAgentTurn(turn, true, 0) return s.runAgentConversationLoop(ctx, modelName, transcript) } - return s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) + return s.handleAgentConfirmAction(ctx, action, confirmType, resp, turn, lineCount) } func (s *Shell) presentAgentToolUpdate(update agent.ToolCallUpdate, prefix string) { @@ -1382,14 +1387,17 @@ func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, } 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 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 } @@ -1398,6 +1406,7 @@ func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, agentTurnAllowsReply(parser.CommandTypeAgent, resp), ) if !needsConfirmation { + s.recordAgentTurn(turn, true, 0) return nil } @@ -1410,9 +1419,10 @@ func (s *Shell) runAgentConversationLoop(ctx context.Context, modelName string, s.responseUI.StopProgress() if action == ConfirmReply { + s.recordAgentTurn(turn, true, 0) continue } - return s.handleAgentConfirmAction(ctx, action, confirmType, resp, responseText, lineCount) + return s.handleAgentConfirmAction(ctx, action, confirmType, resp, turn, lineCount) } } @@ -1520,23 +1530,59 @@ func confirmationTypeForAgentResponse(resp agent.Response, allowExplanationReply return 0, false } +// 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, responseText string, lineCount int) error { +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 { - return s.runAgentCommand(ctx, resp.Command) + commandID, err = s.runAgentCommand(ctx, resp.Command) } // For explanations, ConfirmRun just dismisses case ConfirmEdit: if confirmType == ConfirmTypeCommand { - return s.handleEditCommand(ctx, resp.Command) + 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") } @@ -1545,7 +1591,14 @@ 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 nil + 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 } // runAgentCommand runs a command the agent handed back exactly as if the @@ -1553,12 +1606,18 @@ func (s *Shell) handleAgentConfirmAction(ctx context.Context, action ConfirmActi // 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. -func (s *Shell) runAgentCommand(ctx context.Context, command string) error { +// 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 nil + return 0, nil } - return s.executeRegularCommand(ctx, command) + before := s.lastCommandID + err = s.executeRegularCommand(ctx, command) + if s.lastCommandID != before { + commandID = s.lastCommandID + } + return commandID, err } // handleAgentStreamError handles errors during agent streaming. The caller @@ -1637,7 +1696,7 @@ func writeAgentNotConfiguredHint(w io.Writer) { } // handleEditCommand opens the editor on the command and runs the edited line. -func (s *Shell) handleEditCommand(ctx context.Context, command string) error { +func (s *Shell) handleEditCommand(ctx context.Context, command string) (int64, error) { s.historyIndex = -1 s.historySavedLine = "" @@ -1651,11 +1710,11 @@ func (s *Shell) handleEditCommand(ctx context.Context, command string) error { if err != nil { fmt.Fprintf(os.Stderr, "hash: editor error: %v\n", err) s.lastExitCode = 1 - return nil + return 0, nil } if result.Canceled || result.EOF { - return nil + return 0, nil } return s.runAgentCommand(ctx, result.Text) @@ -1826,7 +1885,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. From 8e50e59170884ec6f9ca77892e55f47cd2cc77bb Mon Sep 17 00:00:00 2001 From: Assaf Schwartz Date: Fri, 9 Oct 2026 15:01:17 -0400 Subject: [PATCH 4/6] feat(history): show the ?? question behind a command in the Ctrl+R preview --- internal/history/search_ui.go | 8 ++++++++ internal/history/search_ui_test.go | 23 +++++++++++++++++++++++ internal/history/store.go | 20 ++++++++++++++++++++ internal/history/store_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) 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") + } +} From ae42052e615b485beb63e547b2f7ea47fca3bc3d Mon Sep 17 00:00:00 2001 From: Assaf Schwartz Date: Fri, 9 Oct 2026 15:01:48 -0400 Subject: [PATCH 5/6] feat(shell): show an agent command on its own input line when it runs --- internal/editor/editor.go | 9 +++++++++ internal/editor/print_submitted_test.go | 24 +++++++++++++++++++++++ internal/shell/agent_command_path_test.go | 20 +++++++++++++++++++ internal/shell/shell.go | 17 ++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 internal/editor/print_submitted_test.go 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/shell/agent_command_path_test.go b/internal/shell/agent_command_path_test.go index 5df678e..2f271ed 100644 --- a/internal/shell/agent_command_path_test.go +++ b/internal/shell/agent_command_path_test.go @@ -1,6 +1,7 @@ package shell import ( + "bytes" "context" "io" "path/filepath" @@ -176,3 +177,22 @@ func TestHandleAgentConfirmAction_CancelRecordsTheTurnAsDeclined(t *testing.T) { 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/shell.go b/internal/shell/shell.go index e3239d1..4610128 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -1572,11 +1572,13 @@ func (s *Shell) handleAgentConfirmAction(ctx context.Context, action ConfirmActi switch action { case ConfirmRun: if confirmType == ConfirmTypeCommand { + s.showAgentCommandAsTyped(resp.Command) commandID, err = s.runAgentCommand(ctx, resp.Command) } // For explanations, ConfirmRun just dismisses case ConfirmEdit: if confirmType == ConfirmTypeCommand { + s.clearConfirmationHint() // the editor takes the hint's place commandID, err = s.handleEditCommand(ctx, resp.Command) break } @@ -1601,6 +1603,21 @@ func (s *Shell) handleAgentConfirmAction(ctx context.Context, action ConfirmActi return err } +// 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. From b48fa293cea2b8085c57e8539379812bb415300f Mon Sep 17 00:00:00 2001 From: Assaf Schwartz Date: Fri, 9 Oct 2026 15:03:24 -0400 Subject: [PATCH 6/6] feat(shell): offer a did-you-mean command as ghost text at the next prompt --- internal/shell/error_handler.go | 16 ++++- internal/shell/error_handler_test.go | 4 +- internal/shell/fix_tracker.go | 13 ++++ internal/shell/shell.go | 19 +++++- internal/shell/shell_additional_test.go | 2 +- internal/shell/typo_ghost_test.go | 82 +++++++++++++++++++++++++ internal/shell/typo_suggest.go | 34 ++++++++++ 7 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 internal/shell/typo_ghost_test.go 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 4610128..70abf95 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -73,6 +73,8 @@ type Shell struct { 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 @@ -700,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 } @@ -760,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) { @@ -789,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) 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()