Skip to content

Commit 094f204

Browse files
committed
feat(ui): add diff output mode for AI actions
Add a new output mode that displays AI-generated changes in a side-by-side diff window, allowing users to accept, reject, or refine the result. - Adding DiffPlex package and related DTOs - Implementing DiffViewWindow and DiffViewModel - Updating action orchestration and UI components - Adding new event for diff view requests
1 parent 18beaed commit 094f204

13 files changed

Lines changed: 653 additions & 109 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using ProseFlow.Core.Enums;
2+
using Action = ProseFlow.Core.Models.Action;
3+
4+
namespace ProseFlow.Application.DTOs;
5+
6+
/// <summary>
7+
/// A request to execute a specific action with potential overrides.
8+
/// </summary>
9+
/// <param name="ActionToExecute">The action chosen by the user.</param>
10+
/// <param name="Mode">The desired output mode for the result (e.g., InPlace, Windowed, Diff).</param>
11+
/// <param name="ProviderOverride">Optional provider name to override the default for this single execution.</param>
12+
public record ActionExecutionRequest(Action ActionToExecute, OutputMode Mode, string? ProviderOverride);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace ProseFlow.Application.DTOs;
2+
3+
/// <summary>
4+
/// A Data Transfer Object to carry all necessary information for displaying the Diff View Window.
5+
/// </summary>
6+
/// <param name="ActionName">The name of the action performed, for the window title.</param>
7+
/// <param name="OriginalText">The user's original, selected text.</param>
8+
/// <param name="GeneratedText">The AI-generated text to be compared.</param>
9+
public record DiffViewData(string ActionName, string OriginalText, string GeneratedText);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace ProseFlow.Application.DTOs;
2+
3+
/// <summary>
4+
/// Represents the user's decision from the Diff View window.
5+
/// This is a discriminated union pattern.
6+
/// </summary>
7+
public abstract record DiffViewResult;
8+
9+
/// <summary>
10+
/// The user accepted the changes. The new text should be pasted.
11+
/// </summary>
12+
public record Accepted(string NewText) : DiffViewResult;
13+
14+
/// <summary>
15+
/// The user wants to refine the output with a new instruction.
16+
/// </summary>
17+
public record Refined(string RefinementInstruction) : DiffViewResult;
18+
19+
/// <summary>
20+
/// The user wants to re-run the original action to get a new suggestion.
21+
/// </summary>
22+
public record Regenerated : DiffViewResult;
23+
24+
/// <summary>
25+
/// The user closed the window or cancelled the operation.
26+
/// </summary>
27+
public record Cancelled : DiffViewResult;

ProseFlow.Application/Events/AppEvents.cs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,6 @@
33

44
namespace ProseFlow.Application.Events;
55

6-
/// <summary>
7-
/// A request to execute a specific action with potential overrides.
8-
/// </summary>
9-
/// <param name="ActionToExecute">The action chosen by the user.</param>
10-
/// <param name="ForceOpenInWindow">Whether the user overrode the default behavior to force opening a new window.</param>
11-
/// <param name="ProviderOverride">Optional provider name to override the default for this single execution.</param>
12-
public record ActionExecutionRequest(Action ActionToExecute, bool ForceOpenInWindow, string? ProviderOverride);
13-
146
public enum NotificationType { Info, Success, Warning, Error }
157

168
public static class AppEvents
@@ -72,7 +64,26 @@ public static class AppEvents
7264
return ShowResultWindowAndAwaitRefinement is not null
7365
? await ShowResultWindowAndAwaitRefinement.Invoke(data)
7466
: await Task.FromResult<RefinementRequest?>(null);
75-
// Graceful failure
67+
}
68+
69+
/// <summary>
70+
/// Raised when a diff view needs to be displayed.
71+
/// The UI subscribes, shows the diff window, and returns a task that completes
72+
/// with the user's decision (Accept, Refine, Regenerate, or Cancel).
73+
/// </summary>
74+
public static event Func<DiffViewData, Task<DiffViewResult?>>? ShowDiffViewRequested;
75+
76+
/// <summary>
77+
/// Invokes the event to show the diff view window and waits for user interaction.
78+
/// </summary>
79+
/// <returns>A DiffViewResult representing the user's choice, or null if the UI handler isn't attached.</returns>
80+
public static async Task<DiffViewResult?> RequestDiffViewAsync(DiffViewData data)
81+
{
82+
if (!IsShowResultWindowEnabled) return null;
83+
84+
return ShowDiffViewRequested is not null
85+
? await ShowDiffViewRequested.Invoke(data)
86+
: await Task.FromResult<DiffViewResult?>(null);
7687
}
7788

