Skip to content

Commit 3daf8a6

Browse files
committed
fix(config): add channel done affordance
1 parent 15fe7f8 commit 3daf8a6

14 files changed

Lines changed: 379 additions & 323 deletions

docs/ui/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This directory contains management UI planning artifacts for Netclaw.
66

77
- `UI-001-ops-console-mockup.md` - page architecture, wireframes, and
88
component behavior
9+
- `TUI-002-netclaw-config-wireframes.md` - `netclaw config` dashboard and
10+
autosave editor interaction patterns
911
- `TUI-004-search-config-progressive-disclosure-poc.md` - redesign POC for the
1012
Search settings flow using progressive disclosure
1113
- `TUI-001-command-wireframes.md` - Termina TUI wireframes for `netclaw init`,

docs/ui/TUI-002-netclaw-config-wireframes.md

Lines changed: 176 additions & 294 deletions
Large diffs are not rendered by default.

src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ public async Task Channels_Escape_ReturnsToDashboardUsingTerminaHistory()
7676
Assert.Equal("/config", app.CurrentPath);
7777
}
7878

79+
[Fact]
80+
public async Task Channels_DoneAddingChannelsRow_ReturnsToDashboardUsingTerminaHistory()
81+
{
82+
var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm);
83+
OpenChannels(dashboardVm);
84+
85+
input.EnqueueKey(ConsoleKey.DownArrow);
86+
input.EnqueueKey(ConsoleKey.DownArrow);
87+
input.EnqueueKey(ConsoleKey.DownArrow);
88+
input.EnqueueKey(ConsoleKey.Enter);
89+
input.EnqueueKey(ConsoleKey.Q, false, false, true);
90+
91+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
92+
await app.RunAsync(cts.Token);
93+
94+
Assert.NotNull(getChannelsVm());
95+
Assert.Equal("/config", app.CurrentPath);
96+
}
97+
7998
[Theory]
8099
[InlineData(ChannelType.Slack)]
81100
[InlineData(ChannelType.Discord)]
@@ -187,6 +206,28 @@ public async Task Channels_ChannelPermissions_DoesNotRemoveSelectedChannelWithDo
187206
Assert.Contains(channelsVm.GetChannelRows(), row => row.Id == "C01" && !row.IsAddAction);
188207
}
189208

209+
[Fact]
210+
public async Task Channels_ChannelPermissions_DoneRow_ReturnsToAdapterMenu()
211+
{
212+
var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm);
213+
OpenChannels(dashboardVm);
214+
MoveToAdapter(input, ChannelType.Discord);
215+
216+
input.EnqueueKey(ConsoleKey.Enter); // Open configured Discord management.
217+
input.EnqueueKey(ConsoleKey.Enter); // Manage channels and permissions.
218+
input.EnqueueKey(ConsoleKey.DownArrow); // + Add channel.
219+
input.EnqueueKey(ConsoleKey.DownArrow); // Done adding channels.
220+
input.EnqueueKey(ConsoleKey.Enter);
221+
input.EnqueueKey(ConsoleKey.Q, false, false, true);
222+
223+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
224+
await app.RunAsync(cts.Token);
225+
226+
var channelsVm = Assert.IsType<ChannelsConfigViewModel>(getChannelsVm());
227+
Assert.Equal(ChannelsConfigScreen.AdapterMenu, channelsVm.Screen.Value);
228+
Assert.Equal("Done adding channels. Completed changes are already saved.", channelsVm.Status.Value.Text);
229+
}
230+
190231
[Fact]
191232
public async Task Channels_ChannelPermissions_DeleteRemovesSelectedChannel()
192233
{

src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,39 @@ public void Back_from_saved_picker_returns_to_dashboard_or_quits()
261261
Assert.True(vm.ShutdownRequestedForTest);
262262
}
263263

264+
[Fact]
265+
public void Config_picker_exposes_done_row_without_save_action()
266+
{
267+
WriteChannelConfig();
268+
WriteChannelSecrets();
269+
using var vm = CreateViewModel();
270+
271+
Assert.True(vm.Step.ShowDonePickerRow);
272+
Assert.False(vm.Step.ShowDoneAction);
273+
Assert.Equal("Done adding channels", vm.Step.DonePickerRowLabel);
274+
Assert.Equal(vm.Step.Adapters.Count + 1, vm.Step.PickerRowCount);
275+
}
276+
277+
[Fact]
278+
public void Channel_permissions_done_row_returns_to_adapter_menu()
279+
{
280+
WriteChannelConfig();
281+
WriteChannelSecrets();
282+
using var vm = CreateViewModel();
283+
vm.OpenAdapterManagement(ChannelType.Slack);
284+
vm.ActivateManagementMenuItem();
285+
var doneIndex = vm.GetChannelRows()
286+
.Select((row, index) => (row, index))
287+
.Single(entry => entry.row.IsDoneAction)
288+
.index;
289+
290+
vm.MoveChannelRow(doneIndex);
291+
vm.OpenSelectedChannelAudience();
292+
293+
Assert.Equal(ChannelsConfigScreen.AdapterMenu, vm.Screen.Value);
294+
Assert.Equal("Done adding channels. Completed changes are already saved.", vm.Status.Value.Text);
295+
}
296+
264297
[Fact]
265298
public void Esc_from_incomplete_add_channel_draft_writes_nothing()
266299
{

src/Netclaw.Cli.Tests/Tui/Wizard/ChannelPickerStepViewModelTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,32 @@ public void SubFlow_BuildContent_ClearsSubscriptionsAcrossSubStepTransitions()
415415
$"bot token had {countAtBotToken}, app token has {subs.Count}");
416416
}
417417

