-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathTui.cs
More file actions
4901 lines (4640 loc) · 240 KB
/
Copy pathTui.cs
File metadata and controls
4901 lines (4640 loc) · 240 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using AIOrchestrator;
using AgentBridge;
using AgentBridge.Resources;
using Terminal.Gui;
using Terminal.Gui.App;
using Terminal.Gui.Configuration;
using Terminal.Gui.Drawing;
using Terminal.Gui.Editor;
using Terminal.Gui.Editor.Document;
using Terminal.Gui.Editor.Rendering;
using Terminal.Gui.Input;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
// Terminal.Gui.Drawing.Attribute collides with System.Attribute (implicit using).
using TuiAttribute = Terminal.Gui.Drawing.Attribute;
// ═══════════════════════════════════════════════════════════════════════
// Terminal.Gui v2 — LOCAL DEVELOPER GUIDE (READ BEFORE EDITING THIS TUI)
// docs-dev/TUI-DEVELOPMENT.md is the offline reference for the pinned package
// versions: API cheat-sheet, pitfalls (focus, Invoke, Editor document
// mutation, console leak) and cross-platform (Windows/Linux/macOS) rules.
// The official API XML docs ship with the NuGet packages (see guide §1).
// ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// The Terminal.Gui terminal UI of AgentBridge: a menu bar, an AGENT logo panel, a
/// streaming chat panel with an input line, a status bar, slash-command and file
/// palettes, keyboard shortcuts and mouse support — while the HTTP server keeps
/// answering API calls in the same process. See README.md → "Terminal UI".
/// </summary>
public static class ConsoleTui
{
/// <summary>Runs the terminal UI against the server at <paramref name="serverUrl"/> until the user exits.</summary>
public static Task RunAsync(string serverUrl, string? hostError = null)
=> new Tui(serverUrl, hostError).RunAsync();
private sealed class Tui : IDisposable
{
private readonly IApplication _app;
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };
// The streamed chat call has NO total-time cap: the server executes agent tools
// synchronously and only streams the reply once the work finishes, so a long run
// (e.g. a CPU TTS podcast, several minutes without a single byte) would otherwise
// be cancelled client-side at the fixed 10-minute timeout of _http while the work
// was still progressing. The run stays cancellable via Esc (_chatCts) and ends on
// connection loss (the local server always terminates the stream). All the short
// control calls keep the capped _http client above.
private readonly HttpClient _chatHttp = new() { Timeout = System.Threading.Timeout.InfiniteTimeSpan };
private readonly string _serverUrl;
private readonly string? _hostError;
private readonly List<Entry> _history = new();
private readonly List<string> _promptHistory = new();
private readonly List<FileRef> _files = new();
private readonly List<string> _attached = new();
private readonly Dictionary<string, bool> _features = new();
private Window? _mainWindow;
private Editor? _chatView;
private Editor? _inputField;
private View? _inputArea;
private Label? _statusLabel;
private int _inputLines = 1;
private Scheme _baseScheme = new();
private bool _inputPlaceholderActive = true;
private bool _suppressCommandMenu;
private bool _disposed;
private View? _asciiBanner;
private bool _bannerVisible;
// Shared state (files/attachments/features/chat control) is touched from the
// background tasks (HTTP, streaming) as well as the UI thread: guard the
// collections and the chat CancellationTokenSource with one lock, and make
// the chat-running flag atomic so a double Enter cannot start two streams.
private readonly object _stateLock = new();
private bool _connected;
private int _chatRunning; // 0 idle, 1 generating (Interlocked)
private CancellationTokenSource? _chatCts;
private Entry? _pending;
private bool _followBottom = true;
private string _lastPrompt = "";
private bool _lastFailed;
private int _escCount;
private int _histIndex = -1;
private string _histDraft = "";
private string _provider = "";
private string _modelName = "";
private string _interactionMode = "";
private string _tokensLast = "";
private string _tokensSession = "";
private int _contextWindow;
private int _historyTokens;
private string _sessionId = "";
private string _agentSet = "default-agent";
// Non-null when the user enabled a custom tool combination in the /agent
// checklist (overrides the agent-set preset in the chat request via `tools`).
private List<string>? _customTools;
private bool _ttsAvailable, _voiceAvailable;
private string _ttsDetail = "", _voiceDetail = "";
private string _statusNote = "";
// Runtime payload tokens missing from this install (from capabilities.assets); empty
// when complete. Shown to the user in human terms, never as a technical error.
private string[] _assetsMissing = Array.Empty<string>();
private bool _assetsNoteShown;
// SIP telephony state (from GET /v1/sip/status; polled while the server reports it available).
private bool _sipAvailable;
private string _sipState = "";
// Telegram chat medium state (from GET /v1/telegram/status; polled while enabled).
private bool _telegramAvailable;
private string _telegramState = "";
private static readonly string[] SpinnerChars = { "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷" };
private int _spinnerIndex;
private volatile bool _spinnerActive;
private Label? _spinnerLabel;
// Top-right busy indicator (right end of the menu-bar row): shows the most important
// operation currently running — [indicizzazione…]/[indexing…] for the background
// document reindex (from AIOrchestrator.Setup.IndexingChanged), the chat stream, voice
// listening. Ops are tagged strings so several concurrent operations collapse into one.
private readonly object _busyLock = new();
private readonly HashSet<string> _busyOps = new(StringComparer.Ordinal);
private Label? _busyLabel;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private const int MaxHistory = 1000;
private const int MaxInputLines = 4;
// Right-end slot on the menu-bar row reserved for the busy indicator (overlay label).
private const int BusyLabelWidth = 20;
// UI strings come from the localized dictionary (system language, English fallback) —
// see Resources/Dictionary.*.resx. Command names (/help, /model...) are NOT translated.
private static string PlaceholderText => Dictionary.InputPlaceholder;
// Web GUI (Giraffe AI): a static chat client served by its own launcher, installed
// and kept up to date next to the executable by WebClientUpdater (see that file).
private sealed class Entry
{
public required string Role;
public required string Text;
public bool Error;
// Files the agent attached to this message (done method's "attachments"): each is
// saved to disk by the client so the terminal user can open/download it.
public List<string>? Attachments;
}
private sealed class FileRef
{
public required string Id;
public required string FileName;
public string Status = "";
public bool Attached;
}
// The single command registry: /help (alphabetical), the "/" palette and the menu
// bars are all generated from this list. MenuGroup picks the top-level menu where
// the command gets its voice (BuildUI logs a warning for commands that are
// forgotten there); MenuTitle is the short localized label used by that voice.
private sealed record CliCommand(
string Name, string Args, string Help, Func<Tui, string, Task> Run,
string[]? Aliases = null,
string? MenuGroup = null, string? MenuTitle = null, Key Shortcut = default);
private static readonly List<CliCommand> Commands = new()
{
// Chat
new("new", "", Dictionary.CmdNew, (t, _) => t.NewSessionAsync(), new[] { "/reset" },
MenuGroup: "chat", MenuTitle: Dictionary.MenuNewChat, Shortcut: Key.N.WithCtrl),
new("clear", "", Dictionary.CmdClear, (t, _) => t.ClearHistoryAsync(),
MenuGroup: "chat", MenuTitle: Dictionary.MenuClearHistory, Shortcut: Key.L.WithCtrl),
new("tts", "[text]", Dictionary.CmdTts, (t, a) => t.TtsAsync(a),
MenuGroup: "chat", MenuTitle: Dictionary.MenuTts),
new("retry", "", Dictionary.CmdRetry, (t, _) => t.RetryAsync(),
MenuGroup: "chat", MenuTitle: Dictionary.MenuRetryLast, Shortcut: Key.Y.WithCtrl),
new("exit", "", Dictionary.CmdExit, (t, _) => t.ExitAsync(), new[] { "/quit" },
MenuGroup: "chat", MenuTitle: Dictionary.MenuExit, Shortcut: Key.Q.WithCtrl),
// File
new("files", "add <path>|rm <id>|list", Dictionary.CmdFiles, (t, a) => t.FilesAsync(a),
MenuGroup: "file", MenuTitle: Dictionary.MenuFiles),
new("attach", "[id]", Dictionary.CmdAttach, (t, a) => t.AttachAsync(a),
MenuGroup: "file", MenuTitle: Dictionary.MenuAttach),
// Files the agent delivered in a reply are saved under attachments/ next to the
// executable; /open reveals them (folder by default, a specific file by name).
new("open", "[name]", Dictionary.CmdOpen, (t, a) => t.OpenAsync(a),
MenuGroup: "file", MenuTitle: Dictionary.MenuOpenAttachments, Shortcut: Key.O.WithCtrl),
// Settings (menu Impostazioni/Settings): three separate panels — LLM/Provider,
// Email (SMTP+IMAP) and General — each with its own Save button and validation,
// so a bad value in one area never blocks the others. Tool selection and the
// SIP/Telegram bridges stay here. Voice is NOT a setting (it starts a one-shot
// dictation) and lives under Session; /model switches only the CURRENT chat.
new("providers", "", Dictionary.CmdProviders, (t, _) => t.ShowProvidersPanelAsync(), new[] { "/setup", "/modelsetup" },
MenuGroup: "settings", MenuTitle: Dictionary.MenuProviders),
new("email", "", Dictionary.CmdEmail, (t, _) => t.ShowEmailPanelAsync(),
MenuGroup: "settings", MenuTitle: Dictionary.MenuEmailSettings),
new("general", "", Dictionary.CmdGeneral, (t, _) => t.ShowGeneralPanelAsync(),
MenuGroup: "settings", MenuTitle: Dictionary.MenuGeneralSettings),
new("tools", "[name]", Dictionary.CmdAgent, (t, a) => t.SwitchAgentAsync(a), new[] { "/agent" },
MenuGroup: "settings", MenuTitle: Dictionary.MenuTools),
new("ttsengine", "[name]", Dictionary.CmdTtsEngine, (t, a) => t.TtsEngineAsync(a),
MenuGroup: "settings", MenuTitle: Dictionary.MenuTtsEngine),
new("sip", "status|config [set <key> <value>|reload]|call <sip-uri>|answer on|off|hangup", Dictionary.CmdSip, (t, a) => t.SipAsync(a),
MenuGroup: "settings", MenuTitle: Dictionary.MenuSip),
new("telegram", "status|config [set <key> <value>|reload]|login-code <code>|allow|disallow <user>", Dictionary.CmdTelegram, (t, a) => t.TelegramAsync(a),
MenuGroup: "settings", MenuTitle: Dictionary.MenuTelegram),
// Session
new("model", "[name]", Dictionary.CmdModel, (t, a) => t.SwitchModelAsync(a),
MenuGroup: "session", MenuTitle: Dictionary.MenuLlmModel),
new("voice", "[lang]", Dictionary.CmdVoice, (t, a) => t.VoiceAsync(a),
MenuGroup: "session", MenuTitle: Dictionary.MenuVoice),
new("features", "[name] [on|off]", Dictionary.CmdFeatures, (t, a) => t.FeaturesAsync(a),
MenuGroup: "session", MenuTitle: Dictionary.MenuFeatures),
new("status", "", Dictionary.CmdStatus, (t, _) => t.ShowStatusAsync(),
MenuGroup: "session", MenuTitle: Dictionary.MenuStatus),
new("health", "", Dictionary.CmdHealth, (t, _) => t.HealthAsync(),
MenuGroup: "session", MenuTitle: Dictionary.MenuHealth),
// Web
new("web", "", Dictionary.CmdWeb, (t, _) => t.LaunchWebClientAsync(),
MenuGroup: "web", MenuTitle: Dictionary.MenuGui),
new("officemanager", "", Dictionary.CmdOfficeManager, (t, _) => t.LaunchOfficeManagerAsync(),
MenuGroup: "web", MenuTitle: Dictionary.MenuOfficeManager),
// Help
new("help", "", Dictionary.CmdHelp, (t, _) => t.ShowHelpAsync(), new[] { "/?" },
MenuGroup: "help", MenuTitle: Dictionary.MenuHelpItem, Shortcut: Key.F1),
new("shortcuts", "", Dictionary.CmdShortcuts, (t, _) => t.ShowShortcutsAsync(), new[] { "/keys" },
MenuGroup: "help", MenuTitle: Dictionary.MenuShortcuts),
new("docs", "", Dictionary.CmdDocs, (t, _) => t.OpenDocsAsync(),
MenuGroup: "help", MenuTitle: Dictionary.MenuDocumentation),
new("update", "", Dictionary.CmdUpdate, (t, _) => t.UpdateAsync(),
MenuGroup: "help", MenuTitle: Dictionary.MenuCheckUpdates),
// No menu voice: the Help menu hosts a dedicated state toggle for this one
// (crashReportItem in BuildUI) — see ValidateMenuCoverage.
new("crashreport", "", Dictionary.CmdCrashReport, (t, _) => t.CrashReportAsync()),
};
private const uint SndAsync = 0x0001;
private const uint SndFilename = 0x00020000;
[DllImport("winmm.dll", SetLastError = true)]
private static extern bool PlaySound(string pszSound, IntPtr hmod, uint fdwSound);
// ── AGENT ASCII art ──
// ONE definition (embedded resource, see the csproj) used by both the startup
// banner above the chat and Help → About. The per-line gradient follows the
// Qwen CLI brand (#4796E4 → #847ACE → #C3677F → BrightBlue/BrightMagenta/BrightRed).
private static readonly string[] AsciiArtLines = LoadAsciiArt();
private static readonly TuiAttribute[] AsciiArtColors =
{
new(Color.BrightBlue, Color.Black),
new(Color.BrightBlue, Color.Black),
new(Color.BrightMagenta, Color.Black),
new(Color.BrightMagenta, Color.Black),
new(Color.BrightRed, Color.Black),
new(Color.BrightRed, Color.Black),
};
private static string[] LoadAsciiArt()
{
try
{
using var s = typeof(ConsoleTui).Assembly
.GetManifestResourceStream("AgentBridge.assets.agent-ascii-art.txt");
if (s == null) return Array.Empty<string>();
using var r = new StreamReader(s);
return r.ReadToEnd().Replace("\r\n", "\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
}
catch { return Array.Empty<string>(); }
}
private static readonly (string Keys, string What)[] ShortcutTable =
{
("Enter", Dictionary.ShortEnter),
("/", Dictionary.ShortSlash),
("@", Dictionary.ShortAt),
("?", Dictionary.ShortQuestion),
("Tab", Dictionary.ShortTab),
("Esc", Dictionary.ShortEsc),
("Ctrl+C", Dictionary.ShortCtrlC),
("Ctrl+D", Dictionary.ShortCtrlD),
("Ctrl+Y", Dictionary.ShortCtrlY),
("Ctrl+O", Dictionary.ShortOpenAttachments),
("Ctrl+R", Dictionary.ShortCtrlR),
("Up / Down", Dictionary.ShortUpDown),
("Left / Right", Dictionary.ShortLeftRight),
("Ctrl+A / Ctrl+E", Dictionary.ShortCtrlAE),
("Ctrl+U / Ctrl+K", Dictionary.ShortCtrlUK),
("Ctrl+W", Dictionary.ShortCtrlW),
("PgUp / PgDn", Dictionary.ShortPgUpDn),
("F1", Dictionary.ShortF1),
("F10", Dictionary.ShortF10),
};
public Tui(string serverUrl, string? hostError)
{
_serverUrl = serverUrl;
_hostError = hostError;
_http.BaseAddress = new Uri(serverUrl);
_chatHttp.BaseAddress = new Uri(serverUrl);
_app = Application.Create().Init();
PuppetMode._app = _app; // Share instance with PuppetMode (debug-only control surface)
if (PuppetMode.Enabled) PuppetMode.StartPump();
// Surface auto-update progress in the status bar (fires from background tasks).
AutoUpdate.OnStatus += OnUpdateStatus;
// Background document reindex events → top-right busy indicator. Subscribing and
// the IsIndexingNow probe never create the processor, so merely running the TUI
// cannot trigger a multi-minute index (see AIOrchestrator.Setup).
AIOrchestrator.Setup.IndexingChanged += OnIndexingChanged;
if (AIOrchestrator.Setup.IsIndexingNow) SetBusy("indexing", true);
// Modern dark theme for the whole main window (views reference it by name).
_baseScheme = new Scheme
{
Normal = new TuiAttribute(Color.White, Color.Black),
Focus = new TuiAttribute(Color.Black, Color.BrightCyan),
HotNormal = new TuiAttribute(Color.BrightCyan, Color.Black),
HotFocus = new TuiAttribute(Color.Black, Color.BrightMagenta),
};
SchemeManager.AddScheme("Dark", _baseScheme);
SchemeManager.AddScheme("Hint", new Scheme
{
Normal = new TuiAttribute(Color.Gray, Color.Black),
Focus = new TuiAttribute(Color.Gray, Color.Black),
});
// Busy indicator (top-right of the menu-bar row): its background is the MENU
// BAR's own background (read from the theme's "Menu" scheme) rather than opaque
// black, and the ink is dark gray — readable, yet distinct from the black menu
// item labels it sits next to.
SchemeManager.AddScheme("Busy", new Scheme
{
Normal = new TuiAttribute(Color.DarkGray, MenuBarBackground()),
Focus = new TuiAttribute(Color.DarkGray, MenuBarBackground()),
});
for (int i = 0; i < Math.Min(AsciiArtLines.Length, AsciiArtColors.Length); i++)
SchemeManager.AddScheme($"Ascii{i}", new Scheme { Normal = AsciiArtColors[i] });
BuildUI();
_history.Add(new Entry
{
Role = "system",
Text = Dictionary.WelcomeMessage,
});
_history.Add(new Entry { Role = "system", Text = string.Format(Dictionary.ServerNote, _serverUrl) });
if (!string.IsNullOrEmpty(_hostError))
_history.Add(new Entry { Role = "system", Text = string.Format(Dictionary.HostErrorNote, _hostError) });
// The /tools selection is persistent (toolset.json under PersistentData): the
// custom combination or preset chosen in the last run is applied now, before the
// first chat request, so the enabled tools survive a TUI restart.
RestoreAgentSelection();
}
public Task RunAsync()
{
if (_mainWindow is { } window)
{
// Give the input the focus once the window is up. The framework's initial
// focus lands on the first focusable child (the menu bar), so re-assert
// the input focus after the first iterations: without it, typing and the
// Esc/Ctrl+C/Ctrl+D exit handling never reach the input.
window.Initialized += (_, _) => _inputField?.SetFocus();
_app.AddTimeout(TimeSpan.FromMilliseconds(60), () =>
{
_inputField?.SetFocus();
UpdateInputLayout();
return false; // one-shot
});
_app.AddTimeout(TimeSpan.FromMilliseconds(100), () =>
{
TickSpinner();
return true; // recurring spinner animation
});
RefreshHistory();
_ = Task.Run(RefreshServerStateAsync);
StartSipPolling();
try
{
_app.Run(window);
}
finally
{
CancelChat();
Dispose();
}
}
return Task.CompletedTask;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
AutoUpdate.OnStatus -= OnUpdateStatus;
AIOrchestrator.Setup.IndexingChanged -= OnIndexingChanged;
CancelChat();
try { _app.Dispose(); } catch { }
_http.Dispose();
_chatHttp.Dispose();
}
private void CancelChat()
{
lock (_stateLock) _chatCts?.Cancel();
}
// ── UI-thread marshalling ──
// Terminal.Gui mutates views only on the main loop thread. Every UI touch
// goes through Ui(); background tasks (HTTP, streaming) queue their updates
// via IApplication.Invoke. Actions posted after dispose are dropped.
private void Ui(Action action)
{
if (_disposed) return;
try { _app.Invoke(action); } catch { }
}
// ── Layout ──
// Slash commands whose menu voice is a custom item (state shown in the title,
// not just "run the command") — see ValidateMenuCoverage. "attach" is folded into
// the unified File panel (its toggle lives there), so it keeps the slash command
// but no separate menu voice.
private static readonly HashSet<string> MenuVoiceExempt = new(StringComparer.Ordinal) { "crashreport", "attach" };
// Commands placed in the menus by CommandMenuItem (see ValidateMenuCoverage).
private readonly HashSet<string> _menuVoices = new(StringComparer.Ordinal);
// Menu voice for a slash command: the label, the accelerator and the action come
// from the command's registry entry, and the action runs through
// RunCommandByName — the same guarded path as typing "/name". Every placed
// command is recorded so ValidateMenuCoverage can flag forgotten ones.
private MenuItem CommandMenuItem(string name)
{
var cmd = Commands.FirstOrDefault(c => c.Name == name);
_menuVoices.Add(name);
if (cmd == null)
{
Log.LogStep($"TUI menu: '/{name}' is not in the Commands registry", monitor: true);
return new MenuItem(name, Key.Empty, () => { });
}
return new MenuItem(cmd.MenuTitle ?? cmd.Help, cmd.Shortcut, () => RunCommandByName(cmd.Name, ""));
}
// Every slash command must be reachable from the menus. New commands must either
// get a CommandMenuItem("name") above or be listed in MenuVoiceExempt; anything
// forgotten shows up here as a loud startup warning, so /help, the "/" palette
// and the menu bars stay in sync by construction.
private void ValidateMenuCoverage()
{
var missing = Commands.Select(c => c.Name)
.Where(n => !_menuVoices.Contains(n) && !MenuVoiceExempt.Contains(n))
.ToList();
if (missing.Count == 0) return;
Log.LogStep(
$"TUI menu: commands without a menu voice: {string.Join(", ", missing.Select(n => "/" + n))} — add them in BuildUI (see Commands registry)",
monitor: true);
}
private void BuildUI()
{
_mainWindow = new Window
{
Title = Dictionary.WindowTitle,
X = 0, Y = 0, Width = Dim.Fill(), Height = Dim.Fill(),
SchemeName = "Dark",
};
// Terminal.Gui v2 has no checkmark on menu items — the state is shown in the title.
MenuItem autoUpdateItem = null!;
autoUpdateItem = new MenuItem(string.Format(Dictionary.MenuAutoUpdate, AutoUpdate.Enabled ? Dictionary.On : Dictionary.Off), Key.Empty, () =>
{
AutoUpdate.Toggle();
Log.LogStep($"TUI AutoUpdate toggled: {AutoUpdate.Enabled}", monitor: true);
autoUpdateItem.Title = string.Format(Dictionary.MenuAutoUpdate, AutoUpdate.Enabled ? Dictionary.On : Dictionary.Off);
});
// Crash-diagnostics toggle: whether sanitized crash reports are sent to the GitHub
// repository (see CrashReporter.cs). State shown in the title, like Auto-Update.
MenuItem crashReportItem = null!;
crashReportItem = new MenuItem(string.Format(Dictionary.MenuCrashReport, CrashReporter.Enabled ? Dictionary.On : Dictionary.Off), Key.Empty, () =>
{
CrashReporter.Toggle();
Log.LogStep($"TUI CrashReport toggled: {CrashReporter.Enabled}", monitor: true);
AddNote(CrashReporter.Enabled ? Dictionary.NoteCrashReportEnabled : Dictionary.NoteCrashReportDisabled);
crashReportItem.Title = string.Format(Dictionary.MenuCrashReport, CrashReporter.Enabled ? Dictionary.On : Dictionary.Off);
});
// Menus are assembled from the Commands registry (single source): each item
// below is a command whose label/accelerator/action come from its registry
// entry, so /help, the "/" palette and the menus can never drift apart.
// ValidateMenuCoverage warns when a command was forgotten here.
var menu = new MenuBar(new MenuBarItem[]
{
new(Dictionary.MenuChat, new MenuItem[]
{
CommandMenuItem("new"),
CommandMenuItem("clear"),
CommandMenuItem("tts"),
new MenuItem(Dictionary.MenuCommands, Key.Empty, () => ShowCommandMenu("")),
CommandMenuItem("retry"),
CommandMenuItem("exit"),
}),
new(Dictionary.MenuFile, new MenuItem[]
{
CommandMenuItem("files"),
CommandMenuItem("open"),
}),
new(Dictionary.MenuSettings, new MenuItem[]
{
CommandMenuItem("providers"),
CommandMenuItem("email"),
CommandMenuItem("general"),
CommandMenuItem("tools"),
CommandMenuItem("ttsengine"),
CommandMenuItem("sip"),
CommandMenuItem("telegram"),
}),
new(Dictionary.MenuSession, new MenuItem[]
{
CommandMenuItem("model"),
CommandMenuItem("voice"),
CommandMenuItem("features"),
CommandMenuItem("status"),
CommandMenuItem("health"),
}),
new(Dictionary.MenuWeb, new MenuItem[]
{
CommandMenuItem("web"),
CommandMenuItem("officemanager"),
}),
new(Dictionary.MenuHelp, new MenuItem[]
{
autoUpdateItem,
crashReportItem,
CommandMenuItem("update"),
CommandMenuItem("help"),
CommandMenuItem("shortcuts"),
CommandMenuItem("docs"),
new MenuItem(Dictionary.MenuIssues, Key.Empty, () => OpenIssuesAsync()),
new MenuItem(Dictionary.MenuAbout, Key.Empty, () => ShowAbout()),
}),
});
_mainWindow.Add(menu);
// Busy indicator on the right end of the menu-bar row. Terminal.Gui's MenuBar
// has no right-side widget slot, so a small overlay label (added after the menu,
// therefore drawn above it) shows the current operation; it is hidden when idle.
// The "Busy" scheme paints dark-gray ink over the MENU BAR's OWN background (see
// MenuBarBackground) — the label's clear+fill is then invisible against the bar,
// no black rectangle, and switching between operations cannot leave ghost text.
_busyLabel = new Label
{
Text = "",
X = Pos.AnchorEnd(BusyLabelWidth), Y = 0, Width = BusyLabelWidth,
TextAlignment = Alignment.End,
SchemeName = "Busy",
Visible = false,
};
_mainWindow.Add(_busyLabel);
ValidateMenuCoverage();
// Esc never quits the app directly: it is handled by the focused view
// (input line, dialogs, menus). Guard the window's default Esc→Quit
// binding so an Esc pressed on a non-handling view (e.g. the send
// button) cannot close the whole UI accidentally.
_mainWindow.KeyDown += (_, key) =>
{
if (key == Key.Esc) key.Handled = true;
// Puppet mode only: PrintScreen dumps the current screen to a file.
else if (PuppetMode.Enabled && key == Key.PrintScreen)
{
key.Handled = true;
PuppetCapture();
}
};
// Content area below the menu bar (status line + StatusBar own the last
// two rows). CanFocus: a plain View defaults to CanFocus=false, which
// would block focus for every focusable child below it (the input field).
var contentArea = new View
{
X = 0, Y = 1, Width = Dim.Fill(), Height = Dim.Fill() - 3,
CanFocus = true,
};
_mainWindow.Add(contentArea);
// Chat panel: history + input line, full width. The AGENT ASCII-art banner
// (gradient) sits above the chat at startup — the welcome message is the
// first history entry below it — and collapses on the first chat message.
var chatFrame = new FrameView
{
Title = Dictionary.ChatFrameTitle,
X = 0, Y = 0,
Width = Dim.Fill(), Height = Dim.Fill(),
};
contentArea.Add(chatFrame);
if (AsciiArtLines.Length > 0)
{
_asciiBanner = new View
{
X = 0, Y = 0, Width = Dim.Fill(), Height = AsciiArtLines.Length + 1,
};
for (int i = 0; i < AsciiArtLines.Length; i++)
_asciiBanner.Add(new Label { Text = AsciiArtLines[i], X = 1, Y = i, SchemeName = $"Ascii{i}" });
chatFrame.Add(_asciiBanner);
_bannerVisible = true;
}
_chatView = new Editor
{
X = 0, Y = _bannerVisible ? Pos.Bottom(_asciiBanner!) : 0, Width = Dim.Fill(), Height = Dim.Fill() - 1,
ReadOnly = true,
WordWrap = true,
CanFocus = false,
SchemeName = "Dark",
};
// User messages are repainted in the menu-bar celeste so they never read as
// agent text (both are otherwise the same white-on-black scheme).
_chatView.LineTransformers.Add(new ChatRoleColorizer(_chatView, MenuBarBackground()));
chatFrame.Add(_chatView);
// Auto-follow the stream only while the user is at the bottom; scrolling
// up (wheel or PgUp) stops the yank until they scroll down or send a message.
_chatView.MouseEvent += (_, e) =>
{
if ((e.Flags & MouseFlags.WheeledUp) != 0) _followBottom = false;
else if ((e.Flags & MouseFlags.WheeledDown) != 0) _followBottom = true;
};
var inputArea = new View
{
X = 0, Y = Pos.Bottom(_chatView), Width = Dim.Fill(), Height = 1,
CanFocus = true,
};
chatFrame.Add(inputArea);
_inputArea = inputArea;
// One-character spinner shown next to the prompt while generating.
_spinnerLabel = new Label
{
Text = " ",
X = 0, Y = 0, Width = 1,
SchemeName = "Dark",
};
inputArea.Add(_spinnerLabel);
// Multi-line prompt box: soft-wraps and grows up to MaxInputLines rows (see
// UpdateInputLayout); full width minus the spinner column, so it reaches
// the right margin of the frame without covering the spinner.
_inputField = new Editor
{
X = 1, Y = 0, Width = Dim.Fill() - 1, Height = 1,
WordWrap = true,
Multiline = true,
GutterOptions = GutterOptions.None,
};
SetPlaceholder();
_inputField.HasFocusChanged += OnInputFocusChanged;
_inputField.KeyDown += OnInputKeyDown;
_inputField.ContentChanged += (_, _) => { OnInputChanged(); UpdateInputLayout(); };
_inputField.ViewportChanged += (_, _) => UpdateInputLayout();
inputArea.Add(_inputField);
// The Editor consumes the movement keys natively; at a text boundary the key
// is left unhandled and would bubble up to the Application-level arrow-key
// focus navigation, moving the focus out of the prompt. Swallow the movement
// keys at the input's parent so the prompt can never lose focus to an arrow.
inputArea.KeyDownNotHandled += (_, key) =>
{
if (key == Key.CursorLeft || key == Key.CursorRight
|| key == Key.CursorUp || key == Key.CursorDown
|| key == Key.CursorLeft.WithCtrl || key == Key.CursorRight.WithCtrl
|| key == Key.CursorUp.WithCtrl || key == Key.CursorDown.WithCtrl
|| key == Key.Home || key == Key.End
|| key == Key.PageUp || key == Key.PageDown)
key.Handled = true;
};
// Status line (dynamic state: connection, provider/model, tools, context,
// capabilities, notes) above a real StatusBar with the static key hints.
_statusLabel = new Label
{
X = 0, Y = Pos.AnchorEnd(2), Width = Dim.Fill(), Height = 1,
SchemeName = "Hint",
Text = "",
};
_mainWindow.Add(_statusLabel);
_mainWindow.Add(new StatusBar(new[]
{
new Shortcut { Title = Dictionary.StatusBarHints },
}));
}
// ── Input field ──
private void SetPlaceholder()
{
if (_inputField == null) return;
_inputPlaceholderActive = true;
_inputField.Text = PlaceholderText;
_inputField.SchemeName = "Hint";
}
private void ClearPlaceholder()
{
if (_inputField == null) return;
_inputPlaceholderActive = false;
_inputField.Text = "";
_inputField.SchemeName = "Dark";
}
private void OnInputFocusChanged(object? sender, HasFocusEventArgs e)
{
if (e.NewValue)
{
if (_inputPlaceholderActive) ClearPlaceholder();
}
else if (string.IsNullOrWhiteSpace(_inputField?.Text))
{
SetPlaceholder();
}
}
private void OnInputChanged()
{
if (_suppressCommandMenu || _inputPlaceholderActive) return;
var t = _inputField?.Text ?? "";
if (t == "/")
{
_suppressCommandMenu = true;
ShowCommandMenu("");
_suppressCommandMenu = false;
ClearInputWhen("/");
}
else if (t == "@")
{
_suppressCommandMenu = true;
ShowFilesDialog();
_suppressCommandMenu = false;
ClearInputWhen("@");
}
else if (t == "?")
{
_suppressCommandMenu = true;
_ = ShowShortcutsAsync();
_suppressCommandMenu = false;
ClearInputWhen("?");
}
}
// Fallback for when a trigger character (/, @, ?) reaches the input by paste
// (typing is intercepted in OnInputKeyDown before insertion). This handler runs
// inside the Editor's DocumentChanged callback, so mutating the document here
// throws "Cannot change document within another document change"; Application.Invoke
// executes synchronously on the main thread, so the clear must go through a
// main-loop timeout, which fires only after the change completes.
private void ClearInputWhen(string expected)
{
_app.AddTimeout(TimeSpan.Zero, () =>
{
if (_inputField != null && _inputField.Text == expected) _inputField.Text = "";
return false; // one-shot
});
}
private void OnInputKeyDown(object? sender, Key key)
{
if (_inputPlaceholderActive)
ClearPlaceholder(); // the first keystroke dismisses the hint, then falls through
// "/" "@" "?" only act as the first character. Intercepting them here
// (before the Editor inserts the char) keeps the trigger character out of
// the document: when the palette runs inside the Editor's DocumentChanged
// handler, clearing it back throws "Cannot change document within another
// document change" (Application.Invoke executes synchronously on the main
// thread, so even a deferred clear failed). Consuming the key avoids the
// insert entirely — no crash, no lingering "/".
if (!_suppressCommandMenu && (_inputField?.Text ?? "").Length == 0)
{
// Opening a palette/dialog is a fresh start: reset the double-Esc exit
// counter (these keys are consumed and no longer reach the reset below).
if (key == (Key)'/')
{
key.Handled = true;
_escCount = 0;
ShowCommandMenu("");
return;
}
if (key == (Key)'@')
{
key.Handled = true;
_escCount = 0;
ShowFilesDialog();
return;
}
if (key == (Key)'?')
{
key.Handled = true;
_escCount = 0;
_ = ShowShortcutsAsync();
return;
}
}
if (key == Key.Enter && !key.IsShift)
{
key.Handled = true;
Submit();
}
else if (key == Key.Enter.WithShift)
{
// Shift+Enter inserts a newline (the Editor binds only plain Enter to NewLine).
key.Handled = true;
if (_inputField is { Document: { } doc })
{
var at = _inputField.CaretOffset;
doc.Insert(at, "\n");
_inputField.CaretOffset = at + 1;
}
}
else if (key == Key.Esc)
{
// Keep the window's default "Esc quits" binding from firing: Esc here
// clears the input, and twice on an empty input exits the app.
key.Handled = true;
if ((_inputField?.Text ?? "").Length > 0)
{
_inputField!.Text = "";
}
else if (++_escCount >= 2)
{
RequestExit();
}
else
{
_statusNote = Dictionary.StatusEscAgain;
UpdateStatusUi();
}
}
else if (key == Key.C.WithCtrl)
{
key.Handled = true;
if (_chatRunning != 0)
{
CancelChat();
_statusNote = Dictionary.StatusCancelling;
UpdateStatusUi();
}
else if ((_inputField?.Text ?? "").Length > 0)
{
_inputField!.Text = "";
}
else if (++_escCount >= 2)
{
RequestExit();
}
else
{
_statusNote = Dictionary.StatusCtrlCAgain;
UpdateStatusUi();
}
}
else if (key == Key.D.WithCtrl)
{
// Exit on an empty input; otherwise the Editor's native
// "delete char in front" applies (same guard as the old TUI).
if ((_inputField?.Text ?? "").Length == 0)
{
key.Handled = true;
RequestExit();
}
}
else if (key == Key.Y.WithCtrl)
{
key.Handled = true;
_ = RetryAsync();
}
else if (key == Key.R.WithCtrl)
{
key.Handled = true;
ReverseSearch();
}
else if (key == Key.F1)
{
key.Handled = true;
_ = ShowHelpAsync();
}
else if (key == Key.O.WithCtrl)
{
// Open the attachments the agent delivered in replies (the folder; a specific
// file via /open <name>). Ctrl+O is the plain "open" convention.
key.Handled = true;
_ = OpenAsync("");
}
else if (key == Key.P.WithCtrl || (key == Key.CursorUp && CaretOnFirstLine()))
{
key.Handled = true;
HistoryPrev();
}
else if (key == Key.N.WithCtrl || (key == Key.CursorDown && CaretOnLastLine()))
{
key.Handled = true;
HistoryNext();
}
else if (key == Key.U.WithCtrl)
{
// Delete from the start of the current line to the insertion point.
key.Handled = true;
if (_inputField is { Document: { } doc })
{
var caret = _inputField.CaretOffset;
var line = doc.GetLineByOffset(caret);
doc.Remove(line.Offset, caret - line.Offset);
}
}
else if (key == Key.K.WithCtrl)
{
// Delete from the insertion point to the end of the current line.
key.Handled = true;
if (_inputField is { Document: { } doc })
{
var caret = _inputField.CaretOffset;
var line = doc.GetLineByOffset(caret);
doc.Remove(caret, line.Offset + line.Length - caret);
}
}
else if (key == Key.W.WithCtrl)
{
// Delete the word before the insertion point.
key.Handled = true;
if (_inputField is { Document: { } doc })
{
var caret = _inputField.CaretOffset;
var t = doc.Text;
var start = caret;
while (start > 0 && char.IsWhiteSpace(t[start - 1])) start--;
while (start > 0 && !char.IsWhiteSpace(t[start - 1])) start--;
if (start < caret) doc.Remove(start, caret - start);
}
}
else if (key == Key.PageUp)
{
key.Handled = true;
_followBottom = false;
_chatView?.ScrollVertical(-(_chatView.Viewport.Height - 1));
}
else if (key == Key.PageDown)
{
key.Handled = true;
_followBottom = true;
_chatView?.ScrollVertical(_chatView.Viewport.Height - 1);
}
else
{
_escCount = 0;
}
}
// ── Multi-line input layout ──
// The prompt box height tracks its wrapped content (max MaxInputLines rows) and
// the chat panel shrinks accordingly. Re-runs on content changes and viewport
// resizes (the wrap column is the editor's viewport width).
private void UpdateInputLayout()
{
if (_inputField is not { } ed || _inputArea is not { } area || _chatView is not { } chat) return;
if (ed.Viewport.Width <= 0) return; // not laid out yet
var rows = Math.Clamp(EstimateWrapRows(ed.Text ?? "", ed.Viewport.Width), 1, MaxInputLines);
if (rows == _inputLines) return;
_inputLines = rows;
ed.Height = rows;
area.Height = rows;
chat.Height = Dim.Fill() - rows - BannerRows;
}
// Rows the startup ASCII-art banner occupies above the chat (0 once collapsed).
private int BannerRows => _bannerVisible ? AsciiArtLines.Length + 1 : 0;
// The banner gives way to the conversation on the first chat message.
private void CollapseBanner()
{
if (!_bannerVisible || _asciiBanner == null || _chatView == null) return;
Log.LogStep("TUI banner collapsed (first chat message)", monitor: true);
_bannerVisible = false;
_asciiBanner.SuperView?.Remove(_asciiBanner);
_chatView.Y = 0;
_inputLines = 0; // force UpdateInputLayout to recompute the chat height
UpdateInputLayout();
}