| ... | ... | @@ -38,6 +38,7 @@ export interface WorkLogEntry { |
| 38 | 38 | label: string; |
| 39 | 39 | detail?: string; |
| 40 | 40 | command?: string; |
| 41 | rawCommand?: string; |
| 41 | 42 | changedFiles?: ReadonlyArray<string>; |
| 42 | 43 | tone: "thinking" | "tool" | "info" | "error"; |
| 43 | 44 | toolTitle?: string; |
| ... | ... | @@ -489,7 +490,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 489 | 490 | activity.payload && typeof activity.payload === "object" |
| 490 | 491 | ? (activity.payload as Record<string, unknown>) |
| 491 | 492 | : null; |
| 492 | | const command = extractToolCommand(payload); |
| 493 | const commandPreview = extractToolCommand(payload); |
| 493 | 494 | const changedFiles = extractChangedFiles(payload); |
| 494 | 495 | const title = extractToolTitle(payload); |
| 495 | 496 | const entry: DerivedWorkLogEntry = { |
| ... | ... | @@ -507,8 +508,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 507 | 508 | entry.detail = detail; |
| 508 | 509 | } |
| 509 | 510 | } |
| 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; |
| 512 | 516 | } |
| 513 | 517 | if (changedFiles.length > 0) { |
| 514 | 518 | entry.changedFiles = changedFiles; |
| ... | ... | @@ -567,6 +571,7 @@ function mergeDerivedWorkLogEntries( |
| 567 | 571 | const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); |
| 568 | 572 | const detail = next.detail ?? previous.detail; |
| 569 | 573 | const command = next.command ?? previous.command; |
| 574 | const rawCommand = next.rawCommand ?? previous.rawCommand; |
| 570 | 575 | const toolTitle = next.toolTitle ?? previous.toolTitle; |
| 571 | 576 | const itemType = next.itemType ?? previous.itemType; |
| 572 | 577 | const requestKind = next.requestKind ?? previous.requestKind; |
| ... | ... | @@ -576,6 +581,7 @@ function mergeDerivedWorkLogEntries( |
| 576 | 581 | ...next, |
| 577 | 582 | ...(detail ? { detail } : {}), |
| 578 | 583 | ...(command ? { command } : {}), |
| 584 | ...(rawCommand ? { rawCommand } : {}), |
| 579 | 585 | ...(changedFiles.length > 0 ? { changedFiles } : {}), |
| 580 | 586 | ...(toolTitle ? { toolTitle } : {}), |
| 581 | 587 | ...(itemType ? { itemType } : {}), |
| ... | ... | @@ -636,7 +642,121 @@ function asTrimmedString(value: unknown): string | null { |
| 636 | 642 | return trimmed.length > 0 ? trimmed : null; |
| 637 | 643 | } |
| 638 | 644 | |
| 639 | | function normalizeCommandValue(value: unknown): string | null { |
| 645 | function 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 | |
| 657 | function 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 | |
| 668 | function 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 | |
| 700 | const 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 | |
| 715 | function findShellWrapperSpec(shell: string) { |
| 716 | return SHELL_WRAPPER_SPECS.find((spec) => |
| 717 | (spec.executables as ReadonlyArray<string>).includes(shell), |
| 718 | ); |
| 719 | } |
| 720 | |
| 721 | function 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 | |
| 736 | function 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 | |
| 755 | function formatCommandArrayPart(value: string): string { |
| 756 | return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; |
| 757 | } |
| 758 | |
| 759 | function formatCommandValue(value: unknown): string | null { |
| 640 | 760 | const direct = asTrimmedString(value); |
| 641 | 761 | if (direct) { |
| 642 | 762 | return direct; |
| ... | ... | @@ -647,21 +767,58 @@ function normalizeCommandValue(value: unknown): string | null { |
| 647 | 767 | const parts = value |
| 648 | 768 | .map((entry) => asTrimmedString(entry)) |
| 649 | 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 | } |
| 652 | 775 | |
| 653 | | function extractToolCommand(payload: Record<string, unknown> | null): string | null { |
| 776 | function normalizeCommandValue(value: unknown): string | null { |
| 777 | const formatted = formatCommandValue(value); |
| 778 | return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; |
| 779 | } |
| 780 | |
| 781 | function 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 | |
| 789 | function extractToolCommand(payload: Record<string, unknown> | null): { |
| 790 | command: string | null; |
| 791 | rawCommand: string | null; |
| 792 | } { |
| 654 | 793 | const data = asRecord(payload?.data); |
| 655 | 794 | const item = asRecord(data?.item); |
| 656 | 795 | const itemResult = asRecord(item?.result); |
| 657 | 796 | 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, |
| 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 | } |
| 666 | 823 | |
| 667 | 824 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { |