xg-cli 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. xg_cli-1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,551 @@
1
+ """Unicode/ASCII text rendering for a bounded flowchart layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rich.cells import cell_len
8
+
9
+ from xg.tui.diagrams.layout import FlowchartLayout, layout_flowchart
10
+ from xg.tui.diagrams.model import FlowchartModel, FlowchartNode
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class DiagramRender:
15
+ text: str
16
+ mode: str = "unicode"
17
+ warnings: tuple[str, ...] = ()
18
+ width: int = 0
19
+ height: int = 0
20
+
21
+
22
+ _UNICODE = {
23
+ "h": "─", "v": "│", "tl": "┌", "tr": "┐", "bl": "└", "br": "┘",
24
+ "tee_down": "┬", "tee_up": "┴", "arrow": "▼", "arrow_right": "▶",
25
+ }
26
+ _ASCII = {
27
+ "h": "-", "v": "|", "tl": "+", "tr": "+", "bl": "+", "br": "+",
28
+ "tee_down": "+", "tee_up": "+", "arrow": "v", "arrow_right": ">",
29
+ }
30
+
31
+
32
+ def _fit(value: str, width: int) -> str:
33
+ if width <= 1:
34
+ return ""
35
+ value = value.replace("\n", " ").strip()
36
+ if cell_len(value) <= width:
37
+ return value
38
+ result = ""
39
+ used = 0
40
+ for char in value:
41
+ size = cell_len(char)
42
+ if used + size > max(0, width - 1):
43
+ break
44
+ result += char
45
+ used += size
46
+ return result + "…"
47
+
48
+
49
+ def _wrap(value: str, width: int) -> list[str]:
50
+ """Wrap text by terminal cells without dropping CJK or emoji characters."""
51
+ if width <= 0:
52
+ return [""]
53
+ lines: list[str] = []
54
+ for raw_line in value.replace("\r", "").split("\n"):
55
+ current = ""
56
+ used = 0
57
+ for char in raw_line:
58
+ char_width = cell_len(char)
59
+ if char_width > 0 and current and used + char_width > width:
60
+ lines.append(current)
61
+ current = ""
62
+ used = 0
63
+ current += char
64
+ used += char_width
65
+ lines.append(current)
66
+ return lines or [""]
67
+
68
+
69
+ def _node_width(node: FlowchartNode, *, unicode: bool) -> int:
70
+ # Keep nodes bounded so long titles wrap instead of forcing the whole
71
+ # diagram beyond the terminal width.
72
+ longest_line = max((cell_len(line) for line in node.label.splitlines()), default=0)
73
+ return max(7, min(38, longest_line) + 4)
74
+
75
+
76
+ def _box(node: FlowchartNode, width: int, *, unicode: bool) -> list[str]:
77
+ chars = _UNICODE if unicode else _ASCII
78
+ top = chars["tl"] + chars["h"] * (width - 2) + chars["tr"]
79
+ bottom = chars["bl"] + chars["h"] * (width - 2) + chars["br"]
80
+ middle: list[str] = []
81
+ for label in _wrap(node.label, width - 4):
82
+ padding = max(0, width - 2 - cell_len(label))
83
+ left = padding // 2
84
+ right = padding - left
85
+ middle.append(chars["v"] + " " * left + label + " " * right + chars["v"])
86
+ if node.shape == "round":
87
+ if unicode:
88
+ top = "╭" + "─" * (width - 2) + "╮"
89
+ bottom = "╰" + "─" * (width - 2) + "╯"
90
+ else:
91
+ top = "/" + "-" * (width - 2) + "\\"
92
+ bottom = "\\" + "-" * (width - 2) + "/"
93
+ elif node.shape == "circle":
94
+ if unicode:
95
+ top = "╭" + "─" * (width - 2) + "╮"
96
+ bottom = "╰" + "─" * (width - 2) + "╯"
97
+ else:
98
+ top = "(" + "-" * (width - 2) + ")"
99
+ bottom = "(" + "-" * (width - 2) + ")"
100
+ elif node.shape == "diamond":
101
+ # A compact diamond-like marker keeps the graph readable at narrow
102
+ # widths while avoiding a large, fragile multi-line polygon.
103
+ top = "<" + "-" * (width - 2) + ">" if not unicode else "◇" + "─" * (width - 2) + "◇"
104
+ bottom = top
105
+ return [top, *middle, bottom]
106
+
107
+
108
+ def _blank(width: int, height: int) -> list[list[str]]:
109
+ return [[" "] * width for _ in range(height)]
110
+
111
+
112
+ _CONTINUATION = ""
113
+
114
+
115
+ def _put_text(canvas: list[list[str]], x: int, y: int, value: str) -> None:
116
+ """Write text using terminal cells rather than Python character indexes."""
117
+ if not (0 <= y < len(canvas)):
118
+ return
119
+ for char in value:
120
+ char_width = cell_len(char)
121
+ if char_width <= 0:
122
+ if x > 0 and x <= len(canvas[y]) and canvas[y][x - 1] not in (" ", _CONTINUATION):
123
+ canvas[y][x - 1] += char
124
+ continue
125
+ if x >= len(canvas[y]):
126
+ break
127
+ if x >= 0:
128
+ canvas[y][x] = char
129
+ for offset in range(1, char_width):
130
+ if x + offset >= len(canvas[y]):
131
+ break
132
+ canvas[y][x + offset] = _CONTINUATION
133
+ x += char_width
134
+
135
+
136
+ def _put(canvas: list[list[str]], x: int, y: int, value: str) -> None:
137
+ """Write one-cell diagram content without corrupting wide characters."""
138
+ if 0 <= y < len(canvas) and 0 <= x < len(canvas[y]):
139
+ if canvas[y][x] == _CONTINUATION:
140
+ return
141
+ canvas[y][x] = value
142
+
143
+
144
+ def _draw_horizontal(canvas: list[list[str]], y: int, x1: int, x2: int, char: str) -> None:
145
+ if not (0 <= y < len(canvas)):
146
+ return
147
+ start, end = sorted((x1, x2))
148
+ for x in range(start, end + 1):
149
+ _put(canvas, x, y, char)
150
+
151
+
152
+ def _merge_edge_char(existing: str, value: str, *, unicode: bool) -> str:
153
+ """Merge crossing connector characters without destroying either path."""
154
+ if existing in (" ", _CONTINUATION):
155
+ return value
156
+ # An arrow is the semantic endpoint of an edge. It must win over the
157
+ # vertical segment that was drawn immediately before it.
158
+ if value in {"▼", "▶", "v", ">"}:
159
+ return value
160
+ if existing in {"▼", "▶", "v", ">"}:
161
+ return existing
162
+ if existing == value:
163
+ return existing
164
+ if not unicode:
165
+ return "+"
166
+ if {existing, value} <= {"─", "│"}:
167
+ return "┼"
168
+ if existing in {"─", "│", "┼"} and value in {"─", "│", "┼"}:
169
+ return "┼"
170
+ return existing
171
+
172
+
173
+ def _put_edge(
174
+ canvas: list[list[str]],
175
+ x: int,
176
+ y: int,
177
+ value: str,
178
+ *,
179
+ unicode: bool,
180
+ node_regions: list[tuple[int, int, int, int, int]],
181
+ ) -> None:
182
+ """Write a connector while protecting every node rectangle."""
183
+ if not (0 <= y < len(canvas) and 0 <= x < len(canvas[y])):
184
+ return
185
+ if any(left <= x < left + node_width and top <= y < top + node_height
186
+ for left, top, node_width, node_height, _ in node_regions):
187
+ return
188
+ existing = canvas[y][x]
189
+ if existing == _CONTINUATION:
190
+ return
191
+ canvas[y][x] = _merge_edge_char(existing, value, unicode=unicode)
192
+
193
+
194
+ def _draw_edge_horizontal(
195
+ canvas: list[list[str]],
196
+ y: int,
197
+ x1: int,
198
+ x2: int,
199
+ char: str,
200
+ *,
201
+ unicode: bool,
202
+ node_regions: list[tuple[int, int, int, int, int]],
203
+ ) -> None:
204
+ start, end = sorted((x1, x2))
205
+ for x in range(start, end + 1):
206
+ _put_edge(canvas, x, y, char, unicode=unicode, node_regions=node_regions)
207
+
208
+
209
+ def _draw_edge_vertical(
210
+ canvas: list[list[str]],
211
+ x: int,
212
+ y1: int,
213
+ y2: int,
214
+ char: str,
215
+ *,
216
+ unicode: bool,
217
+ node_regions: list[tuple[int, int, int, int, int]],
218
+ ) -> None:
219
+ start, end = sorted((y1, y2))
220
+ for y in range(start, end + 1):
221
+ _put_edge(canvas, x, y, char, unicode=unicode, node_regions=node_regions)
222
+
223
+
224
+ def _safe_vertical_lanes(
225
+ canvas_width: int,
226
+ *,
227
+ source_rank: int,
228
+ target_rank: int,
229
+ node_regions: list[tuple[int, int, int, int, int]],
230
+ ) -> list[int]:
231
+ """Return x columns that stay outside nodes crossed by a long edge."""
232
+ blocked: set[int] = set()
233
+ for rank in range(source_rank + 1, target_rank):
234
+ for left, _, node_width, _, node_rank in node_regions:
235
+ if node_rank == rank:
236
+ blocked.update(range(left, left + node_width))
237
+ return [x for x in range(canvas_width) if x not in blocked]
238
+
239
+
240
+ def _choose_vertical_lane(
241
+ candidates: list[int],
242
+ source_x: int,
243
+ target_x: int,
244
+ ) -> int | None:
245
+ if not candidates:
246
+ return None
247
+ # Prefer a lane close to both endpoints, which keeps short branches
248
+ # compact while still allowing long edges to pass around intermediate
249
+ # ranks safely.
250
+ return min(candidates, key=lambda x: (abs(x - source_x) + abs(x - target_x), x))
251
+
252
+
253
+ def _canvas_text(canvas: list[list[str]]) -> str:
254
+ lines = []
255
+ for row in canvas:
256
+ # A wide character already contributes its full terminal width. Its
257
+ # continuation cells must not become literal spaces in the output,
258
+ # otherwise ``中文`` would be serialized as ``中 文``.
259
+ lines.append("".join(cell for cell in row if cell != _CONTINUATION).rstrip())
260
+ return "\n".join(lines).rstrip()
261
+
262
+
263
+ def _text_dimensions(text: str) -> tuple[int, int]:
264
+ lines = text.splitlines() or [""]
265
+ return max((cell_len(line) for line in lines), default=0), len(lines)
266
+
267
+
268
+ def _render_vertical(model: FlowchartModel, layout: FlowchartLayout, *, width: int, unicode: bool) -> str:
269
+ node_by_id = {node.id: node for node in model.nodes}
270
+ gap_x = 5
271
+ widths = {node.id: _node_width(node, unicode=unicode) for node in model.nodes}
272
+ boxes = {node.id: _box(node, widths[node.id], unicode=unicode) for node in model.nodes}
273
+ heights = {node.id: len(boxes[node.id]) for node in model.nodes}
274
+ layer_widths = [sum(widths[n] for n in layer) + gap_x * max(0, len(layer) - 1) for layer in layout.ranks]
275
+ rank_by_node = {
276
+ node_id: rank
277
+ for rank, layer in enumerate(layout.ranks)
278
+ for node_id in layer
279
+ }
280
+ layer_heights = [max((heights[node_id] for node_id in layer), default=3) for layer in layout.ranks]
281
+ adjacent_edges: dict[int, list[tuple[int, object]]] = {}
282
+ for edge_index, edge in enumerate(model.edges):
283
+ source_rank = rank_by_node.get(edge.source)
284
+ target_rank = rank_by_node.get(edge.target)
285
+ if source_rank is not None and target_rank == source_rank + 1:
286
+ adjacent_edges.setdefault(source_rank, []).append((edge_index, edge))
287
+
288
+ # Each direct dependency gets its own horizontal routing row. Without
289
+ # this extra space, all edges between two rounds collapse into one bus.
290
+ layer_gaps = {
291
+ rank: max(2, len(edges) + 1)
292
+ for rank, edges in adjacent_edges.items()
293
+ }
294
+ layer_y = [0]
295
+ for rank in range(max(0, len(layout.ranks) - 1)):
296
+ layer_y.append(layer_y[-1] + layer_heights[rank] + layer_gaps.get(rank, 2))
297
+
298
+ # Reserve a small outer channel for edges which must pass around one or
299
+ # more intermediate ranks. Multiple channels are selected below so
300
+ # long edges remain individually traceable.
301
+ long_edge_count = any(
302
+ edge.source in rank_by_node
303
+ and edge.target in rank_by_node
304
+ and rank_by_node[edge.target] - rank_by_node[edge.source] > 1
305
+ for edge in model.edges
306
+ )
307
+ routing_margin = 2 if long_edge_count else 0
308
+ canvas_width = max(layer_widths or [1]) + routing_margin * 2
309
+ if canvas_width > width:
310
+ raise ValueError("flowchart width exceeded")
311
+ canvas_height = (layer_y[-1] + layer_heights[-1]) if layer_y else 1
312
+ canvas = _blank(canvas_width, max(1, canvas_height))
313
+ positions: dict[str, tuple[int, int]] = {}
314
+ node_regions: list[tuple[int, int, int, int, int]] = []
315
+ for rank, layer in enumerate(layout.ranks):
316
+ current_x = (canvas_width - layer_widths[rank]) // 2
317
+ for node_id in layer:
318
+ node_width = widths[node_id]
319
+ y = layer_y[rank]
320
+ for row, line in enumerate(boxes[node_id]):
321
+ _put_text(canvas, current_x, y + row, line)
322
+ positions[node_id] = (current_x, y)
323
+ node_regions.append((current_x, y, node_width, heights[node_id], rank))
324
+ current_x += node_width + gap_x
325
+
326
+ chars = _UNICODE if unicode else _ASCII
327
+ edge_index_by_identity = {id(edge): edge_index for edge_index, edge in enumerate(model.edges)}
328
+ incoming_edges: dict[str, list[int]] = {}
329
+ for edge_index, edge in enumerate(model.edges):
330
+ if edge.source in positions and edge.target in positions:
331
+ incoming_edges.setdefault(edge.target, []).append(edge_index)
332
+ target_ports: dict[int, int] = {}
333
+ for target, edge_indices in incoming_edges.items():
334
+ target_x, _ = positions[target]
335
+ target_width = widths[target]
336
+ inner_width = max(1, target_width - 2)
337
+ ordered_edges = sorted(
338
+ edge_indices,
339
+ key=lambda edge_index: (
340
+ model.edges[edge_index].source,
341
+ model.edges[edge_index].target,
342
+ edge_index,
343
+ ),
344
+ )
345
+ for order, edge_index in enumerate(ordered_edges):
346
+ if len(ordered_edges) == 1:
347
+ offset = inner_width // 2
348
+ else:
349
+ offset = round(order * (inner_width - 1) / (len(ordered_edges) - 1))
350
+ target_ports[edge_index] = target_x + 1 + offset
351
+
352
+ edge_rows: dict[int, dict[int, int]] = {}
353
+ for rank, edges in adjacent_edges.items():
354
+ start = layer_y[rank] + layer_heights[rank]
355
+ end = layer_y[rank + 1] - 1
356
+ rows = list(range(start, end + 1))
357
+ edge_rows[rank] = {
358
+ edge_index: rows[min(order, len(rows) - 1)]
359
+ for order, (edge_index, _) in enumerate(edges)
360
+ }
361
+ used_long_lanes: set[int] = set()
362
+ for edge in sorted(
363
+ model.edges,
364
+ key=lambda item: (
365
+ rank_by_node.get(item.target, 0) - rank_by_node.get(item.source, 0),
366
+ item.source,
367
+ item.target,
368
+ ),
369
+ reverse=True,
370
+ ):
371
+ if edge.source not in positions or edge.target not in positions:
372
+ continue
373
+ sx, sy = positions[edge.source]
374
+ tx, ty = positions[edge.target]
375
+ source_w = widths[edge.source]
376
+ target_w = widths[edge.target]
377
+ source_h = heights[edge.source]
378
+ edge_index = edge_index_by_identity[id(edge)]
379
+ start_x = sx + source_w // 2
380
+ end_x = target_ports.get(edge_index, tx + target_w // 2)
381
+ start_y, end_y = sy + source_h, ty - 1
382
+ if end_y < start_y:
383
+ continue # backward/cyclic edge: do not overwrite another box
384
+ horizontal_char = "." if edge.style == "dotted" else "=" if edge.style == "thick" else chars["h"]
385
+ vertical_char = "." if edge.style == "dotted" else "=" if edge.style == "thick" else chars["v"]
386
+ source_rank = rank_by_node[edge.source]
387
+ target_rank = rank_by_node[edge.target]
388
+ if target_rank - source_rank <= 1:
389
+ # Route each adjacent edge through its own row in the gap.
390
+ route_y = edge_rows.get(source_rank, {}).get(
391
+ edge_index,
392
+ layer_y[source_rank] + layer_heights[source_rank],
393
+ )
394
+ _draw_edge_vertical(canvas, start_x, start_y, route_y, vertical_char,
395
+ unicode=unicode, node_regions=node_regions)
396
+ _draw_edge_horizontal(canvas, route_y, start_x, end_x, horizontal_char,
397
+ unicode=unicode, node_regions=node_regions)
398
+ _draw_edge_vertical(
399
+ canvas, end_x, route_y, end_y, vertical_char,
400
+ unicode=unicode,
401
+ node_regions=node_regions,
402
+ )
403
+ else:
404
+ lanes = _safe_vertical_lanes(
405
+ canvas_width,
406
+ source_rank=source_rank,
407
+ target_rank=target_rank,
408
+ node_regions=node_regions,
409
+ )
410
+ unused_lanes = [lane for lane in lanes if lane not in used_long_lanes]
411
+ lane = _choose_vertical_lane(unused_lanes or lanes, start_x, end_x)
412
+ if lane is None:
413
+ # This should only occur for an invalid layout with no spare
414
+ # column. Keep the edge out of node cells rather than drawing
415
+ # a misleading line through another task.
416
+ continue
417
+ used_long_lanes.add(lane)
418
+ _draw_edge_horizontal(
419
+ canvas, start_y, start_x, lane, horizontal_char,
420
+ unicode=unicode,
421
+ node_regions=node_regions,
422
+ )
423
+ _draw_edge_vertical(
424
+ canvas, lane, start_y, end_y, vertical_char,
425
+ unicode=unicode,
426
+ node_regions=node_regions,
427
+ )
428
+ _draw_edge_horizontal(
429
+ canvas, end_y, lane, end_x, horizontal_char,
430
+ unicode=unicode,
431
+ node_regions=node_regions,
432
+ )
433
+ _put_edge(
434
+ canvas, end_x, end_y, chars["arrow"],
435
+ unicode=unicode,
436
+ node_regions=node_regions,
437
+ )
438
+ if edge.label:
439
+ label = _fit(edge.label, max(3, abs(end_x - start_x) - 2))
440
+ if label and end_x != start_x and target_rank - source_rank <= 1:
441
+ route_y = edge_rows.get(source_rank, {}).get(edge_index, start_y)
442
+ label_x = min(start_x, end_x) + max(1, (abs(end_x - start_x) - cell_len(label)) // 2)
443
+ _put_text(canvas, label_x, route_y, label)
444
+ return _canvas_text(canvas)
445
+
446
+
447
+ def _render_horizontal(model: FlowchartModel, layout: FlowchartLayout, *, width: int, unicode: bool) -> str:
448
+ node_by_id = {node.id: node for node in model.nodes}
449
+ gap_x, gap_y = 7, 2
450
+ widths = {node.id: _node_width(node, unicode=unicode) for node in model.nodes}
451
+ boxes = {node.id: _box(node, widths[node.id], unicode=unicode) for node in model.nodes}
452
+ heights = {node.id: len(boxes[node.id]) for node in model.nodes}
453
+ layer_widths = [max((widths[n] for n in layer), default=7) for layer in layout.ranks]
454
+ canvas_width = sum(layer_widths) + gap_x * max(0, len(layer_widths) - 1)
455
+ if canvas_width > width:
456
+ raise ValueError("flowchart width exceeded")
457
+ layer_heights = [sum(heights[node_id] for node_id in layer) + max(0, len(layer) - 1) * gap_y for layer in layout.ranks]
458
+ canvas_height = max(layer_heights or [3])
459
+ canvas = _blank(canvas_width, canvas_height)
460
+ positions: dict[str, tuple[int, int]] = {}
461
+ x = 0
462
+ for rank, layer in enumerate(layout.ranks):
463
+ layer_height = layer_heights[rank]
464
+ y = (canvas_height - layer_height) // 2
465
+ for node_id in layer:
466
+ node_w = widths[node_id]
467
+ node_x = x + (layer_widths[rank] - node_w) // 2
468
+ for row, line in enumerate(boxes[node_id]):
469
+ _put_text(canvas, node_x, y + row, line)
470
+ positions[node_id] = (node_x, y)
471
+ y += heights[node_id] + gap_y
472
+ x += layer_widths[rank] + gap_x
473
+
474
+ chars = _UNICODE if unicode else _ASCII
475
+ for edge in model.edges:
476
+ if edge.source not in positions or edge.target not in positions:
477
+ continue
478
+ sx, sy = positions[edge.source]
479
+ tx, ty = positions[edge.target]
480
+ start_x, end_x = sx + widths[edge.source], tx - 1
481
+ start_y, end_y = sy + heights[edge.source] // 2, ty + heights[edge.target] // 2
482
+ if end_x < start_x:
483
+ continue
484
+ line_char = "." if edge.style == "dotted" else "=" if edge.style == "thick" else chars["h"]
485
+ if start_y == end_y:
486
+ _draw_horizontal(canvas, start_y, start_x, end_x, line_char)
487
+ else:
488
+ mid_x = start_x + max(0, (end_x - start_x) // 2)
489
+ for x_pos in range(start_x, mid_x + 1):
490
+ _put(canvas, x_pos, start_y, line_char)
491
+ for y_pos in range(min(start_y, end_y), max(start_y, end_y) + 1):
492
+ _put(canvas, mid_x, y_pos, chars["v"] if line_char != "." else ".")
493
+ for x_pos in range(mid_x, end_x + 1):
494
+ _put(canvas, x_pos, end_y, line_char)
495
+ _put(canvas, end_x, end_y, chars["arrow_right"])
496
+ if edge.label:
497
+ label = _fit(edge.label, max(3, end_x - start_x - 2))
498
+ _put_text(canvas, start_x + 1, start_y, label)
499
+ return _canvas_text(canvas)
500
+
501
+
502
+ def _structured(model: FlowchartModel) -> str:
503
+ lines = [f"Flowchart {model.direction}"]
504
+ lines.extend(f"节点: {node.id}「{node.label}」" for node in model.nodes)
505
+ lines.extend(
506
+ f"连线: {edge.source} -> {edge.target}" + (f"「{edge.label}」" if edge.label else "")
507
+ for edge in model.edges
508
+ )
509
+ return "\n".join(lines)
510
+
511
+
512
+ def render_flowchart(
513
+ model: FlowchartModel,
514
+ *,
515
+ width: int = 120,
516
+ unicode: bool = True,
517
+ max_width: int = 160,
518
+ max_height: int = 120,
519
+ rank_by_node: dict[str, int] | None = None,
520
+ ) -> DiagramRender:
521
+ """Render a model, following the documented Unicode → ASCII → text order."""
522
+ width = max(1, min(width, max_width))
523
+ layout = layout_flowchart(model, rank_by_node=rank_by_node)
524
+ warnings = list(model.warnings)
525
+ renderers = [(unicode, "unicode"), (False, "ascii")] if unicode else [(False, "ascii")]
526
+ for use_unicode, mode in renderers:
527
+ try:
528
+ text = _render_horizontal(model, layout, width=width, unicode=use_unicode) if model.direction in ("LR", "RL") else _render_vertical(model, layout, width=width, unicode=use_unicode)
529
+ if text.count("\n") + 1 <= max_height:
530
+ if layout.cyclic:
531
+ warnings.append("检测到循环边,已使用有限布局")
532
+ render_width, render_height = _text_dimensions(text)
533
+ return DiagramRender(
534
+ text=text,
535
+ mode=mode,
536
+ warnings=tuple(warnings),
537
+ width=render_width,
538
+ height=render_height,
539
+ )
540
+ except ValueError:
541
+ continue
542
+ warnings.append("图表超出当前终端宽度或高度,已降级为结构化文本")
543
+ text = _structured(model)
544
+ render_width, render_height = _text_dimensions(text)
545
+ return DiagramRender(
546
+ text=text,
547
+ mode="structured",
548
+ warnings=tuple(warnings),
549
+ width=render_width,
550
+ height=render_height,
551
+ )