AX Agent ?? ?? ?? ??? ??? ?? ??? ????

- Code ??? ?? ???? ?? ??? ??? ? ?? ?? ???? ???? no-progress ??? ???
- ??? ?? ??? 1~2? ????? ????? ToolCall/ToolResult ?? ??? ?? ????? ????? ???
- ??? Thinking/LLM ?? ??? ??? ???? ??? ?? ?? ??? ??? ??? ????? ????
- Cowork/Code ??? ??? ?? ??? ???? ??? ??? ??? ?? ???? ?
- README.md, docs/DEVELOPMENT.md ??? 2026-04-15 18:30 (KST) ???? ???

??
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\
This commit is contained in:
2026-04-15 18:33:06 +09:00
parent 232d5457d5
commit 53838a046b
18 changed files with 550 additions and 425 deletions

View File

@@ -1274,7 +1274,21 @@ public partial class AgentLoopService
: 1;
if (TryHandleNoProgressReadOnlyLoopTransition(
call,
effectiveCall,
toolCallSignature,
previewRepeatedToolCount,
messages,
lastModifiedCodeFilePath,
requireHighImpactCodeVerification,
taskPolicy))
{
lastAnyToolSignature = toolCallSignature;
repeatedAnyToolSignatureCount = previewRepeatedToolCount;
continue;
}
if (TryHandleRepeatedNoProgressToolLoopTransition(
effectiveCall,
toolCallSignature,
previewRepeatedToolCount,
messages,

View File

@@ -497,6 +497,41 @@ public partial class AgentLoopService
return true;
}
private bool TryHandleRepeatedNoProgressToolLoopTransition(
ContentBlock call,
string toolCallSignature,
int repeatedSameSignatureCount,
List<ChatMessage> messages,
string? lastModifiedCodeFilePath,
bool requireHighImpactCodeVerification,
TaskTypePolicy taskPolicy)
{
if (!ShouldBlockRepeatedNoProgressToolLoop(
call.ToolName,
repeatedSameSignatureCount))
return false;
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId,
call.ToolName,
$"[NO_PROGRESS_SIGNATURE_GUARD] 동일한 도구 호출이 {repeatedSameSignatureCount}회 반복되었습니다. {toolCallSignature}\n" +
"Stop repeating the same tool call and switch to a concrete next action that creates new evidence or state change."));
messages.Add(new ChatMessage
{
Role = "user",
Content = BuildNoProgressLoopRecoveryPrompt(
call.ToolName,
lastModifiedCodeFilePath,
requireHighImpactCodeVerification,
taskPolicy)
});
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
$"동일한 도구 호출이 반복되어 다른 방식으로 전환합니다({repeatedSameSignatureCount}회)");
return true;
}
private bool TryHandleReadOnlyStagnationTransition(
int consecutiveReadOnlySuccessTools,
List<ChatMessage> messages,
@@ -684,6 +719,26 @@ public partial class AgentLoopService
return AgentToolCatalog.IsReadOnly(toolName);
}
private static bool ShouldBlockRepeatedNoProgressToolLoop(
string toolName,
int repeatedSameSignatureCount)
{
if (string.IsNullOrWhiteSpace(toolName) || AgentToolCatalog.IsReadOnly(toolName))
return false;
var baseThreshold = Math.Max(GetReadOnlySignatureLoopThreshold() + 1, 5);
if (IsPollingOrCoordinationTool(toolName))
return repeatedSameSignatureCount >= baseThreshold + 2;
if (IsMutatingOrExecutionProgressTool(toolName))
return repeatedSameSignatureCount >= baseThreshold + 1;
return repeatedSameSignatureCount >= baseThreshold;
}
private static bool IsPollingOrCoordinationTool(string toolName)
=> toolName is "wait_agents" or "task_output" or "task_get" or "task_list";
private static int GetReadOnlySignatureLoopThreshold()
{
return ResolveConfiguredOrEnvThresholdValue(

View File

@@ -57,6 +57,24 @@ internal static class AgentProgressSummarySanitizer
return normalized;
}
public static bool IsLowSignalStatusSummary(string? summary, string? toolName = null)
{
var normalized = NormalizeThinkingSummary(summary, toolName, maxLength: 160);
if (string.IsNullOrWhiteSpace(normalized))
return true;
var lower = normalized.ToLowerInvariant();
return lower.Contains("모델에 요청하는 중")
|| lower.Contains("모델 첫 응답")
|| lower.Contains("계속 기다리는 중")
|| lower.Contains("응답을 기다리는 중")
|| lower.Contains("스트리밍 중간 응답")
|| lower.Contains("일시적 llm 오류")
|| lower.Contains("gemini 무료 티어 대기")
|| lower.Contains("도구명 정규화 적용")
|| lower.Contains("[agentloopwait]");
}
private static string CleanThinkingLine(string line)
{
var cleaned = s_listPrefixRegex.Replace(line.Trim(), string.Empty).Trim();

View File

@@ -16,12 +16,12 @@ internal static class AgentStatusNarrativeCatalog
public static AgentStatusNarrative BuildInitial(string? runTab)
=> IsCodeTab(runTab)
? new AgentStatusNarrative(
"관련 코드와 수정 범위를 파악하고 있습니다...",
"필요한 파일과 테스트 범위를 좁혀서 읽기 시작합니다.",
"작업 범위와 관련 파일을 먼저 파악하고 있습니다...",
"필요한 코드, 로그, 테스트 범위를 정리한 뒤 바로 수정 단계로 이어갑니다.",
"initialize")
: new AgentStatusNarrative(
"요청 목적과 필요한 자료를 정리하고 있습니다...",
"답변 구조와 필요한 문서·근거를 먼저 가볍게 확인합니다.",
"관련 문서와 작업 범위를 확인한 뒤 바로 다음 단계로 이어갑니다.",
"initialize");
public static AgentStatusNarrative BuildFromEvent(AgentEvent evt, string? runTab)
@@ -53,7 +53,7 @@ internal static class AgentStatusNarrativeCatalog
{
return new AgentStatusNarrative(
"긴 대화를 이어가기 위해 컨텍스트를 정리하고 있습니다...",
"최근 대화와 작업 결과를 압축한 뒤 이어서 진행합니다.",
"최근 작업 결과를 압축해 다음 단계에 필요한 정보만 남기고 있습니다.",
"compact");
}
@@ -71,7 +71,7 @@ internal static class AgentStatusNarrativeCatalog
if (idle >= TimeSpan.FromSeconds(90))
{
return new AgentStatusNarrative(
"작업 범위가 커서 현재까지 수집한 내용을 종합하고 있습니다...",
"현재까지 확인한 내용과 다음 단계를 차분히 정리하고 있습니다...",
BuildIdleDetail(category),
category);
}
@@ -120,12 +120,12 @@ internal static class AgentStatusNarrativeCatalog
{
return category switch
{
"read" => "관련 범위를 읽고 확인하는 중",
"edit" => "변경 내용을 적용하는 중",
"read" => "관련 코드와 파일을 확인하는 중",
"edit" => "변경을 적용하는 중",
"execute" => "실행 결과를 확인하는 중",
"document" => "문서 산출물을 구성하는 중",
"document" => "문서 내용을 구성하는 중",
"git" => "변경 범위를 확인하는 중",
"web" => "외부 정보를 정리하는 중",
"web" => "필요한 정보를 정리하는 중",
_ => row.Title,
};
}
@@ -134,7 +134,7 @@ internal static class AgentStatusNarrativeCatalog
{
return category switch
{
"read" => "읽은 내용을 정리하는 중",
"read" => "확인한 내용을 정리하는 중",
"edit" => "적용한 변경을 정리하는 중",
"execute" => "실행 결과를 분석하는 중",
"document" => "생성 결과를 검토하는 중",
@@ -157,24 +157,24 @@ internal static class AgentStatusNarrativeCatalog
AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt)));
if (category == "compact")
return "컨텍스트 압축 중...";
return "컨텍스트를 정리하는 중...";
if (evt.Type == AgentEventType.Thinking && string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase))
return "응답을 정리하는 중...";
return "다음 응답을 준비하는 중...";
return evt.Type switch
{
AgentEventType.Planning => "작업 순서를 정리하는 중...",
AgentEventType.StepStart when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행 중...",
AgentEventType.StepDone when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 정리 중...",
AgentEventType.PermissionRequest => "권한 확인 대기 중...",
AgentEventType.PermissionRequest => "권한 확인을 기다리는 중...",
AgentEventType.PermissionDenied => "대체 경로를 검토하는 중...",
AgentEventType.ToolCall => category switch
{
"read" => "관련 파일을 확인하는 중...",
"edit" => "변경 내용을 적용하는 중...",
"edit" => "변경을 적용하는 중...",
"execute" => "실행 결과를 확인하는 중...",
"document" => "산출물을 구성하는 중...",
"document" => "문서를 구성하는 중...",
"git" => "변경 범위를 정리하는 중...",
"web" => "필요한 정보를 찾는 중...",
_ => null,
@@ -182,7 +182,7 @@ internal static class AgentStatusNarrativeCatalog
AgentEventType.ToolResult => category switch
{
"read" => "읽은 내용을 정리하는 중...",
"edit" => "적용한 변경을 점검하는 중...",
"edit" => "수정 내용을 정리하는 중...",
"execute" => "실행 결과를 분석하는 중...",
"document" => "생성 결과를 검토하는 중...",
_ => null,
@@ -207,16 +207,16 @@ internal static class AgentStatusNarrativeCatalog
AgentEventType.Planning => "계획",
AgentEventType.StepStart or AgentEventType.StepDone => "단계",
AgentEventType.PermissionRequest => "권한",
AgentEventType.PermissionGranted => "권한 인",
AgentEventType.PermissionGranted => "권한 인",
AgentEventType.PermissionDenied => "권한 거부",
AgentEventType.ToolCall => category switch
{
"read" => "탐색",
"edit" => "수정",
"execute" => "실행",
"document" => "산출물",
"document" => "문서",
"git" => "Git",
"web" => "",
"web" => "자료",
_ => "도구",
},
AgentEventType.ToolResult => category switch
@@ -242,15 +242,15 @@ internal static class AgentStatusNarrativeCatalog
{
AgentEventType.Planning => IsCodeTab(runTab)
? "수정 순서와 검증 단계를 정리하고 있습니다..."
: "작업 순서와 산출물 구조를 정리하고 있습니다...",
: "작업 순서와 결과물 구성을 정리하고 있습니다...",
AgentEventType.StepStart when evt.StepTotal > 0
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계 작업을 진행하고 있습니다...",
AgentEventType.StepDone when evt.StepTotal > 0
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 어가고 있습니다...",
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 어가고 있습니다...",
AgentEventType.PermissionRequest => "실행 전에 필요한 권한 확인을 기다리고 있습니다...",
AgentEventType.PermissionGranted => "권한이 확인되어 작업을 이어가고 있습니다...",
AgentEventType.PermissionDenied => "권한이 거부되어 다른 진행 경로를 검토하고 있습니다...",
AgentEventType.SkillCall => "전용 스킬 절차를 적용하고 있습니다...",
AgentEventType.SkillCall => "적절한 스킬이나 작업 흐름을 적용하고 있습니다...",
AgentEventType.ToolCall => category switch
{
"read" => IsCodeTab(runTab)
@@ -260,21 +260,21 @@ internal static class AgentStatusNarrativeCatalog
? "코드 변경을 적용하고 있습니다..."
: "초안 내용을 다듬고 있습니다...",
"execute" => "실행 결과와 로그를 확인하고 있습니다...",
"document" => "문서 산출물을 구성하고 있습니다...",
"document" => "문서 결과물을 구성하고 있습니다...",
"git" => "변경 범위와 저장소 상태를 확인하고 있습니다...",
"web" => "필요한 외부 정보를 찾아 정리하고 있습니다...",
"web" => "필요한 외부 정보를 정리하고 있습니다...",
_ => string.IsNullOrWhiteSpace(itemDisplayName)
? "필요한 작업을 행하고 있습니다..."
? "필요한 작업을 행하고 있습니다..."
: $"{itemDisplayName} 작업을 진행하고 있습니다...",
},
AgentEventType.ToolResult => category switch
{
"read" => "확인한 내용을 정리하고 다음 단계를 준비하고 있습니다...",
"edit" => "적용한 변경을 정리하고 다음 검증으로 어가고 있습니다...",
"read" => "확인한 내용을 정리하고 다음 단계로 이어가고 있습니다...",
"edit" => "적용한 변경을 정리하고 다음 검증으로 어가고 있습니다...",
"execute" => "실행 결과를 분석하고 후속 조치를 판단하고 있습니다...",
"document" => "생성한 결과를 검토하고 다듬고 있습니다...",
"git" => "변경 내용을 정리하고 다음 작업을 준비하고 있습니다...",
"web" => "수집한 정보를 정리하고 답변에 연결하고 있습니다...",
"web" => "수집한 정보를 정리하고 답변에 반영하고 있습니다...",
_ => string.IsNullOrWhiteSpace(row.Title)
? "결과를 정리하고 있습니다..."
: EnsureSentence(row.Title),
@@ -284,13 +284,13 @@ internal static class AgentStatusNarrativeCatalog
AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
=> "현재까지 진행한 내용을 정리하고 있습니다...",
AgentEventType.Thinking when category == "document"
=> "문서 흐름과 누락된 내용을 정리하고 있습니다...",
=> "문서 흐름과 필요한 내용을 정리하고 있습니다...",
AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("검증", StringComparison.OrdinalIgnoreCase)
=> "결과를 검토하고 누락된 부분이 없는지 확인하고 있습니다...",
=> "결과를 검토하고 빠진 부분이 없는지 확인하고 있습니다...",
AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("재시도", StringComparison.OrdinalIgnoreCase)
=> "다시 시도할 경로를 정리하고 있습니다...",
AgentEventType.Complete => "작업이 완료되었습니다.",
AgentEventType.Error => "오류를 정리하고 복구 경로를 판단하고 있습니다...",
AgentEventType.Error => "오류를 정리하고 복구 경로를 검토하고 있습니다...",
AgentEventType.Decision => "다음 진행을 위해 사용자 확인을 기다리고 있습니다...",
_ => string.IsNullOrWhiteSpace(row.Title)
? "작업을 진행하고 있습니다..."
@@ -313,20 +313,14 @@ internal static class AgentStatusNarrativeCatalog
}
if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 })
{
parts.Add($"총 {evt.Steps.Count}단계 계획을 기준으로 진행합니다.");
}
var targetHint = ExtractTargetHint(evt);
if (!string.IsNullOrWhiteSpace(targetHint))
{
parts.Add(evt.Type == AgentEventType.ToolResult
? $"결과 대상: {targetHint}"
: $"대상: {targetHint}");
}
parts.Add($"대상: {targetHint}");
if (category == "compact" && evt.ElapsedMs > 0)
parts.Add("이전 대화와 최근 작업 결과를 함께 압축하고 있습니다.");
parts.Add("이전 대화와 최근 작업 결과를 이어서 사용할 수 있게 정리하고 있습니다.");
return parts.Count == 0 ? null : string.Join(" · ", parts);
}
@@ -336,7 +330,7 @@ internal static class AgentStatusNarrativeCatalog
{
"read" => IsCodeTab(runTab)
? longWait
? "읽은 코드 맥락을 종합하고 다음 수정 지점을 정리하고 있습니다..."
? "읽은 코드와 검색 결과를 종합 다음 수정 지점을 정리하고 있습니다..."
: "읽은 코드와 검색 결과를 정리하고 있습니다..."
: longWait
? "확인한 자료를 종합해 답변 구조를 정리하고 있습니다..."
@@ -348,8 +342,8 @@ internal static class AgentStatusNarrativeCatalog
? "실행 로그를 종합해 원인과 다음 조치를 정리하고 있습니다..."
: "실행 결과와 로그를 정리하고 있습니다...",
"document" => longWait
? "초안 구조와 누락된 근거를 다시 맞추고 있습니다..."
: "초안과 산출물 구성을 다듬고 있습니다...",
? "초안 구조와 빠진 근거를 다시 맞추고 있습니다..."
: "초안과 결과물 구성을 다듬고 있습니다...",
"git" => "변경 범위와 저장소 상태를 다시 정리하고 있습니다...",
"web" => "수집한 정보를 정리해 답변에 연결하고 있습니다...",
"permission" => "권한 확인 결과를 기다리며 다음 단계를 준비하고 있습니다...",
@@ -361,14 +355,14 @@ internal static class AgentStatusNarrativeCatalog
private static string BuildIdleDetail(string category)
=> category switch
{
"read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결니다.",
"edit" => "수정 범위와 영향 파일을 다시 확인니다.",
"execute" => "빌드·테스트 로그에서 실패 원인과 후속 조치를 추니다.",
"document" => "초안 구조 빠진 근거, 결 흐름을 다시 맞니다.",
"git" => "변경 범위와 저장소 상태를 함께 확인합니다.",
"web" => "수집한 정보를 질문 흐름에 맞게 추려냅니다.",
"permission" => "권한 결과가 정리되면 같은 작업 흐름으로 이어집니다.",
_ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결니다.",
"read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결하고 있습니다.",
"edit" => "수정 범위와 영향 파일을 다시 확인하고 있습니다.",
"execute" => "빌드테스트 로그에서 원인과 후속 조치를 추리고 있습니다.",
"document" => "초안 구조, 빠진 근거, 결 흐름을 다시 맞추고 있습니다.",
"git" => "변경 범위와 저장소 상태를 한 번 더 확인하고 있습니다.",
"web" => "수집한 정보를 질문 흐름에 맞게 추리고 있습니다.",
"permission" => "권한 결과가 정리되면 같은 작업 흐름으로 바로 이어집니다.",
_ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결하고 있습니다.",
};
private static string ResolveCategory(AgentEvent evt, AgentTranscriptRowPresentation row)
@@ -448,9 +442,9 @@ internal static class AgentStatusNarrativeCatalog
return Path.GetFileName(text);
if (key == "command")
return text.Length > 60 ? text[..60] + "" : text;
return text.Length > 60 ? text[..60] + "..." : text;
return text.Length > 70 ? text[..70] + "" : text;
return text.Length > 70 ? text[..70] + "..." : text;
}
if (value.ValueKind == JsonValueKind.Array)
@@ -506,7 +500,7 @@ internal static class AgentStatusNarrativeCatalog
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
if (normalized.Length > 160)
normalized = normalized[..160].TrimEnd() + "";
normalized = normalized[..160].TrimEnd() + "...";
return normalized;
}

