From f7384b54986fc55588b43aa887733f5757a7e2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Andr=C3=A9=20Vadla=20Ravn=C3=A5s?= Date: Wed, 17 Jun 2026 12:58:42 +0200 Subject: [PATCH] repl: Render evaluated values richly Encode each result into a bounded tagged tree in the agent so serializing cyclic or huge objects can never hang, then pretty-print it host-side with syntax highlighting, depth and breadth caps, and a hexdump for binary data. --- agents/repl/agent.ts | 8 +- agents/repl/meson.build | 1 + agents/repl/value.ts | 371 ++++++++++++++++++++++++++++++++++++ frida_tools/repl.py | 35 ++-- frida_tools/repl_inspect.py | 315 ++++++++++++++++++++++++++++++ 5 files changed, 705 insertions(+), 25 deletions(-) create mode 100644 agents/repl/value.ts create mode 100644 frida_tools/repl_inspect.py diff --git a/agents/repl/agent.ts b/agents/repl/agent.ts index 86f8b97..c2c0952 100644 --- a/agents/repl/agent.ts +++ b/agents/repl/agent.ts @@ -1,3 +1,5 @@ +import { encodeValue } from "./value.js"; + class REPL { #quickCommands = new Map(); @@ -100,10 +102,10 @@ function evaluate(func: () => any) { const result = func(); if (result instanceof ArrayBuffer) { return result; - } else { - const type = (result === null) ? "null" : typeof result; - return [type, result]; } + const type = (result === null) ? "null" : typeof result; + const [tree, blob] = encodeValue(result, { maxDepth: 5 }); + return ["inspect", type, tree, (blob !== null) ? Array.from(new Uint8Array(blob)) : null]; } catch (exception) { const e = exception as Error; return ["error", { diff --git a/agents/repl/meson.build b/agents/repl/meson.build index 339bf53..aa1c490 100644 --- a/agents/repl/meson.build +++ b/agents/repl/meson.build @@ -2,6 +2,7 @@ entrypoint = 'agent.ts' sources = [ entrypoint, + 'value.ts', 'package.json', 'package-lock.json', 'tsconfig.json', diff --git a/agents/repl/value.ts b/agents/repl/value.ts new file mode 100644 index 0000000..2712dc5 --- /dev/null +++ b/agents/repl/value.ts @@ -0,0 +1,371 @@ +// Turn an arbitrary value into a bounded, JSON-safe tagged tree (plus a binary +// blob holding any byte buffers), so that serializing it can never hang or blow +// up on cyclic or very large objects such as a ControlFlowGraph. The REPL host +// decodes and pretty-prints the tree. + +export interface EncodeOptions { + maxDepth?: number; +} + +const DEFAULT_MAX_DEPTH = 3; + +export type PackedValue = [EncodedValueTree, ArrayBuffer | null]; + +export type EncodedValueTree = EncodedValue; +type EncodedValue = [ValueTag, ...any[]]; + +type BytesEncoded = [tag: ValueTag.Bytes, offset: number, length: number, kind: BytesKind]; +type BytesKind = "ArrayBuffer" | "DataView" | TypedArrayName; + +const enum ValueTag { + Number = 0, + String = 1, + Object = 2, + Array = 3, + NativePointer = 4, + Null = 5, + Boolean = 6, + Bytes = 7, + Function = 8, + Error = 9, + Undefined = 10, + BigInt = 11, + Symbol = 12, + Date = 13, + RegExp = 14, + Map = 15, + Set = 16, + Promise = 17, + WeakMap = 18, + WeakSet = 19, + DepthLimit = 20, + Circular = 21, +} + +type TypedArrayName = + | "Int8Array" + | "Uint8Array" + | "Uint8ClampedArray" + | "Int16Array" + | "Uint16Array" + | "Int32Array" + | "Uint32Array" + | "Float32Array" + | "Float64Array" + | "BigInt64Array" + | "BigUint64Array"; + +interface EncodeState { + seen: Map; + nextId: number; +} + +export function encodeValue(value: unknown, options?: EncodeOptions): PackedValue { + const chunks: BinaryChunk[] = []; + const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH; + const state = makeEncodeState(); + const tree = collectTree(value, chunks, maxDepth, 0, state); + const { blob, offsets } = packChunks(chunks); + const patchedTree = patchTree(tree, offsets); + return [patchedTree, blob]; +} + +function makeEncodeState(): EncodeState { + return { + seen: new Map(), + nextId: 1 + }; +} + +function collectTree( + value: unknown, + chunks: BinaryChunk[], + maxDepth: number, + depth: number, + state: EncodeState +): EncodedValueTree { + if (value === null) { + return [ValueTag.Null]; + } + + const t = typeof value; + + if (t === "number") { + return [ValueTag.Number, value as number]; + } + + if (t === "string") { + return [ValueTag.String, value as string]; + } + + if (t === "object") { + const obj = value as any; + + if (obj instanceof NativePointer) { + return [ValueTag.NativePointer, (obj as NativePointer).toString()]; + } + + if (isBinaryLike(obj)) { + const index = chunks.length; + const binary = obj as ArrayBufferView | ArrayBuffer; + const kind = getBytesKind(binary); + chunks.push({ index, buf: binary }); + return [ValueTag.Bytes, index, kind]; + } + + if (obj instanceof Error) { + const err = obj as Error; + const name = err.name ?? "Error"; + const message = err.message ?? ""; + const stack = err.stack ?? ""; + return [ValueTag.Error, name, message, stack]; + } + + if (obj instanceof Date) { + return [ValueTag.Date, (obj as Date).toISOString()]; + } + + if (obj instanceof RegExp) { + const r = obj as RegExp; + return [ValueTag.RegExp, r.source, r.flags]; + } + + if (obj instanceof Promise) { + return [ValueTag.Promise]; + } + + if (obj instanceof WeakMap) { + return [ValueTag.WeakMap]; + } + + if (obj instanceof WeakSet) { + return [ValueTag.WeakSet]; + } + + const isArray = Array.isArray(obj); + const isMap = obj instanceof Map; + const isSet = obj instanceof Set; + + const existingId = state.seen.get(obj); + if (existingId !== undefined) { + return [ValueTag.Circular, existingId]; + } + + if (depth >= maxDepth) { + if (isArray) { + return [ValueTag.DepthLimit, ValueTag.Array]; + } + if (isMap) { + return [ValueTag.DepthLimit, ValueTag.Map]; + } + if (isSet) { + return [ValueTag.DepthLimit, ValueTag.Set]; + } + return [ValueTag.DepthLimit, ValueTag.Object]; + } + + const id = state.nextId++; + state.seen.set(obj, id); + + if (isMap) { + const mapValue = obj as Map; + const entries: [EncodedValueTree, EncodedValueTree][] = []; + for (const [k, v] of mapValue.entries()) { + const keyTree = collectTree(k, chunks, maxDepth, depth + 1, state); + const valueTree = collectTree(v, chunks, maxDepth, depth + 1, state); + entries.push([keyTree, valueTree]); + } + return [ValueTag.Map, id, entries]; + } + + if (isSet) { + const setValue = obj as Set; + const items: EncodedValueTree[] = []; + for (const v of setValue.values()) { + items.push(collectTree(v, chunks, maxDepth, depth + 1, state)); + } + return [ValueTag.Set, id, items]; + } + + if (isArray) { + const elements = (obj as unknown[]).map(v => + collectTree(v, chunks, maxDepth, depth + 1, state), + ); + return [ValueTag.Array, id, elements]; + } + + try { + const maybeToJSON = obj.toJSON; + if (typeof maybeToJSON === "function") { + const converted = maybeToJSON.call(obj); + return collectTree(converted, chunks, maxDepth, depth, state); + } + } catch { + } + + const entries: [EncodedValueTree, EncodedValueTree][] = []; + + for (const k of Object.keys(obj as Record)) { + const keyTree: EncodedValueTree = [ValueTag.String, k]; + let v; + try { + v = obj[k]; + } catch { + continue; + } + const valueTree = collectTree(v, chunks, maxDepth, depth + 1, state); + entries.push([keyTree, valueTree]); + } + + const symbols = Object.getOwnPropertySymbols(obj as object); + for (const s of symbols) { + if (!Object.prototype.propertyIsEnumerable.call(obj, s)) { + continue; + } + const keyTree: EncodedValueTree = [ValueTag.Symbol, String(s)]; + let v; + try { + v = obj[s]; + } catch { + continue; + } + const valueTree = collectTree(v, chunks, maxDepth, depth + 1, state); + entries.push([keyTree, valueTree]); + } + + return [ValueTag.Object, id, entries]; + } + + if (t === "boolean") { + return [ValueTag.Boolean, value as boolean]; + } + + if (t === "function") { + const fn = value as Function; + const name = fn.name ?? ""; + const sig = name === "" ? "[Function]" : `[Function: ${name}]`; + return [ValueTag.Function, sig]; + } + + if (t === "undefined") { + return [ValueTag.Undefined]; + } + + if (t === "bigint") { + const big = value as bigint; + return [ValueTag.BigInt, big.toString()]; + } + + if (t === "symbol") { + return [ValueTag.Symbol, String(value)]; + } + + return [ValueTag.Undefined]; +} + +interface BinaryChunk { + index: number; + buf: ArrayBufferView | ArrayBuffer; +} + +function packChunks(chunks: BinaryChunk[]): { + blob: ArrayBuffer | null; + offsets: { offset: number; length: number }[]; +} { + if (chunks.length === 0) { + return { blob: null, offsets: [] }; + } + + let total = 0; + const lengths = chunks.map(({ buf }) => { + const b = buf instanceof ArrayBuffer ? buf : buf.buffer; + return b.byteLength; + }); + for (const len of lengths) { + total += len; + } + + const blob = new Uint8Array(total); + const offsets: { offset: number; length: number }[] = []; + + let offset = 0; + chunks.forEach(({ buf }, index) => { + const raw = buf instanceof ArrayBuffer + ? new Uint8Array(buf) + : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + blob.set(raw, offset); + offsets[index] = { offset, length: raw.byteLength }; + offset += raw.byteLength; + }); + + return { blob: blob.buffer as ArrayBuffer, offsets }; +} + +function patchTree( + tree: EncodedValueTree, + offsets: { offset: number; length: number }[] +): EncodedValueTree { + const [tag, ...rest] = tree; + + switch (tag) { + case ValueTag.Object: { + const id = rest[0] as number; + const entries = rest[1] as [EncodedValueTree, EncodedValueTree][]; + const patchedEntries = entries.map(([k, v]) => [ + patchTree(k, offsets), + patchTree(v, offsets), + ] as [EncodedValueTree, EncodedValueTree]); + return [ValueTag.Object, id, patchedEntries]; + } + + case ValueTag.Array: { + const id = rest[0] as number; + const items = (rest[1] as EncodedValueTree[]) + .map(child => patchTree(child, offsets)); + return [ValueTag.Array, id, items]; + } + + case ValueTag.Bytes: { + const index = rest[0] as number; + const kind = rest[1] as BytesKind; + const { offset, length } = offsets[index]; + return [ValueTag.Bytes, offset, length, kind] as BytesEncoded; + } + + case ValueTag.Map: { + const id = rest[0] as number; + const entries = rest[1] as [EncodedValueTree, EncodedValueTree][]; + const patchedEntries = entries.map(([k, v]) => [ + patchTree(k, offsets), + patchTree(v, offsets), + ] as [EncodedValueTree, EncodedValueTree]); + return [ValueTag.Map, id, patchedEntries]; + } + + case ValueTag.Set: { + const id = rest[0] as number; + const items = rest[1] as EncodedValueTree[]; + const patchedItems = items.map(child => patchTree(child, offsets)); + return [ValueTag.Set, id, patchedItems]; + } + + default: + return tree; + } +} + +function getBytesKind(v: ArrayBufferView | ArrayBuffer): BytesKind { + if (v instanceof ArrayBuffer) { + return "ArrayBuffer"; + } + if (v instanceof DataView) { + return "DataView"; + } + const ctor = (v as { constructor?: { name?: string } }).constructor; + const name = ctor?.name ?? "ArrayBuffer"; + return name as BytesKind; +} + +function isBinaryLike(v: unknown): v is ArrayBufferView | ArrayBuffer { + return v instanceof ArrayBuffer || ArrayBuffer.isView(v); +} diff --git a/frida_tools/repl.py b/frida_tools/repl.py index 895b121..30c9bf5 100644 --- a/frida_tools/repl.py +++ b/frida_tools/repl.py @@ -28,7 +28,7 @@ from prompt_toolkit.styles import Style as PromptToolkitStyle from pygments.lexers.javascript import JavascriptLexer from pygments.token import Token -from frida_tools import _repl_magic +from frida_tools import _repl_magic, repl_inspect from frida_tools.application import ConsoleApplication from frida_tools.cli_formatting import THEME_COLOR, format_compiled, format_compiling, format_diagnostic from frida_tools.reactor import Reactor @@ -482,12 +482,11 @@ class REPLApplication(ConsoleApplication): success = False try: t, value = self._perform_on_reactor_thread(lambda: exec(arg)) - if t in ("function", "undefined", "null"): - output = t - elif t == "binary": - output = hexdump(value).rstrip("\n") + if t == "binary": + output = repl_inspect.render_hexdump(value) else: - output = json.dumps(value, sort_keys=True, indent=4, separators=(",", ": ")) + tree, blob = value + output = "undefined" if repl_inspect.is_undefined(tree) else repl_inspect.render(tree, blob) success = True except JavaScriptError as e: error = e.error @@ -529,7 +528,8 @@ class REPLApplication(ConsoleApplication): obj_type, obj_value = self._evaluate_expression(obj_to_identify) if obj_type == "function": - signature = self._evaluate_expression("%s.toString()" % obj_to_identify)[1] + _, (signature_tree, _) = self._evaluate_expression("%s.toString()" % obj_to_identify) + signature = signature_tree[1] clean_signature = signature.split("{")[0][:-1].split("function ")[-1] if "[native code]" in signature: @@ -549,9 +549,9 @@ class REPLApplication(ConsoleApplication): help_text += "Docstring: #TODO :)" elif obj_type == "string": - bool_text = self._evaluate_expression(obj_to_identify + ".toString()")[1] + _, (text_tree, _) = self._evaluate_expression(obj_to_identify + ".toString()") help_text += "Type: Boolean\n" - help_text += f"Text: {bool_text.decode()}\n" + help_text += f"Text: {text_tree[1]}\n" help_text += "Docstring: #TODO :)" self._print(help_text) @@ -681,7 +681,8 @@ class REPLApplication(ConsoleApplication): return ("binary", bytes()) elif result[0] == "error": raise JavaScriptError(result[1]) - return (result[0], result[1]) + _, value_type, tree, blob = result + return (value_type, (tree, blob)) def _process_message(self, message: Mapping[Any, Any], data: Any) -> None: message_type = message["type"] @@ -1096,7 +1097,8 @@ class FridaCompleter(Completer): if t == "error": return [] - return sorted(filter(self._is_valid_name, set(value))) + names = repl_inspect.to_string_list(value[0]) + return sorted(filter(self._is_valid_name, set(names))) def _is_valid_name(self, name) -> bool: tokens = list(self._lexer.get_tokens(name)) @@ -1112,17 +1114,6 @@ def script_needs_compilation(path: AnyStr) -> bool: return path.endswith(b".ts") -def hexdump(src, length: int = 16) -> str: - FILTER = "".join([(len(repr(chr(x))) == 3) and chr(x) or "." for x in range(256)]) - lines = [] - for c in range(0, len(src), length): - chars = src[c : c + length] - hex = " ".join(["%02x" % x for x in iter(chars)]) - printable = "".join(["%s" % ((x <= 127 and FILTER[x]) or ".") for x in iter(chars)]) - lines.append("%04x %-*s %s\n" % (c, length * 3, hex, printable)) - return "".join(lines) - - OS_BINARY_SIGNATURES = { b"\x4d\x5a", # PE b"\xca\xfe\xba\xbe", # Fat Mach-O diff --git a/frida_tools/repl_inspect.py b/frida_tools/repl_inspect.py new file mode 100644 index 0000000..f96e072 --- /dev/null +++ b/frida_tools/repl_inspect.py @@ -0,0 +1,315 @@ +"""Rendering of JavaScript values inspected by the REPL agent. + +The agent encodes each evaluated value into a bounded tagged tree, so that +serializing cyclic or very large objects can never hang. This module +pretty-prints that tree with syntax highlighting. +""" + +from typing import Any, List, Sequence, Tuple + +from colorama import Fore, Style + +# Tags mirror ValueTag in agents/repl/agent.ts. +NUMBER = 0 +STRING = 1 +OBJECT = 2 +ARRAY = 3 +NATIVE_POINTER = 4 +NULL = 5 +BOOLEAN = 6 +BYTES = 7 +FUNCTION = 8 +ERROR = 9 +UNDEFINED = 10 +BIGINT = 11 +SYMBOL = 12 +DATE = 13 +REGEXP = 14 +MAP = 15 +SET = 16 +PROMISE = 17 +WEAKMAP = 18 +WEAKSET = 19 +DEPTH_LIMIT = 20 +CIRCULAR = 21 + +_CYAN = Fore.CYAN +_MINT = Fore.GREEN +_KEY = Fore.LIGHTGREEN_EX +_ORANGE = Fore.YELLOW +_PURPLE = Fore.MAGENTA +_RED = Fore.RED +_BLUE = Fore.BLUE +_GRAY = Fore.LIGHTBLACK_EX +_RESET = Style.RESET_ALL + +_MAX_ITEMS = 100 + +_HEX_BYTES_PER_ROW = 16 + +_CONTAINER_NAMES = {OBJECT: "Object", ARRAY: "Array", MAP: "Map", SET: "Set"} + +Node = Sequence[Any] + + +def render(tree: Node, blob: Sequence[int]) -> str: + out: List[str] = [] + _append(tree, 0, out, blob) + return "".join(out) + + +def render_hexdump(data: bytes) -> str: + rows = [] + for offset in range(0, len(data), _HEX_BYTES_PER_ROW): + rows.append(_hex_row(offset, data[offset:offset + _HEX_BYTES_PER_ROW])) + return "\n".join(rows) + + +def is_undefined(tree: Node) -> bool: + return tree[0] == UNDEFINED + + +def to_string_list(tree: Node) -> List[str]: + return [element[1] for element in tree[2]] + + +def _append(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None: + tag = node[0] + + if tag == NUMBER: + out.append(_color(_format_number(node[1]), _CYAN)) + + elif tag == STRING: + out.append(_color('"%s"' % node[1], _MINT)) + + elif tag == OBJECT: + _append_object(node, level, out, blob) + + elif tag == ARRAY: + _append_array(node, level, out, blob) + + elif tag == NATIVE_POINTER: + out.append(_color(node[1], _ORANGE)) + + elif tag == NULL: + out.append(_color("null", _ORANGE)) + + elif tag == BOOLEAN: + out.append(_color("true" if node[1] else "false", _ORANGE)) + + elif tag == BYTES: + _append_bytes(node, level, out, blob) + + elif tag == FUNCTION: + out.append(_color(node[1], _PURPLE)) + + elif tag == ERROR: + _append_error(node, level, out) + + elif tag == UNDEFINED: + out.append(_color("undefined", _ORANGE)) + + elif tag == BIGINT: + out.append(_color(node[1] + "n", _CYAN)) + + elif tag == SYMBOL: + out.append(_color(node[1], _PURPLE)) + + elif tag == DATE: + out.append(_color("Date(", _BLUE)) + out.append(_color(node[1], _MINT)) + out.append(_color(")", _BLUE)) + + elif tag == REGEXP: + _append_regexp(node, out) + + elif tag == MAP: + _append_map(node, level, out, blob) + + elif tag == SET: + _append_set(node, level, out, blob) + + elif tag == PROMISE: + out.append(_color("Promise", _PURPLE)) + + elif tag == WEAKMAP: + out.append(_color("WeakMap", _PURPLE)) + + elif tag == WEAKSET: + out.append(_color("WeakSet", _PURPLE)) + + elif tag == DEPTH_LIMIT: + out.append(_color("%s" % _CONTAINER_NAMES[node[1]], _ORANGE)) + + elif tag == CIRCULAR: + out.append(_color("⟳ circular *%d" % node[1], _ORANGE)) + + +def _append_object(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None: + properties = node[2] + if not properties: + out.append(_color("{}", _CYAN)) + return + + out.append(_color("{", _CYAN)) + out.append("\n") + shown, hidden = _capped(properties) + for index, (key, value) in enumerate(shown): + _indent(level + 1, out) + out.append(_color(key[1], _KEY)) + out.append(": ") + _append(value, level + 1, out, blob) + if _has_more(index, shown, hidden): + out.append(",") + out.append("\n") + _append_overflow(hidden, level + 1, out) + _indent(level, out) + out.append(_color("}", _CYAN)) + + +def _append_array(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None: + elements = node[2] + if not elements: + out.append("[]") + return + + out.append("[") + out.append("\n") + shown, hidden = _capped(elements) + for index, element in enumerate(shown): + _indent(level + 1, out) + _append(element, level + 1, out, blob) + if _has_more(index, shown, hidden): + out.append(",") + out.append("\n") + _append_overflow(hidden, level + 1, out) + _indent(level, out) + out.append("]") + + +def _append_bytes(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None: + offset, length, kind = node[1], node[2], node[3] + out.append(_color("Bytes(", _MINT)) + out.append(_color(kind, _CYAN)) + out.append(_color("[%d])" % length, _MINT)) + data = bytes(blob[offset:offset + length]) + for start in range(0, len(data), _HEX_BYTES_PER_ROW): + out.append("\n") + _indent(level + 1, out) + out.append(_hex_row(start, data[start:start + _HEX_BYTES_PER_ROW])) + + +def _append_error(node: Node, level: int, out: List[str]) -> None: + name, message, stack = node[1], node[2], node[3] + out.append(_color(name if not message else "%s: %s" % (name, message), _RED)) + if not stack: + return + for line in stack.split("\n"): + out.append("\n") + _indent(level + 1, out) + out.append(Style.DIM + line + _RESET) + + +def _append_regexp(node: Node, out: List[str]) -> None: + pattern, flags = node[1], node[2] + out.append(_color("/", _PURPLE)) + out.append(_color(pattern, _MINT)) + out.append(_color("/", _PURPLE)) + if flags: + out.append(_color(flags, _PURPLE)) + + +def _append_map(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None: + entries = node[2] + if not entries: + out.append(_color("Map{}", _CYAN)) + return + + out.append(_color("Map{", _CYAN)) + out.append("\n") + shown, hidden = _capped(entries) + for index, (key, value) in enumerate(shown): + _indent(level + 1, out) + _append(key, level + 1, out, blob) + out.append(" => ") + _append(value, level + 1, out, blob) + if _has_more(index, shown, hidden): + out.append(",") + out.append("\n") + _append_overflow(hidden, level + 1, out) + _indent(level, out) + out.append(_color("}", _CYAN)) + + +def _append_set(node: Node, level: int, out: List[str], blob: Sequence[int]) -> None: + elements = node[2] + if not elements: + out.append(_color("Set[]", _CYAN)) + return + + out.append(_color("Set[", _CYAN)) + out.append("\n") + shown, hidden = _capped(elements) + for element in shown: + _indent(level + 1, out) + out.append(_color("• ", _CYAN)) + _append(element, level + 1, out, blob) + out.append("\n") + _append_overflow(hidden, level + 1, out) + _indent(level, out) + out.append(_color("]", _CYAN)) + + +def _capped(items: Sequence[Any]) -> Tuple[Sequence[Any], int]: + shown = items[:_MAX_ITEMS] + return shown, len(items) - len(shown) + + +def _has_more(index: int, shown: Sequence[Any], hidden: int) -> bool: + return index < len(shown) - 1 or hidden > 0 + + +def _append_overflow(hidden: int, level: int, out: List[str]) -> None: + if not hidden: + return + _indent(level, out) + out.append(_color("… %d more" % hidden, _ORANGE)) + out.append("\n") + + +def _hex_row(offset: int, chunk: bytes) -> str: + cells = " ".join(_color("%02X" % byte, _byte_color(byte)) for byte in chunk) + padding = " " * (_HEX_BYTES_PER_ROW - len(chunk)) + glyphs = "".join(_color(_ascii_glyph(byte), _GRAY) for byte in chunk) + return "%s %s%s %s" % (_color("%08X" % offset, _KEY), cells, padding, glyphs) + + +def _byte_color(byte: int) -> str: + if byte == 0x00: + return _GRAY + if 0x20 <= byte <= 0x7E: + return _MINT + if byte <= 0x1F or byte == 0x7F: + return _ORANGE + return _CYAN + + +def _ascii_glyph(byte: int) -> str: + return chr(byte) if 0x20 <= byte <= 0x7E else "." + + +def _format_number(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, float): + return str(int(value)) if value.is_integer() else repr(value) + return str(value) + + +def _color(text: str, color: str) -> str: + return color + text + _RESET + + +def _indent(level: int, out: List[str]) -> None: + if level > 0: + out.append(" " * level)