system-diff 0.4.0__tar.gz → 0.4.2__tar.gz

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 (56) hide show
  1. {system_diff-0.4.0 → system_diff-0.4.2}/PKG-INFO +5 -1
  2. {system_diff-0.4.0 → system_diff-0.4.2}/README.md +4 -0
  3. {system_diff-0.4.0 → system_diff-0.4.2}/mods/system-diff/.claude-plugin/plugin.json +1 -1
  4. {system_diff-0.4.0 → system_diff-0.4.2}/mods/system-diff/hooks/page.tsx +7 -4
  5. {system_diff-0.4.0 → system_diff-0.4.2}/mods/system-diff/hooks/pane.test.tsx +12 -0
  6. {system_diff-0.4.0 → system_diff-0.4.2}/mods/system-diff/hooks/register.tsx +19 -3
  7. {system_diff-0.4.0 → system_diff-0.4.2}/mods/system-diff/types/index.d.ts +2 -0
  8. {system_diff-0.4.0 → system_diff-0.4.2}/pyproject.toml +1 -1
  9. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/__init__.py +1 -1
  10. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/agents.py +58 -3
  11. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/canvas.py +17 -7
  12. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/cli.py +2 -1
  13. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/pane.py +14 -2
  14. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/panel.py +3 -2
  15. {system_diff-0.4.0 → system_diff-0.4.2}/tests/test_design_and_pane.py +49 -1
  16. {system_diff-0.4.0 → system_diff-0.4.2}/.claude-plugin/marketplace.json +0 -0
  17. {system_diff-0.4.0 → system_diff-0.4.2}/.gitattributes +0 -0
  18. {system_diff-0.4.0 → system_diff-0.4.2}/.github/workflows/ci.yml +0 -0
  19. {system_diff-0.4.0 → system_diff-0.4.2}/.github/workflows/release.yml +0 -0
  20. {system_diff-0.4.0 → system_diff-0.4.2}/.github/workflows/system-diff.yml +0 -0
  21. {system_diff-0.4.0 → system_diff-0.4.2}/.gitignore +0 -0
  22. {system_diff-0.4.0 → system_diff-0.4.2}/LICENSE +0 -0
  23. {system_diff-0.4.0 → system_diff-0.4.2}/docs/diagrams/system.json +0 -0
  24. {system_diff-0.4.0 → system_diff-0.4.2}/docs/diagrams/system.svg +0 -0
  25. {system_diff-0.4.0 → system_diff-0.4.2}/mods/system-diff/hooks/hooks.json +0 -0
  26. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/__main__.py +0 -0
  27. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/activity.py +0 -0
  28. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/agent.py +0 -0
  29. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/architecture.py +0 -0
  30. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/companion.py +0 -0
  31. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/compare.py +0 -0
  32. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/config.py +0 -0
  33. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/design.py +0 -0
  34. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/diff.py +0 -0
  35. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/extract.py +0 -0
  36. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/graph.py +0 -0
  37. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/inside.py +0 -0
  38. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/learn.py +0 -0
  39. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/mcp_server.py +0 -0
  40. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/narrate.py +0 -0
  41. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/pictures.py +0 -0
  42. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/png.py +0 -0
  43. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/profile.py +0 -0
  44. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/readme.py +0 -0
  45. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/sources.py +0 -0
  46. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/statusline.py +0 -0
  47. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/textmap.py +0 -0
  48. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/topics.py +0 -0
  49. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/tui.py +0 -0
  50. {system_diff-0.4.0 → system_diff-0.4.2}/src/system_diff/watch.py +0 -0
  51. {system_diff-0.4.0 → system_diff-0.4.2}/system-diff.toml +0 -0
  52. {system_diff-0.4.0 → system_diff-0.4.2}/tests/conftest.py +0 -0
  53. {system_diff-0.4.0 → system_diff-0.4.2}/tests/test_capture.py +0 -0
  54. {system_diff-0.4.0 → system_diff-0.4.2}/tests/test_checkpoint_watch_mcp.py +0 -0
  55. {system_diff-0.4.0 → system_diff-0.4.2}/tests/test_system_diff.py +0 -0
  56. {system_diff-0.4.0 → system_diff-0.4.2}/tests/test_terminal_and_learning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: system-diff
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or removes. No dependencies; SVG pictures for PRs and READMEs.
5
5
  Project-URL: Homepage, https://github.com/simplifiedmachine/system-diff