7889
/// <summary>

ProseFlow.Application/Services/ActionOrchestrationService.cs

Lines changed: 146 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ private async Task HandleSmartPasteHotkeyAsync()
9191
return;
9292
}
9393

94-
var request = new ActionExecutionRequest(result.Action, result.Action.OpenInWindow, null);
94+
var request = new ActionExecutionRequest(result.Action, OutputMode.InPlace, null); // Smart Paste is always InPlace
9595
await ProcessRequestAsync(request);
9696
}
9797

@@ -122,84 +122,21 @@ private async Task ProcessRequestAsync(ActionExecutionRequest request)
122122
conversationHistory.Add(new ChatMessage("system", systemInstruction));
123123
conversationHistory.Add(new ChatMessage("user", $"{request.ActionToExecute.Prefix}{userInput}"));
124124

125-
if (request.ForceOpenInWindow || request.ActionToExecute.OpenInWindow)
126-
{
127-
// Windowed processing loop
128-
while (true)
129-
{
130-
var executionResult = await ExecuteRequestWithFallbackAsync(conversationHistory, request.ProviderOverride, localSessionId);
131-
132-
if (executionResult is null)
133-
{
134-
// Both primary and fallback failed, or no providers are configured.
135-
AppEvents.RequestNotification("All available AI providers failed.", NotificationType.Error);
136-
break;
137-
}
138-
139-
if (executionResult.Value.Provider.Type == ProviderType.Local && localSessionId is null)
140-
{
141-
// If this is the first turn in a windowed local session. Create a new session.
142-
localSessionId = _localSessionService.StartSession();
143-
if (localSessionId is null)
144-
{
145-
AppEvents.RequestNotification("Failed to start a local model session.", NotificationType.Error);
146-
return;
147-
}
148-
}
149-
150-
var (aiResponse, provider, latencyMs) = executionResult.Value;
151-
conversationHistory.Add(new ChatMessage("assistant", aiResponse.Content));
125+
var outputMode = request.Mode == OutputMode.Default
126+
? (request.ActionToExecute.OpenInWindow ? OutputMode.Windowed : OutputMode.InPlace)
127+
: request.Mode;
152128

153-
// Log to DB
154-
await LogToHistoryAsync(
155-
request.ActionToExecute.Name,
156-
provider.Name,
157-
aiResponse.ProviderName,
158-
conversationHistory.Last(m => m.Role == "user").Content,
159-
aiResponse.Content,
160-
aiResponse.PromptTokens,
161-
aiResponse.CompletionTokens,
162-
latencyMs,
163-
aiResponse.TokensPerSecond);
164-
165-
// Parse and show the result window
166-
var (mainOutput, explanation) = ParseOutput(aiResponse.Content, request.ActionToExecute.ExplainChanges);
167-
168-
// Show the window and wait for the user to either close it or request a refinement
169-
var windowData = new ResultWindowData(request.ActionToExecute.Name, mainOutput, explanation);
170-
var refinementRequest = await AppEvents.RequestResultWindowAsync(windowData);
171-
172-
if (refinementRequest is null)
173-
break; // User closed the window
174-
175-
conversationHistory.Add(new ChatMessage("user", refinementRequest.NewInstruction));
176-
}
177-
}
178-
else
129+
switch (outputMode)
179130
{
180-
// In-Place execution
181-
var executionResult = await ExecuteRequestWithFallbackAsync(conversationHistory, request.ProviderOverride);
182-
183-
if (executionResult is null)
184-
{
185-
AppEvents.RequestNotification("All available AI providers failed.", NotificationType.Error);
186-
return;
187-
}
188-
189-
var (aiResponse, provider, latencyMs) = executionResult.Value;
190-
191-
await LogToHistoryAsync(
192-
request.ActionToExecute.Name,
193-
provider.Name,
194-
aiResponse.ProviderName,
195-
conversationHistory.Last(m => m.Role == "user").Content,
196-
aiResponse.Content,
197-
aiResponse.PromptTokens,
198-
aiResponse.CompletionTokens,
199-
latencyMs,
200-
aiResponse.TokensPerSecond);
201-
202-
await _clipboardService.PasteTextAsync(aiResponse.Content);
131+
case OutputMode.Windowed:
132+
localSessionId = await HandleWindowedModeAsync(request, conversationHistory, localSessionId);
133+
break;
134+
case OutputMode.InPlace:
135+
await HandleInPlaceModeAsync(request, conversationHistory);
136+
break;
137+
case OutputMode.Diff:
138+
localSessionId = await HandleDiffModeAsync(request, userInput, conversationHistory, localSessionId);
139+
break;
203140
}
204141