418+
[Fact]
419+
public void Picker_DoneRow_EnterAdvancesWithoutTogglingAdapter()
420+
{
421+
using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe)
422+
{
423+
ShowDoneAction = false,
424+
ShowDonePickerRow = true,
425+
DonePickerRowLabel = "Done adding channels"
426+
};
427+
var view = new ChannelPickerStepView();
428+
using var subs = new CompositeDisposable();
429+
var advanced = false;
430+
var callbacks = CreateTestCallbacks(subs, advanceStep: () => advanced = true);
431+
432+
picker.OnEnter(Context, NavigationDirection.Forward);
433+
view.BuildContent(picker, callbacks);
434+
picker.CursorIndex = picker.Adapters.Count;
435+
436+
Assert.True(view.HandleKeyPress(new KeyPressed(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false))));
437+
438+
Assert.True(advanced);
439+
Assert.False(picker.IsAdapterEnabled(ChannelType.Slack));
440+
Assert.False(picker.IsAdapterEnabled(ChannelType.Discord));
441+
Assert.False(picker.IsAdapterEnabled(ChannelType.Mattermost));
442+
}
443+
418444
[Fact]
419445
public void SubFlow_PastedSlackBotTokenSurvivesReRenderBeforeSubmit()
420446
{

src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigPage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ private LayoutNode BuildStatusBar()
9494
.Height(1);
9595

9696
private LayoutNode BuildKeyBindings()
97-
=> NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space/Enter] Select/Save [←/→] Backend/Save [Esc] Settings Areas [Ctrl+Q] Quit");
97+
=> NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space/Enter] Activate [←/→] Backend/Save [Esc] Settings Areas [Ctrl+Q] Quit");
9898

