Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ perf.data*
# lane looks next -- confusingly, since it is named like the file it is not.
# Anchored to the root: this pattern is only ever created there.
/.clang-format-neutered.*

# The design and plan documents the agent workflow (superpowers brainstorming /
# writing-plans) writes are local working files and are never tracked. Anchored
# to the repository root's docs/ directory, like the rules above.
/docs/superpowers/
11 changes: 10 additions & 1 deletion docs/tools/pacectl.md
Original file line number Diff line number Diff line change
Expand Up @@ -863,18 +863,27 @@ A terminal smaller than a panel's minimum gets one line, `needs <columns>x<rows>

| Key | What it does |
|---|---|
| `q`, `Q`, `Esc`, `Ctrl-C` | quit, restoring the terminal |
| `q`, `Q`, `Esc`, `Ctrl-C` | quit, restoring the terminal; `Esc` closes a machine's detail instead while one is open |
| `Tab`, `→` / `←` | the fleet's next / previous section |
| `1` … `9` | the fleet's sections by position |
| `m` `w` `l` `c` `r` `!` `t` | the fleet's sections by name: machines, workers, leases, members (`c`, for cluster: `m` is taken), revoked keys, conditions (`!`: `c` is taken) and tiers |
| `PgDn` / `PgUp`, `Home` | scroll the fleet's table a page, or back to its top |
| `/` | filter the fleet's table: type, `Enter` keeps the filter, `Esc` clears it, `Backspace` edits it |
| `↑` / `↓` | highlight the fleet chart's lanes, then the table's rows, and the machine they name everywhere it appears |
| `Enter` | open the highlighted machine's detail: its rows in every section that names machines; `Esc` goes back |

A section starts at its top with no filter. The filter keeps the rows with a cell
containing the typed text, ignoring ASCII case, and the table says how many rows of
how many it kept. While a filter is being typed, every key is text except
`Ctrl-C`, so a `q` in a machine's name does not end the session.

A machine's detail replaces the tiles, the chart, the strip and the table until `Esc`
closes it, and the highlight is where it was. A machine the latest sample no longer
lists says `not in the latest sample` rather than drawing empty sections. `Enter`
on a row of a section that names no machine, such as revoked keys, opens nothing.
The detail's tables do not scroll: a long one shows its first rows and `+N more`, and
on a short terminal whole sections give way from the bottom.

### How it draws: Sixel, Unicode, ASCII

