srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-04 21:27:00 +0200
committersrdusr <[email protected]>2026-02-04 21:27:00 +0200
commitc39808cca65bb72211359f4d6be3ba869f0121e6 (patch)
tree1fd3e70fbe1a3e7e65bf0ff5359c96d8d6228e6a /cmd
parentce6ce32469da720105258cb66e0274b2b009cd1d (diff)
downloadmitmux-c39808cca65bb72211359f4d6be3ba869f0121e6.tar.gz
mitmux-c39808cca65bb72211359f4d6be3ba869f0121e6.zip
Vi-modal editing for the raw request textareas
Started a broader pass to close the gap with Burp/ZAP/Caido (feature comparison researched, tracked in PLAN.md) and to fully support vi bindings as asked. This commit is the vi-bindings piece. Checked the actual bubbles library before writing anything: table and viewport already ship full vi navigation by default (h/j/k/l, ctrl+u/ ctrl+d, g/G) - nothing to build there. textarea and textinput are Emacs-style with no vi support at all, and textarea's own ctrl+p ("previous line") was silently shadowed by the ctrl+p shortcut I'd bound for Intruder's marker insertion - a real bug from the last session, fixed here by moving it to ctrl+g. vimode.go adds viTextarea, wrapping textarea.Model with a normal/insert modal layer. Vi commands are translated into the underlying textarea's own existing keybindings (a synthesized "alt+right" for `w`, "ctrl+k" for `d$`, etc.) and reuse its tested cursor/line logic rather than reimplementing text manipulation - this is a front-end over textarea, not a parser. Covers what's actually used constantly: h/j/k/l, 0/$, w/b, x, i/a/I/A/o/O, dd/yy/p/P, dw/d$/d0, gg/G, esc-to-normal (esc never leaves the view while still in insert mode, matching real vi). Not attempted: registers beyond one yank slot, visual mode, ex commands, macros, counts. No undo, since textarea itself has none. Starts in normal mode on focus, per vi convention - not insert. Replaces the three textarea.Model fields (Repeater's request editor, Intruder's template and payload editors) with viTextarea, and adds a vim-style mode indicator ("-- NORMAL --" / "-- INSERT --") to both views, without which modal editing is unusable - there'd be no way to tell which mode you're in. Verified live via a real session: normal-mode letters don't leak into the buffer as text, gg/x/dd/yy/p all produce the correct edits in sequence on a real captured request, i enters insert mode and typing works, esc from insert correctly drops to normal without leaving the view, a second esc then backs out, and ctrl+g inserts a marker without colliding with anything in the vi command set.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mitmux/main.go77
-rw-r--r--cmd/mitmux/vimode.go268
2 files changed, 324 insertions, 21 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index c53f975..d9e1685 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -12,7 +12,6 @@ import (
"time"
"github.com/charmbracelet/bubbles/table"
- "github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -126,7 +125,7 @@ type model struct {
detail *ipc.EntryDetail
activeTab detailTab
- reqArea textarea.Model
+ reqArea viTextarea
respView viewport.Model
repeaterFocus repeaterFocus
repeaterScheme string
@@ -149,8 +148,8 @@ type model struct {
intruderScheme string
intruderHost string
- intruderTemplate textarea.Model
- intruderPayloads textarea.Model
+ intruderTemplate viTextarea
+ intruderPayloads viTextarea
intruderResults table.Model
intruderRows []ipc.IntrudeResultMsg
intruderFocus intruderFocus
@@ -184,9 +183,9 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
st.Selected = st.Selected.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Bold(true)
t.SetStyles(st)
- ta := textarea.New()
- ta.Placeholder = "raw request bytes"
- ta.ShowLineNumbers = false
+ ta := newViTextarea()
+ ta.ta.Placeholder = "raw request bytes"
+ ta.ta.ShowLineNumbers = false
si := textinput.New()
si.Prompt = "/"
@@ -210,13 +209,13 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
replaceIn := textinput.New()
replaceIn.Placeholder = "replacement"
- itmpl := textarea.New()
- itmpl.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§"
- itmpl.ShowLineNumbers = false
+ itmpl := newViTextarea()
+ itmpl.ta.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§"
+ itmpl.ta.ShowLineNumbers = false
- ipayloads := textarea.New()
- ipayloads.Placeholder = "payloads, one per line"
- ipayloads.ShowLineNumbers = false
+ ipayloads := newViTextarea()
+ ipayloads.ta.Placeholder = "payloads, one per line"
+ ipayloads.ta.ShowLineNumbers = false
iresultsCols := []table.Column{
{Title: "Pos", Width: 4},
@@ -325,7 +324,7 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) {
// enterIntruder seeds the Intruder view from an already-loaded entry.
// The template starts with no § markers - the user adds them by hand
-// (or ctrl+p at the cursor) around whatever they want to fuzz.
+// (or ctrl+g at the cursor) around whatever they want to fuzz.
func (m *model) enterIntruder(d *ipc.EntryDetail) {
m.intruderScheme = d.Scheme
m.intruderHost = d.Host
@@ -338,7 +337,7 @@ func (m *model) enterIntruder(d *ipc.EntryDetail) {
m.intruderRunning = false
m.intruderCount = 0
m.mode = viewIntruder
- m.statusMsg = "wrap positions to fuzz in § (ctrl+p), fill payloads, ctrl+r to start"
+ m.statusMsg = "wrap positions to fuzz in § (ctrl+g), fill payloads, ctrl+r to start"
}
type intrudeStartedMsg struct {
@@ -707,6 +706,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case viewRepeater:
switch msg.String() {
case "esc":
+ // In insert mode, esc belongs to the textarea - it drops
+ // to normal mode without leaving the view, matching vi.
+ // Only back out when already in normal mode (or focus is
+ // elsewhere entirely).
+ if m.repeaterFocus == focusRequest && m.reqArea.Mode() == viInsert {
+ break
+ }
m.mode = viewList
m.reqArea.Blur()
return m, nil
@@ -731,7 +737,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
var cmd tea.Cmd
if m.repeaterFocus == focusRequest {
- m.reqArea, cmd = m.reqArea.Update(msg)
+ cmd = m.reqArea.Update(msg)
} else {
m.respView, cmd = m.respView.Update(msg)
}
@@ -810,14 +816,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case viewIntruder:
+ editing := (m.intruderFocus == focusTemplate && m.intruderTemplate.Mode() == viInsert) ||
+ (m.intruderFocus == focusPayloads && m.intruderPayloads.Mode() == viInsert)
switch msg.String() {
case "esc":
+ // Same rule as Repeater: esc in insert mode belongs to
+ // the textarea (drop to normal mode), not the view.
+ if editing {
+ break
+ }
if m.intruderRunning && m.intruderClose != nil {
m.intruderClose()
m.intruderRunning = false
}
m.mode = viewList
m.intruderTemplate.Blur()
+ m.intruderPayloads.Blur()
return m, nil
case "ctrl+c":
return m, tea.Quit
@@ -827,7 +841,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.startIntrude()
}
return m, nil
- case "ctrl+p":
+ case "ctrl+g":
if m.intruderFocus == focusTemplate {
m.intruderTemplate.InsertRune('§')
}
@@ -860,9 +874,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch m.intruderFocus {
case focusTemplate:
- m.intruderTemplate, cmd = m.intruderTemplate.Update(msg)
+ cmd = m.intruderTemplate.Update(msg)
case focusPayloads:
- m.intruderPayloads, cmd = m.intruderPayloads.Update(msg)
+ cmd = m.intruderPayloads.Update(msg)
case focusResults:
m.intruderResults, cmd = m.intruderResults.Update(msg)
}
@@ -976,14 +990,28 @@ func (m *model) repeaterView() string {
b.WriteString("\n")
b.WriteString(m.respView.View())
b.WriteString("\n")
+ if m.repeaterFocus == focusRequest {
+ b.WriteString(viModeLabel(&m.reqArea))
+ b.WriteString("\n")
+ }
if m.statusMsg != "" {
b.WriteString(statusStyle.Render(m.statusMsg))
b.WriteString("\n")
}
- b.WriteString(helpStyle.Render("ctrl+r send · tab switch pane · esc back · ctrl+c quit"))
+ b.WriteString(helpStyle.Render("i to edit (vi keys) · ctrl+r send · tab switch pane · esc back · ? help · ctrl+c quit"))
return b.String()
}
+// viModeLabel renders a vim-style mode indicator (e.g. "-- INSERT --")
+// for a focused viTextarea, so the user has some feedback about which
+// mode they're in - without it, vi-modal editing is unusable.
+func viModeLabel(v *viTextarea) string {
+ if v.Mode() == viInsert {
+ return statusStyle.Render("-- INSERT --")
+ }
+ return helpStyle.Render("-- NORMAL --")
+}
+
func (m *model) rulesView() string {
var b strings.Builder
b.WriteString(titleStyle.Render(fmt.Sprintf(" match/replace rules (%d) - headers only for now ", len(m.ruleRows))))
@@ -1080,11 +1108,18 @@ func (m *model) intruderView() string {
b.WriteString(m.intruderResults.View())
b.WriteString("\n")
+ if m.intruderFocus == focusTemplate {
+ b.WriteString(viModeLabel(&m.intruderTemplate))
+ b.WriteString("\n")
+ } else if m.intruderFocus == focusPayloads {
+ b.WriteString(viModeLabel(&m.intruderPayloads))
+ b.WriteString("\n")
+ }
if m.statusMsg != "" {
b.WriteString(statusStyle.Render(m.statusMsg))
b.WriteString("\n")
}
- b.WriteString(helpStyle.Render("tab switch pane · ctrl+p insert § · ctrl+r start · enter (results) view · esc back/stop · ctrl+c quit"))
+ b.WriteString(helpStyle.Render("i to edit (vi keys) · tab switch pane · ctrl+g insert § · ctrl+r start · enter (results) view · esc back/stop · ? help · ctrl+c quit"))
return b.String()
}
diff --git a/cmd/mitmux/vimode.go b/cmd/mitmux/vimode.go
new file mode 100644
index 0000000..5da82a3
--- /dev/null
+++ b/cmd/mitmux/vimode.go
@@ -0,0 +1,268 @@
+package main
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textarea"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// viTextarea wraps textarea.Model with a lightweight vi-style modal
+// layer: normal mode for navigation/editing commands, insert mode for
+// free typing. Vi commands are translated into the underlying
+// textarea's own existing keybindings (see feed) and reuse its tested
+// text-manipulation logic rather than reimplementing cursor/line
+// handling from scratch - this only exists to give a familiar command
+// vocabulary a different front end.
+//
+// Covers what people actually reach for constantly: h/j/k/l, 0/$, w/b,
+// x, i/a/I/A/o/O, dd/yy/p/P, dw/d$/d0, gg/G, esc back to normal. Not
+// attempted: registers beyond one yank slot, visual mode, ex commands,
+// macros, counts (3dd, 5j). textarea has no undo, so neither does this.
+//
+// Starts in normal mode on Focus, matching real vi - not insert mode.
+type viTextarea struct {
+ ta textarea.Model
+ mode viMode
+ yank string
+ pending string // first key of a two-key sequence (d, y, g) awaiting its second
+}
+
+type viMode int
+
+const (
+ viNormal viMode = iota
+ viInsert
+)
+
+func newViTextarea() viTextarea {
+ return viTextarea{ta: textarea.New(), mode: viNormal}
+}
+
+func (v *viTextarea) SetValue(s string) { v.ta.SetValue(s) }
+func (v *viTextarea) Value() string { return v.ta.Value() }
+func (v *viTextarea) SetWidth(w int) { v.ta.SetWidth(w) }
+func (v *viTextarea) SetHeight(h int) { v.ta.SetHeight(h) }
+func (v *viTextarea) View() string { return v.ta.View() }
+func (v *viTextarea) Mode() viMode { return v.mode }
+
+func (v *viTextarea) Blur() {
+ v.ta.Blur()
+ v.mode = viNormal
+ v.pending = ""
+}
+
+// Focus focuses the textarea in normal mode, per vi convention.
+func (v *viTextarea) Focus() tea.Cmd {
+ v.mode = viNormal
+ v.pending = ""
+ return v.ta.Focus()
+}
+
+// InsertRune inserts r at the cursor regardless of mode - used by
+// callers for application-specific shortcuts layered on top of vi mode
+// (e.g. Intruder's marker insertion), not part of the vi command set
+// itself.
+func (v *viTextarea) InsertRune(r rune) { v.ta.InsertRune(r) }
+
+// Update processes one message. Callers should check Mode()=="normal"
+// before treating "esc" as "leave this view" - in insert mode, esc
+// belongs to viTextarea (it drops back to normal mode without
+// propagating further), matching vi's own "esc never closes the
+// buffer" behavior.
+func (v *viTextarea) Update(msg tea.Msg) tea.Cmd {
+ keyMsg, ok := msg.(tea.KeyMsg)
+ if !ok {
+ var cmd tea.Cmd
+ v.ta, cmd = v.ta.Update(msg)
+ return cmd
+ }
+
+ if v.mode == viInsert {
+ if keyMsg.String() == "esc" {
+ v.mode = viNormal
+ return nil
+ }
+ var cmd tea.Cmd
+ v.ta, cmd = v.ta.Update(msg)
+ return cmd
+ }
+
+ return v.handleNormal(keyMsg)
+}
+
+func (v *viTextarea) handleNormal(msg tea.KeyMsg) tea.Cmd {
+ key := msg.String()
+
+ if v.pending != "" {
+ seq := v.pending + key
+ v.pending = ""
+ switch seq {
+ case "dd":
+ v.deleteLine()
+ case "dw":
+ v.feed("alt+d")
+ case "d$":
+ v.feed("ctrl+k")
+ case "d0":
+ v.feed("ctrl+u")
+ case "yy":
+ v.yankLine()
+ case "gg":
+ v.feed("ctrl+home")
+ }
+ return nil
+ }
+
+ switch key {
+ case "h":
+ v.feed("left")
+ case "l":
+ v.feed("right")
+ case "j":
+ v.feed("down")
+ case "k":
+ v.feed("up")
+ case "0":
+ v.feed("home")
+ case "$":
+ v.feed("end")
+ case "w":
+ v.feed("alt+right")
+ case "b":
+ v.feed("alt+left")
+ case "x":
+ v.feed("delete")
+ case "d", "y", "g":
+ v.pending = key
+ case "i":
+ v.mode = viInsert
+ case "a":
+ v.feed("right")
+ v.mode = viInsert
+ case "I":
+ v.feed("home")
+ v.mode = viInsert
+ case "A":
+ v.feed("end")
+ v.mode = viInsert
+ case "o":
+ v.feed("end")
+ v.feed("enter")
+ v.mode = viInsert
+ case "O":
+ v.feed("home")
+ v.feed("enter")
+ v.feed("up")
+ v.mode = viInsert
+ case "p":
+ v.pasteBelow()
+ case "P":
+ v.pasteAbove()
+ case "G":
+ v.feed("ctrl+end")
+ default:
+ // Arrow keys and the like still work in normal mode even though
+ // they're not vi commands - no reason to block them.
+ var cmd tea.Cmd
+ v.ta, cmd = v.ta.Update(msg)
+ return cmd
+ }
+ return nil
+}
+
+// feed synthesizes a key event by its bubbletea string form and runs it
+// through the underlying textarea, so vi commands are implemented in
+// terms of textarea's own tested key bindings rather than reaching into
+// its (mostly unexported) internal state.
+func (v *viTextarea) feed(key string) {
+ v.ta, _ = v.ta.Update(keyMsgFor(key))
+}
+
+func keyMsgFor(key string) tea.KeyMsg {
+ switch key {
+ case "left":
+ return tea.KeyMsg{Type: tea.KeyLeft}
+ case "right":
+ return tea.KeyMsg{Type: tea.KeyRight}
+ case "up":
+ return tea.KeyMsg{Type: tea.KeyUp}
+ case "down":
+ return tea.KeyMsg{Type: tea.KeyDown}
+ case "home":
+ return tea.KeyMsg{Type: tea.KeyHome}
+ case "end":
+ return tea.KeyMsg{Type: tea.KeyEnd}
+ case "enter":
+ return tea.KeyMsg{Type: tea.KeyEnter}
+ case "delete":
+ return tea.KeyMsg{Type: tea.KeyDelete}
+ case "backspace":
+ return tea.KeyMsg{Type: tea.KeyBackspace}
+ case "ctrl+k":
+ return tea.KeyMsg{Type: tea.KeyCtrlK}
+ case "ctrl+u":
+ return tea.KeyMsg{Type: tea.KeyCtrlU}
+ case "ctrl+home":
+ return tea.KeyMsg{Type: tea.KeyCtrlHome}
+ case "ctrl+end":
+ return tea.KeyMsg{Type: tea.KeyCtrlEnd}
+ case "alt+right":
+ return tea.KeyMsg{Type: tea.KeyRight, Alt: true}
+ case "alt+left":
+ return tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
+ case "alt+d":
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}, Alt: true}
+ }
+ return tea.KeyMsg{}
+}
+
+func (v *viTextarea) currentLineText() string {
+ lines := strings.Split(v.ta.Value(), "\n")
+ idx := v.ta.Line()
+ if idx < 0 || idx >= len(lines) {
+ return ""
+ }
+ return lines[idx]
+}
+
+func (v *viTextarea) yankLine() {
+ v.yank = v.currentLineText()
+}
+
+// deleteLine implements dd: clear the current line's text, then merge
+// it away entirely (forward-delete pulls the next line up in the common
+// case; on the last line, backspace merges into the previous one
+// instead, since there's nothing after it to pull up).
+func (v *viTextarea) deleteLine() {
+ v.yank = v.currentLineText()
+ isLast := v.ta.Line() == v.ta.LineCount()-1
+ v.feed("home")
+ v.feed("ctrl+k")
+ if v.ta.LineCount() > 1 {
+ if isLast {
+ v.feed("backspace")
+ } else {
+ v.feed("delete")
+ }
+ }
+}
+
+func (v *viTextarea) pasteBelow() {
+ if v.yank == "" {
+ return
+ }
+ v.feed("end")
+ v.feed("enter")
+ v.ta.InsertString(v.yank)
+}
+
+func (v *viTextarea) pasteAbove() {
+ if v.yank == "" {
+ return
+ }
+ v.feed("home")
+ v.ta.InsertString(v.yank)
+ v.feed("enter")
+ v.feed("up")
+}