Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc9b027bd1 |
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user