View File

@@ -150,7 +150,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
if (!firstEventReceived)
{
firstEventReceived = true;
LogService.Info(
LogService.Debug(
$"[AgentLoopWait] {phaseLabel}: 첫 응답 수신 ({waitStopwatch.ElapsedMilliseconds}ms, kind={evt.Kind})");
if (waitStopwatch.Elapsed >= _firstResponseHeartbeatDelay)
_emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: 모델 첫 응답을 받아 계속 진행합니다.");
@@ -226,7 +226,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
var summary = firstEventReceived
? $"{phaseLabel}: 모델 응답이 길어져 계속 기다리는 중입니다... ({seconds}초)"
: $"{phaseLabel}: 모델 첫 응답을 기다리는 중입니다... ({seconds}초)";
LogService.Info($"[AgentLoopWait] {summary}");
LogService.Debug($"[AgentLoopWait] {summary}");
_emitEvent(AgentEventType.Thinking, "", summary);
}
}

View File

@@ -200,8 +200,220 @@ public static class MarkdownRenderer
return panel;
}
public static FrameworkElement RenderSelectable(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
{
var document = BuildSelectableDocument(markdown, textColor, secondaryColor, accentColor, codeBg);
var viewer = new RichTextBox
{
Document = document,
IsReadOnly = true,
IsDocumentEnabled = true,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
Margin = new Thickness(0),
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
AcceptsReturn = true,
};
void UpdatePageWidth()
{
var width = System.Math.Max(0, viewer.ActualWidth - 8);
viewer.Document.PageWidth = width <= 0 ? 640 : width;
}
viewer.Loaded += (_, _) => UpdatePageWidth();
viewer.SizeChanged += (_, _) => UpdatePageWidth();
return viewer;
}
private static FlowDocument BuildSelectableDocument(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
{
var document = new FlowDocument
{
PagePadding = new Thickness(0),
Background = Brushes.Transparent,
};
if (string.IsNullOrEmpty(markdown))
return document;
var lines = markdown.Replace("\r\n", "\n").Split('\n');
var i = 0;
while (i < lines.Length)
{
var line = lines[i];
if (line.TrimStart().StartsWith("```"))
{
var lang = line.TrimStart().Length > 3 ? line.TrimStart()[3..].Trim() : "";
var codeLines = new System.Text.StringBuilder();
i++;
while (i < lines.Length && !lines[i].TrimStart().StartsWith("```"))
{
codeLines.AppendLine(lines[i]);
i++;
}
if (i < lines.Length)
i++;
if (!string.IsNullOrWhiteSpace(lang))
{
document.Blocks.Add(new Paragraph(new Run(lang))
{
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = accentColor,
Margin = new Thickness(0, 6, 0, 2),
});
}
var codeParagraph = new Paragraph
{
Margin = new Thickness(0, 0, 0, 8),
Padding = new Thickness(12, 10, 12, 10),
Background = codeBg,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
BorderThickness = new Thickness(1),
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 12.5,
};
AppendSyntaxHighlightedInlines(codeParagraph.Inlines, codeLines.ToString().TrimEnd(), lang, textColor);
document.Blocks.Add(codeParagraph);
continue;
}
if (string.IsNullOrWhiteSpace(line))
{
document.Blocks.Add(new Paragraph(new Run(" "))
{
Margin = new Thickness(0, 2, 0, 2),
FontSize = 4,
});
i++;
continue;
}
if (Regex.IsMatch(line.Trim(), @"^-{3,}$|^\*{3,}$"))
{
document.Blocks.Add(new BlockUIContainer(new Border
{
Height = 1,
Background = secondaryColor,
Opacity = 0.3,
Margin = new Thickness(0, 8, 0, 8),
}));
i++;
continue;
}
if (line.StartsWith('#'))
{
var level = 0;
while (level < line.Length && line[level] == '#') level++;
var headerText = line[level..].Trim();
var fontSize = level switch { 1 => 20.0, 2 => 17.0, 3 => 15.0, _ => 14.0 };
var paragraph = new Paragraph
{
FontSize = fontSize,
FontWeight = FontWeights.Bold,
Foreground = textColor,
Margin = new Thickness(0, level == 1 ? 12 : 8, 0, 4),
};
AddInlines(paragraph.Inlines, headerText, textColor, accentColor, codeBg, selectable: true);
document.Blocks.Add(paragraph);
i++;
continue;
}
if (Regex.IsMatch(line, @"^\s*[-*]\s") || Regex.IsMatch(line, @"^\s*\d+\.\s"))
{
var match = Regex.Match(line, @"^(\s*)([-*]|\d+\.)\s(.*)");
if (match.Success)
{
var indent = match.Groups[1].Value.Length / 2;
var bullet = match.Groups[2].Value;
var content = match.Groups[3].Value;
var paragraph = new Paragraph
{
Margin = new Thickness(12 + indent * 16, 2, 0, 2),
Foreground = textColor,
FontSize = 13.5,
};
paragraph.Inlines.Add(new Run(bullet is "-" or "*" ? "• " : $"{bullet} ")
{
Foreground = accentColor,
FontWeight = FontWeights.SemiBold,
});
AddInlines(paragraph.Inlines, content, textColor, accentColor, codeBg, selectable: true);
document.Blocks.Add(paragraph);
}
i++;
continue;
}
if (line.TrimStart().StartsWith('>'))
{
var quoteLines = new List<string>();
while (i < lines.Length && lines[i].TrimStart().StartsWith('>'))
{
var ql = lines[i].TrimStart();
quoteLines.Add(ql.Length > 1 ? ql[1..].TrimStart() : "");
i++;
}
var quoteParagraph = new Paragraph
{
Margin = new Thickness(4, 4, 0, 4),
Padding = new Thickness(12, 6, 8, 6),
BorderBrush = accentColor,
BorderThickness = new Thickness(3, 0, 0, 0),
Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)),
FontStyle = FontStyles.Italic,
FontSize = 13,
Foreground = secondaryColor,
LineHeight = 20,
};
AddInlines(quoteParagraph.Inlines, string.Join("\n", quoteLines), textColor, accentColor, codeBg, selectable: true);
document.Blocks.Add(quoteParagraph);
continue;
}
if (line.Contains('|') && line.Trim().StartsWith('|'))
{
var tableRows = new List<string>();
while (i < lines.Length && lines[i].Contains('|'))
{
tableRows.Add(lines[i]);
i++;
}
var table = CreateMarkdownTable(tableRows, textColor, accentColor, codeBg);
if (table != null)
document.Blocks.Add(new BlockUIContainer(table));
continue;
}
var paragraphBlock = new Paragraph
{
FontSize = 13.5,
Foreground = textColor,
LineHeight = 22,
Margin = new Thickness(0, 2, 0, 2),
};
AddInlines(paragraphBlock.Inlines, line, textColor, accentColor, codeBg, selectable: true);
document.Blocks.Add(paragraphBlock);
i++;
}
return document;
}
/// <summary>인라인 마크다운 처리: **볼드**, *이탤릭*, `코드`, ~~취소선~~, [링크](url)</summary>
private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg)
private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg, bool selectable = false)
{
// 패턴: [link](url) | ~~strikethrough~~ | **bold** | *italic* | `code` | 일반텍스트
var pattern = @"(\[([^\]]+)\]\(([^)]+)\)|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|([^*`~\[]+))";
@@ -242,21 +454,34 @@ public static class MarkdownRenderer
}
else if (m.Groups[7].Success) // `code`
{
var codeBorder = new Border
if (selectable)
{
Background = codeBg,
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(1, 0, 1, 0),
Child = new TextBlock
inlines.Add(new Run(m.Groups[7].Value)
{
Text = m.Groups[7].Value,
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 12.5,
Foreground = accentColor
}
};
inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center });
Foreground = accentColor,
Background = codeBg,
});
}
else
{
var codeBorder = new Border
{
Background = codeBg,
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(1, 0, 1, 0),
Child = new TextBlock
{
Text = m.Groups[7].Value,
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
FontSize = 12.5,
Foreground = accentColor
}
};
inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center });
}
}
else if (m.Groups[8].Success) // 일반 텍스트
{
@@ -797,105 +1022,59 @@ public static class MarkdownRenderer
RegexOptions.Compiled);
private static void ApplySyntaxHighlighting(TextBlock tb, string code, string lang, Brush defaultColor)
{
tb.Inlines.Clear();
AppendSyntaxHighlightedInlines(tb.Inlines, code, lang, defaultColor);
}
private static void AppendSyntaxHighlightedInlines(InlineCollection inlines, string code, string lang, Brush defaultColor)
{
if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(code))
{
tb.Text = code;
inlines.Add(new Run(code) { Foreground = defaultColor });
return;
}
var keywords = GetKeywordsForLang(lang);
var isCommentHash = lang.ToLowerInvariant() is "python" or "py" or "bash" or "sh" or "shell"
or "ruby" or "rb" or "yaml" or "yml" or "toml" or "powershell" or "ps1";
foreach (Match m in SyntaxPattern.Matches(code))
{
if (m.Groups[1].Success) // comment
{
var commentText = m.Groups[1].Value;
// # 주석은 해당 언어에서만 적용
if (commentText.StartsWith('#') && !isCommentHash)
{
// # 이 주석이 아닌 언어: 일반 텍스트로 처리
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
}
else
{
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
}
}
else if (m.Groups[2].Success) // string
{
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
}
else if (m.Groups[3].Success) // number
{
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
}
else if (m.Groups[4].Success) // type
{
var word = m.Groups[4].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
}
else if (m.Groups[5].Success) // method
{
var word = m.Groups[5].Value;
if (keywords.Contains(word))
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
else
tb.Inlines.Add(new Run(word) { Foreground = MethodBrush });
}
else if (m.Groups[6].Success) // identifier
{
var word = m.Groups[6].Value;
if (keywords.Contains(word))
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
else
tb.Inlines.Add(new Run(word) { Foreground = defaultColor });
}
}
// Regex가 매치하지 못한 나머지 문자(공백, 기호 등)를 채움
// Inlines로 빌드했으므로 원본과 비교하여 누락된 부분 보완
// 대신 각 매치 사이의 간격을 처리하기 위해 인덱스 기반 접근
tb.Inlines.Clear();
int lastEnd = 0;
foreach (Match m in SyntaxPattern.Matches(code))
{
if (m.Index > lastEnd)
tb.Inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor });
inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor });
if (m.Groups[1].Success)
{
var commentText = m.Groups[1].Value;
if (commentText.StartsWith('#') && !isCommentHash)
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
inlines.Add(new Run(commentText) { Foreground = defaultColor });
else
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
}
else if (m.Groups[2].Success)
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
else if (m.Groups[3].Success)
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
else if (m.Groups[4].Success)
{
var word = m.Groups[4].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
}
else if (m.Groups[5].Success)
{
var word = m.Groups[5].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush });
inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush });
}
else if (m.Groups[6].Success)
{
var word = m.Groups[6].Value;
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor });
inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor });
}
lastEnd = m.Index + m.Length;
}
if (lastEnd < code.Length)
tb.Inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor });
inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor });
}
}