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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,19 @@ graphtty reads a simple JSON format:

## Benchmarks

graphtty uses a custom Sugiyama-style layout engine and optimized canvas operations for fast rendering. Benchmarks across all 8 sample graphs (50 iterations each, Python 3.11):
graphtty uses a custom Sugiyama-style layout engine and optimized canvas operations for fast rendering. Benchmarks across all 9 sample graphs (50 iterations each, Python 3.11):

| Sample | Avg (ms) | Ops/sec |
|---|---:|---:|
| react-agent (4 nodes) | 0.15 | 6,522 |
| deep-agent (7 nodes) | 0.32 | 3,149 |
| workflow-agent (11 nodes) | 0.43 | 2,347 |
| world-map (15 nodes) | 0.53 | 1,887 |
| rag-pipeline (10 nodes) | 0.71 | 1,419 |
| supervisor-agent (7+subs) | 0.72 | 1,395 |
| etl-pipeline (12 nodes) | 0.78 | 1,282 |
| code-review (8+subs) | 1.13 | 885 |
| deep-agent (7 nodes) | 0.31 | 3,161 |
| function-agent (8 nodes) | 0.36 | 2,806 |
| workflow-agent (11 nodes) | 0.42 | 2,370 |
| world-map (15 nodes) | 0.55 | 1,818 |
| supervisor-agent (7+subs) | 0.71 | 1,406 |
| rag-pipeline (10 nodes) | 0.74 | 1,347 |
| etl-pipeline (12 nodes) | 0.84 | 1,193 |
| code-review (8+subs) | 1.16 | 864 |

Run `python scripts/benchmark.py` to reproduce on your machine.

Expand Down
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.3"
version = "0.1.4"
description = "Turn any directed graph into colored ASCII art for your terminal"
readme = "README.md"
license = "MIT"
Expand Down
104 changes: 104 additions & 0 deletions samples/function-agent/graph.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{
"nodes": [
{
"id": "__start__",
"name": "__start__",
"type": "__start__",
"subgraph": null
},
{
"id": "aggregate_tool_results",
"name": "aggregate_tool_results",
"type": "tool",
"subgraph": null
},
{
"id": "call_tool",
"name": "call_tool",
"type": "tool",
"subgraph": null
},
{
"id": "init_run",
"name": "init_run",
"type": "node",
"subgraph": null
},
{
"id": "parse_agent_output",
"name": "parse_agent_output",
"type": "node",
"subgraph": null
},
{
"id": "run_agent_step",
"name": "run_agent_step",
"type": "model",
"subgraph": null
},
{
"id": "setup_agent",
"name": "setup_agent",
"type": "node",
"subgraph": null
},
{
"id": "__end__",
"name": "__end__",
"type": "__end__",
"subgraph": null
}
],
"edges": [
{
"source": "aggregate_tool_results",
"target": "setup_agent",
"label": "AgentInput"
},
{
"source": "aggregate_tool_results",
"target": "__end__",
"label": "StopEvent"
},
{
"source": "call_tool",
"target": "aggregate_tool_results",
"label": "ToolCallResult"
},
{
"source": "__start__",
"target": "init_run",
"label": "AgentWorkflowStartEvent"
},
{
"source": "init_run",
"target": "setup_agent",
"label": "AgentInput"
},
{
"source": "parse_agent_output",
"target": "__end__",
"label": "StopEvent"
},
{
"source": "parse_agent_output",
"target": "setup_agent",
"label": "AgentInput"
},
{
"source": "parse_agent_output",
"target": "call_tool",
"label": "ToolCall"
},
{
"source": "run_agent_step",
"target": "parse_agent_output",
"label": "AgentOutput"
},
{
"source": "setup_agent",
"target": "run_agent_step",
"label": "AgentSetup"
}
]
}
Binary file added screenshots/function-agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions scripts/generate_screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ def main():
"forest",
"ETL Data Pipeline (forest)",
),
(
"samples/function-agent/graph.json",
"monokai",
"Function Agent (monokai)",
),
]

