Skip to content

Commit 706bfad

Browse files
committed
Add plan and task indicators to session list
Session list now shows plan slugs and task progress bars when sessions have associated plans or team tasks. Loads plans once and indexes by slug, caches task summaries per team name. Includes text and JSON output support with full test coverage.
1 parent 6fb636d commit 706bfad

3 files changed

Lines changed: 178 additions & 0 deletions

File tree

cli/src/commands/session/list.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import chalk from "chalk";
22
import type { Command } from "commander";
33
import { basename } from "path";
44
import { loadHistory } from "../../loaders/history-loader.js";
5+
import { loadPlans } from "../../loaders/plan-loader.js";
56
import { extractSessionMeta } from "../../loaders/session-meta.js";
7+
import { loadTasks } from "../../loaders/task-loader.js";
68
import {
79
formatSessionListJson,
810
formatSessionListText,
911
type SessionListEntry,
12+
type TaskSummary,
1013
} from "../../output/session-list.js";
1114
import { discoverSessionFiles } from "../../utils/glob.js";
1215
import { parseRelativeTime, parseTime } from "../../utils/time.js";
@@ -78,6 +81,39 @@ export function registerListCommand(parent: Command): void {
7881
entries.push({ summary, meta });
7982
}
8083

84+
// Load plans once and index by slug
85+
const plans = await loadPlans();
86+
const planSlugs = new Set(plans.map((p) => p.slug));
87+
88+
// Cache tasks by team name
89+
const taskCache = new Map<string, TaskSummary>();
90+
91+
for (const entry of entries) {
92+
// Plan indicator: match session slug to plan slug
93+
if (entry.meta?.slug && planSlugs.has(entry.meta.slug)) {
94+
entry.planSlug = entry.meta.slug;
95+
}
96+
97+
// Task indicator: only load for sessions with teamName
98+
if (entry.meta?.teamName) {
99+
const teamName = entry.meta.teamName;
100+
if (!taskCache.has(teamName)) {
101+
const tasks = await loadTasks({ team: teamName });
102+
taskCache.set(teamName, {
103+
total: tasks.length,
104+
completed: tasks.filter((t) => t.status === "completed").length,
105+
inProgress: tasks.filter((t) => t.status === "in_progress")
106+
.length,
107+
pending: tasks.filter((t) => t.status === "pending").length,
108+
});
109+
}
110+
const ts = taskCache.get(teamName)!;
111+
if (ts.total > 0) {
112+
entry.taskSummary = ts;
113+
}
114+
}
115+
}
116+
81117
if (options.format === "json") {
82118
console.log(formatSessionListJson(entries));
83119
} else {

cli/src/output/session-list.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@ import chalk from "chalk";
22
import type { SessionSummary } from "../loaders/history-loader.js";
33
import type { SessionMeta } from "../loaders/session-meta.js";
44

5+
export interface TaskSummary {
6+
total: number;
7+
completed: number;
8+
inProgress: number;
9+
pending: number;
10+
}
11+
512
export interface SessionListEntry {
613
summary: SessionSummary;
714
meta?: SessionMeta;
15+
planSlug?: string;
16+
taskSummary?: TaskSummary;
17+
}
18+
19+
function formatTaskBar(ts: TaskSummary): string {
20+
const filled = ts.completed;
21+
const total = ts.total;
22+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(total - filled);
23+
return `[${bar}] ${filled}/${total} tasks`;
824
}
925

1026
function formatTimestamp(iso: string): string {
@@ -50,6 +66,17 @@ export function formatSessionListText(
5066
const end = formatTimestamp(summary.timestamps.last);
5167
const msgPart = meta ? ` (${meta.messageCount} messages)` : "";
5268
lines.push(` ${start} \u2192 ${end}${msgPart}`);
69+
70+
const indicators: string[] = [];
71+
if (entry.planSlug) {
72+
indicators.push(chalk.cyan(`plan: ${entry.planSlug}`));
73+
}
74+
if (entry.taskSummary && entry.taskSummary.total > 0) {
75+
indicators.push(formatTaskBar(entry.taskSummary));
76+
}
77+
if (indicators.length > 0) {
78+
lines.push(` ${indicators.join(" ")}`);
79+
}
5380
lines.push("---");
5481
}
5582

@@ -71,6 +98,8 @@ export function formatSessionListJson(entries: SessionListEntry[]): string {
7198
start: summary.timestamps.first,
7299
end: summary.timestamps.last,
73100
},
101+
plan: entry.planSlug ?? null,
102+
taskSummary: entry.taskSummary ?? null,
74103
};
75104
});
76105

