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
9 changes: 7 additions & 2 deletions src/Capacitor.App/ViewModels/ChatInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ public abstract class ChatInput : ReactiveObject, IDisposable {
public abstract SendAvailability Availability { get; }
public abstract bool CanAcceptText { get; }
public abstract string Hint { get; }
/// Completes when the channel considers the text committed: true to clear the composer.
public abstract Task<bool> SendAsync(string text, CancellationToken ct);
/// Acceptance may clear the composer; an unconfirmed delivery must await transcript evidence.
public abstract Task<ChatSendOutcome> SendAsync(string text, CancellationToken ct);
/// The caller has transcript evidence for its latest send. Older receipts must not clear a
/// newer send's delivery notice.
public virtual void ConfirmLastSend() { }
public virtual bool CanInterrupt => false;
public virtual Task InterruptAsync(CancellationToken ct) => Task.CompletedTask;
public abstract void Dispose();
}
3 changes: 3 additions & 0 deletions src/Capacitor.App/ViewModels/ChatSendOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Capacitor.App.ViewModels;

public enum ChatSendOutcome { Rejected, Accepted, Unconfirmed }
233 changes: 168 additions & 65 deletions src/Capacitor.App/ViewModels/ChatTabViewModel.cs

Large diffs are not rendered by default.

27 changes: 17 additions & 10 deletions src/Capacitor.App/ViewModels/LocalFrameChatInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,20 @@ void ApplyPresence(AgentPresence p) {
_ => "Connecting to the agent…",
};