6
6
  Project-URL: Documentation, https://github.com/simplifiedmachine/system-diff/wiki
@@ -92,6 +92,8 @@ Claude is changing in it. `/system-diff close` (or "× close" in its tab row) hi
92
92
  - **Zoom:** Enter (or a second click) zooms in on a container's components, with the lines between them. `b` goes
93
93
  back, `f` follows Claude again.
94
94
  - **Scroll:** `[` `]` scroll a wide diagram across; shift ↑ ↓, Page Up/Down or the wheel scroll a tall one.
95
+ - **Zoom:** ⊖ and ⊕ in the tab row (or `-` and `=`) draw the diagrams compact (narrower boxes, no flow labels) or
96
+ tiny (titles only), to fit more of the system in a narrow pane; below 110 columns it is compact by itself.
95
97
  - **This turn**: each change to the design, and what it trades off.
96
98
  - **Learn**: how the part Claude works on is designed.
97
99
  - The principle its place calls for: an edge of the system, an adapter, a shared foundation, a coordinator.
@@ -134,6 +136,8 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
134
136
  nobody waits on anybody.
135
137
  - **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
136
138
  under Needs your attention.
139
+ - **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
140
+ turn, the edit hook tells it, once that turn, to plan a team with `plan_agents` and hand the rest to agents.
137
141
 
138
142
  The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`. It all stays in `.git/system-diff/`
139
143
  (`agents.json`, `bus.jsonl`), never pushed.
@@ -60,6 +60,8 @@ Claude is changing in it. `/system-diff close` (or "× close" in its tab row) hi
60
60
  - **Zoom:** Enter (or a second click) zooms in on a container's components, with the lines between them. `b` goes
61
61
  back, `f` follows Claude again.
62
62
  - **Scroll:** `[` `]` scroll a wide diagram across; shift ↑ ↓, Page Up/Down or the wheel scroll a tall one.
63
+ - **Zoom:** ⊖ and ⊕ in the tab row (or `-` and `=`) draw the diagrams compact (narrower boxes, no flow labels) or
64
+ tiny (titles only), to fit more of the system in a narrow pane; below 110 columns it is compact by itself.
63
65
  - **This turn**: each change to the design, and what it trades off.
64
66
  - **Learn**: how the part Claude works on is designed.
65
67
  - The principle its place calls for: an edge of the system, an adapter, a shared foundation, a coordinator.
@@ -102,6 +104,8 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
102
104
  nobody waits on anybody.
103
105
  - **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
104
106
  under Needs your attention.
107
+ - **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
108
+ turn, the edit hook tells it, once that turn, to plan a team with `plan_agents` and hand the rest to agents.
105
109
 
106
110
  The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`. It all stays in `.git/system-diff/`
107
111
  (`agents.json`, `bus.jsonl`), never pushed.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "system-diff",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "A pane beside the chat: what Claude is working on in your system, zoomed in, what just happened and why it matters, and a topic to learn (system-diff)",
5
5
  "types": "./types/index.d.ts",
6
6
  "author": {
@@ -12,7 +12,7 @@ import type { Page, PageBlock, Row, Run } from '../types'
12
12
  // the rest keeps its colours: yellow what changed, green what is new, red what went, gray the background.
13
13
 
14
14
  type Props = { page: Page; canGoBack: boolean }
15
- type State = { sel: string; sx: number; mine: boolean; ticking?: boolean }
15
+ type State = { sel: string; sx: number; mine: boolean; ticking?: boolean; free?: boolean } // free: scrolled by hand
16
16
 
17
17
  const COLORS: Record<string, string> = {
18
18
  accent: 'blue', focus: 'blue', near: 'blue', data: 'blue',
@@ -88,7 +88,7 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
88
88
  const state = surface.state
89
89
  const sel = state?.mine && byId.has(state.sel) ? state.sel : byId.has(page.pick) ? page.pick : (page.blocks[0]?.id ?? '')
90
90
  const picked = byId.get(sel)
91
- const sx = across(page, cols, state?.mine ? state.sx : 0, picked)
91
+ const sx = across(page, cols, state?.mine ? state.sx : 0, state?.free ? undefined : picked)
92
92
 
93
93
  // The pane was laid out for another width (the split moved): ask for this one once it holds, not for every
94
94
  // width a drag passes on its way.
@@ -104,7 +104,8 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
104
104
  surface.post({ width: askedWidth })
105
105
  }
106
106
  })
