AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리

- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함

- OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함

- AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함

- 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함

- README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
This commit is contained in:
2026-04-12 22:02:14 +09:00
parent b8f4df1892
commit fb0bea41f7
137 changed files with 18532 additions and 1144 deletions

View File

@@ -0,0 +1,383 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace AxCopilot.Views;
public partial class ChatWindow
{
private sealed record FileMentionQuery(string Token, int Start, int Length);
private sealed record FileMentionCandidate(string RelativePath, string FileName, int Score);
private static readonly Regex FileMentionTokenRegex = new(
"(?<token>[^\\s\\\"'`()\\[\\]{}<>]{2,})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly HashSet<string> FileMentionIgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
"packages", ".nuget", "TestResults", "coverage", ".next",
"target", ".gradle", ".cargo",
};
private readonly object _fileMentionIndexLock = new();
private List<string> _fileMentionIndexedPaths = new();
private string? _fileMentionIndexedFolder;
private DateTime _fileMentionIndexBuiltAtUtc = DateTime.MinValue;
private CancellationTokenSource? _fileMentionIndexCts;
private bool _fileMentionIndexBuildPending;
private const int FileMentionIndexLimit = 4000;
private void RefreshFileMentionSuggestions(string text)
{
if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(GetCurrentWorkFolder()))
{
HideFileMentionSuggestions();
return;
}
if (SlashPopup?.IsOpen == true && text.StartsWith("/") && !text.Contains(' '))
{
HideFileMentionSuggestions();
return;
}
var query = TryExtractFileMentionQuery(text);
if (query == null)
{
HideFileMentionSuggestions();
return;
}
var workFolder = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
{
HideFileMentionSuggestions();
return;
}
List<string> indexSnapshot;
bool canUseCurrentIndex;
lock (_fileMentionIndexLock)
{
canUseCurrentIndex =
string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase)
&& (DateTime.UtcNow - _fileMentionIndexBuiltAtUtc) < TimeSpan.FromMinutes(3)
&& _fileMentionIndexedPaths.Count > 0;
indexSnapshot = canUseCurrentIndex ? new List<string>(_fileMentionIndexedPaths) : [];
}
if (!canUseCurrentIndex)
{
RenderFileMentionSuggestions(query.Token, [], isLoading: true);
EnsureFileMentionIndexAsync(workFolder);
return;
}
var candidates = FindFileMentionCandidates(indexSnapshot, query.Token);
RenderFileMentionSuggestions(query.Token, candidates, isLoading: false);
}
private void EnsureFileMentionIndexAsync(string workFolder)
{
if (_fileMentionIndexBuildPending
&& string.Equals(_fileMentionIndexedFolder, workFolder, StringComparison.OrdinalIgnoreCase))
return;
_fileMentionIndexCts?.Cancel();
_fileMentionIndexCts = new CancellationTokenSource();
var localCts = _fileMentionIndexCts;
_fileMentionIndexBuildPending = true;
_fileMentionIndexedFolder = workFolder;
_ = Task.Run(() =>
{
var indexedPaths = BuildFileMentionIndex(workFolder, localCts.Token);
if (localCts.IsCancellationRequested)
return;
lock (_fileMentionIndexLock)
{
_fileMentionIndexedPaths = indexedPaths;
_fileMentionIndexedFolder = workFolder;
_fileMentionIndexBuiltAtUtc = DateTime.UtcNow;
}
Dispatcher?.Invoke(() =>
{
_fileMentionIndexBuildPending = false;
RefreshFileMentionSuggestions(InputBox?.Text ?? "");
});
}, localCts.Token).ContinueWith(_ =>
{
Dispatcher?.Invoke(() => { _fileMentionIndexBuildPending = false; });
}, TaskScheduler.Default);
}
private static List<string> BuildFileMentionIndex(string workFolder, CancellationToken ct)
{
var results = new List<string>(capacity: 512);
var pending = new Stack<string>();
pending.Push(workFolder);
while (pending.Count > 0 && results.Count < FileMentionIndexLimit)
{
ct.ThrowIfCancellationRequested();
var current = pending.Pop();
try
{
foreach (var dir in Directory.EnumerateDirectories(current))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(dir);
if (string.IsNullOrWhiteSpace(name)
|| name.StartsWith('.')
|| FileMentionIgnoredDirs.Contains(name))
continue;
pending.Push(dir);
}
foreach (var file in Directory.EnumerateFiles(current))
{
ct.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(file);
if (string.IsNullOrWhiteSpace(fileName) || fileName.StartsWith('.'))
continue;
results.Add(Path.GetRelativePath(workFolder, file));
if (results.Count >= FileMentionIndexLimit)
break;
}
}
catch
{
}
}
return results;
}
private static FileMentionQuery? TryExtractFileMentionQuery(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var trimmedEnd = text.TrimEnd();
if (trimmedEnd.Length < 2)
return null;
var match = FileMentionTokenRegex.Match(trimmedEnd);
if (!match.Success)
return null;
var token = match.Groups["token"].Value.Trim();
if (token.Length < 2)
return null;
var looksFileLike = token.Contains('.')
|| token.Contains('/')
|| token.Contains('\\')
|| token.Contains('_')
|| token.Contains('-');
if (!looksFileLike)
{
var lower = trimmedEnd.ToLowerInvariant();
var fileContext = lower.Contains("파일")
|| lower.Contains("문서")
|| lower.Contains("폴더")
|| lower.Contains("file")
|| lower.Contains("document")
|| lower.Contains("folder")
|| lower.Contains("read ")
|| lower.Contains("open ");
if (!fileContext)
return null;
}
return new FileMentionQuery(
token,
match.Groups["token"].Index,
match.Groups["token"].Length);
}
private static List<FileMentionCandidate> FindFileMentionCandidates(
IReadOnlyCollection<string> indexedPaths,
string query)
{
if (indexedPaths.Count == 0 || string.IsNullOrWhiteSpace(query))
return [];
var normalizedQuery = query.Replace('\\', '/').Trim().ToLowerInvariant();
var queryFileName = Path.GetFileName(normalizedQuery);
var candidates = new List<FileMentionCandidate>();
foreach (var relativePath in indexedPaths)
{
var normalizedPath = relativePath.Replace('\\', '/');
var lowerPath = normalizedPath.ToLowerInvariant();
var fileName = Path.GetFileName(normalizedPath);
var lowerFileName = fileName.ToLowerInvariant();
if (!lowerPath.Contains(normalizedQuery) && !lowerFileName.Contains(queryFileName))
continue;
var score = 0;
if (string.Equals(lowerFileName, queryFileName, StringComparison.Ordinal))
score += 600;
else if (lowerFileName.StartsWith(queryFileName, StringComparison.Ordinal))
score += 420;
else if (lowerFileName.Contains(queryFileName, StringComparison.Ordinal))
score += 260;
if (string.Equals(lowerPath, normalizedQuery, StringComparison.Ordinal))
score += 520;
else if (lowerPath.EndsWith(normalizedQuery, StringComparison.Ordinal))
score += 300;
else if (lowerPath.Contains(normalizedQuery, StringComparison.Ordinal))
score += 140;
score += Math.Max(0, 80 - normalizedPath.Length);
candidates.Add(new FileMentionCandidate(normalizedPath, fileName, score));
}
return candidates
.OrderByDescending(c => c.Score)
.ThenBy(c => c.RelativePath.Length)
.ThenBy(c => c.RelativePath, StringComparer.OrdinalIgnoreCase)
.Take(6)
.ToList();
}
private void RenderFileMentionSuggestions(
string query,
IReadOnlyList<FileMentionCandidate> candidates,
bool isLoading)
{
if (FileMentionSuggestionCard == null
|| FileMentionSuggestionTitle == null
|| FileMentionSuggestionChipPanel == null)
return;
FileMentionSuggestionChipPanel.Children.Clear();
if (isLoading)
{
FileMentionSuggestionTitle.Text = $"파일 후보 찾는 중 · {query}";
FileMentionSuggestionCard.Visibility = Visibility.Visible;
return;
}
if (candidates.Count == 0)
{
HideFileMentionSuggestions();
return;
}
FileMentionSuggestionTitle.Text = $"파일 후보 · {query}";
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? itemBg;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? primaryText;
foreach (var candidate in candidates)
{
var chip = new Border
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
Tag = candidate.RelativePath,
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = candidate.FileName,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
stack.Children.Add(new TextBlock
{
Text = candidate.RelativePath,
Margin = new Thickness(0, 2, 0, 0),
FontSize = 10.5,
Foreground = secondaryText,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = 240,
});
chip.Child = stack;
chip.MouseEnter += (_, _) => chip.Background = hoverBg;
chip.MouseLeave += (_, _) => chip.Background = itemBg;
chip.MouseLeftButtonUp += (_, _) => ApplyFileMentionSuggestion(candidate.RelativePath);
FileMentionSuggestionChipPanel.Children.Add(chip);
}
FileMentionSuggestionCard.Visibility = Visibility.Visible;
}
private void HideFileMentionSuggestions()
{
if (FileMentionSuggestionCard == null || FileMentionSuggestionChipPanel == null)
return;
FileMentionSuggestionChipPanel.Children.Clear();
FileMentionSuggestionCard.Visibility = Visibility.Collapsed;
}
private void ApplyFileMentionSuggestion(string relativePath)
{
if (InputBox == null || string.IsNullOrWhiteSpace(relativePath))
return;
var text = InputBox.Text ?? "";
var query = TryExtractFileMentionQuery(text);
var replacement = relativePath.Replace('\\', '/');
if (query != null)
{
text = text[..query.Start] + replacement + text[(query.Start + query.Length)..];
}
else
{
text = string.IsNullOrWhiteSpace(text) ? replacement : $"{text.TrimEnd()} {replacement}";
}
InputBox.Text = text;
InputBox.CaretIndex = text.Length;
InputBox.Focus();
HideFileMentionSuggestions();
}
private bool TryAcceptTopFileMentionSuggestion()
{
if (FileMentionSuggestionCard?.Visibility != Visibility.Visible
|| FileMentionSuggestionChipPanel == null
|| FileMentionSuggestionChipPanel.Children.Count == 0)
return false;
if (FileMentionSuggestionChipPanel.Children[0] is not Border firstChip
|| firstChip.Tag is not string relativePath)
return false;
ApplyFileMentionSuggestion(relativePath);
return true;
}
}