Shell: Tab completes every word of a command, and * and ? stand for several files (#67)
CI / build (pull_request) Successful in 1m38s
Site / build (pull_request) Successful in 9s

Tab used to complete a command's first word only. It now follows the help
text word by word: `lora st` gives `lora status`, `gnss track ` lists
`start  stop`. The words are read from the help text as written, so a new
command completes with no table to keep; the Shell's own words are added in
the same notation.

`*` and `?` in the last part of a path, for ls, du, rm, cp and mv, from the
Shell and both consoles. The command runs once for each name matched, lined
up and run by the main loop as each finishes; `cancel` empties the line-up.
64 matches at most, refused whole past that. In the Shell, rm with a pattern
asks once, with the count.

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 12:47:10 +02:00
co-authored by Claude Opus 5.5
parent d17d10948d
commit 0fdbb5b1ed
16 changed files with 368 additions and 112 deletions
+20 -15
View File
@@ -25,14 +25,8 @@ void ShellApp::onEnter() {
ringPos_ = 0;
scroll_ = 0;
confirm_.reset();
words_ = commandWords(helpText_);
for (auto* app : apps_.visibleApps()) { // and the Apps, by their names with a capital
std::string name = AppManager::commandFor(app->id);
if (std::find(words_.begin(), words_.end(), name) == words_.end()) words_.push_back(name);
}
words_.push_back("help");
words_.push_back("clear"); // the Shell's own
words_.push_back("quit");
// What Tab completes besides the firmware's own commands, written as `help` writes them.
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n";
log_.clear();
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
}
@@ -43,7 +37,7 @@ void ShellApp::onExit() {
console.shellShowsAll(false);
open_ = false;
log_.clear();
std::vector<std::string>().swap(words_);
std::string().swap(ownHelp_);
confirm_.reset();
}
@@ -78,11 +72,20 @@ void ShellApp::enter(const std::string& line) {
// to. An empty folder goes without a word; anything `rm` would refuse anyway, it refuses itself.
if (startsWith(line, "rm ")) {
files::RmArgs args = files::parseRm(line.substr(3));
Target target = args.force || args.path.empty() ? Target::Missing : probe_(args.path);
bool ask = target == Target::File || (target == Target::FullFolder && args.recursive);
bool ask = false;
if (args.force || args.path.empty()) {
} else if (files::hasGlob(args.path)) { // a pattern: one question for all it matches
bool more = false;
int n = count_(args.path, more);
ask = n > 0 && !more; // none, or too many: `rm` says so itself
question_ = "The " + std::to_string(n) + " that match " + args.path + (args.recursive ? ", folders and what's in them too" : "") + ". It can't be undone.";
} else {
Target target = probe_(args.path);
ask = target == Target::File || (target == Target::FullFolder && args.recursive);
question_ = target == Target::File ? args.path + ". It can't be undone." : args.path + " and everything in it. It can't be undone.";
}
if (ask) {
pending_ = line;
question_ = target == Target::File ? args.path + ". It can't be undone." : args.path + " and everything in it. It can't be undone.";
confirm_.reset(new DialogModel({"Cancel", "Delete"}));
return;
}
@@ -123,12 +126,14 @@ bool ShellApp::onKey(const KeyEvent& e) {
case Key::Down:
if (history_.down(recalled)) input_.setText(recalled);
break;
case Key::Tab: { // the command's name, or past it a path on the card
case Key::Tab: { // the command, every word of it; where its words end, a path on the card
std::vector<std::string> matches;
PathToComplete path;
bool more = false;
if (splitForPath(input_.text(), path)) input_.setText(completePath(path, list_(path.folder, path.prefix, more), matches));
else input_.setText(completeCommand(input_.text(), words_, matches));
const std::string typed = input_.text();
std::string done = completeWords(typed, (std::string(helpText_) + ownHelp_).c_str(), matches);
if (done == typed && matches.empty() && splitForPath(typed, path)) done = completePath(path, list_(path.folder, path.prefix, more), matches);
input_.setText(done);
if (matches.size() > 1) {
std::string all;
for (auto& m : matches) all += (all.empty() ? "" : " ") + m;
+6 -3
View File
@@ -30,9 +30,11 @@ class ShellApp : public App {
// A folder's entries that start with `prefix`, whatever their case, a folder's with a slash at its
// end: what Tab completes a path from. `more` when there were too many to give them all.
using List = std::function<std::vector<std::string>(const std::string& folder, const std::string& prefix, bool& more)>;
// How many names a pattern matches (/notes/*.txt), for the question `rm` asks; `more` past the limit.
using Count = std::function<int(const std::string& pattern, bool& more)>;
ShellApp(Run run, Probe probe, List list, const char* helpText, AppManager& apps)
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), helpText_(helpText), apps_(apps) {}
ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps)
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {}
void onEnter() override;
void onExit() override;
@@ -49,10 +51,11 @@ class ShellApp : public App {
Run run_;
Probe probe_;
List list_;
Count count_;
const char* helpText_;
AppManager& apps_;
ShellLog log_;
std::vector<std::string> words_; // the commands Tab completes: built on entering, from `help`
std::string ownHelp_; // the Shell's own commands and the keys `key` takes, in the form of `help`'s text
LineEditor input_{240};
InputHistory history_{16};
std::unique_ptr<DialogModel> confirm_;
+83 -7
View File
@@ -158,6 +158,7 @@ static const char* helpText();
static void shellRun(const std::string& line);
static ShellApp::Target shellProbe(const std::string& path);
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more);
static int shellCount(const std::string& pattern, bool& more);
void setup() {
nvs.begin();
@@ -216,7 +217,7 @@ void setup() {
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, helpText(), *apps)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
apps->registerApp({"system", "System", false,
@@ -433,6 +434,48 @@ static std::vector<std::string> filesInUse(const std::string& path, bool folder)
// `cp`, `mv`, `rm`, `mkdir`, `du` run as the Storage App's operations do; the result prints here.
static bool consoleFileOp = false;
// A pattern in a path (issue #67): `rm /notes/*.txt` is one `rm` for each name it matches, run one
// after the other, each when the one before is done. 64 at most: more is refused, not started.
constexpr size_t kMaxGlob = 64;
struct QueuedFileCommand {
std::string line;
Console::Origin from;
};
static std::deque<QueuedFileCommand> fileQueue;
static void fileCommand(const String& line);
// The paths a pattern matches, sorted. `more` when there were over kMaxGlob (or over 2000 entries to
// look through); empty when the pattern isn't in the last part of the path alone.
static std::vector<std::string> expandGlob(const std::string& path, bool& more) {
std::vector<std::string> out;
std::string folder, pattern;
more = false;
if (!files::splitGlob(path, folder, pattern) || !storageService->state().present) return out;
storageService->runAndWait([&]() {
File dir = SD.open(folder.c_str());
if (!dir || !dir.isDirectory()) return;
int seen = 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (++seen > 2000 || out.size() > kMaxGlob) {
more = true;
break;
}
if (files::globMatch(pattern, f.name())) out.push_back(files::joinPath(folder, f.name()));
}
});
if (out.size() > kMaxGlob) more = true;
std::sort(out.begin(), out.end());
return out;
}
static void fileQueueStep() {
if (fileQueue.empty() || consoleFileOp) return; // the one before is still running
QueuedFileCommand next = std::move(fileQueue.front());
fileQueue.pop_front();
Console::As as(next.from);
fileCommand(next.line.c_str());
}
static void fileOpsStep() {
FileOps::Status s;
if (!consoleFileOp || !fileOps->finished(s)) return;
@@ -531,7 +574,32 @@ static void fileCommand(const String& line) {
std::string a = rest.substr(0, split), b = split == std::string::npos ? "" : rest.substr(split + 1);
std::string why;
bool exists = false, folder = false;
if (command == "cancel") return fileOps->cancel();
if (command == "cancel") {
fileQueue.clear(); // and what a pattern had lined up
return fileOps->cancel();
}
// A pattern: the same command for each name it matches.
files::RmArgs rmArgs = command == "rm" ? files::parseRm(everything) : files::RmArgs();
const std::string globbed = command == "rm" ? rmArgs.path : command == "du" ? rest : command == "cp" || command == "mv" ? a : "";
if (files::hasGlob(globbed) && storageService->state().present) {
bool more = false, intoExists = false;
std::string folder, pattern;
std::vector<std::string> paths = expandGlob(globbed, more);
if (!files::splitGlob(globbed, folder, pattern)) why = "a pattern goes in the last part of a path: /notes/*.txt";
else if (more) why = "more than 64 match: a narrower pattern, please";
else if (paths.empty()) why = "nothing matches";
else if ((command == "cp" || command == "mv") && (b.empty() || !fileOps->isFolder(b, intoExists) || !intoExists)) why = "several files go into a folder that exists";
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
console.printf("%s: %u match %s\n", command.c_str(), (unsigned)paths.size(), globbed.c_str());
Console::Origin from = console.origin();
for (auto& p : paths) {
std::string one = command == "rm" ? std::string("rm ") + (rmArgs.recursive ? "-r " : "") + p
: command == "du" ? "du " + p
: command + (force ? " -f " : " ") + p + "\t" + b; // a tab between two paths: either may hold a space
fileQueue.push_back({one, from});
}
return;
}
if (!storageService->state().present) why = "no SD card";
else if (command == "du") why = fileOps->count(rest);
else if (command == "mkdir") why = fileOps->makeFolder(rest);
@@ -575,7 +643,7 @@ static const char* const kHelp =
"reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\n"
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask)\n"
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)\n"
"screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause\n"
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
@@ -784,16 +852,21 @@ static void runCommand(String line, bool fromSerial = false) {
fileCommand(line);
if (line == "ls" || line.startsWith("ls ")) {
if (!storageService->state().present) return (void)console.println("sd: no card");
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/";
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/", pattern;
const std::string asked = path;
if (files::hasGlob(path) && !files::splitGlob(asked, path, pattern)) // /notes/*.txt: the folder, and what to show of it
return (void)console.println("ls: error a pattern goes in the last part of a path: /notes/*.txt");
Console::Origin from = console.origin();
storageService->runJob([path, from]() { // card access stays on the storage task
storageService->runJob([path, pattern, asked, from]() { // card access stays on the storage task
Console::As as(from);
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) return (void)console.printf("ls: %s is not a folder\n", path.c_str());
for (File f = dir.openNextFile(); f; f = dir.openNextFile())
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (!pattern.empty() && !files::globMatch(pattern, f.name())) continue;
console.printf("%10u %-16s %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(),
files::formatStamp(static_cast<uint32_t>(f.getLastWrite())).c_str(), f.name(), f.isDirectory() ? "/" : "");
console.printf("ls: end of %s\n", path.c_str());
}
console.printf("ls: end of %s\n", asked.c_str());
});
}
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
@@ -1191,6 +1264,8 @@ static std::vector<std::string> shellList(const std::string& folder, const std::
return names;
}
static int shellCount(const std::string& pattern, bool& more) { return static_cast<int>(expandGlob(pattern, more).size()); }
static void serialCommands() {
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
static String line;
@@ -1287,6 +1362,7 @@ static void loopPass() {
noteStableOnce(now);
updateStep();
fileOpsStep();
fileQueueStep();
uploadStep();
printListingWhenReady();
M5Cardputer.update();