ebman 0.3.1

k9s-style TUI for AWS Elastic Beanstalk
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
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
# ebman backlog

Living list of done / pending / dropped work. New entries get added at the bottom of their section. Priority tiers below are loose — pick what fits.

---

## Done

### Core foundation
- Listing & live refresh: `DescribeEnvironments` with pagination, every 15 s
- Manual refresh (`Ctrl-R` / `F5`) with debounced loading indicator
- Generation counter so stale results from previous contexts are dropped
- Crash-safe panic hook that restores the terminal
- File logging to `~/.cache/ebman/ebman.log` with `RUST_LOG` env support
- Boot splash while STS resolves

### Identity & context
- `Account`, `Region`, `Profile`, `Caller` shown in header
- `STS GetCallerIdentity` runs async after rebuild (off the hot path)
- In-app profile picker (`p`) — parses `~/.aws/config` + `~/.aws/credentials`
- In-app region picker (`r`) — 30 commercial regions + user-defined extras
- `:region NAME` / `:profile NAME` command-bar entries
- SSO-friendly hint: rewrites `ExpiredToken` errors into `aws sso login --profile X` instructions
- Strict-validate profile/region on startup; falls back to env defaults if invalid

### Table view
- Columns: NAME / APPLICATION / TIER / STATUS / ● / TREND / PLATFORM / VERSION / CNAME / AGE
- Sort cycling (`s` / `S`) — order matches column order in the UI
- Filter mode (`/`) — case-insensitive, multi-field
- Group-by-application (`Ctrl-G`) — coloured horizontal partitions between groups
- Stable, sequential per-app colour assignment from a 16-colour palette
- Zebra striping that counts Env rows only (separators don't break the cadence)
- Severity row tint (Red / Yellow envs get tinted backgrounds)
- Mouse: wheel scroll, left-click to select, hover tints the row
- In-memory health sparkline per env (last 20 samples; oldest 1/3 dim)
- Status / tier rendered as coloured "pills"; health rendered as `●` dot (or `*` in ASCII)
- View-mode toggle (`Ctrl-D`) cycles default / compact

### Drill-down
- `Enter` opens a per-env Detail view; `Esc` returns
- Tabs: **Events / Instances / Metrics / Queue (Worker only) / Config**
- `Tab`/`Shift-Tab` (or `h`/`l`) cycle tabs; refresh on switch
- Events tab: `DescribeEvents` filtered by env, regex search (`/`, `n`, `N`), match highlighting
- Instances tab: `DescribeInstancesHealth` with all attributes; cause lines indented
- Metrics tab: real `Chart` widget with braille markers; `EnvHealth / 4xx / 5xx / LatencyP90`; per-series `now / max / min / Δ` with direction-of-bad colouring
- Queue tab: queue URL + visible/in-flight/delayed with sub-character micro-bars
- Config tab: env metadata, no extra API call
- `Ctrl-R` re-fetches active tab

### Worker / SQS
- Tier badge (Web/Worker) parsed from `EnvironmentDescription.tier`
- Platform-family parser handles both solution-stack and platform-arn formats
- Worker queue URL discovery via `DescribeConfigurationSettings`
- DLQ URL derivation by convention if not explicitly configured
- `sqs:GetQueueAttributes` for visible / in-flight / delayed counts on both queues
- DLQ viewer (`d` in Detail's Queue tab): peeks messages, shows id / receive-count / age / body preview
- DLQ actions: per-message resend (send-to-main + delete-from-DLQ), bulk purge with strict typed confirm

### Actions
- Action menu (`a`): Rebuild / Restart / Swap CNAMEs / Terminate
- Rebuild / Restart: Y/N confirm
- Swap CNAMEs: filterable picker of env candidates in the same app, then Y/N confirm
- Terminate: strict — must type the env name exactly
- Status / error feedback in footer
- Auto-refresh after success

### Events panel (main view)
- `Ctrl-E` toggles a bottom panel listing the most recent events across all envs
- Severity-coloured (`ERROR/FATAL/WARN/INFO/DEBUG/TRACE`)
- `Ctrl-↑`/`↓` resize the panel
- Alert badge: count of envs currently in Red (recomputed per refresh)

### Command bar
- `:` enters command mode
- Commands: `:q / :quit`, `:region X`, `:profile X`, `:sort KEY [desc]`, `:group [on|off]`, `:redact [on|off]`, `:events [on|off]`, `:save NAME`, `:f NAME`, `:filter NAME`, `:filters`, `:drop NAME`, `:export`, `:refresh`, `:help`

### Filters, sorting, persistence
- Named filters: `:save NAME` / `:f NAME` / `:filters` / `:drop NAME` — persisted across runs
- Filter / sort / grouping / redact / events-visible / selected env all persist in `~/.config/ebman/state.toml`
- Cursor restored to the same env across restarts

### Privacy & redaction
- Redact mode (`Ctrl-X`) blurs Account, Caller ARN, CNAMEs with `▓` blocks
- TSV export respects redact mode

### Yank / export
- `y` copies CNAME of selected env, `Y` copies the name (via `arboard`)
- `Ctrl-Y` copies the filtered view as TSV

### Tabs / scope
- `Tab` / `Shift-Tab` cycle scope: **Envs ↔ Apps**
- Apps scope lists `DescribeApplications`; `Enter` on an application filters Envs to that app

### Theming & visual polish
- `Theme` struct with `dark` and `light` presets (loaded from `config.toml: theme = "..."`)
- Icon style (`unicode` / `ascii`) with ascii fallbacks for spinner, tab icons, health dot, title decoration
- Rounded borders everywhere
- Decorated block titles (`[ ◆ name ◆ ]`)
- Active-panel vs idle-panel border colours
- Animated braille spinner during loads (ticker gated on `loading_since`)
- Sparkline fade (oldest samples dim)

### Configuration
- `~/.config/ebman/config.toml`: `refresh_interval_secs`, `extra_regions`, `redact_default`, `grouped_default`, `theme`, `icons`
- `~/.config/ebman/state.toml`: persisted app state (managed by app)

### Help & onboarding
- `?` opens help popup; scrollable with `j`/`k`
- Footer key strip mode-aware (Normal / Detail / Action / Picker / Filter / Command / Help / Dlq)

### Quality
- Unit tests (49 passing) across `util / state / config / theme / aws / app / ui`
- Generation/epoch invariants for refresh, identity, detail, DLQ message handlers

### CLI & distribution
- `--version` / `-V` and `--help` / `-h` flags (exit before TUI)
- MSRV declared (`rust-version = "1.82"`)
- README with feature summary, keymap, config, and "what's stored locally" section
- GitHub Actions CI: build + test on macOS and Linux, `cargo fmt --check`, `cargo clippy -D warnings`, MSRV gate on 1.82
- Cargo metadata: license, repository, keywords, categories

### Operator UX (Tier 1 & 2)
- Detail auto-refresh: `R` toggles a per-tab tick driven by the main 15s ticker; AUTO pill in the Detail footer
- Open env in AWS console (`b`) via `open` / `xdg-open`; works in Normal and Detail modes
- Describe overlay (`D`) — popup with the env dumped as pretty JSON; works in Normal and Detail modes
- Breadcrumb top-line in the header: `region / application / env` (env follows selection or the Detail snapshot)
- Frozen / paused mode (`f`) — halts auto-refresh; `FROZEN` pill in the header; manual `Ctrl-R` still works
- Quick-jumps: `1`-`9` select the env at that position in the current view
- Pin / star envs (`*` or `:pin`); pinned envs float to the top; `★` glyph in the NAME column; persisted across runs
- Local env aliases (`:alias NAME LABEL` / `:alias-drop NAME`); alias replaces the rendered name; filter search matches aliases too; persisted

### Safety & audit
- Read-only mode: `--read-only` CLI flag or `:readonly on`; disables Actions menu, DLQ resend, DLQ purge; green `READ-ONLY` pill in the header
- Local audit log at `~/.cache/ebman/audit.log` for every dispatched action (rebuild / restart / swap / terminate / dlq-resend / dlq-purge)
- Crash report writer: on panic, writes `~/.cache/ebman/crash-TS.log` with backtrace, panic location, and payload

### Exports
- JSON export (`:json`) — copies filtered view as a JSON array
- Markdown report (`:report` / `:markdown`) — copies filtered view as a Markdown table

### Themes & onboarding
- `Theme::high_contrast()` preset (`theme = "high-contrast"` / `hc` / `highcontrast`)
- Notification bell on increase in Red-env count (`notify_bell = true` in `config.toml`)
- `:whatsnew` embedded changelog popup
- Spacious view mode — third position in the `Ctrl-D` cycle (Default → Compact → Spacious); 2-row data rows + `Padding::horizontal(2)` on the table block; `SPACIOUS` pill in the header

### Workflow extras
- `Ctrl-W` yanks the equivalent `aws elasticbeanstalk describe-environments` command (POSIX-safe shell-quoting)
- Quick-jump by name: `'` enters a mini-mode; typing a prefix moves selection to the first matching env (matches alias too); `Enter` keeps, `Esc` cancels
- Anomaly highlight: red `▲` glyph in NAME column on envs that transitioned to Red since the previous refresh
- Saved views: `:save-view NAME` snapshots filter / sort / grouping / scope; `:view NAME` restores; `:views` lists; `:view-drop NAME` removes. Persisted across runs.
- Tag view: `ListTagsForResource` per env on Detail open; tags shown in the Config tab
- Metrics time-range selector: `[` / `]` cycles 15m / 1h / 6h / 24h while on the Metrics tab; re-fetches on change
- Cost annotation in Config tab: per-instance hourly rate (us-east-1 baseline table) × instance count → $/hr and $/mo; flags unknown instance types
- Required-tag policy: `required_tags = "Owner,Project"` in `config.toml`; Config tab shows a `⚠ missing required tag(s):` line when any are absent
- Recommendation surface in Detail header health line: `≥ Nm in Red` / `≥ Nm in Yellow` when the env has been in that state for ≥1 minute of consecutive samples
- `:history` overlay listing the last 50 status / error messages with timestamps
- Dry-run preview on destructive confirms: spawns `DescribeInstancesHealth` for Rebuild / Terminate; modal shows `impact: N instances across M AZs`
- Pre-flight events recap: confirm modal also fetches `DescribeEvents` for the env (last 3) and renders them under the impact line
- Configurable columns: `:cols list | hide NAME | show NAME | reset`; persisted in `state.toml` as `hidden_cols`; works on top of the default / compact / spacious view-mode presets; NAME is non-hideable
- Sticky filter row in header: `Filter: <text>` shown in header line 2 when a filter is active, so it stays visible above the table even when the footer is occupied by a status/error message
- In-app `:loglevel <level>` — live-reloads the tracing filter via `tracing-subscriber` reload handle. Bare levels (trace/debug/info/warn/error) auto-add `aws=warn,hyper=warn` so AWS noise stays capped; full directives (`my_crate=trace`) accepted as-is
- CloudWatch alarms list (`:alarms`) — `DescribeAlarms` filtered client-side to alarms whose dimensions reference the selected env; popup overlay with state colouring (ALARM red, OK green, INSUFFICIENT_DATA muted)
- Diff two envs (`:diff NAME`) — overlay showing field-by-field comparison between the currently-selected env and the named target; differing fields prefixed with `≠` in yellow; truncates long values; respects redact mode
- Saved Configurations list (`:saved-configs` / `:configs`) — popup listing EB saved-configuration templates grouped by application; pulled from `DescribeApplications.configuration_templates`
- Plugin commands — `~/.config/ebman/commands.toml` defines `[commands.NAME] template = "..."`; `:NAME` substitutes `{name}`/`{cname}`/`{application}`/`{tier}`/`{region}`/`{profile}` and yanks the rendered command to the clipboard; `:plugins` lists what's available
- Hover preview line — when the mouse hovers a row, the bottom-most row of the table overlays a dim full-detail summary (name + alias + app + status/health + platform + CNAME, untruncated, redact-aware)

### Slick UX pass
- ASCII-block boot splash: 5-line `ebman` logo + tagline + "connecting to AWS…" inside a rounded card while STS resolves
- Empty states with friendly hint lines: "no environments in this account / region — try a different region (r) or profile (p)" / "no events for this environment — ^R to re-fetch" / "no instance data — env may be terminating"
- Status delta indicator in header: per-refresh `▲N Bucket` / `▼N Bucket` chips on Envs line, colour-coded by bucket (Red/Yellow/Green/Updating/Terminating); silently omits unchanged buckets
- Toast notifications: bottom-right transient cards (rounded, kind-coloured border) replace the footer-only feedback for status / error events; up to 4 stacked; auto-dismiss (4 s info / 8 s error); animation ticker wakes the draw loop so toasts disappear on idle
- `Ctrl-K` command palette: fuzzy-search across `:` commands (no-arg / with-arg), env names (jump cursor), saved views, and user plugins; substring scoring with detail-match penalty; ↑/↓ navigate, Enter dispatches, Esc cancels

### Code review follow-ups (2026-05-18)
- Async per-env results carry `env_name`; `AppMsg::Alarms` now drops late results that don't match the requested env. Removed silent overwrite of overlay contents by stale results.
- Overlay rendering routed through the same code path for Detail / Dlq / main views — popups opened from Detail (`D` describe) now actually paint.
- Mouse events only steer the main table in Normal mode + Envs scope + no overlay open. Wheel scroll no longer silently moves selection while Detail / Dlq / Action / Palette is visible.
- Diff state (`prev_health`, `prev_status`, `prev_alerts`, `newly_red`, `health_delta`, `status_delta`) and any open overlay are cleared on profile/region switch. Prevents cross-account "newly red" toasts and ▲N spam on the first refresh after a switch.
- `bucket_delta` semantics tightened: only counts envs present in *both* prev and next. New envs and disappeared envs are not deltas. With an empty prev (post-clear) the delta is empty.
- `init_client` makes `verify_identity` best-effort — `sts:GetCallerIdentity` failure logs a startup warning instead of refusing to launch. EB describe permissions don't require STS.
- `status_message` race fixed: `apply_refresh` only clears messages that still match the snapshot taken at refresh kickoff. User actions during the round-trip (sort, alias, pin, …) survive.
- Audit log captures dispatch + outcome — `write_audit_outcome` writes a second entry once the SDK response lands, so the trail reflects success / validation error / timeout, not just the dispatch time.
- `hsl_to_rgb_clamps_to_valid_range` test asserts real properties: hue wrap, greyscale collapse on zero saturation. No more `let _ = r;`.
- Plugin name collisions surface — `plugins::parse` takes a reserved-name list; colliding entries are dropped with a warning logged via tracing and shown as a startup error in the UI.
- `flatten_err` helper logs the full SDK error chain via `tracing::error!` before flattening to Display for the toast / footer. The chain is no longer lost from `ebman.log`.
- Toast deduplication: identical (kind + text) toasts refresh the existing card's timestamp instead of stacking duplicates.
- Overlay enum: replaced six `Option<String>` fields and one `alarms_pending_for` correlation field with a single `current_overlay: Option<Overlay>`. Unified dismiss, render, and context-switch-close paths.
- `LICENSE-MIT` / `LICENSE-APACHE` files committed; `Cargo.toml` declares `readme = "README.md"`; `.gitignore` covers macOS / editor / cache patterns.
- Audit log + crash report rotation: `audit.log` rotates to `audit.log.1` at 1 MiB; crash hook prunes oldest `crash-*.log` files keeping the 10 most recent.

### Performance + reliability (post-review)
- Per-application colour HashMap memoized in `App.cached_app_colors`; rebuilt only on `rebuild_view` rather than every frame. New `assign_app_colors` pure helper has tests for stable first-appearance, palette wraparound, and empty-palette no-ops.
- Throttle / backoff for EB describe APIs: `is_throttling_error` recognises `ThrottlingException` / `RequestLimitExceeded` / `429`, and `throttle_backoff` doubles the next-refresh delay (capped at 5 min). The ticker skips spawn_refresh while `throttle_until` is in the future; `Ctrl-R` always overrides. Consecutive-throttle counter resets on the next success. State cleared on context switch.

### Tier 1 features (post-review)
- **Live log tail**: new Logs tab in Detail. `^R` triggers `RequestEnvironmentInfo("tail")`, polls `RetrieveEnvironmentInfo` up to 12× at 2s intervals, then fetches each instance's pre-signed S3 URL via `curl`. UI advances through Requesting → Polling (with attempt counter) → Fetching → Ready stages. Per-instance content is shown with a banner row; regex search (`/`) filters visible lines independently of the Events tab search. Requires `curl` on PATH.
- **Deploy a version**: `:versions` lists `DescribeApplicationVersions` for the selected env's app in an overlay, sorted newest-first. `:deploy <label>` calls `UpdateEnvironment(version_label)` and records a dispatched audit entry. The outcome flows through the existing `AppMsg::ActionResult` path so success/failure surfaces in the footer.
- **Multi-region overview**: `:region all` flips into multi-region mode and fans `DescribeEnvironments` across `extra_regions ∪ {current}` in parallel. Each env gets its origin region stamped, and a REGION column is conditionally inserted in the table. `:region off` returns to single-region. New `aws::list_environments_in_region` helper is shared with cross-account search and org-health.

### Tier 2 / 3 / 4 / 5 / 6 / 7 batch
- **Header signals**: SSO session expiry countdown pill (red/yellow/grey by TTL), update-available pill driven by a one-shot crates.io check via curl, `:minimap on|off` overlay of one coloured cell per env (health-driven), saved-filter chip bar appears when `named_filters` is non-empty.
- **Pre-flight traffic warning** in the confirm modal: `compute_traffic_warning` flags ACTIVE DEPLOY / RECENT CHANGE / currently-Red before authorising further actions.
- **Drift glyphs** in NAME column: ◆ for envs updated within 24h, ◇ (muted) for envs unchanged > 30d.
- **5xx / 4xx / p90 anomaly badge** in Metrics tab (`series_anomaly_label` flags last sample > 2x baseline for error rates / 1.5x for latency).
- **Webhook on Red transition**: `webhook_url` config option; `build_webhook_payload` emits a flat JSON object via curl POST.
- **Selectable + yankable events panel**: `events_cursor`, `J`/`K` move, `y` yanks the line; `▶` glyph on the cursor row.
- **Multi-select + batch actions**: space toggles selection (✓ marker in NAME), `:batch-rebuild` / `:batch-restart` dispatch non-destructive actions across the selection in one shot.
- **Mouse-drag panel resize**: drag the divider between events panel and table; height clamped to [4, 30].
- **Focused-panel model + per-panel key strip**: `Focus` enum (Table / Events), `Ctrl-]` / `Ctrl-[` cycle, j/k routes by focus, footer strip swaps to events keys when focused there.
- **Custom keybindings**: new `src/keys.rs` parses `~/.config/ebman/keys.toml`; F1-F12 and uppercase A-Z aliases to `:` commands. `App.lookup_custom_key` intercepts in Normal mode before built-in dispatch.
- **Saved Configurations full CRUD**: `:config-save`, `:config-delete`, `:config-apply` wired to `CreateConfigurationTemplate` / `DeleteConfigurationTemplate` / `UpdateEnvironment(template_name)`.
- **`:account NAME`** alias for `:profile NAME` (the standard AWS pattern of one profile per account). A real `sts:AssumeRole`-based account model is deferred to a dedicated session.
- **Cross-account search**: `:find-env <substring>` fans `DescribeEnvironments` across every profile in `~/.aws/{config,credentials}` (in the current region), reports hits in an overlay.
- **Org-wide health overview**: `:org-health` aggregates env / Red counts per profile across all configured profiles, surfaced in an overlay.
- **First-run wizard**: when no persisted state + no AWS creds, a welcome overlay walks through bare-minimum setup.
- **Metric chart hover**: `metrics_hover_col` + `metrics_body_rect` capture mouse position in Detail/Metrics; `hover_index` pure helper maps column → point index → `@cursor <value>` in each chart's title row.

### Non-interactive CLI
- **`ebman envs [--json]`** prints the env list as TSV or JSON.
- **`ebman action <rebuild|restart|terminate> --env NAME [--yes]`** dispatches an action without entering the TUI. Terminate requires `--yes`.
- `--help` updated to document subcommands; `--version`, `-h`, `-V`, `--read-only` flags continue to work.

### CW Logs auto-discovery on Detail open (2026-05-19)
- **Discovery on Detail open** — `discover_env_log_groups` fires once when the user opens an env (alongside tags / env-vars / instances). Result stored on `DetailState.cw_log_groups`. Logs-tab idle hint now renders one of three tailored strings (groups present → "press s to live-stream", groups absent → "CW Logs not configured (`:logs-stream on` to enable)", still loading → "checking…"). No auto-open of the streaming overlay — that's still triggered by `s`. Discovery errors swallowed silently and fall back to the "checking" hint, so a missing IAM perm on `logs:DescribeLogGroups` doesn't surface as a toast.

### More per-option commands (2026-05-19)
- **`:deployment-policy POLICY`** — sets `aws:elasticbeanstalk:command.DeploymentPolicy`. Accepts canonical names (AllAtOnce, Rolling, RollingWithAdditionalBatch, Immutable, TrafficSplitting) and lower-case aliases.
- **`:rolling-update on|off`** — toggles `aws:autoscaling:updatepolicy:rollingupdate.RollingUpdateEnabled`.
- **`:health-check-url /path`** — sets `aws:elasticbeanstalk:application.Application Healthcheck URL` (the path the ALB target group probes for HTTP 200).
- **Logs tab idle-state hint** — "press ^R for one-shot snapshot · s to live-stream CW Logs (needs `:logs-stream on`)" replaces the prior single-line hint so operators discover the streaming path without reading help.

### Follow-ons (2026-05-19)
- **`:deploy --from s3://bucket/key`** — sidesteps the local-read + upload steps; goes straight to `CreateApplicationVersion` against the existing S3 object. Pure `parse_s3_url` helper with happy-path + 4 malformed-input tests. `spawn_deploy_from_s3` shares the same audit/pending/finish plumbing as the local path.
- **`s` keybind on the Detail Logs tab** opens the CW Logs streaming overlay (`spawn_logs_tail`) over the existing snapshot view. One-keypress upgrade; closing the overlay returns to the snapshot. Updated global + per-context help.
- **Custom metric dimensions** — `:metric add LABEL NS NAME [STAT] [DIM=VAL,DIM=VAL]` accepts explicit dimension overrides. Pure `parse_metric_extra_args` handles the "stat or dims, in any order" heuristic; `CustomMetricSpec` now carries an optional 4th pipe-delimited field for persistence. Metrics in `AWS/EC2`, `AWS/ApplicationELB`, etc. now reachable with the right dimension keys. Tests: `parse_metric_extra_args_*` × 4, `custom_metric_spec_round_trips_with_dimensions`, `custom_metric_spec_parse_drops_malformed_dimension_pairs`.

### Deploy from local path (2026-05-19)
Last remaining Tier 1 blocker, shipped. Tests: `derive_version_label_*` × 3, `expand_tilde_only_replaces_leading`.

- **`:deploy --from PATH [--label LABEL] [--describe DESC] [--no-deploy]`** uploads a local `.zip`, creates an EB application version, and (by default) immediately deploys it to the selected env. Existing `:deploy LABEL` shape preserved for shipping known labels.
- New `aws::create_storage_location` / `put_application_bundle` / `create_app_version` helpers. S3 client added to `AwsClient`.
- Bundle uploaded to EB's managed bucket under `applications/<app>/<label>`; `CreateApplicationVersion` references it via `S3Location`.
- Pure helpers + tests: `derive_version_label` (filename stem + unix ts, sanitised to EB's `[A-Za-z0-9_.-]` charset); `expand_tilde` (only the leading `~/` form).
- Pre-validation in the synchronous path: file exists, non-empty, read into memory. Multi-stage errors surface with stage prefix (`storage-location:`, `s3-put:`, `create-version:`, `deploy:`) so operators know where the chain broke.
- Known limitations (now on backlog): no `s3://bucket/key` source yet; no multipart upload (5 GiB ceiling); whole bundle held in memory during upload.

### Custom metric selection (2026-05-19)
Operator-defined extra charts in the Metrics tab. Tests: `parse_custom_metrics`, `parse_custom_metric_drops_malformed_value`, `custom_metric_spec_round_trips`.

- **`:metric add LABEL NAMESPACE NAME [STAT]`** upserts a custom-metric chart; STAT defaults to Average. Persists to `state.toml` under `metric.LABEL = "namespace|name|stat"`. Auto-refreshes the Metrics tab if it's currently open.
- **`:metric remove LABEL`** drops the entry + persists + refreshes.
- **`:metric list`** dumps the table into a TextOverlay.
- `aws::fetch_custom_env_metrics` generalises the existing GetMetricData path; runs concurrently with the built-in fetch via `tokio::join!`. Results append to the fixed 4-chart set in operator-add order.
- Known limitation (in backlog): charts hard-scope to `EnvironmentName` dimension, so anything outside `AWS/ElasticBeanstalk` namespace returns empty until we support custom dimensions.

### CloudWatch Logs `tail -f` (2026-05-19)
The biggest remaining Tier-1 blocker, shipped. Tests: `pick_default_log_group_*` × 3.

- **`:logs-tail [LOG_GROUP]`** opens a streaming overlay that polls `cloudwatch:FilterLogEvents` every 2s and appends events. If no group specified, discovers groups under `/aws/elasticbeanstalk/{env}/` and auto-picks the most useful (web.stdout.log preferred, then eb-engine.log / eb-hooks.log / nginx access).
- New `Overlay::LogTail` variant with cap of 2000 events (oldest dropped), `following` auto-tail mode, regex filter (`/` activates, `n` clears), j/k scroll, G snap-to-tail, g jump-to-top.
- Polling task lifecycle: aborted on overlay close, on a second `:logs-tail` call, and on profile/region switch via `apply_rebuild`. Session id bumped at every teardown so late `LogTailOpened` messages from the aborted task can't re-open the overlay (abort + channel-send race).
- Pure `pick_default_log_group` helper for the default-group selection. Render gracefully handles plane-1 chars in messages via ratatui's existing Wrap.
- Late `LogTailEvents` arriving during a `?`-help round-trip route into `pre_help_overlay` so events aren't lost while reading help.

### Per-option commands + generic option escape hatch (2026-05-19)
Fills the Network + Security + miscellaneous-option gap without the modal-form abstraction. The new generic commands cover anything we don't have a friendly name for.

- **`:keypair NAME`** — set EC2 key pair (security tab equivalent).
- **`:service-role ARN_OR_NAME`** — set EB service role.
- **`:instance-profile NAME`** — set IAM instance profile attached to EC2.
- **`:public-ip on|off`** — toggle `AssociatePublicIpAddress`.
- **`:elb-scheme public|internal`** — set ELB scheme.
- **`:set-option NAMESPACE OPTION VALUE...`** — generic escape hatch for any option-settings namespace; VALUE tokens joined with single spaces.
- **`:unset-option NAMESPACE OPTION`** — generic clear back to the platform default.

All seven funnel through the shared `spawn_option_settings_update` helper, so read-only + audit + pending tracking are inherited.

### Env vars in Config tab (2026-05-19)
- **Env vars now render in the Config tab** — operators no longer need `:env list` for the common "what's set?" case. Loaded eagerly on Detail open via the same lazy pattern as tags (`fetch_env_vars` → `AppMsg::DetailEnvVars` → `detail.env_vars`). After `:env set` / `:env unset` succeeds the Config tab auto-refreshes (the OptionSettingsUpdate handler keys on the summary prefix). Same key-column auto-sizing + overflow-to-newline layout as tags; empty values render as `""` to distinguish "set to empty" from "not set".

### Instance-type + custom-platform-delete (2026-05-19)
- **`:instance-type TYPE`** — first slice of the "capacity profile beyond min/max" gap; sets `aws:autoscaling:launchconfiguration.InstanceType` via the shared option-settings helper. EB triggers a rolling launch-config replacement. Other capacity settings (spot %, scaling triggers, scheduled scaling) still need either a modal form or per-option commands.
- **`:custom-platform-delete <arn>`** — closes the create/delete loop for the `:custom-platforms` listing. EB rejects with a clear error if any env still uses the platform; otherwise async cleanup proceeds. Create still on backlog (slow Packer-build flow).

### Env-var editor (2026-05-19)
Console's most-used edit surface (env var changes), now reachable without leaving ebman or opening a modal form. Tests: `format_env_vars_aligns_on_equals`, `format_env_vars_handles_empty_input`.

- **`:env list` / `:env set KEY VAL...` / `:env unset KEY`** — single CLI surface for `aws:elasticbeanstalk:application:environment` namespace. List opens a TextOverlay of `KEY = VALUE` lines (sorted; empty values render as `""`). Set/unset funnel through the existing `spawn_option_settings_update` helper so read-only + audit + pending tracking are free. Value tokens joined with single spaces (`:tag` convention). Usage error documents that changes trigger an app-server restart per EB. Pure `format_env_vars` helper for the list rendering.

### Console-parity batch (2026-05-19)
Shipped as one block; all use the existing pending-actions + audit-log + read-only-gating machinery. Tests added: `parse_named_arg_picks_up_value_after_flag`, `alarm_kind_to_metric_covers_known_kinds`, `format_template_settings_groups_by_namespace`, `format_template_settings_handles_empty_input`.

- **Inspect saved config template contents** — `:config-inspect [APP] TEMPLATE` calls `DescribeConfigurationSettings(template_name)` and surfaces the option settings as a sorted text dump grouped by namespace; new `i` keybinding in the interactive `:saved-configs` overlay opens the inspection for the cursor row. Empty values render as `""` so operators can distinguish "set to empty" from "not set". Pure `format_template_settings` helper for the rendering.
- **Create / delete CloudWatch alarms** — `:alarm-create NAME KIND THRESHOLD [OP]` and `:alarm-delete NAME`. KIND is one of `health` / `4xx` / `5xx` / `latency` (matches the existing Metrics-tab chart set). Operator defaults: 5-min period, 1 evaluation period, kind-specific comparison operator (health = LE, others = GT). No SNS action wired — operators set notification topics via console or `:notify`. Pure `alarm_kind_to_metric` helper.
- **CloudWatch Logs streaming toggle** — `:logs-stream on|off [--retention DAYS]` flips the EB option settings under `aws:elasticbeanstalk:cloudwatch:logs` (StreamLogs / RetentionInDays / DeleteOnTerminate). Default retention 7 days. Prerequisite for the still-on-backlog "real `tail -f`" item to have anything to tail.
- **Notifications (SNS topic for env events)** — `:notify EMAIL_OR_SNS_ARN` accepts either an email address (EB creates a topic + subscription) or an existing SNS topic ARN (EB just attaches it). `:notify off` clears the endpoint via options-to-remove.
- **Managed platform updates window** — `:managed-window DAY HOUR | off`. Day accepts full or abbreviated names (mon|monday); hour 0-23. Generates EB's cron-style `Sun:04:00` PreferredStartTime + enables ManagedActionsEnabled. If the env's ServiceRoleForManagedUpdates isn't set, EB will reject with a clear error — operator can address via console or follow-up option-settings call.
- **`OptionSettingsUpdate` AppMsg + `spawn_option_settings_update` shared helper** — all three option-settings commands funnel through one place. New `parse_named_arg` pure helper for `--flag VALUE` style optional args (used by `--retention`).

### UX punch list batch B (2026-05-19)
- **`Overlay::SavedConfigs(String)`-as-generic-text-dump refactor** — new `Overlay::TextDump { title, body }` variant and matching `AppMsg::TextOverlay { gen, title, body }` (renamed from the misleading `CrossAccountSearch`). Every callsite passes its own title; `:pending`, `:resources`, `:find-env`, `:org-health`, `:upgrade`, `:custom-platforms`, `:versions` all surface accurate block titles instead of "saved configurations".
- **`:help` now opens the context-scoped help** — Command-mode `:help` infers `help_topic` from app state (Detail view live → Detail; action flow open → Action; DLQ open → Dlq; SavedConfigs overlay open → SavedConfigs; otherwise Global). Matches the `?` keybinding's behaviour so the two routes don't disagree.
- **`:tag` usage error documents the value-joining convention** — "value tokens joined with single spaces; no shell quoting — use a separate call to set values with literal multi-spaces". Tag editing without surprise.
- **`:delete-version` invalidates the `:versions` overlay** — on a successful delete the handler checks whether the current overlay is the matching `application versions — {app}` text dump and re-fetches if so. No more stale entries after a destructive op.
- **Interactive saved-configs overlay groups by application** — rows render under bold app-name headers instead of a flat `app/template` list. Cursor still indexes items, not headers.

### UX punch list batch A (2026-05-19)
Items from the drive-the-app review, shipped together because they share state. Tests added: `action_labels_are_distinct_and_non_empty`, `visible_window_anchors_to_top_when_items_fit`, `visible_window_slides_to_keep_cursor_visible`, `visible_window_handles_empty_and_zero_budget`.
- **Audit log + toast labelling fixed for `:config-*` and `terminate-instance`** — new `Action::ConfigSave / ConfigDelete / ConfigApply / TerminateInstance` variants with proper labels. Replaces the `Action::Rebuild`-as-placeholder reuse; audit entries now record the real action name. Added `stage=dispatched` audit lines for all three config-* commands (previously only stage=completed was written).
- **Tag / delete-version / config-* writes now appear in the pending-actions panel** — `spawn_tag_update`, `spawn_delete_app_version`, and the three config-* paths all call `push_pending` + the corresponding handler calls `complete_pending`. Header `⏳ N` chip and `:pending` overlay are now an accurate truth-source for in-flight work.
- **Terminate-instance pending-row never matched complete_pending** — pre-existing bug: dispatch wrote `"Terminate instance i-abc"` as the label, completion looked for `"Terminate instance"` (now `Action::TerminateInstance.label()`). Result: termination rows lived forever as "in flight". Fixed by aligning the label and carrying instance id in the target string instead.
- **Pressing `?` from Detail / Action / Dlq now returns there on close** — pre-existing bug: closing help dropped the user back to Normal mode unconditionally. `pre_help_mode: Option<Mode>` field is set at every `?` keypress and restored on close. Same treatment for an overlay open at `?` time via new `pre_help_overlay` field.
- **Per-context `?` help now visible from the footer** — Detail / Action / Dlq key-strips advertise ` ? help`. The feature was unreachable for new users before.
- **`HelpTopic::SavedConfigs` implemented** — pressing `?` inside the interactive overlay stashes the overlay, surfaces a Saved-Configs help renderer (`draw_help_saved_configs`), and restores the overlay on close. Replaces the prior doc-comment-lie behaviour.
- **`x` in interactive overlay now requires Y/N confirm** — armed-confirm state on the overlay variant; banner turns red and the cursor row tints red until y/Y/enter (dispatches) or n/N/esc (cancels). Asymmetric-with-Terminate gap closed for a less destructive op.
- **Interactive overlay scrolls when items overflow** — pure `visible_window(cursor, total, budget)` helper slides the visible window so the cursor stays in view; `↑ N more above` / `↓ N more below` hints render when the list is clipped.

### Write-path batch B (2026-05-19)
- **Saved-configuration overlay → editable** — new `Overlay::SavedConfigsInteractive { items, cursor }` variant replaces the text-dump for `:saved-configs` when any templates exist (falls back to the dump when none do). j/k/g/G/up/down/home/end navigate; enter/a apply selected template to currently-selected env; x deletes; c closes the overlay and prefills `:config-save ` for the user to type a template name. All three dispatch through the existing `:config-apply` / `:config-delete` / `:config-save` paths so they share read-only gating and audit trail. Read-only gating was missing on the underlying commands too — fixed in the same pass. Pure `collect_saved_configs` helper sorts (app, template) pairs deterministically; tested for sort stability + empty-input.

### Write-path batch (2026-05-19)
- **Tags editor** — `aws::update_tags` wraps `UpdateTagsForResource`; `:tag KEY VALUE` adds/updates a tag and `:untag KEY` removes one. Read-only mode blocks both; ARN-missing on the selected env errors out; the call writes a dispatched + completed audit entry; on success a toast fires and the Config-tab tags refresh automatically. Pure helper `parse_tag_args` handles the "value tokens joined with spaces" convention; tested for happy path, multi-token join, and rejection of missing-value input.
- **Application-version delete** — `aws::delete_application_version` wraps `DeleteApplicationVersion`; `:delete-version <label> [--force]` dispatches against the selected env's app. `--force` (alias `-f`) sets `DeleteSourceBundle=true` so the S3 zip is also removed. Read-only mode blocks; dispatched + completed audit entries written; outcome surfaced as a toast. AWS still rejects deletes of versions currently deployed to an env — those bubble up in the error toast.
- **Powerline-font glyph set (`icons = "powerline"`)** — opt-in via config.toml; `IconStyle::Powerline` variant joins Unicode/Ascii. Routes thin powerline separator U+E0B1 through `sep()`, U+E0B6/E0B4 tab caps through `titled_block`, Nerd Font MDI tab icons (flash, server, chart-line, email, text-box, cog) through `tab_icon`, and U+F111 dot through `health_dot`. Spinner reuses the braille frame set (Powerline-targeted fonts include braille). README config example updated; tests for sep glyph routing and tab-icon distinctness across all three icon styles.

### Operator-feedback batch (2026-05-19)
- **Pending-actions panel** — `PendingAction { label, target, started, completed }` queue (cap 20, completed entries expire after 60s); wired into `spawn_action` / `spawn_batch_action` / `spawn_terminate_instance` and the `AppMsg::ActionResult` handler. Header chip `⏳ N` while any are in flight; `:pending` / `:in-flight` / `:inflight` overlay lists label, target, age, and outcome.
- **Per-context help** — new `HelpTopic` enum (Global / Detail / Dlq / Action / Shell) on `App`; `?` in Detail / Dlq / Action modes scopes the help overlay to just the keys relevant to that screen, with a footer pointer back to the global keymap. Implemented as `draw_help_detail` / `_dlq` / `_action` / `_shell` helpers in `ui.rs`. Shell topic kept reachable-shaped but currently unreachable since `?` is forwarded to the subprocess.

### Small-wins batch (2026-05-19)
- **Dry-run preview for Deploy / Scale / Clone** — parameterised actions now run the same `spawn_dry_run` + `spawn_preflight_events` pre-flight that Rebuild/Terminate have, so the confirm modal shows the instance / AZ impact and last 3 events before the operator authorises.
- **`:resources` overlay** (`:resources` / `:res`) — `DescribeEnvironmentResources` dump (ASGs, instances, LCs, LTs, LBs, triggers, queues) in a single overlay. Useful "what's actually in this env" view; also caught the WorkerQueueURL-is-empty bug originally.
- **Crash-report 30-day TTL** — `prune_old_crash_reports` now drops any `crash-*.log` older than 30 days regardless of the count cap. Test `prune_old_crash_reports_drops_files_past_ttl` covers it.
- **Status-diff toast suppression** — `delta_toast_key` extracts a bucket name from text shaped `▲N Bucket`; `push_toast` collapses successive toasts with the same key into the latest value rather than stacking. Tested for happy path + negative cases.
- **Sortable Config-tab tags mini-table** — tags now render alphabetically (case-insensitive); key column auto-sizes to the longest key clamped at 12–40 chars; long keys overflow to their own line so values stay aligned.

### Remote control plane
- **`--control-socket PATH`** — when set, ebman opens a Unix socket at PATH with 0600 perms and accepts one-shot requests: `SCREEN` (plain-text dump of the current frame from the ratatui back-buffer), `KEY <spec>` (synthesised key event injected via `handle_event`; spec supports Ctrl/Shift/Alt + arrows / Enter / F1-12 / single chars / `Char(x)`), `CMD <text>` (runs a `:` command), `STATE` (flat JSON with mode / profile / region / account / envs / selected / load / sort / grouped / redact / focus).
- **`ebman ctl <op>` subcommand** — one-shot client; defaults to `~/.cache/ebman/control.sock`; override with `--socket PATH`. Examples: `ebman ctl screen`, `ebman ctl key Down`, `ebman ctl key Ctrl+R`, `ebman ctl cmd ":region eu-west-2"`, `ebman ctl state`.

### Mocked-AWS coverage: write path + error path (2026-05-20)
- **`update_env_option_settings_builds_correct_request_shape`** — pins the load-bearing write path used by `:capacity`, `:env`, `:tag`, `:subnets`, `:elb-subnets`, `:security-groups`, and every `:set-option`. Asserts environment_name + each option_setting tuple (namespace / name / value, in caller order) + options_to_remove all land on the UpdateEnvironment request. Uses `match_requests` as the assertion vehicle — a request shape that diverges fails the rule match and surfaces as a test error.
- **`update_env_option_settings_rejects_empty_input_before_dispatch`** — the "nothing to do" guard must short-circuit before any AWS call. Test mocks a tripwire rule and asserts `num_calls() == 0` after the guard fires.
- **`update_env_option_settings_surfaces_aws_errors`** — `then_error` returns `InsufficientPrivilegesException`; assert the wrapped error string carries the contextual prefix so the log is actionable.

### ELB-subnets picker (2026-05-20)
- **`:elb-subnets`** — sibling to `:subnets`, targets `aws:ec2:vpc.ELBSubnets` so the ELB attaches to a different subnet set than the instances. Web-tier-only. Added `MultiSelectFlavour::ElbSubnets` variant; `load_multi_select` reuses the existing `list_subnets_in_vpc` call but pulls the initial selection from the new `EnvVpcContext.elb_subnets` field. `fetch_env_vpc_context` extended to parse `ELBSubnets` from option settings; test updated to assert all three subnet/SG fields populate in one round-trip.

### Network + Security MultiSelect pickers (2026-05-20)
- **`FieldKind::MultiSelect` + helpers** — modal-form abstraction gained a multi-select field kind with comma-joined `value` (matches EB's option-settings format directly), per-field `option_cursor` for in-field row navigation, optional `option_annotations` for per-option display suffixes, and pure helpers `parse_multi_value` / `toggle_multi` / `is_multi_selected`. Up / Down (or j / k) moves between options when MultiSelect is focused; tab still moves between fields. Space toggles the option at the cursor. 5 unit tests.
- **`:subnets`** — opens a MultiSelect form with the env's EC2 subnets (filtered by VPC). Pre-fills with `aws:ec2:vpc.Subnets`, submits via the shared option-settings update path. Subnet rows annotated with `(AZ · CIDR · Name)`. Bound to the env table cursor; reports an error if no env is selected. Ordered by AZ then CIDR for stable picker rows.
- **`:security-groups`** — same shape, targets `aws:autoscaling:launchconfiguration.SecurityGroups` and lists EC2 security groups in the VPC. Ordered by group name.
- **`load_multi_select` shared async helper** — fans out to `fetch_env_vpc_context` + the right EC2 list call (DescribeSubnets or DescribeSecurityGroups), assembles options + annotations + initial selection, and lands as new `AppMsg::FormMultiSelectLoaded { gen, env_name, field_key, result }`. Handler matches by `field_key` so multiple MultiSelect fields in one form remain trackable.
- **`aws::fetch_env_vpc_context`** — single DescribeConfigurationSettings round-trip that returns `EnvVpcContext { vpc_id, subnets, security_groups }` from the relevant namespaces in one pass.
- **`aws::list_subnets_in_vpc` / `list_security_groups_in_vpc`** — EC2 inventory queries filtered by `vpc-id`, returning the wide rows the pickers need (id + AZ + CIDR + Name tag for subnets; id + name + description for SGs). Pure `split_csv` helper extracted for the CSV parsing.
- **Tests**: `split_csv_trims_and_drops_empties`, `fetch_env_vpc_context_pulls_vpc_id_subnets_and_sgs`, `list_subnets_in_vpc_filters_orders_and_extracts_name_tag`, `list_security_groups_in_vpc_orders_by_name`. All mocked via `aws-smithy-mocks` against the EB + EC2 SDK surfaces.

### Mocked-AWS test foundation (2026-05-20)
- **`aws-smithy-mocks` wired into the test build** — added `aws-smithy-mocks = "0.2"` plus the `test-util` feature flag on each AWS SDK crate as dev-dependencies. Production paths use the regular config; only the test build pays the extra crate cost.
- **`AwsClient::for_tests` constructor** — gated behind `#[cfg(test)]`, takes pre-built (typically `mock_client!`-backed) sub-clients so tests can swap in mocks for any single SDK surface without touching the others. Bare SdkConfig + a fixed `us-east-1` region keep behaviour reproducible; non-mocked sub-clients fail loudly on use, which is the signal we want for "unexpected AWS call from a code path we thought was pure".
- **Regression #1 pinned**: `worker_queues_resolves_via_describe_environment_resources_when_autocreated`. EB autocreated worker queues return `WorkerQueueURL = ""` from `DescribeConfigurationSettings`; the fix queries `DescribeEnvironmentResources` first. Test mocks both and asserts the primary path fires.
- **Regression #2 pinned**: `peek_messages_loops_and_dedupes_across_batches` + `peek_messages_stops_after_two_empty_batches`. SQS `ReceiveMessage` may return fewer than the requested batch; ebman loops with long-polling, dedupes by message id, and bails after two consecutive empty calls. Mocks a 4-call sequence to exercise both paths.
- **Happy-path lock-in**: `list_environments_maps_describe_environments_to_env_rows` — covers the most-used code path so refactors of `list_environments` can't silently break the table render. Verifies `tier` normalisation (WebServer → Web) and `platform_family` extraction.
- Foundation in place for future `aws.rs` tests; pattern is `mock!(Client::op).then_output(|| out)` + `mock_client!(crate, [&rule])` + `client_with_eb` / `client_with_sqs` helpers.

### UI polish pass 3 (2026-05-20)
- **Action-menu icons** — every entry in `:action` now leads with an icon-style-aware glyph. Powerline picks Nerd Font MDI icons (`F0450` refresh, `F0521` swap, `F01B4` trash, etc.); unicode falls back to `↻ ⇄ ✗ ↑`; ASCII gets fixed-width letter tags. Destructive actions render glyph in `theme.health_red`. New `Action::glyph(IconStyle)` method; test `action_glyph_is_distinct_per_action_per_icon_style`.
- **Version-label highlight** — pure `format_version_label` helper identifies the longest digit run in the version label (typically the build number) and renders it in `theme.app_palette[0]` BOLD; surrounding prefix / suffix dim to `theme.muted`. Operators scanning `build-10678` see the bright `10678` against the dim `build-`. Pure `longest_digit_run`; 5 tests.
- **Group-banner sub-totals** — per-app banner row now shows `3 envs · 2 web · 1 worker · 1 red` in the APPLICATION column. Empty buckets omitted; tier split only when both Web + Worker present in the group. Pure `summarize_group`; 3 tests.
- **Newly-added env marker** — new `App::newly_added: HashSet<String>` populated each refresh with env names absent from the previous `prev_health` (skips first refresh so startup doesn't flag every env). Table renders a transient `+` glyph in `health_green` on the NAME cell.
- **Health-transition pulse** — when an env is in `newly_red`, the rightmost sparkline cell renders as `█` (full block) with BOLD + SLOW_BLINK, drawing the eye to the just-landed transition. `sparkline_for` gained a `pulse_last: bool` arg.
- **Pending pill inline summary** — `⏳ 3` in the header chain replaced with `⏳ rebuild ×2, deploy`. Identical action stems collapse via `×N`; output truncated to 25 chars. Pure `summarize_in_flight` + `label_stem` mapping; 3 tests.
- **Context-aware footer hints** — when the status / error / filter footer slot is empty, surface a `💡 hint` in priority order: 2+ alerts (`:alarms`), 3+ in flight (`:pending`), SSO expiring within 15 min, new envs marked `+` this refresh. Reads only `App` fields; `None` when nothing's worth saying.
- **Form-field validation marker** — invalid fields in the modal form get a trailing `✗` glyph in `health_red` BOLD next to the value, in addition to the existing inline error line below. Eye-catcher for scanning long forms.
- **Confirm-modal env highlight** — destructive confirms (Terminate, Swap, AbortUpdate) render the env name as a red-on-row_red_bg chip inside the question line; non-destructive get a `title_alt` highlight. Pure `highlight_env_in_summary` helper; 2 tests.

### Settings menu + font auto-detect (2026-05-19)
- **`:settings` modal form** — interactive editor for `~/.config/ebman/config.toml`. Pre-fills nine fields from the live `App` state (theme, icons, refresh interval, redact-default, grouped-default, notification bell, required tags, extra regions, webhook URL). Submit writes the file back via the new `config::serialize` round-trip and live-applies theme / icons / refresh interval. `extra_regions`, `notify_bell`, `required_tags`, `webhook_url` update in place and take effect on the next refresh / event. Routed through the existing modal-form abstraction via new `FormSubmit::LocalConfig` variant; `open_form` short-circuits the AWS pre-fill for local-config forms; `submit_form` branches to a new `submit_local_config` path. Pure `Form::apply_to_config` helper merges form values onto a baseline `Config`; pure `config::serialize` round-trips through `config::parse`. Tests: `parse_icons_auto_is_preserved`, `serialize_round_trips_full_config`, `serialize_round_trips_default_config`, `apply_to_config_updates_known_fields`, `apply_to_config_unknown_keys_are_ignored`, `local_config_submit_yields_no_option_settings`.
- **`icons = "auto"` config value + cell-width probe** — new `src/font_probe.rs` writes a single Powerline triangle (`U+E0B0`) at startup, reads the cursor column back via `crossterm::cursor::position`, and resolves to `"powerline"` on a one-cell advance / `"unicode"` otherwise. Probe runs before `enter_tui()` so the glyph never reaches user scrollback; raw mode is enabled briefly via a `Drop`-based guard and torn down regardless of outcome. Pure `classify_advance` + `resolve_icons_setting` helpers keep the probe testable. Non-TTY stdout short-circuits to `false`. Tests: `classify_one_cell_advance_is_supported`, `classify_other_advances_are_unsupported`, `resolve_passes_through_non_auto_values`.

### Powerline polish pass (2026-05-20)
- **Lead-in arrow shape fix** — `pill_chain` and `render_tabs` switched the leading edge from `U+E0B0` (right-pointing) to `U+E0B2` (left-pointing) so the pill's coloured base sits flush with the body, mirroring the trailing `U+E0B0`. Previously the leading wedge read as much smaller than the trailing one (terminal-bg cell + tiny pink point vs. solid pink rectangle + pink wedge). Per-app group banner row picks up the same treatment so it reads as a symmetric `◀{app}▶` ribbon. Tests: `pill_chain_uses_left_wedge_for_lead_in_in_powerline_mode`, `pill_chain_no_powerline_glyphs_in_unicode_mode`.
- **Header pill-chain spacing** — two leading spaces injected before the chain in Powerline mode so the wedge has visual breathing room from the preceding `Sort: ...` text.
- **Loading-indicator linger fix** — refreshes whose round-trip lands just past the 300 ms display threshold no longer flash the spinner on and off in a single frame. New `LOADING_INDICATOR_LINGER` (500 ms) keeps the `loading…` indicator visible after completion if it became visible during the load. Pure `compute_loading_linger_target` helper; tests `loading_linger_target_none_when_no_load`, `loading_linger_target_none_when_under_threshold`, `loading_linger_target_arms_past_threshold`. Anim ticker condition includes the linger window so the spinner keeps advancing.
- **Theme-correct colours (~100 sites)** — removed every hardcoded `Color::Yellow/Cyan/Gray/Red/White` foreground in the footer, breadcrumb, kv() helper, DLQ overlay, action menu, confirm modal, Detail tabs (Events/Instances/Queue), and all six help screens. `help_line()` now takes a `&Theme` argument; ~106 call sites updated. Light + high-contrast themes finally render footer / help / DLQ correctly.
- **Breadcrumb separator** switches to `U+E0B1` (thin Powerline divider) in Powerline mode, matching the rest of the header chain. Falls back to ASCII `/` otherwise.
- **Powerline filter chips** — saved-filter chip bar in the header renders as a `pill_chain` ribbon (active chip in `title_alt`, inactive in `row_alt_bg`) in Powerline mode. Plain pill+bullet style preserved for unicode/ascii.
- **README font section** — Install section gained a "Fonts (optional)" subsection with `brew install font-meslo-lg-nerd-font` / `font-jetbrains-mono-nerd-font`, terminal-font setup paths (iTerm2 / Terminal.app / Ghostty / Alacritty / WezTerm / VS Code), and the `icons = "auto"` follow-up.

### UI polish pass 2 (2026-05-20)
- **Cursor row marker** — new `cursor_marker(theme)` helper. Powerline mode picks up `U+E0B0` as the highlight glyph; unicode/ascii keep the half-block `▌`. Applied to all 5 ratatui List/Table `highlight_symbol` sites (palette, env table, scope table, DLQ list, action menu). Test: `cursor_marker_swaps_per_icon_style`.
- **Empty-state polish** — when no envs match, the centred hint echoes the live filter text back (`no environments match \`prod-\``) so the operator can see what's hiding their rows. Heading in `title_alt` for contrast, properly centred horizontally and vertically. Three copy variants: empty-account, filter-hides-everything, saved-view-hides-everything.
- **Detail-header pills** — env-header line now renders Status as a coloured pill via new `status_pill` helper (extracted from `status_cell`) and Health as `health_dot` + label, matching the main env table aesthetic. Name + Application stay as kv text.
- **Toast notification glyphs** — info / ok / error toasts gained a leading severity glyph in both title and body. Glyph set varies by icon style: Powerline gets Nerd Font (`F05A` / `F058` / `F057`), unicode gets `ⓘ` / `✓` / `✗`, ascii falls back to `i` / `+` / `!`.
- **Splash minimum** bumped from 1 s to 2 s so the gradient pass has time to land before the table replaces it.
- **Region persistence fix** — `persist_state` was writing `override_region`, which is `None` when the user never explicitly `:region`-ed (they were on the AWS_REGION env default). Result: state.toml had no `region =` line and the next restart followed whatever the shell env pointed at *now*, feeling like ebman "forgot" the previous region. Switched to persisting the *effective* `context.region` (and analogously the effective profile). Restart now returns to the last-seen region regardless of how the user got there.
- **Frame consistency (G)** — every overlay border now flows through `rounded_block()`. Action confirm modal, action running modal, Detail Events/Instances/Queue/Logs tab outer frames, embedded shell pane, and the minimap previously used raw `Block::default().borders(Borders::ALL)` without rounded corners.
- **Caret glyph upgrade (H)** — new `caret_glyph(theme)` helper. Unicode + Powerline modes pick up `U+258E` (a thin vertical block that reads as a real terminal cursor) in place of the underscore. ASCII keeps `_`. Applied to all 10 blinking-cursor sites: command bar, filter bar, quick-jump bar, palette input, picker prompt, Detail Events search, DLQ purge confirm, action swap-target picker, Detail Logs filter, type-name terminate confirm. Test: `caret_glyph_falls_back_to_underscore_on_ascii`.
- **Toast accent stripe (F)** — every toast now gets a chunky `▎` severity-coloured stripe on the left edge of the body, Slack / VS Code notification-card style. Truncation budget bumped by 1 cell.

### Post-0.3.0 UX punch list (2026-05-21)
Twelve UX fixes from the v0.3.0 critical review, shipped as one batch (tasks #92–#103):

- **`Action::wants_preflight()`** in `mode_action.rs` — single source of truth for the "show impact preview + last-3 events" gating. Replaces three duplicated allow-lists (`open_parameterised_action`, `advance_action_flow::Terminate`, `advance_action_flow::Rebuild` hand-roll). `Rebuild` now routes through `open_parameterised_action` like every other lifecycle action.
- **`:swap` routes through `open_parameterised_action`** — was building `ActionFlow::Confirm` directly with `loading_dryrun: false`, so `:swap candidate` from the command bar silently skipped the preflight that `a → Swap` runs. Added `swap_with` to `ParameterisedAction` to support the single entry path.
- **`Esc` clears multi-select in Normal mode** — the multi-select status message advertised "esc = clear" but Normal had no Esc handler; a silent footgun for operators who multi-selected and walked away.
- **Multi-select active pill** — persistent "▶ N selected" pill while `multi_selected` is non-empty. Replaces the one-tick status-message hint that disappeared on the next refresh.
- **Pill foreground colours through `theme.contrast_text(bg)`** — WCAG-luminance-based black/white picker. The chain was hardcoded `Color::Black` (with one `Color::White` outlier for alerts) which broke any non-dark theme. Light + high-contrast themes now render readable pills.
- **Pill priority + width-aware elision** — `prune_pills_to_width` trims trailing low-priority pills under width pressure and marks the survivor with `+N` so elision isn't silent. Ordered most-critical-first (alerts > pending > multi-select > read-only > update > SSO > frozen > redact > grouped > view-mode).
- **ASCII glyph fallbacks** — new `warn_glyph` / `hint_glyph` / `stripe_glyph` helpers gate `⚠` / `💡` / `▎` (plus pill `⏳` / `▶`) so `icons = "ascii"` no longer renders box-tofu in the pending pill, footer hints, warnings, and toast accent stripe. Twelve sites swept.
- **Detail-Health tab now shows alarms + recent deploys** — was missing two of the four sections `:why` shows. New `DetailState::cw_alarms` / `recent_versions` fields + `spawn_detail_alarms` / `spawn_detail_recent_versions` helpers + `AppMsg::DetailAlarms` / `AppMsg::DetailRecentVersions` variants populate them when the Health tab opens. Triage surfaces no longer disagree.
- **Help screen completeness** — ~40 commands added across new sections (Multi-account, Lifecycle actions, Env config, Versions/configs/alarms/platforms, Bulk ops, Setup/discovery). Previously stale by half the v0.3.0 surface.
- **`:capacity` in action menu** — `Action::Capacity` variant + menu entry; `a → Capacity` opens the modal form. Was command-bar-only in v0.3.0.
- **`flatten_err_to_string` token coverage** — adds `AccessDenied`, `NotFound`, `Conflict`, `ExpiredToken` prefix-classifiers alongside the existing `ThrottlingException` set. Operators bouncing profiles hit AccessDenied constantly; now it's a labelled prefix instead of a buried SDK chain.
- **`FROZEN` pill goes yellow after 5 min staleness** — frozen auto-refresh during an incident is operationally important to not forget. Pill now reads `FROZEN (stale)` against a yellow bg when `last_refresh` is more than 5 min old.
- **Empty-state hint at no-envs-match** corrected from `views` → `:views`; footer context-hint at `app.alerts > 0` points at `:why` (v0.3.0 triage tool) instead of the stale `:alarms` / `:org-health`.

**14 new tests** covering `prune_pills_to_width` (3), the ASCII glyph helpers (3), `theme.contrast_text` (3), and `flatten_err_to_string` error-code classifiers (5). 282 → 296 total.

### `execute_command` split: final cut — task #66 complete (2026-05-20)
- **Three closing sub-modules in one go**:
  - **`src/app/cmd_alarms.rs`** (168 lines) — `cmd_alarm_create`, `cmd_alarm_delete`. Both still emit `AppMsg::AlarmOp` so the pending pill closes + toast fires; `alarm_kind_to_metric` reachable via `super::*`.
  - **`src/app/cmd_config_template.rs`** (129 lines) — `cmd_config_save`, `cmd_config_delete`, `cmd_config_apply`, `cmd_config_inspect`. `:config-save` keeps its inline `create_config_template` path (the only template arm that doesn't already have a `spawn_*` helper); the other three thunk into existing `spawn_config_*` plumbing.
  - **`src/app/cmd_misc.rs`** (330 lines) — the remaining cluster: `cmd_custom_platforms`, `cmd_versions`, `cmd_delete_version`, `cmd_pending`, `cmd_resources`, `cmd_custom_platform_delete`, `cmd_metric`. `Overlay::TextDump` reachable via `super::Overlay`; `humanize_short_age` / `parse_metric_extra_args` (pub fns) and `flatten_err` / `write_audit_line` (private parent-module fns) all wired via `super::*`.
- **Net for this cut**: `app.rs` 13,023 → 12,478 (-545 this cut; **-1,799 cumulative** from the original 14,277). Ten sub-module files (`cmd_action` 224, `cmd_alarms` 168, `cmd_config_template` 129, `cmd_misc` 330, `cmd_nav` 124, `cmd_option` 231, `cmd_overlay` 289, `cmd_settings` 285, `cmd_view` 206, `cmd_write` 174) total 2,160 lines.
- **`execute_command` is now pure dispatch** — every previously-inline arm body lives in one of the ten sub-modules; the match site reads as a column of one-liners (`"alarm-create" => self.cmd_alarm_create(&rest)`, etc.). The stale "Remaining categories" comment on the `mod cmd_*;` block in `app.rs` is updated to describe the finished layout.
- 282 tests pass; clippy `-D warnings` clean.
- **Task #66 closed**.

### `execute_command` split: seventh cut (2026-05-20)
- **`src/app/cmd_settings.rs`** — seven structured per-env write arms (`:tag`, `:untag`, `:env [list|set|unset]`, `:capacity`, `:logs-stream`, `:notify`, `:managed-window`) lifted into named methods. The big ones: `:env`'s 65-line sub-command tree (list/set/unset), `:managed-window`'s day-of-week + hour normalisation, `:capacity`'s 4-field modal form construction.
- **Net so far across seven slices**: `app.rs` 13,281 → 13,023 (-258 this cut; -1,254 cumulative). Seven sub-module files total 1,533 lines.
- 282 tests pass; clippy `-D warnings` clean. Pattern fully stabilised — `flatten_err`, `format_env_vars`, `parse_tag_args`, `parse_named_arg` all reachable via `super::*` from the sub-module.

### `execute_command` split: sixth cut (2026-05-20)
- **`src/app/cmd_nav.rs`** — six navigation / view-state arms (`:region` / `:r`, `:account`, `:profile` / `:p`, `:sort`, `:group`, `:redact`) lifted. Region multi-region toggle (`:region all` / `off`) preserved verbatim. `:account` keeps its AssumeRole-vs-profile-alias branching. `parse_toggle` helper imported via `super::*`.
- **Net so far across six slices**: `app.rs` 13,368 → 13,281 (-87 this cut; -996 cumulative). Six sub-module files total 1,248 lines. 282 tests still pass; clippy clean.
- This cut is smaller (~87 lines off) because navigation arms were already pretty compact compared to the 200+ line arms in earlier cuts, but the dispatch site is now uniformly one-liners across the entire nav+view+option+action+overlay+bulk-write spectrum — only structured-write + misc remain.

### `execute_command` split: fifth cut (2026-05-20)
- **`src/app/cmd_action.rs`** — eleven lifecycle action arms (`:deploy`, `:upgrade`, `:clone`, `:scale`, `:stop`, `:start`, `:abort`, `:rebuild`, `:restart`, `:terminate`, `:swap`) lifted into named methods. Most route through the existing `open_parameterised_action(action, ParameterisedAction { … })` path; `:terminate` keeps its strict-typed-name guard via the action menu; `:swap` builds the `ActionFlow::Confirm` directly because the swap shape doesn't fit `open_parameterised_action`'s API.
- **`:deploy`** preserves the two-form structure: legacy `:deploy LABEL [--preview]` and `:deploy --from PATH | s3://… [--label] [--describe] [--no-deploy]`. The path discriminator stays in the lifted method.
- **Net so far across five slices**: `app.rs` 13,552 → 13,368 (-184 this cut; -909 cumulative). Five sub-module files total 1,124 lines. 282 tests still pass; clippy clean.

### `execute_command` split: fourth cut (2026-05-20)
- **`src/app/cmd_option.rs`** — eleven per-option-settings arms (`:deployment-policy`, `:rolling-update`, `:health-check-url`, `:keypair`, `:service-role`, `:instance-profile`, `:public-ip`, `:elb-scheme`, `:set-option`, `:unset-option`, `:instance-type`) lifted into named methods. Each calls `spawn_option_settings_update` after its own canonicalisation / validation; the arms varied only in (namespace, name, value-shape) so lifting them turns a 200-line wall of repetitive `match rest.first().copied()` into a column of one-liners.
- **Net so far across four slices**: `app.rs` 13,743 → 13,552 (-191 this cut; -720 cumulative). Four sub-module files total 900 lines. 282 tests still pass; clippy clean.
- **`Some(s @ ("public" | "internal"))`** in `:elb-scheme` no longer needs the redundant `if s == "public"` re-mapping — the captured `s` already holds the matched string, removing 1 line of dead binding.

### `execute_command` split: third cut (2026-05-20)
- **`src/app/cmd_view.rs`** — view / filter / column management arms (`:cols`, `:save-view`, `:view`, `:views`, `:view-drop`, `:filter` / `:f`, `:save`, `:drop`, `:filters`) lifted into nine methods. All pure-state, no AWS, no async — lowest-risk slice yet. 162-line `:cols` arm dropped to one line.
- **Net so far across three slices**: `app.rs` 13,894 → 13,743 (-151 this cut; -529 cumulative). Three sub-module files total 669 lines. 282 tests still pass; clippy clean.
- **`encode_view` / `apply_view`** — private free functions in `app.rs` accessed via `super::*` from the sub-module, same visibility-via-descendants trick as `flatten_err_to_string` etc.

### `execute_command` split: second cut (2026-05-20)
- **`src/app/cmd_write.rs`** — bulk write-side arms (`:batch-rebuild`, `:batch-restart`, `:batch-deploy`, `:batch-tag`, `:batch-untag`, `:batch-set-option`) lifted into four methods (`cmd_batch_action`, `cmd_batch_deploy`, `cmd_batch_tag_or_untag`, `cmd_batch_set_option`). The union arms in `execute_command` collapse from 165 lines to 6 one-liners; `cmd == "batch-rebuild"`-style dispatch becomes an `Action` enum parameter passed in from the call site, cleaner than the in-arm string-check.
- **Net**: `app.rs` 14,052 → 13,894 (-158); `cmd_overlay.rs` 289 + new `cmd_write.rs` 174 = 463 lines in sub-modules. 282 tests still pass; clippy clean.
- **Same pattern as cmd_overlay** — private `spawn_batch_*` helpers stay in app.rs and remain reachable from the sub-module via parent-module visibility. `parse_tag_args` (pub fn) imported via `super::*`.

### `execute_command` split: first cut (2026-05-20)
- **`src/app/cmd_overlay.rs` extracted** — first slice of the long-pending `execute_command` refactor (task #66). The three heaviest multi-account-overlay arms (`:accounts`, `:org-health`, `:find-env`) — ~225 lines combined — moved into `impl App { … }` methods (`cmd_accounts`, `cmd_org_health`, `cmd_find_env`) in a new `app::cmd_overlay` sub-module. The dispatch arms in `execute_command` become one-line method calls.
- **Sub-module pattern**: `mod cmd_overlay;` declared inside `src/app.rs` resolves to `src/app/cmd_overlay.rs`. The new file's `impl App` block accesses App's private fields and methods via the parent-module visibility rule (private = visible within the defining module + descendants). `flatten_err_to_string`, `format_org_accounts`, `AppMsg`, and `crate::config::AccountSpec` imported via `super::*` paths.
- **Why these three first**: they're the heaviest overlay-only arms (each 50-100+ lines of `tokio::spawn` orchestration) and end at `tx.send(AppMsg::TextOverlay)`, so the refactor doesn't change any synchronous state transitions — lowest blast radius for the first cross-module split.
- **Net effect**: `app.rs` -225 lines (~14,277 → ~14,052); 282 tests still pass; clippy `-D warnings` clean. Pattern proven; the remaining write-side, navigation, and misc categories can follow the same shape in dedicated follow-ups.

### Organizations discovery: `:accounts` (2026-05-20)
- **`:accounts` overlay** — new command lists every child account in the active AWS org via `organizations:ListAccounts`. New `aws-sdk-organizations` dep; new `OrgAccount { id, name, email, status }` type; new `OrgClient` field on `AwsClient` (initialised in every constructor path including `with`, `assume_role`, `for_tests`). `list_org_accounts` paginates via `next_token`, sorts ACTIVE-first then by name, surfaces AccessDenied separately so the overlay can show a "no org access" hint with a config-toml workaround instead of an opaque SDK error.
- **Pure `format_org_accounts(accounts, configured)`** renders the overlay body. Each row: status marker (`●` ACTIVE / `⊘` SUSPENDED / `○` other) + name + 12-digit id + status. Email shown as a sub-line when populated. Most importantly: when a matching `accounts.NAME` entry exists in `config.toml` (matched on friendly name OR 12-digit id), the row gets a `:account NAME` suffix telling the operator exactly which keybind switches into it. Operators with no matching entry see informational data only and are pointed at the config workaround.
- **Switch-hint matching is case-insensitive name-or-id** so operators who key their `accounts.*` entries by account-id (e.g. `accounts.111122223333`) still get the hint. 3 tests cover happy-path-with-hint, empty-result hint, and id-based matching.
- **No interactive picker yet** — the overlay is read-only TextDump. Adding `Enter → :account NAME` requires the auto-AssumeRole-by-default-role path; logged for a follow-up. The current flow (configure `accounts.NAME` once, then `:account NAME` to switch) is fine and explicit.

### Cross-account `:find-env` (2026-05-20)
- **`:find-env` now scans AssumeRole accounts** — symmetric with the multi-account `:org-health` ship from earlier. Fans out over profile sources (existing) AND `accounts.NAME` entries via boxed dynamic futures into a single `join_all`. Hit lines for AssumeRole accounts carry the `(assume-role)` suffix so the operator can spot which credential path each hit came from. Status message updated to count both: `searching 'foo' across N profile(s) + M assume-role account(s) in REGION…`. Closes the Tier 4 AssumeRole-everywhere loop (switcher + org-health + find-env all consistent).

### Multi-account `:org-health` (2026-05-20)
- **`:org-health` now walks AssumeRole accounts too** — previously the fan-out only walked `~/.aws/{config,credentials}` profiles via `list_environments_in_region`. Now it also fans out across every `accounts.NAME` in `config.toml`, calling the new `aws::list_environments_for_account(name, &spec, Option<region>)` which assume-roles then lists. One ebman instance in the mgmt account surveys every child account in a single pass.
- **Unified rendering** — both kinds (profile + assume-role) feed into a single `join_all` via boxed dynamic futures. Assume-role rows get a `(assume-role)` suffix in the overlay so the operator can tell the kinds apart. Totals aggregate across both. Title bumped to "one row per profile / assume-role account".
- **Status message** updated to count both — `scanning N profile(s) + M assume-role account(s) in REGION…`.
- **Follow-up** (still open): extend `:find-env SUBSTRING` to also scan AssumeRole accounts. Same pattern; small.

### CW metric batching test + AssumeRole account switcher (2026-05-20)
- **CW batching mocked test** — `fetch_env_metrics_batches_and_reorders_by_canonical_id` pins four contract guarantees: (1) `fetch_env_metrics` dispatches exactly ONE `GetMetricData` call (batched, not fan-out), (2) all 4 canonical ids — `health` / `req4xx` / `req5xx` / `p90` — are requested, (3) the returned `Vec<MetricSeries>` is in canonical order even when AWS shuffles the response (which it has been known to do), (4) per-id labels map correctly. New `client_with_cw(cw)` helper extends the test-fixture family. Closes the last open mocked-AWS coverage gap.
- **AssumeRole account switcher** — new `AccountSpec { role_arn, source_profile?, external_id?, region? }` type + `Config.accounts: HashMap<String, AccountSpec>`; parsed from `accounts.NAME.field = "value"` lines in `config.toml` (mirrors the existing `metric.LABEL.field` shape, no TOML section parser needed). New `AwsClient::assume_role(target_name, &spec)` calls `sts:AssumeRole` with `source_profile`'s creds as the launchpad, captures the returned temp credentials, and builds a fresh `SdkConfig` carrying ONLY the assumed-role identity (no leaked source creds). New `aws-credential-types` dep (1 line in Cargo.toml; transitive via aws-config already).
- **`:account NAME` dispatch** — branches in two ways: (1) `accounts.NAME` configured → AssumeRole flow via new `spawn_assume_role_switch` (lands as `AppMsg::Rebuild`, same swap path as `:profile` so overlay tear-down / throttle reset / identity refresh are free), (2) otherwise legacy fallback to `:profile NAME` aliasing. The two paths coexist so operators with one-profile-per-account in `~/.aws/config` keep working.
- **Context breadcrumb** treats the friendly account name as the "profile" so the header reads `account=prod` rather than the source profile name. Account_id + caller_arn get filled in once `verify_identity` runs against the new client (existing path).
- **Session lifetime** defaults to AWS's 1h cap; the operator's refresh tick re-invokes when the session dies. No background refresh today — `spawn_assume_role_switch` is invoked again by `:account NAME` repeatedly if needed. Auto-renewal is a follow-up.
- 2 config-parse tests (`parse_accounts_collects_multiline_specs`, `parse_accounts_ignores_unknown_field`) lock the schema.

### UI integration test harness (2026-05-20)
- **`App::for_tests(aws, cfg)` constructor** — synchronous, no AWS round-trip, no disk read, no spawn_identity / spawn_refresh kickoffs. Builds the full App struct with sensible defaults so tests start from a known clean state and can mutate any field directly (struct is `pub`, fields are `pub`). Pair with `AwsClient::stub()` — a new `#[cfg(test)] pub` helper on `AwsClient` that returns a no-mocks client; AWS calls against it fail loudly, which is the signal we want for "test accidentally hit the network".
- **Harness pattern** inside `app::tests`: `test_app()` builds a fresh App; `press(&mut app, KeyCode, KeyModifiers)` synthesizes a `KeyEvent::Press` and dispatches via `handle_event`; `render(&mut app, w, h)` renders into a `TestBackend`-backed Terminal and returns the flattened buffer as a string for grep-style assertions. `mk_env(name, app, tier, health)` seeds the env list without going through async fetchers.
- **7 demo tests** cover the load-bearing keyboard flows: `tab_cycles_scope_envs_to_apps_and_back`, `question_mark_opens_help_and_escape_dismisses_it`, `colon_enters_command_mode_and_esc_cancels`, `slash_enters_filter_mode_and_text_lands`, `enter_on_red_env_opens_why_via_bang_keybind`, `render_main_table_includes_seeded_env_name`, `ctrl_x_toggles_redact`. These exercise: mode transitions, key precedence, overlay open/close, text-input mode, render-through, modifier handling.
- **Catches the regressions the pure-helper tests don't**: filter-input states, picker-vs-overlay precedence, mode transitions, overlay-shape-vs-dispatch desync. Build once, scales for every new key / overlay — adding a test for a new keybind is now `press(...); assert_eq!(app.X, Y)`.

### Drillable Health tab (2026-05-20)
- **Cursor on the Health tab** — j/k now walks the interactive items (severity-filtered events, severe instances, main/DLQ queue rows for workers); Enter drills based on item kind. New `pub enum HealthItem { Event{event_idx}, Instance{instance_idx}, MainQueue, Dlq }` and pure `health_items(detail, now) -> Vec<HealthItem>` enumerate the navigable items in render order. Both the renderer and the Enter dispatcher read from the same helper so a refresh that adds/removes items keeps the cursor position predictable.
- **Drill behaviours**: Event → opens the full message in a TextDump overlay (some EB events are multi-line so this gives operators readable text without scrolling the truncated Health row); Instance → switches to the Instances tab and seats the cursor on that instance (operator then has Enter / `i` / `s` / `y` / `x` for per-instance ops); Main/DLQ queue → switches to the Queue tab and positions the queue cursor on the corresponding row (Enter again opens the queue viewer).
- **Cursor glyph** uses the existing `cursor_marker(theme)` — `▌ ` in Unicode / ASCII, `\u{e0b0} ` in Powerline. Inactive item rows get two-space padding so cursor / non-cursor rows align. `detail_scroll` for the Health tab wraps the cursor over `health_items(detail).len()`; rem_euclid means j past the last item loops back to the first.
- **Footer keystrip** for the Health tab now reads `HEALTH  j/k move  enter drill  tab→ Events  a actions  ^R refresh  ? help  esc back`.
- **General principle** going forward: any rendered list in any view should be navigable + drillable. Health-tab implementation is the first sample.
- 6 new tests on `health_items`: event-severity filtering, 30-min recency window, per-3-instance cap, worker-only queue rows, Web tier skips queues, render-order matches operator view.

### Updating-kind classification + alert-aware Ready pill (2026-05-20)
- **`Ready` pill muted on alerting envs** — when an env's health is Red/Severe OR it's a Worker with `DLQ > 0`, the STATUS-column `Ready` pill renders as dim "Ready" text instead of the bright green pill. `Ready` per EB means "no lifecycle op in flight", NOT "everything's fine"; muting it stops the green pill from competing with the health-dot / row-tint / `⚠N` chip for the operator's attention. New `status_pill_for(status, theme, muted)`; `status_pill(...)` is now a thin wrapper that defaults `muted=false` for callers (Detail header etc.) that don't track alerting state. Updating / Terminating pills are unaffected — they already signal "something happening".
- **Updating status blinks** — the Updating/Launching pill picked up `Modifier::SLOW_BLINK` so in-flight lifecycle ops draw the eye away from idle rows. Modern terminals support it; legacy ones silently fall back to a static pill.
- **`classify_update_kind(events)` pure helper** — EB's `status` is generic ("Updating") regardless of cause, but the recent events expose what's happening. Returns a `UpdateKind` enum: `Deploy { version_label }`, `Config`, `Scale`, `Platform`, `Generic`. Walks events newest-first (matches the EB API order), returns the kind from the first matching message. Deploy extracts the version label from `'…'`-quoted strings (`Updating environment to use version label 'build-142'`). 8 unit tests cover each kind, label extraction, label-missing fallback, empty events, and ordering (newest match wins).
- **Health-tab annotation** — when status is Updating, the Health tab's status line gains a `→ deploying build-142` / `→ config change` / `→ scaling instances` / `→ platform update` suffix in `theme.status_updating` bold. Generic (unrecognised events or events not yet loaded) suppresses the suffix rather than guessing.

### Health tab (default Detail landing) (2026-05-20)
- **`DetailTab::Health` as the default tab** — pressing Enter on an env now lands on a rollup view rather than the Events tab. The Health body shows: (1) status pill + health dot + worker-DLQ chip on the top line; (2) recent ERROR / WARN events from the last 30 min (top 10); (3) instance summary with per-colour counts + inline detail for Severe rows (top 3, with up to 2 causes each); (4) main + DLQ queue depths for Worker envs (DLQ tinted red when > 0). Closing line points the operator at the per-source tabs for drill-in.
- **Data sources reuse existing fetchers** — `detail_refresh_active_tab` spawns events + queues on Health-tab visit; instances are eagerly fetched on `open_detail` so the summary is already populated by the time the user sees the tab. No new aws.rs surface required.
- **Tab icons**: `♥` (Unicode) / `\u{f02d1}` heart-pulse (Powerline) / `H` (ASCII). New per-tab keystrip line in the footer. Detail-scroll arm has Health alongside Metrics/Config as "no scroll cursor".
- **Companion to `:why`** — the in-app `:why` overlay still works (and now has its richer worker-queues + DLQ-peek section), but the Health tab is the default visual landing so the operator gets triage context before navigating; `!` still pops the overlay on demand from anywhere.

### Worker DLQ feeds Red alerts (2026-05-20)
- **DLQ-aware Red status check** — `apply_refresh` now fans out `describe_worker_queues` for every Worker-tier env via the new `spawn_worker_queue_check`. Results land as `AppMsg::WorkerQueueCheck { gen, results: Vec<(env, dlq_visible)> }`; the handler rebuilds `App.worker_dlq_depths` from scratch (so DLQs that drained back to zero reflect on the next draw) and recomputes the alert count.
- **New pure `compute_red_alerts(envs, dlq_depths)`** combines EB-health-Red + Worker-with-DLQ>0; a worker that's both is counted once. 3 unit tests cover the EB-only, Worker-DLQ-only, Web-with-spurious-cache-entry, and zero-DLQ cases.
- **Visual surfacing**: Worker rows with `dlq > 0` tint with `theme.row_red_bg` even when EB reports Green — distinctive "EB thinks it's fine but DLQ disagrees" look. STATUS column appends a small `⚠N` chip (3 cells) so the operator can spot the DLQ count without opening the Queue tab.
- **`:why` worker-queues section** — `Overlay::WhyRed` gained `tier`, `queues`, `dlq_messages` fields. For Worker-tier envs, `open_why_red` spawns a 5th fetcher (`describe_worker_queues`); the handler kicks a second-stage `peek_messages(dlq_url, 3)` only when DLQ depth > 0, so healthy workers don't pay the SQS visibility-timeout cost. Renders a new "worker queues" section in the overlay: main + DLQ stats (visible / in-flight / delayed), DLQ counts tinted red when > 0, and a peek of up to 3 DLQ message bodies (truncated to 100 chars) with sent-age + receive-count. Web envs skip the section entirely. Two new AppMsg variants (`WhyRedQueues`, `WhyRedDlqMessages`); all gated on `session_id` so reopening on a different env drops late results.

### Bulk ops + per-profile theme + deploy preview (2026-05-20)
- **Bulk operations** — `:batch-deploy LABEL`, `:batch-tag KEY VALUE`, `:batch-untag KEY`, `:batch-set-option NAMESPACE NAME VALUE` over the existing multi-select set (`space` to toggle). Each dispatches per-env in parallel via a dedicated `spawn_batch_*` helper that funnels through the same pending-pill + audit + `AppMsg::{ActionResult, TagUpdate, OptionSettingsUpdate}` paths as the single-env commands, so toasts / read-only gating / audit-log entries are free. Pre-flight validations: `:batch-deploy` refuses if the selection spans more than one application (the label can't possibly resolve across apps); `:batch-tag` skips envs whose ARN isn't loaded yet and reports the skipped names in the status footer.
- **Per-profile theme override** — new `profile_themes = "prod:high-contrast,staging:dark"` key in `config.toml` parses to a `HashMap<String, String>` on the App. New `maybe_apply_profile_theme()` swaps `self.theme` to the override (or back to the base) whenever `self.context.profile` changes — called from `apply_rebuild` (every `:profile` / `:account` / `:region` switch) and once at App::new bottom so the initial frame is already correct. Theme swap clears `cached_app_colors` so the palette regenerates cleanly. `base_theme_name` field tracks the configured baseline separately from the running theme so `current_config_snapshot` (used by `:settings`) doesn't accidentally persist a profile-overridden theme as the new default. Pure `parse_profile_themes` helper with 4 tests (happy path, malformed/blank skipping, empty input, end-to-end via `parse`); serialize round-trip test extended.
- **`:deploy LABEL --preview`** — opens a TextOverlay showing `env`, `current` version + age, `candidate` version + age + description, and a `⚠ rollback` warning when the candidate predates the current version. Settings-diff would be the natural ask but EB application versions don't carry option settings (settings live on the env), so the preview is "informed deploy" rather than "settings drift". Pure `format_deploy_preview` helper with 3 tests (happy path, rollback warning, unknown-label).
- **`:why` / `:diagnose` unified diagnostic overlay** — single command opens a four-section scrollable overlay aggregating the data an operator needs during triage of a Red env:
  - **Recent events** — last 30 min from `list_events_for_env`, severity-tinted (ERROR red, WARN yellow), top 15 entries.
  - **Alarms** — `list_alarms_for_env` sorted ALARM-first, then INSUFFICIENT_DATA, then OK; state reason rendered as a sub-line for active alarms. Top 10 entries.
  - **Instance health** — `list_instances` with per-instance health colour + causes; up to 3 cause lines per instance.
  - **Recent deploys** — `list_application_versions` top 5, label + relative age + description (truncated to 60 chars). Age suffix uses the same three-bucket `age_color` as the apps view.
- New `Overlay::WhyRed { env_name, events, alarms, instances, deploys, session_id }` variant with each section as `Option<Result<…, String>>` — `None` renders as `fetching…` placeholder; results stream in via four parallel tokio tasks (`spawn_why_red_{events,alarms,instances,deploys}`). Stale-session guard: `why_red_session` counter bumps on each open; late results for a prior invocation drop on arrival.
- Four new `AppMsg::WhyRed{Events,Alarms,Instances,Deploys}` variants carry per-section results.
- New `truncate_for_display(s, max)` pure helper for the deploy-description column; 4-case test (under/at-cap/over-cap/multibyte).
- Discoverability: `:why` / `:diagnose` in `BUILTIN_COMMANDS`, palette description, per-context help line. Bound to `!` in Normal mode + envs scope so the operator can open the diagnostic with one key on the selected row.

### Apps view + header / table polish (2026-05-20)
- **`LATEST` column in the apps view** — new `Application.latest_version_label` + `latest_version_created`. `spawn_app_latest_versions` fans out `DescribeApplicationVersions` per app in parallel via `join_all` once the apps list lands; results merged by name. UPDATED stays for the AWS-metadata timestamp (description / templates / lifecycle); LATEST shows the actual newest version label + relative age. Pure `merge_app_latest_versions(prev, next)` carries values across refreshes so the column doesn't flicker to "—" on every refresh tick — and only fills slots that are currently `None`, so a hypothetical pre-populated `next` isn't stomped. Tests: `merge_app_latest_versions_carries_previous_values_by_name`, `merge_app_latest_versions_does_not_overwrite_already_populated_slots`, `merge_app_latest_versions_handles_app_disappearance`.
- **Highlighted-row contrast preserved** — `Table::row_highlight_style` switched from `.bg(row_selected_bg)` to `Modifier::REVERSED | BOLD` in both `draw_table` and `draw_apps_table`. Pill cells (Worker yellow / Ready green) now keep their colour identity on selection — fg/bg swap to "yellow text on black bg" rather than getting masked by the dark selection bg. Plain text cells get a standard terminal-style inversion as the selection cue.
- **Header pill chain merges onto info row when wide enough** — new `header_layout(app, area_width) -> (rows, merge_pills)` decides per-frame whether the contextual pill chain (`! 1 alert`, `SSO 12m`, in-flight, etc.) fits alongside `Sort · Status · Envs · Last · Caller` on line 2. Wide terminals collapse to 5 header rows; narrow terminals keep the dedicated chain row so pills never clip. Pure `header_dimensions(info_w, chain_w, inner_w, has_filters)` is the testable kernel; `build_chain_pills` extracted as a pure builder so layout + render agree on the chain. Tests: 5 covering merge / split / no-pills / filters-row / boundary.
- **AGE column colour tinting** — three-bucket tint via pure `age_color(updated, now, theme)`: fresh (<24h) gets `title_alt` to pair with the `◆` drift glyph, normal (1–30d) gets `text` (promoted from muted), stale (>30d) keeps `muted`. Clock-skew durations (negative) treated as fresh, not stale. Tests: 6 covering all three buckets, missing, future-clock-skew, and the 24h boundary.
- **Group-separator banner in non-Powerline mode** — previously the per-app divider row in Unicode/ASCII mode was a homogeneous 200×`─` fill with no app name and no visible break. Now: NAME cell shows `── ▶ {app-name} ──` with the app's colour for the chevron + name and `theme.muted` for the dashes; the second cell carries the existing `summarize_group` summary; remaining cells keep the dash fill so the row still scans as a divider. Powerline mode keeps its E0B2/E0B0 ribbon banner. Pure `separator_glyph(icons)` picker (`>` ASCII, `▶` otherwise) with one test.
- **Powerline splash pills** — `font_probe::resolve_icons_setting` runs before `draw_splash`, so the splash now knows whether the user has a Nerd Font and can use PUA glyphs without risk of tofu on first launch. Tagline + byline render as rounded-cap pills (`\u{e0b6}` left + body bg + `\u{e0b4}` right) in Powerline mode, with the tagline prefixed by `\u{f0c2}` (fa-cloud, stable across Nerd Font releases). Unicode / ASCII keep the existing plain-text lines. `draw_splash(terminal, frame, icons)` signature extended with the icons setting; captured in `main.rs` before `cfg` is moved into `App::new`.
- **Powerline splash card tab** — Powerline mode now embeds a `\u{e0b6} v{VERSION} \u{e0b4}` rounded-cap pill on the splash card's top border (centre-aligned via `Block::title_alignment`) so the whole card reads as a labelled tab. A first attempt at swapping the N letter's stair-step diagonal for `\u{e0be}` slants was reverted — the half-cell wedge against full `█` blocks read as a broken / floating stroke rather than a smooth angled edge. Real letter-diagonal smoothing needs visual prototyping in a real terminal before re-attempting.
- **Tab-icon cell-width probe** — `font_probe` already probes `U+E0B0` for the Powerline triangle; extended with a second probe for `U+F048B` (mdi-server, the codepoint used by the `Instances` tab icon — representative of the whole Nerd Font MDI block used by `tab_icon`). When `icons = "auto"` resolves to `"powerline"` but the MDI probe fails, `resolve_icons_setting` logs a `tracing::warn!` pointing at the tab-strip misalignment with a suggested fix (install a Nerd Font or pin `icons = "unicode"`). Advisory only — the rest of Powerline mode still works. Pure `classify_auto(powerline, tab_icons) -> AutoResolved` decision is unit-tested for all 4 cases.
- **Logs auto-open reverted + group picker** — the auto-open of the CW Logs streaming overlay on Logs-tab entry (shipped earlier as task #69) was confusing because it jumped past the tab's own snapshot path. Reverted in `detail_cycle_tab` — `s` is back to being the explicit opener. To make group choice discoverable, `Tab` inside the streaming overlay now opens a `PickerKind::LogGroup` picker over the env's discovered `cw_log_groups`; selecting one calls `spawn_logs_tail(env, Some(group))` which aborts the existing poller and reopens the overlay against the chosen group. The event dispatcher now skips overlay key handlers when `Mode::Picker` is active so the picker's keys aren't swallowed by the underlying LogTail overlay. Footer hint + per-context help updated.
- **Lazy apps-versions fetch** — `spawn_app_latest_versions` no longer fires from every `AppMsg::Applications` landing. The fan-out happens only when `self.scope == Scope::Apps`, so accounts where the operator lives in the envs view all day don't pay N extra `DescribeApplicationVersions` calls per refresh tick. New `set_scope(new)` helper kicks the fetch on demand when transitioning Envs → Apps (Tab / BackTab), so the LATEST column populates on entry rather than waiting for the next periodic refresh. Persisted-via-saved-view scope=apps still works — first refresh tick lands and triggers the fetch since scope is already Apps at that point.
- **Apps view age tinting** — applied the existing three-bucket `age_color` to the CREATED / UPDATED / LATEST cells in the apps view so the stale / active / fresh signal reads consistently with the envs table. LATEST's "  Xh ago" suffix uses `age_color(latest_version_created, …)` (separate from the bold version label).
- **Throttling-error contract test + flatten fix** — new aws-smithy-mocks test `list_environments_throttling_error_is_recognised_by_predicate` mocks `DescribeEnvironments` returning a `ThrottlingException`-coded error and asserts the full path (SDK error → `flatten_err_to_string` → `is_throttling_error`) recognises it. Caught a real bug: `eyre!("OP failed: {e}")` *flattened* the SDK error chain so the structured `ThrottlingException` code never reached the predicate — refresh back-off would have stayed disarmed on real throttling. Fixed two ways: (a) `flatten_err` now also peeks at the eyre `Debug` form for known rate-limit tokens and surfaces a clean `"ThrottlingException: …"` prefix on the user-facing string (so toasts stay readable but predicates fire); (b) `list_environments` migrated from `map_err(|e| eyre!(…))` to `wrap_err(…)` so the SDK error stays the source of the eyre Report and its Debug dump (with code metadata) appears in the chain. **Limitation:** the other ~38 `map_err(|e| eyre!(…))` sites in aws.rs still flatten — back-off only fires for the refresh path today. Migrating them is a small mechanical follow-up.
- **`:deploy --from` multi-stage mocked test** — new `deploy_from_path_chain_dispatches_each_stage` exercises the four-stage flow (CreateStorageLocation → S3 PutObject → CreateApplicationVersion → UpdateEnvironment) in one test. Each mock asserts the upstream stage's output threaded into the downstream stage's request — bucket+key from CreateStorageLocation reaches PutObject + CreateApplicationVersion, version label reaches UpdateEnvironment. `num_calls()` asserts each rule fired exactly once. New `client_with_eb_and_s3(eb, s3) -> AwsClient` helper extends the existing `client_with_*` family. This is the most multi-step pure-AWS path in the project — a refactor that drops or reorders a stage now fails loud.
- **`map_err(|e| eyre!(…))` → `wrap_err(…)` across aws.rs** — all 38 remaining sites migrated in one mechanical pass via a one-shot Python script. Each `.map_err(|e| eyre!("OP failed: {e}"))?` becomes `.wrap_err("OP failed")?`; one site with runtime interpolation (`S3 PutObject {bucket}/{key} failed`) became `.wrap_err_with(|| format!("…"))?`. Effect: SDK error chains are preserved as eyre Report sources across every AWS operation, so `flatten_err_to_string`'s Debug-peek for throttling tokens now fires on all paths (not just the refresh / `DescribeEnvironments` path) — `:deploy`, `:tag`, `:scale`, `:logs-tail`, etc. all install the back-off horizon on rate limits.
- **Expired-token surfacing test** — new `list_environments_expired_token_surfaces_clean_user_message` mocks `ExpiredTokenException`-coded `DescribeEnvironments` failure; asserts (a) `is_throttling_error` does NOT fire (expired ≠ rate-limit), and (b) the user-facing toast string stays free of SDK Debug noise (`StatusCode`, `Extensions`, `SdkBody`). Pins a known shape for the auth-failure path so a future SDK stringification change can't silently dump the whole Debug dump into the toast.
- **`:history` overlay account-context header** — `format_message_log` now prepends `context: account=… · profile=… · region=…` before the recent-messages list so the operator can see, when scanning toasts after `:account` / `:profile` / `:region` switches, which account the messages were emitted under. Account is redacted with full-block shaded chars when `redact` is on. New pure `redact_for_log` helper (duplicates the ui module's private version to avoid an unrelated cross-module change); test covers the four paths (redact-on / redact-off / em-dash placeholder / empty).

### Distribution + remaining bits
- **Custom Platforms list**: `:custom-platforms` (alias `:platforms`) fetches `ListPlatformVersions` filtered to `PlatformOwner=self` and surfaces ARN / branch / version / status / lifecycle in an overlay.
- **GitHub Actions release workflow**: `.github/workflows/release.yml` triggers on `v*` tags, builds `x86_64-unknown-linux-gnu` / `aarch64-apple-darwin` / `x86_64-apple-darwin` release binaries, tarballs each with README + LICENSE files, attaches them + SHA-256 checksums to a draft GitHub Release.
- **Homebrew formula template**: `Formula/ebman.rb` installable via `brew install --formula ./Formula/ebman.rb`. The `sha256` fields are stubs — maintainer will need to bump them per release (the release workflow emits the checksums alongside each tarball).
- **`cargo install` smoke test**: verified locally that `cargo install --path . --locked` builds and produces a `--version`-reporting binary on stock toolchain. The crates.io publish step is still maintainer-driven.

---

## Backlog

Tier definitions:
- **Refactors** — structural / design tightening surfaced by code review.
- **Tier 0** — distribution & hygiene before shipping publicly.
- **Tier 1** — blocks daily-driver replacement of the AWS console.
- **Tier 2** — UX patterns directly borrowed from e1s / lazygit / lazydocker.
- **Tier 3** — observability and smart surfacing.
- **Tier 4** — multi-account / org-scale operations.
- **Tier 5** — safety, audit, and destructive-action workflow.
- **Tier 6** — power-user, scripting, and extensibility.
- **Tier 7** — polish and quality of life.
- **Tier 8** — maybe / unprioritised; not committed to scope.

Items list `Depends on:` only when another backlog or done item is a real prerequisite.

### Console-replacement gap — items between "useful" and "indispensable"

- [ ] **`:deploy --from` multipart upload + streaming** — `:deploy --from PATH` and `:deploy --from s3://bucket/key` both shipped. Bundles >5 GiB still need multipart on the local-upload path; the s3:// path already sidesteps the limit. Whole bundle held in memory during upload is the other follow-on (stream from disk).

### Proposed (review 2026-05-20)

Items surfaced during a backlog-review pass — gaps between "ebman is useful" and "ebman replaces the console during incidents". Ranked by likely operator value.

- [ ] **Cost-Explorer integration** — `$/month` per env in a new column or `:cost` overlay. Surfaces idle envs the team has forgotten about. AWS Cost Explorer API is rate-limited (~1 req/s) and slow (~1-3s per query), so structure as lazy + 24h cache + opt-in via `:cost on`.

### Refactors — structural cleanup remaining

- [x] **Split `src/app.rs` `execute_command` by category** — Done (task #66). Ten sub-modules under `src/app/` (cmd_action / cmd_alarms / cmd_config_template / cmd_misc / cmd_nav / cmd_option / cmd_overlay / cmd_settings / cmd_view / cmd_write) total 2,160 lines; `app.rs` 14,277 → 12,478 (-1,799). Dispatch site is now pure one-liner routing. `app.rs` is still ~12.5k lines because the bulk is `App` state + `AppMsg` handlers + `spawn_*` helpers — splitting *those* would need a different cut (e.g. `app/handlers.rs`, `app/spawn.rs`) and is a separate, larger task to scope.

### UX punch list — drive-the-app review (2026-05-19)

Findings from walking through the surface as a daily operator. Ranked by likelihood of biting a real user. Cross-referenced with file:line so the next session can pick targets without re-discovering them.

### UI polish — deferred candidates (2026-05-20)

Proposed during the Powerline-aesthetic pass but skipped because the cost / payoff was marginal vs. the rest of the surface. Easy to pick up if the visual surface gets another pass.

- [ ] **TIER / STATUS pill caps in env table (option A)** — every row's pills get a Powerline trailing wedge so they read as ribbon-style tags. Blocker: TIER column is `Constraint::Length(7)` and the existing `" Worker "` pill is already 8 cells; STATUS column is 10 and `" Terminating "` is 13. Caps would overflow more rows. Revisit if/when the table column widths get widened — or render the cap *only* when the cell has room.

### Tier 0 — distribution & hygiene
- [ ] **README screenshots / demo gif** — text README shipped; capturing screenshots requires running the TUI in a real terminal (not this shell).
- [ ] **`cargo install ebman` smoke test** — verify the binary installs cleanly from a stock toolchain. (Local-only verification possible; full smoke test needs a crates.io publish.)
- [ ] **Homebrew formula / GitHub Releases with binaries** — macOS users won't `cargo install`. Depends on CI building release artefacts.

### Tier 1 — operator killer features (the daily-driver gap)
All previously listed Tier 1 items are now shipped:
- Option settings editor — `:env`, `:set-option`, plus per-namespace commands (`:capacity`, `:instance-type`, `:keypair`, `:public-ip`, `:elb-scheme`, `:service-role`, `:instance-profile`, `:deployment-policy`, `:rolling-update`, `:health-check-url`, `:logs-stream`, `:notify`, `:managed-window`).
- CloudWatch Logs streaming — `:logs-tail` overlay with regex filter + auto-tail.
- Deploy from local path / S3 — `:deploy --from PATH` and `:deploy --from s3://bucket/key`.

### Console parity — write-side gaps (operators currently open the console for these)

Gaps surfaced during the 2026-05-19 console-vs-ebman comparison. Each entry is a console feature with no ebman equivalent. Ordered by daily-operator frequency.

- [ ] **Attach / detach RDS database** — console exposes a Database tab where you can attach a new RDS instance to an env (creates the security-group + IAM linkage automatically) or detach an existing one. Needs `UpdateEnvironment(option_settings: aws:rds:dbinstance.*)` for create/attach, and a different code path for the post-creation "decouple" workflow (since EB-created RDS instances are pinned to the env's lifecycle by default).
- [ ] **ALB listener + TLS cert config** — list and edit ALB listeners (port, protocol, default action, attached cert) for the env's ALB. Adds an "LB" tab in Detail. Needs `aws:elbv2:listener.*` option settings + an ACM cert picker. Web-tier-only.
- [ ] **Capacity profile beyond min/max + instance type** — `:scale N` sets min=max; `:instance-type TYPE` sets the launch-config InstanceType. Console exposes a full Capacity tab with fleet composition (multi-instance-type list, on-demand base + spot %), scaling triggers (custom CW metric / threshold / cooldown), and scheduled scaling actions. New `:capacity` command opening a modal-form (depends on the option-settings editor abstraction from Tier 1) — or per-option commands like `:trigger metric LATENCY threshold 0.5`.
- [ ] **Custom platforms — create** — delete shipped as `:custom-platform-delete <arn>`. Create still missing: console offers a wizard that builds a new custom AMI from a Packer template (slow — minutes — needs polling); ours would be `:custom-platform-create <packer-config>` via `elasticbeanstalk:CreatePlatformVersion`. Niche but a real gap for operators who maintain in-house base AMIs.

### Tier 4 — multi-account / child accounts

### Tier 6 — power-user / scripting
- [ ] **Embedded recorder** — record + replay sessions to `.cast` (asciinema). Deferred — needs its own input-capture + replay infrastructure.

### Tier 8 — maybe / unprioritised
- [ ] **Snapshot at a point in time** — "what envs looked like 1h ago" (would need local history).
- [ ] **Visual resource topology graph** — console shows a "Resources" graph linking ASG → EC2 instances → ELB → target groups. We have `:resources` as a text dump which most operators prefer; the graph is nice-to-have but rarely the reason someone opens the console.
- [ ] **Route 53 / custom DNS integration** — console offers a one-click "set up custom domain" wizard tied to a Route 53 hosted zone. Niche and easy to do via AWS CLI or the Route 53 console directly.

### Trim candidates — built, but probably over-served
Honest list of features that landed during expansion sprints but aren't earning their maintenance cost. Don't remove unilaterally; flag for review.

- **Webhook on Red transition** — single-URL POST is too rigid for real ops workflows. Pick a direction: either (a) replace with a proper Slack block-kit integration (env context + action buttons + per-app routing rules) and earn the maintenance, or (b) trim back to `tracing::warn!` + a status toast and document that operators wire their own notifier via the audit log. Letting it sit half-built is the worst option.
- **Custom keybindings (`keys.toml`)** — supports F1-F12 and uppercase aliases to `:commands`. Almost no one will write this file; the underlying need (palette + per-context hints) is served by `Ctrl-K`.
- **Multi-region overview / org-wide health / cross-account search** — useful in theory; most teams operate in one account+region day-to-day. The `aws::list_environments_in_region` fan-out helper is the real win, retain that.
- **Embedded mini-map (`:minimap`)** — cute corner overlay; no operational signal beyond what the main table already shows.
- **Asciinema recorder (deferred in BACKLOG)** — keep deferred; standalone replay infrastructure is its own product.

---

## Skipped — needs retry

Populated by autonomous runs per `CLAUDE.md` stop-conditions. Each entry: one-line reason. Drop the entry once retried (successfully or with the user's deliberate decision to defer further).

- **README screenshots / demo gif** — autonomous shell has no real TTY; can't render the TUI for capture. Retry from an interactive session.
- **Option settings editor (Tier 1)** — requires a modal text-input form generator and a category-tree of namespaces; defer.
- **Split `src/app.rs`** — refactor spans 10+ mode handlers + state machines, exceeds the CLAUDE.md "touches > 3 modules" stop condition. Pick up in a focused session.
- **Embedded asciinema recorder (Tier 6)** — needs its own input-capture/replay infrastructure; defer.
- **`sts:AssumeRole`-based account switcher (Tier 4)** — needs an `[accounts]` config schema with role ARNs + credentials injection; defer. The current `:account NAME` aliases `:profile NAME` for the standard one-profile-per-account pattern.

---

## Dropped / explicitly out of scope

- Multi-service AWS dashboard (RDS / ECS / Lambda). Stays out of scope — ebman is EB-focused on purpose; generic-AWS TUIs already exist (clawscli, cloudlens) and sprawl.
- `Ctrl-N` to dismiss alert badge. Removed when alerts switched from "transitions since last ack" to "currently Red".

---

## Notable inspirations

- **[e1s](https://github.com/keidarcy/e1s)** — same problem shape (k9s-for-ECS). UX template; `b` console deeplink and `d` describe overlay come from here.
- **[k9s](https://github.com/derailed/k9s)** — original model. Resource aliases, `:` command bar, drill-down.
- **[stu](https://github.com/lusingander/stu)** — Rust + ratatui S3 explorer; same stack idioms.
- **[gitui](https://github.com/gitui-org/gitui)** — ratatui async patterns under load.
- **[lazydocker](https://github.com/jesseduffield/lazydocker)** — panel + tab metaphor mirrors our drill-down.
- **[lazygit](https://github.com/jesseduffield/lazygit)** — per-panel hint strip, contextual action menu.
- **[gh dash](https://github.com/dlvhdr/gh-dash)** — sectioned dashboards inspired the "env groups as tabs" idea.
- **[bottom](https://github.com/ClementTsang/bottom)** — ratatui dashboard widget patterns; Metrics tab follows this.
- **[harlequin](https://github.com/tconbeer/harlequin)** / **[atuin](https://github.com/atuinsh/atuin)** — fuzzy-find UI patterns for filtering long streams.
- **[tig](https://github.com/jonas/tig)** — paged event-log + ref panel for timeline views.