agentmon 0.1.0

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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/exe/agentmon +18 -0
  6. data/lib/agentmon/commands/cli_options.rb +21 -0
  7. data/lib/agentmon/commands/footprint.rb +81 -0
  8. data/lib/agentmon/commands/memory.rb +110 -0
  9. data/lib/agentmon/commands/record.rb +118 -0
  10. data/lib/agentmon/commands/report.rb +127 -0
  11. data/lib/agentmon/commands/sessions.rb +98 -0
  12. data/lib/agentmon/commands/top.rb +87 -0
  13. data/lib/agentmon/commands/tree.rb +96 -0
  14. data/lib/agentmon/darwin.rb +235 -0
  15. data/lib/agentmon/engine.rb +115 -0
  16. data/lib/agentmon/focus.rb +88 -0
  17. data/lib/agentmon/metrics/memory.rb +76 -0
  18. data/lib/agentmon/metrics/network.rb +245 -0
  19. data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
  20. data/lib/agentmon/metrics/process_rates.rb +65 -0
  21. data/lib/agentmon/metrics/process_rows.rb +41 -0
  22. data/lib/agentmon/metrics/session_ledger.rb +160 -0
  23. data/lib/agentmon/metrics/session_memory.rb +84 -0
  24. data/lib/agentmon/metrics/session_names.rb +169 -0
  25. data/lib/agentmon/metrics/sessions.rb +80 -0
  26. data/lib/agentmon/metrics/system.rb +114 -0
  27. data/lib/agentmon/model.rb +263 -0
  28. data/lib/agentmon/probes/cwd.rb +32 -0
  29. data/lib/agentmon/probes/memory.rb +139 -0
  30. data/lib/agentmon/probes/network.rb +326 -0
  31. data/lib/agentmon/probes/processes.rb +72 -0
  32. data/lib/agentmon/probes/system.rb +134 -0
  33. data/lib/agentmon/program.rb +55 -0
  34. data/lib/agentmon/reading.rb +83 -0
  35. data/lib/agentmon/recorders/memory.rb +19 -0
  36. data/lib/agentmon/recorders/sessions.rb +40 -0
  37. data/lib/agentmon/registry.rb +155 -0
  38. data/lib/agentmon/sampler.rb +50 -0
  39. data/lib/agentmon/store.rb +86 -0
  40. data/lib/agentmon/ui/connections.rb +75 -0
  41. data/lib/agentmon/ui/interaction.rb +94 -0
  42. data/lib/agentmon/ui/memory.rb +125 -0
  43. data/lib/agentmon/ui/process_actions.rb +49 -0
  44. data/lib/agentmon/ui/process_detail.rb +148 -0
  45. data/lib/agentmon/ui/process_network.rb +13 -0
  46. data/lib/agentmon/ui/process_scopes.rb +32 -0
  47. data/lib/agentmon/ui/process_waits.rb +99 -0
  48. data/lib/agentmon/ui/processes.rb +42 -0
  49. data/lib/agentmon/ui/session_focus.rb +126 -0
  50. data/lib/agentmon/ui/session_memory.rb +78 -0
  51. data/lib/agentmon/ui/sessions.rb +130 -0
  52. data/lib/agentmon/ui/theme.rb +49 -0
  53. data/lib/agentmon/ui.rb +80 -0
  54. data/lib/agentmon/version.rb +5 -0
  55. data/lib/agentmon/views/dense.rb +157 -0
  56. data/lib/agentmon/views/history.rb +62 -0
  57. data/lib/agentmon/views/signals.rb +279 -0
  58. data/lib/agentmon/views/visual.rb +172 -0
  59. data/lib/agentmon/views/widgets.rb +352 -0
  60. data/lib/agentmon/views.rb +875 -0
  61. data/lib/agentmon.rb +38 -0
  62. metadata +135 -0