images: list[tuple[str, Image.Image]] = []
Expand Down
205 changes: 203 additions & 2 deletions src/graphtty/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,15 @@ def _do_render_canvas(
# 3. Layout
boxes = _sugiyama_layout(graph.nodes, graph.edges, node_sizes, options.padding)

# 4. Determine canvas size — account for backward-edge corridors
# 4. Determine canvas size — account for edge corridors
corridor_map, extra_right = _backward_edge_corridors(graph.edges, boxes)
# Forward skip-layer corridors (placed after backward corridors)
min_fwd = (max(corridor_map.values()) + 3) if corridor_map else 0
fwd_map, fwd_extra = _forward_skip_corridors(
graph.edges, boxes, min_route_x=min_fwd
)
corridor_map.update(fwd_map)
extra_right = max(extra_right, fwd_extra)
max_x = max(b.x + b.w for b in boxes.values()) + extra_right + options.padding
max_y = max(b.y + b.h for b in boxes.values()) + options.padding
canvas = Canvas(max_x, max_y)
Expand Down Expand Up @@ -334,7 +341,84 @@ def _backward_edge_corridors(
return corridor_map, 0

max_route_x = max(corridor_map.values())
margin = max(0, max_route_x - global_max_right + 3)
# Account for label width on backward corridors
max_label_w = 0
for idx in corridor_map:
lbl = edges[idx].label
if lbl:
max_label_w = max(max_label_w, len(lbl) + 2) # +2 gap
margin = max(0, max_route_x + max_label_w - global_max_right + 3)
return corridor_map, margin


def _forward_skip_corridors(
edges: list[AsciiEdge],
boxes: dict[str, Box],
min_route_x: int = 0,
) -> tuple[dict[int, int], int]:
"""Compute route_x corridors for forward edges that skip layers.

Forward edges whose straight vertical path passes through an intermediate
box are re-routed through a right-side corridor, similar to backward edges.

Returns ``(corridor_map, margin)`` — same shape as backward corridors.
"""
all_boxes = list(boxes.values())
global_max_right = max((b.x + b.w for b in all_boxes), default=0)
corridor_map: dict[int, int] = {}
slot = 0
for idx, edge in enumerate(edges):
src = boxes.get(edge.source)
tgt = boxes.get(edge.target)
if src is None or tgt is None:
continue
if src.bottom >= tgt.top:
continue # not a forward edge

src_cx = src.cx
tgt_cx = tgt.cx
if abs(src_cx - tgt_cx) > _STRAIGHT_TOLERANCE:
continue # Z-shape edges unlikely to collide with intermediate boxes

edge_x = tgt_cx # straight edges align to target centre
src_bottom = src.bottom
tgt_top = tgt.top
# Check if vertical at edge_x passes through any intermediate box
collides = False
local_max_right = 0
for b in all_boxes:
if b is src or b is tgt:
continue
# Box must be vertically between src and tgt
b_bottom = b.y + b.h
if b_bottom <= src_bottom or b.y >= tgt_top:
continue
b_right = b.x + b.w
# Box must horizontally contain the edge x
if b.x <= edge_x < b_right:
collides = True
# Track rightmost box in the vertical span for corridor placement
if b_right > local_max_right:
local_max_right = b_right

if collides:
route_x = max(local_max_right + 3, min_route_x) + slot * 3
corridor_map[idx] = route_x
slot += 1

if not corridor_map:
return corridor_map, 0

max_route_x = max(corridor_map.values())
# Account for label width on forward corridors
max_label_w = 0
for idx in corridor_map:
lbl = edges[idx].label
if lbl:
lbl_w = len(lbl) + 2 # +2 gap
if lbl_w > max_label_w:
max_label_w = lbl_w
margin = max(0, max_route_x + max_label_w - global_max_right + 3)
return corridor_map, margin


Expand Down Expand Up @@ -365,6 +449,8 @@ def _draw_edge(
ch,
edge_color=color,
label_color=label_color,
route_x=route_x,
all_boxes=all_boxes,
)
elif tgt.bottom < src.top:
_draw_backward_edge(
Expand Down Expand Up @@ -399,8 +485,24 @@ def _draw_forward_edge(
*,
edge_color: str | None = None,
label_color: str | None = None,
route_x: int | None = None,
all_boxes: dict[str, Box] | None = None,
) -> None:
"""Source is above target — connect bottom-centre to top-centre."""
if route_x is not None:
_draw_forward_corridor(
canvas,
src,
tgt,
label,
ch,
route_x=route_x,
edge_color=edge_color,
label_color=label_color,
all_boxes=all_boxes,
)
return

src_cx = src.cx
tgt_cx = tgt.cx
start_y = src.bottom
Expand Down Expand Up @@ -473,6 +575,105 @@ def _draw_forward_edge(
canvas.put(tgt_cx, arrow_y, ch["arrow_down"], edge_color)


def _draw_forward_corridor(
canvas: Canvas,
src: Box,
tgt: Box,
label: str | None,
ch: dict[str, str],
*,
route_x: int,
edge_color: str | None = None,
label_color: str | None = None,
all_boxes: dict[str, Box] | None = None,
) -> None:
"""Draw a forward edge routed through a right-side corridor.

Used when the straight vertical path would collide with intermediate boxes.
Routes: src ┬ → down → └──┐ corridor │ ┘──┌ → down → ▼ tgt
"""
src_cx = src.cx
tgt_cx = tgt.cx
start_y = src.bottom
end_y = tgt.top

top_horiz_y = start_y + 2
bot_horiz_y = end_y - 2
# Safety clamp
if top_horiz_y >= bot_horiz_y:
mid = (start_y + end_y) // 2
top_horiz_y = mid
bot_horiz_y = mid + 1

arrow_y = end_y - 1 if end_y - 1 > start_y else end_y

# Pre-compute occupied intervals for the two horizontal rows
top_intervals: list[tuple[int, int]] = []
bot_intervals: list[tuple[int, int]] = []
if all_boxes:
top_intervals = _x_intervals_at_y(top_horiz_y, all_boxes, src, tgt)
bot_intervals = _x_intervals_at_y(bot_horiz_y, all_boxes, src, tgt)

# Direct array access for performance
rows = canvas._rows
colors = canvas._colors
ch_v = ch["v"]
ch_h = ch["h"]

# 1. Junction at source bottom
canvas.put(src_cx, start_y, ch["jt"], edge_color)

# 2. Vertical from source down to top_horiz_y
for y in range(start_y + 1, top_horiz_y):
rows[y][src_cx] = ch_v
if edge_color is not None:
colors[y][src_cx] = edge_color

# 3. Corner └ at (src_cx, top_horiz_y), horizontal to route_x, corner ┐
canvas.put(src_cx, top_horiz_y, ch["bl"], edge_color)
top_row = rows[top_horiz_y]
top_color_row = colors[top_horiz_y]
for x in range(src_cx + 1, route_x):
if top_intervals and _x_in_intervals(x, top_intervals):
continue
top_row[x] = ch_h
if edge_color is not None:
top_color_row[x] = edge_color
canvas.put(route_x, top_horiz_y, ch["tr"], edge_color)

# 4. Vertical down corridor
for y in range(top_horiz_y + 1, bot_horiz_y):
rows[y][route_x] = ch_v
if edge_color is not None:
colors[y][route_x] = edge_color

# 5. Corner ┘ at (route_x, bot_horiz_y), horizontal back to tgt_cx, corner ┌
canvas.put(route_x, bot_horiz_y, ch["br"], edge_color)
bot_row = rows[bot_horiz_y]
bot_color_row = colors[bot_horiz_y]
for x in range(tgt_cx + 1, route_x):
if bot_intervals and _x_in_intervals(x, bot_intervals):
continue
bot_row[x] = ch_h
if edge_color is not None:
bot_color_row[x] = edge_color
canvas.put(tgt_cx, bot_horiz_y, ch["tl"], edge_color)

# 6. Vertical down to arrow
for y in range(bot_horiz_y + 1, arrow_y):
rows[y][tgt_cx] = ch_v
if edge_color is not None:
colors[y][tgt_cx] = edge_color

# 7. Arrow above target box
canvas.put(tgt_cx, arrow_y, ch["arrow_down"], edge_color)

# 8. Label alongside corridor vertical
if label:
label_y = (top_horiz_y + bot_horiz_y) // 2
canvas.puts(route_x + 2, label_y, label, label_color)


def _x_intervals_at_y(
y: int, boxes: dict[str, Box], src: Box, tgt: Box
) -> list[tuple[int, int]]:
Expand Down
Loading