authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-04-06 18:19:27+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-06 17:19:27+00:00
log340dbbb36e5198aa6a9a1bc1b20dbf5b86d3adb9
treeba059244f291b38dfb15ae7829210c6d2655d0e5
parent53a552e80d5783c1824a81dbe0d8e49302601e31
signature Signed by PGP key B5690EEEBB952194

fix(web): unwrap windows shell command wrappers (#1719)


3 files changed, 312 insertions(+), 25 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+52-13
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48 type MessagesTimelineRow,48 type MessagesTimelineRow,
49} from "./MessagesTimeline.logic";49} from "./MessagesTimeline.logic";
50import { TerminalContextInlineChip } from "./TerminalContextInlineChip";50import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
51import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
51import {52import {
52 deriveDisplayedUserMessageState,53 deriveDisplayedUserMessageState,
53 type ParsedTerminalContextEntry,54 type ParsedTerminalContextEntry,
...@@ -792,6 +793,16 @@ function workEntryPreview(...@@ -792,6 +793,16 @@ function workEntryPreview(
792 : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;793 : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
793}794}
794795
796function workEntryRawCommand(
797 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
798): string | null {
799 const rawCommand = workEntry.rawCommand?.trim();
800 if (!rawCommand || !workEntry.command) {
801 return null;
802 }
803 return rawCommand === workEntry.command.trim() ? null : rawCommand;
804}
805
795function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {806function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {
796 if (workEntry.requestKind === "command") return TerminalIcon;807 if (workEntry.requestKind === "command") return TerminalIcon;
797 if (workEntry.requestKind === "file-read") return EyeIcon;808 if (workEntry.requestKind === "file-read") return EyeIcon;
...@@ -840,6 +851,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -840,6 +851,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
840 const EntryIcon = workEntryIcon(workEntry);851 const EntryIcon = workEntryIcon(workEntry);
841 const heading = toolWorkEntryHeading(workEntry);852 const heading = toolWorkEntryHeading(workEntry);
842 const preview = workEntryPreview(workEntry);853 const preview = workEntryPreview(workEntry);
854 const rawCommand = workEntryRawCommand(workEntry);
843 const displayText = preview ? `${heading} - ${preview}` : heading;855 const displayText = preview ? `${heading} - ${preview}` : heading;
844 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;856 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
845 const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail;857 const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail;
...@@ -853,19 +865,46 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -853,19 +865,46 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
853 <EntryIcon className="size-3" />865 <EntryIcon className="size-3" />
854 </span>866 </span>
855 <div className="min-w-0 flex-1 overflow-hidden">867 <div className="min-w-0 flex-1 overflow-hidden">
856 <p868 <div className="max-w-full">
857 className={cn(869 <p
858 "truncate text-[11px] leading-5",870 className={cn(
859 workToneClass(workEntry.tone),871 "truncate text-[11px] leading-5",
860 preview ? "text-muted-foreground/70" : "",872 workToneClass(workEntry.tone),
861 )}873 preview ? "text-muted-foreground/70" : "",
862 title={displayText}874 )}
863 >875 title={rawCommand ? undefined : displayText}
864 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>876 >
865 {heading}877 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
866 </span>878 {heading}
867 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}879 </span>
868 </p>880 {preview &&
881 (rawCommand ? (
882 <Tooltip>
883 <TooltipTrigger
884 closeDelay={0}
885 delay={75}
886 render={
887 <span className="max-w-full cursor-default text-muted-foreground/55 transition-colors hover:text-muted-foreground/75 focus-visible:text-muted-foreground/75">
888 {" "}
889 - {preview}
890 </span>
891 }
892 />
893 <TooltipPopup
894 align="start"
895 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"
896 side="top"
897 >
898 <div className="max-w-[min(56rem,calc(100vw-2rem))] overflow-x-auto px-1.5 py-1 font-mono text-[11px] leading-4 whitespace-nowrap">
899 {rawCommand}
900 </div>
901 </TooltipPopup>
902 </Tooltip>
903 ) : (
904 <span className="text-muted-foreground/55"> - {preview}</span>
905 ))}
906 </p>
907 </div>
869 </div>908 </div>
870 </div>909 </div>
871 {hasChangedFiles && !previewIsChangedFiles && (910 {hasChangedFiles && !previewIsChangedFiles && (
apps/web/src/session-logic.test.ts+91
...@@ -709,6 +709,97 @@ describe("deriveWorkLogEntries", () => {...@@ -709,6 +709,97 @@ describe("deriveWorkLogEntries", () => {
709 expect(entry?.command).toBe("bun run lint");709 expect(entry?.command).toBe("bun run lint");
710 });710 });
711711
712 it("unwraps PowerShell command wrappers for displayed command text", () => {
713 const activities: OrchestrationThreadActivity[] = [
714 makeActivity({
715 id: "command-tool-windows-wrapper",
716 kind: "tool.completed",
717 summary: "Ran command",
718 payload: {
719 itemType: "command_execution",
720 data: {
721 item: {
722 command: "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'",
723 },
724 },
725 },
726 }),
727 ];
728
729 const [entry] = deriveWorkLogEntries(activities, undefined);
730 expect(entry?.command).toBe("bun run lint");
731 expect(entry?.rawCommand).toBe(
732 "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'",
733 );
734 });
735
736 it("unwraps PowerShell command wrappers from argv-style command payloads", () => {
737 const activities: OrchestrationThreadActivity[] = [
738 makeActivity({
739 id: "command-tool-windows-wrapper-argv",
740 kind: "tool.completed",
741 summary: "Ran command",
742 payload: {
743 itemType: "command_execution",
744 data: {
745 item: {
746 command: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "-Command", "rg -n foo ."],
747 },
748 },
749 },
750 }),
751 ];
752
753 const [entry] = deriveWorkLogEntries(activities, undefined);
754 expect(entry?.command).toBe("rg -n foo .");
755 expect(entry?.rawCommand).toBe(
756 '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command "rg -n foo ."',
757 );
758 });
759
760 it("extracts command text from command detail when structured command metadata is missing", () => {
761 const activities: OrchestrationThreadActivity[] = [
762 makeActivity({
763 id: "command-tool-windows-detail-fallback",
764 kind: "tool.completed",
765 summary: "Ran command",
766 payload: {
767 itemType: "command_execution",
768 detail:
769 '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command \'rg -n -F "new Date()" .\' <exited with exit code 0>',
770 },
771 }),
772 ];
773
774 const [entry] = deriveWorkLogEntries(activities, undefined);
775 expect(entry?.command).toBe('rg -n -F "new Date()" .');
776 expect(entry?.rawCommand).toBe(
777 `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n -F "new Date()" .'`,
778 );
779 });
780
781 it("does not unwrap shell commands when no wrapper flag is present", () => {
782 const activities: OrchestrationThreadActivity[] = [
783 makeActivity({
784 id: "command-tool-shell-script",
785 kind: "tool.completed",
786 summary: "Ran command",
787 payload: {
788 itemType: "command_execution",
789 data: {
790 item: {
791 command: "bash script.sh",
792 },
793 },
794 },
795 }),
796 ];
797
798 const [entry] = deriveWorkLogEntries(activities, undefined);
799 expect(entry?.command).toBe("bash script.sh");
800 expect(entry?.rawCommand).toBeUndefined();
801 });
802
712 it("keeps compact Codex tool metadata used for icons and labels", () => {803 it("keeps compact Codex tool metadata used for icons and labels", () => {
713 const activities: OrchestrationThreadActivity[] = [804 const activities: OrchestrationThreadActivity[] = [
714 makeActivity({805 makeActivity({
apps/web/src/session-logic.ts+169-12
...@@ -38,6 +38,7 @@ export interface WorkLogEntry {...@@ -38,6 +38,7 @@ export interface WorkLogEntry {
38 label: string;38 label: string;
39 detail?: string;39 detail?: string;
40 command?: string;40 command?: string;
41 rawCommand?: string;
41 changedFiles?: ReadonlyArray<string>;42 changedFiles?: ReadonlyArray<string>;
42 tone: "thinking" | "tool" | "info" | "error";43 tone: "thinking" | "tool" | "info" | "error";
43 toolTitle?: string;44 toolTitle?: string;
...@@ -489,7 +490,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -489,7 +490,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
489 activity.payload && typeof activity.payload === "object"490 activity.payload && typeof activity.payload === "object"
490 ? (activity.payload as Record<string, unknown>)491 ? (activity.payload as Record<string, unknown>)
491 : null;492 : null;
492 const command = extractToolCommand(payload);493 const commandPreview = extractToolCommand(payload);
493 const changedFiles = extractChangedFiles(payload);494 const changedFiles = extractChangedFiles(payload);
494 const title = extractToolTitle(payload);495 const title = extractToolTitle(payload);
495 const entry: DerivedWorkLogEntry = {496 const entry: DerivedWorkLogEntry = {
...@@ -507,8 +508,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -507,8 +508,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
507 entry.detail = detail;508 entry.detail = detail;
508 }509 }
509 }510 }
510 if (command) {511 if (commandPreview.command) {
511 entry.command = command;512 entry.command = commandPreview.command;
513 }
514 if (commandPreview.rawCommand) {
515 entry.rawCommand = commandPreview.rawCommand;
512 }516 }
513 if (changedFiles.length > 0) {517 if (changedFiles.length > 0) {
514 entry.changedFiles = changedFiles;518 entry.changedFiles = changedFiles;
...@@ -567,6 +571,7 @@ function mergeDerivedWorkLogEntries(...@@ -567,6 +571,7 @@ function mergeDerivedWorkLogEntries(
567 const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);571 const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
568 const detail = next.detail ?? previous.detail;572 const detail = next.detail ?? previous.detail;
569 const command = next.command ?? previous.command;573 const command = next.command ?? previous.command;
574 const rawCommand = next.rawCommand ?? previous.rawCommand;
570 const toolTitle = next.toolTitle ?? previous.toolTitle;575 const toolTitle = next.toolTitle ?? previous.toolTitle;
571 const itemType = next.itemType ?? previous.itemType;576 const itemType = next.itemType ?? previous.itemType;
572 const requestKind = next.requestKind ?? previous.requestKind;577 const requestKind = next.requestKind ?? previous.requestKind;
...@@ -576,6 +581,7 @@ function mergeDerivedWorkLogEntries(...@@ -576,6 +581,7 @@ function mergeDerivedWorkLogEntries(
576 ...next,581 ...next,
577 ...(detail ? { detail } : {}),582 ...(detail ? { detail } : {}),
578 ...(command ? { command } : {}),583 ...(command ? { command } : {}),
584 ...(rawCommand ? { rawCommand } : {}),
579 ...(changedFiles.length > 0 ? { changedFiles } : {}),585 ...(changedFiles.length > 0 ? { changedFiles } : {}),
580 ...(toolTitle ? { toolTitle } : {}),586 ...(toolTitle ? { toolTitle } : {}),
581 ...(itemType ? { itemType } : {}),587 ...(itemType ? { itemType } : {}),
...@@ -636,7 +642,121 @@ function asTrimmedString(value: unknown): string | null {...@@ -636,7 +642,121 @@ function asTrimmedString(value: unknown): string | null {
636 return trimmed.length > 0 ? trimmed : null;642 return trimmed.length > 0 ? trimmed : null;
637}643}
638644
639function normalizeCommandValue(value: unknown): string | null {645function trimMatchingOuterQuotes(value: string): string {
646 const trimmed = value.trim();
647 if (
648 (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
649 (trimmed.startsWith('"') && trimmed.endsWith('"'))
650 ) {
651 const unquoted = trimmed.slice(1, -1).trim();
652 return unquoted.length > 0 ? unquoted : trimmed;
653 }
654 return trimmed;
655}
656
657function executableBasename(value: string): string | null {
658 const trimmed = trimMatchingOuterQuotes(value);
659 if (trimmed.length === 0) {
660 return null;
661 }
662 const normalized = trimmed.replace(/\\/g, "/");
663 const segments = normalized.split("/");
664 const last = segments.at(-1)?.trim() ?? "";
665 return last.length > 0 ? last.toLowerCase() : null;
666}
667
668function splitExecutableAndRest(value: string): { executable: string; rest: string } | null {
669 const trimmed = value.trim();
670 if (trimmed.length === 0) {
671 return null;
672 }
673
674 if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
675 const quote = trimmed.charAt(0);
676 const closeIndex = trimmed.indexOf(quote, 1);
677 if (closeIndex <= 0) {
678 return null;
679 }
680 return {
681 executable: trimmed.slice(0, closeIndex + 1),
682 rest: trimmed.slice(closeIndex + 1).trim(),
683 };
684 }
685
686 const firstWhitespace = trimmed.search(/\s/);
687 if (firstWhitespace < 0) {
688 return {
689 executable: trimmed,
690 rest: "",
691 };
692 }
693
694 return {
695 executable: trimmed.slice(0, firstWhitespace),
696 rest: trimmed.slice(firstWhitespace).trim(),
697 };
698}
699
700const SHELL_WRAPPER_SPECS = [
701 {
702 executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"],
703 wrapperFlagPattern: /(?:^|\s)-command\s+/i,
704 },
705 {
706 executables: ["cmd", "cmd.exe"],
707 wrapperFlagPattern: /(?:^|\s)\/c\s+/i,
708 },
709 {
710 executables: ["bash", "sh", "zsh"],
711 wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i,
712 },
713] as const;
714
715function findShellWrapperSpec(shell: string) {
716 return SHELL_WRAPPER_SPECS.find((spec) =>
717 (spec.executables as ReadonlyArray<string>).includes(shell),
718 );
719}
720
721function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null {
722 const match = wrapperFlagPattern.exec(value);
723 if (!match) {
724 return null;
725 }
726
727 const command = value.slice(match.index + match[0].length).trim();
728 if (command.length === 0) {
729 return null;
730 }
731
732 const unwrapped = trimMatchingOuterQuotes(command);
733 return unwrapped.length > 0 ? unwrapped : null;
734}
735
736function unwrapKnownShellCommandWrapper(value: string): string {
737 const split = splitExecutableAndRest(value);
738 if (!split || split.rest.length === 0) {
739 return value;
740 }
741
742 const shell = executableBasename(split.executable);
743 if (!shell) {
744 return value;
745 }
746
747 const spec = findShellWrapperSpec(shell);
748 if (!spec) {
749 return value;
750 }
751
752 return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value;
753}
754
755function formatCommandArrayPart(value: string): string {
756 return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
757}
758
759function formatCommandValue(value: unknown): string | null {
640 const direct = asTrimmedString(value);760 const direct = asTrimmedString(value);
641 if (direct) {761 if (direct) {
642 return direct;762 return direct;
...@@ -647,21 +767,58 @@ function normalizeCommandValue(value: unknown): string | null {...@@ -647,21 +767,58 @@ function normalizeCommandValue(value: unknown): string | null {
647 const parts = value767 const parts = value
648 .map((entry) => asTrimmedString(entry))768 .map((entry) => asTrimmedString(entry))
649 .filter((entry): entry is string => entry !== null);769 .filter((entry): entry is string => entry !== null);
650 return parts.length > 0 ? parts.join(" ") : null;770 if (parts.length === 0) {
771 return null;
772 }
773 return parts.map((part) => formatCommandArrayPart(part)).join(" ");
651}774}
652775
653function extractToolCommand(payload: Record<string, unknown> | null): string | null {776function normalizeCommandValue(value: unknown): string | null {
777 const formatted = formatCommandValue(value);
778 return formatted ? unwrapKnownShellCommandWrapper(formatted) : null;
779}
780
781function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null {
782 const formatted = formatCommandValue(value);
783 if (!formatted || normalizedCommand === null) {
784 return null;
785 }
786 return formatted === normalizedCommand ? null : formatted;
787}
788
789function extractToolCommand(payload: Record<string, unknown> | null): {
790 command: string | null;
791 rawCommand: string | null;
792} {
654 const data = asRecord(payload?.data);793 const data = asRecord(payload?.data);
655 const item = asRecord(data?.item);794 const item = asRecord(data?.item);
656 const itemResult = asRecord(item?.result);795 const itemResult = asRecord(item?.result);
657 const itemInput = asRecord(item?.input);796 const itemInput = asRecord(item?.input);
658 const candidates = [797 const itemType = asTrimmedString(payload?.itemType);
659 normalizeCommandValue(item?.command),798 const detail = asTrimmedString(payload?.detail);
660 normalizeCommandValue(itemInput?.command),799 const candidates: unknown[] = [
661 normalizeCommandValue(itemResult?.command),800 item?.command,
662 normalizeCommandValue(data?.command),801 itemInput?.command,
802 itemResult?.command,
803 data?.command,
804 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
663 ];805 ];
664 return candidates.find((candidate) => candidate !== null) ?? null;806
807 for (const candidate of candidates) {
808 const command = normalizeCommandValue(candidate);
809 if (!command) {
810 continue;
811 }
812 return {
813 command,
814 rawCommand: toRawToolCommand(candidate, command),
815 };
816 }
817
818 return {
819 command: null,
820 rawCommand: null,
821 };
665}822}
666823
667function extractToolTitle(payload: Record<string, unknown> | null): string | null {824function extractToolTitle(payload: Record<string, unknown> | null): string | null {