205142
overallStopwatch.Stop();
@@ -221,6 +158,138 @@ await LogToHistoryAsync(
221158
}
222159
}
223160

161+
private async Task<Guid?> HandleWindowedModeAsync(ActionExecutionRequest request, List<ChatMessage> conversationHistory, Guid? localSessionId)
162+
{
163+
while (true)
164+
{
165+
var executionResult = await ExecuteRequestWithFallbackAsync(conversationHistory, request.ProviderOverride, localSessionId);
166+
167+
if (executionResult is null)
168+
{
169+
AppEvents.RequestNotification("All available AI providers failed.", NotificationType.Error);
170+
break;
171+
}
172+
173+
if (executionResult.Value.Provider.Type == ProviderType.Local && localSessionId is null)
174+
{
175+
localSessionId = _localSessionService.StartSession();
176+
if (localSessionId is null)
177+
{
178+
AppEvents.RequestNotification("Failed to start a local model session.", NotificationType.Error);
179+
return localSessionId;
180+
}
181+
}
182+
183+
var (aiResponse, provider, latencyMs) = executionResult.Value;
184+
conversationHistory.Add(new ChatMessage("assistant", aiResponse.Content));
185+
186+
await LogToHistoryAsync(
187+
request.ActionToExecute.Name,
188+
provider.Name,
189+
aiResponse.ProviderName,
190+
conversationHistory.Last(m => m.Role == "user").Content,
191+
aiResponse.Content,
192+
aiResponse.PromptTokens,
193+
aiResponse.CompletionTokens,
194+
latencyMs,
195+
aiResponse.TokensPerSecond);
196+
197+
var (mainOutput, explanation) = ParseOutput(aiResponse.Content, request.ActionToExecute.ExplainChanges);
198+
var windowData = new ResultWindowData(request.ActionToExecute.Name, mainOutput, explanation);
199+
var refinementRequest = await AppEvents.RequestResultWindowAsync(windowData);
200+
201+
if (refinementRequest is null) break;
202+
203+
conversationHistory.Add(new ChatMessage("user", refinementRequest.NewInstruction));
204+
}
205+
return localSessionId;
206+
}
207+
208+
private async Task HandleInPlaceModeAsync(ActionExecutionRequest request, List<ChatMessage> conversationHistory)
209+
{
210+
var executionResult = await ExecuteRequestWithFallbackAsync(conversationHistory, request.ProviderOverride);
211+
212+
if (executionResult is null)
213+
{
214+
AppEvents.RequestNotification("All available AI providers failed.", NotificationType.Error);
215+
return;
216+
}
217+
218+
var (aiResponse, provider, latencyMs) = executionResult.Value;
219+
220+
await LogToHistoryAsync(
221+
request.ActionToExecute.Name,
222+
provider.Name,
223+
aiResponse.ProviderName,
224+
conversationHistory.Last(m => m.Role == "user").Content,
225+
aiResponse.Content,
226+
aiResponse.PromptTokens,
227+
aiResponse.CompletionTokens,
228+
latencyMs,
229+
aiResponse.TokensPerSecond);
230+
231+
await _clipboardService.PasteTextAsync(aiResponse.Content);
232+
}
233+
234+
private async Task<Guid?> HandleDiffModeAsync(ActionExecutionRequest request, string originalInput, List<ChatMessage> conversationHistory, Guid? localSessionId)
235+
{
236+
while (true)
237+
{
238+
var executionResult = await ExecuteRequestWithFallbackAsync(conversationHistory, request.ProviderOverride, localSessionId);
239+
240+
if (executionResult is null)
241+
{
242+
AppEvents.RequestNotification("All available AI providers failed.", NotificationType.Error);
243+
return localSessionId;
244+
}
245+
246+
var (aiResponse, provider, latencyMs) = executionResult.Value;
247+
248+
if (provider.Type == ProviderType.Local && localSessionId is null)
249+
{
250+
localSessionId = _localSessionService.StartSession();
251+
if (localSessionId is null)
252+
{
253+
AppEvents.RequestNotification("Failed to start a local model session.", NotificationType.Error);
254+
return localSessionId;
255+
}
256+
}
257+
258+
var diffData = new DiffViewData(request.ActionToExecute.Name, originalInput, aiResponse.Content);
259+
var userDecision = await AppEvents.RequestDiffViewAsync(diffData);
260+
261+
switch (userDecision)
262+
{
263+
case Accepted accepted:
264+
await LogToHistoryAsync(
265+
request.ActionToExecute.Name,
266+
provider.Name,
267+
aiResponse.ProviderName,
268+
conversationHistory.Last(m => m.Role == "user").Content,
269+
accepted.NewText,
270+
aiResponse.PromptTokens,
271+
aiResponse.CompletionTokens,
272+
latencyMs,
273+
aiResponse.TokensPerSecond);
274+
await _clipboardService.PasteTextAsync(accepted.NewText);
275+
return localSessionId;
276+
277+
case Refined refined:
278+
// The last message in history is the assistant's previous response. Add it before the user's refinement.
279+
conversationHistory.Add(new ChatMessage("assistant", aiResponse.Content));
280+
conversationHistory.Add(new ChatMessage("user", refined.RefinementInstruction));
281+
continue;
282+
283+
case Regenerated:
284+
continue;
285+
286+
case Cancelled or null:
287+
return localSessionId;
288+
}
289+
}
290+
}
291+
292+
224293
/// <summary>
225294
/// Executes an AI request, trying the primary provider first and then the fallback provider upon failure.
226295
/// </summary>

