Compare commits

..
Author SHA1 Message Date
Miguel Palhas cc9b027bd1 feat(maestro): make TUI session cards two lines 2026-08-25 13:11:06 +01:00
6 changed files with 160 additions and 70 deletions
+85 -38
View File
@@ -2,8 +2,7 @@
//!
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity dot + duration in the state's colour, plus the
//! blocked-work and exited fragments when they apply. Absent entirely when
//! it has nothing to say.
//! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::HashSet;
@@ -18,8 +17,8 @@ use uuid::Uuid;
use crate::palette;
/// Time-in-state alone earns a meta line only once the session has sat in
/// its current activity state this long (docs/SIDEBAR.md).
/// Time-in-state earns a duration fragment once the session has sat in its
/// current activity state this long (docs/SIDEBAR.md).
const META_LINE_AFTER_SECS: i64 = 30;
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
@@ -74,9 +73,8 @@ impl Sidebar {
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
lines.push(Line::from(self.name_row(s, width)));
if let Some(meta) = self.meta_row(s, width) {
lines.push(Line::from(pad_to_width(meta, width, base)));
}
let meta = self.meta_row(s, width);
lines.push(Line::from(pad_to_width(meta, width, base)));
}
f.render_widget(Paragraph::new(lines), inner);
}
@@ -123,8 +121,8 @@ impl Sidebar {
spans
}
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
fn meta_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
let now = Utc::now();
let held = held_for(s, now);
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
@@ -132,14 +130,6 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let errored = s.activity == ActivityState::Error;
if (held.is_none() || held_secs < META_LINE_AFTER_SECS)
&& blocked.is_none()
&& !dead
&& !errored
{
return None;
}
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted)));
@@ -148,13 +138,10 @@ impl Sidebar {
Span::styled(META_INDENT, base),
Span::styled("●", dot_style),
];
if let Some(d) = held {
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(format_state_duration(d), dot_style));
}
// The trailing fragments are measured up front so a long detail
// truncates ahead of them instead of pushing them off the line
// (docs/SIDEBAR.md).
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
@@ -162,9 +149,19 @@ impl Sidebar {
if dead {
tail.push(Span::styled(" exited", muted));
}
let command = Span::styled(format!(" {}", s.command), muted);
// Reserve the trailing fragments so detail truncates before them;
// the command is itself the final truncation target on narrow cards.
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
let command_w = command.width();
let available = width.saturating_sub(head_w);
if tail_w + command_w > available {
spans.extend(fit_to_width(vec![command], available));
return spans;
}
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
let budget = width.saturating_sub(head_w + tail_w);
spans.extend(fit_to_width(
vec![Span::styled(format!(" {detail}"), muted)],
@@ -172,7 +169,8 @@ impl Sidebar {
));
}
spans.extend(tail);
Some(fit_to_width(spans, width))
spans.push(command);
fit_to_width(spans, width)
}
}
@@ -406,10 +404,10 @@ mod tests {
}
}
/// A freshly-active session stays one line — never an empty placeholder
/// row (docs/SIDEBAR.md).
/// A fresh session still gets its unconditional second line, but no
/// duration until it has been held in state for 30 seconds.
#[test]
fn freshly_active_session_stays_one_line() {
fn fresh_session_renders_two_lines_without_duration() {
let mut s = session_named(1, "claude");
s.activity_since = Utc::now() - Duration::seconds(10);
let sb = Sidebar {
@@ -421,8 +419,18 @@ mod tests {
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
content(&all[2]).is_empty(),
"line 2 must be absent, got {:?}",
all[2].contains('●'),
"line 2 must carry the dot: {:?}",
all[2]
);
assert!(
all[2].contains("bash"),
"line 2 must carry the command: {:?}",
all[2]
);
assert!(
!all[2].contains("10s"),
"fresh cards have no duration: {:?}",
all[2]
);
}
@@ -553,11 +561,12 @@ mod tests {
);
}
/// The sidebar is narrow: the meta line truncates with `…` and never
/// wraps onto a third row (docs/SIDEBAR.md).
/// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md).
#[test]
fn meta_line_truncates_instead_of_wrapping() {
fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into();
s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay {
uncommitted_files: 120,
@@ -573,7 +582,7 @@ mod tests {
let meta = content(&all[idx + 1]);
assert!(
meta.trim_end().ends_with('…'),
"truncated meta line must end with …: {meta:?}"
"truncated command must end with …: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
@@ -688,10 +697,6 @@ mod tests {
rendered.contains("exited"),
"dead session must still show line 2:\n{rendered}"
);
assert!(
!rendered.contains('h') || !rendered.contains("● "),
"no duration may follow the dot for an epoch stamp:\n{rendered}"
);
for line in rendered.lines().filter(|l| l.contains('●')) {
assert!(
!line
@@ -736,6 +741,48 @@ mod tests {
);
}
#[test]
fn command_renders_on_line_two_even_when_name_is_metadata() {
let s = session_named(1, "friendly name");
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(
all[1].contains("friendly name"),
"name must stay on line 1: {:?}",
all[1]
);
assert!(
all[2].contains("bash"),
"command must be on line 2: {:?}",
all[2]
);
}
#[test]
fn narrow_meta_line_keeps_dot_and_truncates_command() {
let s = session_named(1, "name");
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 10, 6));
assert!(
all[2].contains('●'),
"dot must survive narrow cards: {:?}",
all[2]
);
assert!(
all[2].contains('…'),
"command should truncate: {:?}",
all[2]
);
}
/// Reversed video makes a card unreadable against a background; cards
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
#[test]
@@ -775,7 +822,7 @@ mod tests {
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
let bash_y = all.iter().position(|l| l.contains("○ bash")).unwrap();
let p = palette::active();
assert_eq!(
+41
View File
@@ -4,7 +4,9 @@ import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
showsMetaLine,
} from "./activity.js";
import type { Session } from "./types.js";
describe("activityHeldMs", () => {
it("measures time spent in the current state", () => {
@@ -58,3 +60,42 @@ describe("activityColorSlug", () => {
expect(activityColorSlug("Teleported")).toBe("idle");
});
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
activity: "Active",
...overrides,
};
}
describe("showsMetaLine", () => {
const fresh = new Date(Date.now() - 10_000).toISOString();
it("stays hidden for a freshly-active session", () => {
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
});
it("appears once the state has been held past 30 seconds", () => {
const held = new Date(Date.now() - 31_000).toISOString();
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
});
it("appears for blocked work, exited sessions and reported errors", () => {
expect(
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
});
it("needs no activity timestamp when another trigger holds", () => {
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
});
});
+15 -3
View File
@@ -2,10 +2,11 @@
// the shared duration format. Both clients compute it identically; the TUI
// mirrors this in crates/maestro-tui.
import type { ActivityState, IsoTimestamp } from "./types.js";
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
import { sessionBlockers } from "./types.js";
/** A state held shorter than this has no duration fragment. */
export const META_LINE_MIN_HOLD_MS = 30_000;
/** A state held shorter than this keeps the card to one line (contract rule 1). */
const META_LINE_MIN_HOLD_MS = 30_000;
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
// implausibly old is treated as "never observed" rather than a huge duration.
@@ -42,3 +43,14 @@ export function activityColorSlug(state: ActivityState | string): string {
return "idle";
}
}
/** Line 2 renders only when at least one contract trigger holds. */
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
const held = activityHeldMs(s.activity_since, now);
return (
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
sessionBlockers(s) !== null ||
s.status === "Dead" ||
s.activity === "Error"
);
}
+2 -16
View File
@@ -150,18 +150,14 @@ describe("session cards (#107)", () => {
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
});
it("always renders line 2 for a freshly spawned session", () => {
it("stays a single line while freshly active", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
});
it("shows dot + floored duration once the state has been held past 30s", () => {
@@ -177,16 +173,6 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("renders the command when the session name equals it", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
command: "claude",
metadata: { name: "claude" },
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
});
it("maps each activity state to its contract slug", () => {
const mk = (id: string, activity: string) =>
session({
+14 -11
View File
@@ -4,12 +4,7 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "./activity.js";
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -33,13 +28,16 @@ const STATE_WORDS: Record<string, string> = {
};
function metaLine(s: Session, now: number): string {
const name = sessionName(s);
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const duration = held !== null ? activityDurationLabel(held) : null;
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
@@ -51,7 +49,9 @@ function metaLine(s: Session, now: number): string {
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
dead ? `<span class="card-flag">exited</span>` : "",
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
showCmd
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
: "",
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
@@ -106,7 +106,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
// the actions reserve their width, so the name truncates before it can ever
// reach them. Line 2 renders only when a contract trigger holds.
const showMeta = showsMetaLine(s, now);
row.innerHTML = `
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
@@ -115,7 +118,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${metaLine(s, now)}
${showMeta ? metaLine(s, now) : ""}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
+3 -2
View File
@@ -736,6 +736,7 @@ textarea {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 30px;
margin: 1px var(--sp-2);
padding: 4px var(--sp-3);
border-radius: var(--r-1);
@@ -786,12 +787,12 @@ textarea {
font-weight: 400;
}
/* #120: every session card has two lines, so its row height is stable. */
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
always, line 2 (meta) only when it has something to say. */
.tree-leaf.session-card {
flex-direction: column;
align-items: stretch;
gap: 1px;
height: 40px;
}
.card-line1 {