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
//! 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`] resolves the color gate once and prints the
//! header plus one row per session; [`format_ls_row`] and
//! [`format_tombstone_row`] are the pure row formatters; [`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.
//!
//! Test: `format_tombstone_row_*`, `format_state_column_*`, `truncate_*`,
//! `short_timestamp_*`, and the color/alignment cases in `managed_tests.rs`.
use ManagedSessionSummary;
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;
/// 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`.
/// 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
/// 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`.
/// 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.
/// Test: `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`.
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 in [`NUM_COLOR`], padded to the live row's `NUM`
/// column width so the table stays aligned, then `-- deleted --`. The
/// placeholder itself is left uncolored: it sits under `ID`, not `NAME`, and a
/// third hue would only add noise.
/// Test: `format_tombstone_row_shows_slot_and_placeholder`,
/// `ls_row_alignment_matches_with_and_without_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.
/// Test: `format_state_column_appends_dead_marker`,
/// `format_state_column_appends_stale_assets_marker`,
/// `format_state_column_appends_unchecked_assets_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