From fe06963e84097a8dc8b01d0781111a06ecd63605 Mon Sep 17 00:00:00 2001 From: proxyctl coordinator Date: Sat, 18 Jul 2026 00:06:20 +0800 Subject: [PATCH] fix: align contextual action hints --- internal/tui/help_dialog.go | 27 +++++-- internal/tui/help_dialog_test.go | 122 ++++++++++++++++++++++++++++++- internal/tui/pages.go | 29 +++++--- 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/internal/tui/help_dialog.go b/internal/tui/help_dialog.go index 7c8cd3a..09ac810 100644 --- a/internal/tui/help_dialog.go +++ b/internal/tui/help_dialog.go @@ -57,9 +57,16 @@ func (m Model) dashboardQuitAvailable() bool { func (p terminalPresentation) helpDialogView(model presentationState) terminalPage { content := lipgloss.NewStyle().Width(max(1, model.width)).Render(p.contextualHelp(model)) + exitHint := "Ctrl+C: exit" + if model.operationActive { + exitHint = "Ctrl+C: defer safe exit" + } + if model.exitDeferred { + exitHint = "Exit pending until safe completion" + } return terminalPage{ primary: content, - footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help Ctrl+C: exit", + footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help " + exitHint, focusLine: -1, primaryOffset: model.help.offset, } @@ -68,7 +75,7 @@ func (p terminalPresentation) helpDialogView(model presentationState) terminalPa func (p terminalPresentation) contextualHelp(model presentationState) string { var body strings.Builder body.WriteString(p.theme.section.Render("Contextual Help")) - body.WriteString("\nOnly the actions listed for this context are accepted while the dialog beneath Help remains unchanged.\n\n") + body.WriteString("\nThis describes the page or dialog beneath Help. While Help is open, only its scrolling and closing keys plus global Ctrl+C are accepted; the underlying state remains unchanged.\n\n") switch { case model.confirmation != nil: @@ -77,20 +84,22 @@ func (p terminalPresentation) contextualHelp(model presentationState) string { if model.confirmation.Summary != "" { fmt.Fprintf(&body, "%s\n", model.confirmation.Summary) } - body.WriteString("Left/Right or Tab changes the focused choice. Enter chooses it. y confirms. n, q, or Escape cancels. ") + body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y confirms. n, q, or Escape cancels. ") if model.confirmationDialog != nil { + body.WriteByte('\n') body.WriteString(confirmationFocusDescription(*model.confirmationDialog)) } body.WriteByte('\n') case model.profileDelete != nil: body.WriteString(p.theme.section.Render("Delete confirmation")) fmt.Fprintf(&body, "\nDelete Proxy Profile %q. Deletion cannot be undone.\n", model.profileDelete.name) - body.WriteString("Left/Right or Tab changes the focused choice. Enter chooses it. y deletes. n, q, or Escape cancels. ") + body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y deletes. n, q, or Escape cancels. ") + body.WriteByte('\n') body.WriteString(confirmationFocusDescription(model.profileDelete.dialog)) body.WriteByte('\n') case model.picker.open: body.WriteString(p.theme.section.Render("Active Profile picker")) - body.WriteString("\nType to filter Proxy Profiles; q is ordinary search input here. Up/Down and PgUp/PgDn select. Enter activates the selection. Escape cancels.\n") + body.WriteString("\nType to filter Proxy Profiles; q is ordinary search input here. Up/Down or Ctrl+P/Ctrl+N selects, and PgUp/PgDn moves by a page. Enter activates the selection. Escape cancels.\n") case model.driftResolutionTargetID != "": body.WriteString(p.theme.section.Render("Drift Resolution dialog")) body.WriteString("\nr reapplies the Active Profile. o restores original configuration when ownership evidence permits. Enter, q, or Escape cancels.\n") @@ -127,9 +136,13 @@ func (p terminalPresentation) contextualHelp(model presentationState) string { if model.form.field >= 0 && model.form.field < len(labels) { fmt.Fprintf(&body, "\nFocus: %s field.\n", labels[model.form.field]) } - body.WriteString("Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode. Ctrl+S saves. q or Escape cancels and returns to Profile management.\n") + body.WriteString("Type to append text and Backspace to delete. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode. Ctrl+S saves. q or Escape cancels and returns to Profile management.\n") if model.form.profile.Mode == "single" && model.form.field == 1 { - body.WriteString("Ctrl+D explicitly runs Protocol Probe for the Single proxy URL field.\n") + if model.form.probing { + body.WriteString("Protocol Probe is in progress; Ctrl+D does not start a duplicate probe.\n") + } else { + body.WriteString("Ctrl+D explicitly runs Protocol Probe for the Single proxy URL field.\n") + } } default: body.WriteString(p.theme.section.Render("Dashboard")) diff --git a/internal/tui/help_dialog_test.go b/internal/tui/help_dialog_test.go index 7e2ef5e..cb80043 100644 --- a/internal/tui/help_dialog_test.go +++ b/internal/tui/help_dialog_test.go @@ -64,6 +64,16 @@ func TestCompleteModelContextualHelpOpensOverEveryPageAndRestoresPageState(t *te t.Fatalf("closing Help did not restore form state: page=%d field=%d Profile=%q", model.page, model.form.field, model.form.profile.Name) } }) + + t.Run("Active Profile picker search and selection", func(t *testing.T) { + model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24)) + model = updateKey(model, "down") + model = updateKey(model, "r") + model = updateKey(model, "?") + assertUsableFrame(t, model.View(), 80, 24, "Contextual Help", "Active Profile picker", "q is ordinary search input", "Ctrl+P/Ctrl+N") + model = updateKey(model, "esc") + assertUsableFrame(t, model.View(), 80, 24, "Switch Active Profile", "Search: r", "> remote") + }) } func TestCompleteModelLongHelpScrollsAndSurvivesResize(t *testing.T) { @@ -183,16 +193,32 @@ func TestCompleteModelGlobalBackQuitAndInterruptSemantics(t *testing.T) { func(model Model) Model { return updateKey(model, "m") }, func(model Model) Model { return updateKey(updateKey(model, "m"), "e") }, func(model Model) Model { return updateKey(model, "?") }, + func(model Model) Model { return openProfilePicker(t, model) }, + func(model Model) Model { return updateKey(updateKey(model, "m"), "d") }, } { candidate := setup(resizedModel(t, module, themeModeNoColor, 80, 24)) _, command := candidate.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) if command == nil { - t.Fatalf("Ctrl+C from page %d did not request exit", candidate.page) + t.Fatalf("Ctrl+C from page or dialog did not request exit:\n%s", candidate.View()) } if _, ok := command().(tea.QuitMsg); !ok { - t.Fatalf("Ctrl+C from page %d did not emit tea.Quit", candidate.page) + t.Fatalf("Ctrl+C from page or dialog did not emit tea.Quit:\n%s", candidate.View()) } } + + driftSnapshot := operationSnapshot() + driftSnapshot.Targets[0].PrimaryAction = app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true} + driftSnapshot.Targets[0].DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}} + driftModule := &recordingModule{snapshot: driftSnapshot} + drift := resizedModel(t, driftModule, themeModeNoColor, 80, 24) + drift = updateKey(drift, " ") + _, command := drift.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + if command == nil { + t.Fatal("Ctrl+C from Drift Resolution did not request exit") + } + if _, ok := command().(tea.QuitMsg); !ok { + t.Fatal("Ctrl+C from Drift Resolution did not emit tea.Quit") + } } func TestCompleteModelHelpDuringMutationExplainsAndPreservesDisabledActions(t *testing.T) { @@ -206,7 +232,10 @@ func TestCompleteModelHelpDuringMutationExplainsAndPreservesDisabledActions(t *t model = updateKey(model, "pgdown") } view := model.View() - assertUsableFrame(t, view, 60, 16, "Mutation in progress", "Conflicting mutations are disabled") + assertUsableFrame(t, view, 60, 16, "Mutation in progress", "Conflicting mutations are disabled", "Ctrl+C: defer safe exit") + if strings.Contains(view, "Ctrl+C: exit") { + t.Fatalf("busy Help incorrectly claimed Ctrl+C exits immediately:\n%s", view) + } model = updateKey(model, "esc") updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeySpace}) @@ -217,3 +246,90 @@ func TestCompleteModelHelpDuringMutationExplainsAndPreservesDisabledActions(t *t close(updates) _ = command } + +func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) { + module := &recordingModule{snapshot: app.Snapshot{ + ActiveProfile: "office", + Profiles: []profile.Profile{{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080"}}, + Targets: operationSnapshot().Targets, + }} + + t.Run("dashboard and busy details", func(t *testing.T) { + model := resizedModel(t, module, themeModeNoColor, 120, 36) + assertUsableFrame(t, model.View(), 120, 36, "Space: Enable", "u: show unavailable", "r: refresh", "q/Ctrl+C: quit") + model = updateKey(model, "tab") + assertUsableFrame(t, model.View(), 120, 36, "PgUp/PgDn: scroll", "u: show unavailable", "q/Ctrl+C: quit") + + updates := make(chan app.OperationUpdate) + busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}} + model = resizedModel(t, busyModule, themeModeNoColor, 120, 36) + model = updateKey(model, "tab") + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + assertUsableFrame(t, model.View(), 120, 36, "u: show unavailable", "q/Ctrl+C: defer safe exit", "mutations disabled") + model = updateKey(model, "u") + assertUsableFrame(t, model.View(), 120, 36, "u: hide unavailable") + close(updates) + }) + + t.Run("picker, management, and confirmation", func(t *testing.T) { + model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24)) + assertUsableFrame(t, model.View(), 80, 24, "Type to search", "PgUp/PgDn: select", "Enter: activate", "Esc: cancel", "?: help") + model = updateKey(model, "esc") + model = updateKey(model, "m") + assertUsableFrame(t, model.View(), 80, 24, "n: create", "e: edit", "d: delete", "q/Esc: back") + model = updateKey(model, "d") + assertUsableFrame(t, model.View(), 80, 24, "←/→/Tab: choose", "Enter: choose", "y: delete", "n/q/Esc: cancel", "?: help") + model = updateKey(model, "q") + if model.profileDelete != nil || model.page != profilesPage { + t.Fatal("confirmation footer advertised q cancellation but q did not close only the dialog") + } + }) + + t.Run("form probe hint follows mode, focus, and progress", func(t *testing.T) { + model := resizedModel(t, module, themeModeNoColor, 80, 24) + model = updateKey(model, "m") + model = updateKey(model, "e") + view := model.View() + assertUsableFrame(t, view, 80, 24, "Shift+Tab", "Ctrl+T: mode", "Ctrl+S: save", "q/Esc: cancel") + if strings.Contains(view, "Ctrl+D: probe") { + t.Fatalf("form advertised a field action while Name was focused:\n%s", view) + } + + model = updateKey(model, "tab") + assertUsableFrame(t, model.View(), 80, 24, "Ctrl+D: probe") + updated, probeCommand := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + model = updated.(Model) + if probeCommand == nil || !strings.Contains(model.View(), "Protocol Probe in progress") || strings.Contains(model.View(), "Ctrl+D: probe") { + t.Fatalf("probe footer did not follow accepted in-field action and progress:\n%s", model.View()) + } + model = updateKey(model, "?") + assertUsableFrame(t, model.View(), 80, 24, "Protocol Probe is in progress", "Ctrl+D does not start a duplicate probe") + model = updateKey(model, "esc") + + model = updateKey(model, "ctrl+t") + if strings.Contains(model.View(), "Ctrl+D: probe") || !strings.Contains(model.View(), "advanced mode") { + t.Fatalf("Advanced form advertised a Single-field Protocol Probe:\n%s", model.View()) + } + }) + + t.Run("Help exit hint follows operation state", func(t *testing.T) { + idle := resizedModel(t, module, themeModeNoColor, 80, 24) + idle = updateKey(idle, "?") + assertUsableFrame(t, idle.View(), 80, 24, "Ctrl+C: exit") + + updates := make(chan app.OperationUpdate) + busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}} + busy := resizedModel(t, busyModule, themeModeNoColor, 80, 24) + updated, _ := busy.Update(tea.KeyMsg{Type: tea.KeySpace}) + busy = updateKey(updated.(Model), "?") + assertUsableFrame(t, busy.View(), 80, 24, "Ctrl+C: defer safe exit") + updated, quit := busy.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + busy = updated.(Model) + if quit != nil { + t.Fatal("busy Help Ctrl+C hint promised deferral but returned immediate quit") + } + assertUsableFrame(t, busy.View(), 80, 24, "Exit pending until safe completion") + close(updates) + }) +} diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 30f9729..0213bc8 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -22,9 +22,6 @@ func (p terminalPresentation) mainView(model presentationState, width, height in catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID) footer := p.dashboardFooter(model, catalog) - if model.confirmation != nil && !model.operationActive { - footer = "←/→/Tab: choose y: confirm Enter: choose n/q/Esc: cancel ?: help" - } if model.currentError != nil { footer += "\ne: expand/collapse current error x: acknowledge error" if model.errorExpanded { @@ -91,14 +88,14 @@ func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) ( } func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string { - if model.exitDeferred { - return "Exit pending; browsing remains safe while the current mutation completes" - } visibility := "u: show unavailable" if model.showUnavailable { visibility = "u: hide unavailable" } if model.dashboardFocus == targetDetailsPane { + if model.exitDeferred { + return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets " + visibility + " ?: help\nExit pending; new work disabled until safe completion" + } if model.width < wideWidth { if model.operationActive { return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets\n?: help q/Ctrl+C: defer exit; mutations disabled" @@ -110,11 +107,14 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t } footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: switch Active Profile m: manage Proxy Profiles ?: help q/Ctrl+C: quit" if model.operationActive { - footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets ?: help\nMutations disabled: wait for the current mutation to finish" + footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + " ?: help\nq/Ctrl+C: defer safe exit; mutations disabled" } return footer } actions := "↑/k ↓/j: select Tab: focus details " + visibility + if model.exitDeferred { + return actions + " ?: help\nExit pending; new work disabled until safe completion" + } selected, ok := catalog.selected(model.selectedTargetID) availability := targetMutationAvailability(selected, ok, model.operationActive || model.exitDeferred) label := targetActionLabel(selected.PrimaryAction.Kind) @@ -125,9 +125,9 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t } if model.width < wideWidth { if availability.available { - actions = "Space: " + label + " ↑/k ↓/j: select Tab: details" + actions = "Space: " + label + " ↑/k ↓/j: select Tab: focus details" } else { - actions = "Space: " + label + " disabled ↑/k ↓/j: select Tab: details" + actions = "Space: " + label + " disabled ↑/k ↓/j: select Tab: focus details" } if model.operationActive { @@ -236,10 +236,19 @@ func (p terminalPresentation) formView(model presentationState) terminalPage { } fmt.Fprintf(&fields, "%s%s: %s", cursor, label, value) } + secondaryHints := "?: help q/Esc: cancel" + if model.form.profile.Mode == profile.SingleMode && model.form.field == 1 { + probeHint := "Ctrl+D: probe" + if model.form.probing { + probeHint = "Protocol Probe in progress" + } + secondaryHints = probeHint + " " + secondaryHints + } + footer := "Tab/Shift+Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\n" + secondaryHints return terminalPage{ header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)), primary: fields.String(), - footer: "Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\nCtrl+D: probe ?: help q/Esc: cancel", + footer: footer, focusLine: model.form.field, } }