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:
383
src/AxCopilot/Views/ChatWindow.FileMentionSuggestions.cs
Normal file
383
src/AxCopilot/Views/ChatWindow.FileMentionSuggestions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user