Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
87985e3
Polish plan and artefacts presentation in the work-context pane
nortonandreev Oct 14, 2026
1ad429b
Polish plan document and artefacts sidebar rows
nortonandreev Oct 14, 2026
88d231d
Polish chat tool cards and ToolSearch rows
nortonandreev Oct 14, 2026
45f9cf6
Unify document kind badges and align plan/selection colors
nortonandreev Oct 14, 2026
332f440
Align tool row labels with details and inset tool cards
nortonandreev Oct 14, 2026
7d470e2
Simplify sidebar active states and remove duplicate Plan pills
nortonandreev Oct 14, 2026
463fe16
Align ArtefactsSummaryRow with sibling section headers (#1098)
nortonandreev Oct 14, 2026
5aa71e9
Move kind badge to metadata row in DocumentReader (#1098)
nortonandreev Oct 14, 2026
300c711
Left-align ToolCardTitle with ToolCardMeta in tool cards (#1098)
nortonandreev Oct 14, 2026
8ce4c11
Render human tool labels in KcapTextBrush and trailing details muted …
nortonandreev Oct 14, 2026
3a5f7c6
Stack ToolCardName above kind and metadata subtitle in tool cards (#1…
nortonandreev Oct 14, 2026
f8d2c5e
Remove nested ToolCard box chrome and anchor Open at right edge (#1098)
nortonandreev Oct 14, 2026
d8887c1
Remove redundant ArtefactsSummaryRow from work-context sidebar (#1098)
nortonandreev Oct 14, 2026
3744133
Center ChatKindHeader icons and unclip ToolCardOpen hover bleed (#1098)
nortonandreev Oct 14, 2026
d7d6dd8
Lead every tool row and card with a human verb label (#1098)
nortonandreev Oct 14, 2026
f9a8bc0
Collapse DocumentReader header into a single-line metadata bar (#1098)
nortonandreev Oct 14, 2026
5465c20
Refine tool verbs, select-query collisions, and find classification (…
nortonandreev Oct 14, 2026
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
2 changes: 0 additions & 2 deletions src/Capacitor.App/ViewModels/ArtefactsTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ public sealed class ArtefactsTabViewModel : ReactiveObject {
public bool HasAny => _documents.Count > 0;
/// True while a session lease is open and has not yet applied a result — distinct from a settled empty list.
public bool IsLoading => _reads.IsLoading;
public string SummaryText => _documents.Count == 1 ? "1 document" : $"{_documents.Count} documents";
/// Right-pane prompt when nothing is open: loading, settled empty, or pick a row.
public string EmptyReaderNote => IsLoading ? "Loading documents…"
: HasAny ? "Select a document to read it."
Expand Down Expand Up @@ -210,7 +209,6 @@ void Clear() {

void RaiseShape() {
this.RaisePropertyChanged(nameof(HasAny));
this.RaisePropertyChanged(nameof(SummaryText));
this.RaisePropertyChanged(nameof(EmptyReaderNote));
}

Expand Down
4 changes: 2 additions & 2 deletions src/Capacitor.App/ViewModels/ChatItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ public sealed class ToolCallItem(string name, string detail, ToolCategory catego
public string Name { get; } = name;
public string Detail { get; } = detail;
public ToolCategory Category { get; } = category;
/// The verb phrase a kcap or foreign MCP row opens with ("Attached work item"); null for a
/// built-in tool, whose row is the detail alone.
/// The verb phrase a row opens with; null when a built-in call carries its own prose
/// description or is a question, whose row is the detail alone.
public string? Label { get; } = label;
public bool HasLabel => !string.IsNullOrEmpty(Label);
public ToolCardKind CardKind { get; } = cardKind;
Expand Down
7 changes: 5 additions & 2 deletions src/Capacitor.App/ViewModels/ChatTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -960,11 +960,14 @@ void Apply(int generation, FeedRead read) {
_openShell = null;
var name = e.ToolName ?? "tool";
var entry = KcapToolCatalogue.Match(name);
var isToolSearch = entry is null && KcapToolCatalogue.IsToolSearch(name);
var category = entry?.Category ?? ToolSummary.Categorize(name, e.ToolInputJson);
var label = entry?.Label ?? KcapToolCatalogue.ForeignLabel(name);
var foreignLabel = entry is null && !isToolSearch ? KcapToolCatalogue.ForeignLabel(name) : null;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
var detail = entry is not null ? ToolDetail.ForKey(e.ToolInputJson, entry.DetailKey)
: label is not null ? FirstOrAny(e.ToolInputJson, category)
: isToolSearch ? TextElision.End(KcapToolCatalogue.FormatToolSearchQuery(ToolDetail.RawForKey(e.ToolInputJson, "query")), 80)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: foreignLabel is not null ? FirstOrAny(e.ToolInputJson, category)
: ToolDetail.From(e.ToolInputJson, _root, category);
var label = entry?.Label ?? (isToolSearch ? "Looked up tools" : foreignLabel ?? ToolSummary.RowLabel(name, category, e.ToolInputJson));
var item = new ToolCallItem(name, detail, category, label, entry?.Card ?? ToolCardKind.None, e.ToolInputJson);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
if (e.ToolCallId is { } id) {
_pendingTools[id] = item;
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.App/ViewModels/DocumentReaderViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public sealed class DocumentReaderViewModel {
public string MetaLabel => SizeLabel.Length > 0 ? $"{DeclaredLabel} · {SizeLabel}" : DeclaredLabel;

public static DocumentReaderViewModel For(DocumentRow row, DriftState drift, DateTimeOffset now) => new() {
KindLabel = row.Kind switch { "plan" => "Plan", "spec" => "Spec", "design" => "Design", _ => "Document" },
KindLabel = row.KindLabel,
FileName = row.FileName,
Path = row.Path,
Body = row.Content ?? "",
Expand Down
1 change: 1 addition & 0 deletions src/Capacitor.App/ViewModels/DocumentRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Capacitor.App.ViewModels;
public sealed class DocumentRow(string kind, string title, string path, string source, string contentState, string? content,
string contentHash, long? originalBytes, DateTimeOffset discoveredAt, bool isPrimary) : ReactiveObject {
public string Kind { get; } = kind;
public string KindLabel { get; } = kind switch { "plan" => "Plan", "spec" => "Spec", "design" => "Design", _ => "Document" };
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
public string Title { get; } = title;
public string Path { get; } = path;
public string FileName { get; } = ToolCards.FileName(path);
Expand Down
38 changes: 38 additions & 0 deletions src/Capacitor.App/ViewModels/KcapToolCatalogue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public static class KcapToolCatalogue {
const string McpPrefix = "mcp__";
const string Separator = "__";
const string PiPrefix = "kcap_";
const string SelectPrefix = "select:";

internal static readonly KcapToolEntry[] Entries = [
// pages
Expand Down Expand Up @@ -120,6 +121,43 @@ public static class KcapToolCatalogue {
return $"{Humanise(server)} · {tool.Replace('_', ' ')}";
}

public static bool IsToolSearch(string? toolName) =>
string.Equals(toolName, "ToolSearch", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "tool_search", StringComparison.OrdinalIgnoreCase);

/// Shortens MCP tool-search queries (`select:mcp__plugin_kcap_...` or space-separated tool
/// names) to comma-separated bare tool names so the row fits on one scannable line.
public static string FormatToolSearchQuery(string? query) {
if (string.IsNullOrWhiteSpace(query)) return "";
var trimmed = query.Trim();
var isSelect = trimmed.StartsWith(SelectPrefix, StringComparison.OrdinalIgnoreCase);
var body = isSelect ? trimmed[SelectPrefix.Length..] : trimmed;
var parts = body.Split([',', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) return "";
if (!isSelect && !parts.All(IsToolIdentifierToken)) return trimmed;
var items = new List<(string? Server, string Clean)>(parts.Length);
var seenRaw = new HashSet<string>(StringComparer.Ordinal);
var cleanCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var part in parts) {
if (!seenRaw.Add(part)) continue;
var (server, tool) = Split(part);
var clean = tool.Length > 0 ? tool : part;
items.Add((server, clean));
cleanCounts[clean] = cleanCounts.GetValueOrDefault(clean) + 1;
}
var names = new List<string>(items.Count);
foreach (var (server, clean) in items) {
var formatted = cleanCounts[clean] > 1 && !string.IsNullOrEmpty(server)
? $"{Humanise(server)} · {clean}"
: clean;
if (!names.Contains(formatted, StringComparer.Ordinal)) names.Add(formatted);
}
return string.Join(", ", names);
}

static bool IsToolIdentifierToken(string token) =>
token.Contains('_', StringComparison.Ordinal) || token.StartsWith(McpPrefix, StringComparison.Ordinal);

// (server, tool) for `mcp__<server>__<tool>` or Pi's `kcap_<server>_<tool>`; (null, name) for a
// bare name. A plugin server segment (`plugin_kcap_kcap-plans`, `plugin_linear_linear`) keeps
// its last `_`-separated token.
Expand Down
19 changes: 12 additions & 7 deletions src/Capacitor.App/ViewModels/PlanSectionViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public sealed class PlanSectionViewModel : ReactiveObject {
/// What the expanded header says; collapsed, the header shows Counts.
public string HeaderText => $"{DoneCount} of {_tasks.Count} done";
/// Pending, in progress, done: one entry per state something is in, so the numbers add up
/// to the list. The instance changes only when a number does.
/// to the list.
public IReadOnlyList<PlanTaskCount> Counts => _counts;

bool _isExpanded;
Expand Down Expand Up @@ -99,6 +99,7 @@ void OnPlanWritten() {

void OnSessionOverChanged() {
foreach (var task in _tasks) task.Present(_activity.SessionOver);
SyncCounts();
}

void Apply(SessionPlansRead read) {
Expand Down Expand Up @@ -165,11 +166,7 @@ void Clear() {

void RaiseShape() {
StableRows.Sync(_inProgress, _tasks.Where(task => task.IsInProgress));
var counts = CountStates();
if (!counts.SequenceEqual(_counts)) {
_counts = counts;
this.RaisePropertyChanged(nameof(Counts));
}
SyncCounts();
this.RaisePropertyChanged(nameof(HasPlan));
this.RaisePropertyChanged(nameof(ShowsPlan));
this.RaisePropertyChanged(nameof(HasTasks));
Expand All @@ -179,6 +176,14 @@ void RaiseShape() {
this.RaisePropertyChanged(nameof(HeaderText));
}

void SyncCounts() {
var counts = CountStates();
if (!counts.SequenceEqual(_counts)) {
_counts = counts;
this.RaisePropertyChanged(nameof(Counts));
}
}

void RaiseLoading() {
this.RaisePropertyChanged(nameof(IsLoading));
this.RaisePropertyChanged(nameof(ShowsPlan));
Expand All @@ -188,7 +193,7 @@ PlanTaskCount[] CountStates() {
var done = DoneCount;
PlanTaskCount[] states = [
new(PlanTaskState.Pending, _tasks.Count - _inProgress.Count - done),
new(PlanTaskState.InProgress, _inProgress.Count),
new(PlanTaskState.InProgress, _inProgress.Count, _inProgress.Count > 0 && !_activity.SessionOver),
new(PlanTaskState.Completed, done),
];
return [.. states.Where(state => state.Count > 0)];
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.App/ViewModels/PlanTaskCount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace Capacitor.App.ViewModels;

/// How many of the plan's tasks are in one state, as the collapsed sidebar section shows it.
/// Completed stands for every settled task, a skipped one included.
public sealed record PlanTaskCount(PlanTaskState State, int Count) {
public sealed record PlanTaskCount(PlanTaskState State, int Count, bool IsActive = false) {
/// The count in words, for the tooltip: the mark beside the number carries no text.
public string Label => State switch {
PlanTaskState.InProgress => $"{Count} in progress",
Expand Down
4 changes: 3 additions & 1 deletion src/Capacitor.App/ViewModels/ToolCard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ namespace Capacitor.App.ViewModels;
/// What a producing call left behind, as the chat shows it: the verb line ("Published page"), the
/// thing's own name, one meta line, and whichever target it can open. Url is a page; DocumentPath
/// is a declared document as the declaration spelled it.
public sealed record ToolCard(ToolCardKind Kind, string Title, string Name, string Meta, string? Url, string? DocumentPath);
public sealed record ToolCard(ToolCardKind Kind, string Title, string Name, string Meta, string? Url, string? DocumentPath) {
public bool HasDistinctMeta => !string.IsNullOrEmpty(Meta) && !string.Equals(Meta, Name, StringComparison.Ordinal);
}
7 changes: 6 additions & 1 deletion src/Capacitor.App/ViewModels/ToolDetail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,16 @@ public static string From(string? inputJson, string? root = null, ToolCategory c

/// The one argument a catalogued kcap row shows, first line only, elided like any detail.
public static string ForKey(string? inputJson, string? key) {
var raw = RawForKey(inputJson, key);
return raw.Length > 0 ? TextElision.End(FirstLine(raw), MaxLength) : "";
}

public static string RawForKey(string? inputJson, string? key) {
if (string.IsNullOrEmpty(inputJson) || string.IsNullOrEmpty(key)) return "";
try {
using var doc = JsonDocument.Parse(inputJson);
if (!doc.RootElement.IsObject) return "";
return doc.RootElement.Str(key) is { } s && s.Trim().Length > 0 ? TextElision.End(FirstLine(s), MaxLength) : "";
return doc.RootElement.Str(key) is { } s && s.Trim().Length > 0 ? FirstLine(s) : "";
} catch (JsonException) {
return "";
}
Expand Down
29 changes: 29 additions & 0 deletions src/Capacitor.App/ViewModels/ToolSummary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,35 @@ public static string Describe(IEnumerable<ToolCategory> categories) {
_ => "Tool",
};

/// Human verb for a built-in tool row whose input carries no prose description of its own.
public static string? RowLabel(string name, ToolCategory category, string? inputJson) {
if (category == ToolCategory.Question || ToolDetail.RawForKey(inputJson, "description").Length > 0) return null;
switch (name.ToLowerInvariant()) {
case "write" or "write_file": return "Wrote file";
case "ls": return "Listed files";
case "bashoutput": return "Read shell output";
case "killshell": return "Stopped shell";
case "write_stdin" or "send_input" or "send_message": return "Sent input";
case "taskoutput": return "Read agent output";
case "taskstop" or "interrupt_agent" or "close_agent": return "Stopped agent";
case "wait_agent": return "Waited on agent";
case "resume_agent": return "Resumed agent";
case "list_agents": return "Listed agents";
}
return category switch {
ToolCategory.Read => "Read file",
ToolCategory.Edit => "Edited file",
ToolCategory.Command => "Ran command",
ToolCategory.Search => "Searched files",
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
ToolCategory.WebSearch => "Searched the web",
ToolCategory.Fetch => "Fetched page",
ToolCategory.Skill => "Loaded skill",
ToolCategory.Agent => "Ran agent",
Comment thread
nortonandreev marked this conversation as resolved.
ToolCategory.Plan => "Updated the plan",
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
_ => null,
};
}

static bool IsSkillFile(string? path) =>
path is not null && (path == "SKILL.md" || path.EndsWith("/SKILL.md", StringComparison.Ordinal));
}
7 changes: 2 additions & 5 deletions src/Capacitor.App/ViewModels/WorkContextViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,9 @@ RefreshShortcut.FromTerminalLabel is { } fromTerminal
? $"Refresh this work · {RefreshShortcut.Label} or {fromTerminal}"
: $"Refresh this work · {RefreshShortcut.Label}";

/// The Artefacts tab's model, when this pane's workspace has one; the pane shows its summary
/// row and switches its session beside the plan's.
/// The Artefacts tab's model, when this pane's workspace has one; the pane switches and
/// refreshes its session beside the plan's.
public ArtefactsTabViewModel? Artefacts { get; }
public ReactiveCommand<Unit, Unit> OpenArtefactsCommand { get; }
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

public ReactiveCommand<Unit, Unit> RefreshCommand { get; }
/// The item's own page in the web UI; enabled once a read has named the item.
Expand All @@ -249,8 +248,6 @@ public WorkContextViewModel(
reading => IsReading = reading);
Plan = new PlanSectionViewModel(plans, planActivity ?? new PlanActivity(), time);
Artefacts = artefacts;
OpenArtefactsCommand = ReactiveCommand.Create(() => Artefacts?.RequestOpen());
_disposables.Add(OpenArtefactsCommand);
_opener = opener;
_time = time;
_openWorkItem = openWorkItem;
Expand Down
6 changes: 3 additions & 3 deletions src/Capacitor.App/Views/ArtefactsView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@
<StackPanel Spacing="2">
<TextBlock Text="{Binding FileName}" FontSize="13" Foreground="{StaticResource KcapTextBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Kind}" FontSize="11" Foreground="{StaticResource KcapFaintBrush}" />
<Border IsVisible="{Binding HasStateChip}" Background="{StaticResource KcapWarningDimBrush}" CornerRadius="4" Padding="5,0">
<TextBlock Text="{Binding StateChip}" FontSize="10.5" Foreground="{StaticResource KcapWarningBrush}" />
<TextBlock Text="{Binding KindLabel}" FontSize="11" Foreground="{StaticResource KcapFaintBrush}" VerticalAlignment="Center" />
<Border IsVisible="{Binding HasStateChip}" Background="{StaticResource KcapWarningDimBrush}" CornerRadius="999" Padding="7,1" VerticalAlignment="Center">
<TextBlock Text="{Binding StateChip}" FontSize="10" FontWeight="SemiBold" Foreground="{StaticResource KcapWarningBrush}" />
</Border>
</StackPanel>
</StackPanel>
Expand Down
26 changes: 19 additions & 7 deletions src/Capacitor.App/Views/ChatBubbleStyles.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,25 @@
<Setter Property="MaxWidth" Value="660" />
</Style>

<!-- A producing call's card: raised against the group it sits in, padded like a pane card. -->
<Style Selector="Border.toolCard">
<Setter Property="Background" Value="{StaticResource KcapSurfaceRaisedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource KcapBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="Margin" Value="0,2,0,6" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,4,0,4" />
</Style>
<Style Selector="Path.toolCardOpenArrow">
<Setter Property="Width" Value="11" />
<Setter Property="Height" Value="11" />
<Setter Property="Stretch" Value="None" />
<Setter Property="Stroke" Value="{StaticResource KcapMutedBrush}" />
<Setter Property="StrokeThickness" Value="1.5" />
<Setter Property="StrokeLineCap" Value="Round" />
<Setter Property="StrokeJoin" Value="Round" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Data" Value="M3,8 L8,3 M4.5,3 H8 V6.5" />
</Style>
<Style Selector="Button.kcapGhost:pointerover Path.toolCardOpenArrow,
Button.kcapGhost:pressed Path.toolCardOpenArrow">
<Setter Property="Stroke" Value="{StaticResource KcapTextBrush}" />
</Style>
</Styles>
2 changes: 1 addition & 1 deletion src/Capacitor.App/Views/ChatKindHeader.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
otherwise shear the top/bottom of the glyph against the 14×14 panel. -->
<Panel Width="14" Height="14" VerticalAlignment="Center" ClipToBounds="False"
IsVisible="{Binding #Root.IconData, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Path Width="12" Height="12" Stretch="Uniform"
<Path MaxWidth="12" MaxHeight="12" Stretch="Uniform"
Data="{Binding #Root.IconData}"
Stroke="{StaticResource KcapMutedBrush}" StrokeThickness="1.4"
StrokeLineCap="Round" StrokeJoin="Round"
Expand Down
Loading
Loading