Shell: the console's commands on the device's own screen and keyboard (#67)
CI / build (pull_request) Successful in 1m48s
Site / build (pull_request) Successful in 9s

An App in the Launcher that runs the same commands as USB serial and the
Debug Console, trusted like the first. It shows what the console prints
while it is open, through a second ring of the console's that exists only
meanwhile; Ctrl+b keeps only what follows your own commands. Tab completes
a command's name from the firmware's help text, Fn with up and down recalls
earlier lines, Alt with up and down scrolls back. `rm` asks first in the
Shell, `rm -f` doesn't. Nothing is kept once the App is left.

`screenshot [seconds]` saves the screen as a PNG in /screenshots on the
card, now or after a pause: written a row at a time, indexed colour with
RGB332 as the palette, in one stored deflate block.

487 host tests (11 new: the PNG writer, the Shell's log filter, Tab).
Checked on the device over the Debug Console: commands, Tab, history, a
screenshot fetched and decoded on the PC, rm with and without the question,
a delayed screenshot of another screen. 12 KB of flash; 7 KB of heap while
open. Decisions Q204 to Q212 in docs/milestones/S1.md. Safe Mode: #77.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-07 10:53:31 +02:00
co-authored by Claude Opus 5.5
parent c278a06ca1
commit 3863d28593
29 changed files with 1112 additions and 50 deletions
+95
View File
@@ -0,0 +1,95 @@
#include "shell_log.h"
#include <algorithm>
namespace roro {
bool ShellLog::wanted(const std::string& line, uint32_t nowMs) const {
if (line.rfind("status: heap ", 0) == 0) return false;
if (background_) return true;
return ranOne_ && nowMs - lastCommandMs_ < kMineMs; // unsigned: right across the clock's wrap
}
void ShellLog::push(const std::string& line) {
lines_.push_back(line);
bytes_ += line.size() + 1;
while (bytes_ > kMaxBytes && lines_.size() > 1) {
bytes_ -= lines_.front().size() + 1;
lines_.pop_front();
}
revision_++;
}
void ShellLog::add(const std::string& line) { push(line); }
void ShellLog::feed(const char* data, size_t len, uint32_t nowMs) {
for (size_t i = 0; i < len; i++) {
char c = data[i];
if (c == '\r') continue;
if (c != '\n') {
if (partial_.size() < 512) partial_ += c; // a line that never ends doesn't take the heap
continue;
}
if (wanted(partial_, nowMs)) push(partial_);
partial_.clear();
}
}
void ShellLog::clear() {
lines_.clear();
partial_.clear();
bytes_ = 0;
revision_++;
}
std::vector<std::string> commandWords(const char* helpText) {
std::vector<std::string> words;
std::string text = helpText ? helpText : "";
size_t lineStart = 0;
while (lineStart < text.size()) {
size_t lineEnd = text.find('\n', lineStart);
if (lineEnd == std::string::npos) lineEnd = text.size();
std::string line = text.substr(lineStart, lineEnd - lineStart);
// The commands stop where the description starts: three spaces.
size_t gap = line.find(" ");
std::string commands = line.substr(0, gap);
size_t at = 0;
while (at <= commands.size()) {
size_t bar = commands.find(" | ", at);
std::string one = commands.substr(at, bar == std::string::npos ? std::string::npos : bar - at);
size_t start = one.find_first_not_of(' ');
if (start != std::string::npos) {
size_t end = one.find(' ', start);
std::string word = one.substr(start, end == std::string::npos ? std::string::npos : end - start);
bool plain = !word.empty() && std::all_of(word.begin(), word.end(), [](char ch) { return ch >= 'a' && ch <= 'z'; });
if (plain && std::find(words.begin(), words.end(), word) == words.end()) words.push_back(word);
}
if (bar == std::string::npos) break;
at = bar + 3;
}
lineStart = lineEnd + 1;
}
return words;
}
std::string completeCommand(const std::string& typed, const std::vector<std::string>& words, std::vector<std::string>& matches) {
matches.clear();
if (typed.empty() || typed.find(' ') != std::string::npos) return typed;
for (auto& w : words)
if (w.rfind(typed, 0) == 0) matches.push_back(w);
if (matches.empty()) return typed;
if (matches.size() == 1) {
std::string only = matches[0] + " ";
matches.clear();
return only;
}
std::string common = matches[0];
for (auto& m : matches) {
size_t n = 0;
while (n < common.size() && n < m.size() && common[n] == m[n]) n++;
common.resize(n);
}
return common;
}
} // namespace roro
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <deque>
#include <string>
#include <vector>
namespace roro {
// What the Shell App shows (issue #67): the lines the console printed, with the oldest dropped past
// a size. Everything the firmware prints passes through; the Shell keeps what's worth a place on a
// ten-line screen (Q206).
class ShellLog {
public:
static constexpr size_t kMaxBytes = 4096;
static constexpr uint32_t kMineMs = 10000; // how long after a command its output is taken to be
// Bytes as the console printed them: lines may arrive in pieces.
void feed(const char* data, size_t len, uint32_t nowMs);
// A line the Shell adds itself: the command typed, an answer of its own.
void add(const std::string& line);
// A command was run now: what follows for a while is "its".
void commandRun(uint32_t nowMs) {
lastCommandMs_ = nowMs;
ranOne_ = true;
}
// All, or mine only: with background lines hidden, only what's printed in the ten seconds after
// a command is kept. A reply can't be told from other output any better: `ls` and `tasks` answer
// later, from other tasks. The line with the free heap every ten seconds is never kept.
void showBackground(bool show) { background_ = show; }
bool showsBackground() const { return background_; }
const std::deque<std::string>& lines() const { return lines_; }
void clear();
uint32_t revision() const { return revision_; } // changes when the lines do
private:
bool wanted(const std::string& line, uint32_t nowMs) const;
void push(const std::string& line);
std::deque<std::string> lines_;
std::string partial_;
size_t bytes_ = 0;
bool background_ = true, ranOne_ = false;
uint32_t lastCommandMs_ = 0, revision_ = 0;
};
// The commands `help` lists: the first word of each (`ls [folder] | du <path>` gives ls and du),
// each once, in the order they appear.
std::vector<std::string> commandWords(const char* helpText);
// Tab: `typed` with its first word completed as far as the commands agree. Unchanged when the cursor
// is past the first word, or when nothing starts with it. `matches` gets what it could become, when
// there is more than one.
std::string completeCommand(const std::string& typed, const std::vector<std::string>& words, std::vector<std::string>& matches);
} // namespace roro