Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions src/Capacitor.App/ViewModels/ChatTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@ public IReadOnlyList<TerminalMenuChoiceViewModel> TerminalMenuChoices {
/// hides them and leaves the hint — a greyed empty box is the wrong affordance.
public bool ShowsComposer => _showsComposer.Value;

readonly ObservableAsPropertyHelper<bool> _showsInterrupt;
public bool ShowsInterrupt => _showsInterrupt.Value;

string _vendor = "";
IReadOnlyList<HarnessOption> _options = HostedHarnessCatalog.Build(null);

Expand Down Expand Up @@ -670,6 +673,18 @@ public ChatTabViewModel(
})
.DisposeWith(_disposables);

_showsInterrupt = Observable.CombineLatest(
_input.WhenAnyValue(i => i.CanInterrupt),
this.WhenAnyValue(x => x.IsReadOnlyParticipant),
this.WhenAnyValue(x => x.HasTerminalMenu),
this.WhenAnyValue(x => x.AgentStatus),
(can, readOnly, menu, status) => can && !readOnly && !menu
&& (status.Kind == AgentStatusKind.Working || status.IsRunningUnknownTurn))
.ToProperty(this, x => x.ShowsInterrupt,
initialValue: _input.CanInterrupt && !IsReadOnlyParticipant && !HasTerminalMenu
&& (AgentStatus.Kind == AgentStatusKind.Working || AgentStatus.IsRunningUnknownTurn))
.DisposeWith(_disposables);

// The banner carries the read-only explanation for a flow participant, so the hint
// goes blank there instead of offering a reply that can never be sent.
_composerHint = Observable.CombineLatest(
Expand Down Expand Up @@ -783,11 +798,8 @@ public ChatTabViewModel(
});
_disposables.Add(SendQueuedCommand);

var canInterrupt = Observable.CombineLatest(
_input.WhenAnyValue(i => i.CanInterrupt),
this.WhenAnyValue(x => x.IsReadOnlyParticipant),
(can, readOnly) => can && !readOnly);
InterruptCommand = ReactiveCommand.CreateFromTask(() => _input.InterruptAsync(_lifetimeToken), canInterrupt);
InterruptCommand = ReactiveCommand.CreateFromTask(
() => _input.InterruptAsync(_lifetimeToken), this.WhenAnyValue(x => x.ShowsInterrupt));
_disposables.Add(InterruptCommand);