public override async Task<bool> SendAsync(string text, CancellationToken ct) {
if (_disposed || !CanAcceptText) return false;
public override async Task<ChatSendOutcome> SendAsync(string text, CancellationToken ct) {
if (_disposed || !CanAcceptText || ct.IsCancellationRequested) return ChatSendOutcome.Rejected;
_sending = true; _notice = null; Raise();
SendTextResult result;
try {
result = await _ops.SendTextAsync(_agentId, text, ct);
} catch (OperationCanceledException) {
return Settle(false, Unconfirmed);
} catch (Exception ex) {
return Settle(false, ex.Message);
return Settle(ChatSendOutcome.Unconfirmed, Unconfirmed);
} catch (Exception) {
return Settle(ChatSendOutcome.Unconfirmed, Unconfirmed);
}
if (result.Ok) return Settle(true, null);
return Settle(false, result.Reason switch {
if (result.Ok) return Settle(ChatSendOutcome.Accepted, null);
var outcome = result.Reason == SendTextReasons.Transport ? ChatSendOutcome.Unconfirmed : ChatSendOutcome.Rejected;
return Settle(outcome, result.Reason switch {
SendTextReasons.Transport => Unconfirmed,
SendTextReasons.NotRunning => "agent is no longer running",
SendTextReasons.NoSuchAgent => "agent is no longer running",
Expand All @@ -90,10 +91,16 @@ public override async Task<bool> SendAsync(string text, CancellationToken ct) {
});
}

bool Settle(bool committed, string? notice) {
if (_disposed) return committed;
ChatSendOutcome Settle(ChatSendOutcome outcome, string? notice) {
if (_disposed) return outcome;
_sending = false; _notice = notice; Raise();
return committed;
return outcome;
}

public override void ConfirmLastSend() {
if (_disposed || _sending || _notice != Unconfirmed) return;
_notice = null;
Raise();
}

void Raise() {
Expand Down
33 changes: 33 additions & 0 deletions src/Capacitor.App/ViewModels/QueuedChatMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using ReactiveUI.Reactive;

namespace Capacitor.App.ViewModels;

/// An outgoing message stays here until the transcript acknowledges it. Transport acceptance
/// alone does not mean the runtime has started consuming a prompt queued behind its current turn.
public sealed class QueuedChatMessage(string text, int composerEdits, int generation, long? offset) : ReactiveObject {
public string Text { get; } = text;
internal int ComposerEdits { get; } = composerEdits;
internal bool Acknowledged { get; set; }
int _generation = generation;
long? _offset = offset;
internal bool HasBaseline => _offset.HasValue;

bool _isUnconfirmed;
public bool IsUnconfirmed { get => _isUnconfirmed; private set => this.RaiseAndSetIfChanged(ref _isUnconfirmed, value); }

internal void MarkUnconfirmed() => IsUnconfirmed = true;

/// A new transcript may contain either replayed history or the lost echo. Neither is safe
/// evidence. Retain the message, then allow only subsequent appends in this generation.
internal void Rebase(int generation, long? offset) {
_generation = generation;
_offset = offset;
MarkUnconfirmed();
}

internal bool Matches(string text, int generation, long offset) =>
_generation == generation && _offset is { } baseline && offset >= baseline
&& Normalize(Text) == Normalize(text);

static string Normalize(string text) => text.Replace("\r\n", "\n").Trim();
}
7 changes: 7 additions & 0 deletions src/Capacitor.App/ViewModels/RailSessionViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public sealed class RailSessionViewModel : ReactiveObject, IDisposable {
readonly ObservableAsPropertyHelper<bool> _needsYou;
public bool NeedsYou => _needsYou.Value;

readonly ObservableAsPropertyHelper<string> _statusBadge;
public string StatusBadge => _statusBadge.Value;

readonly CompositeDisposable _disposables = new();

public RailSessionViewModel(
Expand Down Expand Up @@ -62,6 +65,10 @@ public RailSessionViewModel(
_needsYou = agentsWithPending.Select(set => byStatus || set.Contains(row.Id))
.ToProperty(this, x => x.NeedsYou, initialValue: byStatus)
.DisposeWith(_disposables);
_statusBadge = agentsWithPending.Select(set => row.Status == "Failed" || set.Contains(row.Id)
? "!" : SessionStatusDots.WaitsOnUser(row) ? "zzz" : "")
.ToProperty(this, x => x.StatusBadge, initialValue: SessionStatusDots.WaitsOnUser(row) ? "zzz" : "")
.DisposeWith(_disposables);

// Remote rows are read-only in-app; opening deep-links to the web.
OpenCommand = ReactiveCommand.Create(() => (IsRemote ? openRemoteInWeb : openLocal)(row.Id));
Expand Down
10 changes: 10 additions & 0 deletions src/Capacitor.App/ViewModels/RailWorktreeViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public sealed class RailWorktreeViewModel : ReactiveObject, IDisposable {
readonly ObservableAsPropertyHelper<bool> _needsYou;
public bool NeedsYou => _needsYou.Value;

readonly ObservableAsPropertyHelper<string> _statusBadge;
public string StatusBadge => _statusBadge.Value;

readonly ObservableAsPropertyHelper<bool> _holdsSelected;
public bool HoldsSelected => _holdsSelected.Value;

Expand Down Expand Up @@ -103,6 +106,13 @@ public RailWorktreeViewModel(
.ToProperty(this, x => x.NeedsYou, initialValue: false)
.DisposeWith(_disposables);

_statusBadge = sessionsCache.Connect().QueryWhenChanged()
.CombineLatest(agentsWithPending, (q, set) =>
q.Items.Any(r => r.Status == "Failed" || set.Contains(r.Id)) ? "!"
: q.Items.Any(SessionStatusDots.WaitsOnUser) ? "zzz" : "")
.ToProperty(this, x => x.StatusBadge, initialValue: "")
.DisposeWith(_disposables);

_holdsSelected = sessionsCache.Connect().QueryWhenChanged()
.CombineLatest(selectedAgentId, (q, sel) => sel is not null && q.Items.Any(r => r.Id == sel))
.ToProperty(this, x => x.HoldsSelected, initialValue: false)
Expand Down
9 changes: 7 additions & 2 deletions src/Capacitor.App/ViewModels/TerminalChatInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@ public TerminalChatInput(TerminalTabViewModel terminal) {
this.RaisePropertyChanged(nameof(Availability));
this.RaisePropertyChanged(nameof(CanAcceptText));
this.RaisePropertyChanged(nameof(Hint));
this.RaisePropertyChanged(nameof(CanInterrupt));
});
}

public override SendAvailability Availability => _disposed ? SendAvailability.Ended : _terminal.SendAvailability;
public override bool CanAcceptText => !_disposed && _terminal.CanAcceptText;
public override bool CanInterrupt => !_disposed && _terminal.CanInterrupt;
public override Task InterruptAsync(CancellationToken ct) =>
CanInterrupt ? _terminal.SendEscapeAsync(ct) : Task.CompletedTask;
public override string Hint => HintFor(_terminal.SendAvailability, _terminal.State);

/// The terminal path has nothing to cancel: acceptance is synchronous.
public override Task<bool> SendAsync(string text, CancellationToken ct) =>
Task.FromResult(!_disposed && _terminal.TrySendText(text));
public override Task<ChatSendOutcome> SendAsync(string text, CancellationToken ct) =>
Task.FromResult(!_disposed && !ct.IsCancellationRequested && _terminal.TrySendText(text)
? ChatSendOutcome.Accepted : ChatSendOutcome.Rejected);

public override void Dispose() {
if (_disposed) return;
Expand Down
17 changes: 17 additions & 0 deletions src/Capacitor.App/ViewModels/TerminalTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ public TerminalSessionState State {
/// Bound; the one authority TrySendText itself consults, so can-execute and acceptance can
/// never disagree.
public bool CanAcceptText => GateOpen && !SendInFlight;
public bool CanInterrupt => GateOpen && State is { Phase: TerminalSessionPhase.Attached, ReadOnly: false };

/// Bound; the gate folded into the state, so a hint built from it is true in every window.
public SendAvailability SendAvailability {
Expand All @@ -136,6 +137,7 @@ public SendAvailability SendAvailability {
void RaiseSendProjections() {
this.RaisePropertyChanged(nameof(SendInFlight));
this.RaisePropertyChanged(nameof(CanAcceptText));
this.RaisePropertyChanged(nameof(CanInterrupt));
this.RaisePropertyChanged(nameof(SendAvailability));
}

Expand Down Expand Up @@ -211,6 +213,21 @@ public bool TrySendText(string text) {
return true;
}

/// A control key bypasses bracketed paste and submit. The same attach gate protects it from
/// reaching a retired or read-only client, and an in-flight paste must finish before Escape.
public async Task SendEscapeAsync(CancellationToken ct) {
var token = Volatile.Read(ref _openingToken);
if (!CanInterrupt || _client is not { } client || ct.IsCancellationRequested) return;
if (Volatile.Read(ref _openingToken) != token) return;
try {
while (_delivery is { IsCompleted: false } delivery) await delivery.WaitAsync(ct);
if (!CanInterrupt || Volatile.Read(ref _openingToken) != token || ct.IsCancellationRequested) return;
await client.SendInputAsync(new byte[] { 0x1b });
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception ex) { Console.Error.WriteLine($"kcap: composer interrupt failed: {ex.Message}"); }
}

// Paste, wait out the TUI's post-paste Enter suppression, then one CR -- only if nothing
// advanced the token meanwhile, since the paste already went to a client a replacement must
// never follow with a stray Enter. A fault ends the delivery and never touches State: the
Expand Down
28 changes: 26 additions & 2 deletions src/Capacitor.App/Views/ChatTabView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
xmlns:views="clr-namespace:Capacitor.App.Views"
x:Class="Capacitor.App.Views.ChatTabView"
x:DataType="vm:ChatTabViewModel">
<Grid RowDefinitions="*,Auto,Auto,Auto">
<Grid RowDefinitions="*,Auto,Auto,Auto,Auto">
<!-- The template owns the ScrollViewer: that is the shape Avalonia virtualizes. An
ItemsControl inside an external ScrollViewer is measured at infinite height. -->
<ItemsControl x:Name="ChatItems" Grid.Row="0" ItemsSource="{Binding Items}">
Expand Down Expand Up @@ -360,7 +360,31 @@
</StackPanel>
</Border>

<Border Grid.Row="3" Margin="22,8,22,18"
<Border x:Name="QueuedMessagesBanner" Grid.Row="3" Margin="22,4,22,0" Padding="13,10"
Background="{StaticResource KcapSurfaceBrush}" BorderBrush="{StaticResource KcapBorderBrush}"
BorderThickness="1" CornerRadius="8" IsVisible="{Binding HasQueuedMessages}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding QueueSummary}" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource KcapTextBrush}" />
<ScrollViewer MaxHeight="120" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding QueuedMessages}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:QueuedChatMessage">
<StackPanel Margin="0,2" Spacing="2">
<TextBlock Text="{Binding Text}" ToolTip.Tip="{Binding Text}" FontSize="12"
TextTrimming="CharacterEllipsis" MaxLines="1"
Foreground="{StaticResource KcapMutedBrush}" />
<TextBlock Text="Delivery unconfirmed" IsVisible="{Binding IsUnconfirmed}"
FontSize="11" Foreground="{StaticResource KcapMutedBrush}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>

<Border Grid.Row="4" Margin="22,8,22,18"
Background="{StaticResource KcapSurfaceBrush}" BorderBrush="{StaticResource KcapBorderBrush}"
BorderThickness="1" CornerRadius="10" Padding="13,12">
<StackPanel Spacing="6">
Expand Down
6 changes: 6 additions & 0 deletions src/Capacitor.App/Views/ChatTabView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ void OnScrollChanged(object? sender, ScrollChangedEventArgs e) {
/// nothing, leaving the text and the hint that says why. Shift+Enter falls through to the
/// TextBox's own newline.
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();
return;
}
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) return;
e.Handled = true;
if (DataContext is ChatTabViewModel vm && ((ICommand)vm.SendCommand).CanExecute(null))
Expand Down
4 changes: 2 additions & 2 deletions src/Capacitor.App/Views/SessionRailView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
Foreground="{StaticResource KcapFaintBrush}" VerticalAlignment="Center" />
<Border Background="{StaticResource KcapWarningDimBrush}" CornerRadius="999"
Padding="6,0" IsVisible="{Binding NeedsYou}" VerticalAlignment="Center">
<TextBlock Text="!" FontSize="9" FontWeight="Bold"
<TextBlock Text="{Binding StatusBadge}" FontSize="9" FontWeight="Bold"
Foreground="{StaticResource KcapWarningBrush}" />
</Border>
</StackPanel>
Expand Down Expand Up @@ -176,7 +176,7 @@
<Border DockPanel.Dock="Right" Background="{StaticResource KcapWarningDimBrush}"
CornerRadius="999" Padding="7,1" IsVisible="{Binding NeedsYou}"
VerticalAlignment="Top">
<TextBlock Text="!" FontSize="9" FontWeight="Bold"
<TextBlock Text="{Binding StatusBadge}" FontSize="9" FontWeight="Bold"
Foreground="{StaticResource KcapWarningBrush}" />
</Border>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="8">
Expand Down
4 changes: 4 additions & 0 deletions src/Capacitor.Cli.Core/ChatProjectionResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Capacitor.Cli.Core;

/// A local slash command can acknowledge input even when its transcript wrappers remain hidden.
public sealed record ChatProjectionResult(IReadOnlyList<AcpEventEnvelope> Envelopes, IReadOnlyList<string> SubmittedInputs);
22 changes: 22 additions & 0 deletions src/Capacitor.Cli.Core/Harness/Claude/ClaudeChatRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ public sealed partial class ClaudeChatRules : IChatDisplayRules {

ClaudeChatRules() { }

public string? SubmittedInput(CanonicalEvent evt, AcpEventEnvelope raw, AcpEventEnvelope? displayed) {
if (raw.Kind != AcpEventKind.UserMessage) return null;
var slug = SchemaExtensions.Slug(evt.Payload, ClaudeCodeExtension.Slug);
if (SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsSidechain)
|| SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsMeta)
|| SchemaExtensions.Text(slug, ClaudeCodeExtension.OriginKind) == "task-notification") return null;

var text = raw.Text ?? "";
var name = CommandName().Match(text).Groups[1].Value.Trim();
if (name.StartsWith('/')) {
var args = CommandArgs().Match(text).Groups[1].Value.Trim();
return args.Length == 0 ? name : $"{name} {args}";
}
return displayed is { Kind: AcpEventKind.UserMessage } user ? user.Text : null;
}

public AcpEventEnvelope? Filter(CanonicalEvent evt, AcpEventEnvelope envelope) {
var slug = SchemaExtensions.Slug(evt.Payload, ClaudeCodeExtension.Slug);
if (SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsSidechain) || SchemaExtensions.Flag(slug, ClaudeCodeExtension.IsMeta)) return null;
Expand Down Expand Up @@ -48,6 +64,12 @@ public sealed partial class ClaudeChatRules : IChatDisplayRules {
/// echoes.
internal static string StripWrappers(string text) => Wrappers().Replace(text, "").Trim();

[GeneratedRegex(@"<command-name>(.*?)</command-name>", RegexOptions.Singleline)]
private static partial Regex CommandName();

[GeneratedRegex(@"<command-args>(.*?)</command-args>", RegexOptions.Singleline)]
private static partial Regex CommandArgs();

[GeneratedRegex(@"<summary>(.*?)</summary>", RegexOptions.Singleline)]
private static partial Regex TaskSummary();

Expand Down
5 changes: 5 additions & 0 deletions src/Capacitor.Cli.Core/IChatTranscriptProjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@ namespace Capacitor.Cli.Core;
public interface IChatTranscriptProjection {
TranscriptContext CreateContext(string sessionId, string? agentId);
IReadOnlyList<AcpEventEnvelope> Project(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context);

ChatProjectionResult ProjectWithInputs(string line, int lineNumber, DateTimeOffset receivedAt, TranscriptContext context) {
var envelopes = Project(line, lineNumber, receivedAt, context);
return new(envelopes, envelopes.Where(e => e.Kind == AcpEventKind.UserMessage && e.Text is not null).Select(e => e.Text!).ToArray());
}
}
Loading