Skip to content

Commit 4029f1c

Browse files
authored
fix(message): thread --limit through to CLI formatter and surface provider pagination hints (#99089)
1 parent 1ec42c9 commit 4029f1c

3 files changed

Lines changed: 267 additions & 10 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Tests for CLI message text formatting helpers (renderMessageList, formatMessageCliText).
2+
import { describe, expect, it } from "vitest";
3+
import { formatMessageCliText } from "./message-format.js";
4+
5+
function readResultPayload(payload: unknown) {
6+
return {
7+
kind: "action" as const,
8+
channel: "matrix" as const,
9+
action: "read" as const,
10+
handledBy: "plugin" as const,
11+
payload,
12+
dryRun: false,
13+
};
14+
}
15+
16+
function pinsResultPayload(payload: unknown) {
17+
return {
18+
kind: "action" as const,
19+
channel: "matrix" as const,
20+
action: "list-pins" as const,
21+
handledBy: "plugin" as const,
22+
payload,
23+
dryRun: false,
24+
};
25+
}
26+
27+
function searchResultPayload(payload: unknown) {
28+
return {
29+
kind: "action" as const,
30+
channel: "discord" as const,
31+
action: "search" as const,
32+
handledBy: "plugin" as const,
33+
payload,
34+
dryRun: false,
35+
};
36+
}
37+
38+
function msg(id: string, ts: string, authorTag: string, content: string) {
39+
return { id, ts, authorTag, content };
40+
}
41+
42+
function textJoined(lines: string[]): string {
43+
return lines.join("\n");
44+
}
45+
46+
describe("formatMessageCliText displayLimit", () => {
47+
it("honors displayLimit for message read", () => {
48+
const messages = Array.from({ length: 40 }, (_, i) =>
49+
msg(`id-${i}`, `2026-01-01T00:00:0${i % 10}.000Z`, `user-${i}`, `text-${i}`),
50+
);
51+
52+
const result = readResultPayload({ messages });
53+
const out = textJoined(formatMessageCliText(result, { displayLimit: 15 }));
54+
55+
// Should include items within the limit
56+
expect(out).toContain("text-0");
57+
expect(out).toContain("text-14");
58+
// Should NOT include items beyond the limit
59+
expect(out).not.toContain("text-15");
60+
});
61+
62+
it("honors displayLimit for list-pins", () => {
63+
const pins = Array.from({ length: 40 }, (_, i) =>
64+
msg(`pin-${i}`, `2026-01-01T00:00:0${i % 10}.000Z`, `user-${i}`, `pin-text-${i}`),
65+
);
66+
67+
const result = pinsResultPayload({ pins });
68+
const out = textJoined(formatMessageCliText(result, { displayLimit: 15 }));
69+
70+
expect(out).toContain("pin-text-0");
71+
expect(out).toContain("pin-text-14");
72+
expect(out).not.toContain("pin-text-15");
73+
});
74+
75+
it("honors displayLimit for search", () => {
76+
const messages = Array.from({ length: 40 }, (_, i) =>
77+
msg(`id-${i}`, `2026-01-01T00:00:0${i % 10}.000Z`, `user-${i}`, `search-${i}`),
78+
);
79+
// Discord search returns messages as array-of-array
80+
const wrapped = messages.map((m) => [m]);
81+
82+
const result = searchResultPayload({ results: { messages: wrapped } });
83+
const out = textJoined(formatMessageCliText(result, { displayLimit: 15 }));
84+
85+
expect(out).toContain("search-0");
86+
expect(out).toContain("search-14");
87+
expect(out).not.toContain("search-15");
88+
});
89+
90+
it("defaults to 25 when no displayLimit is provided", () => {
91+
const messages = Array.from({ length: 50 }, (_, i) =>
92+
msg(`id-${i}`, `2026-01-01T00:00:0${i % 10}.000Z`, `user-${i}`, `text-${i}`),
93+
);
94+
95+
const result = readResultPayload({ messages });
96+
const out = textJoined(formatMessageCliText(result));
97+
98+
expect(out).toContain("text-24");
99+
expect(out).not.toContain("text-25");
100+
});
101+
102+
it("renders all rows when total is below displayLimit", () => {
103+
const messages = [
104+
msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello"),
105+
msg("id-2", "2026-01-01T00:00:01.000Z", "bob", "world"),
106+
];
107+
108+
const result = readResultPayload({ messages });
109+
const out = textJoined(formatMessageCliText(result, { displayLimit: 30 }));
110+
111+
expect(out).toContain("hello");
112+
expect(out).toContain("world");
113+
});
114+
});
115+
116+
describe("renderPaginationHint", () => {
117+
it("emits hint when payload has hasMore: true", () => {
118+
const messages = [msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello")];
119+
const result = readResultPayload({ messages, hasMore: true });
120+
const out = textJoined(formatMessageCliText(result, { displayLimit: 5 }));
121+
122+
expect(out).toContain("More results available");
123+
});
124+
125+
it("emits hint when payload has nextBatch string", () => {
126+
const messages = [msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello")];
127+
const result = readResultPayload({ messages, nextBatch: "token-123" });
128+
const out = textJoined(formatMessageCliText(result, { displayLimit: 5 }));
129+
130+
expect(out).toContain("More results available");
131+
});
132+
133+
it("emits hint when payload has @odata.nextLink string", () => {
134+
const messages = [msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello")];
135+
const result = readResultPayload({
136+
messages,
137+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/next",
138+
});
139+
const out = textJoined(formatMessageCliText(result, { displayLimit: 5 }));
140+
141+
expect(out).toContain("More results available");
142+
});
143+
144+
it("emits hint when search results has hasMore without total_results", () => {
145+
const messages = [msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello")];
146+
// hasMore: true inside results — Discord does not always include total_results.
147+
const wrapped = messages.map((m) => [m]);
148+
const result = searchResultPayload({
149+
results: { messages: wrapped, hasMore: true },
150+
});
151+
const out = textJoined(formatMessageCliText(result, { displayLimit: 5 }));
152+
153+
expect(out).toContain("More results available");
154+
});
155+
156+
it("emits hint when total_results exceeds returned count (Discord search)", () => {
157+
const messages = [msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello")];
158+
// Discord search wraps messages and total_results inside a results object.
159+
// total_results: 200 with 1 returned message → 199 more exist.
160+
const wrapped = messages.map((m) => [m]);
161+
const result = searchResultPayload({
162+
results: { messages: wrapped, total_results: 200 },
163+
});
164+
const out = textJoined(formatMessageCliText(result, { displayLimit: 5 }));
165+
166+
expect(out).toContain("More results available");
167+
});
168+
169+
it("does NOT emit hint when total_results equals returned count (completed search)", () => {
170+
const messages = [
171+
msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello"),
172+
msg("id-2", "2026-01-01T00:00:01.000Z", "bob", "world"),
173+
];
174+
// total_results: 2 with 2 returned messages → search is complete.
175+
const wrapped = messages.map((m) => [m]);
176+
const result = searchResultPayload({
177+
results: { messages: wrapped, total_results: 2 },
178+
});
179+
const out = textJoined(formatMessageCliText(result, { displayLimit: 5 }));
180+
181+
expect(out).not.toContain("More results available");
182+
});
183+
184+
it("does NOT emit hint when no pagination signal is present", () => {
185+
const messages = [msg("id-1", "2026-01-01T00:00:00.000Z", "alice", "hello")];
186+
const result = readResultPayload({ messages });
187+
const out = textJoined(formatMessageCliText(result, { displayLimit: 5 }));
188+
189+
expect(out).not.toContain("More results available");
190+
});
191+
});

src/commands/message-format.ts

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ function extractMessageId(payload: unknown): string | null {
3636

3737
type FormatOpts = {
3838
width: number;
39+
/** Max rows to render. Defaults to 25 when omitted. */
40+
displayLimit?: number;
3941
};
4042

4143
function renderObjectSummary(payload: unknown, opts: FormatOpts): string[] {
@@ -87,7 +89,8 @@ function renderObjectSummary(payload: unknown, opts: FormatOpts): string[] {
8789
}
8890

8991
function renderMessageList(messages: unknown[], opts: FormatOpts, emptyLabel: string): string[] {
90-
const rows = messages.slice(0, 25).map((m) => {
92+
const cap = opts.displayLimit ?? 25;
93+
const rows = messages.slice(0, cap).map((m) => {
9194
const msg = m as Record<string, unknown>;
9295
const id =
9396
(typeof msg.id === "string" && msg.id) ||
@@ -238,14 +241,45 @@ function renderReactions(payload: unknown, opts: FormatOpts): string[] | null {
238241
];
239242
}
240243

241-
export function formatMessageCliText(result: MessageActionRunResult): string[] {
244+
/**
245+
* Emit a muted hint when the provider payload signals more results are available
246+
* beyond the current page (e.g. hasMore, nextBatch, @odata.nextLink).
247+
*/
248+
function renderPaginationHint(payload: unknown, muted: (text: string) => string): string | null {
249+
if (!payload || typeof payload !== "object") {
250+
return null;
251+
}
252+
const obj = payload as Record<string, unknown>;
253+
if (obj.hasMore === true) {
254+
return muted(
255+
"More results available. Use --limit to fetch more, or --json for the raw cursor.",
256+
);
257+
}
258+
if (typeof obj.nextBatch === "string" && obj.nextBatch) {
259+
return muted(
260+
"More results available. Use --limit to fetch more, or --json for the raw cursor.",
261+
);
262+
}
263+
if (typeof obj["@odata.nextLink"] === "string" && obj["@odata.nextLink"]) {
264+
return muted(
265+
"More results available. Use --limit to fetch more, or --json for the raw cursor.",
266+
);
267+
}
268+
return null;
269+
}
270+
271+
export function formatMessageCliText(
272+
result: MessageActionRunResult,
273+
opts?: { displayLimit?: number },
274+
): string[] {
242275
const rich = isRich();
243276
const ok = (text: string) => (rich ? theme.success(text) : text);
244277
const muted = (text: string) => (rich ? theme.muted(text) : text);
245278
const heading = (text: string) => (rich ? theme.heading(text) : text);
246279

247280
const width = getTerminalTableWidth();
248-
const opts: FormatOpts = { width };
281+
const displayLimit = opts?.displayLimit;
282+
const formatOpts: FormatOpts = { width, displayLimit };
249283

250284
if (result.dryRun) {
251285
return [muted(`[dry-run] would run ${result.action} via ${result.channel}`)];
@@ -267,7 +301,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
267301
return [
268302
headingLine,
269303
renderTable({
270-
width: opts.width,
304+
width: formatOpts.width,
271305
columns: [
272306
{ key: "Channel", header: "Channel", minWidth: 10 },
273307
{ key: "Target", header: "Target", minWidth: 12, flex: true },
@@ -352,27 +386,35 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
352386
return lines;
353387
}
354388

355-
const reactionsTable = renderReactions(payload, opts);
389+
const reactionsTable = renderReactions(payload, formatOpts);
356390
if (reactionsTable && result.action === "reactions") {
357391
lines.push(heading("Reactions"));
358392
lines.push(reactionsTable[0] ?? "");
359393
return lines;
360394
}
361395

362396
if (result.action === "read") {
363-
const messagesTable = renderMessagesFromPayload(payload, opts);
397+
const messagesTable = renderMessagesFromPayload(payload, formatOpts);
364398
if (messagesTable) {
365399
lines.push(heading("Messages"));
366400
lines.push(messagesTable[0] ?? "");
401+
const hint = renderPaginationHint(payload, muted);
402+
if (hint) {
403+
lines.push(hint);
404+
}
367405
return lines;
368406
}
369407
}
370408

371409
if (result.action === "list-pins") {
372-
const pinsTable = renderPinsFromPayload(payload, opts);
410+
const pinsTable = renderPinsFromPayload(payload, formatOpts);
373411
if (pinsTable) {
374412
lines.push(heading("Pinned messages"));
375413
lines.push(pinsTable[0] ?? "");
414+
const hint = renderPaginationHint(payload, muted);
415+
if (hint) {
416+
lines.push(hint);
417+
}
376418
return lines;
377419
}
378420
}
@@ -382,14 +424,36 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
382424
const list = extractDiscordSearchResultsMessages(results);
383425
if (list) {
384426
lines.push(heading("Search results"));
385-
lines.push(renderMessageList(list, opts, "No results.")[0] ?? "");
427+
lines.push(renderMessageList(list, formatOpts, "No results.")[0] ?? "");
428+
// Discord search nests cursor signals (hasMore, total_results) inside
429+
// results rather than at the payload top level. Try payload first, then
430+
// the nested results object, then a total_results-vs-count comparison
431+
// so completed searches (total_results === returned) show no hint.
432+
const hint =
433+
renderPaginationHint(payload, muted) ??
434+
renderPaginationHint(results, muted) ??
435+
(() => {
436+
if (!results || typeof results !== "object") {
437+
return null;
438+
}
439+
const r = results as Record<string, unknown>;
440+
if (typeof r.total_results === "number" && r.total_results > list.length) {
441+
return muted(
442+
"More results available. Use --limit to fetch more, or --json for the raw cursor.",
443+
);
444+
}
445+
return null;
446+
})();
447+
if (hint) {
448+
lines.push(hint);
449+
}
386450
return lines;
387451
}
388452
}
389453

390454
// Generic success + compact details table.
391455
lines.push(ok(`✅ ${result.action} via ${resolveChannelLabel(result.channel)}.`));
392-
const summary = renderObjectSummary(payload, opts);
456+
const summary = renderObjectSummary(payload, formatOpts);
393457
if (summary.length) {
394458
lines.push("");
395459
lines.push(...summary);

src/commands/message.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { formatCliCommand } from "../cli/command-format.js";
1515
import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-targets.js";
1616
import { resolveMessageSecretScope } from "../cli/message-secret-scope.js";
1717
import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js";
18+
import { parsePositiveIntOrUndefined } from "../cli/program/helpers.js";
1819
import { withProgress } from "../cli/progress.js";
1920
import { getRuntimeConfig } from "../config/config.js";
2021
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
@@ -132,7 +133,8 @@ export async function messageCommand(
132133
}
133134

134135
const { formatMessageCliText } = await import("./message-format.js");
135-
for (const line of formatMessageCliText(result)) {
136+
const displayLimit = parsePositiveIntOrUndefined(opts.limit);
137+
for (const line of formatMessageCliText(result, { displayLimit })) {
136138
runtime.log(line);
137139
}
138140
}

0 commit comments

Comments
 (0)