-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtui.cpp
More file actions
2894 lines (2742 loc) · 111 KB
/
Copy pathtui.cpp
File metadata and controls
2894 lines (2742 loc) · 111 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
#include "tui.hpp"
#include "clipboard.hpp"
#include "net/json.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/event.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp>
#include <ftxui/screen/color.hpp>
#include <ftxui/screen/terminal.hpp>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <exception>
#include <fstream>
#include <functional>
#include <iostream>
#include <mutex>
#include <set>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
// wingdi.h's RGB(r,g,b) macro clobbers ftxui::Color::RGB(...) call sites
// textually - ftxui/screen/color.hpp already #undefs it for its own header,
// but that doesn't protect a later windows.h include re-defining it, so undo
// it again here for the rest of this file.
#undef RGB
#endif
namespace droidcli::cli {
namespace {
// "HH:MM:SS", matching DroidHost::make_log_timestamp()'s format (the one
// already shown in log_view/tools_view/apps_view) - that method is private to
// DroidHost, so the chat panel gets its own copy rather than exposing it.
std::string current_time_hms()
{
const std::time_t raw_time = std::time(nullptr);
std::tm local_time {};
#if defined(_WIN32)
localtime_s(&local_time, &raw_time);
#else
localtime_r(&raw_time, &local_time);
#endif
char buffer[16] {};
std::strftime(buffer, sizeof(buffer), "%H:%M:%S", &local_time);
return buffer;
}
// Extracts each top-level object of a named JSON array, e.g. {"tasks":[{...}]}.
// Hand-rolled brace-depth walk, mirroring extract_connector_objects() in
// droidcli.cpp - consistent with the rest of net/json.hpp's no-JSON-library
// convention, generalized here to any array key so it covers connectors,
// tasks, and app-log entries with one function.
std::vector<std::string> extract_json_object_array(const std::string& json, const std::string& key)
{
std::vector<std::string> objects;
const std::string needle = "\"" + key + "\":";
const size_t array_key = json.find(needle);
if (array_key == std::string::npos)
{
return objects;
}
size_t cursor = json.find('[', array_key);
if (cursor == std::string::npos)
{
return objects;
}
++cursor;
while (cursor < json.size())
{
while (cursor < json.size() && (json[cursor] == ' ' || json[cursor] == '\t'
|| json[cursor] == '\n' || json[cursor] == '\r' || json[cursor] == ','))
{
++cursor;
}
if (cursor >= json.size() || json[cursor] == ']')
{
break;
}
if (json[cursor] != '{')
{
break;
}
size_t depth = 0;
const size_t start = cursor;
for (; cursor < json.size(); ++cursor)
{
if (json[cursor] == '{')
{
++depth;
}
else if (json[cursor] == '}')
{
--depth;
if (depth == 0)
{
++cursor;
break;
}
}
}
objects.push_back(json.substr(start, cursor - start));
}
return objects;
}
struct ConnectorRow {
std::string id;
std::string kind;
std::string base_url;
std::string launch_cmd;
bool enabled = true;
std::string live_status = "unknown";
};
struct TaskRow {
std::string id;
std::string connector_id;
std::string command;
std::string status;
// Absolute epoch-ms deadline (0 = runnable immediately) - see
// Task::scheduled_for_ms, src/app/tasks.hpp. Rendered as a countdown in
// the Tasks panel while still in the future.
int64_t scheduled_for_ms = 0;
};
std::vector<ConnectorRow> parse_connectors(const std::string& json)
{
std::vector<ConnectorRow> rows;
for (const std::string& object : extract_json_object_array(json, "connectors"))
{
ConnectorRow row;
row.id = net::extract_json_string_field(object, "id");
row.kind = net::extract_json_string_field(object, "kind");
row.base_url = net::extract_json_string_field(object, "base_url");
row.launch_cmd = net::extract_json_string_field(object, "launch_cmd");
bool enabled = true;
if (net::extract_json_bool_field(object, "enabled", enabled))
{
row.enabled = enabled;
}
if (!row.id.empty())
{
rows.push_back(row);
}
}
return rows;
}
std::vector<TaskRow> parse_tasks(const std::string& json)
{
std::vector<TaskRow> rows;
for (const std::string& object : extract_json_object_array(json, "tasks"))
{
TaskRow row;
row.id = net::extract_json_string_field(object, "id");
row.connector_id = net::extract_json_string_field(object, "connector_id");
row.command = net::extract_json_string_field(object, "command");
row.status = net::extract_json_string_field(object, "status");
int64_t scheduled_for_ms = 0;
net::extract_json_int_field(object, "scheduled_for_ms", scheduled_for_ms);
row.scheduled_for_ms = scheduled_for_ms;
if (!row.id.empty())
{
rows.push_back(row);
}
}
return rows;
}
// One remembered name -> path mapping from DroidHost::list_known_locations_json()
// (see KnownLocation, cli/memory_store.hpp) - the Locations panel's second
// bullet group, below the system-locations one.
struct LocationRow {
std::string name;
std::string resolved_path;
std::string updated_at;
};
// One system-level location (cwd, Desktop, Home, Documents, Downloads,
// Program Files - see SystemInfo, cli/system_info.hpp) - "where we are and
// what this machine looks like," not something the model explicitly
// remembered. Same {name, path} shape as LocationRow so both render as
// identical bullets in the Locations panel; kept as a separate struct/list
// since these two groups have different provenance (a live OS query vs. a
// persisted memory) worth keeping visually distinct.
struct LocationEntry {
std::string name;
std::string path;
};
// Parsed GET /api/locations response: {"ok":true,"cwd":...,"desktop_path":...,
// "system_locations":[{"name":...,"path":...}],
// "known_locations":[{"name":...,"resolved_path":...,"updated_at":...}]}.
struct LocationsSnapshot {
std::vector<LocationEntry> system_locations;
std::vector<LocationRow> remembered;
};
LocationsSnapshot parse_locations(const std::string& json)
{
LocationsSnapshot snapshot;
const std::string cwd = net::extract_json_string_field(json, "cwd");
if (!cwd.empty())
{
snapshot.system_locations.push_back(LocationEntry{"Current Directory", cwd});
}
const std::string desktop_path = net::extract_json_string_field(json, "desktop_path");
if (!desktop_path.empty())
{
snapshot.system_locations.push_back(LocationEntry{"Desktop", desktop_path});
}
for (const std::string& object : extract_json_object_array(json, "system_locations"))
{
LocationEntry entry;
entry.name = net::extract_json_string_field(object, "name");
entry.path = net::extract_json_string_field(object, "path");
if (!entry.name.empty())
{
snapshot.system_locations.push_back(entry);
}
}
for (const std::string& object : extract_json_object_array(json, "known_locations"))
{
LocationRow row;
row.name = net::extract_json_string_field(object, "name");
row.resolved_path = net::extract_json_string_field(object, "resolved_path");
row.updated_at = net::extract_json_string_field(object, "updated_at");
if (!row.name.empty())
{
snapshot.remembered.push_back(row);
}
}
return snapshot;
}
// One entry of GET /api/app/log, kept structured (not flattened to a string)
// so the log panel can color by channel/success and pick out real tool
// executions ("tool <name>(...) -> ...", see append_app_log calls in
// cli/host.cpp) instead of narrated chat text - see "Phase 9 follow-up:
// log coloring + execution visibility" in ARCHITECTURE.md.
struct LogRow {
std::string timestamp;
std::string channel;
std::string direction;
std::string summary;
bool success = true;
};
std::vector<LogRow> parse_log_lines(const std::string& json)
{
std::vector<LogRow> rows;
for (const std::string& object : extract_json_object_array(json, "entries"))
{
LogRow row;
row.timestamp = net::extract_json_string_field(object, "timestamp");
row.channel = net::extract_json_string_field(object, "channel");
row.direction = net::extract_json_string_field(object, "direction");
row.summary = net::extract_json_string_field(object, "summary");
bool success = true;
if (net::extract_json_bool_field(object, "success", success))
{
row.success = success;
}
rows.push_back(row);
}
return rows;
}
// Channels that mean droidcli actually launched or ran something on the
// host machine (a real process, not just chat narration) - "run" (run_command),
// "ffmpeg" (run_ffmpeg), "open" (open_application), "process"
// (launch_connector/stop_connector via ProcessManager). Used to give process
// launches their own unmistakable color in the log panel.
bool is_process_launch_channel(const std::string& channel)
{
return channel == "run" || channel == "ffmpeg" || channel == "open" || channel == "process";
}
// Boils DroidHost::connector_status_json()'s two response shapes (launched_process
// vs http_peer, see cli/host.cpp) down to one short display string.
std::string summarize_status(const std::string& kind, const std::string& status_json)
{
bool ok = false;
net::extract_json_bool_field(status_json, "ok", ok);
if (!ok)
{
return "error";
}
if (kind == "launched_process")
{
bool running = false;
net::extract_json_bool_field(status_json, "running", running);
const std::string status_text = net::extract_json_string_field(status_json, "status");
return running ? ("running: " + status_text) : ("stopped: " + status_text);
}
bool online = false;
net::extract_json_bool_field(status_json, "online", online);
return online ? "online" : "offline";
}
// State computed on the background poller thread, handed off to the FTXUI
// event-loop thread via a mutex + Event::Custom nudge. Only the poller thread
// writes here; only the event loop reads/clears it (see run_tui below).
struct PolledState {
std::mutex mutex;
std::vector<ConnectorRow> connectors;
std::vector<TaskRow> tasks;
std::vector<LogRow> log_lines;
LocationsSnapshot locations;
// Pulled models available on the active Ollama daemon (DroidHost::
// build_ollama_status_json's "models" field) - drives the model
// dropdown in the top status line. A cheap HTTP GET to /api/tags, not
// the heavier ollama_setup_status_json() (which shells out to `where
// ollama`) - safe to poll on the same cadence as everything else here.
std::vector<std::string> ollama_models;
// Every session id with at least one persisted message, most recently
// active first (DroidHost::build_agent_sessions_json - a local SQLite
// read via MemoryStore, no network call) - drives the session dropdown
// in the top status line.
std::vector<std::string> agent_session_ids;
};
// One line of the chat panel: who said it (drives color/weight) and the text.
// timestamp defaults to "now" (DroidHost::make_log_timestamp(), the same
// HH:MM:SS used by the App Log/Agent Tools/Apps panels) so every existing
// ChatEntry{role, text} call site gets a timestamp for free, taken at the
// moment the entry is actually constructed. Explicit constructors (not a
// default member initializer on an aggregate) - MSVC rejects a partial-brace
// aggregate init ({role, text}, timestamp defaulted) as "invalid aggregate
// initialization" even though it's valid since C++14; constructors sidestep
// the conformance gap entirely while keeping every existing two-arg call site
// unchanged.
struct ChatEntry {
std::string role; // "user" | "assistant" | "thinking" | "tool" | "error" | "info"
std::string text;
std::string timestamp;
// Collapsed by default (see chat_log_view's Collapsible-style rendering
// below) - only meaningful for "thinking"/"tool" roles, but every entry
// carries it rather than a role-keyed lookup, since it's one bool and
// this way it survives exactly as long as the entry itself does with no
// separate structure to keep in sync across chat_entries' several
// push_back/clear call sites.
bool expanded = false;
ChatEntry(std::string role_, std::string text_)
: role(std::move(role_)), text(std::move(text_)), timestamp(current_time_hms()) {}
ChatEntry(std::string role_, std::string text_, std::string timestamp_)
: role(std::move(role_)), text(std::move(text_)), timestamp(std::move(timestamp_)) {}
};
// Plain-text rendering of the chat transcript (one line per entry, with the
// same [USER]/[AGENT]/[SYSTEM] prefixes the chat panel shows, minus color)
// for copying to the clipboard.
std::string format_chat_transcript(const std::vector<ChatEntry>& entries)
{
std::ostringstream stream;
for (const ChatEntry& entry : entries)
{
const std::string ts_prefix = "[" + entry.timestamp + "] ";
if (entry.role == "user")
{
stream << ts_prefix << "[USER] " << entry.text << "\n";
}
else if (entry.role == "assistant")
{
stream << ts_prefix << "[AGENT] " << entry.text << "\n";
}
else if (entry.role == "thinking")
{
stream << ts_prefix << "[AGENT] [THINKING] " << entry.text << "\n";
}
else if (entry.role == "tool")
{
stream << ts_prefix << "[AGENT] [EXECUTION] " << entry.text << "\n";
}
else if (entry.role == "error")
{
stream << ts_prefix << "error: " << entry.text << "\n";
}
else
{
stream << ts_prefix << "[SYSTEM] " << entry.text << "\n";
}
}
return stream.str();
}
// Chat results computed on a detached background thread (agent turns, Ollama
// install/start/pull can all take seconds to minutes) and handed off to the
// FTXUI event-loop thread the same way PolledState is: the background thread
// appends here under the mutex and posts Event::Custom; only the event loop
// drains it (see run_tui below). This is what keeps the chat input and the
// rest of the UI responsive instead of freezing for the duration of a
// network call - previously the whole handler ran inline on the FTXUI event
// thread, so the screen could not redraw (the just-cleared input looked
// "stuck" showing stale text) until the call finished.
struct ChatWork {
std::mutex mutex;
std::vector<ChatEntry> pending_entries;
bool clear_in_flight = false;
// Set whenever an agent_turn response carries a "session_id" - drained
// into the UI thread's current_session_id the same way pending_entries
// is, so the TUI can display it and include it in the next request
// (resuming a session across a restart - see "Persistent memory" in
// ARCHITECTURE.md). Empty means "no update this round", not "clear it".
std::string session_id;
// Set whenever a response carries a "pending_tool_call" - drained into
// the UI thread's pending_tool_approval the same way session_id is, so
// the TUI shows the proposal and waits for a yes/no instead of treating
// the turn as finished. See PendingToolApproval below.
bool has_pending_tool_call = false;
std::string pending_tool_name;
std::string pending_tool_args;
};
// Extracts DroidHost::agent_turn()/agent_tool_decision()'s response
// ({"ok":bool,"assistant":"...","session_id":"...","actions":[{"tool":"...",
// "arguments_json":"...","result_json":"..."}],"pending_tool_call":
// {"tool":"...","arguments_json":"..."},"error":"..."}) into chat lines: one
// line per tool call already executed this turn, plus either the assistant's
// final reply or (if the loop paused instead of finishing) a proposal line
// asking the user to approve/decline the pending call - never both, since a
// paused response carries no "assistant" text yet. out_session_id is set
// whenever the response carries one (including on some error paths - see
// agent_turn()'s doc comment in cli/host.hpp) so the caller can persist it
// for resuming this conversation later. out_has_pending_tool_call/
// out_pending_tool_name/out_pending_tool_args are set only when the loop
// paused - the caller (run_chat_turn/run_tool_decision below) stages them
// into ChatWork so the UI thread can start a PendingToolApproval.
std::vector<ChatEntry> parse_agent_turn_response(
const std::string& json,
std::string& out_session_id,
bool& out_has_pending_tool_call,
std::string& out_pending_tool_name,
std::string& out_pending_tool_args)
{
std::vector<ChatEntry> entries;
out_session_id = net::extract_json_string_field(json, "session_id");
out_has_pending_tool_call = false;
out_pending_tool_name.clear();
out_pending_tool_args.clear();
bool ok = false;
net::extract_json_bool_field(json, "ok", ok);
if (!ok)
{
const std::string error = net::extract_json_string_field(json, "error");
entries.push_back(ChatEntry{"error", error.empty() ? "agent turn failed" : error});
return entries;
}
// DroidHost::classify_via_llm's own chain-of-thought (see "The LLM
// provider" in ARCHITECTURE.md) - shown first, since it's what actually
// drove whichever tool call/reply follows. Never the assistant's real
// reply - a distinct role/line so it can never be mistaken for one.
const std::string thinking = net::extract_json_string_field(json, "thinking");
if (!thinking.empty())
{
entries.push_back(ChatEntry{"thinking", thinking});
}
for (const std::string& action : extract_json_object_array(json, "actions"))
{
const std::string tool = net::extract_json_string_field(action, "tool");
const std::string args = net::extract_json_string_field(action, "arguments_json");
const std::string result = net::extract_json_string_field(action, "result_json");
// The raw tool result (list_windows_locations' full candidate list,
// find_application's matches, etc.) used to only reach the App Log
// panel via DroidHost::append_app_log's own "chat" channel line -
// Agent Chat showed just the call, never what it actually returned,
// so a request answered from a long candidate list looked like it
// vanished into a vague "returned a comprehensive list" summary with
// no way to see the list itself without switching panels.
entries.push_back(ChatEntry{"tool",
"called " + tool + "(" + args + ")" + (result.empty() ? "" : " -> " + result)});
}
// build_pending_tool_call_response() (cli/host.cpp) always writes
// "pending_tool_call" before "actions" in the response, so slicing from
// its key onward and taking the first "tool"/"arguments_json" match
// lands on the pending call's own fields, not a later action's -
// matches this file's existing first-occurrence JSON field lookups
// (extract_json_object_array et al.), not a real parser.
const size_t pending_index = json.find("\"pending_tool_call\":");
if (pending_index != std::string::npos)
{
const std::string pending_object = json.substr(pending_index);
out_has_pending_tool_call = true;
out_pending_tool_name = net::extract_json_string_field(pending_object, "tool");
out_pending_tool_args = net::extract_json_string_field(pending_object, "arguments_json");
bool looks_destructive = false;
net::extract_json_bool_field(pending_object, "looks_destructive", looks_destructive);
// A visibility aid, not a gate (cli/host.cpp's looks_like_destructive_command -
// "Phase 26" - never blocks anything on its own) - a human skimming
// past a routine "yes" is more likely to actually stop and read this
// particular prompt with the warning prefixed.
const std::string prefix = looks_destructive ? "[!! DESTRUCTIVE !!] " : "";
entries.push_back(ChatEntry{"info",
prefix + "[AGENT WANTS TO] " + out_pending_tool_name + "(" + out_pending_tool_args + ") - approve? (yes/no, or say why not)"});
return entries;
}
const std::string assistant = net::extract_json_string_field(json, "assistant");
entries.push_back(ChatEntry{"assistant", assistant.empty() ? "(no reply)" : assistant});
return entries;
}
// Mirrors DroidHost::agent_turn()/agent_tool_decision()'s "pending_tool_call"
// field - set whenever the agent's tool-calling loop pauses on a
// side-effecting tool (run_command, run_ffmpeg, write_file,
// open_application, launch/stop_connector, enqueue_task; see
// tool_call_requires_approval in cli/host.cpp) instead of running it. The
// next Enter is treated as the yes/no (or free-text decline reason) that
// resolves it, not as a new chat message.
struct PendingToolApproval {
bool active = false;
std::string session_id;
std::string tool;
std::string arguments_json;
};
// Parses a plain JSON array of strings, e.g. {"models":["a","b"]}. Distinct
// from extract_json_object_array above, which walks an array of {...}
// objects - DroidHost::ollama_setup_status_json()'s "models" field is an
// array of bare strings instead.
std::vector<std::string> parse_json_string_array(const std::string& json, const std::string& key)
{
std::vector<std::string> values;
const std::string needle = "\"" + key + "\":";
const size_t key_index = json.find(needle);
if (key_index == std::string::npos)
{
return values;
}
size_t cursor = json.find('[', key_index);
if (cursor == std::string::npos)
{
return values;
}
++cursor;
while (cursor < json.size())
{
while (cursor < json.size() && (json[cursor] == ' ' || json[cursor] == '\t'
|| json[cursor] == '\n' || json[cursor] == '\r' || json[cursor] == ','))
{
++cursor;
}
if (cursor >= json.size() || json[cursor] == ']')
{
break;
}
if (json[cursor] != '"')
{
break;
}
++cursor;
std::string value;
while (cursor < json.size() && json[cursor] != '"')
{
if (json[cursor] == '\\' && cursor + 1 < json.size())
{
value += json[cursor + 1];
cursor += 2;
continue;
}
value += json[cursor++];
}
if (cursor < json.size())
{
++cursor; // skip closing quote
}
values.push_back(value);
}
return values;
}
// Mirrors DroidHost::ollama_setup_status_json()'s response shape.
struct OllamaSetupStatus {
bool installed = false;
bool online = false;
std::vector<std::string> models;
std::string configured_model;
bool configured_model_pulled = false;
};
OllamaSetupStatus parse_ollama_setup_status(const std::string& json)
{
OllamaSetupStatus status;
net::extract_json_bool_field(json, "installed", status.installed);
net::extract_json_bool_field(json, "online", status.online);
status.models = parse_json_string_array(json, "models");
status.configured_model = net::extract_json_string_field(json, "configured_model");
net::extract_json_bool_field(json, "configured_model_pulled", status.configured_model_pulled);
return status;
}
// Drives the TUI's hardcoded in-chat Ollama setup flow (install -> start ->
// pull a model), recomputed fresh from ollama_setup_status_json() on every
// submitted chat message rather than tracked as persistent state - simplest
// thing that reflects reality even if the user fixes Ollama out-of-band
// (e.g. installs it themselves in another window) between messages.
enum class OllamaSetupState { Ready, NeedsInstall, NeedsStart, NeedsModel };
OllamaSetupState compute_setup_state(const OllamaSetupStatus& status)
{
if (!status.installed)
{
return OllamaSetupState::NeedsInstall;
}
if (!status.online)
{
return OllamaSetupState::NeedsStart;
}
if (status.models.empty() || !status.configured_model_pulled)
{
return OllamaSetupState::NeedsModel;
}
return OllamaSetupState::Ready;
}
std::string trim(const std::string& value)
{
size_t start = 0;
size_t end = value.size();
while (start < end && std::isspace(static_cast<unsigned char>(value[start])))
{
++start;
}
while (end > start && std::isspace(static_cast<unsigned char>(value[end - 1])))
{
--end;
}
return value.substr(start, end - start);
}
std::string to_lower(const std::string& value)
{
std::string result = value;
std::transform(result.begin(), result.end(), result.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return result;
}
// Copies `text` (UTF-8) to the OS clipboard - now a thin wrapper over
// cli::write_text_to_clipboard (clipboard.hpp/.cpp), shared with the
// read_clipboard/write_clipboard agent tools (Phase 15, ARCHITECTURE.md)
// rather than a second, TUI-only implementation of the same Win32 calls.
bool copy_text_to_clipboard(const std::string& text)
{
return write_text_to_clipboard(text).ok;
}
// One line describing what the user should type next for a given setup
// state, listing pulled models by number when there are any. Shown
// proactively as soon as the TUI starts (so the user isn't left guessing
// what to type) and again any time their input doesn't match an expected
// action for the current state.
std::string describe_setup_prompt(const OllamaSetupState state, const OllamaSetupStatus& status)
{
switch (state)
{
case OllamaSetupState::NeedsInstall:
return "Ollama isn't installed. Type 'install ollama' to install it automatically "
"(via winget), or install it yourself from ollama.com.";
case OllamaSetupState::NeedsStart:
return "Ollama is installed but not running. Type 'start ollama' to launch it.";
case OllamaSetupState::NeedsModel:
{
if (status.models.empty())
{
return "No Ollama models found on this machine. Type a model name to download it "
"(e.g. 'llama3.2', 'qwen2.5', 'mistral-nemo') and I'll pull it for you.";
}
std::string list_text;
for (size_t index = 0; index < status.models.size(); ++index)
{
if (index > 0)
{
list_text += " ";
}
list_text += std::to_string(index + 1) + ") " + status.models[index];
}
return "Ollama has these models available: " + list_text
+ ". Type a number or name to use one (Enter = 1), or 'pull <name>' to download a different model.";
}
case OllamaSetupState::Ready:
default:
return {};
}
}
// Resolves a trimmed chat message against the currently pulled models for
// the NeedsModel state: blank (Enter with no text) defaults to the first
// pulled model, a 1-based index or an exact case-insensitive name match
// selects that model. Returns false (and leaves out_model untouched) if the
// message doesn't resolve to an existing model - the caller then treats it
// as a new model name to pull instead.
bool try_resolve_model_selection(
const std::string& trimmed_message,
const OllamaSetupStatus& status,
std::string& out_model)
{
if (status.models.empty())
{
return false;
}
if (trimmed_message.empty())
{
out_model = status.models.front();
return true;
}
bool is_number = !trimmed_message.empty();
for (const char character : trimmed_message)
{
if (std::isdigit(static_cast<unsigned char>(character)) == 0)
{
is_number = false;
break;
}
}
if (is_number)
{
const int index = std::atoi(trimmed_message.c_str());
if (index >= 1 && static_cast<size_t>(index) <= status.models.size())
{
out_model = status.models[static_cast<size_t>(index) - 1];
return true;
}
return false;
}
const std::string lower_message = to_lower(trimmed_message);
for (const std::string& model : status.models)
{
if (to_lower(model) == lower_message)
{
out_model = model;
return true;
}
}
return false;
}
// Where the TUI remembers which agent_turn session to resume next launch -
// alongside droidcli_state.json/droidcli_memory.sqlite3 in db/ (see
// db/README.md), git-ignored (see .gitignore). db/ is created by
// DroidHost::initialize(), called before run_tui() (see main() in
// cli/droidcli.cpp), so it already exists by the time this is touched.
// JSON, matching every other piece of droidcli's persisted state, rather
// than a bare .txt with an implicit one-line format.
const char* kLastSessionIdFile = "db/droidcli_last_session.json";
std::string read_last_session_id()
{
std::ifstream file(kLastSessionIdFile);
if (!file)
{
return {};
}
std::ostringstream buffer;
buffer << file.rdbuf();
return trim(net::extract_json_string_field(buffer.str(), "session_id"));
}
void write_last_session_id(const std::string& session_id)
{
std::ofstream file(kLastSessionIdFile, std::ios::trunc);
if (file)
{
file << '{' << net::json_string_field("session_id", session_id) << '}' << std::endl;
}
}
// Turns DroidHost::build_agent_history_json()'s response
// ({"session_id":"...","messages":[{"hop_index":N,"role":"...",
// "content":"...","created_at":"..."}]}) into chat lines for replaying a
// resumed session into the chat panel at startup. The system-prompt message
// (always hop_index 0 when present) is summarized rather than shown in
// full - it's long by design (see HostConfig::system_prompt) and was never
// something the user typed or read the first time either.
std::vector<ChatEntry> parse_agent_history_for_resume(const std::string& json)
{
std::vector<ChatEntry> entries;
bool saw_system_prompt = false;
for (const std::string& message : extract_json_object_array(json, "messages"))
{
const std::string role = net::extract_json_string_field(message, "role");
const std::string content = net::extract_json_string_field(message, "content");
if (role == "system")
{
saw_system_prompt = true;
continue;
}
// created_at is stored/reported as "YYYY-MM-DD HH:MM:SS"
// (MemoryStore, DroidHost::make_full_log_timestamp) - the trailing 8
// characters are the HH:MM:SS this panel actually displays, so a
// resumed message shows when it really happened instead of the
// session-resume time ChatEntry's default ("now") would give it.
const std::string created_at = net::extract_json_string_field(message, "created_at");
const std::string resumed_timestamp = created_at.size() >= 8
? created_at.substr(created_at.size() - 8)
: current_time_hms();
if (role == "assistant")
{
entries.push_back(ChatEntry{"assistant", content, resumed_timestamp});
}
else if (role == "tool")
{
entries.push_back(ChatEntry{"tool", "(resumed) " + content, resumed_timestamp});
}
else
{
entries.push_back(ChatEntry{"user", content, resumed_timestamp});
}
}
if (!entries.empty())
{
std::string summary = "Resumed a prior session (" + std::to_string(entries.size()) + " message"
+ (entries.size() == 1 ? "" : "s");
if (saw_system_prompt)
{
summary += ", plus the system prompt";
}
summary += "). Press 'n' to start a new session instead.";
entries.insert(entries.begin(), ChatEntry{"info", summary});
}
return entries;
}
// A window() title styled with a light-blue background/dark text - shared by
// every panel title (Connectors, Tasks, Agent Tools, Apps, Locations, Agent
// Chat, App Log) so they read as one consistent set of chrome alongside the
// top status line and bottom focus-hint bar, which get the same treatment
// directly in run_tui below. window() takes an Element for its title
// argument (not just plain text), so this slots in with no other changes to
// how each panel is built.
ftxui::Element panel_title(const std::string& label)
{
return ftxui::text(label) | ftxui::bgcolor(ftxui::Color::LightSkyBlue1) | ftxui::color(ftxui::Color::Black);
}
// True (not palette-remappable) colors: named ANSI colors are palette
// indices that some terminal profiles override with their own shade
// (Windows Terminal's "Command Prompt"/admin-elevated profiles in
// particular often default to a different background than a plain user
// shell) - RGB(...) forces the actual color regardless of the profile the
// TUI happens to be launched under, same fix already applied to the
// erase-session popup (see build_confirm_popup's kBlack/kWhite). Light grey,
// almost white, not pure white - kAppForeground is the deliberate dark
// counterpart: any text that doesn't set its own explicit color (several
// table headers/placeholder lines don't) would otherwise fall back to
// whatever the terminal's own default foreground is, which on plenty of
// terminals is a light color meant to read against a *dark* background and
// would be nearly invisible against this light one.
const ftxui::Color kAppBackground = ftxui::Color::RGB(235, 235, 235);
const ftxui::Color kAppForeground = ftxui::Color::RGB(20, 20, 20);
// window(panel_title(...), content), plus explicit bgcolor/color so every
// individual panel (Connectors, Tasks, Agent Tools, Apps, Locations, Agent
// Chat, App Log, Session/Provider/Model) paints its own background and
// default text color - see kAppBackground's own comment for why this can't
// just be left to whatever the terminal profile happens to default to.
ftxui::Element panel(const std::string& title, ftxui::Element content)
{
return ftxui::window(panel_title(title), std::move(content))
| ftxui::bgcolor(kAppBackground) | ftxui::color(kAppForeground);
}
// A reusable yes/no confirm popup for ftxui::Modal - a bordered, centered
// box (Modal's own composition: `document, modal | clear_under | center` -
// see ftxui/src/ftxui/component/modal.cpp) asking a yes/no question before
// a destructive action, instead of hand-rolling a new bordered
// Renderer+CatchEvent pair for each one that needs one.
//
// message_fn is called on every render (not captured once at construction
// time), so the same popup instance can be reused across many invocations
// of the same action - e.g. one popup built once for "erase session", shown
// for session X, closed, then shown again for session Y - as long as the
// caller updates whatever state message_fn reads before setting
// *show_flag = true. on_confirm runs once if the user presses Y; N/Escape
// just closes it, no callback needed since cancelling has never needed to
// do anything beyond that. The popup only ever writes *show_flag = false
// itself - opening it (show_flag = true, plus whatever state message_fn/
// on_confirm read) is entirely the caller's job.
ftxui::Component build_confirm_popup(
std::string title,
std::function<std::string()> message_fn,
std::function<void()> on_confirm,
bool* show_flag)
{
using namespace ftxui;
// True (not palette-remappable) white-on-black: named ANSI colors like
// Color::White are index 15, which plenty of terminal color schemes
// (Solarized, etc.) remap to a muted off-white - RGB(255,255,255) forces
// the actual color regardless of the user's terminal theme.
const Color kWhite = Color::RGB(255, 255, 255);
const Color kBlack = Color::RGB(0, 0, 0);
// A single-line label, not the library's default bordered/boxed look
// (ButtonOption::Animated() pads with an empty border, which reads as an
// oversized button) - just the text itself, colored via animated_colors
// so focus/hover still shows a red fill against the dialog's black
// background.
ButtonOption button_style;
button_style.transform = [](const EntryState& s) -> Element
{
Element label = text(s.label);
return s.focused ? label | bold : label;
};
button_style.animated_colors.background.Set(kBlack, Color::Red);
button_style.animated_colors.foreground.Set(kWhite, kWhite);
Component yes_button = Button(" Yes ", [on_confirm, show_flag]
{
on_confirm();
*show_flag = false;
}, button_style);
Component no_button = Button(" No ", [show_flag]
{
*show_flag = false;
}, button_style);
Component buttons = Container::Horizontal({yes_button, no_button});
Component dialog = Renderer(buttons, [title, message_fn, buttons, kWhite, kBlack]() -> Element
{
return window(text(" " + title + " ") | bold | color(Color::Red),
vbox({
paragraph(message_fn()) | color(kWhite),
text(""),
hbox({filler(), buttons->Render(), filler()}),
}) | size(WIDTH, GREATER_THAN, 44)) | bgcolor(kBlack);
});
return CatchEvent(dialog, [on_confirm, show_flag](Event event) -> bool
{
if (event == Event::y)
{
on_confirm();
*show_flag = false;
return true;
}
if (event == Event::n || event == Event::Escape)
{
*show_flag = false;
return true;
}
// Anything else (a mouse click on Yes/No, Tab between them, ...)
// falls through to the buttons container below rather than being
// swallowed - Modal() already routes every event exclusively to
// this popup while it's shown, so there is nothing else in the
// tree for an unhandled event to leak into.
return false;
});
}
} // namespace
int run_tui(DroidHost& host, int http_port, volatile bool& running_flag)
{
using namespace ftxui;
PolledState polled;
ScreenInteractive screen = ScreenInteractive::Fullscreen();
// UI-thread-owned state. Only ever mutated inside the FTXUI event loop (in
// response to Event::Custom), so it needs no locking of its own - the
// PolledState mutex above is the only cross-thread hand-off point.
std::vector<ConnectorRow> connectors;
std::vector<std::string> connector_entries;
int selected_connector = 0;
std::vector<TaskRow> tasks;
std::vector<LogRow> log_lines;
LocationsSnapshot locations;
// Backs the model dropdown in the top status line. model_menu_selected is
// written directly by FTXUI's Dropdown component on click/keyboard
// selection - there is no on-change callback, so
// apply_model_menu_selection() below detects a change by comparing
// against model_menu_known_model every render frame instead.
std::vector<std::string> model_menu_entries;
int model_menu_selected = 0;