From 4dcb380a6818ebefff2d23947589817415c0ea76 Mon Sep 17 00:00:00 2001 From: iUnstable0 Date: Fri, 31 Jul 2026 23:36:08 +0700 Subject: [PATCH] Open the run report from the final frame The end-of-run frame prints the report path as an OSC 8 link and draws an open button next to it. Terminals only follow hyperlinks on ctrl/cmd+click, so the frame records where the button sits and the mouse is captured for that frame alone, leaving text selection alone during the run. Pressing o does the same for terminals that report no mouse. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + README.md | 4 ++ app.go | 64 ++++++++++++++++++++-- main.go | 2 +- open.go | 20 +++++++ tui.go | 56 +++++++++++++++++++ tui_test.go | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 open.go diff --git a/.gitignore b/.gitignore index ee1ab2f..4c79fff 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ !demo/demo-package.zip package/ demo.mp4 +.idea/ diff --git a/README.md b/README.md index 82f49e3..5c28465 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,10 @@ Reports land in your OS cache directory, timestamped, next to the resume log: Use `--report PATH` to write it somewhere specific, or `DISCORD_DELETE_STATE_DIR` to move the whole directory. +The final screen prints that path with an `↗ open report` button. Click it, or press `o`, to open the report in whatever handles `.txt`. It is an OSC 8 hyperlink too, for terminals that follow those on ctrl+click (cmd+click on macOS). + +The mouse is captured only on that final frame, so drag-to-select works as usual during a run; hold shift to select there. + That directory also holds the resume log, `.deleted.log`, one confirmed-gone ID per line. It is keyed by account and therefore works across packages. Delete the log to start over from scratch. The TUI also saves its settings to `.config.json` in that directory, so the next run for that account starts where you left off. The token is not saved here. Any explicit flag overrides a saved value, and headless runs ignore the file. diff --git a/app.go b/app.go index c0d9366..bd2356f 100644 --- a/app.go +++ b/app.go @@ -174,6 +174,8 @@ type appModel struct { reportPath string // where the run report was written ("" = not yet / failed) reportOverride string // --report path; "" = default alongside the resume log notifyResult string // short outcome of the ntfy ping, shown on the final frame + reportHit hitBox // where the final frame's open button sits, for click handling + openErr string // last failure from opening the report width, height int quitting bool @@ -654,6 +656,7 @@ func (m *appModel) launchEngine() (tea.Model, tea.Cmd) { m.reported = false m.stopping, m.pausePend = false, false m.reportPath, m.notifyResult, m.logWarn = "", "", "" + m.reportHit, m.openErr = hitBox{}, "" m.screen = scRunning // Remote control (pause/resume/stop from the phone) rides the same ntfy @@ -896,12 +899,34 @@ func (m *appModel) finalizeRun() tea.Cmd { Results: m.phaseResults, Resumed: m.resumed, } + cmds := []tea.Cmd{notifyCmd(resolveNtfyURL(m.cfg.ntfy), r)} if path := r.destPath(m.reportOverride, m.reportProgPath()); path != "" { if err := writeRunReport(path, r); err == nil { m.reportPath = path + // Only now: during the run the terminal keeps the mouse, so + // selecting text works as usual. + cmds = append(cmds, tea.EnableMouseCellMotion) } } - return notifyCmd(resolveNtfyURL(m.cfg.ntfy), r) + return tea.Batch(cmds...) +} + +// clickReport answers a plain click on the final frame's button, which the OSC 8 +// link cannot: terminals reserve that for ctrl/cmd+click. +func (m *appModel) clickReport(msg tea.MouseMsg) { + if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { + return + } + if m.reportPath != "" && m.reportHit.contains(msg.X, msg.Y) { + m.openReport() + } +} + +func (m *appModel) openReport() { + m.openErr = "" + if err := openFile(m.reportPath); err != nil { + m.openErr = "could not open " + m.reportPath + ": " + err.Error() + } } // reportProgPath is the resume log the report path is derived from: the message @@ -1028,6 +1053,10 @@ func (m *appModel) viewConfirm() string { // --- running --------------------------------------------------------------- func (m *appModel) updateRunning(msg tea.Msg) (tea.Model, tea.Cmd) { + if mouse, ok := msg.(tea.MouseMsg); ok { + m.clickReport(mouse) + return m, nil + } key, ok := msg.(tea.KeyMsg) if !ok { return m, nil @@ -1055,7 +1084,13 @@ func (m *appModel) updateRunning(msg tea.Msg) (tea.Model, tea.Cmd) { m.finishRun() m.recompute() m.screen = scHome - return m, nil + // Hand the mouse back: nothing off this screen is clickable. + m.reportHit, m.openErr = hitBox{}, "" + return m, tea.DisableMouse + } + case "o": + if m.reportPath != "" { // only set once the run has finalized + m.openReport() } case "p", " ": // Pause / resume (no-op once the run is done or in dry run). A manual @@ -1140,7 +1175,15 @@ func (m *appModel) viewRunning() string { b.WriteString(wrapText(stYellow.Render(fmt.Sprintf("⤼ %s message(s) can't be deleted (system messages or servers/DMs you've left). Post-run report has more details.", commafy(n))), m.width, 2) + "\n") } if m.reportPath != "" { - b.WriteString(wrapText(stDim.Render("report: "+m.reportPath), m.width, 2) + "\n") + // Wrapped first, linked after: the escapes stay out of the wrap. + b.WriteString(linkPath(m.reportPath, + wrapText(stDim.Render("report: "+m.reportPath), m.width, 2)) + "\n") + line, hit := reportButton(m.reportPath, strings.Count(b.String(), "\n")) + m.reportHit = hit + b.WriteString(line + "\n") + if m.openErr != "" { + b.WriteString(wrapText(stYellow.Render("⚠ "+m.openErr), m.width, 2) + "\n") + } } if m.notifyResult != "" { b.WriteString(wrapText(stDim.Render(m.notifyResult), m.width, 2) + "\n") @@ -1152,7 +1195,11 @@ func (m *appModel) viewRunning() string { case !snap.Completed: done = stYellow.Render("stopped") } - b.WriteString(wrapText(done+stDim.Render(" · ")+ + keys := "" + if m.reportPath != "" { + keys = stKeyHelp.Render("o") + stDim.Render(" open report ") + } + b.WriteString(wrapText(done+stDim.Render(" · ")+keys+ stKeyHelp.Render("b")+stDim.Render(" home ")+stKeyHelp.Render("q")+stDim.Render(" quit"), m.width, 2) + "\n") } else { if m.paused { @@ -1172,7 +1219,14 @@ func (m *appModel) viewRunning() string { } b.WriteString(wrapText(stKeyHelp.Render(help), m.width, 2) + "\n") } - return b.String() + + out := b.String() + // Bubble Tea paints only the last m.height lines, so the button moves up with + // whatever scrolled off the top. + if lines := strings.Count(out, "\n") + 1; m.height > 0 && lines > m.height { + m.reportHit.y -= lines - m.height + } + return out } // engBaseDelay is the current live pacing floor (0 if the engine isn't running). diff --git a/main.go b/main.go index 049a4dd..5198683 100644 --- a/main.go +++ b/main.go @@ -665,7 +665,7 @@ func writePlainReport(in plainRun, cfg runConfig, startedAt time.Time, snaps []p } if path := r.destPath(in.reportOverride, in.progPath); path != "" { if err := writeRunReport(path, r); err == nil { - fmt.Printf("Report written to %s\n", path) + fmt.Printf("Report written to %s\n", plainPathLink(path)) } } if target := resolveNtfyURL(cfg.ntfy); target != "" { diff --git a/open.go b/open.go new file mode 100644 index 0000000..3f00700 --- /dev/null +++ b/open.go @@ -0,0 +1,20 @@ +package main + +import ( + "os/exec" + "runtime" +) + +// openFile hands path to the desktop's handler for its type. A var so tests can +// take a click without a text editor opening on someone's desktop. +var openFile = func(path string) error { + switch runtime.GOOS { + case "windows": + // Not cmd's start builtin: that needs a console window of its own. + return exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", path).Start() + case "darwin": + return exec.Command("open", path).Start() + default: + return exec.Command("xdg-open", path).Start() + } +} diff --git a/tui.go b/tui.go index 440597c..fca2e37 100644 --- a/tui.go +++ b/tui.go @@ -2,7 +2,9 @@ package main import ( "fmt" + "net/url" "os" + "path/filepath" "strconv" "strings" "time" @@ -11,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" ) func demoMode() bool { return os.Getenv("DEV_DISCORD_DELETE_DEMO") != "" } @@ -81,6 +84,59 @@ func wrapText(s string, width, indent int) string { return strings.Join(lines, "\n") } +// osc8 wraps text in an OSC 8 hyperlink: clickable where the terminal supports +// it, and invisible everywhere else. +func osc8(target, text string) string { + return "\x1b]8;;" + target + "\x1b\\" + text + "\x1b]8;;\x1b\\" +} + +// fileURL is the file:// form of path. Drive letters need the extra leading +// slash, and a UNC path's server is the URL's host rather than part of its path. +func fileURL(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + p := filepath.ToSlash(abs) + if rest, ok := strings.CutPrefix(p, "//"); ok { + host, tail, _ := strings.Cut(rest, "/") + return (&url.URL{Scheme: "file", Host: host, Path: "/" + tail}).String() + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return (&url.URL{Scheme: "file", Path: p}).String() +} + +// linkPath makes already-rendered text open path when clicked. +func linkPath(path, text string) string { return osc8(fileURL(path), text) } + +// plainPathLink is linkPath for --no-tui runs, minus the escapes when stdout is +// piped somewhere that would only have to strip them. +func plainPathLink(path string) string { + if !isatty.IsTerminal(os.Stdout.Fd()) { + return path + } + return linkPath(path, path) +} + +// hitBox is a clickable region of the frame: row, inclusive column span, 0-based. +type hitBox struct{ y, x0, x1 int } + +func (h hitBox) contains(x, y int) bool { return y == h.y && x >= h.x0 && x <= h.x1 } + +const ( + btnOpenReport = "↗ open report" + btnIndent = 2 +) + +// reportButton renders the end-of-run button on row y and the cells it covers. +// The OSC 8 link is the fallback: terminals only follow it on ctrl/cmd+click. +func reportButton(path string, y int) (string, hitBox) { + line := strings.Repeat(" ", btnIndent) + linkPath(path, stKeyHelp.Render(btnOpenReport)) + return line, hitBox{y: y, x0: btnIndent, x1: btnIndent + lipgloss.Width(btnOpenReport) - 1} +} + // twoColMin is the terminal width below which side-by-side panels stack. const twoColMin = 78 diff --git a/tui_test.go b/tui_test.go index 4c710ec..9486129 100644 --- a/tui_test.go +++ b/tui_test.go @@ -1,10 +1,15 @@ package main import ( + "errors" + "path/filepath" + "runtime" "strings" "testing" "github.com/charmbracelet/bubbles/cursor" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) func TestDemoModeStaticCursor(t *testing.T) { @@ -56,6 +61,154 @@ func TestRunningViewRendersWithoutPanic(t *testing.T) { } } +// finishedModel is parked on the final frame of a completed run. The width keeps +// the path on one unwrapped line, the height keeps the frame on screen. +func finishedModel() *appModel { + m := testModel() + m.stats = NewStats(4, 1) + m.stats.finished.Store(true) + m.stats.completed.Store(true) + m.screen = scRunning + m.width, m.height = 100, 200 + m.reportPath = filepath.Join("state", "run-20260731-120000.report.txt") + return m +} + +// stubOpen replaces the opener for one test and reports what a click asked for. +func stubOpen(t *testing.T, err error) *string { + t.Helper() + var got string + prev := openFile + openFile = func(p string) error { got = p; return err } + t.Cleanup(func() { openFile = prev }) + return &got +} + +func TestRunningViewLinksReportPath(t *testing.T) { + m := finishedModel() + + out := m.viewRunning() + if !strings.Contains(out, "\x1b]8;;"+fileURL(m.reportPath)+"\x1b\\") { + t.Fatalf("report path is not a hyperlink:\n%q", out) + } + if !strings.Contains(out, m.reportPath) { + t.Fatalf("the path itself should still be readable:\n%q", out) + } +} + +// A plain click has to work: terminals keep the OSC 8 link for ctrl/cmd+click. +func TestFinishedFrameButtonOpensOnPlainClick(t *testing.T) { + m := finishedModel() + lines := strings.Split(m.viewRunning(), "\n") // rendering records the hit-box + btn := m.reportHit + + // A hit-box that disagrees with the rendered frame sends clicks to nowhere. + if btn.y >= len(lines) || !strings.Contains(lines[btn.y], btnOpenReport) { + t.Fatalf("row %d does not hold the button", btn.y) + } + if got := btn.x1 - btn.x0 + 1; got != lipgloss.Width(btnOpenReport) { + t.Errorf("button spans %d columns, want %d", got, lipgloss.Width(btnOpenReport)) + } + + got := stubOpen(t, nil) + click := func(x, y int) { + *got = "" + m.updateRunning(tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) + } + + click(btn.x0, btn.y) + if *got != m.reportPath { + t.Errorf("click opened %q, want %q", *got, m.reportPath) + } + click(btn.x1, btn.y) // the last column is still inside + if *got != m.reportPath { + t.Errorf("click on the last column opened %q", *got) + } + + click(btn.x1+1, btn.y) + if *got != "" { + t.Errorf("click past the button opened %q", *got) + } + click(btn.x0, btn.y+1) + if *got != "" { + t.Errorf("click on the next row opened %q", *got) + } + + *got = "" + m.updateRunning(tea.MouseMsg{X: btn.x0, Y: btn.y, Action: tea.MouseActionRelease, Button: tea.MouseButtonLeft}) + m.updateRunning(tea.MouseMsg{X: btn.x0, Y: btn.y, Action: tea.MouseActionPress, Button: tea.MouseButtonRight}) + if *got != "" { + t.Errorf("only a left press should open; got %q", *got) + } +} + +// The keyboard fallback, for terminals that report no mouse. +func TestFinishedFrameOpensOnKey(t *testing.T) { + m := finishedModel() + got := stubOpen(t, nil) + + m.updateRunning(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + if *got != m.reportPath { + t.Errorf("o opened %q, want %q", *got, m.reportPath) + } + + // Mid-run there is no report yet, so the key does nothing. + live := testModel() + live.stats, live.screen = NewStats(4, 1), scRunning + *got = "" + live.updateRunning(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + if *got != "" { + t.Errorf("o during a run opened %q", *got) + } +} + +// Bubble Tea paints only the last height lines, so the row has to move with them. +func TestReportButtonRowShiftsWhenFrameOverflows(t *testing.T) { + m := finishedModel() + full := strings.Split(m.viewRunning(), "\n") + base := m.reportHit.y + + m.height = len(full) - 3 + m.viewRunning() + visible := full[len(full)-m.height:] // the slice Bubble Tea actually paints + + got := m.reportHit.y + if got != base-3 { + t.Fatalf("row = %d, want %d once 3 lines scroll off the top", got, base-3) + } + if got < 0 || got >= len(visible) || !strings.Contains(visible[got], btnOpenReport) { + t.Errorf("shifted row %d does not hold the button", got) + } +} + +func TestFinishedFrameShowsOpenFailure(t *testing.T) { + m := finishedModel() + m.viewRunning() + stubOpen(t, errors.New("no xdg-open")) + + m.updateRunning(tea.MouseMsg{ + X: m.reportHit.x0, Y: m.reportHit.y, + Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, + }) + if out := m.viewRunning(); !strings.Contains(out, "no xdg-open") { + t.Errorf("open failure not surfaced:\n%s", out) + } +} + +func TestFileURL(t *testing.T) { + in, want := "/tmp/dd state/run 1.report.txt", "file:///tmp/dd%20state/run%201.report.txt" + if runtime.GOOS == "windows" { + in, want = `C:\dd state\run 1.report.txt`, "file:///C:/dd%20state/run%201.report.txt" + } + if got := fileURL(in); got != want { + t.Fatalf("fileURL(%q) = %q, want %q", in, got, want) + } + // Relative paths still resolve to an absolute URL the terminal can open. + if got := fileURL("report.txt"); !strings.HasPrefix(got, "file:///") || strings.HasSuffix(got, "//report.txt") { + t.Fatalf("relative path not made absolute: %q", got) + } +} + func TestHomeAndConfigureRender(t *testing.T) { m := testModel() if out := m.viewHome(); !strings.Contains(out, "discord-delete") || !strings.Contains(out, "messages") {