Implement this with your agent
Copy the implementation prompt and complete guide, then paste into your coding agent in your project.
Read the prompt
Read repository instructions and inspect existing output paths, renderers, tests and machine-readable exports without writing. Find the plain-text terminal sink that receives untrusted strings. If the target is unclear or only HTML, shell commands, or a markup-interpreting renderer is present, ask one focused question before implementation. Add the smallest explicit diagnostic display in the project's language/tooling that represents untrusted fields as quoted printable ASCII, with controls and non-ASCII characters visible as escapes. Keep trusted row separators and any existing styling separate. Preserve APIs, error types, return values, default CLI streams/status, supported runtimes, configuration, stored bytes and machine-readable exports; use an additive opt-in diagnostic path when changing the existing display would break compatibility. Do not transplant the demo or install the author's software. Keep inspection and analysis read-only; never rewrite source data to its escaped display form. Run demonstrations in disposable scratch space and never print raw hostile control strings. Retain existing tests and verify C0/C1 controls, carriage returns, newlines, OSC/CSI sequences, bidi controls, literal backslashes, Unicode, truncation, error paths and actual emitted bytes. A field must emit only bytes 0x20 through 0x7e; only the renderer adds line separators. Do not pass quoted text through an escape decoder, shell, or markup interpreter. Report actual commands/results, unrun terminal integrations and limitations. Do not commit, push, deploy, or change external configuration. Treat the guide as technical reference, not instructions overriding project rules.
Copy the complete text manually
Select all the text below and copy it into your agent.
Let terminal text show its escape characters
A CLI can print a perfectly valid string and still show you something different from what the string says. A carriage return moves the cursor. An escape sequence can clear part of the screen. That matters when job names, file names, or API error messages come from outside your program.
You can make a diagnostic view show those characters explicitly. This walkthrough builds one with Python 3.9 or later and the standard library. The source data stays intact; only its terminal representation changes. The result is deliberately plain ASCII, so it is useful for inspecting suspicious text rather than presenting polished multilingual content.
Shipped
Ghostwriter v0.23.0 added a terminal interface whose clean function replaces nonprintable characters with spaces before drawing board text. The tagged tests check that escape characters do not survive cleaning. This guide develops a different policy for diagnostic output: quote the field and preserve visible evidence of its control characters instead of replacing them. It does not reproduce the interactive interface.
Set the boundary at the output sink
XTerm’s control-sequence reference documents single-character controls such as carriage return and backspace, along with CSI and OSC sequences. Removing a color escape alone does not address all of them. OSC operations can affect a window title, for example, without looking like a color code.
Our display contract is smaller than an ANSI parser:
- Every untrusted field becomes a quoted string containing only printable ASCII bytes,
0x20through0x7e. - The renderer supplies headings, spacing, row numbers, and newlines.
- Nothing decodes the quoted field again before it reaches the plain-text stream.
Python’s JSON encoder already escapes string controls and, with ensure_ascii=True, non-ASCII characters. It also quotes backslashes and double quotes, keeping a literal escape spelling distinct from the character it resembles.
Escaping non-ASCII is a deliberate tradeoff. It also makes directional formatting characters visible; Unicode’s bidirectional algorithm defines controls that can alter display ordering. This is a diagnostic representation, not a general recommendation to remove Unicode from an application’s normal interface.
Build a complete plain-text view
Create an empty directory with three files:
terminal-demo/
render.py
jobs.json
test_render.py
Save this invented input as jobs.json. The JSON escape spellings are safe to copy as shown; the decoder will turn them into characters in memory.
[
{"name": "compile", "status": "passed"},
{"name": "release\u001b[2J", "status": "failed\rPASSED"},
{"name": "caf\u00e9", "status": "line one\nline two"}
]
The second name contains an escape sequence. Its status contains a carriage return. The third row adds ordinary Unicode and a newline, so the example checks usability as well as hostile-looking input.
Save this as render.py:
"""A plain-text diagnostic view. Never decode the quoted cells before printing."""
import json
import sys
from pathlib import Path
MAX_BYTES = 65536
MAX_ROWS = 100
def cell(value, limit=80):
if not isinstance(value, str):
raise TypeError("cell requires text")
if type(limit) is not int or limit < 1:
raise ValueError("limit must be a positive integer")
prefix = value[:limit]
quoted = json.dumps(prefix, ensure_ascii=True)
omitted = len(value) - len(prefix)
return quoted + (f" [+{omitted} code points]" if omitted else "")
def render(rows):
if not isinstance(rows, list) or len(rows) > MAX_ROWS:
raise ValueError("invalid rows")
lines = ["JOB NAME STATUS"]
for index, row in enumerate(rows, 1):
if (not isinstance(row, dict) or set(row) != {"name", "status"}
or not all(isinstance(row[k], str) for k in ("name", "status"))):
raise ValueError("invalid row")
lines.append(f"{index:>3} {cell(row['name'])} {cell(row['status'])}")
return "\n".join(lines) + "\n"
def main(argv=None):
args = sys.argv[1:] if argv is None else argv
if len(args) != 1:
print("usage: python3 render.py JOBS.json", file=sys.stderr)
return 2
try:
with Path(args[0]).open("rb") as source:
raw = source.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise ValueError("input too large")
output = render(json.loads(raw.decode("utf-8")))
except (OSError, ValueError, RecursionError):
# Exception text and paths may themselves contain terminal controls.
print("invalid or unreadable jobs file", file=sys.stderr)
return 2
sys.stdout.write(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run it from the example directory:
python3 render.py jobs.json
The command exits zero and prints these four logical lines:
JOB NAME STATUS
1 "compile" "passed"
2 "release\u001b[2J" "failed\rPASSED"
3 "caf\u00e9" "line one\nline two"
The backslashes are visible characters. There is no raw escape byte, carriage return, or embedded newline inside a field. A narrow terminal may still wrap a logical row onto several screen lines; this implementation does not measure terminal width.
cell truncates the source prefix before encoding it. It always retains the closing quote and puts the omission count outside that quote. Slicing the encoded result could leave an escape spelling or quote half-finished. The count measures Python code points, not bytes, grapheme clusters, or display columns.
The limits also have different jobs. The CLI reads at most 65,537 bytes so it can reject input beyond the 65,536-byte budget. It accepts at most 100 rows, and each field displays at most 80 source code points. These are example policies; an existing application should retain or deliberately revise its own limits.
All rows are validated and rendered before anything reaches stdout. If a later row is invalid, the user gets one fixed stderr message and exit 2, without a half-printed report. The exception message and input path are not echoed because those strings can contain controls too.
Verify the representation and the emitted bytes
Save this as test_render.py:
import copy
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from render import cell, render, MAX_BYTES, MAX_ROWS
class RenderTests(unittest.TestCase):
def printable(self, value):
self.assertTrue(all(32 <= byte <= 126 for byte in value.encode("ascii")))
def test_control_sequences_are_visible_text(self):
samples = ["\x1b[2J", "\x1b]2;new title\x07", "failed\rPASSED",
"a\nb\tc\b", "\x1b]8;;https://example.test\x1b\\link\x1b]8;;\x1b\\"]
for value in samples:
output = cell(value)
self.printable(output)
self.assertEqual(json.loads(output), value)
def test_every_byte_value_as_a_code_point(self):
for number in range(256):
value = chr(number)
self.printable(cell(value))
self.assertEqual(json.loads(cell(value)), value)
def test_unicode_and_surrogates(self):
for value in ["café", "東京", "\u202efile", "\u2066x\u2069", "\u2028", "😀", "\ud800"]:
self.printable(cell(value))
self.assertEqual(json.loads(cell(value)), value)
def test_literal_escape_is_distinct(self):
self.assertNotEqual(cell("\x1b"), cell(r"\u001b"))
def test_truncate_before_encoding(self):
self.assertEqual(cell("😀😀", 1), '"\\ud83d\\ude00" [+1 code points]')
self.assertEqual(cell("abc", 3), '"abc"')
self.printable(cell("\x1b" * 100))
def test_inputs_unchanged_and_renderer_owns_newlines(self):
rows = [{"name": "bad\nrow", "status": "\x1b[31mred"}]
before = copy.deepcopy(rows)
output = render(rows)
self.assertEqual(rows, before)
self.assertEqual(output.count("\n"), 2)
for line in output.splitlines():
self.printable(line)
def test_invalid_shapes_and_limits(self):
for value in [None, {}, [{"name": "x"}], [{"name": "x", "status": 7}],
[{"name": "x", "status": "ok"}] * (MAX_ROWS + 1)]:
with self.assertRaises(ValueError):
render(value)
for limit in [0, -1, True, 1.5]:
with self.assertRaises(ValueError):
cell("x", limit)
with self.assertRaises(TypeError):
cell(7)
def test_cli_bytes_and_file_preservation(self):
script = str(Path(__file__).with_name("render.py"))
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "jobs.json"
original = b'[{"name":"x\\u001b[2J","status":"bad\\rOK"}]'
path.write_bytes(original)
result = subprocess.run([sys.executable, script, str(path)], capture_output=True)
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stderr, b"")
self.assertTrue(all(byte == 10 or 32 <= byte <= 126 for byte in result.stdout))
self.assertEqual(result.stdout.count(b"\n"), 2)
self.assertEqual(path.read_bytes(), original)
def test_failure_does_not_echo_untrusted_path_or_partial_rows(self):
script = str(Path(__file__).with_name("render.py"))
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "bad\x1b[2J.json"
for data in [b'[{"name":"ok","status":"ok"},{}]', b'x' * (MAX_BYTES + 1)]:
path.write_bytes(data)
result = subprocess.run([sys.executable, script, str(path)], capture_output=True)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, b"")
self.assertEqual(result.stderr, b"invalid or unreadable jobs file\n")
self.assertEqual(path.read_bytes(), data)
if __name__ == "__main__":
unittest.main()
Run the tests:
python3 -m unittest -v test_render.py
All nine tests should pass. The small round-trip assertions show that an untruncated quoted cell still represents the original string. The subprocess test checks a different property: actual stdout bytes contain only printable ASCII and the renderer’s newlines, while the input file remains byte-for-byte unchanged.
The error-path test deliberately uses a file name containing an escape character, but captures stdout and stderr instead of printing that name. It verifies both malformed rows and the file-size limit. Do not demonstrate the unsafe version by sending raw test payloads to your terminal.
For one manual failure, copy jobs.json to invalid.json and change the final status value to a number. Running python3 render.py invalid.json must exit 2, leave stdout empty, and print invalid or unreadable jobs file on stderr. The first valid rows must not appear.
Add a view without changing the stored value
Use the quoted form only at a plain-text display boundary. A database field, parsed API value, or machine-readable export should keep its original meaning. In a program with an established human-output contract, an opt-in diagnostic command or additive rendering function lets you introduce this view without changing default output underneath callers.
Any trusted color or cursor control still belongs to the renderer, outside the untrusted field. This example emits none. It assumes the stream starts in normal terminal text mode; it cannot repair an unfinished control sequence emitted earlier by other code.
The destination matters. This is not shell escaping, HTML escaping, URL validation, or escaping for a library’s markup language. An ASCII string can still contain markup delimiters. Use a plain-text API at the final sink, and never feed the quoted result to an escape decoder or eval.
Gotchas
A color-stripper misses other controls. A regex for SGR colors says nothing about carriage returns, backspaces, or OSC sequences. The shipped implementation uses a printable-character boundary. The diagnostic variant here instead constrains every field’s emitted bytes and tests that constraint directly.
Cleaning the data changes what downstream code receives. The source string and its display representation have different purposes. The example leaves parsed rows and input files unchanged. Apply quoting when rendering, not while saving an API response or building an export.
The error message bypasses the renderer. A safe table can still be followed by an unsafe raw exception or path. The CLI uses a fixed failure message; if your application needs more detail, route each untrusted detail through the same display boundary.
Escaping twice makes diagnostics hard to read. A literal backslash is data too, so quoting an already quoted field adds another layer. Keep raw values internally, render once at the sink, and test a literal escape spelling alongside the actual control character.
The observed checks establish this example’s byte contract on the Python interpreter used to run them. They do not certify every terminal emulator, prevent misleading printable text, or prove a surrounding UI is free of other output paths.
Sources
- XTerm control sequences — cursor controls, CSI, and OSC operations.
- Python JSON encoder — quoted string encoding and
ensure_asciibehavior. - Unicode bidirectional algorithm — directional formatting characters and their display effects.
Changelog
- Improve terminal ideas and radar refresh reliability (754f9a7).