-
-
Notifications
You must be signed in to change notification settings - Fork 229
Expand file tree
/
Copy pathagent-shell-ui.el
More file actions
1708 lines (1579 loc) · 85 KB
/
Copy pathagent-shell-ui.el
File metadata and controls
1708 lines (1579 loc) · 85 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
;;; agent-shell-ui.el --- Interactive shell UI elements -*- lexical-binding: t; -*-
;; Copyright (C) 2024 Alvaro Ramirez
;; Author: Alvaro Ramirez https://xenodium.com
;; URL: https://github.com/xenodium/agent-shell
;; This package is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation; either version 3, or (at your option)
;; any later version.
;; This package is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;;
;; A library for creating interactive shell UI elements.
;;
;; Note: This package is in very early stages and likely has
;; rough edges.
;;
;; Report issues at https://github.com/xenodium/agent-shell/issues
;;
;; ✨ Please support this work https://github.com/sponsors/xenodium ✨
;;; Code:
(require 'cl-lib)
(require 'agent-shell-work-buffer)
(require 'map)
(require 'cursor-sensor)
(require 'seq)
(require 'subr-x)
(require 'text-property-search)
(defvar agent-shell-ui-post-expand-fragment-at-point-hook nil
"Hook run after expanding a fragment at point.
When run, the buffer is narrowed to the body region and
`inhibit-read-only' is in effect.")
(defvar agent-shell-ui-debug-enabled nil
"When non-nil, surface internal UI debugging aids.
For example, fragment qualified-ids appear in the echo area on
hover. These are implementation details of no use to users, so
they stay hidden by default.")
(defvar agent-shell-ui-fragment-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'agent-shell-ui-toggle-fragment)
(define-key map [mouse-1] #'agent-shell-ui-toggle-fragment)
(define-key map [remap self-insert-command] #'ignore)
map)
"Keymap active on a fragment's fold indicator and labels.
Applied as a `keymap' text property by
`agent-shell-ui-make-foldable-text', so it reaches that chrome and
nothing else. Scoping the keys to the text is what leaves the rest of
the buffer alone: RET still submits the prompt, typing still inserts,
and the mouse still selects.
Typing on the chrome runs `ignore' rather than erroring, since that
text is read-only.
For example, to fold with TAB as well as RET:
(with-eval-after-load \\='agent-shell-ui
(define-key agent-shell-ui-fragment-map
(kbd \"TAB\") #\\='agent-shell-ui-toggle-fragment))
Rebind with `define-key' rather than `setq': already rendered text
holds on to this keymap object.")
(defun agent-shell-ui--echo-action-hint (verb)
"Echo how to run an action described by VERB.
Searches `agent-shell-ui-fragment-map' for whichever key runs
`agent-shell-ui-toggle-fragment', skipping mouse bindings so the hint
names something the user can press.
For example, VERB \"toggle\" echoes \"Press RET to toggle\" with the
default bindings, or \"Press TAB to toggle\" once that map binds TAB."
(when-let* ((keys (seq-remove
(lambda (key) (mouse-event-p (aref key 0)))
(where-is-internal
#'agent-shell-ui-toggle-fragment
(list agent-shell-ui-fragment-map)))))
(message "Press %s to %s" (key-description (seq-first keys)) verb)))
(defun agent-shell-ui--fragment-help-echo (qualified-id)
"Return the `help-echo' value for a fragment tagged QUALIFIED-ID.
Returns QUALIFIED-ID only when `agent-shell-ui-debug-enabled' is set,
otherwise nil so the id stays hidden from users."
(when agent-shell-ui-debug-enabled
qualified-id))
(cl-defun agent-shell-ui-make-fragment-model (&key (namespace-id "global") (block-id "1") label-left label-right body group-id group-label (group-expanded t))
"Create a fragment model alist.
NAMESPACE-ID, BLOCK-ID, LABEL-LEFT, LABEL-RIGHT, and BODY are the keys.
GROUP-ID nests this fragment under a collapsible group header (a sibling
fragment with `block-id' GROUP-ID in the same namespace). When that
header does not yet exist, GROUP-LABEL materializes it (auto-create) with
GROUP-EXPANDED as its initial fold state. GROUP-ID nil means a top-level
fragment."
(list (cons :namespace-id namespace-id)
(cons :block-id block-id)
(cons :label-left (agent-shell-ui--string-or-nil label-left))
(cons :label-right (agent-shell-ui--string-or-nil label-right))
(cons :body (agent-shell-ui--string-or-nil body))
(cons :group-id (agent-shell-ui--string-or-nil group-id))
(cons :group-label (agent-shell-ui--string-or-nil group-label))
(cons :group-expanded group-expanded)))
(cl-defun agent-shell-ui-make-group-model (&key (namespace-id "global") (block-id "1") label-left label-right (expanded t))
"Create a group-header model alist.
A group header is a collapsible fragment with no body of its own; its
children are separate fragments referencing it by qualified-id via
`agent-shell-ui-make-fragment-model' GROUP-ID. NAMESPACE-ID, BLOCK-ID,
LABEL-LEFT, and LABEL-RIGHT render the header line. EXPANDED sets the
initial fold state. v1 is two-level: a group may not itself be nested."
(list (cons :namespace-id namespace-id)
(cons :block-id block-id)
(cons :kind 'group)
(cons :label-left (agent-shell-ui--string-or-nil label-left))
(cons :label-right (agent-shell-ui--string-or-nil label-right))
(cons :expanded expanded)))
(defun agent-shell-ui--insert-read-only (text)
"Insert TEXT as read-only output."
(add-text-properties 0 (length text)
'(read-only t front-sticky (read-only))
text)
(insert text))
(cl-defun agent-shell-ui-update-fragment (model &key append create-new on-post-process navigation expanded no-undo)
"Update or add a fragment using MODEL.
When APPEND is non-nil, append to body instead of replacing.
When CREATE-NEW is non-nil, create new block.
When ON-POST-PROCESS is non-nil, call this function after updating.
When NAVIGATION is `never', block won't be TAB navigatable.
When NAVIGATION is `auto', block is navigatable if non-empty body.
When NAVIGATION is `always', block is always TAB navigatable.
When EXPANDED is non-nil, body will be expanded by default.
When NO-UNDO is non-nil, disable undo recording for this operation.
For existing blocks, the current expansion state is preserved unless overridden.
Updates to existing blocks are applied per section: a body append
inserts the new chunk at the end of the body region without disturbing
already-rendered content, so `agent-shell-markdown' frozen ranges
stay intact and streaming append is O(new-chunk) rather than
O(accumulated-body). Label-only updates leave the body untouched."
(let* ((window (get-buffer-window (current-buffer)))
(saved-window-start (and window (window-start window)))
;; Marker so it survives the edits made while updating the block.
;; Bound out here so the unwind form below can release it.
(block-end nil))
(unwind-protect
(save-mark-and-excursion
(let* ((inhibit-read-only t)
(buffer-undo-list (if no-undo t buffer-undo-list))
(namespace-id (map-elt model :namespace-id))
(qualified-id (format "%s-%s" namespace-id (map-elt model :block-id)))
(new-label-left (map-elt model :label-left))
(new-label-right (map-elt model :label-right))
(new-body (map-elt model :body))
(group-id (map-elt model :group-id))
(effective-expanded (if (eq (map-elt model :kind) 'group)
(map-elt model :expanded)
expanded))
(block-start nil)
(body-range nil)
(wrote-hidden nil)
(padding-start nil)
(padding-end nil)
(group-header nil)
;; Locating the block means walking back over every
;; interval in it, and a streamed body accrues intervals
;; per chunk, so the search costs O(chunks so far) on every
;; chunk (issue #757). The cache answers it outright when
;; it still describes the buffer.
(cached (unless create-new
(agent-shell-ui--cached-block qualified-id)))
(match (unless cached
(save-mark-and-excursion
(goto-char (point-max))
(text-property-search-backward
'agent-shell-ui-state nil
(lambda (_ state)
(equal (map-elt state :qualified-id)
qualified-id))
t))))
(existing-start (cond (cached (marker-position
(map-elt cached :start)))
(match (prop-match-beginning match)))))
;; Resolve the parent group. A NEW child materializes its
;; header (auto-create) and routes into the group's region. An
;; EXISTING child keeps whatever group it already belongs to;
;; an update must never create a header or re-route, otherwise a
;; caller whose group-id advanced (e.g. a message streamed between
;; a tool call and its completion) would spawn an empty group.
;; Either way the resolved parent qualified-id and indent are
;; recorded on the model so insertion and body regeneration nest.
(cond
((and existing-start (not create-new))
(when-let* ((state (get-text-property existing-start
'agent-shell-ui-state))
(existing-group (map-elt state :group-id)))
(setq model (append model
(list (cons :group-qualified-id existing-group)
(cons :group-indent
(or (map-elt state :group-indent) " ")))))))
(group-id
(setq group-header (agent-shell-ui--insert-group-header
:namespace-id namespace-id
:group-id group-id
:group-label (map-elt model :group-label)
:expanded (map-elt model :group-expanded)
:navigation navigation))
(setq model (append model
(list (cons :group-qualified-id (map-elt group-header :qualified-id))
(cons :group-indent " "))))))
(when (or new-label-left new-label-right new-body)
(cond
;; Existing block — apply edits per changed section.
((and existing-start (not create-new))
(let* ((state (get-text-property existing-start
'agent-shell-ui-state))
(collapsed (map-elt state :collapsed)))
(setq block-start existing-start)
(save-excursion
(goto-char block-start)
(skip-chars-backward "\n")
(setq padding-start (point)))
;; Derive the block extent once, up front, and hold it as
;; a marker. Finding it means walking every text-property
;; interval in the block, and a streamed body accrues an
;; interval per chunk, so each rescan costs O(chunks so
;; far). A marker tracks every edit below for free,
;; including a label rewrite that changes length and shifts
;; everything under it, so it stays right where a position
;; captured here would go stale (issue #757).
(setq block-end
(copy-marker (or (and cached
(marker-position
(map-elt cached :end)))
(map-elt (agent-shell-ui--block-range
:position block-start)
:end)
(and match (prop-match-end match)))
t))
(when new-label-left
(agent-shell-ui--replace-label
qualified-id 'label-left new-label-left
block-start block-end))
(when new-label-right
(agent-shell-ui--replace-label
qualified-id 'label-right new-label-right
block-start block-end))
(when new-body
(let* ((current-block-end (marker-position block-end))
(existing-body-range
(agent-shell-ui--body-range block-start
current-block-end)))
(cond
;; Append to existing body — preserves rendered content.
((and append existing-body-range)
;; `--append-body' hides what it writes whenever the
;; body it extends is hidden, so an append into a
;; folded child leaves nothing exposed and the
;; group's fold below can be left alone.
(setq wrote-hidden
(and (not new-label-left)
(not new-label-right)
(agent-shell-ui--body-invisible-p
(map-elt existing-body-range :start)
(map-elt existing-body-range :end))))
(setq body-range
(or (agent-shell-ui--append-body
existing-body-range new-body qualified-id collapsed)
;; Empty chunk wrote nothing, so the body
;; range is the one we already have.
existing-body-range)))
;; Replace existing body in place.
(existing-body-range
(setq body-range
(agent-shell-ui--replace-body
existing-body-range new-body qualified-id collapsed)))
;; Body arriving for the first time on a labels-only
;; block — fall back to delete-and-regenerate so the
;; indicator transitions from placeholder to triangle
;; and the labels↔body separator is inserted. Labels
;; are recovered from the buffer (no cache).
(t
(let* ((existing-labels
(agent-shell-ui--read-fragment-labels
block-start current-block-end))
(final-model
(list (cons :namespace-id namespace-id)
(cons :block-id (map-elt model :block-id))
(cons :label-left
(or new-label-left
(map-elt existing-labels :label-left)))
(cons :label-right
(or new-label-right
(map-elt existing-labels :label-right)))
(cons :body new-body)
;; Preserve the parent group + indent so
;; the regenerated child stays nested.
(cons :group-qualified-id
(map-elt model :group-qualified-id))
(cons :group-indent
(map-elt model :group-indent)))))
(delete-region block-start current-block-end)
(goto-char block-start)
(agent-shell-ui--insert-fragment
final-model qualified-id (not collapsed) navigation))))))
(setq padding-end (or (marker-position block-end) (point)))))
;; New group child, inserted into the group's region. The
;; group's trailing separator (after the header) already sits
;; below, so no trailing newlines are added here.
((map-elt model :group-qualified-id)
(goto-char (agent-shell-ui--group-insertion-point
:group-qualified-id (map-elt model :group-qualified-id)))
(setq padding-start (point))
(agent-shell-ui--insert-read-only (agent-shell-ui--required-newlines 2))
(setq block-start (point))
(agent-shell-ui--insert-fragment model qualified-id effective-expanded navigation)
;; The group's trailing separator (the header's `\n\n', inserted
;; once) now sits right after this last child; fold it into
;; this child's padding so it is not stranded outside every
;; block's range.
(skip-chars-forward "\n")
(setq padding-end (point)))
;; New block.
(t
(goto-char (point-max))
(setq padding-start (point))
(agent-shell-ui--insert-read-only (agent-shell-ui--required-newlines 2))
(setq block-start (point))
(agent-shell-ui--insert-fragment model qualified-id effective-expanded navigation)
(agent-shell-ui--insert-read-only "\n\n")
(setq padding-end (point)))))
;; A collapsed group's children must stay hidden across updates.
;; A child's own edit path (insert, or replace-label/body on an
;; update) restores visibility from the child's own state, which
;; would reveal it under a folded header; re-apply the group
;; collapse so updates don't leak children onto the header line.
;;
;; Skipped when the update only appended to an already hidden
;; body, which hides its own chars. Re-applying then costs a
;; pass over the whole child region, and that region holds the
;; streamed body, so a folded group paid O(chunks so far) on
;; every chunk (issue #757).
(when-let* (((not wrote-hidden))
(group-qid (map-elt model :group-qualified-id))
(header (agent-shell-ui--group-header-range group-qid))
(header-state (get-text-property (map-elt header :start)
'agent-shell-ui-state))
((map-elt header-state :collapsed)))
(agent-shell-ui--set-group-collapsed group-qid t))
(when on-post-process
(funcall on-post-process))
(when-let* ((block-range (if block-end
;; Tracked across the edits above, so
;; no second walk of the block.
(list (cons :start block-start)
(cons :end (marker-position block-end)))
(agent-shell-ui--block-range :position block-start))))
(agent-shell-ui--cache-block qualified-id block-range)
;; Sections the update didn't touch are left out rather than
;; searched for: each search walks the block's accumulated
;; intervals, so on a streamed body that cost grew per chunk
;; (issue #757). A body written above reports the range it
;; wrote; only a regenerated block has to be searched.
(list (cons :block block-range)
(cons :body (or body-range
(when new-body
(agent-shell-ui--body-range
(map-elt block-range :start)
(map-elt block-range :end)))))
(cons :label-left (when new-label-left
(agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-section :value 'label-left
:from (map-elt block-range :start)
:to (map-elt block-range :end))))
(cons :label-right (when new-label-right
(agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-section :value 'label-right
:from (map-elt block-range :start)
:to (map-elt block-range :end))))
(cons :padding (when (and padding-start padding-end)
(list (cons :start padding-start)
(cons :end padding-end))))
(cons :group-header (map-elt group-header :range))))))
(when (markerp block-end)
(set-marker block-end nil))
(when window
(set-window-start window saved-window-start t)))))
(defun agent-shell-ui--read-fragment-labels (block-start block-end)
"Return alist with :label-left and :label-right strings (no properties).
Reads from the buffer between BLOCK-START and BLOCK-END. Used only by
the body-arriving-on-labels-only fallback in `agent-shell-ui-update-fragment'.
Labels are short, prop-free strings — safe to round-trip through the
buffer."
(let (fields)
(when-let* ((range (agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-section :value 'label-right
:from block-start :to block-end)))
(push (cons :label-right
(buffer-substring-no-properties (map-elt range :start)
(map-elt range :end)))
fields))
(when-let* ((range (agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-section :value 'label-left
:from block-start :to block-end)))
(push (cons :label-left
(buffer-substring-no-properties (map-elt range :start)
(map-elt range :end)))
fields))
fields))
(defun agent-shell-ui--apply-body-section-properties (start end qualified-id state body-invisible)
"Apply body-section text properties to chars in [START, END).
QUALIFIED-ID and STATE feed the help-echo and agent-shell-ui-state
properties. BODY-INVISIBLE non-nil means the existing body region
is currently hidden (collapsed label-ful fragment); new chars must
match. Explicit `invisible' assignment overrides any value the
new chars might have inherited via rear-stickiness from preceding
trailing-whitespace chars."
(add-text-properties start end
`(agent-shell-ui-section body
help-echo ,(agent-shell-ui--fragment-help-echo qualified-id)
read-only t
front-sticky (read-only)))
(when state
(put-text-property start end 'agent-shell-ui-state state))
(put-text-property start end 'invisible (if body-invisible t nil)))
(defun agent-shell-ui--body-invisible-p (body-start body-end)
"Return non-nil if the existing body region [BODY-START, BODY-END) is hidden.
Inspects the `invisible' property on the first body char. The
trailing-whitespace handler only sets `invisible' on chars from
the last non-whitespace position onwards, never the first char,
so the first char's `invisible' tracks the body's true collapse
state — including whitespace-only bodies (e.g. a body left as
two newlines after the markdown renderer stripped an empty
fenced block)."
(and (< body-start body-end)
(eq (get-text-property body-start 'invisible) t)))
(defun agent-shell-ui--apply-trailing-whitespace-invisible (body-start body-end)
"Hide trailing whitespace within [BODY-START, BODY-END) via invisible property.
Marks the hidden chars `rear-nonsticky' for `invisible' so chars later
inserted at BODY-END don't silently inherit `invisible t' from the
trailing-whitespace tail."
(save-excursion
(goto-char body-end)
(when (re-search-backward "[^ \t\n]" body-start t)
(forward-char 1)
(when (< (point) body-end)
(add-text-properties (point) body-end
'(invisible t rear-nonsticky (invisible)))))))
(defun agent-shell-ui--append-body (body-range chunk qualified-id _collapsed)
"Append CHUNK to the body region described by BODY-RANGE.
BODY-RANGE is an alist with `:start' and `:end' marking the existing
body section. Existing body chars stay in place — `agent-shell-markdown'
frozen tags and per-char faces survive across streaming chunks, no
re-rendering needed. QUALIFIED-ID is the fragment identifier used to
tag the new chars so the body's section property and help-echo line up
with the rest of the block.
_COLLAPSED is intentionally unused: visibility for new chars is derived
from the current visibility of the existing body, not from caller-supplied
state, because label-less fragments don't follow `state :collapsed'
\(their bodies stay visible regardless of how `:collapsed' was stored)."
(when (and (stringp chunk) (not (string-empty-p chunk)))
(let* ((body-start (map-elt body-range :start))
(body-end (map-elt body-range :end))
(state (get-text-property (max body-start (1- body-end))
'agent-shell-ui-state))
(body-invisible (agent-shell-ui--body-invisible-p body-start body-end)))
;; Trailing-whitespace invisibility on the old tail may no longer
;; apply once the chunk lands — clear and re-derive. Only when
;; the body is visible; for a hidden body the existing invisible
;; spans the whole body and must stay.
;;
;; `invisible' can only sit on the trailing-whitespace tail of a
;; visible body (`agent-shell-markdown' never sets it mid-body), so
;; clearing just the tail is equivalent to clearing the whole body
;; without walking every property interval on each chunk (the whole
;; body grows, so a full clear is O(body) per chunk).
(unless body-invisible
(when (and (< body-start body-end)
(eq (get-text-property (1- body-end) 'invisible) t))
(let ((tail-start (or (previous-single-property-change
body-end 'invisible nil body-start)
body-start)))
(remove-text-properties tail-start body-end '(invisible nil)))))
(goto-char body-end)
(let ((insert-start (point)))
(insert (agent-shell-ui--indent-text
chunk (concat (or (map-elt state :group-indent) "") " ")))
(let ((insert-end (point)))
(agent-shell-ui--apply-body-section-properties
insert-start insert-end qualified-id state body-invisible)
(agent-shell-ui--apply-trailing-whitespace-invisible
body-start insert-end)
;; The body grew by exactly what we inserted, so the caller can
;; take the new range from here instead of searching the block's
;; accumulated intervals for it again (issue #757).
(list (cons :start body-start)
(cons :end insert-end)))))))
(defun agent-shell-ui--replace-body (body-range new-body qualified-id _collapsed)
"Replace the body region described by BODY-RANGE with NEW-BODY.
BODY-RANGE is an alist with `:start' and `:end'. Only the body chars
are touched — the surrounding label, indicator, and padding stay put,
so block-id and section tagging on the rest of the block are preserved.
QUALIFIED-ID is the fragment identifier used to tag the inserted chars.
_COLLAPSED is intentionally unused: visibility on the inserted chars
matches the body's current visibility, not caller-supplied state."
(let* ((body-start (map-elt body-range :start))
(body-end (map-elt body-range :end))
(state (get-text-property (max body-start (1- body-end))
'agent-shell-ui-state))
(body-invisible (agent-shell-ui--body-invisible-p body-start body-end)))
(delete-region body-start body-end)
(goto-char body-start)
(when (and (stringp new-body) (not (string-empty-p new-body)))
(let ((trimmed new-body))
(when (string-prefix-p "\n" trimmed)
(setq trimmed (string-trim-left trimmed "\n")))
(when (string-suffix-p "\n\n" trimmed)
(setq trimmed (concat (string-trim-right trimmed) "\n\n")))
(let ((insert-start (point)))
(insert (agent-shell-ui--indent-text
(string-remove-prefix " " trimmed)
(concat (or (map-elt state :group-indent) "") " ")))
(let ((insert-end (point)))
(agent-shell-ui--apply-body-section-properties
insert-start insert-end qualified-id state body-invisible)
(agent-shell-ui--apply-trailing-whitespace-invisible
insert-start insert-end)))))
;; Point sits at the end of what we wrote (or at BODY-START when the
;; new body was empty), so the caller can take the new range from here
;; rather than searching the block for it again (issue #757).
(list (cons :start body-start)
(cons :end (point)))))
(defun agent-shell-ui--label-rendered-p (text section start end)
"Return non-nil when the region START to END already renders TEXT.
SECTION is one of `label-left' or `label-right'. Left labels compare
`font-lock-face' alongside the text, since a status label can recolor
without changing a character (e.g. a style whose box color alone
separates pending from completed). Right labels compare text only:
markdown is rendered over them in place, so their faces no longer match
the ones TEXT carries.
Faces are compared in place, a run at a time, so the labels streaming
re-sends unchanged cost no allocation. Each step lands on the nearer
of the two sides' next face change, since the stretch before it is
uniform on both."
(and (string= (substring-no-properties text)
(buffer-substring-no-properties start end))
(or (not (eq section 'label-left))
(let ((position start))
(while (and (< position end)
(equal (get-text-property position 'font-lock-face)
(get-text-property (- position start)
'font-lock-face text)))
(setq position
(min (or (next-single-property-change
position 'font-lock-face nil end)
end)
(+ start (or (next-single-property-change
(- position start) 'font-lock-face text)
(length text))))))
(>= position end)))))
(defun agent-shell-ui--replace-label (qualified-id section new-text block-start block-end)
"Replace the SECTION region of fragment QUALIFIED-ID with NEW-TEXT.
SECTION is one of `label-left' or `label-right'. Only the named label
region is rewritten — the other label, the indicator, and the body of
the same block stay untouched, so block tagging and fragment identity
are preserved across label updates.
BLOCK-START and BLOCK-END bound QUALIFIED-ID's block, as the caller
already resolved it. Labels sit at the top of a block, so searching
down from BLOCK-START lands on one within a few intervals. Locating the
block here instead meant walking back from `point-max' over everything
below it, and an activity group's header, relabeled on every chunk, sits
above its group's whole accumulated body (issue #757). BLOCK-END is
read at the point of use, so a marker following the edits made here can
be handed in."
(when (stringp new-text)
(when-let* ((region
(save-excursion
(goto-char block-start)
(when-let* ((m (text-property-search-forward
'agent-shell-ui-section section t t)))
(when (<= (prop-match-end m) block-end)
(cons (prop-match-beginning m)
(prop-match-end m))))))
;; Skip the rewrite when the label already renders
;; identically: tool-call updates re-send unchanged
;; status/title labels on every chunk, and the rewrite
;; (delete + insert + re-propertize) is pure waste. A
;; guard clause returning nil makes the whole `when-let*'
;; short-circuit so the rewrite body never runs.
((not (agent-shell-ui--label-rendered-p
new-text section (car region) (cdr region)))))
(let* ((region-start (car region))
(region-end (cdr region))
(state (get-text-property region-start 'agent-shell-ui-state)))
(delete-region region-start region-end)
(goto-char region-start)
(let ((insert-start (point)))
(insert (agent-shell-ui-make-foldable-text
:text new-text
:hint "toggle"))
(let ((insert-end (point)))
(add-text-properties insert-start insert-end
`(agent-shell-ui-section ,section
help-echo ,(agent-shell-ui--fragment-help-echo qualified-id)
read-only t
front-sticky (read-only)))
(when state
(put-text-property insert-start insert-end
'agent-shell-ui-state state))))))))
(cl-defun agent-shell-ui-delete-fragment (&key namespace-id block-id no-undo)
"Delete fragment with NAMESPACE-ID and BLOCK-ID.
When NO-UNDO is non-nil, disable undo recording for this operation."
(save-mark-and-excursion
(let* ((inhibit-read-only t)
(buffer-undo-list (if no-undo t buffer-undo-list))
(qualified-id (format "%s-%s" namespace-id block-id))
(match (save-mark-and-excursion
(goto-char (point-max))
(text-property-search-backward
'agent-shell-ui-state nil
(lambda (_ state)
(equal (map-elt state :qualified-id) qualified-id))
t))))
(when match
(let ((block-start (prop-match-beginning match))
(block-end (prop-match-end match)))
;; Remove trailing vertical space that's part of the block, but
;; stop at the next fragment's content. The next fragment's
;; leading indicator (e.g. the " " collapse placeholder) is
;; whitespace too, so a plain `skip-chars-forward' would swallow
;; it and misalign that fragment. Its chars carry an
;; `agent-shell-ui-state', which the inter-block separators do not.
(goto-char block-end)
(while (and (not (eobp))
(memq (char-after) '(?\s ?\t ?\n))
(not (get-text-property (point) 'agent-shell-ui-state)))
(forward-char 1))
(setq block-end (point))
(delete-region block-start block-end))))))
(cl-defun agent-shell-ui--block-range (&key position)
"Get block range at POSITION if found. Nil otherwise.
In the form:
((start . 1)
(end . 3))."
(when-let* ((qualified-id (map-elt (get-text-property (or position (point)) 'agent-shell-ui-state) :qualified-id)))
(agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-state
:value qualified-id
:predicate (lambda (qualified-id property)
(equal (map-elt property :qualified-id) qualified-id)))))
(defvar-local agent-shell-ui--block-cache nil
"Markers for the blocks most recently updated in this buffer.
Most recent first, for example:
(((:qualified-id . \"3-msg\") (:start . #<marker at 412>)
(:end . #<marker at 980>))
((:qualified-id . \"3-tool1\") (:start . #<marker at 96>)
(:end . #<marker at 410>)))
Buffer local by design: a shell and its viewport render the same
fragments into separate buffers, so each keeps its own markers and there
is nothing to keep in step between them.
Only the last few blocks are kept. Streaming revisits a handful at a
time, and every live marker costs a little on each insertion, so this
must not grow with the session.")
(defun agent-shell-ui--block-id-at (position qualified-id)
"Return non-nil when POSITION holds a char of QUALIFIED-ID's block."
(equal (map-elt (get-text-property position 'agent-shell-ui-state)
:qualified-id)
qualified-id))
(defun agent-shell-ui--cached-block (qualified-id)
"Return QUALIFIED-ID's cached entry when it still describes the buffer.
Entries carry markers, so the caller reads `:start' and `:end' at the
point of use: a label rewritten in between moves the end, and the marker
has already followed it.
Verified against the buffer on every use rather than invalidated when
the buffer changes. The viewport erases and rebuilds itself
\(`agent-shell-viewport--render'), which collapses every marker in it to
`point-min', so an entry that no longer describes a block has to fail
here and send the caller back to searching. Both ends are checked:
after an erase a stale end sits at `point-min' too, where a start-only
check could pass and hand back an inverted range."
(when-let* ((entry (seq-find (lambda (entry)
(equal (map-elt entry :qualified-id)
qualified-id))
agent-shell-ui--block-cache))
(start (marker-position (map-elt entry :start)))
(end (marker-position (map-elt entry :end)))
((< start end))
((<= end (point-max)))
((agent-shell-ui--block-id-at start qualified-id))
((agent-shell-ui--block-id-at (1- end) qualified-id))
;; END must be the block's end, not a position inside it.
((or (= end (point-max))
(not (agent-shell-ui--block-id-at end qualified-id)))))
entry))
(defun agent-shell-ui--cache-block (qualified-id range)
"Remember RANGE as QUALIFIED-ID's block range in this buffer.
Replaces any entry already held for QUALIFIED-ID, and drops the oldest
once more than a handful are cached. See `agent-shell-ui--block-cache'."
(setq agent-shell-ui--block-cache
(seq-remove (lambda (entry)
(when (equal (map-elt entry :qualified-id)
qualified-id)
(agent-shell-ui--release-cached-block entry)
t))
agent-shell-ui--block-cache))
(push (list (cons :qualified-id qualified-id)
(cons :start (copy-marker (map-elt range :start)))
;; Advances as the body is appended to, so the end stays
;; right without re-deriving it.
(cons :end (copy-marker (map-elt range :end) t)))
agent-shell-ui--block-cache)
(seq-do #'agent-shell-ui--release-cached-block
(seq-drop agent-shell-ui--block-cache 8))
(setq agent-shell-ui--block-cache (seq-take agent-shell-ui--block-cache 8)))
(defun agent-shell-ui--release-cached-block (entry)
"Point ENTRY's markers nowhere, so they stop tracking buffer edits."
(set-marker (map-elt entry :start) nil)
(set-marker (map-elt entry :end) nil))
(defun agent-shell-ui--body-range (block-start block-end)
"Return the body section range within [BLOCK-START, BLOCK-END), or nil.
A block lays its sections out as indicator, labels, then body, and the
body runs to the block's end:
[indicator][label-left][ ][label-right][\\n\\n][body ... BLOCK-END)
So the end is BLOCK-END, and only the start has to be found: step over
the sections above it, of which there are a fixed few, and stop at the
first body char. Searching for the body's own run instead would walk
every interval in it, and a streamed body accrues intervals per chunk,
making that O(chunks so far) on every chunk (issue #757)."
(let ((pos block-start))
(while (and (< pos block-end)
(not (eq (get-text-property pos 'agent-shell-ui-section)
'body)))
(setq pos (next-single-property-change pos 'agent-shell-ui-section
nil block-end)))
;; The scan stops early only on a body char, so landing inside the
;; block means POS is the body's start. A block with no body runs
;; POS to BLOCK-END instead.
(when (< pos block-end)
(list (cons :start pos)
(cons :end block-end)))))
(cl-defun agent-shell-ui--nearest-range-matching-property (&key property value (predicate t) from to)
"Return nearest range where PREDICATE is non-nil for PROPERTY and VALUE."
(save-mark-and-excursion
(save-restriction
(when (and from to)
(narrow-to-region from to)
;; Search from the start of the region rather than from wherever
;; point was left. Callers passing FROM/TO are after a section
;; within a single block, and a block holds at most one of each,
;; so the result is the same either way. The searches below walk
;; from point, and during streaming point sits at the block end
;; while the sections sought (labels, body start) sit at the
;; block start, so starting at point walked the whole accumulated
;; body on every chunk (issue #757).
(goto-char (point-min)))
(let ((backward-match (or (text-property-search-backward property value predicate)
(progn
(unless (eobp)
(forward-char 1))
(text-property-search-backward property value predicate))))
(forward-match (text-property-search-forward property value predicate)))
(when (or backward-match forward-match)
`((:start . ,(if backward-match
(prop-match-beginning backward-match)
(prop-match-beginning forward-match)))
(:end . ,(if forward-match
(prop-match-end forward-match)
(prop-match-end backward-match)))))))))
(defun agent-shell-ui--cached-group-header (group-qualified-id)
"Return GROUP-QUALIFIED-ID's cached header range, or nil.
Only a block recorded as a group header qualifies, so a child that
happened to share the id could not be mistaken for one."
(when-let* ((cached (agent-shell-ui--cached-block group-qualified-id))
(start (marker-position (map-elt cached :start)))
((eq (map-elt (get-text-property start 'agent-shell-ui-state)
:kind)
'group)))
(list (cons :start start)
(cons :end (marker-position (map-elt cached :end))))))
(defun agent-shell-ui--group-header-range (group-qualified-id)
"Return (:start :end) of the group header GROUP-QUALIFIED-ID, or nil.
Answered from `agent-shell-ui--block-cache' where possible, and the
result of a search is recorded there. The search starts at `point-min',
so it costs whatever sits above the group, and a collapsed group looks
its header up several times per child update: without the cache a
turn's cost grew with everything already in the buffer (issue #757)."
(or (agent-shell-ui--cached-group-header group-qualified-id)
(when-let* ((match (save-mark-and-excursion
(goto-char (point-min))
(text-property-search-forward
'agent-shell-ui-state nil
(lambda (_ state)
(and (equal (map-elt state :qualified-id)
group-qualified-id)
(eq (map-elt state :kind) 'group)))
t)))
(range (agent-shell-ui--block-range
:position (prop-match-beginning match))))
(agent-shell-ui--cache-block group-qualified-id range)
range)))
(cl-defun agent-shell-ui--group-children (&key group-qualified-id)
"Return ordered child block ranges of group GROUP-QUALIFIED-ID.
Each element is (:qualified-id ID :start S :end E). Children are the
fragments that follow the header contiguously and carry `:group-id'
equal to GROUP-QUALIFIED-ID; the run stops at the first non-child."
(when-let* ((header (agent-shell-ui--group-header-range group-qualified-id)))
(save-mark-and-excursion
(let ((children '())
(pos (map-elt header :end)))
(catch 'done
(while t
(goto-char pos)
(skip-chars-forward " \t\n")
(when (eobp) (throw 'done nil))
(let ((state (get-text-property (point) 'agent-shell-ui-state)))
(unless (and state (equal (map-elt state :group-id) group-qualified-id))
(throw 'done nil))
(let ((block (agent-shell-ui--block-range :position (point))))
;; POS advances only to the child's end, with nothing requiring
;; that end to move forward. A range ending at or behind POS
;; re-examines the same child forever (consing on every pass),
;; and a nil BLOCK reaches `goto-char' as nil. Stop instead,
;; and log it: the remaining children go unenumerated, which
;; surfaces later as a child left out of a fold or a new child
;; inserted above its siblings.
(unless (> (map-elt block :end 0) pos)
(message "agent-shell: stopped enumerating group %s: child %s at %d resolved to %S, not past %d"
group-qualified-id (map-elt state :qualified-id) (point) block pos)
(throw 'done nil))
(push (list (cons :qualified-id (map-elt state :qualified-id))
(cons :start (map-elt block :start))
(cons :end (map-elt block :end)))
children)
(setq pos (map-elt block :end))))))
(nreverse children)))))
(defun agent-shell-ui--group-children-end (group-qualified-id from)
"Return the end of the last child in group with GROUP-QUALIFIED-ID.
Nil when the group has no children.
The same position `agent-shell-ui--group-children' reports for its last
element, found by walking the children's `agent-shell-ui-state'
intervals forward from FROM instead of resolving each child's block
range. FROM is the group header block's end, where the children start
\(a header is labels-only, so its block ends before the separator
preceding the first child).
Callers needing only that one position use this: enumerating costs a
block walk per child, and runs on every insertion and on every update
under a collapsed group (issue #757)."
(save-mark-and-excursion
(goto-char from)
(let ((end from))
(catch 'done
(while t
(skip-chars-forward " \t\n")
(when (eobp)
(throw 'done nil))
(unless (equal (map-elt (get-text-property (point) 'agent-shell-ui-state)
:group-id)
group-qualified-id)
(throw 'done nil))
;; Step over this child's state run. A non-advancing change
;; position would spin forever, so stop on one.
(let ((next (next-single-property-change (point) 'agent-shell-ui-state
nil (point-max))))
(unless (> next (point))
(throw 'done nil))
(goto-char next)
(setq end next))))
(unless (= end from)
end))))
(cl-defun agent-shell-ui--group-child-region (&key group-qualified-id)
"Return (:start :end) spanning group GROUP-QUALIFIED-ID's children, or nil.
Spans from just after the header to the end of the last child."
(when-let* ((header (agent-shell-ui--group-header-range group-qualified-id))
(end (agent-shell-ui--group-children-end group-qualified-id
(map-elt header :end))))
(list (cons :start (map-elt header :end))
(cons :end end))))
(cl-defun agent-shell-ui--group-insertion-point (&key group-qualified-id)
"Return the buffer position for a new child of group GROUP-QUALIFIED-ID.
After the current last child, or just after the header when empty."
(when-let* ((header (agent-shell-ui--group-header-range group-qualified-id)))
(or (agent-shell-ui--group-children-end group-qualified-id
(map-elt header :end))
(map-elt header :end))))
(cl-defun agent-shell-ui--insert-group-header (&key namespace-id group-id group-label (expanded t) navigation)
"Insert a header for NAMESPACE-ID/GROUP-ID unless one already exists.
When created, it lands at `point-max' with GROUP-LABEL as its label,
EXPANDED as its initial fold state, and NAVIGATION for navigability.
Return an alist with `:qualified-id' and, only when this call created
the header, `:range' as (:start . :end) spanning the inserted header
plus its surrounding padding."
(let ((group-qualified-id (format "%s-%s" namespace-id group-id))
(range nil))
(unless (agent-shell-ui--group-header-range group-qualified-id)
(goto-char (point-max))
(let ((start (point)))
(agent-shell-ui--insert-read-only (agent-shell-ui--required-newlines 2))
(agent-shell-ui--insert-fragment
(agent-shell-ui-make-group-model
:namespace-id namespace-id :block-id group-id
:label-left group-label :expanded expanded)
group-qualified-id expanded navigation)
(agent-shell-ui--insert-read-only "\n\n")
(setq range (list (cons :start start)
(cons :end (point))))))
(list (cons :qualified-id group-qualified-id)
(cons :range range))))
(defun agent-shell-ui--labels-end (block)
"Return the end of BLOCK's label-right, else label-left, else nil."
(or (map-elt (agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-section :value 'label-right
:from (map-elt block :start) :to (map-elt block :end))
:end)
(map-elt (agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-section :value 'label-left
:from (map-elt block :start) :to (map-elt block :end))
:end)))
(defun agent-shell-ui--set-indicator-collapsed (block collapsed)
"Set BLOCK's fold indicator glyph to `▶'/`▼' to match COLLAPSED.
Both glyphs are two columns wide, so surrounding positions do not shift."
(when-let* ((indicator (agent-shell-ui--nearest-range-matching-property
:property 'agent-shell-ui-section :value 'indicator
:from (map-elt block :start) :to (map-elt block :end)))
(props (text-properties-at (map-elt indicator :start))))
(delete-region (map-elt indicator :start) (map-elt indicator :end))
(goto-char (map-elt indicator :start))
(insert (if collapsed "▶ " "▼ "))
(add-text-properties (map-elt indicator :start) (point) props)))
(defun agent-shell-ui--apply-own-collapsed (block-start)