fix: align contextual action hints

This commit is contained in:
proxyctl coordinator
2026-07-18 00:06:20 +08:00
parent c93dc61ef6
commit fe06963e84
3 changed files with 158 additions and 20 deletions
+20 -7
View File
@@ -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"))
+119 -3
View File
@@ -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)
})
}
+19 -10
View File
@@ -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,
}
}