Skip to content

tui: spike a vim layer — ':' command line, counts, motions (#9) - #16

Merged
citizen-123 merged 11 commits into
mainfrom
vim-mode
Jul 30, 2026
Merged

tui: spike a vim layer — ':' command line, counts, motions (#9)#16
citizen-123 merged 11 commits into
mainfrom
vim-mode

Conversation

@citizen-123

@citizen-123 citizen-123 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Spike for #9. The issue suggested building : commands plus counts/motions first and judging from there, so that is what this is — a working layer to try, not a commitment to full modality.

Everything here is confined to the traffic pane, so a count or a : can never reach a target app that is itself running vim. The left pane is untouched.

The : command line

The part of #9 that stands on its own, independent of modality — a home for actions that never earned a keybinding:

:filter host:api     :sort size        :export flagged
:curl  :resend  :flag  :w  :help  :q

Every command calls the same model method as its key, so :curl and c cannot diverge. The command table carries its name, help text, aliases, and handler on one record, so the help overlay renders from the dispatcher itself rather than a parallel list — the same principle as the keymap-driven help from #8, applied to commands.

Commands stay reachable when the matching key is unbound, which is rather the point of having a command line. That required scoping one existing help assertion to the keymap-derived sections.

Motions

Keys Motion
{count} repeat the next motion — 5j, 3k, 2}
gg / G first / last flow
{count}G jump to a row — 12G
} / { next / previous host

Counts are a prefix and gg is a sequence, so neither is a single key the keymap can name — those are built into the pane. } and { are single keys, so they are ordinary rebindable bindings (flow.host-next / flow.host-prev), honouring #8's "a vim layer is a different keymap rather than a second dispatch path".

A pending count or a lone g renders in the traffic header, so a half-typed motion is never invisible; esc discards it. User bindings always win — the motion path only sees keys the keymap does not claim.

Also

  • The help overlay grew ~17 rows and could scroll its own title off a short terminal (lipgloss.Place centres but does not clip). It now clips with a … N more lines hint. Pre-existing, but this change made it likely.
  • Fixed the new tests' fixture: a zero textinput.Model has no blink context, so Focus() panics on it — inputs must come from their constructors, as New() does.

Judging it

Answering the issue's open questions from having built it:

  • Mode or default? Nothing here needs a mode. Counts, gg/G, }/{ and : all coexist with the existing plain keys, and there is still one dispatch path. The maintenance cost the issue worried about did not materialise, because none of this is a second input model — it is a prefix, two sequences, two bindings, and a command line.
  • What would need a mode is the rest of the issue: dd/yy operators, and normal/insert in the editor and repeater. Operators need a pending-operator state, and an insert mode means the editor stops being always-insert. That is where two divergent input paths start, and it is a separate decision.
  • : earns its place regardless. It pairs with Advanced filter syntax in the flow list #12 as the issue predicted — :filter and / already share a grammar here.

Suggest merging this and deciding operators/insert-mode separately, rather than treating #9 as one all-or-nothing change.

Testing

go build ./..., go vet ./..., go test ./... all pass; -race clean on tui and config. New tests cover counted motions, gg/G/{count}G, host jumps (including empty list, clamping, and no-wrap at both ends), esc discarding a count, alias resolution, and command-table hygiene (no duplicate name/alias, every entry has a handler and a description).

Docs: docs/keybindings.md gains motion and command sections; docs/configuration.md lists the two new actions and notes why motions and : are the one part of the overlay that is not keymap-derived.

Closes #9 as an exploration — the follow-on operator/insert-mode question is left open.

Explores the modal UI question in #9 by building the two pieces the issue
suggested judging from: a ':' command line and vim motions in the traffic
list. Both are confined to the traffic pane, so a count or a ':' can never
reach a target app that is itself running vim.

The ':' command line is the part that stands on its own, independent of
modality: it is a home for actions that do not deserve a keybinding
(:sort size, :export flagged). Every command calls the same model method
as its key, so a command and its key cannot diverge. The command table
carries its own help text and handler on one record, so the help overlay
renders from the dispatcher itself rather than a parallel list.

Motions: {count} prefixes, gg/G, {count}G, and }/{ to jump between hosts.
Counts and gg are a prefix and a sequence, so they are built into the pane
rather than bound through the keymap; } and { are single keys and so are
ordinary, rebindable bindings, per #8's "a vim layer is a different keymap,
not a second dispatch path".

A pending count shows in the traffic header so a half-typed motion is never
invisible, and esc discards it. The help overlay grew enough that it could
scroll its own title off a short terminal, so it now clips with a hint.

Claude-Session: https://claude.ai/code/session_01P151ygkx2NBLDtnVnUghRN
Correctness

- A pending count or a lone 'g' leaked out of the traffic pane. onKey returns
  early for the leader, a leader sequence, and terminal focus, all before the
  reset, so a count typed and abandoned survived a trip to the target app and
  then silently multiplied a later j. clearMotion() now runs on every path that
  takes focus off the list.
- ':filter' clamped an out-of-range selection to the LAST match while '/' sends
  it to the first, so the two disagreed about where the cursor lands — under a
  heading claiming a command and its key do the same thing. ':filter' now
  follows onFilterKey.
- ctrl+q was swallowed by the ':' input. Every other modal honours it; this was
  the one place in the app where the quit key did nothing.
- ':flag' reported nothing and skipped the re-clamp its siblings do, so under
  'F' it could unflag the selected row out of the list and leave the selection
  past the end, with no status to say anything had happened.
- truncate() sliced bytes, so any multibyte rune at the cut emitted invalid
  UTF-8. It counts runes now.
- A flow pausing while ':' or '/' was open left the input focused under a
  prompt reading "[e]dit [f]orward [d]rop" — those keys went into the text box
  while a real client stayed blocked. Pausing now closes both inputs.

Discoverability

The overlay is 84 lines and the ':' section starts at line 62, so the previous
commit's clip-with-a-hint guaranteed the new command documentation was the
first thing cut — on every realistic terminal. The overlay scrolls now
(j/k/G/gg/pgup/pgdn), which is what makes a table that documents itself
actually reachable. {count}gg also works, matching vim.

Footguns

- ':flagged' wrote every flagged request and response body to disk, unprompted,
  under the name that reads like the 'F' view toggle. It now toggles the view;
  the write is spelled ':export flagged' in full, so the destructive one is
  never the shorter thing to type.
- docs/configuration.md's "vim-ish list movement" example bound g and G in the
  traffic context, which silently disables gg/G/{count}G — the motions this
  branch adds. Retargeted the examples and documented the conflict.
- The keybindings doc said ":filter and f ... are aliases", which reads as a
  claim about pane keys. In the traffic pane 'f' FORWARDS a paused flow. Recast
  as command abbreviations with an explicit warning.
- The ':' binding description carried its own hardcoded command list, already
  missing four commands — the exact parallel list the table exists to prevent.

Claude-Session: https://claude.ai/code/session_01P151ygkx2NBLDtnVnUghRN
@citizen-123
citizen-123 merged commit fa37bfa into main Jul 30, 2026
2 checks passed
@citizen-123
citizen-123 deleted the vim-mode branch July 30, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore a vim-style modal UI

1 participant