From 8ec54d5ad018828a7e6e40980e9bd9dfaeaa5e7b Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:33:00 +0200 Subject: 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. --- cmd/mitmux/main.go | 172 +++++++++++++++++++++++++++++++++++++++++++++++----- cmd/mitmuxd/main.go | 2 +- 2 files changed, 159 insertions(+), 15 deletions(-) (limited to 'cmd') 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) -- cgit v1.2.3