Repository navigation
Expand file tree
/
Copy pathscreen.zig
More file actions
2049 lines (1888 loc) · 86.6 KB
/
Copy pathscreen.zig
File metadata and controls
2049 lines (1888 loc) · 86.6 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
//! Full-screen conversation layout.
//!
//! The interactive session takes the terminal over: the conversation scrolls in
//! a region at the top and the input line stays at the bottom, where the eye
//! expects it. The alternate screen is used, so the shell's own scrollback is
//! handed back unchanged when zagent exits, and a suspended zagent (Ctrl-Z) can
//! give the terminal back to the shell and take it again when it resumes.
//!
//! Everything written to the log is kept in `transcript`. A terminal cannot be
//! asked what is on it, and the layout changes whenever the input grows or the
//! window is resized; the bytes that produced the screen are the only way to
//! draw it again, which is what Ctrl-Z resume and the growing input rely on.
//!
//! The bottom rows are reserved all the time, not only while the prompt is
//! being edited. Answers stream above them, so the prompt never moves and no
//! answer is hidden under it.
const std = @import("std");
const builtin = @import("builtin");
const term = @import("term.zig");
const ansi = @import("style.zig");
const RESET = ansi.RESET;
const HIDE_CURSOR = ansi.HIDE_CURSOR;
const SHOW_CURSOR = ansi.SHOW_CURSOR;
const SAVE_CURSOR = ansi.SAVE_CURSOR;
const RESTORE_CURSOR = ansi.RESTORE_CURSOR;
const SYNC_BEGIN = ansi.SYNC_BEGIN;
const SYNC_END = ansi.SYNC_END;
const ALT_ON = ansi.ALT_ON;
const ALT_OFF = ansi.ALT_OFF;
const selection = @import("selection.zig");
/// How much conversation history the screen keeps for redrawing. A terminal
/// cannot be asked what it holds, so the bytes that drew the log are what a
/// repaint works from; the oldest ones are dropped rather than kept for the
/// whole life of a long-running session.
pub const max_transcript_bytes: usize = 8 * 1024 * 1024;
/// Rows the live thinking panel takes above the prompt. One on purpose: it is
/// a window onto reasoning that is still being written, not a transcript, and
/// the row it takes is a row the conversation gives up while it is open. The
/// newest row of the thought is the one shown, so the panel reads as a single
/// line that scrolls under the model's pen.
pub const thinking_panel_rows: usize = 1;
/// How much reasoning text the panel keeps to scroll through. Only the last
/// panelful is ever visible, so a bounded tail is all a long thought costs.
const thinking_capacity_bytes: usize = 4096;
/// How long the panel stays once the answer has begun. The reasoning is what
/// led to the answer, and its last rows are worth reading before the rows go
/// back to the conversation.
pub const thinking_linger_ms: i64 = 3000;
/// The panel's left margin, so what the model is thinking lines up with the
/// conversation it is thinking about.
const thinking_indent = " ";
pub const Screen = struct {
allocator: std.mem.Allocator,
io: std.Io,
out: std.Io.File,
/// Use tmux's native clipboard command rather than application OSC 52,
/// which the default `set-clipboard external` deliberately ignores.
tmux_env: ?*const std.process.Environ.Map = null,
/// False for a run whose output is not a terminal. Every method then does
/// nothing, so callers do not have to ask before writing.
active: bool,
/// The bytes that drew the log, in the order they were written.
transcript: std.ArrayList(u8) = .empty,
rows: usize = 24,
cols: usize = 80,
/// Rows kept clear at the bottom for the editor and its popup. Never zero
/// while active: the prompt is always at the bottom.
input_rows: usize = 1,
/// Question dock above the status bar, separate from the editor below it.
question_rows: usize = 0,
/// The log has to be drawn again before the next write.
dirty: bool = true,
/// Transcript bytes already painted; queued output can extend the log
/// without clearing it when following the newest output.
rendered_len: usize = 0,
status_dirty: bool = false,
/// The editor already owns a synchronized frame during its layout callback.
/// DEC synchronized updates are a boolean mode, not a nesting counter.
editor_frame: bool = false,
scroll_rows: usize = 0,
buffered: bool = false,
queued: std.ArrayList(u8) = .empty,
queue_mutex: std.Io.Mutex = .init,
progress: [256]u8 = undefined,
progress_len: usize = 0,
pending_progress: [256]u8 = undefined,
pending_progress_len: usize = 0,
progress_changed: bool = false,
/// Rows reserved above the prompt for the live thinking panel, zero when
/// nothing is being narrated. Read through `thinkingRows`, which clamps it
/// to what the window can spare.
thinking_rows: usize = 0,
/// The reasoning text as it is drawn. Only the tail is kept.
thinking: std.ArrayList(u8) = .empty,
/// Where a worker publishes reasoning as it arrives. The foreground moves
/// it into `thinking` and draws it, the way the progress line is published:
/// a worker never writes to the terminal itself.
pending_thinking: std.ArrayList(u8) = .empty,
/// Rows the worker asked for, applied by the next drain.
pending_thinking_rows: usize = 0,
thinking_dirty: bool = false,
/// When a close asked for with a delay was requested, and how long it may
/// wait. The foreground performs it: the worker that asked is still
/// streaming the answer it began with.
thinking_close_started: ?std.Io.Timestamp = null,
thinking_close_delay_ms: i64 = 0,
/// Completed thinking duration shown in the status bar, never the log.
thinking_summary: [128]u8 = undefined,
thinking_summary_len: usize = 0,
thinking_summary_changed: bool = false,
anchor: ?usize = null,
anchor_end: usize = 0,
selection_start: usize = 0,
selection_end: usize = 0,
dragging: bool = false,
notice: [128]u8 = undefined,
notice_len: usize = 0,
notice_started: ?std.Io.Timestamp = null,
pub fn mouse(self: *Screen, report: []const u8) !void {
if (!self.active) return;
const m = selection.parse(report) orelse return;
// Only unmodified left-button presses, drags and releases select.
if (m.button != 0 and m.button != 32) return;
if (m.y > self.logBottom() and !self.dragging) return;
const rows = try visualRows(self.allocator, self.transcript.items, self.cols);
defer self.allocator.free(rows);
const end_row = rows.len - @min(self.scroll_rows, rows.len -| self.logBottom());
const first = end_row -| self.logBottom();
const row = @min(first + @min(m.y, self.logBottom()) - 1, end_row - 1);
const begin = rows[row];
const end = if (row + 1 < rows.len) rows[row + 1] else self.transcript.items.len;
const line = self.transcript.items[begin..end];
const point = begin + selection.offset(line, m.x - 1, false);
const point_end = begin + selection.offset(line, m.x - 1, true);
if (!m.release and m.button == 0) {
self.anchor = point;
self.anchor_end = point_end;
self.selection_start = point;
self.selection_end = point;
self.dragging = true;
} else if (self.dragging) {
const anchor = self.anchor orelse return;
// A click without motion is not a selection.
if (point != anchor or self.selection_end != self.selection_start) {
self.selection_start = @min(anchor, point);
self.selection_end = @max(self.anchor_end, point_end);
}
if (m.release) {
self.dragging = false;
if (self.selection_end > self.selection_start) try self.copySelection();
}
} else return;
self.dirty = true;
}
fn copySelection(self: *Screen) !void {
const plain = try selection.plain(self.allocator, self.transcript.items[self.selection_start..self.selection_end]);
defer self.allocator.free(plain);
if (plain.len == 0) return;
if (self.tmux_env) |env| {
self.copyViaTmux(env, plain) catch {
const message = "复制失败:tmux 剪贴板写入失败";
@memcpy(self.notice[0..message.len], message);
self.notice_len = message.len;
self.notice_started = std.Io.Timestamp.now(self.io, .awake);
return;
};
} else {
try self.copyViaOsc52(plain);
}
const count = try std.unicode.utf8CountCodepoints(plain);
const message = try std.fmt.bufPrint(&self.notice, "已请求复制 {d} 个字符", .{count});
self.notice_len = message.len;
self.notice_started = std.Io.Timestamp.now(self.io, .awake);
}
fn copyViaOsc52(self: *Screen, plain: []const u8) !void {
const encoder = std.base64.standard.Encoder;
const encoded = try self.allocator.alloc(u8, encoder.calcSize(plain.len));
defer self.allocator.free(encoded);
_ = encoder.encode(encoded, plain);
// OSC 52 has no success acknowledgement; terminal permissions still apply.
try self.out.writeStreamingAll(self.io, ansi.CLIPBOARD_PREFIX);
try self.out.writeStreamingAll(self.io, encoded);
try self.out.writeStreamingAll(self.io, ansi.OSC_END);
}
fn copyViaTmux(self: *Screen, env: *const std.process.Environ.Map, plain: []const u8) !void {
// stdin preserves arbitrary UTF-8/newlines without shell quoting, argv
// size limits, or temporary files. -w asks tmux itself to send OSC 52
// to its client, even with set-clipboard=external and passthrough off.
var child = try std.process.spawn(self.io, .{
.argv = &.{ "tmux", "load-buffer", "-w", "-" },
.environ_map = env,
.stdin = .pipe,
.stdout = .ignore,
.stderr = .ignore,
});
defer child.kill(self.io);
try child.stdin.?.writeStreamingAll(self.io, plain);
child.stdin.?.close(self.io);
child.stdin = null;
const result = try child.wait(self.io);
if (!result.success()) return error.TmuxClipboardFailed;
}
/// Workers publish a transient line; only the foreground draws it.
pub fn setProgress(self: *Screen, line: []const u8) void {
self.queue_mutex.lockUncancelable(self.io);
defer self.queue_mutex.unlock(self.io);
const len = @min(line.len, self.pending_progress.len);
@memcpy(self.pending_progress[0..len], line[0..len]);
self.pending_progress_len = len;
self.progress_changed = true;
}
/// Publish the chain of thought as it is streamed, opening the panel it is
/// shown in. Called from the request's own thread, so it only appends to a
/// buffer; the foreground wraps and draws it.
///
/// Control characters are dropped: the text is the model's, and a stray
/// escape sequence in it would move the cursor the layout depends on.
pub fn publishThinking(self: *Screen, delta: []const u8) void {
if (!self.active or delta.len == 0) return;
self.queue_mutex.lockUncancelable(self.io);
defer self.queue_mutex.unlock(self.io);
// Reasoning that arrives while a close is pending belongs to a new
// thought and takes the panel over: the delayed close is called off
// rather than pulling the rows out from under it.
self.thinking_close_started = null;
self.thinking_summary_len = 0;
self.thinking_summary_changed = true;
for (delta) |byte| {
if (byte < 0x20 and byte != '\n') {
if (byte == '\t') self.pending_thinking.appendSlice(self.allocator, " ") catch return;
continue;
}
self.pending_thinking.append(self.allocator, byte) catch return;
}
// The panel only ever shows its last rows, so the buffer keeps a tail
// and no more.
trimThinking(&self.pending_thinking);
self.pending_thinking_rows = thinking_panel_rows;
}
/// Clear the previous turn's duration before a new query starts.
pub fn clearThinkingSummary(self: *Screen) void {
self.queue_mutex.lockUncancelable(self.io);
defer self.queue_mutex.unlock(self.io);
self.thinking_summary_len = 0;
self.thinking_summary_changed = true;
}
/// Close the panel now, keeping the completed duration in the status bar.
pub fn hideThinking(self: *Screen) void {
if (!self.active) return;
self.queue_mutex.lockUncancelable(self.io);
defer self.queue_mutex.unlock(self.io);
self.thinking_close_started = null;
self.thinking_close_delay_ms = 0;
self.pending_thinking_rows = 0;
self.pending_thinking.clearRetainingCapacity();
}
/// Show the duration in the status bar now, then close the panel later.
///
/// The close is timed by the foreground, which is the only side that is not
/// busy: the answer is streaming on the thread that asked for this.
pub fn scheduleThinkingClose(self: *Screen, summary: []const u8) void {
self.scheduleThinkingCloseAfter(summary, thinking_linger_ms);
}
fn scheduleThinkingCloseAfter(self: *Screen, summary: []const u8, delay_ms: i64) void {
if (!self.active) return;
self.queue_mutex.lockUncancelable(self.io);
defer self.queue_mutex.unlock(self.io);
const len = @min(summary.len, self.thinking_summary.len);
@memcpy(self.thinking_summary[0..len], summary[0..len]);
self.thinking_summary_len = len;
self.thinking_summary_changed = true;
self.thinking_close_delay_ms = delay_ms;
self.thinking_close_started = std.Io.Timestamp.now(self.io, .awake);
}
/// Perform a close with the queue mutex held; the status survives it.
fn settleThinkingLocked(self: *Screen) void {
self.thinking_close_started = null;
self.thinking_close_delay_ms = 0;
self.pending_thinking_rows = 0;
self.thinking_rows = 0;
self.thinking.clearRetainingCapacity();
self.pending_thinking.clearRetainingCapacity();
self.thinking_dirty = false;
self.dirty = true;
}
/// Drop the oldest whole lines once the transcript outgrows its bound.
/// Everything that points into the transcript moves with it; a selection
/// or drag whose text was dropped collapses instead of pointing at bytes
/// that are gone. The screen is repainted from what is left.
fn trimTranscript(self: *Screen) void {
if (self.transcript.items.len <= max_transcript_bytes) return;
// Keep the newest part, and cut on a line boundary so a repaint never
// shows half a line as though it had been written that way.
const from = self.transcript.items.len - max_transcript_bytes / 2;
const newline = std.mem.indexOfScalarPos(u8, self.transcript.items, from, '\n') orelse self.transcript.items.len;
const dropped = @min(newline + 1, self.transcript.items.len);
if (dropped == 0) return;
const rest = self.transcript.items.len - dropped;
std.mem.copyForwards(u8, self.transcript.items[0..rest], self.transcript.items[dropped..]);
self.transcript.shrinkRetainingCapacity(rest);
self.rendered_len = self.rendered_len -| dropped;
self.selection_start = self.selection_start -| dropped;
self.selection_end = self.selection_end -| dropped;
if (self.anchor) |anchor| self.anchor = anchor -| dropped;
self.anchor_end = self.anchor_end -| dropped;
self.dirty = true;
}
pub fn drain(self: *Screen) !bool {
// Keep the viewport stable while the pointer is selecting streamed text.
if (self.dragging) return false;
self.queue_mutex.lockUncancelable(self.io);
defer self.queue_mutex.unlock(self.io);
const old_bottom = self.logBottom();
var changed = self.progress_changed or self.thinking_summary_changed;
if (self.thinking_summary_changed) {
self.status_dirty = true;
self.thinking_summary_changed = false;
}
if (self.notice_started) |started| {
if (started.untilNow(self.io, .awake).toMilliseconds() >= 3000) {
self.notice_len = 0;
self.notice_started = null;
self.status_dirty = true;
changed = true;
}
}
if (self.progress_changed) {
self.progress_len = self.pending_progress_len;
@memcpy(self.progress[0..self.progress_len], self.pending_progress[0..self.progress_len]);
self.progress_changed = false;
self.status_dirty = true;
}
// A close the worker asked for happens here, on the only side that is
// not writing the answer.
if (self.thinking_close_started) |started| {
if (started.untilNow(self.io, .awake).toMilliseconds() >= self.thinking_close_delay_ms) {
self.settleThinkingLocked();
changed = true;
}
}
// The published reasoning is taken before the height is compared: a
// panel that opened or closed moves the log's bottom row, which is what
// makes the next layout draw the whole conversation again.
if (self.pending_thinking_rows != self.thinking_rows) {
self.thinking_rows = self.pending_thinking_rows;
if (self.thinking_rows == 0) self.thinking.clearRetainingCapacity();
changed = true;
}
if (self.pending_thinking.items.len > 0) {
self.thinking.appendSlice(self.allocator, self.pending_thinking.items) catch {};
self.pending_thinking.clearRetainingCapacity();
trimThinking(&self.thinking);
self.thinking_dirty = true;
changed = true;
}
if (old_bottom != self.logBottom()) self.dirty = true;
if (self.queued.items.len == 0) return changed;
const old_rows = if (self.scroll_rows > 0) blk: {
const rows = try visualRows(self.allocator, self.transcript.items, self.cols);
defer self.allocator.free(rows);
break :blk rows.len;
} else 0;
try self.transcript.appendSlice(self.allocator, self.queued.items);
if (old_rows > 0) {
const rows = try visualRows(self.allocator, self.transcript.items, self.cols);
defer self.allocator.free(rows);
self.scroll_rows += rows.len -| old_rows;
}
self.queued.clearRetainingCapacity();
self.trimTranscript();
// A scrolled/selected viewport must be reconstructed, not appended to.
if (self.scroll_rows > 0 or self.selection_end > self.selection_start) {
self.dirty = true;
}
return true;
}
/// Wheel events advance a few visual rows; keyboard paging keeps its
/// viewport-sized step. Wrapped lines count as separate visual rows.
pub fn scroll(self: *Screen, up: bool, page: bool) !void {
self.dragging = false;
self.selection_end = self.selection_start;
_ = try self.drain();
const step = if (page) @max(@as(usize, 1), self.logBottom() -| 1) else 3;
const total = try visualRows(self.allocator, self.transcript.items, self.cols);
defer self.allocator.free(total);
const limit = total.len -| self.logBottom();
self.scroll_rows = if (up) @min(limit, self.scroll_rows +| step) else self.scroll_rows -| step;
self.dirty = true;
}
/// A submitted input resumes following output, even when viewing history.
/// Reset before draining so pending output cannot re-anchor the viewport.
pub fn followLatest(self: *Screen) void {
if (self.scroll_rows != 0 or self.anchor != null or self.selection_start != self.selection_end or self.dragging)
self.dirty = true;
self.scroll_rows = 0;
self.anchor = null;
self.anchor_end = 0;
self.selection_start = 0;
self.selection_end = 0;
self.dragging = false;
}
pub fn init(allocator: std.mem.Allocator, io: std.Io, active: bool) Screen {
return .{
.allocator = allocator,
.io = io,
.out = std.Io.File.stdout(),
.active = active,
};
}
pub fn deinit(self: *Screen) void {
if (self.active) self.leave();
self.transcript.deinit(self.allocator);
self.queued.deinit(self.allocator);
self.thinking.deinit(self.allocator);
self.pending_thinking.deinit(self.allocator);
}
pub fn isActive(self: *const Screen) bool {
return self.active;
}
/// Take the terminal over and start with a blank screen.
pub fn enter(self: *Screen) !void {
if (!self.active) return;
_ = self.refreshSize();
self.syncSignals();
if (builtin.os.tag != .windows) {
handler_termios = std.posix.tcgetattr(self.out.handle) catch null;
}
try self.out.writeStreamingAll(self.io, ALT_ON ++ HIDE_CURSOR ++ ansi.HOME_POSITION ++ ansi.CLEAR_SCREEN_END ++ SHOW_CURSOR);
self.dirty = true;
installSignals();
}
/// Give the terminal back exactly as it was found.
pub fn leave(self: *Screen) void {
if (!self.active) return;
uninstallSignals();
self.out.writeStreamingAll(self.io, SYNC_END ++ RESET ++ ansi.RESET_SCROLL_REGION ++ SHOW_CURSOR ++ ALT_OFF) catch {};
self.active = false;
}
/// Re-read the window size. Returns whether it changed.
fn refreshSize(self: *Screen) bool {
const rows = term.rows(self.out);
const cols = term.columns(self.out);
const changed = rows != self.rows or cols != self.cols;
self.rows = rows;
self.cols = cols;
return changed;
}
/// Reserve `wanted` rows at the bottom for the editor. Called before a line
/// is read and whenever the editor's own height changes.
///
/// The log is drawn again only when the reserved height, the window size or
/// the transcript changed. `prepareInput` is what saves the log cursor;
/// doing it here too would save the editor's own cursor on every keystroke.
pub fn reserveInput(self: *Screen, wanted: usize, repaint: bool) !void {
if (!self.active) return;
_ = try self.drain();
const resized = self.refreshSize();
if (repaint) self.dirty = true;
const clamped = @max(@as(usize, 1), @min(wanted, self.rows -| 2));
if (resized or clamped != self.input_rows or self.dirty or repaintPending()) {
self.input_rows = clamped;
try self.replay();
} else {
try self.appendLog();
if (self.status_dirty) try self.repaintStatus();
// A full replay has already drawn the panel with everything else;
// only the reasoning written since then is left to paint.
if (self.thinking_dirty) try self.repaintThinking();
}
self.syncSignals();
}
/// Get ready to read a line: reserve the prompt row and remember where the
/// log ended. `endInput` restores that position once the editor is done,
/// because the editor moves the cursor into its own rows.
pub fn prepareInput(self: *Screen) !void {
try self.reserveInput(1, false);
if (self.active) self.saveCursor();
}
/// Reserve a question dock independently of the editor. Repeated calls
/// also refresh the geometry and drain queued status/thinking updates.
pub fn reserveQuestion(self: *Screen, wanted: usize) !void {
if (self.question_rows != wanted) {
self.question_rows = wanted;
self.dirty = true;
}
try self.reserveInput(1, false);
}
pub fn questionTop(self: *const Screen) usize {
return self.logBottom() + 1;
}
pub fn questionRows(self: *const Screen) usize {
// Keep a log row, status, editor, and the thinking panel if present.
const thinking = @min(self.thinking_rows, thinking_panel_rows);
return @min(self.question_rows, self.rows -| (self.usableInputRows() + 2 + thinking));
}
/// Do not erase-to-end: status and thinking live below the question dock.
pub fn endQuestion(self: *Screen) !void {
if (!self.active) return;
self.question_rows = 0;
self.dirty = true;
try self.replay();
}
/// Clear the editor's rows and put the cursor back where the log ended, so
/// the answer that follows starts right above the prompt.
///
/// The position to return to is the one `prepareInput` saved before the
/// editor took the cursor away. Saving it again here would save the
/// editor's cursor instead, and everything written next — the question,
/// the spinner, the answer — would land in the prompt rows and be erased
/// by the next draw.
pub fn endInput(self: *Screen) !void {
if (!self.active) return;
var buf: [64]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try w.print(ansi.ROW_START ++ ansi.CLEAR_SCREEN_END, .{self.inputTop()});
try w.writeAll(RESTORE_CURSOR);
try self.out.writeStreamingAll(self.io, w.buffered());
// A multi-line question no longer needs its rows once it is sent: the
// next write repaints the log with the taller region.
if (self.input_rows != 1) {
self.input_rows = 1;
self.dirty = true;
self.syncSignals();
}
}
/// Append to the conversation and show it.
pub fn writeLog(self: *Screen, bytes: []const u8) !void {
if (!self.active or bytes.len == 0) return;
if (self.buffered) {
self.queue_mutex.lockUncancelable(self.io);
defer self.queue_mutex.unlock(self.io);
try self.queued.appendSlice(self.allocator, bytes);
return;
}
if (self.dirty or repaintPending()) try self.replay();
try self.transcript.appendSlice(self.allocator, bytes);
self.trimTranscript();
try self.out.writeStreamingAll(self.io, bytes);
self.rendered_len = self.transcript.items.len;
}
/// Throw the visible conversation away, for `/new`.
///
/// A terminal cannot be asked what it holds, so the transcript is what a
/// redraw works from: dropping it and marking the screen dirty is what
/// makes the next frame clear the whole screen and paint a blank log
/// region. Output still queued was written for the session being left, and
/// the status line, the selection and the scroll position belonged to it
/// too, so none of them survive.
pub fn clearLog(self: *Screen) void {
if (!self.active) return;
self.queue_mutex.lockUncancelable(self.io);
self.queued.clearRetainingCapacity();
self.queue_mutex.unlock(self.io);
self.transcript.clearRetainingCapacity();
self.rendered_len = 0;
self.scroll_rows = 0;
self.anchor = null;
self.anchor_end = 0;
self.selection_start = 0;
self.selection_end = 0;
self.dragging = false;
self.progress_len = 0;
self.pending_progress_len = 0;
self.progress_changed = false;
// The panel belonged to the conversation being left like everything
// else on the screen, and its rows go back to the log.
self.thinking.clearRetainingCapacity();
self.pending_thinking.clearRetainingCapacity();
self.thinking_rows = 0;
self.pending_thinking_rows = 0;
self.thinking_dirty = false;
self.thinking_close_started = null;
self.thinking_summary_len = 0;
self.notice_len = 0;
self.notice_started = null;
self.status_dirty = true;
self.dirty = true;
}
/// Only called by the foreground layout, before the editor redraws its
/// input. Do not save the editor cursor over the saved log position.
fn repaintStatus(self: *Screen) !void {
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
if (!self.editor_frame) try w.writeAll(SYNC_BEGIN);
try w.writeAll(HIDE_CURSOR);
try w.print(ansi.ROW_START ++ ansi.CLEAR_LINE, .{self.statusRow()});
try self.writeStatus(&w);
// The editor owns the visible cursor. Keep it hidden at the log
// position until refreshBottom has redrawn and positioned the input.
try w.writeAll(RESTORE_CURSOR);
if (!self.editor_frame) try w.writeAll(SYNC_END);
try self.out.writeStreamingAll(self.io, w.buffered());
self.status_dirty = false;
}
/// Keep the current status on the left; notices never overwrite it.
fn writeStatus(self: *const Screen, w: *std.Io.Writer) !void {
try w.writeAll(RESET);
var status_buf: [512]u8 = undefined;
var status = std.Io.Writer.fixed(&status_buf);
try status.writeAll(self.progress[0..self.progress_len]);
if (self.thinking_summary_len > 0) {
try status.writeAll(RESET ++ ansi.DIM);
try status.writeAll(if (self.progress_len > 0) " · " else " ");
try status.writeAll(self.thinking_summary[0..self.thinking_summary_len]);
}
const progress = status.buffered();
const end = selection.offset(progress, self.cols, false);
try w.writeAll(progress[0..end]);
const progress_width = statusWidth(progress[0..end]);
const notice = self.notice[0..self.notice_len];
const notice_width = statusWidth(notice);
const gap: usize = if (progress_width > 0) 1 else 0;
if (notice.len > 0 and progress_width + gap + notice_width <= self.cols) {
try w.writeAll(RESET);
try w.print(ansi.CURSOR_POSITION, .{ self.statusRow(), self.cols - notice_width + 1 });
try w.writeAll(notice);
}
try w.writeAll(RESET);
}
/// Draw the thinking panel: its rows cleared, the newest reasoning at the
/// bottom of the window, the way a terminal scrolls.
///
/// Nothing of the panel goes into the transcript. The completed duration
/// belongs to the status bar instead.
fn writeThinking(self: *Screen, w: *std.Io.Writer) !void {
const rows = self.thinkingRows();
if (rows == 0) return;
try w.writeAll(RESET);
const available = self.cols -| 1;
const indent = thinking_indent[0..@min(thinking_indent.len, available)];
const width = available - indent.len;
var lines = try wrapThinking(self.allocator, self.thinking.items, @max(width, 1));
defer lines.deinit(self.allocator);
// The window shows the newest rows; the rest scroll off the top. Blank
// rows were dropped by the wrapper, since in one row an empty one is a
// row of the model's thought that is not shown.
const shown = @min(rows, lines.items.len);
const first = lines.items.len - shown;
const top = self.thinkingTop();
var row: usize = 0;
while (row + shown < rows) : (row += 1) {
try w.print(ansi.ROW_START ++ ansi.CLEAR_LINE, .{top + row});
}
for (lines.items[first..]) |line| {
try w.print(ansi.ROW_START ++ ansi.CLEAR_LINE, .{top + row});
try w.writeAll(ansi.DIM);
try w.writeAll(indent);
// The wrapper must make progress even for a wide codepoint in a
// one-cell viewport; painting it must not wrap into the editor.
const end = term.takeFit(line, width);
if (term.width(line[0..end]) <= width) try w.writeAll(line[0..end]);
try w.writeAll(RESET);
row += 1;
}
}
/// Redraw the panel alone, leaving the conversation as it is. Reasoned text
/// arrives in bursts, so this is what the layout runs on every wake-up
/// while the model thinks.
fn repaintThinking(self: *Screen) !void {
if (!self.active or self.thinkingRows() == 0) return;
if (!self.editor_frame) try self.out.writeStreamingAll(self.io, SYNC_BEGIN);
defer if (!self.editor_frame) self.out.writeStreamingAll(self.io, SYNC_END) catch {};
var buf: [8192]u8 = undefined;
var fw = self.out.writerStreaming(self.io, &buf);
const w = &fw.interface;
try w.writeAll(HIDE_CURSOR);
try self.writeThinking(w);
// The cursor belongs at the log's end until the editor draws the input.
try w.writeAll(RESTORE_CURSOR);
try w.flush();
self.thinking_dirty = false;
}
/// Stream new bytes into the existing scrolling region. Full replay is
/// still used for resize, layout changes, history browsing and selection.
fn appendLog(self: *Screen) !void {
if (self.rendered_len == self.transcript.items.len) return;
if (!self.editor_frame) try self.out.writeStreamingAll(self.io, SYNC_BEGIN);
defer if (!self.editor_frame) self.out.writeStreamingAll(self.io, SYNC_END) catch {};
var buf: [8192]u8 = undefined;
var fw = self.out.writerStreaming(self.io, &buf);
const w = &fw.interface;
try w.writeAll(HIDE_CURSOR ++ RESTORE_CURSOR);
const bytes = self.transcript.items[self.rendered_len..];
var start: usize = 0;
for (bytes, 0..) |byte, i| {
if (byte != '\n') continue;
try w.writeAll(bytes[start..i]);
try w.writeAll("\r\n");
start = i + 1;
}
try w.writeAll(bytes[start..]);
try w.writeAll(SAVE_CURSOR);
try w.flush();
self.rendered_len = self.transcript.items.len;
}
/// A writer that appends to the conversation, for callers that format into
/// a writer rather than handing over finished bytes.
pub fn logWriter(self: *Screen, buffer: []u8) LogWriter {
return .init(self, buffer);
}
/// Draw the whole conversation again.
///
/// Writing the transcript from the top of the log region is enough: a short
/// conversation ends where it ends and leaves the space below blank, and a
/// long one scrolls the region as it is written, ending at the bottom. No
/// line arithmetic is needed, which is what keeps this right when the text
/// contains wide characters or colour codes.
pub fn replay(self: *Screen) !void {
if (!self.active) return;
// Cleared before drawing: a suspend that arrives in the middle of the
// replay leaves a half-drawn screen, and the flag is what makes the
// next write draw it again.
_ = pending.swap(false, .acquire);
_ = self.refreshSize();
const bottom = self.logBottom();
const top = bottom + 1;
// A replay may exceed the writer buffer. Keep the clear and all its
// writes in one terminal frame, and release sync mode even on error.
if (!self.editor_frame) try self.out.writeStreamingAll(self.io, SYNC_BEGIN);
defer if (!self.editor_frame) self.out.writeStreamingAll(self.io, SYNC_END) catch {};
var buf: [8192]u8 = undefined;
var file_writer = self.out.writerStreaming(self.io, &buf);
const w = &file_writer.interface;
try w.writeAll(HIDE_CURSOR ++ RESET);
try w.print(ansi.SCROLL_REGION, .{bottom});
try w.writeAll(ansi.HOME_POSITION ++ ansi.CLEAR_SCREEN_END);
// The editor turns off output post-processing, so a bare newline would
// leave the column where it was. Writing CRLF keeps the replay correct
// both while a line is being edited and between turns.
const offsets = try visualRows(self.allocator, self.transcript.items, self.cols);
defer self.allocator.free(offsets);
self.scroll_rows = @min(self.scroll_rows, offsets.len -| bottom);
const end_row = offsets.len - self.scroll_rows;
const first_row = end_row -| bottom;
const view_start = offsets[first_row];
const view_end = if (end_row < offsets.len) offsets[end_row] else self.transcript.items.len;
const end = if (end_row < offsets.len and view_end > view_start and self.transcript.items[view_end - 1] == '\n') view_end - 1 else view_end;
const view = self.transcript.items[view_start..end];
try restoreRendition(w, self.transcript.items[0..view_start]);
var index: usize = 0;
var inverse = false;
while (index < view.len) {
if (view[index] == 0x1b) {
const next = @import("../util/terminal/ansi.zig").skipAnsiEscape(view, index);
try w.writeAll(view[index..next]);
if (inverse) try w.writeAll(ansi.INVERSE);
index = next;
continue;
}
const selected = view_start + index >= self.selection_start and view_start + index < self.selection_end;
if (selected != inverse) try w.writeAll(if (selected) ansi.INVERSE else ansi.NO_INVERSE);
inverse = selected;
const cp = term.decodeAt(view, index);
if (view[index] == '\n') {
if (inverse) try w.writeAll(ansi.NO_INVERSE);
inverse = false;
try w.writeAll("\r\n");
} else {
try w.writeAll(view[index..][0..cp.len]);
}
index += cp.len;
}
if (inverse) try w.writeAll(ansi.NO_INVERSE);
try w.writeAll(SAVE_CURSOR);
try w.print(ansi.ROW_START ++ ansi.CLEAR_SCREEN_END, .{top});
try self.writeStatus(w);
try self.writeThinking(w);
try w.writeAll(RESTORE_CURSOR);
try w.flush();
self.dirty = false;
self.status_dirty = false;
self.thinking_dirty = false;
self.rendered_len = self.transcript.items.len;
self.syncSignals();
}
/// Suspend the process the way Ctrl-Z does in a shell.
///
/// The terminal is handed back first: a program stopped while holding the
/// alternate screen hides the shell that is about to talk to the user. On
/// resume the screen is taken again and the conversation is marked for
/// repainting; the caller calls `reserveInput` before reading the line, and
/// that is where the repaint happens.
pub fn suspendProcess(self: *Screen) !void {
if (!self.active) {
// The inline layout has no screen to hand back, but the editor
// still reads Ctrl-Z as a key: stop like any foreground program.
raiseStop();
return;
}
self.leave();
raiseStop();
self.active = true;
try self.out.writeStreamingAll(self.io, ALT_ON ++ HIDE_CURSOR);
self.dirty = true;
self.syncSignals();
installSignals();
}
fn logBottom(self: *const Screen) usize {
const reserved = self.usableInputRows() + self.thinkingRows() + self.questionRows() + 1;
return self.rows -| @min(reserved, self.rows -| 1);
}
/// Leave a row each for the log and status whenever the window permits.
fn usableInputRows(self: *const Screen) usize {
return @max(@as(usize, 1), @min(self.input_rows, self.rows -| 2));
}
/// Rows the thinking panel actually has. Never more than the window can
/// spare: the status line and one row of conversation stay outside it.
fn thinkingRows(self: *const Screen) usize {
if (self.thinking_rows == 0) return 0;
const spare = self.rows -| (self.usableInputRows() + self.questionRows() + 2);
return @min(@min(self.thinking_rows, thinking_panel_rows), spare);
}
/// Progress and notices sit below the log/question dock and above thinking.
fn statusRow(self: *const Screen) usize {
// In a one- or two-row terminal some regions must overlap, but no
// cursor address may fall outside the physical window.
return @min(self.logBottom() + self.questionRows() + 1, @max(self.rows, 1));
}
/// The first row of the thinking panel, or 0 when there is none.
fn thinkingTop(self: *const Screen) usize {
return self.statusRow() + 1;
}
fn inputTop(self: *const Screen) usize {
return self.rows -| self.usableInputRows() + 1;
}
fn saveCursor(self: *Screen) void {
self.out.writeStreamingAll(self.io, SAVE_CURSOR) catch {};
}
fn syncSignals(self: *Screen) void {
handler_fd = self.out.handle;
handler_rows = self.rows;
handler_input_rows = self.input_rows;
handler_thinking_rows = self.thinkingRows();
}
};
fn statusWidth(bytes: []const u8) usize {
var width: usize = 0;
var i: usize = 0;
while (i < bytes.len) {
if (bytes[i] == 0x1b) {
i = @import("../util/terminal/ansi.zig").skipAnsiEscape(bytes, i);
continue;
}
const cp = term.decodeAt(bytes, i);
width += term.codepointWidth(cp);
i += cp.len;
}
return width;
}
/// Drop the front of the reasoning once it outgrows what the panel can show,
/// cutting on a line break so the oldest row on screen is not half a line.
fn trimThinking(text: *std.ArrayList(u8)) void {
if (text.items.len <= thinking_capacity_bytes) return;
var dropped = text.items.len - thinking_capacity_bytes;
if (std.mem.indexOfScalarPos(u8, text.items, dropped, '\n')) |newline| dropped = newline + 1;
const rest = text.items.len - dropped;
std.mem.copyForwards(u8, text.items[0..rest], text.items[dropped..]);
text.shrinkRetainingCapacity(rest);
}
/// Break the reasoning into rows `width` columns wide, at a space where the row
/// has one: the text arrives as a stream of tokens with almost no newlines, and
/// a word cut in half reads as a broken terminal rather than as thought.
///
/// A word longer than the window is still broken, because the alternative is a
/// row that cannot be drawn at all.
fn wrapThinking(allocator: std.mem.Allocator, text: []const u8, width: usize) !std.ArrayList([]const u8) {
var lines: std.ArrayList([]const u8) = .empty;
errdefer lines.deinit(allocator);
var start: usize = 0;
while (start < text.len) {
// Blank lines and the run of spaces after a break carry nothing.
while (start < text.len and (text[start] == '\n' or text[start] == '\r' or text[start] == ' ')) start += 1;
if (start >= text.len) break;
var i = start;
var used: usize = 0;
var last_space: ?usize = null;
while (i < text.len and text[i] != '\n') {
const cp = term.decodeAt(text, i);
const w = term.codepointWidth(cp);
if (used + w > width and i > start) break;
if (text[i] == ' ') last_space = i;
used += w;
i += cp.len;
}
// A row that ended at the window breaks at its last space; one that
// ended at a newline takes everything up to it.
const end = if (i >= text.len or text[i] == '\n')
i
else if (last_space) |space|
if (space > start) space else i
else
i;
// One codepoint wider than the window still has to fit on a row of its
// own, or the loop would never move past it.
const stop = if (end == start) start + term.decodeAt(text, start).len else end;
try lines.append(allocator, text[start..stop]);
var next = stop;
if (next < text.len and text[next] == '\n') next += 1;
while (next < text.len and text[next] == ' ') next += 1;
start = next;
}
return lines;
}
/// A viewport can start inside a styled span (including a soft-wrapped line).
/// Reconstruct its SGR state, but never replay cursor movement or OSC commands
/// from the hidden history. Start from defaults so redraws are deterministic.
fn restoreRendition(w: *std.Io.Writer, prefix: []const u8) !void {
try w.writeAll(RESET);
var i: usize = 0;
while (i < prefix.len) {
if (prefix[i] != 0x1b) {
i += 1;
continue;
}
const next = @import("../util/terminal/ansi.zig").skipAnsiEscape(prefix, i);
if (next >= i + 3 and prefix[i + 1] == '[' and prefix[next - 1] == 'm') {
try w.writeAll(prefix[i..next]);
}
i = next;
}
}
/// A `std.Io.Writer` over the screen's log, so the markdown renderer can stream
/// into the conversation through its own small buffer.
fn visualRows(a: std.mem.Allocator, bytes: []const u8, cols: usize) ![]usize {
var rows: std.ArrayList(usize) = .empty;
errdefer rows.deinit(a);
try rows.append(a, 0);
var column: usize = 0;
var i: usize = 0;
while (i < bytes.len) {
if (bytes[i] == 0x1b) {
i = @import("../util/terminal/ansi.zig").skipAnsiEscape(bytes, i);
continue;
}
if (bytes[i] == '\n') {