fix: align contextual action hints
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user