srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-05 11:33:00 +0200
committersrdusr <[email protected]>2026-02-05 11:33:00 +0200
commit8ec54d5ad018828a7e6e40980e9bd9dfaeaa5e7b (patch)
tree29064dba0a9787ba88197a12b4748ca02db62a15 /cmd
parentc39808cca65bb72211359f4d6be3ba869f0121e6 (diff)
downloadmitmux-8ec54d5ad018828a7e6e40980e9bd9dfaeaa5e7b.tar.gz
mitmux-8ec54d5ad018828a7e6e40980e9bd9dfaeaa5e7b.zip
Status bar and help screen
Baseline TUI UX that should exist regardless of feature parity - flagged directly by the Burp/ZAP/Caido comparison research as missing. A persistent one-line status bar (proxy address, live request count, current view) is now appended to every screen. Fetched once at startup via a new "status" IPC request (internal/store gains Store.Count(); internal/ipc gains StatusMsg plus a daemon-side handler reading proxy.Server.Addr through ipc.NewServer's new proxyAddr parameter), then kept approximately live by incrementing locally on each "new" subscribe push rather than re-querying every time. '?' opens a full keybinding reference from every view, gated so it never shadows literal text entry - it's a no-op while typing in the search box, a rule form field, or (checked via viTextarea.Mode()) insert-mode text in Repeater/Intruder, where a URL query string literally starting with '?' is completely ordinary input. Any key dismisses it and returns to whichever view opened it. Every existing height calculation (table, viewport, textarea panes) had to shrink by one line to make room for the status bar without pushing content off-screen - done once via a shared `h := msg.Height-1` in the WindowSizeMsg handler rather than touching each call site individually. Verified live: status bar shows the real proxy address and updates its count after a live-captured request; '?' renders the full reference from the history list; dismissing returns to the correct prior view; and specifically confirmed '?' still types literally (tested typing "?foo=bar" into a Repeater request body in insert mode) rather than being swallowed by the help shortcut.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/main.go172
-rw-r--r--cmd/mitmuxd/main.go2
2 files changed, 159 insertions, 15 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index d9e1685..55ab263 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -73,6 +73,7 @@ const (
viewRepeater
viewRules
viewIntruder
+ viewHelp
)
type detailTab int
@@ -158,6 +159,9 @@ type model struct {
intruderCh <-chan ipc.IntrudeResultMsg
intruderClose func() error
+ daemonStatus *ipc.StatusMsg
+ prevMode viewMode // for the ? help screen's "esc back" target
+
statusMsg string
width int
height int
@@ -271,6 +275,16 @@ type repeatSentMsg struct {
err error
}
+type statusLoadedMsg struct {
+ status *ipc.StatusMsg
+ err error
+}
+
+func (m *model) loadStatus() tea.Msg {
+ s, err := m.client.Status()
+ return statusLoadedMsg{status: s, err: err}
+}
+
func (m *model) loadList() tea.Msg {
var entries []store.Summary
var err error
@@ -474,7 +488,7 @@ func (m *model) focusRuleField() {
}
func (m *model) Init() tea.Cmd {
- return tea.Batch(m.loadList, m.waitForEntry)
+ return tea.Batch(m.loadList, m.waitForEntry, m.loadStatus)
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -482,31 +496,35 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ready = true
+ // Reserve one line for the persistent status bar (see statusBar,
+ // appended in View); every height below is against h, not the
+ // raw terminal height, to leave room for it.
+ h := msg.Height - 1
m.table.SetWidth(msg.Width)
- m.table.SetHeight(msg.Height - 5)
+ m.table.SetHeight(h - 5)
m.searchInput.Width = msg.Width - 2
- m.viewport = viewport.New(msg.Width, msg.Height-5)
+ m.viewport = viewport.New(msg.Width, h-5)
- reqHeight := (msg.Height - 6) / 2
+ reqHeight := (h - 6) / 2
m.reqArea.SetWidth(msg.Width)
m.reqArea.SetHeight(reqHeight)
- m.respView = viewport.New(msg.Width, msg.Height-6-reqHeight)
+ m.respView = viewport.New(msg.Width, h-6-reqHeight)
m.rulesTable.SetWidth(msg.Width)
- m.rulesTable.SetHeight(msg.Height - 5)
+ m.rulesTable.SetHeight(h - 5)
formWidth := msg.Width - 12
m.ruleName.Width = formWidth
m.ruleMatch.Width = formWidth
m.ruleReplace.Width = formWidth
- itmplHeight := (msg.Height - 8) / 3
+ itmplHeight := (h - 8) / 3
ipayloadsHeight := itmplHeight
m.intruderTemplate.SetWidth(msg.Width)
m.intruderTemplate.SetHeight(itmplHeight)
m.intruderPayloads.SetWidth(msg.Width)
m.intruderPayloads.SetHeight(ipayloadsHeight)
m.intruderResults.SetWidth(msg.Width)
- m.intruderResults.SetHeight(msg.Height - 8 - itmplHeight - ipayloadsHeight)
+ m.intruderResults.SetHeight(h - 8 - itmplHeight - ipayloadsHeight)
return m, nil
case listLoadedMsg:
@@ -523,6 +541,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusMsg = "live updates disconnected"
return m, nil
}
+ if m.daemonStatus != nil {
+ m.daemonStatus.HistoryCount++
+ }
if m.query != "" {
// Whether it matches the active filter isn't knowable without
// re-querying; rather than guess, just surface a count and let
@@ -535,6 +556,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.table.SetRows(rowsFor(m.entries))
return m, m.waitForEntry
+ case statusLoadedMsg:
+ if msg.err == nil {
+ m.daemonStatus = msg.status
+ }
+ return m, nil
+
case detailLoadedMsg:
if msg.err != nil {
m.statusMsg = "get error: " + msg.err.Error()
@@ -633,6 +660,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
+ case "?":
+ m.prevMode = viewList
+ m.mode = viewHelp
+ return m, nil
case "enter":
if row := m.table.Cursor(); row >= 0 && row < len(m.entries) {
m.mode = viewDetail
@@ -677,6 +708,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "ctrl+c":
return m, tea.Quit
+ case "?":
+ m.prevMode = viewDetail
+ m.mode = viewHelp
+ return m, nil
case "r":
if m.detail != nil {
m.enterRepeater(m.detail)
@@ -718,6 +753,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "ctrl+c":
return m, tea.Quit
+ case "?":
+ if m.repeaterFocus != focusRequest || m.reqArea.Mode() != viInsert {
+ m.prevMode = viewRepeater
+ m.mode = viewHelp
+ return m, nil
+ }
case "ctrl+r":
if !m.sending {
m.sending = true
@@ -795,6 +836,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "ctrl+c":
return m, tea.Quit
+ case "?":
+ m.prevMode = viewRules
+ m.mode = viewHelp
+ return m, nil
case "a":
m.enterRuleForm(nil)
return m, nil
@@ -835,6 +880,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "ctrl+c":
return m, tea.Quit
+ case "?":
+ if !editing {
+ m.prevMode = viewIntruder
+ m.mode = viewHelp
+ return m, nil
+ }
case "ctrl+r":
if !m.intruderRunning {
m.statusMsg = "starting attack..."
@@ -881,6 +932,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.intruderResults, cmd = m.intruderResults.Update(msg)
}
return m, cmd
+
+ case viewHelp:
+ switch msg.String() {
+ case "ctrl+c":
+ return m, tea.Quit
+ default:
+ m.mode = m.prevMode
+ return m, nil
+ }
}
}
return m, nil
@@ -890,21 +950,103 @@ func (m *model) View() string {
if !m.ready {
return "loading..."
}
+ if m.mode == viewHelp {
+ return m.helpView()
+ }
+
+ var body string
switch m.mode {
case viewDetail:
- return m.detailView()
+ body = m.detailView()
case viewRepeater:
- return m.repeaterView()
+ body = m.repeaterView()
case viewRules:
if m.ruleForm {
- return m.ruleFormView()
+ body = m.ruleFormView()
+ } else {
+ body = m.rulesView()
}
- return m.rulesView()
case viewIntruder:
- return m.intruderView()
+ body = m.intruderView()
default:
- return m.listView()
+ body = m.listView()
+ }
+ return body + "\n" + m.statusBar()
+}
+
+// statusBar is a persistent one-line footer: daemon connection info and
+// the current view, so it's always visible regardless of which screen
+// is showing (see the WindowSizeMsg handler, which reserves a line for
+// it in every height calculation).
+func (m *model) statusBar() string {
+ proxy := "connecting..."
+ count := ""
+ if m.daemonStatus != nil {
+ proxy = m.daemonStatus.ProxyAddr
+ count = fmt.Sprintf(" · %d requests", m.daemonStatus.HistoryCount)
}
+ view := map[viewMode]string{
+ viewList: "history", viewDetail: "detail", viewRepeater: "repeater",
+ viewRules: "rules", viewIntruder: "intruder",
+ }[m.mode]
+ return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view))
+}
+
+func (m *model) helpView() string {
+ var b strings.Builder
+ b.WriteString(titleStyle.Render(" mitmux - keybindings "))
+ b.WriteString("\n\n")
+
+ section := func(title string, lines ...string) {
+ b.WriteString(tabActive.Render(" " + title + " "))
+ b.WriteString("\n")
+ for _, l := range lines {
+ b.WriteString(" " + l + "\n")
+ }
+ b.WriteString("\n")
+ }
+
+ section("Global",
+ "? this screen (any key returns)",
+ "ctrl+c quit",
+ )
+ section("History list",
+ "↑/↓ or j/k navigate (also g/G top/bottom, ctrl+u/d half-page)",
+ "enter view request/response detail",
+ "r open in Repeater",
+ "i open in Intruder",
+ "/ search (plain text, host:value, AND/OR/NOT)",
+ "esc clear active search filter",
+ "m match-and-replace rules",
+ "q quit",
+ )
+ section("Detail view",
+ "tab switch request/response",
+ "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)",
+ "r / i open in Repeater / Intruder",
+ "esc / q back to history",
+ )
+ section("Repeater / Intruder editors - vi-modal",
+ "Starts in NORMAL mode (not insert) - press i to type, esc to stop.",
+ "h j k l left/down/up/right 0 / $ line start/end",
+ "w / b word forward/back x delete character",
+ "i a I A insert (before/after/line-start/line-end)",
+ "o / O open line below/above and insert",
+ "dd / yy delete/yank line p / P paste below/above",
+ "dw d$ d0 delete word/to-end/to-start-of-line",
+ "gg / G top/bottom of buffer esc back to normal mode",
+ "ctrl+r send / start attack tab switch pane",
+ "ctrl+g (Intruder template only) insert a § marker at cursor",
+ )
+ section("Rules",
+ "a add rule enter / e edit selected",
+ "d delete selected space toggle enabled",
+ "tab/shift+tab move between form fields ctrl+s save form",
+ "◀▶ change scope/regex toggle esc cancel/back",
+ )
+
+ b.WriteString(helpStyle.Render("press any key to go back"))
+ return b.String()
}
var (
@@ -913,6 +1055,8 @@ var (
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
tabActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Padding(0, 1)
tabInactive = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1)
+
+ statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("240"))
)
func (m *model) listView() string {
diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go
index 812e146..9db31e5 100644
--- a/cmd/mitmuxd/main.go
+++ b/cmd/mitmuxd/main.go
@@ -74,7 +74,7 @@ func main() {
hub := ipc.NewHub()
srv.OnEntry = ipc.LogAndBroadcast(hub)
- ipcSrv := ipc.NewServer(db, hub, srv)
+ ipcSrv := ipc.NewServer(db, hub, srv, *listen)
go func() {
if err := ipcSrv.Serve(sockLn); err != nil {
log.Printf("control socket: %v", err)