diff --git a/pyproject.toml b/pyproject.toml index 420693e..31a5bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "graphtty" -version = "0.1.7" +version = "0.1.8" description = "Turn any directed graph into colored ASCII art for your terminal" readme = "README.md" license = "MIT" diff --git a/screenshots/code-review.png b/screenshots/code-review.png index 45ee94d..b229ee2 100644 Binary files a/screenshots/code-review.png and b/screenshots/code-review.png differ diff --git a/screenshots/deep-agent.png b/screenshots/deep-agent.png index d70687c..e3e95de 100644 Binary files a/screenshots/deep-agent.png and b/screenshots/deep-agent.png differ diff --git a/screenshots/etl-pipeline.png b/screenshots/etl-pipeline.png index bb18015..5c115a3 100644 Binary files a/screenshots/etl-pipeline.png and b/screenshots/etl-pipeline.png differ diff --git a/screenshots/rag-pipeline.png b/screenshots/rag-pipeline.png index 85f820d..b20429d 100644 Binary files a/screenshots/rag-pipeline.png and b/screenshots/rag-pipeline.png differ diff --git a/screenshots/react-agent.png b/screenshots/react-agent.png index 2c8cf97..abbf8c0 100644 Binary files a/screenshots/react-agent.png and b/screenshots/react-agent.png differ diff --git a/screenshots/supervisor-agent.png b/screenshots/supervisor-agent.png index 1612ad3..5de73d5 100644 Binary files a/screenshots/supervisor-agent.png and b/screenshots/supervisor-agent.png differ diff --git a/screenshots/workflow-agent.png b/screenshots/workflow-agent.png index deb16af..fe52731 100644 Binary files a/screenshots/workflow-agent.png and b/screenshots/workflow-agent.png differ diff --git a/src/graphtty/renderer.py b/src/graphtty/renderer.py index 40c3636..f450aae 100644 --- a/src/graphtty/renderer.py +++ b/src/graphtty/renderer.py @@ -163,7 +163,7 @@ def _do_render_canvas( inner_w = max(len(line) for line in content_lines) # Ensure box is wide enough for the type label in the border - type_lbl = _type_label(node.type, options.show_types) + type_lbl = _type_label(node.type, node.name, options.show_types) if type_lbl: inner_w = max(inner_w, len(type_lbl) + 2) box_w = inner_w + 4 # 1 border + 1 pad + content + 1 pad + 1 border @@ -225,7 +225,7 @@ def _do_render_canvas( node_border_colors: dict[str, str | None] = {} for node in graph.nodes: box = boxes[node.id] - type_lbl = _type_label(node.type, options.show_types) + type_lbl = _type_label(node.type, node.name, options.show_types) style = style_cache[node.type] border_c = style.border or None node_border_colors[node.id] = border_c @@ -276,15 +276,15 @@ def _do_render_canvas( # Metadata helpers # --------------------------------------------------------------------------- -# Node types that are structural markers — no border label needed. +# Node types/names that are structural markers — no border label needed. _HIDDEN_TYPE_LABELS = {"__start__", "__end__", "__truncated__"} -def _type_label(node_type: str, show_types: bool) -> str | None: +def _type_label(node_type: str, node_name: str, show_types: bool) -> str | None: """Return the border type label, or *None* if it should be hidden.""" if not show_types: return None - if node_type in _HIDDEN_TYPE_LABELS: + if node_type in _HIDDEN_TYPE_LABELS or node_name in _HIDDEN_TYPE_LABELS: return None return node_type diff --git a/src/graphtty/truncate.py b/src/graphtty/truncate.py index faaed11..841925c 100644 --- a/src/graphtty/truncate.py +++ b/src/graphtty/truncate.py @@ -38,26 +38,47 @@ def truncate_graph( forward[e.source].append(e.target) in_degree[e.target] += 1 - # Longest-path layer assignment via topological order (Kahn's algorithm). - # Processing in topo order guarantees all incoming edges are resolved - # before a node is expanded — correct for longest-path in a DAG. + # Longest-path layer assignment via modified Kahn's algorithm. + # Standard topo sort processes nodes whose in-degree reaches 0. + # When the queue empties with unprocessed nodes remaining (cycles), + # force-process the unprocessed node with the highest current layer + # to break the cycle, then resume normal topo sort. roots = [nid for nid in node_ids if in_degree[nid] == 0] + layers: dict[str, int] = {nid: 0 for nid in node_ids} if not roots: - # Pure cycle — treat all nodes as layer 0 - layers: dict[str, int] = {nid: 0 for nid in node_ids} + # Pure cycle — no roots to start from, all stay at layer 0 + pass else: - layers = {nid: 0 for nid in node_ids} remaining = dict(in_degree) + processed: set[str] = set() topo: deque[str] = deque(roots) - while topo: - nid = topo.popleft() - for child in forward[nid]: - new_layer = layers[nid] + 1 - if new_layer > layers[child]: - layers[child] = new_layer - remaining[child] -= 1 - if remaining[child] == 0: - topo.append(child) + + while len(processed) < len(node_ids): + # Normal topo sort phase + while topo: + nid = topo.popleft() + if nid in processed: + continue + processed.add(nid) + for child in forward[nid]: + # Only update layers for unprocessed children so + # that cycle back-edges don't inflate already-placed nodes. + if child not in processed: + new_layer = layers[nid] + 1 + if new_layer > layers[child]: + layers[child] = new_layer + remaining[child] -= 1 + if remaining[child] == 0: + topo.append(child) + + # If stuck on a cycle, force-process the unprocessed node + # with the highest layer (best information from predecessors) + if len(processed) < len(node_ids): + best = max( + (n.id for n in graph.nodes if n.id not in processed), + key=lambda nid: layers[nid], + ) + topo.append(best) # --- Depth truncation --- keep_ids: set[str] = set() diff --git a/tests/test_truncate.py b/tests/test_truncate.py index 2d0beca..2338e77 100644 --- a/tests/test_truncate.py +++ b/tests/test_truncate.py @@ -91,6 +91,30 @@ def test_fan_out_depth_0(self): assert ids == {"root", "__truncated_depth__"} assert ("root", "__truncated_depth__") in _edge_pairs(result) + def test_cyclic_graph_no_truncation(self): + """Cyclic graphs should be fully preserved when limits are large enough. + + Models the deep-agent pattern: start → A → B → C → A (cycle), C → end. + """ + g = AsciiGraph( + nodes=[ + AsciiNode(id="start", name="start"), + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + AsciiNode(id="C", name="C"), + AsciiNode(id="end", name="end"), + ], + edges=[ + AsciiEdge(source="start", target="A"), + AsciiEdge(source="A", target="B"), + AsciiEdge(source="B", target="C"), + AsciiEdge(source="C", target="A"), # back-edge + AsciiEdge(source="C", target="end"), + ], + ) + result = truncate_graph(g, max_depth=10, max_breadth=10) + assert _ids(result) == {"start", "A", "B", "C", "end"} + def test_depth_no_truncation_needed(self): """Graph fits within max_depth — no placeholder added.""" g = AsciiGraph( diff --git a/uv.lock b/uv.lock index 87b0583..5d77915 100644 --- a/uv.lock +++ b/uv.lock @@ -105,7 +105,7 @@ toml = [ [[package]] name = "graphtty" -version = "0.1.7" +version = "0.1.8" source = { editable = "." } [package.dev-dependencies]