Release WN (v0.51.633): collapsed tool cards preview result not call args (#4851, closes #4752)
This commit is contained in:
6 files changed
+312
-55
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.633] — 2026-06-24 — Release WN (collapsed tool cards preview the result, not the call args)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A completed tool card's collapsed header now previews the tool's result instead of its call arguments.** For a finished tool, the one-line header showed the call args (e.g. `path=src/`) rather than what actually happened (e.g. `Found 3 matches`). Completed tools now surface a one-line result preview (from the tool's preview/snippet), suppressing JSON object/array blobs and capping length, while running tools and the args-preview fallback are unchanged. The preview is escaped on render (no XSS). Thanks @rodboev. (#4851, closes #4752)
|
||||
|
||||
## [v0.51.632] — 2026-06-24 — Release WM (new conversations use your configured default model)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -12903,9 +12903,21 @@ function _formatToolArgPreview(args){
|
||||
const out=parts.join(' · ');
|
||||
return out.length>180?`${out.slice(0,177)}…`:out;
|
||||
}
|
||||
function _toolResultOneLiner(preview){
|
||||
if(!preview) return '';
|
||||
const first=preview.split('\n').find(l=>l.trim())||'';
|
||||
const trimmed=first.trim();
|
||||
if(!trimmed) return '';
|
||||
if(trimmed[0]==='{') return '';
|
||||
if(trimmed[0]==='['){try{JSON.parse(trimmed);return '';}catch(e){/* not JSON */}}
|
||||
return trimmed.length>180?trimmed.slice(0,177)+'…':trimmed;
|
||||
}
|
||||
function _toolCardPreviewText(tc, displaySnippet){
|
||||
const explicitPreview=String(tc&&tc.preview||'').trim();
|
||||
if(tc&&tc.done===false&&explicitPreview) return explicitPreview;
|
||||
const resultSource=explicitPreview||String(tc&&tc.snippet||'').trim();
|
||||
const resultLine=_toolResultOneLiner(resultSource);
|
||||
if(tc&&tc.done!==false&&resultLine) return resultLine;
|
||||
const argPreview=_formatToolArgPreview(tc&&tc.args);
|
||||
if(argPreview) return argPreview;
|
||||
if(tc&&tc.done===false) return 'Running';
|
||||
|
||||
@@ -112,6 +112,7 @@ def test_rendered_apply_patch_tool_card_html_contains_diff_lines():
|
||||
"_toolArgPreviewValue",
|
||||
"_toolArgPreviewKeyIsHidden",
|
||||
"_formatToolArgPreview",
|
||||
"_toolResultOneLiner",
|
||||
"_toolCardPreviewText",
|
||||
# #3336: buildToolCard now wraps diff snippets via these helpers.
|
||||
"_snippetLooksLikeDiff",
|
||||
|
||||
@@ -23,6 +23,7 @@ shims, and runs the same render path live streaming and persisted reload share
|
||||
(both go through _decorateTransparentEventRow(buildToolCard(tc))).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -35,11 +36,77 @@ UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
|
||||
NODE = shutil.which("node")
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
_FN_NAMES = [
|
||||
'_toolDisplayName', '_toolActionKind', '_toolKindIcon', '_toolPathBasename',
|
||||
'_decodeToolLabelEntities', '_redactToolTargetLabel', '_shortToolLabel', '_toolI18n',
|
||||
'_toolTargetLabel', '_toolVisibleTargetLabel', '_toolCommandTitle', '_toolQueryTitle',
|
||||
'_toolActionLabelText', '_toolArgPreviewValue', '_toolArgPreviewKeyIsHidden',
|
||||
'_formatToolArgPreview', '_toolResultOneLiner', '_toolCardPreviewText', '_toolCardAllowsDetail',
|
||||
'_toolDetailLeadLabel', '_toolDetailLeadText', '_toolShortName', '_transparentEventPreview',
|
||||
'_transparentToolStatus', '_transparentToolSummary',
|
||||
'_isMemorySave', '_isSkillUpdate', '_tcAction',
|
||||
'buildToolCard', '_decorateTransparentEventRow',
|
||||
]
|
||||
|
||||
_DRIVER_SRC = r"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(process.argv[2], 'utf8');
|
||||
|
||||
def _function_source(src: str, name: str) -> str:
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
||||
if not match:
|
||||
return ""
|
||||
brace = src.find("{", match.end())
|
||||
assert brace != -1, f"{name}() has no body"
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
in_string = None
|
||||
escaped = False
|
||||
in_line_comment = False
|
||||
in_block_comment = False
|
||||
while i < len(src) and depth:
|
||||
ch = src[i]
|
||||
nxt = src[i + 1] if i + 1 < len(src) else ""
|
||||
if in_line_comment:
|
||||
if ch == "\n":
|
||||
in_line_comment = False
|
||||
i += 1
|
||||
continue
|
||||
if in_block_comment:
|
||||
if ch == "*" and nxt == "/":
|
||||
in_block_comment = False
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == in_string:
|
||||
in_string = None
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and nxt == "/":
|
||||
in_line_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch == "/" and nxt == "*":
|
||||
in_block_comment = True
|
||||
i += 2
|
||||
continue
|
||||
if ch in "'\"`":
|
||||
in_string = ch
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
assert depth == 0, f"{name}() body did not close"
|
||||
return src[match.start():i]
|
||||
|
||||
|
||||
_DRIVER_TEMPLATE = r"""
|
||||
// ── Minimal DOM shims (enough for buildToolCard + _decorateTransparentEventRow)
|
||||
function makeEl(tag){
|
||||
const el = {
|
||||
@@ -61,7 +128,6 @@ function makeEl(tag){
|
||||
get innerHTML(){return el._html;},
|
||||
set innerHTML(v){
|
||||
el._html=String(v);
|
||||
// Parse just the spans we assert on out of the template buildToolCard emits.
|
||||
el.children=[];
|
||||
const mk=(cls,text)=>{const c=makeEl('span'); c._classes=new Set(cls.split(' ').filter(Boolean)); c.textContent=text; c.parentNode=el; el.children.push(c);};
|
||||
const grab=(cls)=>{const re=new RegExp('<span class="'+cls.replace(/[-]/g,'\\-')+'">([\\s\\S]*?)<\\/span>'); const m=el._html.match(re); return m?m[1]:null;};
|
||||
@@ -70,7 +136,6 @@ function makeEl(tag){
|
||||
const preview=grab('tool-card-preview');
|
||||
if(preview!==null) mk('tool-card-preview', preview);
|
||||
const header=makeEl('div'); header._classes=new Set(['tool-card-header']);
|
||||
// re-home the parsed spans under a header element so querySelector('.tool-card-header') works
|
||||
header.children=el.children.slice(); header.children.forEach(c=>c.parentNode=header);
|
||||
const card=makeEl('div'); card._classes=new Set(['tool-card']); card.appendChild(header);
|
||||
el.children=[card]; el.firstChild=card;
|
||||
@@ -87,8 +152,8 @@ function makeEl(tag){
|
||||
return el;
|
||||
}
|
||||
|
||||
global.document = { createElement: (t)=>makeEl(t), querySelectorAll:()=>[], querySelector:()=>null };
|
||||
global.window = {};
|
||||
global.document = { createElement: (t)=>makeEl(t), querySelectorAll:()=>[], querySelector:()=>null, addEventListener:()=>{}, removeEventListener:()=>{} };
|
||||
global.window = { addEventListener:()=>{}, removeEventListener:()=>{} };
|
||||
global.CSS = { escape: s=>s };
|
||||
global.t = undefined;
|
||||
global.li = () => '<svg></svg>';
|
||||
@@ -100,36 +165,10 @@ global._attachCopyButton = () => {};
|
||||
global._attachProgressBar = () => {};
|
||||
global._wireTransparentHeaderToggle = () => {};
|
||||
global._transparentToolDetailHtml = () => '<div class="tool-card-detail"></div>';
|
||||
// _toolDisclosureIdentity pulls in worklog hashing helpers irrelevant to the
|
||||
// collapsed-preview assertion; stub it (only feeds a data- attribute).
|
||||
global._toolDisclosureIdentity = () => '';
|
||||
|
||||
function extractFunc(name){
|
||||
const re=new RegExp('function\\s+'+name+'\\s*\\(');
|
||||
const start=src.search(re); if(start<0) throw new Error(name+' not found');
|
||||
let i=src.indexOf('{',start); let depth=1; i++;
|
||||
while(depth>0&&i<src.length){ if(src[i]==='{')depth++; else if(src[i]==='}')depth--; i++; }
|
||||
return src.slice(start,i);
|
||||
}
|
||||
// helpers buildToolCard / decorate depend on — eval'd at TOP LEVEL (a function
|
||||
// declaration eval'd inside a callback would be scoped to that callback and
|
||||
// lost, so collect all sources and eval them once here).
|
||||
var _FN_NAMES=[
|
||||
'_toolDisplayName','_toolActionKind','_toolKindIcon','_toolPathBasename',
|
||||
'_decodeToolLabelEntities','_redactToolTargetLabel','_shortToolLabel','_toolI18n',
|
||||
'_toolTargetLabel','_toolVisibleTargetLabel','_toolCommandTitle','_toolQueryTitle',
|
||||
'_toolActionLabelText','_toolArgPreviewValue','_toolArgPreviewKeyIsHidden',
|
||||
'_formatToolArgPreview','_toolCardPreviewText','_toolCardAllowsDetail',
|
||||
'_toolDetailLeadLabel','_toolDetailLeadText','_toolShortName','_transparentEventPreview',
|
||||
'_transparentToolStatus','_transparentToolSummary',
|
||||
'_isMemorySave','_isSkillUpdate','_tcAction',
|
||||
'buildToolCard','_decorateTransparentEventRow',
|
||||
];
|
||||
var _FN_SRC='';
|
||||
for(var _i=0;_i<_FN_NAMES.length;_i++){
|
||||
try{ _FN_SRC+=extractFunc(_FN_NAMES[_i])+'\n'; }catch(e){ /* optional helper absent */ }
|
||||
}
|
||||
eval(_FN_SRC);
|
||||
// ── EXTRACTED_FUNCTIONS placeholder (replaced by Python) ──
|
||||
%%EXTRACTED_FUNCTIONS%%
|
||||
|
||||
function previewFor(tc){
|
||||
const row=_decorateTransparentEventRow(buildToolCard(tc),{
|
||||
@@ -142,7 +181,7 @@ function previewFor(tc){
|
||||
return { preview: preview?preview.textContent:null, name: name?name.textContent:null };
|
||||
}
|
||||
|
||||
const cases = JSON.parse(process.argv[3]);
|
||||
const cases = JSON.parse(process.argv[2]);
|
||||
const out = {};
|
||||
for(const [key,tc] of Object.entries(cases)) out[key]=previewFor(tc);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -162,10 +201,16 @@ CASES = {
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results(tmp_path_factory):
|
||||
ui_src = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
extracted = "\n".join(
|
||||
_function_source(ui_src, name) for name in _FN_NAMES
|
||||
if _function_source(ui_src, name)
|
||||
)
|
||||
driver_src = _DRIVER_TEMPLATE.replace("%%EXTRACTED_FUNCTIONS%%", extracted)
|
||||
driver = tmp_path_factory.mktemp("t4658") / "driver.js"
|
||||
driver.write_text(_DRIVER_SRC, encoding="utf-8")
|
||||
driver.write_text(driver_src, encoding="utf-8")
|
||||
proc = subprocess.run(
|
||||
[NODE, str(driver), str(UI_JS_PATH), json.dumps(CASES)],
|
||||
[NODE, str(driver), json.dumps(CASES)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Regression tests for issue #4752 — collapsed tool card shows call result, not args.
|
||||
|
||||
Extracts _toolResultOneLiner, _toolCardPreviewText, and _formatToolArgPreview from
|
||||
static/ui.js by line range and runs them via Node.js to verify the fix.
|
||||
|
||||
Before the fix, _toolCardPreviewText always fell through to argPreview for completed
|
||||
tools, so 'Found 3 matches' would show as 'path=src/' in the collapsed header.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
NODE = shutil.which("node")
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def _extract_js_block():
|
||||
"""Extract the tool-card preview functions from ui.js by finding their definition range."""
|
||||
lines = open(UI_JS, encoding='utf-8').readlines()
|
||||
|
||||
# Find start line of _toolArgPreviewValue (the earliest dependency)
|
||||
start_line = None
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r'^function _toolArgPreviewValue\(', line):
|
||||
start_line = i
|
||||
break
|
||||
if start_line is None:
|
||||
raise AssertionError("_toolArgPreviewValue not found in ui.js")
|
||||
|
||||
# Find end line of _toolCardPreviewText (the last function we need)
|
||||
# It ends at the function _toolCardAllowsDetail that follows it
|
||||
end_line = None
|
||||
for i in range(start_line, len(lines)):
|
||||
if re.search(r'^function _toolCardAllowsDetail\(', lines[i]):
|
||||
end_line = i
|
||||
break
|
||||
if end_line is None:
|
||||
raise AssertionError("_toolCardAllowsDetail (end sentinel) not found in ui.js")
|
||||
|
||||
return "".join(lines[start_line:end_line])
|
||||
|
||||
|
||||
def _run_js(js_body, *args):
|
||||
"""Run a Node.js snippet with optional JSON arguments, return stdout."""
|
||||
fn_defs = _extract_js_block()
|
||||
js_code = fn_defs + "\n" + js_body
|
||||
tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8')
|
||||
tf.write(js_code)
|
||||
tf.close()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[NODE, tf.name] + [json.dumps(a) for a in args],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"node error: {result.stderr}")
|
||||
return result.stdout
|
||||
finally:
|
||||
os.unlink(tf.name)
|
||||
|
||||
|
||||
def _preview_text(tc):
|
||||
"""Call _toolCardPreviewText(tc) via Node.js."""
|
||||
return _run_js(
|
||||
"var tc=JSON.parse(process.argv[2]);\n"
|
||||
"process.stdout.write(String(_toolCardPreviewText(tc)));\n",
|
||||
tc
|
||||
)
|
||||
|
||||
|
||||
def _result_one_liner(preview):
|
||||
"""Call _toolResultOneLiner(preview) via Node.js."""
|
||||
return _run_js(
|
||||
"var p=JSON.parse(process.argv[2]);\n"
|
||||
"process.stdout.write(String(_toolResultOneLiner(p)));\n",
|
||||
preview
|
||||
)
|
||||
|
||||
|
||||
class TestToolResultOneLiner:
|
||||
"""Unit tests for the _toolResultOneLiner helper."""
|
||||
|
||||
def test_empty_string_returns_empty(self):
|
||||
assert _result_one_liner("") == ""
|
||||
|
||||
def test_plain_text_returned(self):
|
||||
assert _result_one_liner("Found 3 matches") == "Found 3 matches"
|
||||
|
||||
def test_json_object_suppressed(self):
|
||||
assert _result_one_liner('{"key":"val"}') == ""
|
||||
|
||||
def test_json_array_suppressed(self):
|
||||
assert _result_one_liner('[1,2,3]') == ""
|
||||
|
||||
def test_bracket_prefixed_non_json_shown(self):
|
||||
assert _result_one_liner('[INFO] 3 files created') == "[INFO] 3 files created"
|
||||
|
||||
def test_multiline_collapses_to_first_nonempty_line(self):
|
||||
assert _result_one_liner("Line 1\nLine 2\nLine 3") == "Line 1"
|
||||
|
||||
def test_leading_blank_lines_skipped(self):
|
||||
assert _result_one_liner("\n\nActual result") == "Actual result"
|
||||
|
||||
def test_truncation_at_180_chars(self):
|
||||
long_line = "x" * 200
|
||||
result = _result_one_liner(long_line)
|
||||
assert result.endswith("…")
|
||||
# 177 ASCII chars + 1 ellipsis character = 178 chars total
|
||||
assert len(result) == 178
|
||||
|
||||
def test_short_line_not_truncated(self):
|
||||
line = "x" * 180
|
||||
result = _result_one_liner(line)
|
||||
assert len(result) == 180
|
||||
assert not result.endswith("…")
|
||||
|
||||
|
||||
class TestToolCardPreviewText:
|
||||
"""Integration tests for the updated _toolCardPreviewText function."""
|
||||
|
||||
def test_result_wins_on_completion(self):
|
||||
"""Completed tool with both preview and args returns the preview result."""
|
||||
tc = {"done": True, "preview": "Found 3 matches", "args": {"path": "src/"}}
|
||||
assert _preview_text(tc) == "Found 3 matches"
|
||||
|
||||
def test_args_fallback_when_no_preview(self):
|
||||
"""Completed tool without preview falls back to arg preview."""
|
||||
tc = {"done": True, "preview": "", "args": {"path": "src/"}}
|
||||
result = _preview_text(tc)
|
||||
assert "path" in result
|
||||
assert "src" in result
|
||||
|
||||
def test_json_preview_suppressed_falls_through_to_args(self):
|
||||
"""JSON body in preview is suppressed; falls back to arg preview."""
|
||||
tc = {"done": True, "preview": '{"key":"val"}', "args": {"path": "src/"}}
|
||||
result = _preview_text(tc)
|
||||
# JSON suppressed, so falls through to arg preview
|
||||
assert result != '{"key":"val"}'
|
||||
assert "path" in result or "src" in result
|
||||
|
||||
def test_array_preview_suppressed_falls_through_to_args(self):
|
||||
"""Array body in preview is suppressed; falls back to arg preview."""
|
||||
tc = {"done": True, "preview": "[1,2,3]", "args": {"path": "src/"}}
|
||||
result = _preview_text(tc)
|
||||
assert result != "[1,2,3]"
|
||||
assert "path" in result or "src" in result
|
||||
|
||||
def test_multiline_preview_collapses_to_first_line(self):
|
||||
"""Multiline preview collapses to first non-empty line."""
|
||||
tc = {"done": True, "preview": "Line 1\nLine 2\nLine 3", "args": {}}
|
||||
assert _preview_text(tc) == "Line 1"
|
||||
|
||||
def test_running_preview_unchanged(self):
|
||||
"""Running tool (done===false) returns the explicit preview unchanged."""
|
||||
tc = {"done": False, "preview": "Searching...", "args": {"path": "src/"}}
|
||||
assert _preview_text(tc) == "Searching..."
|
||||
|
||||
def test_long_preview_truncated(self):
|
||||
"""Single-line preview >180 chars is truncated with ellipsis."""
|
||||
long_preview = "x" * 200
|
||||
tc = {"done": True, "preview": long_preview, "args": {}}
|
||||
result = _preview_text(tc)
|
||||
assert result.endswith("…")
|
||||
assert len(result) == 178 # 177 chars + ellipsis
|
||||
|
||||
def test_no_args_no_preview_returns_completed(self):
|
||||
"""Completed tool with no args and no preview returns 'Completed'."""
|
||||
tc = {"done": True, "preview": "", "args": {}}
|
||||
assert _preview_text(tc) == "Completed"
|
||||
|
||||
def test_error_state_returns_failed(self):
|
||||
"""Error tool with no preview or args returns 'Failed'."""
|
||||
tc = {"done": True, "is_error": True, "preview": "", "args": {}}
|
||||
assert _preview_text(tc) == "Failed"
|
||||
|
||||
def test_running_no_preview_returns_running(self):
|
||||
"""Running tool with no preview returns 'Running'."""
|
||||
tc = {"done": False, "preview": "", "args": {}}
|
||||
assert _preview_text(tc) == "Running"
|
||||
|
||||
def test_snippet_fallback_on_cold_load(self):
|
||||
"""Cold-loaded tool with result in snippet (no preview) uses snippet."""
|
||||
tc = {"done": True, "preview": "", "snippet": "Found 3 matches", "args": {"path": "src/"}}
|
||||
assert _preview_text(tc) == "Found 3 matches"
|
||||
|
||||
def test_error_with_text_snippet_shows_message(self):
|
||||
"""Error tool with a plain-text snippet shows the error message, not 'Failed'."""
|
||||
tc = {"done": True, "is_error": True, "preview": "", "snippet": "Permission denied", "args": {}}
|
||||
assert _preview_text(tc) == "Permission denied"
|
||||
|
||||
def test_snippet_json_suppressed(self):
|
||||
"""Cold-loaded tool with JSON in snippet falls through to args."""
|
||||
tc = {"done": True, "preview": "", "snippet": '{"key":"val"}', "args": {"path": "src/"}}
|
||||
result = _preview_text(tc)
|
||||
assert result != '{"key":"val"}'
|
||||
assert "path" in result or "src" in result
|
||||
@@ -21,24 +21,14 @@ pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
_DRIVER_SRC = r"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(process.argv[2], 'utf8');
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
return src.slice(start, i);
|
||||
const lines = fs.readFileSync(process.argv[2], 'utf8').split('\n');
|
||||
let startIdx = -1, endIdx = lines.length;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^function _toolArgPreviewValue\(/.test(lines[i]) && startIdx < 0) startIdx = i;
|
||||
if (/^function _toolCardAllowsDetail\(/.test(lines[i])) { endIdx = i; break; }
|
||||
}
|
||||
eval(extractFunc('_toolArgPreviewValue'));
|
||||
eval(extractFunc('_toolArgPreviewKeyIsHidden'));
|
||||
eval(extractFunc('_formatToolArgPreview'));
|
||||
eval(extractFunc('_toolCardPreviewText'));
|
||||
if (startIdx < 0) throw new Error('_toolArgPreviewValue not found');
|
||||
eval(lines.slice(startIdx, endIdx).join('\n'));
|
||||
let buf = '';
|
||||
process.stdin.on('data', c => { buf += c; });
|
||||
process.stdin.on('end', () => {
|
||||
|
||||
Reference in new issue
Block a user