From 8683f70ba0dd5a8850101162da4c03f0334b2e28 Mon Sep 17 00:00:00 2001 From: Gubarz <1037896+Gubarz@users.noreply.github.com> Date: Tue, 12 May 2026 23:16:22 -0600 Subject: [PATCH] feat history all commands ran through cheatmd can now be pulled back via command history --- README.md | 11 ++ cheatmd.example.yaml | 10 ++ cmd/cheatmd/main.go | 6 +- internal/config/config.go | 34 +++++ internal/history/history.go | 128 ++++++++++++++++ internal/ui/cheat_select.go | 5 + internal/ui/history_view.go | 297 ++++++++++++++++++++++++++++++++++++ internal/ui/main_model.go | 8 + internal/ui/resolve.go | 6 + internal/ui/run.go | 46 ++++++ 10 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 internal/history/history.go create mode 100644 internal/ui/history_view.go diff --git a/README.md b/README.md index 14afb38..f82e5be 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,17 @@ auto-scrolled to the cheat's heading. `↑/↓`/`PgUp/PgDn` scroll, `Esc` or `q` returns. Useful for reading the surrounding notes (descriptions, links, warnings) without leaving the TUI. +### Execution history + +Every time you run a cheat, the final substituted command plus the cheat's +file/header reference and the resolved variable values are appended to +`$XDG_DATA_HOME/cheatmd/history.jsonl` (falling back to +`~/.local/share/cheatmd/history.jsonl`). Press `Ctrl-H` in the cheat picker +to open the history overlay, or launch directly with `cheatmd --history`. +Pick an entry with `Enter` to re-open the original cheat with its previous +values pre-filled, so you can confirm or edit any variable before running +again. `Esc` cancels. + ## DSL ``` diff --git a/cheatmd.example.yaml b/cheatmd.example.yaml index 16a44fb..a06f1d3 100644 --- a/cheatmd.example.yaml +++ b/cheatmd.example.yaml @@ -21,6 +21,16 @@ key_substitute: "ctrl+t" # key_preview: opens a full-screen markdown preview of the current cheat's # source file, scrolled to the cheat's section. Esc / q closes. key_preview: "ctrl+y" +# key_history: opens the execution history picker. Enter on an entry re-opens +# the original cheat with its previous variable values pre-filled. +key_history: "ctrl+h" + +# Execution history. Each cheat run appends a line to history_file as JSONL. +# history_file - path override; empty means $XDG_DATA_HOME/cheatmd/history.jsonl +# (or ~/.local/share/cheatmd/history.jsonl as a fallback). +# history_max - max entries shown in the picker; 0 = unlimited. +history_file: "" +history_max: 1000 # Substitute search sources. Valid entries: "env", "history". # Empty list disables the feature. diff --git a/cmd/cheatmd/main.go b/cmd/cheatmd/main.go index 64b00bc..ef7c8c0 100644 --- a/cmd/cheatmd/main.go +++ b/cmd/cheatmd/main.go @@ -56,6 +56,7 @@ func init() { rootCmd.PersistentFlags().Bool("exec", false, "Execute command (shorthand for -o exec)") rootCmd.PersistentFlags().Bool("auto", false, "Auto-select if query matches exactly one result") rootCmd.PersistentFlags().BoolP("benchmark", "b", false, "Benchmark load time and exit") + rootCmd.PersistentFlags().Bool("history", false, "Open the execution history picker") viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")) } @@ -276,7 +277,10 @@ func runCheats(cmd *cobra.Command, args []string) error { return nil } - // Run the TUI + // Run the TUI (history view if --history was passed) + if historyFlag, _ := cmd.Flags().GetBool("history"); historyFlag { + return ui.RunHistory(index, exec) + } return ui.Run(index, exec, query, match) } diff --git a/internal/config/config.go b/internal/config/config.go index 591f062..730f55f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,11 @@ type Config struct { KeyOpen string `mapstructure:"key_open"` KeySubstitute string `mapstructure:"key_substitute"` KeyPreview string `mapstructure:"key_preview"` + KeyHistory string `mapstructure:"key_history"` + + // Execution history + HistoryFile string `mapstructure:"history_file"` + HistoryMax int `mapstructure:"history_max"` // Substitute search SubstituteSources []string `mapstructure:"substitute_sources"` @@ -88,6 +93,9 @@ var defaults = struct { keyOpen string keySubstitute string keyPreview string + keyHistory string + historyFile string + historyMax int substituteSources []string showFolder bool showFile bool @@ -110,6 +118,9 @@ var defaults = struct { keyOpen: "ctrl+o", // Ctrl+O in TUI keySubstitute: "ctrl+t", // Ctrl+T opens substitute search during var resolution keyPreview: "ctrl+y", // Ctrl+Y opens markdown preview of current cheat's file + keyHistory: "ctrl+h", // Ctrl+H opens execution history + historyFile: "", // Empty -> $XDG_DATA_HOME/cheatmd/history.jsonl + historyMax: 1000, substituteSources: []string{"env", "history"}, showFolder: true, showFile: true, @@ -177,6 +188,11 @@ func setDefaults() { viper.SetDefault("key_open", defaults.keyOpen) viper.SetDefault("key_substitute", defaults.keySubstitute) viper.SetDefault("key_preview", defaults.keyPreview) + viper.SetDefault("key_history", defaults.keyHistory) + + // Execution history + viper.SetDefault("history_file", defaults.historyFile) + viper.SetDefault("history_max", defaults.historyMax) // Substitute search viper.SetDefault("substitute_sources", defaults.substituteSources) @@ -330,6 +346,24 @@ func GetKeyPreview() string { return viper.GetString("key_preview") } +// GetKeyHistory returns the keybinding for opening the execution history +// overlay (e.g., "ctrl+h"). +func GetKeyHistory() string { + return viper.GetString("key_history") +} + +// GetHistoryFile returns the override path for the history file, or "" for +// the default ($XDG_DATA_HOME/cheatmd/history.jsonl). +func GetHistoryFile() string { + return viper.GetString("history_file") +} + +// GetHistoryMax returns the cap on history entries shown in the picker. +// Zero or negative means unlimited. +func GetHistoryMax() int { + return viper.GetInt("history_max") +} + // GetSubstituteSources returns the enabled sources for substitute search. // Valid entries: "env", "history". Empty disables the feature. func GetSubstituteSources() []string { diff --git a/internal/history/history.go b/internal/history/history.go new file mode 100644 index 0000000..fcf1b44 --- /dev/null +++ b/internal/history/history.go @@ -0,0 +1,128 @@ +// Package history records and reads the user's cheat execution history. +// +// Entries are stored newline-delimited JSON in $XDG_DATA_HOME/cheatmd/history.jsonl +// (falling back to ~/.local/share/cheatmd/history.jsonl). Each entry captures +// the final substituted command plus the source cheat reference and the +// resolved scope, so re-running can re-prefill variables. +package history + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// Entry is one recorded execution of a cheat. +type Entry struct { + Timestamp time.Time `json:"ts"` + Command string `json:"cmd"` + File string `json:"file"` + Header string `json:"header"` + Scope map[string]string `json:"scope,omitempty"` +} + +// DefaultPath returns the canonical history file path. The override is used +// verbatim if non-empty; otherwise $XDG_DATA_HOME/cheatmd/history.jsonl is +// preferred, with ~/.local/share/cheatmd/history.jsonl as the fallback. +func DefaultPath(override string) (string, error) { + if override = strings.TrimSpace(override); override != "" { + if strings.HasPrefix(override, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, override[2:]), nil + } + return override, nil + } + if xdg := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xdg != "" { + return filepath.Join(xdg, "cheatmd", "history.jsonl"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "cheatmd", "history.jsonl"), nil +} + +// Append writes one entry to the history file. The file and any parent +// directories are created on demand. Errors writing history are non-fatal +// to the caller; surface them only for logging/diagnostics. +func Append(path string, e Entry) error { + if e.Timestamp.IsZero() { + e.Timestamp = time.Now() + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + return enc.Encode(e) +} + +// Load returns up to maxEntries most-recent entries from path, newest first. +// A missing file is not an error; an empty slice is returned. +func Load(path string, maxEntries int) ([]Entry, error) { + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer f.Close() + + entries := make([]Entry, 0, 256) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var e Entry + if err := json.Unmarshal(line, &e); err != nil { + continue // skip corrupt lines silently + } + entries = append(entries, e) + } + if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) { + return entries, err + } + + // Newest first. + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + if maxEntries > 0 && len(entries) > maxEntries { + entries = entries[:maxEntries] + } + return entries, nil +} + +// Display renders an entry as a single line for picker display. Long commands +// are truncated to keep each row to one screen line. +func (e Entry) Display(maxWidth int) string { + ts := e.Timestamp.Local().Format("2006-01-02 15:04") + cmd := strings.ReplaceAll(e.Command, "\n", " ") + prefix := fmt.Sprintf("%s ", ts) + avail := maxWidth - len(prefix) + if avail < 10 { + avail = 10 + } + if len(cmd) > avail { + cmd = cmd[:avail-1] + "…" + } + return prefix + cmd +} diff --git a/internal/ui/cheat_select.go b/internal/ui/cheat_select.go index 3faa639..9dd8e6f 100644 --- a/internal/ui/cheat_select.go +++ b/internal/ui/cheat_select.go @@ -219,6 +219,11 @@ func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd { } } } + if msg.String() == config.GetKeyHistory() { + if m.enterHistory() { + return tea.ClearScreen + } + } } return nil } diff --git a/internal/ui/history_view.go b/internal/ui/history_view.go new file mode 100644 index 0000000..99deb49 --- /dev/null +++ b/internal/ui/history_view.go @@ -0,0 +1,297 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/gubarz/cheatmd/internal/config" + "github.com/gubarz/cheatmd/internal/history" + "github.com/gubarz/cheatmd/internal/parser" +) + +// historyState holds the overlay state for the execution-history picker. +type historyState struct { + entries []history.Entry // all loaded entries, newest first + filtered []history.Entry + cursor int + offset int + // Saved cheat-select state to restore on cancel. + prevInput string + prevCursor int + prevOffset int +} + +// enterHistory transitions from phaseCheatSelect into the history overlay. +// Returns true on success, false if there are no entries or history is +// otherwise unavailable. +func (m *mainModel) enterHistory() bool { + path, err := history.DefaultPath(config.GetHistoryFile()) + if err != nil { + return false + } + entries, err := history.Load(path, config.GetHistoryMax()) + if err != nil || len(entries) == 0 { + return false + } + m.histState = &historyState{ + entries: entries, + filtered: entries, + prevInput: m.textInput.Value(), + prevCursor: m.cursor, + prevOffset: m.offset, + } + m.textInput.SetValue("") + m.textInput.Placeholder = "Search history..." + m.cursor = 0 + m.offset = 0 + m.phase = phaseHistory + return true +} + +// exitHistory returns to phaseCheatSelect without selecting an entry. +func (m *mainModel) exitHistory() { + if m.histState != nil { + m.textInput.SetValue(m.histState.prevInput) + m.cursor = m.histState.prevCursor + m.offset = m.histState.prevOffset + } + m.histState = nil + m.textInput.Placeholder = "Type to search..." + m.phase = phaseCheatSelect +} + +// acceptHistory takes the currently highlighted entry, finds its cheat in +// the index, copies the recorded scope into it, and transitions to var +// resolution. If the cheat is no longer in the index (file renamed, header +// changed) it falls back to inserting the raw command into the prompt. +func (m *mainModel) acceptHistory() tea.Cmd { + if m.histState == nil || m.histState.cursor >= len(m.histState.filtered) { + m.exitHistory() + return nil + } + entry := m.histState.filtered[m.histState.cursor] + cheat := findCheatByRef(m.cheatIndex, entry.File, entry.Header) + if cheat == nil { + // Cheat no longer exists. Bail back to cheat select with the command + // as a search query so the user has something to act on. + m.textInput.SetValue(entry.Command) + m.exitHistory() + m.filterCheats() + return nil + } + + // Reset and pre-fill the cheat's scope from the recorded entry. + cheat.Scope = make(map[string]string, len(entry.Scope)) + for k, v := range entry.Scope { + cheat.Scope[k] = v + } + + m.histState = nil + m.textInput.Placeholder = "Type to filter or enter value..." + m.selected = cheat + return m.startVarResolution() +} + +// filterHistoryEntries applies the current input as a case-insensitive AND +// fuzzy filter over entries (command + header + file). +func (m *mainModel) filterHistoryEntries() { + if m.histState == nil { + return + } + query := strings.ToLower(strings.TrimSpace(m.textInput.Value())) + if query == "" { + m.histState.filtered = m.histState.entries + m.histState.cursor = 0 + m.histState.offset = 0 + return + } + words := strings.Fields(query) + result := make([]history.Entry, 0, len(m.histState.entries)) + for _, e := range m.histState.entries { + hay := strings.ToLower(e.Command + " " + e.Header + " " + e.File) + if matchesAllWords(hay, words) { + result = append(result, e) + } + } + m.histState.filtered = result + if m.histState.cursor >= len(result) { + m.histState.cursor = max(0, len(result)-1) + } + if m.histState.offset > m.histState.cursor { + m.histState.offset = m.histState.cursor + } +} + +// moveHistoryCursor clamps the cursor; offset is reconciled at render time. +func (m *mainModel) moveHistoryCursor(delta int) { + if m.histState == nil { + return + } + m.histState.cursor += delta + m.histState.cursor = clamp(m.histState.cursor, 0, max(0, len(m.histState.filtered)-1)) +} + +// handleHistoryKey processes keys while in phaseHistory. +func (m *mainModel) handleHistoryKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+c": + m.quitting = true + m.selected = nil + return tea.Quit + case "esc": + m.exitHistory() + return tea.ClearScreen + case "enter": + return m.acceptHistory() + case "up", "ctrl+p": + m.moveHistoryCursor(-1) + return nil + case "down", "ctrl+n": + m.moveHistoryCursor(1) + return nil + case "pgup": + m.moveHistoryCursor(-10) + return nil + case "pgdown": + m.moveHistoryCursor(10) + return nil + } + return nil +} + +// updateHistory handles updates while the history overlay is open. +func (m *mainModel) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if cmd := m.handleHistoryKey(msg); cmd != nil { + return m, cmd + } + if isHistoryNavKey(msg.String()) { + return m, nil + } + } + + prev := m.textInput.Value() + var tiCmd tea.Cmd + m.textInput, tiCmd = m.textInput.Update(msg) + if m.textInput.Value() != prev { + m.filterHistoryEntries() + } + return m, tiCmd +} + +// isHistoryNavKey mirrors isSubstituteNavKey: navigation/accept/cancel keys +// the overlay swallows rather than passing to the text input. +func isHistoryNavKey(key string) bool { + switch key { + case "ctrl+c", "esc", "enter", "up", "down", "ctrl+p", "ctrl+n", "pgup", "pgdown": + return true + } + return false +} + +// renderHistory renders the history overlay using the same layout shape as +// renderSubstituteSearch. +func (m *mainModel) renderHistory() string { + width := max(m.width, 80) + height := m.height + if height < 1 { + height = 24 + } + + inputLines := 3 + previewHeight := 2 + preview := m.renderHistoryPreview(width, previewHeight) + + previewLines := countLines(preview) + listHeight := max(height-previewLines-inputLines, 1) + list := m.renderHistoryList(listHeight, width) + + return renderWindowLayout(height, preview, list, m.renderHistoryInput(width)) +} + +// renderHistoryPreview is the top header: title + divider, padded to fit. +func (m *mainModel) renderHistoryPreview(width, maxLines int) string { + b := getBuilder() + defer putBuilder(b) + lines := 0 + + if lines < maxLines { + b.WriteString(styles.Header.Render("History")) + if m.histState != nil { + b.WriteString(" ") + b.WriteString(styles.Dim.Render(fmt.Sprintf("(%d entries)", len(m.histState.entries)))) + } + b.WriteString("\n") + lines++ + } + for lines < maxLines { + b.WriteString("\n") + lines++ + } + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + return b.String() +} + +// renderHistoryList renders the scrolling list of history entries. +func (m *mainModel) renderHistoryList(maxHeight, width int) string { + if m.histState == nil || len(m.histState.filtered) == 0 { + return "" + } + + start, end := scrollWindow(m.histState.cursor, len(m.histState.filtered), maxHeight, &m.histState.offset) + maxLen := max(width-2, 10) + + b := getBuilder() + defer putBuilder(b) + for i := start; i < end; i++ { + entry := m.histState.filtered[i] + display := entry.Display(maxLen) + if i == m.histState.cursor { + b.WriteString(styles.Cursor.Render("▶ ")) + b.WriteString(styles.Selected.Render(styles.Command.Render(display))) + } else { + b.WriteString(" ") + b.WriteString(styles.Command.Render(display)) + } + b.WriteString("\n") + } + return b.String() +} + +// renderHistoryInput renders the bottom divider, hint, and search input. +func (m *mainModel) renderHistoryInput(width int) string { + b := getBuilder() + defer putBuilder(b) + b.WriteString(styles.Divider.Render(strings.Repeat("─", width))) + b.WriteString("\n") + matchCount := 0 + if m.histState != nil { + matchCount = len(m.histState.filtered) + } + b.WriteString(styles.Dim.Render(fmt.Sprintf(" %d matches", matchCount))) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("ESC cancel")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("Enter re-run cheat")) + b.WriteString("\n") + b.WriteString(m.textInput.View()) + return b.String() +} + +// findCheatByRef locates a cheat in the index matching the given file path +// and header. Returns nil if no exact match. +func findCheatByRef(index *parser.CheatIndex, file, header string) *parser.Cheat { + if index == nil { + return nil + } + for _, c := range index.Cheats { + if c.File == file && c.Header == header { + return c + } + } + return nil +} diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index 6b69461..1214f39 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -52,6 +52,7 @@ const ( phaseVarResolve // Resolving variables phaseSubstituteSearch // Substitute-search overlay during var resolution phasePreview // Full-screen markdown preview of cheat's source file + phaseHistory // Execution-history overlay ) // mainModel is the Bubble Tea model for cheat selection AND variable resolution @@ -84,6 +85,9 @@ type mainModel struct { // Preview overlay state (only used in phasePreview) previewState *previewOverlayState + // History overlay state (only used in phaseHistory) + histState *historyState + // Dependencies for variable resolution cheatIndex *parser.CheatIndex executor Executor @@ -154,6 +158,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.phase { case phasePreview: return m.updatePreview(msg) + case phaseHistory: + return m.updateHistory(msg) case phaseSubstituteSearch: return m.updateSubstituteSearch(msg) case phaseVarResolve: @@ -174,6 +180,8 @@ func (m *mainModel) View() string { switch m.phase { case phasePreview: return m.renderPreview() + case phaseHistory: + return m.renderHistory() case phaseSubstituteSearch: return m.renderSubstituteSearch() case phaseVarResolve: diff --git a/internal/ui/resolve.go b/internal/ui/resolve.go index 40fbae9..d53bd3f 100644 --- a/internal/ui/resolve.go +++ b/internal/ui/resolve.go @@ -21,6 +21,12 @@ func Run(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string) return RunTUI(index, exec, initialQuery, matchCmd) } +// RunHistory launches the TUI with the history overlay open. If history is +// empty or unreadable, an error is returned without entering the TUI. +func RunHistory(index *parser.CheatIndex, exec Executor) error { + return RunTUIWithStart(index, exec, "", "", phaseHistory) +} + // ============================================================================ // Variable Resolution // ============================================================================ diff --git a/internal/ui/run.go b/internal/ui/run.go index 24b0740..b5392e2 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -10,9 +10,36 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/gubarz/cheatmd/internal/config" + "github.com/gubarz/cheatmd/internal/history" "github.com/gubarz/cheatmd/internal/parser" ) +// recordRun appends one entry to the history file. Errors are silently +// dropped; history is best-effort, never blocking execution. +func recordRun(cheat *parser.Cheat, finalCmd string) { + if cheat == nil || finalCmd == "" { + return + } + path, err := history.DefaultPath(config.GetHistoryFile()) + if err != nil { + return + } + // Copy scope so later mutations to cheat.Scope can't corrupt the entry. + var scopeCopy map[string]string + if len(cheat.Scope) > 0 { + scopeCopy = make(map[string]string, len(cheat.Scope)) + for k, v := range cheat.Scope { + scopeCopy[k] = v + } + } + _ = history.Append(path, history.Entry{ + Command: finalCmd, + File: cheat.File, + Header: cheat.Header, + Scope: scopeCopy, + }) +} + // getTTY returns file handles for TUI input/output. Uses /dev/tty to bypass // shell pipes and command substitution when stdout is not a terminal. func getTTY() (in *os.File, out *os.File, cleanup func()) { @@ -48,6 +75,13 @@ func getTTY() (in *os.File, out *os.File, cleanup func()) { // RunTUI launches the Bubble Tea interface (unified, no flicker). func RunTUI(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string) error { + return RunTUIWithStart(index, exec, initialQuery, matchCmd, phaseCheatSelect) +} + +// RunTUIWithStart launches the TUI and (optionally) jumps directly into a +// non-default starting phase. startPhase == phaseCheatSelect behaves the +// same as RunTUI; phaseHistory opens the history overlay on entry. +func RunTUIWithStart(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd string, startPhase uiPhase) error { requireCheatBlock := config.GetRequireCheatBlock() autoSelect := config.GetAutoSelect() @@ -67,6 +101,7 @@ func RunTUI(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd stri if m.phase != phaseVarResolve { finalCmd := exec.BuildFinalCommand(m.selected) + recordRun(m.selected, finalCmd) return executeOutput(finalCmd, exec) } @@ -92,11 +127,21 @@ func RunTUI(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd stri if m.phase != phaseVarResolve { finalCmd := exec.BuildFinalCommand(m.selected) + recordRun(m.selected, finalCmd) return executeOutput(finalCmd, exec) } } } + // If a non-default start phase is requested, transition into it before + // starting the bubbletea program. Only phaseHistory is supported as a + // jump-start currently; unsupported values are ignored. + if startPhase == phaseHistory && m.phase == phaseCheatSelect { + if !m.enterHistory() { + return fmt.Errorf("no history yet (run some cheats first)") + } + } + ttyIn, ttyOut, cleanup := getTTY() RefreshStyles() p := tea.NewProgram(&m, tea.WithAltScreen(), tea.WithOutput(ttyOut), tea.WithInput(ttyIn)) @@ -116,6 +161,7 @@ func RunTUI(index *parser.CheatIndex, exec Executor, initialQuery, matchCmd stri } finalCmd := exec.BuildFinalCommand(result.selected) + recordRun(result.selected, finalCmd) return executeOutput(finalCmd, exec) }