OpenLinkCommand = ReactiveCommand.Create<string>(url => LinkPolicy.Open(_opener, url));
Expand Down
9 changes: 7 additions & 2 deletions src/Capacitor.App/Views/ChatTabView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@
<TextBlock Text="{Binding ReadOnlyNotice, StringFormat='Read-only: {0}'}"
Foreground="{StaticResource KcapWarningBrush}" TextWrapping="Wrap" />
</Border>
<Grid ColumnDefinitions="Auto,*,Auto">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!-- The hint exists only while the gate is shut, which is exactly when a tooltip
is suppressed by default. -->
<Button x:Name="AttachButton" IsVisible="{Binding ShowsComposer}"
Expand All @@ -447,7 +447,12 @@
</Button>
<TextBlock Grid.Column="1" Text="{Binding ComposerHint}" FontSize="12.5" Foreground="{StaticResource KcapFaintBrush}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<Button x:Name="SendButton" Grid.Column="2" Classes="kcapPrimary" Content="Send" Command="{Binding SendCommand}" Margin="12,0,0,0"
<Button x:Name="InterruptButton" Grid.Column="2" Classes="kcapChip" Content="Interrupt (Esc)"
Command="{Binding InterruptCommand}" IsVisible="{Binding ShowsInterrupt}"
ToolTip.Tip="Interrupt the current turn without ending the session" Margin="12,0,0,0"
BorderThickness="1" Height="24" Padding="10,0" VerticalContentAlignment="Center"
FontSize="13" FontWeight="SemiBold" CornerRadius="7" Cursor="Hand" />
<Button x:Name="SendButton" Grid.Column="3" Classes="kcapPrimary" Content="Send" Command="{Binding SendCommand}" Margin="8,0,0,0"
Height="24" Padding="12,0" VerticalContentAlignment="Center" FontSize="13" FontWeight="SemiBold" CornerRadius="7"
Background="{StaticResource KcapPrimaryBrush}" Foreground="{StaticResource KcapOnPrimaryBrush}"
IsVisible="{Binding ShowsComposer}" />
Expand Down
15 changes: 13 additions & 2 deletions src/Capacitor.App/Views/ChatTabView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -418,8 +418,7 @@ bool ListTextBoxOwnsFocus() {
void OnComposerKeyDown(object? sender, KeyEventArgs e) {
if (e.Key == Key.Escape && e.KeyModifiers == KeyModifiers.None) {
e.Handled = true;
if (DataContext is ChatTabViewModel chat && ((ICommand)chat.InterruptCommand).CanExecute(null))
chat.InterruptCommand.Execute().Subscribe();
TryInterrupt();
return;
}
if (e.Key is Key.Up or Key.Down && e.KeyModifiers == KeyModifiers.None) {
Expand All @@ -437,5 +436,17 @@ void OnComposerKeyDown(object? sender, KeyEventArgs e) {
vm.SendCommand.Execute().Subscribe();
}

protected override void OnKeyDown(KeyEventArgs e) {
base.OnKeyDown(e);
if (e.Handled || e.Key != Key.Escape || e.KeyModifiers != KeyModifiers.None || OriginatesFromPendingCard(e)) return;
if (TryInterrupt()) e.Handled = true;
}

bool TryInterrupt() {
if (DataContext is not ChatTabViewModel chat || !((ICommand)chat.InterruptCommand).CanExecute(null)) return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
chat.InterruptCommand.Execute().Subscribe();
return true;
}

public void FocusComposer() => ComposerInput.Focus();
}
124 changes: 122 additions & 2 deletions test/Capacitor.App.Tests.Unit/ChatComposerTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System.Reactive.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using Capacitor.App.Services;
using Capacitor.App.ViewModels;
using Capacitor.App.Views;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.LocalIpc;
using DynamicData;
Expand All @@ -13,14 +16,14 @@ namespace Capacitor.App.Tests.Unit;

public class ChatComposerTests {
static async Task<(FakeDaemonClientService Daemon, FakeTimeProvider Time, TerminalTabViewModel Terminal, ChatTabViewModel Chat, FakeTerminalAttachClient Client, RecordingOpener Opener)>
BuildAttachedAsync() {
BuildAttachedAsync(FakePermissionService? permissions = null) {
var daemon = new FakeDaemonClientService();
var factory = new FakeTerminalAttachClientFactory();
var time = new FakeTimeProvider();
var opener = new RecordingOpener();
var terminal = new TerminalTabViewModel("a1", daemon, factory.Factory, () => new FakeTerminalSurface(), time);
var chat = new ChatTabViewModel(
"a1", daemon, new TerminalChatInput(terminal, "a1", daemon, new ScriptedLocalControlOps(), Observable.Never<AgentPresence>()), new NoAttachmentUploader(), TranscriptChat.For("claude"), opener, time, new FakePermissionService(), new SessionRuns(time));
"a1", daemon, new TerminalChatInput(terminal, "a1", daemon, new ScriptedLocalControlOps(), Observable.Never<AgentPresence>()), new NoAttachmentUploader(), TranscriptChat.For("claude"), opener, time, permissions ?? new FakePermissionService(), new SessionRuns(time));
daemon.SnapshotsSubject.OnNext(FakeDaemonClientService.Snap(supportedVendors: ["claude", "codex"]));
daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true, repoPath: "/repo", model: "claude-opus-5") with { Status = "Running" });
// The Avalonia scheduler always posts, even when the caller is already on the UI thread,
Expand Down Expand Up @@ -94,9 +97,13 @@ await RunOnUiAsync(async () => {
await Assert.That(chat.VendorLabel).IsEqualTo("Claude Code");
await Assert.That(chat.ComposerHint).IsEqualTo("Enter sends · Shift+Enter for a new line");

daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true) with { Status = "Running", AwaitingInput = false });
await Assert.That(chat.ShowsInterrupt).IsTrue();
client.Result.SetResult(new AttachOutcome.Detached());
await terminal.CurrentRunForTesting!;
await Assert.That(chat.ComposerHint).IsEqualTo("Reattach the terminal to send");
await Assert.That(chat.ShowsInterrupt).IsFalse();
await Assert.That(await chat.InterruptCommand.CanExecute.FirstAsync()).IsFalse();

// Only the run's own Exited/Failed verdict outranks a cache removal, so a removal after
// a detach lands SessionEnded — and the hint follows the terminal there too.
Expand Down Expand Up @@ -233,4 +240,117 @@ public async Task A_pool_thread_state_flip_updates_the_hint_on_the_ui_thread() {
});
await Assert.That(onUi).IsTrue();
}

