1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! The `tm ls` / `tm session ls` table renderer.
//!
//! Why: extracted from `managed.rs`, which sits within a couple of dozen SLOC
//! of the 500-SLOC production cap. Table rendering is a self-contained concern
//! — column widths, per-column color, and the two row shapes — so it splits
//! cleanly, mirroring the `session_picker.rs` → `session_picker_render.rs`
//! precedent.
//!
//! What: [`render_session_table`](crate::commands::managed_render::render_session_table) resolves the color gate once and prints the
//! header plus one row per session; [`format_ls_row`](crate::commands::managed_render::format_ls_row) and
//! [`format_tombstone_row`](crate::commands::managed_render::format_tombstone_row) are the pure row formatters; [`format_state_column`](crate::commands::managed_render::format_state_column)
//! and the two small text helpers came across unchanged.
//!
//! Color, and why padding had to change with it: `NUM`, `ID`, and `NAME` are
//! colored in DISTINCT hues. `NUM` and `NAME` must not share one — a name's
//! trailing `NN` is a per-project serial from `allocate_serial` that reuses
//! gaps, while `NUM` is a global slot, so `tm-foo-01` sitting at `NUM 1` is
//! coincidence. 57 currently listed sessions end in `-01`; one hue across both
//! columns would assert a correspondence that does not exist. Colored text also
//! breaks `{:<N}`, which counts an ANSI escape's bytes as width while the
//! terminal draws none of them — hence `pad_visible`, which measures the
//! visible text and appends the padding outside the escape.
//!
//! #8506: a row whose state has a color (active, stopped, dead, attached,
//! provisioning, decommissioned) is painted whole in it instead; the column hues remain only on
//! a row whose state maps to no color.
//!
//! Test: `format_tombstone_row_*`, `format_state_column_*`, `truncate_*`,
//! `short_timestamp_*`, and the color/alignment cases in `managed_tests.rs`.
use ManagedSessionSummary;
use SessionKind;
use ;
/// Hue for the `NUM` column — the stable, global slot number (#3034).
const NUM_COLOR: StateColor = Magenta;
/// Hue for the `ID` column — dimmed, because the full UUID is the widest thing
/// on the row and the least often read: it exists to be copy-pasted, not
/// scanned. Dimming pushes it behind `NUM`/`NAME` without hiding it, and it is
/// a third hue so no two adjacent columns share one.
const ID_COLOR: StateColor = Dim;
/// Hue for the `NAME` column, deliberately different from [`NUM_COLOR`].
const NAME_COLOR: StateColor = Cyan;
/// Rendered width of the `NUM` column.
const NUM_WIDTH: usize = 5;
/// Rendered width of the `ID` column — a full UUID is exactly 36 chars.
const ID_WIDTH: usize = 36;
/// Rendered width of the `NAME` column; names are truncated to match.
const NAME_WIDTH: usize = 24;
/// Rendered width of the `TASK` column; tasks are truncated to match.
const TASK_WIDTH: usize = 30;
/// Rendered width of the `START` column — the turn-1 startup context (#7424).
///
/// Why: the widest cell it holds is a six-character `1234k`-shaped figure plus
/// the header's own five, so five is the floor and the column never grows.
/// What: the padding applied to [`format_startup_cell`]'s output.
/// Test: `ls_row_renders_the_startup_context_column`.
const STARTUP_WIDTH: usize = 5;
/// Floor for the `STATE` column, preserving the pre-#4061 table shape when no
/// row carries an annotation.
const STATE_MIN_WIDTH: usize = 14;
/// Left-align `text` in a `width`-wide column, coloring only the text itself.
///
/// Why: `{:<width}` pads to the formatted value's char count. An ANSI-wrapped
/// string carries ~9 extra chars the terminal never draws, so every colored
/// column would come out that much narrow and the table would stagger. Padding
/// outside the escape keeps the visible width exact, and keeps the plain
/// (`use_color == false`) output byte-identical to the pre-color table.
/// What: colorizes `text`, then appends `width - text.chars().count()` spaces —
/// `chars().count()` being the same measure `{:<width}` uses for a `&str`. An
/// over-wide `text` gets no padding rather than a negative count (callers
/// truncate first).
/// Test: `ls_row_alignment_matches_with_and_without_color`.
/// Report that `tm ls -a` matched nothing.
///
/// Why: an empty table under `-a` is a legitimate answer — nobody is attached
/// to anything — and printing a bare header (or nothing) leaves the operator
/// unable to tell that apart from a broken filter or an unreachable daemon.
/// The distinct wording also keeps it from reading as "you have no sessions",
/// which `render_session_table`'s "no managed sessions" would wrongly imply
/// while a dozen detached sessions are running.
/// What: prints `no attached sessions[ for <slug>]`, mirroring
/// [`render_session_table`]'s empty-list phrasing. The caller returns `Ok(())`,
/// so the exit status is 0.
/// Test: `render_no_attached_sessions_*` in `tests_behavior_d_tests.rs`.
pub
/// The exact line [`render_no_attached_sessions`] prints, as a pure value.
///
/// Why: splitting the string from the `println!` is what makes the message
/// assertable in a unit test without capturing stdout.
/// Test: `render_no_attached_sessions_line_unscoped`,
/// `render_no_attached_sessions_line_scoped_to_source_id`.
pub
/// Render the static `tm session ls` / `tm ls` table for a scoped session list.
///
/// Why: both the static list command and the `tm ls` connector's non-picker path
/// (0 sessions, or a piped/`--json`/`--all` invocation) must print the identical
/// table, so the row formatting lives in one place rather than being duplicated
/// across the two entry points.
/// What: prints a "no managed sessions[ for <slug>]" line when the list is empty;
/// otherwise prints the header + one row per session via [`format_ls_row`] (or
/// [`format_tombstone_row`] for a tombstoned slot). The color gate is resolved
/// ONCE here, at the I/O boundary, from STDOUT's TTY-ness — this table is
/// written with `println!`, so the picker's stderr gate would leak escapes into
/// `tm ls | grep`.
/// Test: `ls_source_id_filter_selects_correct_slug` covers the scoping seam; the
/// table bytes are exercised by `tests/session_manager_mvp.rs`; the row
/// formatters carry their own unit tests.
pub
/// One row's stored turn-1 startup context, if a session recorded one (#7424).
///
/// Why: the reading is keyed by the CLAUDE session id — the id the statusline
/// render knows — and a managed record carries that id only once Claude Code
/// has reported it. A row with no captured id simply has no reading to show,
/// which is the same blank cell as a session whose first turn has not landed.
/// What: [`trusty_mpm::core::startup_context::read_startup_context`] against
/// the row's `claude_session_id`.
/// Test: `ls_row_renders_the_startup_context_column` covers the rendering; the
/// store's own round trip is `a_reading_round_trips_through_the_store`.
/// The `START` cell — a session's turn-1 startup context, in thousands.
///
/// Why: the raw figure is five or six digits and the column exists to be
/// SCANNED down, not read precisely — `98k` beside `31k` says what a row of
/// `98214` beside `31007` makes the reader work for. `tm doctor`'s
/// `startup_context` row carries the exact numbers.
/// What: `None` renders as `-` (nothing recorded); otherwise the token count
/// divided by 1000, rounded to nearest, suffixed `k`. A reading under 500
/// tokens rounds to `0k` rather than to `-`, so "measured and tiny" stays
/// distinguishable from "not measured".
/// Test: `ls_row_renders_the_startup_context_column`,
/// `startup_cell_distinguishes_unmeasured_from_tiny`.
pub
/// Width the `STATE` column needs so no row's annotation overflows it.
///
/// Why: `STATE` used to be a hardcoded `{:<14}`, but the column's rendered
/// value is the state PLUS any annotation — `attached [stale-assets]` is 23
/// chars, `stopped [assets ?]` is 18. Every row wider than 14 pushed `NAME`,
/// `TASK`, and `CREATED` right by the overflow, so a single annotated session
/// staggered the whole table. The width has to be measured across the rows
/// actually being printed, not guessed at compile time.
/// What: the longest [`row_state`] value among the non-tombstone rows, floored
/// at [`STATE_MIN_WIDTH`] so an all-plain listing keeps its historical shape.
/// Tombstone rows are excluded — they have no `STATE` cell at all.
/// Test: `state_column_width_absorbs_longest_annotation`,
/// `ls_table_columns_align_when_a_row_carries_an_annotation`.
pub
/// The `STATE` cell for one live row, annotations included.
///
/// Why: the width pass and the render pass must agree exactly on the cell's
/// text, so both go through this one function rather than rebuilding it.
/// What: maps `attached` onto the base state, then delegates to
/// [`format_state_column`].
/// Test: `state_column_width_absorbs_longest_annotation`.
/// The STATE-cell tag of a protected record kind (#8942).
///
/// What: `architect` for the Architect, `architect-helper` for its poller or
/// collector, `protected` for a kind this build does not know; none for an
/// ordinary session or an older daemon that sends no kind.
/// Test: `session_table_pins_and_tags_the_architect_row`.
pub
/// `sessions` with the live Architect rows first (#8942 design §4).
///
/// What: a stable reorder — the Architect, then its helpers and any other
/// protected kind, then everything else in its original order. A deleted row
/// is never pinned.
/// Test: `session_table_pins_and_tags_the_architect_row`.
pub
/// Format one live `tm ls` row.
///
/// Why: extracted as a pure function so the column widths and the two colored
/// columns are unit-testable without capturing stdout — the same reason
/// [`format_state_column`] and [`format_tombstone_row`] are.
/// What: `NUM` (colored [`NUM_COLOR`]), id, state, `NAME` (truncated to 24,
/// colored [`NAME_COLOR`]), task (truncated to 30, or the `(interactive)`
/// placeholder when the session has none, #1841), short created-at, and any
/// pending decision. The STATE column appends `[dead]` (#2595) when the
/// server-computed `unresumable` flag is set — the session's workspace no
/// longer exists anywhere on disk, so a resume would only ever fail and the
/// operator's remedy is `tm session rm`. It also appends `[stale-assets]`
/// (#2444) when `stale_assets` is set — remedy `tm sessions sync-assets <id>`
/// — or `[assets ?]` (#4322) when the daemon skipped the probe for that row
/// (`stopped` sessions), so the operator reads "undetermined" rather than
/// mistaking a missing marker for "assets fresh". An ATTACHED session
/// (live-tmux reconciled by the daemon) reads `attached` rather than the raw
/// `active`, so a session the operator is connected to is distinguishable from
/// a merely-running one.
/// `state_width` comes from [`state_column_width`] over the whole listing, so a
/// row whose annotation is longer than [`STATE_MIN_WIDTH`] widens the column for
/// every row instead of shoving its own `NAME`/`TASK`/`CREATED` out of line.
///
/// `startup_tokens` (#7424) is this session's recorded turn-1 startup context,
/// read by [`render_session_table`] from the per-session usage store and passed
/// in rather than read here, so every column this function renders stays a pure
/// function of its arguments. `None` — no reading recorded — renders as `-`.
///
/// #8506: a row whose [`session_color`] is not `Plain` is painted whole in that
/// color and drops the three column hues; only an unmapped state keeps them.
/// Test: `ls_rows_are_painted_whole_in_their_state_color`,
/// `ls_row_state_colors_never_reach_a_plain_table`,
/// `ls_row_colors_num_and_name_in_distinct_hues`,
/// `ls_row_colors_id_column_dimmed`, `ls_row_plain_when_color_disabled`,
/// `ls_row_alignment_matches_with_and_without_color`,
/// `ls_table_columns_align_when_a_row_carries_an_annotation`,
/// `ls_row_renders_the_startup_context_column`.
pub
/// Format the `-- deleted --` tombstone row for a deleted slot (issue #3034).
///
/// Why: extracted as a pure function — mirroring [`format_state_column`] — so
/// the exact tombstone row text is unit-testable without capturing stdout.
/// What: the slot number, padded to the live row's `NUM` column width so the
/// table stays aligned, then `-- deleted --`; the whole row is painted
/// [`DEAD_COLOR`] (#8506), the hue [`session_color`] gives a tombstone.
/// Test: `format_tombstone_row_shows_slot_and_placeholder`,
/// `ls_row_alignment_matches_with_and_without_color`,
/// `ls_rows_are_painted_whole_in_their_state_color`.
pub
/// Format the STATE column value for one `tm session ls` row (#2595, #2444).
///
/// Why: extracted as a pure function so the `[dead]`/`[stale-assets]` markers
/// are unit-testable without capturing stdout.
/// What: renders the base state (`deleted` → the `--deleted--` marker, #2012;
/// every other state verbatim), then appends `" [dead]"` when `unresumable` is
/// `true` (the session is `stopped`/`errored` with no workdir candidate left on
/// disk, so a resume is guaranteed to fail), and `" [stale-assets]"` when
/// `stale` (the summary's `stale_assets`) is `true`, or `" [assets ?]"` when
/// `unchecked` (the summary's `stale_assets_unchecked`) is `true` (#4322: the
/// daemon did not probe this row — the list path skips `stopped` sessions — so
/// staleness is UNDETERMINED, not known-fresh). The two asset markers are
/// mutually exclusive — an unprobed row has no verdict to report — so a stale
/// verdict always wins if both flags somehow arrive set. Markers can otherwise
/// appear together.
///
/// `" [resume-parked]"` (#6568) marks a session the supervisor has stopped
/// auto-resuming because its runtime kept dying within seconds of each resume.
/// Without it a parked row is indistinguishable from an ordinary `stopped` one,
/// and the difference is that nothing will ever revive this one. `tm session
/// status` prints the full reason; the row only has space for the fact.
/// Test: `format_state_column_appends_dead_marker`,
/// `format_state_column_appends_stale_assets_marker`,
/// `format_state_column_appends_unchecked_assets_marker`,
/// `format_state_column_appends_resume_parked_marker`,
/// `format_state_column_leaves_healthy_state_unchanged`,
/// `format_state_column_renders_deleted_marker`.
pub
/// Truncate a string to at most `max` characters, appending `…` when cut.
///
/// Why: task descriptions can be arbitrarily long; the ls table needs a bounded
/// column width.
/// What: returns `&s[..max-1]…` when `s.chars().count() > max`, else `s`.
/// Test: `truncate_clips_and_appends_ellipsis`.
pub
/// Render an RFC-3339 timestamp as a short local-ish string (`YYYY-MM-DD HH:MM`).
///
/// Why: the full RFC-3339 value is too wide for a table column; a date+time
/// prefix is human-readable without truncating important information.
/// What: takes the first 16 characters of the timestamp string (`YYYY-MM-DDTHH:MM`),
/// replaces the `T` separator with a space, and returns the result. Falls back to
/// the raw string if it is shorter than 16 chars.
/// Test: `short_timestamp_formats_correctly`.
pub