-
Notifications
You must be signed in to change notification settings - Fork 2.3k
TUI overhaul #4897
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
hxnyk
wants to merge
10
commits into
main
Choose a base branch
from
tui-overhaul
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
TUI overhaul #4897
Changes from 7 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
97a3fa4
tui: scaffold app, theme, form, sources/registry
hxnyk a99e130
tui: implement form widgets, validators, and Args()
hxnyk d99111f
tui: Phase 3 — migrate sources to declarative Definitions
hxnyk 1a20089
tui: Phase 4 — port pages to app.Page + kebab-case rename
hxnyk c2422c6
tui: Phase 5 — delete dead packages + sources shim
hxnyk 4859d87
tui: Phase 6 — add README.md
hxnyk 637df36
fix form component alignment issues
hxnyk bb3b001
quit after showing link card
hxnyk 888020e
loop back around on source tabs
hxnyk 26d64bd
adjust escape handling
hxnyk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| # TruffleHog TUI | ||
|
|
||
| The TUI is a Bubble Tea program that lets the user configure a scan or an | ||
| analyzer interactively. When it exits, control hands back to `kingpin` (for | ||
| scans) or to `pkg/analyzer.Run` (for analyzer flows). | ||
|
|
||
| ## Architecture | ||
|
|
||
| Three layers, nothing else: | ||
|
|
||
| 1. **`app` — the router.** Owns the page stack, global key handling, the | ||
| shared style sheet and key bindings, and the parent-process handoff | ||
| (argv for kingpin, SecretInfo for the analyzer). Pages know nothing | ||
| about each other; they emit navigation messages and the router | ||
| reacts. | ||
|
|
||
| 2. **`pages/*` — one directory per page.** Every page implements | ||
| `app.Page`, receives `app.ResizeMsg` for layout, and communicates | ||
| upward with `app.PushMsg` / `app.PopMsg` / `app.ReplaceMsg` / | ||
| `app.ExitMsg` / `app.RunAnalyzerMsg`. Layout is kebab-case | ||
| (`pages/source-picker/`, `pages/analyzer-form/`, …). | ||
|
|
||
| 3. **`components/form` + `sources` — declarative configuration.** | ||
| `sources.Definition` is a registry entry for each scan source: | ||
| title, description, `form.FieldSpec`s, validators, optional | ||
| `BuildArgs` override. `form.Form` renders the fields, validates | ||
| them, and produces a kingpin-style `[]string` arg vector. The few | ||
| sources with irreducibly complex arg-emission rules (elasticsearch, | ||
| jenkins, postman) provide a `BuildArgs` hook. | ||
|
|
||
| ```mermaid | ||
| graph TD | ||
| subgraph Runtime | ||
| main[main.go] -->|argv| tui[pkg/tui.Run] | ||
| end | ||
|
|
||
| subgraph pkg/tui | ||
| tui -->|builds| Model[app.Model router] | ||
| Model --> Pages[pages/*] | ||
| Pages -->|uses| Form[components/form] | ||
| Pages -->|uses| Registry[sources registry] | ||
| end | ||
|
|
||
| Model -->|ExitMsg| kingpin | ||
| Model -->|RunAnalyzerMsg| analyzer.Run | ||
| ``` | ||
|
|
||
| ## Workflows | ||
|
|
||
| ### Entry | ||
|
|
||
| ```mermaid | ||
| flowchart LR | ||
| start([main.go]) --> argv{argv} | ||
| argv -->|no args| wizard[wizard] | ||
| argv -->|analyze| picker[analyzer-picker] | ||
| argv -->|analyze known| form[analyzer-form] | ||
| argv -->|analyze unknown| picker | ||
| ``` | ||
|
|
||
| ### Source scan | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant U as user | ||
| participant W as wizard | ||
| participant SP as source-picker | ||
| participant SC as source-config | ||
| participant R as router | ||
| participant K as kingpin | ||
|
|
||
| U->>W: choose "Scan a source" | ||
| W->>R: PushMsg(source-picker) | ||
| U->>SP: pick source | ||
| SP->>R: PushMsg(source-config, SourceID) | ||
| U->>SC: fill source tab → tab | ||
| U->>SC: fill trufflehog tab → tab | ||
| U->>SC: enter on run tab | ||
| SC->>R: ExitMsg(argv) | ||
| R->>K: argv | ||
| ``` | ||
|
|
||
| ### Analyzer | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant U as user | ||
| participant AP as analyzer-picker | ||
| participant AF as analyzer-form | ||
| participant R as router | ||
| participant A as analyzer.Run | ||
|
|
||
| U->>AP: pick analyzer type | ||
| AP->>R: PushMsg(analyzer-form, KeyType) | ||
| U->>AF: fill fields | ||
| AF->>R: RunAnalyzerMsg(type, info) | ||
| R->>A: dispatch | ||
| ``` | ||
|
|
||
| ## Adding a new source | ||
|
|
||
| 1. Create `pkg/tui/sources/<id>/` with a `Definition()` returning a | ||
| `sources.Definition`. | ||
| 2. Declare fields with `form.FieldSpec` — pick the appropriate `Kind` | ||
| (`KindText`, `KindSecret`, `KindCheckbox`, `KindSelect`) and | ||
| `EmitMode` (`EmitPositional`, `EmitLongFlagEq`, `EmitPresence`, | ||
| `EmitRepeatedLongFlagEq`, `EmitConstant`, …). Add validators from | ||
| `form` (`Required`, `Integer`, `OneOf`) and cross-field | ||
| `Constraint`s (`XOrGroup`) as needed. | ||
| 3. If the arg-emission logic isn't expressible declaratively, set | ||
| `Definition.BuildArgs` to a `func(values map[string]string) []string`. | ||
| 4. Add `func init() { sources.Register(Definition()) }` and blank-import | ||
| the package from `pkg/tui/tui.go` so the registry picks it up. | ||
| 5. Add a test case to `pkg/tui/sources/sources_test.go` covering the | ||
| emitted arg vector. | ||
|
|
||
| ## Adding a new page | ||
|
|
||
| 1. Create `pkg/tui/pages/<kebab-name>/` with a `Page` type satisfying | ||
| `app.Page`. | ||
| 2. Export an `ID` constant re-declaring the appropriate `app.PageID` (or | ||
| add a new one to `pkg/tui/app/page.go`). | ||
| 3. Register a factory in `pkg/tui/tui.go`'s `registerPages`. | ||
| 4. Emit `app.PushMsg` / `app.PopMsg` / `app.ExitMsg` / | ||
| `app.RunAnalyzerMsg` from the page as appropriate; never touch the | ||
| stack directly. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| package app | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/charmbracelet/bubbles/key" | ||
| tea "github.com/charmbracelet/bubbletea" | ||
| zone "github.com/lrstanley/bubblezone" | ||
|
|
||
| "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" | ||
| ) | ||
|
|
||
| // Minimum terminal size below which the router refuses to render any page. | ||
| const ( | ||
| minWidth = 40 | ||
| minHeight = 10 | ||
| ) | ||
|
|
||
| // Model is the TUI router. It owns the navigation stack, chrome, global keys | ||
| // and the parent-process handoff. Pages know nothing about each other and | ||
| // only communicate through the navigation messages in messages.go. | ||
| type Model struct { | ||
| styles *theme.Styles | ||
| keymap *theme.KeyMap | ||
| zone *zone.Manager | ||
| factories map[PageID]Factory | ||
| stack []Page | ||
| width int | ||
| height int | ||
|
|
||
| // Deliverables passed back to pkg/tui.Run once the program exits. | ||
| args []string | ||
| analyzerType string | ||
| analyzerInfo analyzer.SecretInfo | ||
|
|
||
| // initial page + data, resolved on first Update after WindowSizeMsg so | ||
| // pages are created with a valid size from the start. | ||
| initialID PageID | ||
| initialData any | ||
| initialized bool | ||
| } | ||
|
|
||
| // New constructs a Model with default styles, keymap and zone manager. The | ||
| // caller is responsible for registering page factories with Register before | ||
| // calling Run, and for seeding the initial page with SetInitialPage. | ||
| func New() *Model { | ||
| return &Model{ | ||
| styles: theme.DefaultStyles(), | ||
| keymap: theme.DefaultKeyMap(), | ||
| zone: zone.New(), | ||
| factories: make(map[PageID]Factory), | ||
| } | ||
| } | ||
|
|
||
| // Register associates a PageID with a factory. Calling Register twice with | ||
| // the same ID panics; pages are expected to be registered once at startup. | ||
| func (m *Model) Register(id PageID, f Factory) { | ||
| if _, ok := m.factories[id]; ok { | ||
| panic(fmt.Sprintf("app: duplicate page factory for %q", id)) | ||
| } | ||
| m.factories[id] = f | ||
| } | ||
|
|
||
| // SetInitialPage seeds the page the router should push on the first tick. | ||
| // The page is not constructed until the first tea.WindowSizeMsg so it sees a | ||
| // valid size from Init. | ||
| func (m *Model) SetInitialPage(id PageID, data any) { | ||
| m.initialID = id | ||
| m.initialData = data | ||
| } | ||
|
|
||
| // Styles returns the shared style sheet. Pages use this via the constructor | ||
| // dependency injected by their Factory. | ||
| func (m *Model) Styles() *theme.Styles { return m.styles } | ||
|
|
||
| // Keymap returns the shared keymap. | ||
| func (m *Model) Keymap() *theme.KeyMap { return m.keymap } | ||
|
|
||
| // Zone returns the shared bubblezone manager. | ||
| func (m *Model) Zone() *zone.Manager { return m.zone } | ||
|
|
||
| // Args is the arg vector to hand to kingpin on re-exec. nil means "user | ||
| // quit". | ||
| func (m *Model) Args() []string { return m.args } | ||
|
|
||
| // AnalyzerType is the lowercased analyzer name if the user completed an | ||
| // analyzer flow, empty string otherwise. | ||
| func (m *Model) AnalyzerType() string { return m.analyzerType } | ||
|
|
||
| // AnalyzerInfo carries the analyzer form submission, only valid when | ||
| // AnalyzerType() is non-empty. | ||
| func (m *Model) AnalyzerInfo() analyzer.SecretInfo { return m.analyzerInfo } | ||
|
|
||
| // Init implements tea.Model. | ||
| func (m *Model) Init() tea.Cmd { return nil } | ||
|
|
||
| func (m *Model) active() Page { | ||
| if len(m.stack) == 0 { | ||
| return nil | ||
| } | ||
| return m.stack[len(m.stack)-1] | ||
| } | ||
|
|
||
| func (m *Model) contentSize() (int, int) { | ||
| // Router-owned chrome is the App style's frame size. The content | ||
| // rectangle is whatever's left. | ||
| hFrame, vFrame := m.styles.App.GetFrameSize() | ||
| w := m.width - hFrame | ||
| h := m.height - vFrame | ||
| if w < 0 { | ||
| w = 0 | ||
| } | ||
| if h < 0 { | ||
| h = 0 | ||
| } | ||
| return w, h | ||
| } | ||
|
|
||
| func (m *Model) resizeActive() tea.Cmd { | ||
| p := m.active() | ||
| if p == nil { | ||
| return nil | ||
| } | ||
| w, h := m.contentSize() | ||
| p.SetSize(w, h) | ||
| resize := ResizeMsg{Width: w, Height: h} | ||
| _, cmd := p.Update(resize) | ||
| return cmd | ||
| } | ||
|
|
||
| func (m *Model) push(id PageID, data any) tea.Cmd { | ||
| f, ok := m.factories[id] | ||
| if !ok { | ||
| panic(fmt.Sprintf("app: no factory registered for page %q", id)) | ||
| } | ||
| page := f(data) | ||
| m.stack = append(m.stack, page) | ||
| initCmd := page.Init() | ||
| resizeCmd := m.resizeActive() | ||
| return tea.Batch(initCmd, resizeCmd) | ||
| } | ||
|
|
||
| func (m *Model) pop() tea.Cmd { | ||
| if len(m.stack) == 0 { | ||
| return tea.Quit | ||
| } | ||
| m.stack = m.stack[:len(m.stack)-1] | ||
| if len(m.stack) == 0 { | ||
| return tea.Quit | ||
| } | ||
| return m.resizeActive() | ||
| } | ||
|
|
||
| func (m *Model) replace(id PageID, data any) tea.Cmd { | ||
| if len(m.stack) > 0 { | ||
| m.stack = m.stack[:len(m.stack)-1] | ||
| } | ||
| return m.push(id, data) | ||
| } | ||
|
|
||
| // Update implements tea.Model. | ||
| func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| switch msg := msg.(type) { | ||
| case tea.WindowSizeMsg: | ||
| m.width = msg.Width | ||
| m.height = msg.Height | ||
| if !m.initialized { | ||
| m.initialized = true | ||
| if m.initialID == "" { | ||
| return m, tea.Quit | ||
| } | ||
| return m, m.push(m.initialID, m.initialData) | ||
| } | ||
| return m, m.resizeActive() | ||
|
|
||
| case tea.KeyMsg: | ||
| if cmd := m.handleGlobalKey(msg); cmd != nil { | ||
| return m, cmd | ||
| } | ||
|
|
||
| case PushMsg: | ||
| return m, m.push(msg.ID, msg.Data) | ||
|
|
||
| case PopMsg: | ||
| return m, m.pop() | ||
|
|
||
| case ReplaceMsg: | ||
| return m, m.replace(msg.ID, msg.Data) | ||
|
|
||
| case ExitMsg: | ||
| m.args = msg.Args | ||
| return m, tea.Quit | ||
|
|
||
| case RunAnalyzerMsg: | ||
| m.analyzerType = msg.Type | ||
| m.analyzerInfo = msg.Info | ||
| return m, tea.Quit | ||
| } | ||
|
|
||
| // Forward everything else to the active page. | ||
| if p := m.active(); p != nil { | ||
| next, cmd := p.Update(msg) | ||
| m.stack[len(m.stack)-1] = next | ||
| return m, cmd | ||
| } | ||
| return m, nil | ||
| } | ||
|
|
||
| func (m *Model) handleGlobalKey(msg tea.KeyMsg) tea.Cmd { | ||
| switch { | ||
| case key.Matches(msg, m.keymap.Quit): | ||
| m.args = nil | ||
| return tea.Quit | ||
| case key.Matches(msg, m.keymap.CmdQuit): | ||
| p := m.active() | ||
| if p != nil && len(m.stack) == 1 && p.AllowQKey() { | ||
| m.args = nil | ||
| return tea.Quit | ||
| } | ||
| case key.Matches(msg, m.keymap.Back): | ||
| if len(m.stack) <= 1 { | ||
| m.args = nil | ||
| return tea.Quit | ||
| } | ||
| return m.pop() | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // View implements tea.Model. | ||
| func (m *Model) View() string { | ||
| if m.width < minWidth || m.height < minHeight { | ||
| return m.styles.App.Render(fmt.Sprintf( | ||
| "Terminal too small — need at least %d×%d, got %d×%d", | ||
| minWidth, minHeight, m.width, m.height, | ||
| )) | ||
| } | ||
| p := m.active() | ||
| if p == nil { | ||
| return "" | ||
| } | ||
| return m.zone.Scan(m.styles.App.Render(p.View())) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Router intercepts
esckey, breaking list filter cancellationHigh Severity
The router's
handleGlobalKeyunconditionally intercepts theesckey (bound toBack) and pops the page or quits — before the message ever reaches the active page. Both the source picker and analyzer picker usebubbles/listwith filtering enabled, which relies onescto cancel an active filter. When a user activates the filter and pressesescto dismiss it, the router pops the page instead, making list filtering effectively unusable.Additional Locations (2)
pkg/tui/pages/source-picker/source_picker.go#L72-L74pkg/tui/pages/analyzer-picker/analyzer_picker.go#L72-L74Reviewed by Cursor Bugbot for commit 637df36. Configure here.