9999
private void HandleKeyPress(KeyPressed key)
100100
{

src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ private ILayoutNode BuildChannelPermissions()
133133
.WithChild(Layouts.Empty().Height(1));
134134

135135
var rows = ViewModel.GetChannelRows();
136-
if (rows.Count == 1 && rows[0].IsAddAction)
136+
if (rows.All(static row => row.IsAction))
137137
{
138138
layout = layout.WithChild(Hint(" No allowed channels configured."));
139139
}
140140

141-
var editableRows = rows.Where(static row => !row.IsAddAction).ToArray();
141+
var editableRows = rows.Where(static row => !row.IsAction).ToArray();
142142
var displayNameWidth = Math.Clamp(
143143
editableRows.Select(static row => row.DisplayName.Length).DefaultIfEmpty(16).Max(),
144144
16,
@@ -148,7 +148,7 @@ private ILayoutNode BuildChannelPermissions()
148148
{
149149
var row = rows[i];
150150
var focused = i == ViewModel.ChannelRowIndex;
151-
var line = row.IsAddAction
151+
var line = row.IsAction
152152
? $"{FocusPrefix(focused)}{row.DisplayName}"
153153
: $"{FocusPrefix(focused)}{Column(row.DisplayName, displayNameWidth)} {AudienceCycle(row.Audience)}";
154154
layout = layout.WithChild(Row(line, focused));
@@ -293,7 +293,7 @@ private LayoutNode BuildHelpText()
293293
var help = ViewModel.Screen.Value switch
294294
{
295295
ChannelsConfigScreen.AdapterMenu => " Manage this adapter without re-entering credentials.",
296-
ChannelsConfigScreen.ChannelPermissions => " Enter edits an audience. a adds a channel. Delete removes the selected channel.",
296+
ChannelsConfigScreen.ChannelPermissions => " Enter edits an audience or activates Done. a adds a channel. Delete removes the selected channel.",
297297
ChannelsConfigScreen.EditAudience => " Select the audience profile for this channel.",
298298
ChannelsConfigScreen.AddChannel => " Enter applies the channel draft. Esc cancels.",
299299
ChannelsConfigScreen.AllowedUsers => " Use comma-separated user IDs. Blank means unrestricted users in allowed channels.",
@@ -326,7 +326,7 @@ private LayoutNode BuildKeyBindings()
326326
var text = ViewModel.Screen.Value switch
327327
{
328328
ChannelsConfigScreen.AdapterMenu => " [↑/↓] Navigate [Enter] Select [Esc] Channels [Ctrl+Q] Quit",
329-
ChannelsConfigScreen.ChannelPermissions => " [↑/↓] Navigate [←/→] Audience [Enter] Edit [a] Add [Del] Remove [Esc] Menu",
329+
ChannelsConfigScreen.ChannelPermissions => " [↑/↓] Navigate [←/→] Audience [Enter] Edit/Done [a] Add [Del] Remove [Esc] Menu",
330330
ChannelsConfigScreen.EditAudience => " [↑/↓] Navigate [Enter] Apply [Esc] Channels [Ctrl+Q] Quit",
331331
ChannelsConfigScreen.AddChannel => " [↑/↓] Audience [Enter] Add [Esc] Channels [Ctrl+Q] Quit",
332332
ChannelsConfigScreen.AllowedUsers => " [Enter] Apply [Esc] Menu [Ctrl+Q] Quit",
@@ -335,7 +335,7 @@ private LayoutNode BuildKeyBindings()
335335
ChannelsConfigScreen.ResetConfirm => " [↑/↓] Navigate [Enter] Select [Esc] Menu [Ctrl+Q] Quit",
336336
_ => ViewModel.Step.IsInSubFlow
337337
? " [Enter] Next [Esc] Back [Ctrl+Q] Quit"
338-
: " [↑/↓] Navigate [Space] Toggle/Save [Enter] Open [Esc] Back [Ctrl+Q] Quit"
338+
: " [↑/↓] Navigate [Space] Toggle/Save [Enter] Open/Done [Esc] Back [Ctrl+Q] Quit"
339339
};
340340

341341
return NetclawTuiChrome.BuildKeyHintLine(text);

src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,12 @@ public ChannelsConfigViewModel(
5757
Status = new ReactiveProperty<ConfigStatusMessage>(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral));
5858
Step = new ChannelPickerStepViewModel(slackProbe, discordProbe)
5959
{
60-
DoneActionText = "channel settings",
61-
DoneKeyActionLabel = "Apply",
62-
DoneKey = ConsoleKey.S,
60+
DoneActionText = "return to Settings Areas",
61+
DoneKeyActionLabel = "Done",
62+
DoneKey = ConsoleKey.D,
6363
ShowDoneAction = false,
64+
ShowDonePickerRow = true,
65+
DonePickerRowLabel = "Done adding channels",
6466
PreserveDisabledAdapterDrafts = true
6567
};
6668
_context = new WizardContext
@@ -211,7 +213,7 @@ internal async Task<bool> SaveFromInputAsync(CancellationToken ct = default)
211213

212214
internal bool TryOpenSelectedAdapterManagement()
213215
{
214-
if (!Step.IsInPickerMode)
216+
if (!Step.IsInPickerMode || !Step.IsAdapterRowSelected)
215217
return false;
216218

217219
var type = Step.SelectedAdapterType;
@@ -224,7 +226,7 @@ internal bool TryOpenSelectedAdapterManagement()
224226

225227
internal bool TryToggleSelectedAdapterFromPicker()
226228
{
227-
if (!Step.IsInPickerMode)
229+
if (!Step.IsInPickerMode || !Step.IsAdapterRowSelected)
228230
return false;
229231

230232
var type = Step.SelectedAdapterType;
@@ -371,7 +373,8 @@ internal IReadOnlyList<ChannelPermissionRow> GetChannelRows(bool includeAddActio
371373
FormatChannelLabel(_activeAdapterType, channelId),
372374
GetChannelAudience(_activeAdapterType, channelId, DefaultChannelAudience()),
373375
IsDirectMessage: false,
374-
IsAddAction: false));
376+
IsAddAction: false,
377+
IsDoneAction: false));
375378
}
376379

377380
if (GetAllowDirectMessages(_activeAdapterType))
@@ -381,7 +384,8 @@ internal IReadOnlyList<ChannelPermissionRow> GetChannelRows(bool includeAddActio
381384
"Direct messages",
382385
GetChannelAudience(_activeAdapterType, "dm", DefaultDirectMessageAudience()),
383386
IsDirectMessage: true,
384-
IsAddAction: false));
387+
IsAddAction: false,
388+
IsDoneAction: false));
385389
}
386390