@@ -0,0 +1,875 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "views/signals"
4
+ require_relative "views/history"
5
+ require_relative "views/widgets"
6
+
7
+ # Performance views: a small DSL for dashboards of the machine, a process or a session, on top of
8
+ # r2ui's rows and panels (docs/views.md is the contract). A ten-line view already looks finished:
9
+ #
10
+ # Agentmon.view :mine, title: "agentmon" do
11
+ # row height: 8 do
12
+ # panel :cpu, title: "CPU" do
13
+ # meter "system.cpu" # CPU ▕████▎ ▏ 42.0%
14
+ # trend "system.cpu", height: 4 # braille area, the last four minutes
15
+ # end
16
+ # panel :memory, title: "Memory" do
17
+ # meter "memory.used" # used ▕██████ ▏ 23G / 32G 71%
18
+ # stat "memory.app", "memory.wired", "memory.compressed", "memory.swap_used"
19
+ # end
20
+ # end
21
+ # row { top :process, by: :cpu, columns: %i[name session cpu footprint] }
22
+ # end
23
+ #
24
+ # agentmon --layout mine
25
+ #
26
+ # Words (all take signals, "metric.member", with defaults from views/signals.rb):
27
+ # view-level row, focused { rows shown instead while a session is focused } (Views::Drill)
28
+ # row-level panel, use :name (a registered agentmon panel), top, band, detail
29
+ # in a panel meter, trend, spark, stat, top, band, detail
30
+ #
31
+ # How it maps onto r2ui: `Agentmon.view` records a View (this file's builders; r2ui's own
32
+ # PanelBuilder already has `stat`, so the view words live on agentmon's builders, not r2ui's).
33
+ # `UI.install(engine:, view:)` turns it into the :agentmon r2ui dashboard: `top` becomes
34
+ # `table(columns:, sort:, limit:, motion: true)`, every other word a panel item drawn by the
35
+ # :agentmon_views extension below through Widgets (pure strings). Panels with only view words get
36
+ # the hidden :view_signals resource, whose feed keeps the engine sampling in the background (and
37
+ # so marks new frames due). Values are read from `engine.current(max_age: ∞)`, never sampled on
38
+ # the drawing thread.
39
+ module Agentmon
40
+ module Views
41
+ # --- the recorded view ---
42
+
43
+ # `rows`: the home rows; `focused`: the rows shown instead while a session is focused
44
+ # (`focused do ... end`; empty when the view has no drill-down).
45
+ View = Data.define(:name, :title, :rows, :focused) do
46
+ def initialize(name:, title:, rows:, focused: [].freeze) = super
47
+ def drill? = !focused.empty?
48
+ def all_rows = rows + focused
49
+ end
50
+ RowDef = Data.define(:height, :panels)
51
+ PanelDef = Data.define(:name, :title, :span, :resource, :items, :options)
52
+ UseDef = Data.define(:name, :span, :title)
53
+
54
+ # `part`: a Signal stacked into the bar from the left (a share of the meter's whole), or nil.
55
+ Meter = Data.define(:signal, :heat, :part) do
56
+ def initialize(signal:, heat:, part: nil) = super
57
+ end
58
+ Trend = Data.define(:signals, :height)
59
+ Spark = Data.define(:signal)
60
+ Stat = Data.define(:signals, :columns)
61
+ # `widths`: { column => cells } fixes text columns and cuts their values at word boundaries.
62
+ # `spark`: true puts a braille sparkline on the sort column, false on none (narrow panels), an
63
+ # Array of column keys on those columns.
64
+ Top = Data.define(:resource, :by, :limit, :columns, :group_by, :widths, :spark) do
65
+ # The columns that get a sparkline.
66
+ def spark_columns
67
+ case spark
68
+ when true then [by].compact.map(&:to_sym)
69
+ when false, nil then []
70
+ else Array(spark).map(&:to_sym)
71
+ end
72
+ end
73
+ end
74
+ Band = Data.define(:resource, :show)
75
+ Detail = Data.define(:resource, :columns)
76
+
77
+ SIGNALS = :view_signals
78
+ FPS = 10
79
+ PULSE_SECONDS = 0.5
80
+ TWEEN_SECONDS = 0.35
81
+
82
+ # Prepended to a view app's Motion: `hold` (r2ui's table gutter) no longer keeps frames due.
83
+ module NoHold
84
+ def hold(_seconds) = nil
85
+ end
86
+ MIN_TILE = 18 # room for a "claude 12345" title uncut
87
+
88
+ class << self
89
+ # The view UI.install is building (nil for the default dashboard): resource decorations
90
+ # (heat styles, braille sort column, priorities) apply only then.
91
+ attr_accessor :installing
92
+
93
+ # --no-motion / AGENTMON_MOTION=0 turn animation off (R2UI::Motion#enabled).
94
+ attr_writer :motion
95
+
96
+ def motion? = @motion.nil? ? ENV["AGENTMON_MOTION"] != "0" : @motion
97
+
98
+ def build(name, title:, &block)
99
+ builder = ViewBuilder.new(title)
100
+ builder.instance_eval(&block)
101
+ View.new(name: name.to_sym, title: builder.title, rows: builder.rows.freeze,
102
+ focused: builder.focused_rows.freeze)
103
+ end
104
+
105
+ def signal(name, **) = Signals.parse(name.to_s, **)
106
+
107
+ # Every Top item of `view` for `resource` (home and focused rows).
108
+ def tops(view, resource)
109
+ view.all_rows.flat_map(&:panels).grep(PanelDef).flat_map(&:items).grep(Top).select { |t| t.resource == resource }
110
+ end
111
+ end
112
+
113
+ class ViewBuilder
114
+ attr_reader :rows, :focused_rows
115
+
116
+ def initialize(title)
117
+ @title = title
118
+ @rows = []
119
+ @focused_rows = []
120
+ @in_focused = false
121
+ end
122
+
123
+ def title(text = nil)
124
+ @title = text if text
125
+ @title
126
+ end
127
+
128
+ # Inside `focused`, rows go to the drill-down instead of the home rows.
129
+ def row(height: nil, &block)
130
+ builder = RowBuilder.new
131
+ builder.instance_eval(&block)
132
+ (@in_focused ? @focused_rows : @rows) << RowDef.new(height:, panels: builder.panels.freeze)
133
+ end
134
+
135
+ # The rows shown instead of the home rows while a session is focused (engine.focus): Enter on
136
+ # the Sessions table (or the band) drills in, Escape comes back. Once per view, not nested.
137
+ def focused(&block)
138
+ raise ArgumentError, "focused: needs a block of rows" unless block
139
+ raise ArgumentError, "focused: not inside another focused" if @in_focused
140
+ raise ArgumentError, "focused: only once per view" if @focused_rows.any?
141
+
142
+ @in_focused = true
143
+ instance_eval(&block)
144
+ ensure
145
+ @in_focused = false
146
+ end
147
+ end
148
+
149
+ class RowBuilder
150
+ attr_reader :panels
151
+
152
+ def initialize = @panels = []
153
+
154
+ # A panel of view words. `resource:` is set by a `top` inside, else the hidden signals feed.
155
+ def panel(name, title: nil, span: 1, resource: nil, **options, &block)
156
+ builder = PanelBuilder.new
157
+ builder.instance_eval(&block) if block
158
+ items = builder.items.freeze
159
+ resource ||= items.grep(Top).first&.resource || SIGNALS
160
+ @panels << PanelDef.new(name: name.to_sym, title:, span:, resource:, items:, options:)
161
+ end
162
+
163
+ # An existing registered agentmon panel (:memory, :session, :process, :detail, ...).
164
+ def use(name, span: nil, title: nil) = @panels << UseDef.new(name: name.to_sym, span:, title:)
165
+
166
+ # Row-level shortcuts: one panel named after what it shows.
167
+ def top(resource, span: 1, title: nil, **)
168
+ panel(resource, title:, span:, resource:) { top(resource, **) }
169
+ end
170
+
171
+ def band(resource = :session, span: 1, title: "Sessions", **)
172
+ panel(:band, title:, span:) { band(resource, **) }
173
+ end
174
+
175
+ def detail(resource = :process, span: 1, title: "Detail", **)
176
+ panel(:detail, title:, span:) { detail(resource, **) }
177
+ end
178
+ end
179
+
180
+ class PanelBuilder
181
+ attr_reader :items
182
+
183
+ def initialize = @items = []
184
+
185
+ # `part:` stacks another signal into the bar: `meter "memory.used", part: "focus.footprint"`
186
+ # draws the part from the left in the accent, the rest of used dim, free memory empty, and
187
+ # the text "part share · used/total" (the share is of the total). Needs a total (`of:`).
188
+ def meter(sig, of: nil, max: nil, label: nil, unit: nil, heat: true, part: nil)
189
+ signal = Views.signal(sig, of:, max:, label:, unit:)
190
+ raise ArgumentError, "meter #{sig} part: needs a total (of:)" if part && signal.of.nil?
191
+
192
+ @items << Meter.new(signal:, heat:, part: part && Views.signal(part))
193
+ end
194
+
195
+ # Several signals stack, sharing `height` chart lines (nil: the rest of the panel; a Float
196
+ # below 1: that share of the lines left, labels included, e.g. 0.6 above a `top`).
197
+ def trend(*sigs, height: 3, label: nil, max: nil, unit: nil)
198
+ @items << Trend.new(signals: sigs.map { |s| Views.signal(s, label:, max:, unit:) }.freeze, height:)
199
+ end
200
+
201
+ def spark(sig, label: nil, max: nil, unit: nil) = @items << Spark.new(signal: Views.signal(sig, label:, max:, unit:))
202
+
203
+ def stat(*sigs, columns: 2) = @items << Stat.new(signals: sigs.map { |s| Views.signal(s) }.freeze, columns:)
204
+
205
+ # `spark:` true (the `by:` column), false (none) or column keys (`%i[cpu footprint]`).
206
+ def top(resource, by: nil, limit: nil, columns: nil, group_by: nil, widths: {}, spark: true)
207
+ spark = Array(spark).map(&:to_sym).freeze unless [true, false, nil].include?(spark)
208
+ @items << Top.new(resource: resource.to_sym, by:, limit:, columns: columns&.map(&:to_sym), group_by:,
209
+ widths: widths.transform_keys(&:to_sym).freeze, spark:)
210
+ end
211
+
212
+ def band(resource = :session, show: %i[cpu footprint]) = @items << Band.new(resource:, show:)
213
+
214
+ def detail(resource = :process, columns: 1) = @items << Detail.new(resource:, columns:)
215
+ end
216
+
217
+ # --- per-app drawing state ---
218
+
219
+ # The engine, signal history, last shown texts (for pulses) and a cache of drawn strings, one
220
+ # per dashboard built from a view (declared on it, so drawers find it).
221
+ class Runtime
222
+ CACHE_LIMIT = 4000
223
+
224
+ attr_reader :engine, :view, :history
225
+
226
+ def initialize(engine:, view:)
227
+ @engine = engine
228
+ @view = view
229
+ @history = History.new
230
+ @shown = {}
231
+ @cache = {}
232
+ end
233
+
234
+ # The latest reading (focused when a session is), never sampling here: feeds keep it fresh.
235
+ def reading = @engine.current(max_age: Float::INFINITY)
236
+
237
+ def machine = @engine.current(max_age: Float::INFINITY, focused: false)
238
+
239
+ # The signal's recent values: the metric's own trend, else ours (appended once per reading).
240
+ def series(signal, reading)
241
+ Signals.trend(signal, reading) ||
242
+ @history.record(history_key(signal), reading.at, Signals.value(signal, reading))
243
+ end
244
+
245
+ def history_key(signal) = signal.metric == :focus ? "#{signal.name}@#{@engine.focus}" : signal.name
246
+
247
+ # 0..1, quantized: 1.0 on the frame `text` changed for `key`, fading over a second.
248
+ def pulse(motion, key, text)
249
+ before = @shown[key]
250
+ @shown[key] = text
251
+ level = motion.pulse(key, !before.nil? && before != text, duration: PULSE_SECONDS)
252
+ (level * 12).round / 12.0
253
+ end
254
+
255
+ # A drawn string for `key`, built once per distinct key.
256
+ def cached(key)
257
+ @cache.clear if @cache.size > CACHE_LIMIT
258
+ @cache.fetch(key) { @cache[key] = yield }
259
+ end
260
+
261
+ # --- the drill-down (`focused`): which r2ui rows are on the dashboard ---
262
+
263
+ # Splits the r2ui dashboard's rows (built as home rows, then focused rows) into the two sets.
264
+ def attach(dashboard)
265
+ rows = dashboard.rows.dup
266
+ @home_rows = rows.first(@view.rows.size).freeze
267
+ @focused_rows = rows.drop(@view.rows.size).freeze
268
+ end
269
+
270
+ def drill? = @view.drill?
271
+
272
+ def mode = @engine.focus ? :focused : :home
273
+
274
+ # Puts the set for the current mode on the dashboard (only that set: its layout, panels, tab
275
+ # order and drawing are exactly those of a view with only these rows).
276
+ def show(dashboard)
277
+ want = mode == :focused ? @focused_rows : @home_rows
278
+ rows = dashboard.rows
279
+ return if rows.size == want.size && rows.each_with_index.all? { |r, i| r.equal?(want[i]) }
280
+
281
+ rows.replace(want)
282
+ end
283
+ end
284
+
285
+ # --- what each word shows ---
286
+
287
+ module Draw
288
+ module_function
289
+
290
+ def text(signal, value) = Signals.format(value, signal.unit)
291
+
292
+ # "42.0%", or "23G / 32G 71%" with a total.
293
+ def meter_text(signal, reading)
294
+ value = Signals.value(signal, reading)
295
+ return nil if value.nil?
296
+
297
+ total = Signals.total(signal, reading)
298
+ return text(signal, value) unless total&.positive?
299
+
300
+ "#{text(signal, value)} / #{text(signal, total)} #{(value * 100.0 / total).round}%"
301
+ end
302
+
303
+ # A stacked meter's fractions of the total and text, e.g. [0.02, 0.56, "387M 1% · 9.0G/16G"]:
304
+ # the part (clamped to the whole's value), the whole's value, then "part share · value/total"
305
+ # (share of the total, "<1%" below one percent). Any of them unknown: [nil, nil, nil].
306
+ def stacked(item, reading)
307
+ sig = item.signal
308
+ used = Signals.value(sig, reading)
309
+ total = Signals.total(sig, reading)
310
+ part = Signals.value(item.part, reading)
311
+ return [nil, nil, nil] if used.nil? || part.nil? || !total&.positive?
312
+
313
+ share = part * 100.0 / total
314
+ pct = share.positive? && share < 0.5 ? "<1%" : "#{share.round}%"
315
+ text = "#{text(item.part, part)} #{pct} · #{text(sig, used)}/#{text(sig, total)}"
316
+ [([part, used].min.to_f / total).clamp(0.0, 1.0), (used.to_f / total).clamp(0.0, 1.0), text]
317
+ end
318
+
319
+ def item_meter_text(item, reading) = item.part ? stacked(item, reading)[2] : meter_text(item.signal, reading)
320
+
321
+ def max_width(texts) = texts.map { |t| Widgets.visible_width(t || Widgets::UNKNOWN) }.max || 0
322
+
323
+ # Labels and texts of a panel's one-line words share widths, so bars and charts line up.
324
+ def label_width(panel)
325
+ panel.items.filter_map { |i| (i.respond_to?(:signal) && i.signal.label) || nil }.map(&:length).max
326
+ end
327
+
328
+ def meter(ctx, rt, item)
329
+ return stacked_meter(ctx, rt, item) if item.part
330
+
331
+ reading = rt.reading
332
+ sig = item.signal
333
+ fraction = Signals.fraction(sig, reading)
334
+ text = meter_text(sig, reading)
335
+ shown = fraction && ctx.motion.tween([:agentmon_meter, ctx.panel.name, sig.name], fraction,
336
+ duration: TWEEN_SECONDS)
337
+ pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
338
+ text_w = max_width(ctx.panel.items.grep(Meter).map { |m| item_meter_text(m, reading) })
339
+ label_w = label_width(ctx.panel)
340
+ width = ctx.width
341
+ steps = shown && (shown * width * 8).round
342
+ rt.cached([:meter, item, width, steps, text, pulse, text_w, label_w, fraction&.round(2)]) do
343
+ bar = item.heat && fraction ? R2UI::Widgets::Glyphs.heat(shown) : R2UI::Widgets::Glyphs.fg(Widgets::ACCENT)
344
+ Widgets.meter(label: sig.label, text:, fraction: shown, width:, label_width: label_w, text_width: text_w,
345
+ bar_sgr: bar, text_sgr: value_sgr(sig, fraction, pulse, text))
346
+ end
347
+ end
348
+
349
+ # `meter sig, part:`: both fractions eased (keys per panel and signal), the text pulsing.
350
+ def stacked_meter(ctx, rt, item)
351
+ reading = rt.reading
352
+ sig = item.signal
353
+ part, used, text = stacked(item, reading)
354
+ panel = ctx.panel.name
355
+ shown = used && ctx.motion.tween([:agentmon_meter, panel, sig.name], used, duration: TWEEN_SECONDS)
356
+ shown_part = part && ctx.motion.tween([:agentmon_meter_part, panel, sig.name, item.part.name], part,
357
+ duration: TWEEN_SECONDS)
358
+ pulse = rt.pulse(ctx.motion, [:agentmon_pulse, panel, sig.name, item.part.name], text)
359
+ text_w = max_width(ctx.panel.items.grep(Meter).map { |m| item_meter_text(m, reading) })
360
+ label_w = label_width(ctx.panel)
361
+ width = ctx.width
362
+ steps = [shown, shown_part].map { |f| f && (f * width * 8).round }
363
+ rt.cached([:stacked_meter, item, width, steps, text, pulse, text_w, label_w]) do
364
+ Widgets.stacked_meter(label: sig.label, text:, part: shown_part, fraction: shown, width:,
365
+ label_width: label_w, text_width: text_w,
366
+ text_sgr: Widgets.value_sgr(unit: item.part.unit, pulse:, known: !text.nil?))
367
+ end
368
+ end
369
+
370
+ def value_sgr(sig, fraction, pulse, text)
371
+ Widgets.value_sgr(unit: sig.unit, fraction: sig.unit == :percent ? fraction : nil, pulse:, known: !text.nil?)
372
+ end
373
+
374
+ def chart_sgr(sig, fraction)
375
+ sig.max && fraction ? R2UI::Widgets::Glyphs.heat(fraction) : R2UI::Widgets::Glyphs.fg(Widgets::ACCENT)
376
+ end
377
+
378
+ def trend(ctx, rt, item)
379
+ reading = rt.reading
380
+ sigs = item.signals
381
+ room = case item.height
382
+ when nil then ctx.height - sigs.size # the rest of the panel
383
+ when Float then (ctx.height * item.height).floor - sigs.size # a share of what is left
384
+ else item.height
385
+ end
386
+ room = [room, sigs.size].max
387
+ share, extra = room.divmod(sigs.size)
388
+ sigs.each_with_index.flat_map do |sig, i|
389
+ values = rt.series(sig, reading)
390
+ value = Signals.value(sig, reading)
391
+ fraction = Signals.fraction(sig, reading)
392
+ text = text(sig, value)
393
+ pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
394
+ height = share + (i < extra ? 1 : 0)
395
+ # The header pulses frame by frame; the braille area changes once per sample, so it is
396
+ # cached on the series itself (a pulse frame redraws only the header).
397
+ header = rt.cached([:trend_head, sig.label, ctx.width, text, pulse, fraction&.round(2)]) do
398
+ Widgets.sides(sig.label.to_s, R2UI::Widgets::Glyphs.fg(Widgets::MUTED), text || Widgets::UNKNOWN,
399
+ value_sgr(sig, fraction, pulse, text), ctx.width)
400
+ end
401
+ area = rt.cached([:trend_area, ctx.width, height, sig.max, values.hash, fraction&.round(2)]) do
402
+ Widgets.trend(label: "", text: "", values:, width: ctx.width, height:, max: sig.max,
403
+ chart_sgr: chart_sgr(sig, fraction)).drop(1)
404
+ end
405
+ [header, *area]
406
+ end.join("\n")
407
+ end
408
+
409
+ def spark(ctx, rt, item)
410
+ reading = rt.reading
411
+ sig = item.signal
412
+ values = rt.series(sig, reading)
413
+ fraction = Signals.fraction(sig, reading)
414
+ text = text(sig, Signals.value(sig, reading))
415
+ pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
416
+ label_w = label_width(ctx.panel)
417
+ text_w = max_width(ctx.panel.items.grep(Spark).map { |s| text(s.signal, Signals.value(s.signal, reading)) })
418
+ rt.cached([:spark, item, ctx.width, values.hash, text, pulse, label_w, text_w]) do
419
+ Widgets.spark(label: sig.label, values:, text:, width: ctx.width, max: sig.max, label_width: label_w,
420
+ text_width: text_w, chart_sgr: chart_sgr(sig, fraction),
421
+ text_sgr: value_sgr(sig, fraction, pulse, text))
422
+ end
423
+ end
424
+
425
+ def stat(ctx, rt, item)
426
+ reading = rt.reading
427
+ columns = ctx.width < 40 ? 1 : item.columns
428
+ pairs = item.signals.map do |sig|
429
+ text = text(sig, Signals.value(sig, reading))
430
+ pulse = rt.pulse(ctx.motion, [:agentmon_pulse, ctx.panel.name, sig.name], text)
431
+ [sig.label, text, value_sgr(sig, Signals.fraction(sig, reading), pulse, text)]
432
+ end
433
+ rt.cached([:stat, item, ctx.width, columns, pairs]) do
434
+ Widgets.stat(pairs:, width: ctx.width, columns:).join("\n")
435
+ end
436
+ end
437
+
438
+ # The Detail panel's pairs for the selected process, reflowed into `columns` columns that
439
+ # fill the panel top to bottom. The command line (wrapped by ProcessDetail) is joined back
440
+ # and cut at a word boundary instead.
441
+ def detail(ctx, rt, item)
442
+ columns = [item.columns, 1].max
443
+ gap = 3
444
+ col_w = (ctx.width - (gap * (columns - 1))) / columns
445
+ text = UI::ProcessDetail.render(ctx.app, rt.engine, ctx.width)
446
+ lines = join_wrapped(text.split("\n"))
447
+ rt.cached([:detail, ctx.width, ctx.height, lines]) do
448
+ # The heading (name and pid) gets its own full-width line; the pairs flow in columns.
449
+ head, *pairs = lines
450
+ head = R2UI::Widgets::Glyphs.paint(Widgets.cut(head.to_s, ctx.width), "1")
451
+ rows = [(pairs.size + columns - 1) / columns, ctx.height - 1].min
452
+ rows = 0 if rows.negative?
453
+ cells = pairs.first(rows * columns).map { |line| fit_pair(line, col_w) }
454
+ body = Array.new(rows) do |r|
455
+ Array.new(columns) { |c| Widgets.pad(cells[(c * rows) + r] || "", col_w) }.join(" " * gap)
456
+ end
457
+ [head, *body].join("\n")
458
+ end
459
+ end
460
+
461
+ LABEL = UI::ProcessDetail::LABEL_WIDTH
462
+
463
+ def join_wrapped(lines)
464
+ lines.each_with_object([]) do |line, out|
465
+ if line.start_with?(" " * LABEL) && out.last&.start_with?("Command")
466
+ out[-1] = out.last + line[LABEL..]
467
+ else
468
+ out << line
469
+ end
470
+ end
471
+ end
472
+
473
+ def fit_pair(line, width)
474
+ return Widgets.cut(line, width) if line.length <= width || line.length <= LABEL
475
+
476
+ label = line[0, LABEL]
477
+ value = line[LABEL..]
478
+ label + Widgets.cut(value, [width - LABEL, 0].max)
479
+ end
480
+ end
481
+
482
+ # --- the band of session tiles ---
483
+
484
+ module BandDraw
485
+ Tile = Data.define(:id, :title, :cpu, :values, :series_key)
486
+
487
+ module_function
488
+
489
+ # The machine first (selected when nothing is focused), then each live session, oldest first.
490
+ def tiles(rt, item)
491
+ reading = rt.machine
492
+ system = reading[:system]
493
+ memory = reading[:memory]
494
+ machine = Tile.new(id: nil, title: "machine", cpu: system&.cpu,
495
+ values: [["used", memory&.used && R2UI::Format.bytes(memory.used), :bytes]],
496
+ series_key: :machine)
497
+ sessions = (reading[:session_ledger] || []).select(&:alive?).sort_by { |s| [s.started_at || 0, s.id] }
498
+ [machine] + sessions.map do |s|
499
+ values = (item.show - [:cpu]).map do |member|
500
+ unit = Signals.infer(member)[:unit]
501
+ [Signals.infer(member)[:label], Signals.format(Signals.read(s, member), unit), unit]
502
+ end
503
+ Tile.new(id: s.id, title: s.label, cpu: s.cpu, values:, series_key: s.id)
504
+ end
505
+ end
506
+
507
+ def draw(ctx, rt, item)
508
+ all = tiles(rt, item)
509
+ width = ctx.width
510
+ fit = [width / MIN_TILE, 1].max
511
+ shown = all.size <= fit ? all : all.first(fit - 1)
512
+ more = all.size - shown.size
513
+ slots = shown.size + (more.positive? ? 1 : 0)
514
+ tile_w = width / slots
515
+ reading = rt.machine
516
+ cursor = ctx.store(:agentmon_band)[:cursor]
517
+ focus = rt.engine.focus
518
+ columns = shown.each_with_index.map do |tile, i|
519
+ w = i == slots - 1 && more.zero? ? width - (tile_w * (slots - 1)) : tile_w
520
+ tile_lines(ctx, rt, tile, w, ctx.height, focused: tile.id == focus, cursor: cursor == i, at: reading.at)
521
+ end
522
+ if more.positive?
523
+ w = width - (tile_w * shown.size)
524
+ columns << Widgets.tile(title: "+#{more}", body: ["#{more} more", "→ to pick"], width: w,
525
+ height: ctx.height, border_sgr: R2UI::Widgets::Glyphs.fg(Widgets::DIM))
526
+ end
527
+ Array.new(ctx.height) { |row| columns.map { |c| c[row] || "" }.join }.join("\n")
528
+ end
529
+
530
+ def tile_lines(ctx, rt, tile, width, height, focused:, cursor:, at:)
531
+ glow = ctx.motion.tween([:agentmon_band, tile.series_key], focused ? 1.0 : 0.0, duration: 0.3)
532
+ values = rt.history.record("band.cpu.#{tile.series_key}", at, tile.cpu)
533
+ cpu_text = tile.cpu && Kernel.format("%.0f%%", tile.cpu)
534
+ pulse = rt.pulse(ctx.motion, [:agentmon_band_pulse, tile.series_key], cpu_text)
535
+ fraction = tile.cpu && (tile.cpu / 100.0).clamp(0.0, 1.0)
536
+ shown = fraction && ctx.motion.tween([:agentmon_band_cpu, tile.series_key], fraction, duration: TWEEN_SECONDS)
537
+ key = [:tile, tile, width, height, (glow * 16).round, cursor, values.hash,
538
+ shown && (shown * width * 8).round, pulse]
539
+ rt.cached(key) do
540
+ inner = width - 2
541
+ border = if cursor then "1;#{R2UI::Widgets::Glyphs.fg("#E0E0E0")}"
542
+ else R2UI::Widgets::Glyphs.fg(R2UI::Motion.mix_hex(Widgets::DIM, Widgets::ACCENT, glow))
543
+ end
544
+ title_sgr = glow > 0.5 ? "1;#{R2UI::Widgets::Glyphs.fg(Widgets::ACCENT)}" : R2UI::Widgets::Glyphs.fg("#BCBCBC")
545
+ text_sgr = Widgets.value_sgr(unit: :percent, fraction:, pulse:, known: !cpu_text.nil?)
546
+ body = [Widgets.meter(label: "cpu", text: cpu_text, fraction: shown, width: inner, text_width: 4,
547
+ text_sgr:)]
548
+ pairs = tile.values.map { |label, text, unit| [label, text, Widgets.value_sgr(unit:, known: !text.nil?)] }
549
+ body.concat(Widgets.stat(pairs:, width: inner, columns: 1))
550
+ body << Widgets.spark(label: "", values:, text: "", width: inner, max: 100,
551
+ chart_sgr: R2UI::Widgets::Glyphs.heat(fraction || 0))
552
+ Widgets.tile(title: tile.title, body: body.first([height - 2, 0].max), width:, height:,
553
+ border_sgr: border, title_sgr:)
554
+ end
555
+ end
556
+
557
+ # Key handling: ←/→ (h/l) move a cursor over the tiles, Enter focuses the tile's session
558
+ # (the machine tile clears the focus), Escape drops the cursor (and the focus, through
559
+ # ui/session_focus.rb).
560
+ def key(ctx, rt, item, key)
561
+ store = ctx.store(:agentmon_band)
562
+ count = tiles(rt, item).size
563
+ case key
564
+ when :left, "h", :right, "l"
565
+ step = %i[left].include?(key) || key == "h" ? -1 : 1
566
+ start = store[:cursor] || tiles(rt, item).index { |t| t.id == rt.engine.focus } || 0
567
+ store[:cursor] = (start + step) % count
568
+ true
569
+ when :enter
570
+ return false unless store[:cursor]
571
+
572
+ tile = tiles(rt, item)[store.delete(:cursor).clamp(0, count - 1)]
573
+ UI::SessionFocus.focus!(ctx.app, rt.engine, tile.id)
574
+ true
575
+ when :escape
576
+ had = store.delete(:cursor)
577
+ !had.nil? && rt.engine.focus.nil?
578
+ else false
579
+ end
580
+ end
581
+ end
582
+
583
+ # --- the drill-down: home rows, or the focused rows while a session is focused ---
584
+
585
+ # r2ui has no way to hide panels or switch a dashboard's rows, so the view's r2ui dashboard is
586
+ # built with both sets (App.new then makes feeds and table states for every panel) and the
587
+ # Runtime swaps `dashboard.rows` to the set for engine.focus before every key and every frame.
588
+ # Only the visible set's panels are on the dashboard: r2ui lays them out as if alone (fixed
589
+ # heights, shared rest, the last row taking what is left), tab cycles only them, hidden panels
590
+ # draw nothing and lookups by name (:session, :process, :detail) find the visible one.
591
+ module Drill
592
+ STORE = :agentmon_drill
593
+ HOME = "sessions · ⏎ open a session"
594
+ BACK = "esc back to sessions"
595
+
596
+ module_function
597
+
598
+ # Shows the set for the current mode and moves key focus when the mode changed: to the
599
+ # first table panel of the focused set when drilling in, back to the home panel that had it
600
+ # when coming out (the first home table panel when there is none, e.g. at start).
601
+ def sync(app, rt)
602
+ return unless rt&.drill?
603
+
604
+ dashboard = app.dashboard
605
+ rt.show(dashboard)
606
+ store = app.store(STORE)
607
+ mode = rt.mode
608
+ before = store[:mode]
609
+ panels = dashboard.panels
610
+ return if before == mode && panels.include?(app.focus)
611
+
612
+ store[:mode] = mode
613
+ store[:home_focus] = app.focus if before == :home
614
+ target = mode == :home ? store.delete(:home_focus) : nil
615
+ target = nil unless panels.include?(target)
616
+ target ||= panels.find(&:table) || panels.first
617
+ app.focus = target if target
618
+ end
619
+
620
+ # The status bar on a view with a drill-down: where you are and how to go back.
621
+ def status(rt)
622
+ return nil unless rt&.drill?
623
+
624
+ engine = rt.engine
625
+ id = engine.focus or return HOME
626
+ session = engine.focused_session
627
+ label = session&.label || id.to_s
628
+ status = session.respond_to?(:status) && session.status # Claude Code's "busy" / "idle"
629
+ "▸ #{label}#{" · #{status}" if status} · #{BACK}"
630
+ end
631
+ end
632
+
633
+ # --- resource decoration (only while a view is installed) ---
634
+
635
+ module Decorate
636
+ PRIORITIES = {
637
+ process: { name: 9, cpu: 9, footprint: 8, session: 6, pid: 5, run_wait: 4, read_rate: 4, write_rate: 4,
638
+ net_out_rate: 4, net_in_rate: 3, pagein_rate: 3, resident: 2, cwd: 1 },
639
+ connection: { process: 9, remote_text: 8, out_rate: 7, in_rate: 7, state: 6, bytes_out: 5, bytes_in: 5,
640
+ protocol: 4 },
641
+ session: { label: 9, cpu: 9, footprint: 8, processes: 6, age: 5, net_out_rate: 4, net_in_rate: 4,
642
+ peak_footprint: 3, remote_hosts: 3, connections: 2, read_rate: 2, write_rate: 2, cpu_seconds: 1,
643
+ bytes_written: 1, bytes_in: 1, bytes_out: 1 }
644
+ }.freeze
645
+ # The anomaly mark (docs/views.md, "Anomaly mark"): a session row whose `net_flags` is not
646
+ # empty shows FLAG before its label, in FLAG_COLOR. The metric decides; this only marks.
647
+ FLAG = "⚑ "
648
+ FLAG_COLOR = "#E5484D"
649
+
650
+ # Text columns get a fixed width in a view, so their values are cut at a word boundary here
651
+ # (r2ui's table cuts a flexible column mid-word). `top widths:` overrides these.
652
+ WIDTHS = {
653
+ process: { name: 18, session: 16, cwd: 20 },
654
+ session: { label: 18 }
655
+ }.freeze
656
+ FROM_LEFT = %i[cwd].freeze
657
+
658
+ module_function
659
+
660
+ # Heat on percent cells (a full core is red), the accent on bytes, a braille sparkline on
661
+ # each `spark:` column (the sort column by default), priorities for dropping, fixed word-cut
662
+ # widths (WIDTHS, then `top widths:`) and the anomaly mark on session labels. Applies to
663
+ # every panel of the resource on a view dashboard, `use` ones too.
664
+ def call(resource, name)
665
+ view = Views.installing or return
666
+ tops = Views.tops(view, name)
667
+ sparks = tops.flat_map(&:spark_columns).uniq
668
+ sparks = [:cpu] if tops.empty?
669
+ widths = tops.map(&:widths).reduce(WIDTHS.fetch(name, {}), :merge)
670
+ resource.columns.map! { |col| column(col, name, sparks, widths) }
671
+ end
672
+
673
+ def flagged?(row)
674
+ flags = Signals.fetch(row, :net_flags)
675
+ flags.respond_to?(:empty?) && !flags.empty?
676
+ end
677
+
678
+ # The label with the mark when `row` is flagged, cut at a word boundary to `width` cells
679
+ # (nil: uncut).
680
+ def flag_label(row, value, width)
681
+ return (width ? Widgets.cut(value, width) : value) unless value.is_a?(String) && flagged?(row)
682
+
683
+ FLAG + (width ? Widgets.cut(value, width - Widgets.visible_width(FLAG)) : value)
684
+ end
685
+
686
+ def column(col, name, sorts, widths)
687
+ opts = { priority: PRIORITIES.dig(name, col.key) || 0 }
688
+ case col.format
689
+ when :percent
690
+ opts[:style] = ->(value, _line) { value.is_a?(Numeric) ? R2UI::Widgets::Glyphs.heat(value / 100.0) : nil }
691
+ when :bytes, :bytes_per_sec
692
+ opts[:style] = ->(value, _line) { value.is_a?(Numeric) ? R2UI::Widgets::Glyphs.fg(Widgets::ACCENT) : nil }
693
+ end
694
+ if sorts.include?(col.key) && col.numeric?
695
+ opts.merge!(sparkline: :braille, spark_width: 5, spark_max: col.format == :percent ? 100 : nil)
696
+ if col.format == :percent
697
+ opts[:spark_style] = ->(values, _line) { R2UI::Widgets::Glyphs.heat((values.last || 0) / 100.0) }
698
+ end
699
+ elsif col.sparkline
700
+ opts[:sparkline] = false
701
+ end
702
+ reader = col.reader
703
+ key = col.key
704
+ w = widths[key]
705
+ if name == :session && key == :label
706
+ opts[:width] = w if w
707
+ opts[:reader] = lambda do |row|
708
+ flag_label(row, reader ? reader.call(row) : R2UI::Value.fetch(row, key), w)
709
+ end
710
+ red = R2UI::Widgets::Glyphs.fg(FLAG_COLOR)
711
+ opts[:style] = ->(value, _line) { value.is_a?(String) && value.start_with?(FLAG) ? red : nil }
712
+ elsif w
713
+ opts[:width] = w
714
+ opts[:reader] = lambda do |row|
715
+ value = reader ? reader.call(row) : R2UI::Value.fetch(row, key)
716
+ value.is_a?(String) ? Widgets.cut(value, w, from: FROM_LEFT.include?(key) ? :left : :right) : value
717
+ end
718
+ end
719
+ col.with(**opts)
720
+ end
721
+ end
722
+
723
+ # Reached through the resource builder: r2ui has no way to amend a column another file
724
+ # declared (`column` with the same key appends a second one), so this edits the list.
725
+ %i[process session connection].each do |name|
726
+ Agentmon.extend_resource(name) do |_engine|
727
+ Decorate.call(instance_variable_get(:@r), name)
728
+ end
729
+ end
730
+
731
+ # --- r2ui wiring ---
732
+
733
+ module_function
734
+
735
+ # The r2ui dashboard (named like the default one, so every agentmon dashboard extension
736
+ # applies) built from `view`'s rows: the home rows, then the focused ones (Drill shows one set).
737
+ def dashboard(view, engine, registry)
738
+ extras = registry.dashboard_blocks
739
+ runtime = Runtime.new(engine:, view:)
740
+ built = R2UI::DSL::Dashboard.build(UI::DASHBOARD) do
741
+ title view.title
742
+ extras.each { |b| instance_exec(engine, &b) }
743
+ agentmon_view(runtime)
744
+ view.all_rows.each do |spec|
745
+ row(height: spec.height) do
746
+ spec.panels.each { |p| Views.place(self, p, engine, registry, runtime) }
747
+ end
748
+ end
749
+ end
750
+ runtime.attach(built)
751
+ built
752
+ end
753
+
754
+ def place(row, spec, engine, registry, runtime)
755
+ if spec.is_a?(UseDef)
756
+ p = registry[:panel, spec.name] or raise Error, "use #{spec.name}: no such agentmon panel"
757
+ items = p.block && proc { instance_exec(engine, &p.block) }
758
+ return row.panel(p.name, resource: p.resource, span: spec.span || p.span, title: spec.title || p.title,
759
+ **p.options, &items)
760
+ end
761
+
762
+ options = spec.options
763
+ if spec.items.any?(Band)
764
+ options = options.merge(border_style: lambda {
765
+ engine.focus ? R2UI::Widgets::Glyphs.fg(Widgets::ACCENT) : nil
766
+ })
767
+ end
768
+ row.panel(spec.name, resource: spec.resource, span: spec.span, title: spec.title, **options) do
769
+ spec.items.each do |item|
770
+ if item.is_a?(Top)
771
+ sort = item.by && [item.by, :desc]
772
+ table(sort:, limit: item.limit, columns: item.columns, group_by: item.group_by, motion: true)
773
+ else
774
+ agentmon_view_item(item)
775
+ end
776
+ end
777
+ end
778
+ end
779
+
780
+ # The hidden resource behind panels with only view words: its feed samples the engine on
781
+ # the feed thread every interval (so drawers never do) and marks frames due.
782
+ # When the view also shows a table (or a `use` panel), that resource's feed already samples
783
+ # every interval and marks frames due, so this one refreshes once an hour: every feed refresh
784
+ # is a frame, and a second feed at another phase would double the idle frames.
785
+ def signals_resource(engine, view = Views.installing)
786
+ tables = view && view.all_rows.flat_map(&:panels).any? { |p| p.is_a?(UseDef) || p.resource != SIGNALS }
787
+ every = tables ? 3600 : engine.interval
788
+ R2UI::DSL::Resource.build(SIGNALS) do
789
+ title "Signals"
790
+ source do
791
+ engine.current
792
+ []
793
+ end
794
+ refresh every:
795
+ end
796
+ end
797
+ end
798
+
799
+ R2UI.extension :agentmon_views do
800
+ dsl :dashboard do
801
+ def agentmon_view(runtime) = declare(:agentmon_view, runtime)
802
+ end
803
+
804
+ dsl :panel do
805
+ def agentmon_view_item(value) = item(value)
806
+ end
807
+
808
+ helpers do
809
+ # The Runtime of the view dashboard drawing; nil on other dashboards.
810
+ def view_runtime = dashboard.declared(:agentmon_view).first
811
+ end
812
+
813
+ setup do
814
+ app.motion.enabled = Views.motion?
815
+ # Cost (docs/views.md, "Motion"): r2ui's table gutter holds the motion active for 1.5-2 s
816
+ # after every reorder, and a process list reorders on nearly every sample, so frames would
817
+ # never stop. On a view dashboard the gutter marks show and fade with the frames that tweens
818
+ # and pulses already draw, and go at the next sample, instead of keeping 20 fps running.
819
+ app.motion.singleton_class.prepend(Views::NoHold) if view_runtime
820
+ Views::Drill.sync(app, view_runtime)
821
+ end
822
+
823
+ # The drill-down follows engine.focus: before any handler sees a message (so tab, Enter and
824
+ # the name lookups see only the visible panels), after handlers changed the focus, and before
825
+ # every frame (`styles` is the only hook App#frame runs before laying out; a focus set
826
+ # directly, or a session that ended, shows on the next frame).
827
+ observe { |_message| Views::Drill.sync(app, view_runtime) }
828
+ after_update { Views::Drill.sync(app, view_runtime) }
829
+ styles do
830
+ rt = view_runtime
831
+ if rt&.drill?
832
+ UI::SessionFocus.clear_if_gone(app, rt.engine)
833
+ Views::Drill.sync(app, rt)
834
+ end
835
+ nil
836
+ end
837
+
838
+ # Where you are and how to go back, on views with a drill-down. r2ui shows the first status
839
+ # hook that returns text, in load order, and has no priority for them (extension.rb:87), so
840
+ # this registers through the hook method with one above session_focus's "focus: ..." (which
841
+ # loads first). Problems still win: with engine errors this returns nil and agentmon_errors
842
+ # shows them.
843
+ hook(:status, nil, proc {
844
+ rt = view_runtime
845
+ rt && Agentmon.engine_errors.empty? ? Views::Drill.status(rt) : nil
846
+ }, priority: 10)
847
+
848
+ # Animation needs fewer frames than r2ui's default 20 fps to look smooth at these durations.
849
+ program_options do
850
+ view_runtime && Views.motion? ? { fps: Views::FPS } : nil
851
+ end
852
+
853
+ panel_item(Views::Meter) { |item| Views::Draw.meter(self, view_runtime, item) }
854
+ panel_item(Views::Trend) { |item| Views::Draw.trend(self, view_runtime, item) }
855
+ panel_item(Views::Spark) { |item| Views::Draw.spark(self, view_runtime, item) }
856
+ panel_item(Views::Stat) { |item| Views::Draw.stat(self, view_runtime, item) }
857
+ panel_item(Views::Band) { |item| Views::BandDraw.draw(self, view_runtime, item) }
858
+ panel_item(Views::Detail) { |item| Views::Draw.detail(self, view_runtime, item) }
859
+
860
+ on(->(m) { m.is_a?(Bubbletea::KeyMessage) }, priority: 60) do |message|
861
+ rt = view_runtime or pass
862
+ band = dashboard.panels.flat_map(&:items).grep(Views::Band).first or pass
863
+ Views::BandDraw.key(self, rt, band, R2UI::Keys.name(message)) or pass
864
+ nil
865
+ end
866
+ # No "←→ pick" hint: r2ui's status bar drops every hint when they don't all fit beside the
867
+ # status text (renderer.rb draw_status), and at 100 columns one more would hide them all.
868
+ end
869
+ end
870
+
871
+ # The layouts: one file each, written only in the words above.
872
+ %w[dense focus visual].each do |name|
873
+ path = File.join(__dir__, "views", "#{name}.rb")
874
+ require path if File.exist?(path)
875
+ end