Skip to content

Commit 08fccff

Browse files
committed
Make the shell approval dialog readable
The command now renders exactly once, as the segment list — the separate raw verbatim dump that duplicated it is gone. Heredoc bodies and multi-line quoted arguments (commit messages, etc.) collapse to a "<label, N lines>" placeholder inline, expandable via the existing Ctrl+O. Persistent Allow options now lead with a single concise grant subject and its scope ("Allow these 4 git commands — this session") instead of a duplicated, ellipsized command mash with the scope buried and dimmed at the end.
1 parent 46513dd commit 08fccff

4 files changed

Lines changed: 313 additions & 100 deletions

File tree

src/tui/command-display.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { test, expect } from "bun:test";
2-
import { groupChainSegmentsForDisplay, verbatimCommandLines, middleEllipsis } from "./command-display.js";
2+
import {
3+
collapseSegmentPayloads,
4+
groupChainSegmentsForDisplay,
5+
verbatimCommandLines,
6+
middleEllipsis,
7+
} from "./command-display.js";
38

49
test("pipe stages stay inline while chain operators split", () => {
510
expect(groupChainSegmentsForDisplay("ls | head -5 && echo done")).toEqual([
@@ -69,6 +74,39 @@ test("bare carriage returns render as a visible marker", () => {
6974
]);
7075
});
7176

77+
test("collapseSegmentPayloads leaves a single-line segment untouched", () => {
78+
expect(collapseSegmentPayloads("git status")).toEqual({ display: "git status", payloads: [] });
79+
});
80+
81+
test("collapseSegmentPayloads collapses a heredoc body to a placeholder with a line count", () => {
82+
const segment = "git commit -F - <<'EOF'\nfix: something\n\nlonger body line\nEOF";
83+
const { display, payloads } = collapseSegmentPayloads(segment);
84+
expect(display).toBe("git commit -F - <<'EOF' <heredoc, 3 lines>");
85+
expect(payloads).toEqual([
86+
{ placeholder: "<heredoc, 3 lines>", lines: ["fix: something", "", "longer body line"] },
87+
]);
88+
});
89+
90+
test("collapseSegmentPayloads collapses a multi-line -m message to <message, N lines>", () => {
91+
const segment = 'git commit -m "line one\nline two\nline three"';
92+
const { display, payloads } = collapseSegmentPayloads(segment);
93+
expect(display).toBe("git commit -m <message, 3 lines>");
94+
expect(payloads).toEqual([
95+
{ placeholder: "<message, 3 lines>", lines: ["line one", "line two", "line three"] },
96+
]);
97+
});
98+
99+
test("collapseSegmentPayloads labels a non-message multi-line quoted argument as <text, N lines>", () => {
100+
const segment = 'echo "line one\nline two"';
101+
const { display } = collapseSegmentPayloads(segment);
102+
expect(display).toBe("echo <text, 2 lines>");
103+
});
104+
105+
test("collapseSegmentPayloads never collapses a single-line quoted argument", () => {
106+
const segment = 'git commit -m "a normal one-line message"';
107+
expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] });
108+
});
109+
72110
test("middleEllipsis keeps head and tail", () => {
73111
expect(middleEllipsis("abcdefghij", 20)).toBe("abcdefghij");
74112
const cut = middleEllipsis("prefix-common middle distinguishing-tail", 20);

src/tui/command-display.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,98 @@ export function verbatimCommandLines(text: string): VerbatimLine[] {
231231
return lines.filter((line, i) => line.text.trim().length > 0 || i === 0);
232232
}
233233

234+
export type CollapsedPayload = { placeholder: string; lines: string[] };
235+
236+
export type CollapsedSegment = {
237+
// The segment with each qualifying payload (a heredoc body, or a quoted
238+
// string spanning multiple lines) replaced by a short "<label, N lines>"
239+
// placeholder. A segment returned by groupChainSegmentsForDisplay is
240+
// already boundary-resolved, so any newline still inside it comes from one
241+
// of these two sources — never a chain boundary — which is what lets the
242+
// collapsed segment always render as a single line.
243+
display: string;
244+
// The full text of each collapsed payload, in placeholder order, shown when
245+
// the operator expands via Ctrl+O.
246+
payloads: CollapsedPayload[];
247+
};
248+
249+
// Picks a short, human label for a collapsed quoted payload by looking at the
250+
// flag token immediately before it (`-m`/`--message`/`-F` read as a commit
251+
// message; anything else is generic "text"). Display-only guesswork — never
252+
// used for classification or matching.
253+
function payloadLabel(segment: string, quoteStart: number): string {
254+
let k = quoteStart - 1;
255+
while (k >= 0 && (segment[k] === " " || segment[k] === "=")) k--;
256+
const end = k + 1;
257+
while (k >= 0 && segment[k] !== " " && segment[k] !== "=") k--;
258+
const token = segment.slice(k + 1, end);
259+
return token === "-m" || token === "--message" || token === "-F" ? "message" : "text";
260+
}
261+
262+
function lineCountSuffix(count: number): string {
263+
return `${count} line${count === 1 ? "" : "s"}`;
264+
}
265+
266+
// Collapse a heredoc body or a multi-line quoted-string argument within one
267+
// display segment into a placeholder. Never influences classification or
268+
// grant matching — display only, mirroring the header comment for this file.
269+
export function collapseSegmentPayloads(segment: string): CollapsedSegment {
270+
const payloads: CollapsedPayload[] = [];
271+
let display = "";
272+
let i = 0;
273+
while (i < segment.length) {
274+
const ch = segment[i] as string;
275+
276+
if (ch === "<" && segment[i + 1] === "<") {
277+
const marker = parseHeredocMarker(segment, i);
278+
if (marker !== null) {
279+
let j = i;
280+
while (j < segment.length && segment[j] !== "\n") j++;
281+
display += segment.slice(i, j);
282+
i = j + 1;
283+
const bodyLines: string[] = [];
284+
while (i < segment.length) {
285+
let lineEnd = segment.indexOf("\n", i);
286+
if (lineEnd === -1) lineEnd = segment.length;
287+
const line = segment.slice(i, lineEnd);
288+
if (line.trim() === marker) {
289+
i = lineEnd + 1;
290+
break;
291+
}
292+
bodyLines.push(line);
293+
i = lineEnd + 1;
294+
}
295+
const placeholder = `<heredoc, ${lineCountSuffix(bodyLines.length)}>`;
296+
display += ` ${placeholder}`;
297+
payloads.push({ placeholder, lines: bodyLines });
298+
continue;
299+
}
300+
}
301+
302+
if (ch === '"' || ch === "'" || ch === "`") {
303+
const quote = ch;
304+
let j = i + 1;
305+
while (j < segment.length && segment[j] !== quote) j++;
306+
const content = segment.slice(i + 1, j);
307+
if (content.includes("\n")) {
308+
const lines = content.split("\n");
309+
const placeholder = `<${payloadLabel(segment, i)}, ${lineCountSuffix(lines.length)}>`;
310+
display += placeholder;
311+
payloads.push({ placeholder, lines });
312+
i = j < segment.length ? j + 1 : j;
313+
continue;
314+
}
315+
display += segment.slice(i, j < segment.length ? j + 1 : j);
316+
i = j < segment.length ? j + 1 : j;
317+
continue;
318+
}
319+
320+
display += ch;
321+
i++;
322+
}
323+
return { display, payloads };
324+
}
325+
234326
// Truncate to `max` characters keeping both the head and tail, so a set of
235327
// strings that share a long common prefix (e.g. persistent Allow options that
236328
// differ only in their trailing grant note) stay visually distinguishable

0 commit comments

Comments
 (0)