Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Binary file modified screenshots/code-review.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/deep-agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/etl-pipeline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/rag-pipeline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/react-agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/supervisor-agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/workflow-agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions src/graphtty/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
51 changes: 36 additions & 15 deletions src/graphtty/truncate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
24 changes: 24 additions & 0 deletions tests/test_truncate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.