[Test]
[NotInParallel("AvaloniaSession")]
public async Task Working_turn_surfaces_interrupt_and_escape_from_chat_list_preserves_the_draft() {
await RunOnUiAsync(async () => {
var (daemon, _, _, chat, client, _) = await BuildAttachedAsync();
daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true, repoPath: "/repo") with { Status = "Running", AwaitingInput = true });
var view = new ChatTabView { DataContext = chat };
var window = new Window { Content = view, Width = 550, Height = 600 };
try {
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();

var interruptButton = view.FindControl<Button>("InterruptButton")!;
var chatItems = view.FindControl<ItemsControl>("ChatItems")!;

await Assert.That(chat.ShowsInterrupt).IsFalse();
await Assert.That(interruptButton.IsEffectivelyVisible).IsFalse();
await Assert.That(chat.ComposerHint).IsEqualTo("Enter sends · Shift+Enter for a new line");

daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true, repoPath: "/repo") with { Status = "Running", AwaitingInput = false });
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();

await Assert.That(chat.ShowsInterrupt).IsTrue();
await Assert.That(interruptButton.IsEffectivelyVisible).IsTrue();
await Assert.That(await chat.InterruptCommand.CanExecute.FirstAsync()).IsTrue();
chat.ComposerText = "keep this draft";
chatItems.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Escape, KeyModifiers = KeyModifiers.Control });
await Assert.That(client.SentInput).IsEmpty();

chatItems.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Escape });
await WaitUntilAsync(() => client.SentInput.Count == 1, what: "escape sent from chat list");
await Assert.That(client.SentInput[0]).IsEquivalentTo(new byte[] { 0x1b });
await Assert.That(chat.ComposerText).IsEqualTo("keep this draft");

daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true, repoPath: "/repo") with { Status = "Running", AwaitingInput = true });
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();

await Assert.That(chat.ShowsInterrupt).IsFalse();
await Assert.That(interruptButton.IsEffectivelyVisible).IsFalse();
await Assert.That(chat.ComposerHint).IsEqualTo("Enter sends · Shift+Enter for a new line");

} finally {
window.Close();
Dispatcher.UIThread.RunJobs();
await chat.TeardownAsync();
}
});
}

[Test]
[Arguments(false)]
[Arguments(true)]
[NotInParallel("AvaloniaSession")]
public async Task Escape_does_not_interrupt_a_turn_awaiting_input(bool composer) {
await RunOnUiAsync(async () => {
var (daemon, _, _, chat, client, _) = await BuildAttachedAsync();
var view = new ChatTabView { DataContext = chat };
var window = new Window { Content = view, Width = 800, Height = 600 };
try {
daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true, repoPath: "/repo") with { Status = "Running", AwaitingInput = true });
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
await Assert.That(chat.ShowsInterrupt).IsFalse();
await Assert.That(await chat.InterruptCommand.CanExecute.FirstAsync()).IsFalse();
chat.ComposerText = "keep this draft";
var source = view.FindControl<Control>(composer ? "ComposerInput" : "ChatItems")!;
source.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Escape });
Dispatcher.UIThread.RunJobs();
await Assert.That(client.SentInput).IsEmpty();
await Assert.That(chat.ComposerText).IsEqualTo("keep this draft");
} finally {
window.Close();
Dispatcher.UIThread.RunJobs();
await chat.TeardownAsync();
}
});
}