107
- surface.setState({ sel: state?.sel ?? '', sx: state?.sx ?? 0, mine: state?.mine ?? false, ticking: true })
107
+ surface.setState({ sel: state?.sel ?? '', sx: state?.sx ?? 0, mine: state?.mine ?? false, ticking: true,
108
+ free: state?.free })
108
109
  }
109
110
 
110
111
  const pick = (b: PageBlock | undefined, x = sx) => {
@@ -127,8 +128,10 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
127
128
  } else if (['up', 'down', 'left', 'right'].includes(e.key)) {
128
129
  pick(move(page.blocks, picked, e.key))
129
130
  } else if (e.key === '[' || e.key === ']') {
130
- surface.setState({ sel, sx: across(page, cols, sx + (e.key === '[' ? -STEP : STEP), undefined), mine: true,
131
+ surface.setState({ sel, sx: across(page, cols, sx + (e.key === '[' ? -STEP : STEP), undefined), mine: true, free: true,
131
132
  ticking: true })
133
+ } else if (e.key === '-' || e.key === '=' || e.key === '+') {
134
+ surface.post({ zoomBy: e.key === '-' ? 1 : -1 }) // - shows more of the system, = bigger boxes
132
135
  } else if (e.key === 'return') {
133
136
  act(picked, 'enter')
134
137
  } else if (e.key === ' ') {
@@ -185,3 +185,15 @@ test('/system-diff close closes the pane, and so does its close button', async (
185
185
  expect(closed).toEqual(['system-diff', 'system-diff'])
186
186
  await ui.unmount()
187
187
  })
188
+
189
+ test('zoom out draws the diagrams compact, zoom in full again', async ($, on) => {
190
+ const asked: string[][] = []
191
+ answer(on, asked)
192
+ await $.command.run(RUN)
193
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
194
+ await ui.press({ key: 'zoom-out' })
195
+ expect(asked[asked.length - 1]?.join(' ')).toContain('--zoom 1')
196
+ await ui.key({ key: '=', in: 'page' })
197
+ expect(asked[asked.length - 1]?.join(' ')).toContain('--zoom 0')
198
+ await ui.unmount()
199
+ })
@@ -14,7 +14,7 @@ import type { Act, Page, Row } from '../types'
14
14
  // green what is new, done or to learn, white what to read, gray the background, red only what was taken away.
15
15
 
16
16
  const PANE = 'system-diff'
17
- const VERSION = '0.4.0' // the plugin's own, as .claude-plugin/plugin.json says (a test keeps them one)
17
+ const VERSION = '0.4.2' // the plugin's own, as .claude-plugin/plugin.json says (a test keeps them one)
18
18
  const WIDEST = 999 // as wide as Claude Code docks a pane: 45% of the terminal
19
19
  const COMMANDS = ['system-diff', 'system-diff-pane'] as const
20
20
  const VIEWS = [['now', 'Now', 'n'], ['map', 'Map', 'm'], ['agents', 'Agents', 'a'], ['changes', 'Changes', 'c'],
@@ -25,6 +25,9 @@ const view = atom({ plugin: 'system-diff', key: 'view' } as const, 'now', { shap
25
25
  const part = atom({ plugin: 'system-diff', key: 'part' } as const, '', { shape: 'part-1' })
26
26
  const back = atom({ plugin: 'system-diff', key: 'back' } as const, [] as string[], { shape: 'back-2' })
27
27
  const note = atom({ plugin: 'system-diff', key: 'note' } as const, 'Reading the system…', { shape: 'note-1' })
28
+ // How compact the diagrams are: -1 by the pane's width (compact below 110 columns), 0 full, 1 compact, 2 tiny.
29
+ const zoomLevel = atom({ plugin: 'system-diff', key: 'zoom' } as const, -1, { shape: 'zoom-1' })
30
+ const ZOOM_WORD = ['full', 'compact', 'tiny']
28
31
 
29
32
  // How to run system-diff: the repository's own virtualenv first, then whatever is on PATH.
30
33
  const RUNNERS: string[][] = [
@@ -73,6 +76,8 @@ async function refresh($: EngineInterface): Promise<void> {
73
76
  again = false
74
77
  const argv = ['panel', '--protocol', '2', '--width', String(columns), '--view', await read($, view),
75
78
  '--part', await read($, part)]
79
+ const level = await read($, zoomLevel)
80
+ if (level >= 0) argv.push('--zoom', String(level)) // left out, the engine picks by the width (older ones too)
76
81
  const got = await run($, argv)
77
82
  if (got && !got.old) {
78
83
  if (got.out !== last) {
@@ -155,6 +160,13 @@ async function scrollBy($: EngineInterface, rows: number): Promise<void> {
155
160
 
156
161
  // As wide as Claude Code docks a pane, the first time: a width the person set (dragged, or with the pane's keys:
157
162
  // ctrl+x ← →) is theirs from then on.
163
+ // More of the system in the pane (zoom out, +1) or bigger boxes (zoom in, -1), from where the page is drawn now.
164
+ async function zoomBy($: EngineInterface, by: number): Promise<void> {
165
+ const drawnAt = (await read($, page))?.zoom ?? 0
166
+ await update($, zoomLevel, () => Math.max(0, Math.min(2, drawnAt + by)))
167
+ await refresh($)
168
+ }
169
+
158
170
  function open($: EngineInterface) {
159
171
  return $.ui.open({ id: PANE, title: 'System diff', columns: WIDEST })
160
172
  }
@@ -213,7 +225,7 @@ export const register: Register = on => {
213
225
  // What the page (page.tsx) asks for.
214
226
  on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
215
227
  const data = (e.data ?? {}) as Act & { back?: boolean; follow?: boolean; width?: number; reveal?: number;
216
- scrollBy?: number }
228
+ scrollBy?: number; zoomBy?: number }
217
229
  if (typeof data.width === 'number' && data.width > 0 && data.width !== columns) {
218
230
  columns = data.width
219
231
  await refresh($)
@@ -223,6 +235,7 @@ export const register: Register = on => {
223
235
  else if (typeof data.learn === 'string') await learn($, data.learn)
224
236
  else if (data.back) await goBack($)
225
237
  else if (data.follow) await follow($)
238
+ else if (typeof data.zoomBy === 'number') await zoomBy($, data.zoomBy)
226
239
  else if (typeof data.view === 'string') await choose($, data.view)
227
240
  return {}
228
241
  })
@@ -278,11 +291,14 @@ export const register: Register = on => {
278
291
  {behind.length > 0 && (
279
292
  <Button key="back" hotkey="b" plain onPress={() => void goBack($)}><Text dimColor>← Back</Text></Button>
280
293
  )}
294
+ <Button key="zoom-out" plain onPress={() => void zoomBy($, 1)}><Text dimColor>⊖</Text></Button>
295
+ <Text dimColor>{ZOOM_WORD[drawn?.zoom ?? 0] ?? 'full'}</Text>
296
+ <Button key="zoom-in" plain onPress={() => void zoomBy($, -1)}><Text dimColor>⊕</Text></Button>
281
297
  <Button key="close" plain onPress={() => void $.ui.close({ id: PANE })}><Text dimColor>× close</Text></Button>
282
298
  </Box>
283
299
  {said !== '' && <Text dimColor>{said}</Text>}
284
300
  {drawn !== null && Client !== undefined && (
285
- <Client key="page" module="./page.tsx" props={{ page: drawn, canGoBack: behind.length > 0 }} />
301
+ <Client key="page" module="./page.tsx" width="100%" props={{ page: drawn, canGoBack: behind.length > 0 }} />
286
302
  )}
287
303
  {drawn !== null && Client === undefined && drawn.rows.map(line)}
288
304
  {drawn !== null && Client === undefined && (
@@ -24,6 +24,7 @@ export type Page = {
24
24
  lights: Record<string, string[]>
25
25
  details: Record<string, Row[]>
26
26
  pick: string
27
+ zoom?: number // how compact it is drawn: 0 full, 1 compact, 2 tiny
27
28
  }
28
29
 
29
30
  declare module 'claude-code' {
@@ -34,6 +35,7 @@ declare module 'claude-code' {
34
35
  part: Shaped<string>
35
36
  back: Shaped<string[]>
36
37
  note: Shaped<string>
38
+ zoom: Shaped<number>
37
39
  }
38
40
  }
39
41
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "system-diff"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or removes. No dependencies; SVG pictures for PRs and READMEs."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,4 +5,4 @@
5
5
  bp = extract.extract(DirSource(Path(".")), cfg)
6
6
  svg = graph.system_svg(bp, cfg)
7
7
  """
8
- __version__ = "0.4.0"
8
+ __version__ = "0.4.2"
@@ -24,6 +24,7 @@ from .sources import git_dir
24
24
 
25
25
  STATE, BUS = "agents.json", "bus.jsonl"
26
26
  MAIN = "main"
27
+ SYSTEM = "system-diff" # the sender of what system-diff itself publishes
27
28
  KEEP = 2000 # messages kept on the bus
28
29
  MOST_AGENTS = 4 # a plan's agents at most, unless asked for more
29
30
 
@@ -33,10 +34,11 @@ def _load(root: Path) -> Dict[str, Any]:
33
34
  data = json.loads((git_dir(root) / STATE).read_text(encoding="utf-8"))
34
35
  if isinstance(data, dict):
35
36
  return {"plan": data.get("plan") or {}, "claims": data.get("claims") or {},
36
- "agents": data.get("agents") or {}, "ids": data.get("ids") or {}}
37
+ "agents": data.get("agents") or {}, "ids": data.get("ids") or {},
38
+ "warned": data.get("warned") or {}}
37
39
  except (OSError, ValueError):
38
40
  pass
39
- return {"plan": {}, "claims": {}, "agents": {}, "ids": {}}
41
+ return {"plan": {}, "claims": {}, "agents": {}, "ids": {}, "warned": {}}
40
42
 
41
43
 
42
44
  def _save(root: Path, data: Dict[str, Any]) -> None:
@@ -182,7 +184,60 @@ def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[
182
184
  if part and claimed and claimed != name:
183
185
  warn = f"{part} is claimed by agent {claimed}; {name} just edited it"
184
186
  publish(root, f"agent:{claimed}", f"{name} edited your block {part}", sender=name, kind="warning")
185
- return {"context": as_context(inbox(root, name)), "warn": warn, "name": name}
187
+ from . import config
188
+ wide = spread(root, name, agent_id, bp, config.lenient(root))
189
+ # spread warnings reach the agent they are about directly; on the bus they are a record for the inbox tool
190
+ unread = [m for m in inbox(root, name) if not (m.get("from") == SYSTEM and m.get("kind") == "warning")]
191
+ context = "\n".join(t for t in (as_context(unread), wide) if t)
192
+ warn = "; ".join(t for t in (warn, wide) if t)
193
+ return {"context": context, "warn": warn, "name": name}
194
+
195
+
196
+ def areas_of(root: Path, bp: dict, cfg: Dict[str, Any]) -> Dict[str, str]:
197
+ """Each block's area: the agent the saved plan gave it to, when there is a plan; else its container, or the
198
+ block itself when it is in none."""
199
+ agents = (_load(root)["plan"] or {}).get("agents") or []
200
+ if agents:
201
+ return {b: g["name"] for g in agents for b in g.get("blocks") or []}
202
+ from .architecture import groups_of
203
+ group = groups_of(bp, cfg, design.kinds(bp, cfg))
204
+ return {cid: (group[cid][2:] if group.get(cid, "").startswith("g:") else cid) for cid in bp["components"]}
205
+
206
+
207
+ def spread(root: Path, name: str, agent_id: str, bp: dict, cfg: Dict[str, Any]) -> str:
208
+ """When one agent has edited blocks in two or more areas this turn: a message telling it to plan a team and hand
209
+ the rest on, once per agent per turn (also published to all); else ""."""
210
+ from .extract import Code
211
+ code = Code(cfg)
212
+ me = "" if agent_id in ("", None, MAIN) else agent_id
213
+ start, parts = 0.0, []
214
+ for e in activity.events(root, 400):
215
+ if e.get("event") == activity.PROMPT and not activity.is_system(e.get("text", "")):
216
+ start, parts = e["at"], []
217
+ elif e.get("event") == activity.EDIT and (e.get("agent") or "") == me and e.get("path"):
218
+ part = activity.part_of(e["path"], code)
219
+ if part and part not in parts:
220
+ parts.append(part)
221
+ area = areas_of(root, bp, cfg)
222
+ seen: List[str] = []
223
+ for p in parts:
224
+ a = area.get(p, p)
225
+ if a not in seen:
226
+ seen.append(a)
227
+ if len(seen) < 2:
228
+ return ""
229
+ data = _load(root)
230
+ turn = str(start)
231
+ told = data["warned"].get(turn) or []
232
+ if name in told:
233
+ return ""
234
+ data["warned"] = {turn: told + [name]} # only the latest turn is kept
235
+ _save(root, data)
236
+ text = (f"{name} has edited blocks in {len(seen)} areas this turn ({', '.join(seen)}). system-diff runs work "
237
+ "that spans areas as a team: call plan_agents with what is left, launch its agents in parallel, and "
238
+ "keep to one area yourself.")
239
+ publish(root, "all", text, sender=SYSTEM, kind="warning")
240
+ return text
186
241
 
187
242
 
188
243
  def who_worked(events: Iterable[dict], code) -> Dict[str, Dict[str, Any]]:
@@ -138,8 +138,12 @@ def title_style(n: dict) -> str:
138
138
  return f"title bold {st}" if st != "same" else "title"
139
139
 
140
140
 
141
+ ZOOMS = {0: (20, 32, 2, 6, 4), 1: (12, 22, 0, 5, 3), 2: (8, 16, 0, 5, 3)} # min box, max box, rows, channel, gap
142
+ # by zoom: full, compact, tiny
143
+
144
+
141
145
  def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]], *, width: int = 100,
142
- centre: bool = False, most_lines: int = 2) -> Grid:
146
+ centre: bool = False, most_lines: int = 2, zoom: int = 0) -> Grid:
143
147
  """A system diagram: `nodes` (id, col, title, sub, status, type: actor|component|group|store|host; `agent` where
144
148
  the agent works, `focus` for the box zoomed in on, `items` for the components a container holds, `lines` for
145
149
  rows inside a box) in `columns` order, joined by `edges` (from, to, kind, status, label; `from_item`/`to_item`:
@@ -150,6 +154,12 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
150
154
  present = [k for k, _h in columns if any(n["col"] == k for n in nodes)]
151
155
  if not present:
152
156
  return g
157
+ least, most_w, rows_in, chan_min, chan_pad = ZOOMS.get(zoom, ZOOMS[0])
158
+ most_lines = min(most_lines, rows_in)
159
+ if zoom: # compact: no labels on flows (a picked box's detail names them), tiny: no subtitles either
160
+ edges = [{**e, "label": ""} for e in edges]
161
+ if zoom >= 2:
162
+ nodes = [{**n, "sub": ""} for n in nodes]
153
163
  heading = {k: h for k, h in columns}
154
164
  ci = {k: i for i, k in enumerate(present)}
155
165
  col_nodes = [[n for n in nodes if n["col"] == k] for k in present]
@@ -181,13 +191,13 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
181
191
  item_row = {it["id"]: (n["id"], 3 + j) for n in nodes for j, it in enumerate(n.get("items") or [])}
182
192
 
183
193
  # widths: as wide as the longest row wants, narrowed together when the whole is wider than the pane
184
- want = [min(32, max([len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][:1]]
185
- + [len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][2:]]
186
- + [len(heading[present[i]]) + 1, 14]))
194
+ want = [min(most_w, max([len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][:1]]
195
+ + [len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][2:]]
196
+ + [min(len(heading[present[i]]) + 1, most_w), least]))
187
197
  for i, col in enumerate(col_nodes)]
188
198
  label_w = max([len(e.get("label") or "") for e in edges] + [0])
189
- gaps = (8 + label_w) * len(present)
190
- while sum(want) + gaps > width and max(want) > 20:
199
+ gaps = (chan_min + 2 + label_w) * len(present)
200
+ while sum(want) + gaps > width and max(want) > least:
191
201
  biggest = max(range(len(want)), key=lambda i: want[i])
192
202
  want[biggest] -= 1
193
203
  heights = {n["id"]: 2 + len(contents[n["id"]]) for n in nodes}
@@ -320,7 +330,7 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
320
330
  if e.get("label") and cs != ct:
321
331
  label_in[hops[-1][0]] = max(label_in[hops[-1][0]], len(e["label"]) + 2)
322
332
  widths = want
323
- chan = [max(6, len(lanes[k]) + 4 + label_in[k]) for k in range(len(present))]
333
+ chan = [max(chan_min, len(lanes[k]) + chan_pad + label_in[k]) for k in range(len(present))]
324
334
  if not lanes[len(present) - 1]:
325
335
  chan[-1] = 0
326
336
  xs, x = [], 0
@@ -195,6 +195,7 @@ def parser() -> argparse.ArgumentParser:
195
195
  pn.add_argument("--width", type=int, default=80)
196
196
  pn.add_argument("--view", default="now", choices=["now", "map", "changes", "topics", "agents"])
197
197
  pn.add_argument("--part", default="", help="the part to zoom in on (default: the one the agent works on)")
198
+ pn.add_argument("--zoom", type=int, default=-1, help="diagrams 0 full, 1 compact, 2 tiny; -1 by the width")
198
199
  pn.add_argument("--protocol", type=int, default=1, help="2: a page of runs and pickable blocks (the mod's painter)")
199
200
  pf = sub.add_parser("profile", help="what you want explained: technologies marked new to you")
200
201
  pf.add_argument("--learn", metavar="TECH", help="mark a technology new to you, or unmark it")
@@ -254,7 +255,7 @@ def main(argv: Optional[List[str]] = None) -> int:
254
255
  return agent.run_hook(sys.stdin.read() if not sys.stdin.isatty() else "")
255
256
  if args.cmd == "panel":
256
257
  from . import panel
257
- return panel.main(root, args.width, args.view, args.part, args.protocol)
258
+ return panel.main(root, args.width, args.view, args.part, args.protocol, args.zoom)
258
259
  if args.cmd == "profile":
259
260
  from . import profile
260
261
  if args.learn:
@@ -35,7 +35,7 @@ def _name(nid: str, *bps: dict) -> str:
35
35
  def _diagram(page: canvas.Page, c, model: Dict[str, Any], width: int, centre: bool = False) -> canvas.Grid:
36
36
  """The diagram on the page, every box a block: Enter zooms in on it; its detail says what it uses and what uses
37
37
  it, all of it (the diagram draws each box's main flows)."""
38
- g = canvas.layered(model["nodes"], model["edges"], model["columns"], width=width, centre=centre)
38
+ g = canvas.layered(model["nodes"], model["edges"], model["columns"], width=width, centre=centre, zoom=ZOOM)
39
39
  conns = graph.connections(c.after)
40
40
  names = {n["id"]: n["title"] for n in model["nodes"]}
41
41
  names.update({i["id"]: i["title"] for n in model["nodes"] for i in n.get("items") or []})
@@ -476,7 +476,19 @@ def view_agents(root: Path, width: int) -> Dict[str, Any]:
476
476
  return page.json(view="agents", part="")
477
477
 
478
478
 
479
- def show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None) -> Dict[str, Any]:
479
+ ZOOM = 0 # how compact the diagrams are drawn: 0 full, 1 compact, 2 tiny (show sets it)
480
+
481
+
482
+ def show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None, zoom: int = -1) -> Dict[str, Any]:
483
+ """The page for a view. `zoom` -1: compact below 110 columns, full above."""
484
+ global ZOOM
485
+ ZOOM = (1 if width < 110 else 0) if zoom < 0 else max(0, min(2, zoom))
486
+ out = _show(root, width, view, part)
487
+ out["zoom"] = ZOOM
488
+ return out
489
+
490
+
491
+ def _show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None) -> Dict[str, Any]:
480
492
  if view == "agents":
481
493
  return view_agents(root, width)
482
494
  if view == "map":
@@ -232,10 +232,11 @@ def rows(root: Path, width: int = 80) -> List[textmap.Line]:
232
232
  return got["head"] + got["rows"]
233
233
 
234
234
 
235
- def main(root: Path, width: int, view: str = "now", part: Optional[str] = None, protocol: int = 1) -> int:
235
+ def main(root: Path, width: int, view: str = "now", part: Optional[str] = None, protocol: int = 1,
236
+ zoom: int = -1) -> int:
236
237
  if protocol >= 2:
237
238
  from . import pane
238
- print(json.dumps(pane.show(root, width, view, part or None), ensure_ascii=False))
239
+ print(json.dumps(pane.show(root, width, view, part or None, zoom), ensure_ascii=False))
239
240
  return 0
240
241
  got = show(root, width, view, part or None)
241
242
  for key in ("head", "rows"):
@@ -18,7 +18,7 @@ REFUND = ('from fastapi import APIRouter\nrouter = APIRouter()\n\n\n@router.post
18
18
 
19
19
 
20
20
  def page(capsys, repo, *args):
21
- assert main(["--repo", str(repo), "panel", "--width", "100", "--protocol", "2", *args]) == 0
21
+ assert main(["--repo", str(repo), "panel", "--width", "100", "--protocol", "2", "--zoom", "0", *args]) == 0
22
22
  out = json.loads(capsys.readouterr().out)
23
23
  return out, ["".join(run[0] for run in row) for row in out["rows"]]
24
24
 
@@ -268,3 +268,51 @@ def test_the_agents_tab_shows_each_agent_and_warns_when_two_edit_one_block(repo,
268
268
  assert any(t.startswith("THE TEAM plan: make the API") for t in text)
269
269
  assert any("api and store all edit API" in t for t in text)
270
270
  assert "● API @api @st" in "\n".join(text) # who works where, on the diagram
271
+
272
+
273
+ def test_one_agent_editing_two_areas_in_a_turn_is_told_once_to_plan_a_team(repo, files):
274
+ from system_diff import agent
275
+
276
+ def prompt():
277
+ agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "UserPromptSubmit", "prompt": "go"}),
278
+ emit=lambda _s: None)
279
+
280
+ def edit(path, text, **who):
281
+ files(repo, {path: text})
282
+ said: list = []
283
+ agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "PostToolUse", "tool_name": "Edit", **who,
284
+ "tool_input": {"file_path": str(repo / path)}}), emit=said.append)
285
+ outs = [json.loads(s) for s in said]
286
+ warns = " ".join(o.get("systemMessage", "") for o in outs)
287
+ context = " ".join(o.get("hookSpecificOutput", {}).get("additionalContext", "") for o in outs)
288
+ return warns, context
289
+
290
+ routes, rules = "app/api/routes.py", "app/core/rules.py"
291
+ prompt()
292
+ warns, context = edit(routes, (repo / routes).read_text() + "\n# one\n")
293
+ assert "areas this turn" not in warns + context # one area so far
294
+ warns, context = edit(rules, (repo / rules).read_text() + "\n# one\n")
295
+ assert "main has edited blocks in 2 areas this turn" in warns and "plan_agents" in warns
296
+ assert "main has edited blocks in 2 areas this turn" in context and "plan_agents" in context
297
+ warns, context = edit("app/db/repo.py", (repo / "app/db/repo.py").read_text() + "\n# one\n")
298
+ assert "areas this turn" not in warns + context # once a turn
299
+ prompt()
300
+ edit(routes, (repo / routes).read_text() + "# two\n")
301
+ warns, context = edit(rules, (repo / rules).read_text() + "# two\n")
302
+ assert "areas this turn" in warns and "areas this turn" in context # a new turn may warn again
303
+ prompt()
304
+ for text in ("# a\n", "# b\n"): # a subagent staying in its one area
305
+ warns, context = edit(routes, (repo / routes).read_text() + text, agent_id="a1")
306
+ assert "areas this turn" not in warns and "a1 has edited" not in context # main's warning may reach it by bus
307
+
308
+
309
+ def test_a_narrow_pane_gets_compact_diagrams_unless_asked_otherwise(repo, capsys):
310
+ narrow = json.loads(_run(capsys, repo, "--width", "90"))
311
+ wide = json.loads(_run(capsys, repo, "--width", "140"))
312
+ assert narrow["zoom"] == 1 and wide["zoom"] == 0
313
+ assert json.loads(_run(capsys, repo, "--width", "90", "--zoom", "2"))["zoom"] == 2
314
+
315
+
316
+ def _run(capsys, repo, *args):
317
+ assert main(["--repo", str(repo), "panel", "--protocol", "2", *args]) == 0
318
+ return capsys.readouterr().out
File without changes
File without changes
File without changes