cli/tests/session-list.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatSessionListJson,
66
formatSessionListText,
77
type SessionListEntry,
8+
type TaskSummary,
89
} from "../src/output/session-list.js";
910

1011
const makeSummary = (overrides?: Partial<SessionSummary>): SessionSummary => ({
@@ -172,3 +173,115 @@ describe("session list formatter", () => {
172173
expect(output).toContain("0 sessions listed");
173174
});
174175
});
176+
177+
describe("session list plan and task indicators", () => {
178+
test("text format shows plan indicator", () => {
179+
const entries: SessionListEntry[] = [
180+
{
181+
summary: makeSummary(),
182+
meta: makeMeta(),
183+
planSlug: "wondrous-rainbow",
184+
},
185+
];
186+
const output = formatSessionListText(entries, { noColor: true });
187+
expect(output).toContain("plan: wondrous-rainbow");
188+
});
189+
190+
test("text format shows task progress bar", () => {
191+
const ts: TaskSummary = {
192+
total: 4,
193+
completed: 2,
194+
inProgress: 1,
195+
pending: 1,
196+
};
197+
const entries: SessionListEntry[] = [
198+
{
199+
summary: makeSummary(),
200+
meta: makeMeta(),
201+
taskSummary: ts,
202+
},
203+
];
204+
const output = formatSessionListText(entries, { noColor: true });
205+
expect(output).toContain("2/4 tasks");
206+
expect(output).toContain("\u2588");
207+
expect(output).toContain("\u2591");
208+
});
209+
210+
test("text format shows both plan and tasks", () => {
211+
const ts: TaskSummary = {
212+
total: 4,
213+
completed: 2,
214+
inProgress: 1,
215+
pending: 1,
216+
};
217+
const entries: SessionListEntry[] = [
218+
{
219+
summary: makeSummary(),
220+
meta: makeMeta(),
221+
planSlug: "test-plan",
222+
taskSummary: ts,
223+
},
224+
];
225+
const output = formatSessionListText(entries, { noColor: true });
226+
expect(output).toContain("plan:");
227+
expect(output).toContain("tasks");
228+
});
229+
230+
test("text format omits indicator line when neither present", () => {
231+
const entries: SessionListEntry[] = [
232+
{
233+
summary: makeSummary(),
234+
meta: makeMeta(),
235+
},
236+
];
237+
const output = formatSessionListText(entries, { noColor: true });
238+
expect(output).not.toContain("plan:");
239+
expect(output).not.toContain("tasks");
240+
});
241+
242+
test("JSON format includes plan field", () => {
243+
const entries: SessionListEntry[] = [
244+
{
245+
summary: makeSummary(),
246+
meta: makeMeta(),
247+
planSlug: "test-plan",
248+
},
249+
];
250+
const output = formatSessionListJson(entries);
251+
const parsed = JSON.parse(output);
252+
expect(parsed[0].plan).toBe("test-plan");
253+
});
254+
255+
test("JSON format includes taskSummary field", () => {
256+
const ts: TaskSummary = {
257+
total: 4,
258+
completed: 2,
259+
inProgress: 1,
260+
pending: 1,
261+
};
262+
const entries: SessionListEntry[] = [
263+
{
264+
summary: makeSummary(),
265+
meta: makeMeta(),
266+
taskSummary: ts,
267+
},
268+
];
269+
const output = formatSessionListJson(entries);
270+
const parsed = JSON.parse(output);
271+
expect(parsed[0].taskSummary.total).toBe(4);
272+
expect(parsed[0].taskSummary.completed).toBe(2);
273+
});
274+
275+
test("JSON format has null plan and taskSummary when absent", () => {
276+
const entries: SessionListEntry[] = [
277+
{
278+
summary: makeSummary(),
279+
meta: makeMeta(),
280+
},
281+
];
282+
const output = formatSessionListJson(entries);
283+
const parsed = JSON.parse(output);
284+
expect(parsed[0].plan).toBeNull();
285+
expect(parsed[0].taskSummary).toBeNull();
286+
});
287+
});

0 commit comments

Comments
 (0)