srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md35
-rw-r--r--README.md45
-rw-r--r--cmd/mitmux/csv.go73
-rw-r--r--cmd/mitmux/csv_test.go84
-rw-r--r--cmd/mitmux/export.go100
-rw-r--r--cmd/mitmux/export_test.go70
-rw-r--r--cmd/mitmux/main.go56
7 files changed, 432 insertions, 31 deletions
diff --git a/PLAN.md b/PLAN.md
index a3ca810..a070b20 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -306,11 +306,38 @@ recorded despite being out of scope, confirmed toggling the rule off
resumed recording everything, and confirmed both the substring and
regex pattern forms save and match correctly.
+Shipped since: CSV export and copy-as-curl, both extending the existing
+export system by dispatching on the file extension the user types
+rather than adding a separate format-selection control - ".har" (the
+existing default) or ".csv" for bulk export from the history list,
+".txt" (the existing default) or ".sh"/".curl" for single-entry export
+from Detail view. CSV is deliberately a lighter, faster path than HAR:
+a summary table (id/method/host/path/status/sizes/timing/flag/source)
+built straight from the already-loaded Summary rows, no per-entry fetch
+from the daemon needed, matching Burp's own "export as CSV" being a
+listing rather than a full-fidelity capture - HAR already covers that.
+Copy-as-curl parses the raw request (same net/http parsing used
+everywhere else in this codebase) and re-serializes it as a runnable
+curl command line rather than another copy of the raw bytes, which the
+plain-text format already gives you; verified by actually executing a
+generated command against the real target and confirming the response
+matched the original.
+
+CSV export required one more thing HAR didn't: guarding against CSV/
+formula injection. Method, host, path, and error all ultimately trace
+back to a request line or Host header - content this tool exists
+specifically to inspect from potentially hostile traffic - and a field
+starting with =, +, -, @, tab, or CR is a formula to Excel/LibreOffice/
+Sheets when the exported file is later opened. csvSafe prefixes any
+such field with a single quote, the standard mitigation (OWASP's own
+guidance), so every affected spreadsheet application treats it as
+literal text instead. This is the same class of bug as the terminal-
+injection fix from the robustness audit, just for a different output
+format - captured content controlling the tool that later processes it,
+rather than the terminal that renders it.
+
Still open from the expanded "worth considering" list: import (no path
-back in yet - HAR export was prioritized as the more common daily need,
-getting captured evidence OUT for a report or another tool, over
-bringing traffic IN) and copy-as-curl. Both requested explicitly; not
-started yet.
+back in yet). Requested explicitly; not started yet.
Skipped deliberately (from the research, matches this tool's stated
scope): active/passive vulnerability scanning, plugin marketplace,
diff --git a/README.md b/README.md
index de8274e..7058180 100644
--- a/README.md
+++ b/README.md
@@ -158,7 +158,7 @@ below is enough to get going.
| `c` | mark for comparison - press `c` on another entry to diff |
| `x` | delete the selected entry (asks `y`/`n` to confirm) |
| `X` | clear ALL history, not just the current search filter (asks `y`/`n` to confirm) |
-| `E` | export the current view (respects an active search filter) as a HAR file |
+| `E` | export the current view (respects an active search filter) - `.har` or `.csv` |
| `d` | Decoder |
| `/` | search |
| `m` | match-and-replace rules |
@@ -196,20 +196,37 @@ only - not chained/pipelined the way Burp's Decoder supports.
### Export
Two independent export paths, both a modal path-prompt (`enter` writes
-and confirms, `esc` cancels):
-
-- `e` from Detail view exports the single selected entry - request and
- response raw bytes, plain text, exactly what Detail view already
- shows. Each side is annotated when it isn't wire-exact (truncated or
- reconstructed), matching Detail view's own labels.
+and confirms, `esc` cancels). Format is picked by the extension you
+type, the same convention any "save as" dialog uses - no separate
+format-selection control:
+
+- `e` from Detail view exports the single selected entry.
+ - `.txt` (default) - request and response raw bytes, plain text,
+ exactly what Detail view already shows. Each side is annotated when
+ it isn't wire-exact (truncated or reconstructed), matching Detail
+ view's own labels.
+ - `.sh` / `.curl` - the request as a runnable `curl` command line
+ (Burp/DevTools' own "copy as curl"), for handing to someone else or
+ re-running standalone without mitmux. Every value is shell-quoted
+ (a captured or edited request can contain arbitrary bytes).
- `E` from the history list exports the current view - the visible,
- filtered set if a search is active, everything otherwise - as one
- [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) file, for
- importing into Chrome/Firefox DevTools, Burp, Postman, or anything
- else that reads HAR 1.2. A binary body (an image, say) is base64-
- encoded in the HAR rather than corrupted as text. An entry that fails
- to fetch or parse is skipped rather than aborting the whole export;
- the status line reports how many, if any.
+ filtered set if a search is active, everything otherwise.
+ - `.har` (default) - one
+ [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) 1.2 file, for
+ importing into Chrome/Firefox DevTools, Burp, Postman, or anything
+ else that reads HAR. A binary body (an image, say) is base64-
+ encoded rather than corrupted as text. An entry that fails to fetch
+ or parse is skipped rather than aborting the whole export; the
+ status line reports how many, if any.
+ - `.csv` - a summary table (id, method, host, path, status, sizes,
+ timing, flag, source) for a report or spreadsheet - lighter and
+ faster than HAR since it needs no per-entry fetch from the daemon.
+ Any field that could be interpreted as a spreadsheet formula (starts
+ with `=`, `+`, `-`, `@`, tab, or CR - method/host/path/error all
+ ultimately trace back to a request line or Host header, exactly the
+ kind of content this tool exists to inspect from hostile traffic)
+ is neutralized with a leading quote before writing, the standard
+ CSV-injection mitigation.
There's no import yet (see `PLAN.md`).
diff --git a/cmd/mitmux/csv.go b/cmd/mitmux/csv.go
new file mode 100644
index 0000000..f64a071
--- /dev/null
+++ b/cmd/mitmux/csv.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/csv"
+ "strconv"
+ "strings"
+ "time"
+
+ "mitmux/internal/store"
+)
+
+// csvFromSummaries renders entries as CSV - a summary table (ID,
+// method, host, path, status, sizes, timing, flag, source), not full
+// request/response bytes. This is deliberately the lighter, faster
+// bulk-export path: unlike HAR, it needs no per-entry fetch from the
+// daemon (Summary already has everything a spreadsheet/report table
+// needs), matching Burp's own "export as CSV" being a summary listing
+// rather than a full-fidelity capture format - that's what HAR is for.
+func csvFromSummaries(entries []store.Summary) (string, error) {
+ var b strings.Builder
+ w := csv.NewWriter(&b)
+
+ header := []string{"id", "method", "scheme", "host", "path", "status", "request_bytes", "response_bytes", "duration_ms", "flagged", "source", "started_at", "error"}
+ if err := w.Write(header); err != nil {
+ return "", err
+ }
+ for _, e := range entries {
+ row := []string{
+ strconv.FormatInt(e.ID, 10),
+ csvSafe(e.Method),
+ csvSafe(e.Scheme),
+ csvSafe(e.Host),
+ csvSafe(e.Path),
+ strconv.Itoa(e.StatusCode),
+ strconv.Itoa(e.ReqSize),
+ strconv.Itoa(e.RespSize),
+ strconv.FormatInt(e.Duration.Milliseconds(), 10),
+ strconv.FormatBool(e.Flagged),
+ e.Source,
+ e.StartedAt.Format(time.RFC3339),
+ csvSafe(e.Error),
+ }
+ if err := w.Write(row); err != nil {
+ return "", err
+ }
+ }
+ w.Flush()
+ if err := w.Error(); err != nil {
+ return "", err
+ }
+ return b.String(), nil
+}
+
+// csvSafe guards against CSV/formula injection: Method, Host, Path, and
+// Error all ultimately trace back to a request line or Host header -
+// content this tool exists specifically to inspect from potentially
+// hostile traffic. A value starting with =, +, -, @, tab, or CR is a
+// formula to Excel/LibreOffice/Sheets when the exported file is opened,
+// which (depending on the app and its settings) can call out to other
+// cells, external data, or worse. Prefixing such a value with a single
+// quote makes every affected spreadsheet application treat it as
+// literal text instead - the standard mitigation (OWASP's own CSV
+// injection guidance), not something specific to this tool.
+func csvSafe(s string) string {
+ if s == "" {
+ return s
+ }
+ switch s[0] {
+ case '=', '+', '-', '@', '\t', '\r':
+ return "'" + s
+ }
+ return s
+}
diff --git a/cmd/mitmux/csv_test.go b/cmd/mitmux/csv_test.go
new file mode 100644
index 0000000..b2f6074
--- /dev/null
+++ b/cmd/mitmux/csv_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "encoding/csv"
+ "strings"
+ "testing"
+ "time"
+
+ "mitmux/internal/store"
+)
+
+func TestCsvFromSummariesBasic(t *testing.T) {
+ entries := []store.Summary{
+ {ID: 1, Method: "GET", Scheme: "https", Host: "example.com", Path: "/a", StatusCode: 200, ReqSize: 100, RespSize: 200, Duration: 150 * time.Millisecond, Source: "proxy"},
+ }
+ out, err := csvFromSummaries(entries)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ r := csv.NewReader(strings.NewReader(out))
+ rows, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("output is not valid CSV: %v", err)
+ }
+ if len(rows) != 2 { // header + 1 row
+ t.Fatalf("expected 2 rows (header+1), got %d: %v", len(rows), rows)
+ }
+ if rows[1][0] != "1" || rows[1][3] != "example.com" || rows[1][5] != "200" {
+ t.Errorf("unexpected row content: %v", rows[1])
+ }
+}
+
+func TestCsvFromSummariesEmpty(t *testing.T) {
+ out, err := csvFromSummaries(nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ r := csv.NewReader(strings.NewReader(out))
+ rows, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("output is not valid CSV: %v", err)
+ }
+ if len(rows) != 1 { // header only
+ t.Fatalf("expected header-only output, got %d rows", len(rows))
+ }
+}
+
+func TestCsvSafeNeutralizesFormulaPrefixes(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"=cmd|'/c calc'!A1", "'=cmd|'/c calc'!A1"},
+ {"+1+1", "'+1+1"},
+ {"-1", "'-1"},
+ {"@SUM(A1)", "'@SUM(A1)"},
+ {"example.com", "example.com"},
+ {"", ""},
+ }
+ for _, tt := range tests {
+ if got := csvSafe(tt.in); got != tt.want {
+ t.Errorf("csvSafe(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
+
+func TestCsvFromSummariesNeutralizesFormulaInjection(t *testing.T) {
+ entries := []store.Summary{
+ {ID: 1, Method: "GET", Scheme: "http", Host: "=cmd|'/c calc'!A1", Path: "/", StatusCode: 200},
+ }
+ out, err := csvFromSummaries(entries)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ r := csv.NewReader(strings.NewReader(out))
+ rows, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("output is not valid CSV: %v", err)
+ }
+ host := rows[1][3]
+ if !strings.HasPrefix(host, "'") {
+ t.Errorf("expected malicious host to be neutralized with a leading quote, got %q", host)
+ }
+}
diff --git a/cmd/mitmux/export.go b/cmd/mitmux/export.go
index 5d11ca8..8b79226 100644
--- a/cmd/mitmux/export.go
+++ b/cmd/mitmux/export.go
@@ -1,8 +1,14 @@
package main
import (
+ "bufio"
+ "bytes"
"fmt"
+ "io"
+ "net/http"
"os"
+ "path/filepath"
+ "sort"
"strings"
"time"
@@ -38,6 +44,100 @@ func exportEntryText(d *ipc.EntryDetail) string {
return b.String()
}
+// singleEntryFormat picks the single-entry export format from path's
+// extension, the same "save as" convention bulkExportFormat uses for
+// the history list - no separate format-selection UI, just write the
+// extension you want.
+type singleEntryFormat int
+
+const (
+ formatPlainText singleEntryFormat = iota
+ formatCurl
+)
+
+func singleEntryFormatFor(path string) singleEntryFormat {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".sh", ".curl":
+ return formatCurl
+ default:
+ return formatPlainText
+ }
+}
+
+// bulkExportFormat picks the history-list export format from path's
+// extension - same convention as singleEntryFormat, a separate type
+// because the two operate on entirely different data (Summary rows vs.
+// one EntryDetail) and don't share format options.
+type bulkExportFormat int
+
+const (
+ formatHAR bulkExportFormat = iota
+ formatCSV
+)
+
+func bulkExportFormatFor(path string) bulkExportFormat {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".csv":
+ return formatCSV
+ default:
+ return formatHAR
+ }
+}
+
+// curlCommand renders one entry's request as a curl command line -
+// Burp/browser DevTools' own "copy as curl", so a request captured or
+// edited in mitmux can be handed to someone else, or re-run standalone,
+// without needing mitmux itself. Parses the raw request the same way
+// prettyResponse and the HAR conversion do (net/http, not reimplemented)
+// and re-serializes it as curl flags rather than emitting the raw bytes
+// directly - the point of this format specifically is a runnable shell
+// command, not another copy of the same raw text plain-text export
+// already gives you.
+func curlCommand(d *ipc.EntryDetail) (string, error) {
+ req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(d.RequestRaw)))
+ if err != nil {
+ return "", fmt.Errorf("parse request: %w", err)
+ }
+ body, _ := io.ReadAll(req.Body)
+ req.Body.Close()
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "curl -X %s", shellQuote(req.Method))
+
+ names := make([]string, 0, len(req.Header))
+ for k := range req.Header {
+ if strings.EqualFold(k, "Host") || strings.EqualFold(k, "Content-Length") {
+ continue // -H Host is redundant with the URL; curl sets Content-Length itself from --data-raw
+ }
+ names = append(names, k)
+ }
+ sort.Strings(names)
+ for _, k := range names {
+ for _, v := range req.Header[k] {
+ fmt.Fprintf(&b, " \\\n -H %s", shellQuote(k+": "+v))
+ }
+ }
+ if len(body) > 0 {
+ fmt.Fprintf(&b, " \\\n --data-raw %s", shellQuote(string(body)))
+ }
+ fmt.Fprintf(&b, " \\\n %s\n", shellQuote(fmt.Sprintf("%s://%s%s", d.Scheme, d.Host, req.URL.RequestURI())))
+ return b.String(), nil
+}
+
+// shellQuote wraps s in single quotes for safe use as one POSIX shell
+// argument, escaping any embedded single quote by closing the quoted
+// string, inserting an escaped literal quote, then reopening it. Every
+// value here
+// (header values, body, URL) can be arbitrary attacker- or user-
+// supplied bytes - a captured/edited request is exactly the kind of
+// content that might contain shell metacharacters, so building the curl
+// command with naive string concatenation would risk producing a
+// command that does something other than what it appears to when
+// pasted into a shell.
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
func exportSuffix(exact, truncated bool) string {
switch {
case exact:
diff --git a/cmd/mitmux/export_test.go b/cmd/mitmux/export_test.go
index 03c38e0..84c93bb 100644
--- a/cmd/mitmux/export_test.go
+++ b/cmd/mitmux/export_test.go
@@ -77,3 +77,73 @@ func TestWriteExportFileEmptyPath(t *testing.T) {
t.Error("expected an error for an empty path, got nil")
}
}
+
+func TestSingleEntryFormatFor(t *testing.T) {
+ tests := []struct {
+ path string
+ want singleEntryFormat
+ }{
+ {"entry.txt", formatPlainText},
+ {"entry.sh", formatCurl},
+ {"entry.curl", formatCurl},
+ {"entry.CURL", formatCurl},
+ {"entry", formatPlainText},
+ {"", formatPlainText},
+ }
+ for _, tt := range tests {
+ if got := singleEntryFormatFor(tt.path); got != tt.want {
+ t.Errorf("singleEntryFormatFor(%q) = %v, want %v", tt.path, got, tt.want)
+ }
+ }
+}
+
+func TestCurlCommand(t *testing.T) {
+ d := &ipc.EntryDetail{
+ Summary: store.Summary{Method: "POST", Scheme: "https", Host: "example.com"},
+ RequestRaw: []byte("POST /a?x=1 HTTP/1.1\r\nHost: example.com\r\nContent-Type: application/json\r\nContent-Length: 13\r\n\r\n{\"key\":\"val\"}"),
+ }
+ got, err := curlCommand(d)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, want := range []string{
+ "curl -X 'POST'",
+ "-H 'Content-Type: application/json'",
+ "--data-raw '{\"key\":\"val\"}'",
+ "'https://example.com/a?x=1'",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("curlCommand output missing %q, got:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "-H 'Host:") {
+ t.Errorf("curlCommand should not include a redundant Host header, got:\n%s", got)
+ }
+ if strings.Contains(got, "Content-Length") {
+ t.Errorf("curlCommand should not include Content-Length (curl sets it itself), got:\n%s", got)
+ }
+}
+
+func TestCurlCommandMalformedRequest(t *testing.T) {
+ d := &ipc.EntryDetail{RequestRaw: []byte("not a request")}
+ if _, err := curlCommand(d); err == nil {
+ t.Error("expected an error for a malformed request, got nil")
+ }
+}
+
+func TestShellQuoteEscapesEmbeddedQuotes(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"simple", "'simple'"},
+ {"it's", `'it'\''s'`},
+ {"", "''"},
+ {"a; rm -rf /", "'a; rm -rf /'"},
+ }
+ for _, tt := range tests {
+ if got := shellQuote(tt.in); got != tt.want {
+ t.Errorf("shellQuote(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index 21209bf..35cadaa 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -420,7 +420,7 @@ func (m *model) clearHistory() tea.Cmd {
}
}
-type harExportedMsg struct {
+type bulkExportedMsg struct {
path string
count int
skipped int
@@ -452,12 +452,30 @@ func (m *model) exportHAR(entries []store.Summary, path string) tea.Cmd {
doc, parseFailed := harDocFrom(details)
data, err := harMarshal(doc)
if err != nil {
- return harExportedMsg{err: err}
+ return bulkExportedMsg{err: err}
}
if err := writeExportFile(path, string(data)); err != nil {
- return harExportedMsg{err: err}
+ return bulkExportedMsg{err: err}
}
- return harExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed}
+ return bulkExportedMsg{path: path, count: len(doc.Log.Entries), skipped: fetchFailed + parseFailed}
+ }
+}
+
+// exportCSV writes entries as a CSV summary table. Unlike exportHAR,
+// this needs no per-entry fetch - Summary already carries everything a
+// CSV row needs - so it's synchronous work wrapped in a tea.Cmd only
+// for consistency with every other write-a-file action, not because it
+// actually blocks on anything slow.
+func (m *model) exportCSV(entries []store.Summary, path string) tea.Cmd {
+ return func() tea.Msg {
+ data, err := csvFromSummaries(entries)
+ if err != nil {
+ return bulkExportedMsg{err: err}
+ }
+ if err := writeExportFile(path, data); err != nil {
+ return bulkExportedMsg{err: err}
+ }
+ return bulkExportedMsg{path: path, count: len(entries)}
}
}
@@ -940,7 +958,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "history cleared"
return m, tea.Batch(m.loadList, m.loadStatus)
- case harExportedMsg:
+ case bulkExportedMsg:
if msg.err != nil {
m.statusMsg = "export error: " + msg.err.Error()
return m, nil
@@ -1091,6 +1109,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.statusMsg = "exporting..."
+ if bulkExportFormatFor(path) == formatCSV {
+ return m, m.exportCSV(entries, path)
+ }
return m, m.exportHAR(entries, path)
case "esc":
m.exportEditing = false
@@ -1228,7 +1249,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.detail == nil {
return m, nil
}
- if err := writeExportFile(path, exportEntryText(m.detail)); err != nil {
+ content := exportEntryText(m.detail)
+ if singleEntryFormatFor(path) == formatCurl {
+ c, err := curlCommand(m.detail)
+ if err != nil {
+ m.statusMsg = "export error: " + err.Error()
+ return m, nil
+ }
+ content = c
+ }
+ if err := writeExportFile(path, content); err != nil {
m.statusMsg = "export error: " + err.Error()
} else {
m.statusMsg = "exported to " + path
@@ -1800,7 +1830,7 @@ func (m *model) helpView() string {
"c mark for comparison, then press c on another entry to diff",
"x delete the selected entry (asks to confirm)",
"X clear ALL history, not just the current filter (asks to confirm)",
- "E export the current view (respects an active search filter) as a HAR file",
+ "E export the current view (respects an active filter) - .har or .csv",
"d decoder (URL/Base64/Hex/HTML encode/decode)",
"/ search: plain text, host:value, AND/OR/NOT,",
" status:404 / status:4xx / status:>=400,",
@@ -1816,7 +1846,7 @@ func (m *model) helpView() string {
"p toggle pretty-printed JSON (response only, display-only)",
"c mark/compare (same as history list)",
"r / i open in Repeater / Intruder",
- "e export this entry to a file (request + response, plain text)",
+ "e export this entry - .txt (raw request+response) or .sh/.curl (curl command)",
"esc / q back to history",
)
section("Comparer",
@@ -1895,10 +1925,10 @@ func (m *model) listView() string {
b.WriteString(m.table.View())
b.WriteString("\n")
if m.exportEditing {
- b.WriteString("export HAR (current view) to: ")
+ b.WriteString("export current view to: ")
b.WriteString(m.exportInput.View())
b.WriteString("\n")
- b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit"))
+ b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .har (full, default) or .csv (summary table)"))
return b.String()
}
if m.confirmPrompt != "" {
@@ -1908,9 +1938,9 @@ func (m *model) listView() string {
b.WriteString(statusStyle.Render(sanitizeLine(m.statusMsg)))
b.WriteString("\n")
}
- help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR · / search · m rules · s scope · ? help · q quit"
+ help := "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (HAR/CSV) · / search · m rules · s scope · ? help · q quit"
if m.query != "" {
- help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export HAR (this filter) · / search · esc clear filter · s scope · ? help · q quit"
+ help = "enter view · r/i/c/d tools · f flag · x delete · X clear all · E export (this filter) · / search · esc clear filter · s scope · ? help · q quit"
}
b.WriteString(helpStyle.Render(help))
return b.String()
@@ -1949,7 +1979,7 @@ func (m *model) detailView() string {
b.WriteString("export to: ")
b.WriteString(m.exportInput.View())
b.WriteString("\n")
- b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit"))
+ b.WriteString(helpStyle.Render("enter write · esc cancel · ctrl+c quit - .txt (raw, default) or .sh/.curl (curl command)"))
return b.String()
}
if m.statusMsg != "" {