Files
ObsidianDragon/src/ui/windows/console_input_model.cpp
T
DanSandClaude Opus 4.8 f7ed1ae115 perf(console): tail-cap large RPC results without a full copy
A large RPC result was fully split into lines and copied before being
handed to the console, even though only the last few lines could survive
the model's line cap.

- The formatter now takes a line cap and keeps only the last N lines,
  located by a backward newline scan, so a huge result is never fully
  split or copied. JSON classification still keys off the first
  character of the whole result.
- It works over a string view and moves line text through, and fold
  spans are computed over the formatted lines directly instead of a
  separate vector of copies.
- getline semantics are preserved: a trailing newline adds no empty
  line and an empty result yields no lines.

Tests: getline parity for empty, trailing-newline and blank-line inputs,
the backward tail cap, and JSON classification and fold spans over a
capped result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00

355 lines
13 KiB
C++

#include "console_input_model.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <sstream>
namespace dragonx {
namespace ui {
const std::vector<std::string>& ConsoleRpcCommandNames()
{
static const std::vector<std::string> commands = {
"help", "getinfo", "stop",
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
"addnode", "setban", "listbanned", "clearbanned", "ping",
"getblockchaininfo", "getblockcount", "getbestblockhash",
"getblock", "getblockhash", "getblockheader", "getdifficulty",
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
"getmininginfo", "setgenerate", "getgenerate",
"getnetworkhashps", "getblocksubsidy",
"getbalance", "z_gettotalbalance", "z_getbalances",
"getnewaddress", "z_getnewaddress",
"listaddresses", "z_listaddresses",
"sendtoaddress", "z_sendmany",
"listtransactions", "listunspent", "z_listunspent",
"z_getoperationstatus", "z_getoperationresult",
"getwalletinfo", "backupwallet",
"dumpprivkey", "importprivkey",
"z_exportkey", "z_importkey",
"signmessage", "settxfee",
"createrawtransaction", "decoderawtransaction",
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
"validateaddress", "z_validateaddress", "estimatefee",
"clear"
};
return commands;
}
void AppendConsoleHistory(std::vector<std::string>& history,
const std::string& command,
std::size_t maxEntries)
{
if (command.empty()) return;
if (history.empty() || history.back() != command) {
history.push_back(command);
while (history.size() > maxEntries) {
history.erase(history.begin());
}
}
}
int NavigateConsoleHistoryIndex(int currentIndex, std::size_t historySize, bool up)
{
if (historySize == 0) return -1;
if (up) {
if (currentIndex < 0) return static_cast<int>(historySize) - 1;
if (currentIndex > 0) return currentIndex - 1;
return currentIndex;
}
if (currentIndex >= 0) {
int next = currentIndex + 1;
if (next >= static_cast<int>(historySize)) return -1;
return next;
}
return currentIndex;
}
std::string ConsoleHistoryEntry(const std::vector<std::string>& history, int historyIndex)
{
if (historyIndex < 0 || historyIndex >= static_cast<int>(history.size())) return {};
return history[static_cast<std::size_t>(historyIndex)];
}
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input)
{
ConsoleCompletionResult result;
if (input.empty()) return result;
for (const auto& command : ConsoleRpcCommandNames()) {
if (command.compare(0, input.size(), input) == 0) {
result.matches.push_back(command);
}
}
if (result.matches.empty()) return result;
result.commonPrefix = result.matches.front();
for (std::size_t matchIndex = 1; matchIndex < result.matches.size(); ++matchIndex) {
const auto& match = result.matches[matchIndex];
std::size_t prefixLength = 0;
while (prefixLength < result.commonPrefix.size() &&
prefixLength < match.size() &&
result.commonPrefix[prefixLength] == match[prefixLength]) {
++prefixLength;
}
result.commonPrefix.resize(prefixLength);
}
return result;
}
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
std::size_t maxLineLength)
{
std::vector<std::string> lines;
std::string line = " ";
for (std::size_t matchIndex = 0; matchIndex < matches.size(); ++matchIndex) {
if (matchIndex > 0) line += " ";
line += matches[matchIndex];
if (line.length() > maxLineLength) {
lines.push_back(line);
line = " ";
}
}
if (line.length() > 2) {
lines.push_back(line);
}
return lines;
}
std::vector<ConsoleArg> ParseConsoleCommandArgsTagged(const std::string& command)
{
std::vector<ConsoleArg> args;
std::size_t index = 0;
while (index < command.size()) {
while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) {
++index;
}
if (index >= command.size()) break;
ConsoleArg arg;
if (command[index] == '"' || command[index] == '\'') {
arg.quoted = true;
char quote = command[index++];
while (index < command.size() && command[index] != quote) {
arg.text += command[index++];
}
if (index < command.size()) ++index;
} else if (command[index] == '[' || command[index] == '{') {
char open = command[index];
char close = (open == '[') ? ']' : '}';
int depth = 0;
while (index < command.size()) {
if (command[index] == open) ++depth;
else if (command[index] == close) --depth;
arg.text += command[index++];
if (depth == 0) break;
}
} else {
while (index < command.size() && command[index] != ' ' && command[index] != '\t') {
arg.text += command[index++];
}
}
// A quoted empty string ("") is a real argument; only skip genuinely-empty unquoted tokens.
if (!arg.text.empty() || arg.quoted) args.push_back(std::move(arg));
}
return args;
}
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
{
std::vector<std::string> args;
for (auto& a : ParseConsoleCommandArgsTagged(command)) args.push_back(std::move(a.text));
return args;
}
namespace {
// Interpret a bare token as a JSON number only when the WHOLE token is a valid, finite number;
// otherwise return it as a string. Guards against std::stoll silently truncating "1e9"/"123abc"
// (it stops at the first non-digit) and std::stod yielding inf/subnormal for out-of-range input.
nlohmann::json ParseConsoleNumberOrString(const std::string& s)
{
if (!s.empty()) {
try {
std::size_t pos = 0;
long long ll = std::stoll(s, &pos);
if (pos == s.size()) return ll;
} catch (...) {}
try {
std::size_t pos = 0;
double d = std::stod(s, &pos);
if (pos == s.size() && std::isfinite(d)) return d;
} catch (...) {}
}
return s;
}
} // namespace
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
{
auto args = ParseConsoleCommandArgsTagged(command);
ConsoleRpcCall call;
if (args.empty()) return call;
call.valid = true;
call.method = args.front().text;
for (std::size_t argIndex = 1; argIndex < args.size(); ++argIndex) {
const ConsoleArg& a = args[argIndex];
const std::string& arg = a.text;
// A quoted token is always a string — never coerced — so a genuinely-string argument that
// happens to look numeric (e.g. an all-digit label) is sent as a string, not a number.
if (a.quoted) {
call.params.push_back(arg);
continue;
}
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
auto parsed = nlohmann::json::parse(arg, nullptr, false);
if (!parsed.is_discarded()) {
call.params.push_back(parsed);
continue;
}
// Malformed JSON — fail the whole command instead of silently sending it as a string.
call.valid = false;
call.error = "Argument " + std::to_string(argIndex) + " is not valid JSON: " + arg;
return call;
}
if (arg == "true") {
call.params.push_back(true);
} else if (arg == "false") {
call.params.push_back(false);
} else {
call.params.push_back(ParseConsoleNumberOrString(arg));
}
}
return call;
}
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(std::string_view result,
bool isError,
std::size_t maxLines)
{
if (isError) {
std::string text;
text.reserve(7 + result.size());
text.append("Error: ").append(result);
return {{std::move(text), ConsoleResultLineRole::Error}};
}
std::vector<ConsoleResultLine> lines;
const std::string_view normalized = (result == "null") ? std::string_view("(no result)") : result;
if (normalized.empty()) return lines; // getline yields no lines for an empty string
const bool isJson = normalized[0] == '{' || normalized[0] == '[';
// getline parity: split on '\n'; one trailing '\n' terminates the last line (no empty extra).
std::size_t end = normalized.size();
if (normalized[end - 1] == '\n') --end;
// Tail cap: walk back until maxLines lines are covered; start just after the boundary newline.
std::size_t begin = 0;
if (maxLines > 0) {
std::size_t seen = 0;
for (std::size_t i = end; i-- > 0; ) {
if (normalized[i] == '\n' && ++seen == maxLines) { begin = i + 1; break; }
}
}
std::size_t pos = begin;
while (true) {
std::size_t nl = normalized.find('\n', pos);
if (nl == std::string_view::npos || nl > end) nl = end;
const std::string_view line = normalized.substr(pos, nl - pos);
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
if (isJson && !line.empty()) {
std::size_t first = line.find_first_not_of(" \t");
if (first != std::string_view::npos) {
const std::string_view trimmed = line.substr(first);
unsigned char firstChar = static_cast<unsigned char>(trimmed[0]);
if (trimmed[0] == '{' || trimmed[0] == '}' ||
trimmed[0] == '[' || trimmed[0] == ']') {
role = ConsoleResultLineRole::JsonBrace;
} else if (trimmed[0] == '"') {
if (trimmed.find("\":") != std::string_view::npos) {
role = ConsoleResultLineRole::JsonKey;
} else {
role = ConsoleResultLineRole::JsonString;
}
} else if (std::isdigit(firstChar) || trimmed[0] == '-') {
role = ConsoleResultLineRole::JsonNumber;
} else if (trimmed == "true," || trimmed == "false," ||
trimmed == "true" || trimmed == "false" ||
trimmed == "null," || trimmed == "null") {
role = ConsoleResultLineRole::JsonNumber;
}
}
}
lines.push_back({std::string(line), role});
if (nl >= end) break;
pos = nl + 1;
}
return lines;
}
namespace {
template <typename Lines, typename TextOf>
std::vector<int> computeFoldSpans(const Lines& lines, TextOf textOf)
{
std::vector<int> spans(lines.size(), 0);
auto leadingSpaces = [](const std::string& s) -> std::size_t {
std::size_t n = 0;
while (n < s.size() && s[n] == ' ') ++n;
return n;
};
auto lastNonSpace = [](const std::string& s) -> char {
for (std::size_t i = s.size(); i-- > 0; )
if (s[i] != ' ' && s[i] != '\t') return s[i];
return '\0';
};
auto firstNonSpace = [](const std::string& s) -> char {
for (char c : s)
if (c != ' ' && c != '\t') return c;
return '\0';
};
for (std::size_t i = 0; i < lines.size(); ++i) {
const std::string& li = textOf(lines[i]);
const char last = lastNonSpace(li);
if (last != '{' && last != '[') continue; // not a block opener
const std::size_t indent = leadingSpaces(li);
const char wantClose = (last == '{') ? '}' : ']';
// Matching closer = the next line at the same indentation whose first non-space char
// is the corresponding closing bracket (pretty-printed JSON has aligned brackets).
for (std::size_t j = i + 1; j < lines.size(); ++j) {
const std::string& lj = textOf(lines[j]);
if (leadingSpaces(lj) == indent && firstNonSpace(lj) == wantClose) {
if (j - i >= 2) spans[i] = static_cast<int>(j - i); // >=1 inner line
break;
}
}
}
return spans;
}
} // namespace
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
{
return computeFoldSpans(lines, [](const std::string& s) -> const std::string& { return s; });
}
std::vector<int> ComputeConsoleResultFoldSpans(const std::vector<ConsoleResultLine>& lines)
{
return computeFoldSpans(lines, [](const ConsoleResultLine& l) -> const std::string& { return l.text; });
}
} // namespace ui
} // namespace dragonx