diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index 075892e..c308201 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -245,6 +245,34 @@ + + + + + + + @@ -264,12 +292,10 @@ @@ -278,7 +304,7 @@ - + - + @@ -678,12 +705,13 @@ - + diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index f2a2d72..8440a70 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -1,8 +1,10 @@ using System.Collections.ObjectModel; +using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; @@ -17,7 +19,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private readonly IServiceProvider _services; // Current task row (set by IslandsShellViewModel via Bind) - [ObservableProperty] private TaskRowViewModel? _task; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RunNowCommand))] + private TaskRowViewModel? _task; // Editable fields [ObservableProperty] private string _editableTitle = ""; @@ -28,12 +32,22 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; // Agent strip fields - [ObservableProperty] private string _agentStatusLabel = "Idle"; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RunNowCommand))] + private string _agentStatusLabel = "Idle"; public bool IsRunning => AgentStatusLabel == "Running"; + public bool IsDone => AgentStatusLabel == "Done"; + public bool IsFailed => AgentStatusLabel == "Failed"; - partial void OnAgentStatusLabelChanged(string value) => OnPropertyChanged(nameof(IsRunning)); + partial void OnAgentStatusLabelChanged(string value) + { + OnPropertyChanged(nameof(IsRunning)); + OnPropertyChanged(nameof(IsDone)); + OnPropertyChanged(nameof(IsFailed)); + } [ObservableProperty] private string? _model; [ObservableProperty] private string? _worktreePath; + [ObservableProperty] private string? _worktreeBaseCommit; [ObservableProperty] private string? _branchLine; [ObservableProperty] private int _turns; [ObservableProperty] private int _tokens; @@ -61,6 +75,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase public ObservableCollection Log { get; } = new(); public ObservableCollection Subtasks { get; } = new(); + // Claude CLI stream-json parser + buffer for partial text deltas + private readonly StreamLineFormatter _formatter = new(); + private readonly StringBuilder _claudeBuf = new(); + // The task ID we are currently subscribed to for live log messages private string? _subscribedTaskId; @@ -89,23 +107,92 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // Subscribe once; filter by current task id inside the handler _worker.TaskMessageEvent += OnTaskMessage; + + // Re-evaluate RunNow CanExecute when worker connection flips. + _worker.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(WorkerClient.IsConnected)) + RunNowCommand.NotifyCanExecuteChanged(); + }; + + // If the task row's live status changes (e.g. TaskStarted/Finished), mirror it. + _worker.TaskStartedEvent += (slot, taskId, startedAt) => + { + if (Task?.Id == taskId) AgentStatusLabel = "Running"; + }; + _worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) => + { + if (Task?.Id != taskId) return; + FlushClaudeBuffer(); + Log.Add(new LogLineViewModel + { + Kind = LogKind.Done, + Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──", + }); + AgentStatusLabel = status; + // Re-query to pick up worktree created during the run. + _ = RefreshWorktreeAsync(taskId); + }; + + _worker.WorktreeUpdatedEvent += taskId => + { + if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId); + }; } private void OnTaskMessage(string taskId, string line) { if (taskId != _subscribedTaskId) return; - // Parse a simple prefix convention: "[sys]", "[tool]", "[claude]", etc. - // Fall back to Msg for unrecognised lines. + + // `[stdout] ...json...` lines are Claude CLI stream-json; parse through the + // formatter so the user sees human text, not raw JSON. + if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase)) + { + var body = line["[stdout]".Length..].TrimStart(); + var formatted = _formatter.FormatLine(body); + if (formatted is null) return; // filter noise (message_start, etc.) + AppendClaudeText(formatted); + return; + } + + // Non-stdout tagged lines: flush any buffered text then classify by prefix. + FlushClaudeBuffer(); + var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys : line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool : line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude - : line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stdout : line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr : line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done : LogKind.Msg; Log.Add(new LogLineViewModel { Kind = kind, Text = line }); } + private void AppendClaudeText(string chunk) + { + _claudeBuf.Append(chunk); + // Emit a log entry for every completed line; keep the trailing remainder buffered. + while (true) + { + var text = _claudeBuf.ToString(); + var nl = text.IndexOf('\n'); + if (nl < 0) break; + var piece = text[..nl].TrimEnd('\r'); + if (!string.IsNullOrWhiteSpace(piece)) + Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); + _claudeBuf.Clear(); + _claudeBuf.Append(text[(nl + 1)..]); + } + } + + private void FlushClaudeBuffer() + { + if (_claudeBuf.Length == 0) return; + var piece = _claudeBuf.ToString().TrimEnd(); + _claudeBuf.Clear(); + if (!string.IsNullOrWhiteSpace(piece)) + Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); + } + public void Bind(TaskRowViewModel? row) { _loadCts?.Cancel(); @@ -117,6 +204,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase OnPropertyChanged(nameof(TaskIdBadge)); Log.Clear(); Subtasks.Clear(); + _claudeBuf.Clear(); if (row == null) { @@ -137,11 +225,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase { try { - await using var ctx = _dbFactory.CreateDbContext(); - var taskRepo = new TaskRepository(ctx); + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var subtaskRepo = new SubtaskRepository(ctx); - var entity = await taskRepo.GetByIdAsync(row.Id); + // Own query with Include so WorktreePath/BranchLine are populated. + var entity = await ctx.Tasks + .AsNoTracking() + .Include(t => t.Worktree) + .FirstOrDefaultAsync(t => t.Id == row.Id, ct); ct.ThrowIfCancellationRequested(); if (entity == null) return; @@ -163,6 +254,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase catch (OperationCanceledException) { } } + private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId) + { + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks + .AsNoTracking() + .Include(t => t.Worktree) + .FirstOrDefaultAsync(t => t.Id == taskId); + if (entity == null || Task?.Id != taskId) return; + + WorktreePath = entity.Worktree?.Path; + WorktreeBaseCommit = entity.Worktree?.BaseCommit; + BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null; + AgentStatusLabel = entity.Status.ToString(); + if (Task is { } row && entity.Worktree?.DiffStat is { } stat) + row.DiffStat = stat; + } + catch { /* best-effort refresh */ } + } + [RelayCommand(CanExecute = nameof(CanOpenDiff))] private async System.Threading.Tasks.Task OpenDiffAsync() { @@ -170,6 +282,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase var diffVm = new DiffModalViewModel(_services.GetRequiredService()) { WorktreePath = WorktreePath, + BaseRef = WorktreeBaseCommit, }; await diffVm.LoadAsync(); await ShowDiffModal(diffVm); @@ -178,19 +291,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private bool CanOpenDiff() => WorktreePath != null; [RelayCommand(CanExecute = nameof(CanOpenWorktree))] - private async System.Threading.Tasks.Task OpenWorktreeAsync() - { - if (WorktreePath == null || ShowWorktreeModal == null) return; - var vm = _services.GetRequiredService(); - vm.WorktreePath = WorktreePath; - await vm.LoadAsync(); - await ShowWorktreeModal(vm); - } - - private bool CanOpenWorktree() => WorktreePath != null; - - [RelayCommand(CanExecute = nameof(CanOpenWorktree))] - private void OpenInExplorer() + private void OpenWorktree() { if (WorktreePath == null) return; try @@ -204,11 +305,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase catch { /* explorer open is best-effort */ } } + private bool CanOpenWorktree() => WorktreePath != null; + partial void OnWorktreePathChanged(string? value) { OpenDiffCommand.NotifyCanExecuteChanged(); OpenWorktreeCommand.NotifyCanExecuteChanged(); - OpenInExplorerCommand.NotifyCanExecuteChanged(); } [RelayCommand] @@ -270,6 +372,25 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase if (Task == null) return; await _worker.CancelTaskAsync(Task.Id); } + + [RelayCommand(CanExecute = nameof(CanRunNow))] + private async System.Threading.Tasks.Task RunNowAsync() + { + if (Task == null) return; + AgentStatusLabel = "Running"; + try + { + await _worker.RunNowAsync(Task.Id); + } + catch + { + AgentStatusLabel = "Failed"; + throw; + } + } + + private bool CanRunNow() => + Task != null && _worker.IsConnected && !IsRunning; } public sealed partial class SubtaskRowViewModel : ViewModelBase diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 19a4625..1de2f36 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -3,7 +3,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace ClaudeDo.Ui.ViewModels.Islands; @@ -12,11 +14,23 @@ public enum ListKind { Smart, Virtual, User } public sealed partial class ListsIslandViewModel : ViewModelBase { private readonly IDbContextFactory _dbFactory; + private readonly IServiceProvider? _services; public event EventHandler? SelectionChanged; public event EventHandler? FocusSearchRequested; public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty); + public Func? ShowSettingsModal { get; set; } + + [RelayCommand] + private async Task OpenSettings() + { + if (ShowSettingsModal is null || _services is null) return; + var settingsVm = _services.GetRequiredService(); + await settingsVm.LoadAsync(); + await ShowSettingsModal(settingsVm); + } + public ObservableCollection Items { get; } = new(); public ObservableCollection SmartLists { get; } = new(); public ObservableCollection UserLists { get; } = new(); @@ -28,9 +42,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase public string MachineName { get; } = Environment.MachineName; public string UserInitials { get; } - public ListsIslandViewModel(IDbContextFactory dbFactory) + public ListsIslandViewModel(IDbContextFactory dbFactory, IServiceProvider? services = null) { _dbFactory = dbFactory; + _services = services; var parts = Environment.UserName.Split('.', '_', '-', ' '); UserInitials = parts.Length >= 2 ? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant() diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index d96ca9c..7136260 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -130,10 +130,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var running = Items.Count(i => i.Status == TaskStatus.Running); var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null); - var weekday = now.ToString("dddd", CultureInfo.CurrentCulture); - var month = now.ToString("MMM", CultureInfo.CurrentCulture); - var day = now.Day; - Subtitle = $"{weekday}, {month} {day} · {open} open"; + Subtitle = open == 1 ? "1 open task" : $"{open} open tasks"; if (running > 0 || review > 0) { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs index 09641ae..5924001 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs @@ -41,6 +41,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase private readonly GitService _git; public required string WorktreePath { get; init; } + public string? BaseRef { get; init; } public ObservableCollection Files { get; } = new(); @@ -62,7 +63,12 @@ public sealed partial class DiffModalViewModel : ViewModelBase Files.Clear(); string raw; - try { raw = await _git.GetDiffAsync(WorktreePath, ct); } + try + { + raw = BaseRef is not null + ? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct) + : await _git.GetDiffAsync(WorktreePath, ct); + } catch { return; } if (string.IsNullOrWhiteSpace(raw)) return; diff --git a/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml b/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml index 62fb214..c40a673 100644 --- a/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml @@ -42,6 +42,15 @@ + + diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 96e7746..154cd7e 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -88,66 +88,70 @@ - - - + + - - + + - - + + - - - - - - - - - - - + + + + + + + + + + + + - - - - - - + Cursor="Hand" + Margin="0,0,8,0"/> + + + + + + + - - - - - + + + + + - - + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index 16b576f..963e174 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -15,10 +15,6 @@ - @@ -69,7 +65,9 @@ -