What the terminal can draw is asked once, when the session starts, and decides
Expand Down
8 changes: 8 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ validation:
not_in_nav: |
snippets/*

# The design and plan files the agent workflow writes under `docs/superpowers/`
# are local working documents: untracked (.gitignore) and never published. They
# still sit in a developer's docs/ tree, so `exclude_docs` keeps a local
# `mkdocs build --strict` from refusing them as pages missing from `nav:`;
# `not_in_nav` would only hide them from the menu and publish every one of them.
exclude_docs: |
/superpowers/

site_name: Pace
site_description: A memcached- and Redis-compatible cache daemon, and a portable compile cache
site_author: Pace contributors
Expand Down
2 changes: 2 additions & 0 deletions src/apps/pacectl/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ set(_pace_cli_sources
DashboardLoop.cpp
DashboardSampler.cpp
FleetChartModel.cpp
FleetFocus.cpp
FleetDocument.cpp
HistoryChart.cpp
LiveEventSource.cpp
Expand Down Expand Up @@ -111,6 +112,7 @@ if(PACE_BUILD_TESTS)
DashboardLoop_test.cpp
DashboardSampler_test.cpp
FleetChartModel_test.cpp
FleetFocus_test.cpp
FleetDocument_test.cpp
HistoryChart_test.cpp
LiveEventSource_test.cpp
Expand Down
15 changes: 11 additions & 4 deletions src/apps/pacectl/DashboardGlyphs.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ struct RungGlyphs
/// with `Δ` (`DeltaMark`) and drawn through this column, one cell either way, so the layout is the same on
/// every rung and no UTF-8 byte reaches an ASCII terminal.
std::string_view delta;

/// The Up and Down keys as a hint names them: `↑↓`, and `Up/Dn` where the terminal is ASCII.
std::string_view upDown;
};

/// The mark a note is written with for a change over an interval, and drawn as its rung's `RungGlyphs::delta`.
Expand Down Expand Up @@ -106,7 +109,8 @@ inline constexpr EnumTable<RenderRung, RungGlyphs> RungGlyphTable { {
.gaugeHeld = "▒",
.gaugeEmpty = "░",
.gaugeClose = "",
.delta = DeltaMark },
.delta = DeltaMark,
.upDown = "↑↓" },
{ .rung = RenderRung::Unicode,
.sparkLevels = BlockLevels,
.chartLevels = BlockLevels,
Expand All @@ -122,7 +126,8 @@ inline constexpr EnumTable<RenderRung, RungGlyphs> RungGlyphTable { {
.gaugeHeld = "▒",
.gaugeEmpty = "░",
.gaugeClose = "",
.delta = DeltaMark },
.delta = DeltaMark,
.upDown = "↑↓" },
{ .rung = RenderRung::Ascii,
.sparkLevels = {},
.chartLevels = AsciiChartLevels,
Expand All @@ -138,7 +143,8 @@ inline constexpr EnumTable<RenderRung, RungGlyphs> RungGlyphTable { {
.gaugeHeld = "=",
.gaugeEmpty = ".",
.gaugeClose = "]",
.delta = "d" },
.delta = "d",
.upDown = "Up/Dn" },
{ .rung = RenderRung::Piped,
.sparkLevels = {},
.chartLevels = {},
Expand All @@ -154,7 +160,8 @@ inline constexpr EnumTable<RenderRung, RungGlyphs> RungGlyphTable { {
.gaugeHeld = "=",
.gaugeEmpty = ".",
.gaugeClose = "]",
.delta = "d" },
.delta = "d",
.upDown = "Up/Dn" },
} };

static_assert(RowsInEnumeratorOrder(RungGlyphTable, &RungGlyphs::rung),
Expand Down
12 changes: 8 additions & 4 deletions src/apps/pacectl/DashboardLoop.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ namespace
/// `Ctrl+C`: the quit key that stays one while a view `CapturesText`, so a session is always leavable.
constexpr std::string_view InterruptKey = "\x03";

/// The keystrokes that mean *leave*.
/// The keystrokes that mean *leave*, unless the view takes them first.
///
/// A table rather than an `if` ladder, so a fourth spelling is a row. `ESC` is here
/// because an operator reaches for it and a dashboard that ignores it feels stuck;
/// it is safe only because this view sends no query whose reply begins with `ESC`
/// -- the DA1 exchange is over before the loop starts, which is stage 2's one-shot
/// probe, not a thing that can arrive mid-run.
constexpr auto QuitKeys = std::to_array<std::string_view>({ "q", "Q", InterruptKey, "\x1b" });
/// probe, not a thing that can arrive mid-run. A key here still reaches the view, rather
/// than ending the run, while the view `CapturesText` (every key but `Ctrl+C`) and, for
/// `ESC` alone, while it `ClaimsEscape` -- a machine's detail is open, and `ESC` goes back.
constexpr auto QuitKeys = std::to_array<std::string_view>({ "q", "Q", InterruptKey, EscapeKey });

/// Whether a reading continues the run the reading before it belongs to.
///
Expand Down Expand Up @@ -294,7 +296,9 @@ core::async::Task<DashboardExit> RunDashboard(
break;

case DashboardEventKind::Key:
if (!IsQuitKey(event.keys) || (view->CapturesText() && event.keys != InterruptKey))
// A quit key quits unless the view is typing it, or it is Esc and the view has something open to close.
if (!IsQuitKey(event.keys) || (view->CapturesText() && event.keys != InterruptKey)
|| (event.keys == EscapeKey && view->ClaimsEscape()))
{
// Owed now, not at the next `Tick`: the view changed, and nothing was read.
if (view->Key(event.keys))
Expand Down
13 changes: 12 additions & 1 deletion src/apps/pacectl/DashboardLoop.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ class IDashboardView
}

/// Act on a keystroke that is not a quit key -- or on any keystroke but `Ctrl+C` while the view
/// `CapturesText`.
/// `CapturesText`, and on `EscapeKey` while it `ClaimsEscape`.
///
/// **What a view is showing is the view's state, not the model's**: the model is what was READ,
/// and a key reads nothing. So a key the view acts on changes the view, and the loop draws the
Expand All @@ -567,6 +567,14 @@ class IDashboardView
{
return false;
}

/// Whether Esc closes something this view has open, rather than quitting the session.
///
/// **Pure, unlike `CapturesText`**: a view that opens something Esc should close and answered through a
/// default would quit the session on the key meant to go back, and nothing would say so. Asked only for
/// `EscapeKey`; every other quit key quits whatever the view has open.
/// @return True while Esc is the view's to act on.
[[nodiscard]] virtual bool ClaimsEscape() const noexcept = 0;
};

/// What bounds the run.
Expand Down Expand Up @@ -597,6 +605,9 @@ struct DashboardLimits
[[nodiscard]] core::async::Task<DashboardExit> RunDashboard(
IDashboardEventSource* events, SampleReader reader, IDashboardView* view, IFrameSink* sink, DashboardLimits limits);

/// `Esc`, as a keystroke delivers it: a quit key unless the view `ClaimsEscape`, and what closes what it has open.
inline constexpr std::string_view EscapeKey = "\x1b";

/// Whether @p keys asks the dashboard to quit.
///
/// Its own function so the loop states no key literal and a test can assert the set
Expand Down
98 changes: 98 additions & 0 deletions src/apps/pacectl/DashboardLoop_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ namespace
class RecordingView final: public IDashboardView
{
public:
/// @return False: this view opens nothing Esc closes.
[[nodiscard]] bool ClaimsEscape() const noexcept override
{
return false;
}

[[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override
{
++calls;
Expand Down Expand Up @@ -1186,6 +1192,12 @@ namespace
class DocumentWatchingView final: public IDashboardView
{
public:
/// @return False: this view opens nothing Esc closes.
[[nodiscard]] bool ClaimsEscape() const noexcept override
{
return false;
}

[[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override
{
seen.emplace_back(model.latestDocument);
Expand Down Expand Up @@ -1233,6 +1245,12 @@ namespace
class PlacingView final: public IDashboardView
{
public:
/// @return False: this view opens nothing Esc closes.
[[nodiscard]] bool ClaimsEscape() const noexcept override
{
return false;
}

[[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override
{
cellPixelsSeen.push_back(model.cellPixels);
Expand Down Expand Up @@ -1315,6 +1333,12 @@ namespace
class OneKeyView final: public IDashboardView
{
public:
/// @return False: this view opens nothing Esc closes.
[[nodiscard]] bool ClaimsEscape() const noexcept override
{
return false;
}

[[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override
{
(void) model;
Expand Down Expand Up @@ -1374,6 +1398,12 @@ namespace
class TypingView final: public IDashboardView
{
public:
/// @return False: this view opens nothing Esc closes.
[[nodiscard]] bool ClaimsEscape() const noexcept override
{
return false;
}

[[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override
{
(void) model;
Expand Down Expand Up @@ -1433,3 +1463,71 @@ TEST_CASE("a view typing text is offered the quit keys as text, and Ctrl+C still
CHECK(doneExit.stop == DashboardStop::Quit);
CHECK(done.offered == std::vector<std::string> { "\r" });
}

namespace
{

/// A view that claims Esc or does not, and writes down every key it is offered.
///
/// Every member public, as this file's other recording fakes keep what a case reads.
class EscapeClaimingView final: public IDashboardView
{
public:
/// @param claimsEscape What `ClaimsEscape` answers.
explicit EscapeClaimingView(bool claimsEscape):
claims { claimsEscape }
{
}

[[nodiscard]] DashboardFrame PlacedFrame(DashboardModel const& model) override
{
(void) model;
return DashboardFrame { .text = std::format("offered {}", offered.size()), .placements = {} };
}

[[nodiscard]] bool Key(std::string_view keys) override
{
offered.emplace_back(keys);
return true;
}

[[nodiscard]] bool ClaimsEscape() const noexcept override
{
return claims;
}

bool claims; ///< What `ClaimsEscape` answers.
std::vector<std::string> offered {}; ///< Every key offered, in order.
};

/// Drive @p view through Esc, then `x`, then `q`, then `y`.
/// @param view The view.
/// @return How the run ended.
[[nodiscard]] DashboardExit EscThenQuit(EscapeClaimingView& view)
{
auto sink = CollectingSink {};
return Drive({ DashboardEvent { .kind = DashboardEventKind::Key, .keys = std::string { EscapeKey } },
DashboardEvent { .kind = DashboardEventKind::Key, .keys = "x" },
DashboardEvent { .kind = DashboardEventKind::Key, .keys = "q" },
DashboardEvent { .kind = DashboardEventKind::Key, .keys = "y" } },
DashboardLimits {},
view,
sink);
}

} // namespace

TEST_CASE("esc quits a view that claims nothing and is handed to a view that claims it", "[cli][dashboard][loop]")
{
// WHAT DISTINGUISHES: who is offered Esc. A view that claims it is handed Esc and the run goes on to `x`,
// ending at `q`; a view that claims nothing is offered nothing, since Esc ends the run as it always did.
auto claiming = EscapeClaimingView { true };
auto const claimed = EscThenQuit(claiming);
CHECK(claimed.stop == DashboardStop::Quit);
CHECK(claiming.offered == std::vector<std::string> { std::string { EscapeKey }, "x" });

auto plain = EscapeClaimingView { false };
auto const quit = EscThenQuit(plain);
CHECK(quit.stop == DashboardStop::Quit);
CHECK(plain.offered.empty());
}
Loading
Loading