387391
if (includeAddAction)
@@ -391,7 +395,15 @@ internal IReadOnlyList<ChannelPermissionRow> GetChannelRows(bool includeAddActio
391395
"+ Add channel",
392396
DefaultChannelAudience(),
393397
IsDirectMessage: false,
394-
IsAddAction: true));
398+
IsAddAction: true,
399+
IsDoneAction: false));
400+
rows.Add(new ChannelPermissionRow(
401+
string.Empty,
402+
"Done adding channels",
403+
DefaultChannelAudience(),
404+
IsDirectMessage: false,
405+
IsAddAction: false,
406+
IsDoneAction: true));
395407
}
396408

397409
if (_channelRowIndex >= rows.Count)
@@ -419,6 +431,12 @@ internal void OpenSelectedChannelAudience()
419431
return;
420432
}
421433

434+
if (row.IsDoneAction)
435+
{
436+
FinishChannelPermissions();
437+
return;
438+
}
439+
422440
_editingAudienceId = row.Id;
423441
_editingAudienceLabel = row.DisplayName;
424442
_editingAudienceIsDm = row.IsDirectMessage;
@@ -434,7 +452,7 @@ internal void ChangeSelectedChannelAudience(int delta)
434452
return;
435453

436454
var row = rows[_channelRowIndex];
437-
if (row.IsAddAction)
455+
if (row.IsAction)
438456
return;
439457

440458
var currentIndex = AudienceIndex(row.Audience);
@@ -450,7 +468,7 @@ internal void RemoveSelectedChannel()
450468
return;
451469

452470
var row = rows[_channelRowIndex];
453-
if (row.IsAddAction || row.IsDirectMessage)
471+
if (row.IsAction || row.IsDirectMessage)
454472
return;
455473

456474
var remaining = GetChannelIds(_activeAdapterType)
@@ -502,12 +520,22 @@ internal void ApplyAddChannel()
502520
SetChannelIds(_activeAdapterType, [.. existing, channelId]);
503521
SetChannelAudience(_activeAdapterType, channelId, AudienceOptions[_audienceSelectionIndex]);
504522
UpdateAdapterPickerSummary(_activeAdapterType);
505-
_channelRowIndex = Math.Max(GetChannelRows().Count - 2, 0);
523+
_channelRowIndex = GetChannelRows()
524+
.Select((row, index) => (row, index))
525+
.Single(entry => string.Equals(entry.row.Id, channelId, StringComparison.Ordinal))
526+
.index;
506527
Screen.Value = ChannelsConfigScreen.ChannelPermissions;
507528
AutosaveCompletedAction($"Added {channelId} and saved.");
508529
NotifyContentChanged();
509530
}
510531

532+
internal void FinishChannelPermissions()
533+
{
534+
Screen.Value = ChannelsConfigScreen.AdapterMenu;
535+
Status.Value = new ConfigStatusMessage("Done adding channels. Completed changes are already saved.", ConfigStatusTone.Neutral);
536+
NotifyContentChanged();
537+
}
538+
511539
internal string? EditingAudienceLabel => _editingAudienceLabel;
512540
internal string? EditingAudienceId => _editingAudienceId;
513541
internal bool EditingAudienceIsDm => _editingAudienceIsDm;
@@ -1456,7 +1484,11 @@ internal sealed record ChannelPermissionRow(
14561484
string DisplayName,
14571485
TrustAudience Audience,
14581486
bool IsDirectMessage,
1459-
bool IsAddAction);
1487+
bool IsAddAction,
1488+
bool IsDoneAction)
1489+
{
1490+
internal bool IsAction => IsAddAction || IsDoneAction;
1491+
}
14601492

14611493
internal sealed record CredentialFieldSpec(
14621494
string Key,

src/Netclaw.Cli/Tui/Config/InboundWebhooksConfigPage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ private LayoutNode BuildStatusBar()
9191
.Height(1);
9292

9393
private LayoutNode BuildKeyBindings()
94-
=> NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space] Toggle/Save [Type] Edit timeout [Enter] Apply [Esc] Settings Areas [Ctrl+Q] Quit");
94+
=> NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space] Toggle/Save [Type/Paste] Edit timeout [Enter] Apply [Esc] Settings Areas [Ctrl+Q] Quit");
9595

9696
private void HandleKeyPress(KeyPressed key)
9797
{

src/Netclaw.Cli/Tui/Config/SkillSourcesConfigPage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ private LayoutNode BuildStatusBar()
8585
.Height(1);
8686

8787
private LayoutNode BuildKeyBindings()
88-
=> NetclawTuiChrome.BuildKeyHintLine(" [Up/Down] Navigate [Type/Paste] Edit [Backspace] Delete [Enter] Apply [Esc] Settings Areas [Ctrl+Q] Quit");
88+
=> NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Type/Paste] Edit [Backspace] Delete [Enter] Apply [Esc] Settings Areas [Ctrl+Q] Quit");
8989

9090
private void HandleKeyPress(KeyPressed key)
9191
{

0 commit comments

Comments
 (0)