ProseFlow.Core/Enums/OutputMode.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace ProseFlow.Core.Enums;
2+
3+
/// <summary>
4+
/// Specifies the desired output mode for an AI action.
5+
/// </summary>
6+
public enum OutputMode
7+
{
8+
/// <summary>
9+
/// The application decides whether to open a new window or replace in-place based on the action's configuration.
10+
/// </summary>
11+
Default,
12+
13+
/// <summary>
14+
/// The result should be pasted directly, replacing the selected text.
15+
/// </summary>
16+
InPlace,
17+
18+
/// <summary>
19+
/// The result should be displayed in a new, interactive window.
20+
/// </summary>
21+
Windowed,
22+
23+
/// <summary>
24+
/// The result should be displayed in a diff view window for comparison and approval.
25+
/// </summary>
26+
Diff
27+
}

ProseFlow.UI/App.axaml.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,23 @@ private void SubscribeToAppEvents()
462462
});
463463
};
464464

465+
AppEvents.ShowDiffViewRequested += data =>
466+
{
467+
return Dispatcher.UIThread.InvokeAsync(async () =>
468+
{
469+
var viewModel = new DiffViewModel(data);
470+
var window = new DiffViewWindow
471+
{
472+
DataContext = viewModel,
473+
Focusable = true,
474+
WindowStartupLocation = WindowStartupLocation.CenterScreen,
475+
WindowState = WindowState.Normal,
476+
};
477+
window.Show();
478+
return await viewModel.CompletionSource.Task;
479+
});
480+
};
481+
465482
AppEvents.ShowFloatingMenuRequested += async (actions, context) =>
466483
{
467484
var providerSettings = await Services.GetRequiredService<SettingsService>().GetProviderSettingsAsync();

0 commit comments

Comments
 (0)