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 {
4848 type MessagesTimelineRow,
4949} from "./MessagesTimeline.logic";
5050import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
51import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5152import {
5253 deriveDisplayedUserMessageState,
5354 type ParsedTerminalContextEntry,
......@@ -792,6 +793,16 @@ function workEntryPreview(
792793 : `${firstPath} +${workEntry.changedFiles!.length - 1} more`;
793794}
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
795806function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {
796807 if (workEntry.requestKind === "command") return TerminalIcon;
797808 if (workEntry.requestKind === "file-read") return EyeIcon;
......@@ -840,6 +851,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
840851 const EntryIcon = workEntryIcon(workEntry);
841852 const heading = toolWorkEntryHeading(workEntry);
842853 const preview = workEntryPreview(workEntry);
854 const rawCommand = workEntryRawCommand(workEntry);
843855 const displayText = preview ? `${heading} - ${preview}` : heading;
844856 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
845857 const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail;
......@@ -853,19 +865,46 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
853865 <EntryIcon className="size-3" />
854866 </span>
855867 <div className="min-w-0 flex-1 overflow-hidden">
856 <p
857 className={cn(
858 "truncate text-[11px] leading-5",
859 workToneClass(workEntry.tone),
860 preview ? "text-muted-foreground/70" : "",
861 )}
862 title={displayText}
863 >
864 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
865 {heading}
866 </span>
867 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}
868 </p>
868 <div className="max-w-full">
869 <p
870 className={cn(
871 "truncate text-[11px] leading-5",
872 workToneClass(workEntry.tone),
873 preview ? "text-muted-foreground/70" : "",
874 )}
875 title={rawCommand ? undefined : displayText}
876 >
877 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
878 {heading}
879 </span>
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>
869908 </div>
870909 </div>
871910 {hasChangedFiles && !previewIsChangedFiles && (
apps/web/src/session-logic.test.ts+91
......@@ -709,6 +709,97 @@ describe("deriveWorkLogEntries", () => {
709709 expect(entry?.command).toBe("bun run lint");
710710 });
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
712803 it("keeps compact Codex tool metadata used for icons and labels", () => {
713804 const activities: OrchestrationThreadActivity[] = [
714805 makeActivity({
apps/web/src/session-logic.ts+169-12
......@@ -38,6 +38,7 @@ export interface WorkLogEntry {
3838 label: string;
3939 detail?: string;
4040 command?: string;
41 rawCommand?: string;
4142 changedFiles?: ReadonlyArray<string>;
4243 tone: "thinking" | "tool" | "info" | "error";
4344 toolTitle?: string;
......@@ -489,7 +490,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
489490 activity.payload && typeof activity.payload === "object"
490491 ? (activity.payload as Record<string, unknown>)
491492 : null;
492 const command = extractToolCommand(payload);
493 const commandPreview = extractToolCommand(payload);
493494 const changedFiles = extractChangedFiles(payload);
494495 const title = extractToolTitle(payload);
495496 const entry: DerivedWorkLogEntry = {
......@@ -507,8 +508,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
507508 entry.detail = detail;
508509 }
509510 }
510 if (command) {
511 entry.command = command;
511 if (commandPreview.command) {
512 entry.command = commandPreview.command;
513 }
514 if (commandPreview.rawCommand) {
515 entry.rawCommand = commandPreview.rawCommand;
512516 }
513517 if (changedFiles.length > 0) {
514518 entry.changedFiles = changedFiles;
......@@ -567,6 +571,7 @@ function mergeDerivedWorkLogEntries(
567571 const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
568572 const detail = next.detail ?? previous.detail;
569573 const command = next.command ?? previous.command;
574 const rawCommand = next.rawCommand ?? previous.rawCommand;
570575 const toolTitle = next.toolTitle ?? previous.toolTitle;
571576 const itemType = next.itemType ?? previous.itemType;
572577 const requestKind = next.requestKind ?? previous.requestKind;
......@@ -576,6 +581,7 @@ function mergeDerivedWorkLogEntries(
576581 ...next,
577582 ...(detail ? { detail } : {}),
578583 ...(command ? { command } : {}),
584 ...(rawCommand ? { rawCommand } : {}),
579585 ...(changedFiles.length > 0 ? { changedFiles } : {}),
580586 ...(toolTitle ? { toolTitle } : {}),
581587 ...(itemType ? { itemType } : {}),
......@@ -636,7 +642,121 @@ function asTrimmedString(value: unknown): string | null {
636642 return trimmed.length > 0 ? trimmed : null;
637643}
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 {
640760 const direct = asTrimmedString(value);
641761 if (direct) {
642762 return direct;
......@@ -647,21 +767,58 @@ function normalizeCommandValue(value: unknown): string | null {
647767 const parts = value
648768 .map((entry) => asTrimmedString(entry))
649769 .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(" ");
651774}
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} {
654793 const data = asRecord(payload?.data);
655794 const item = asRecord(data?.item);
656795 const itemResult = asRecord(item?.result);
657796 const itemInput = asRecord(item?.input);
658 const candidates = [
659 normalizeCommandValue(item?.command),
660 normalizeCommandValue(itemInput?.command),
661 normalizeCommandValue(itemResult?.command),
662 normalizeCommandValue(data?.command),
797 const itemType = asTrimmedString(payload?.itemType);
798 const detail = asTrimmedString(payload?.detail);
799 const candidates: unknown[] = [
800 item?.command,
801 itemInput?.command,
802 itemResult?.command,
803 data?.command,
804 itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null,
663805 ];
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 };
665822}
666823
667824function extractToolTitle(payload: Record<string, unknown> | null): string | null {