[Test]
[Arguments("permission")]
[Arguments("question")]
[Arguments("menu")]
[Arguments("limit")]
[NotInParallel("AvaloniaSession")]
public async Task Interrupt_is_unavailable_while_a_prompt_or_menu_needs_a_response(string prompt) {
await RunOnUiAsync(async () => {
var permissions = new FakePermissionService();
var (daemon, _, _, chat, _, _) = await BuildAttachedAsync(permissions);
try {
var agent = Agent("a1", "claude", hasTerminal: true) with { Status = "Running", AwaitingInput = false };
daemon.Agents.AddOrUpdate(agent);
await Assert.That(chat.ShowsInterrupt).IsTrue();
switch (prompt) {
case "permission": permissions.Add(PermissionEntries.Entry("r1", "a1")); break;
case "question": permissions.Add(PermissionEntries.Question("r1")); break;
case "menu":
daemon.Agents.AddOrUpdate(agent with { TerminalDialog = new TerminalDialogDto("Continue?", "", ["Yes", "No"], 0, "") });
break;
case "limit":
daemon.Agents.AddOrUpdate(agent with { UsageLimit = new UsageLimitNoticeDto(UsageLimitKinds.Blocked, "Usage limit", "Continue?", [new(1, "Wait")]) });
break;
}
Dispatcher.UIThread.RunJobs();
await Assert.That(chat.ShowsInterrupt).IsFalse();
await Assert.That(await chat.InterruptCommand.CanExecute.FirstAsync()).IsFalse();
} finally { await chat.TeardownAsync(); }
});
}
}
31 changes: 21 additions & 10 deletions test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -693,18 +693,29 @@ await Assert.That(client.SentInput.Select(Encoding.UTF8.GetString))
}

[Test]
[Arguments(false)]
[Arguments(true)]
[NotInParallel("AvaloniaSession")]
public async Task Escape_from_the_composer_sends_a_control_key_and_preserves_the_draft() {
public async Task Interrupt_from_the_button_or_composer_preserves_the_draft_and_staged_files(bool button) {
await RunOnUiAsync(async () => {
var host = new Host();
var client = await host.AttachAsync(Tmp.CreateFile("escape.jsonl", [UserLine]));
host.Type("keep this draft");
host.Window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
host.Settle();
await Assert.That(client.SentInput).Count().IsEqualTo(1);
await Assert.That(client.SentInput[0]).IsEquivalentTo(new byte[] { 0x1b });
await Assert.That(host.Composer.Text).IsEqualTo("keep this draft");
await host.CloseAsync();
var host = new Host(show: false);
host.Window.Width = 550;
host.Show();
try {
var path = Tmp.CreateFile("escape.jsonl", [UserLine]);
var client = await host.AttachAsync(path);
host.Daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true) with { TranscriptPath = path, Status = "Running", AwaitingInput = false });
host.Type("keep this draft");
host.Chat.Tray.AddAll([new StagedAttachment("notes.txt", "text/plain", new byte[] { 1 })]);
if (button) Click(host, host.View.FindControl<Button>("InterruptButton")!);
else host.Window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
host.Settle();
await Assert.That(client.SentInput).Count().IsEqualTo(1);
await Assert.That(client.SentInput[0]).IsEquivalentTo(new byte[] { 0x1b });
await Assert.That(host.Composer.Text).IsEqualTo("keep this draft");
await Assert.That(host.Chat.Tray.Items.Select(f => f.FileName)).IsEquivalentTo(["notes.txt"]);
await Assert.That(host.Chat.ShowsComposer).IsTrue();
} finally { await host.CloseAsync(); }
});
}

Expand Down
Loading
Loading