-
-
Notifications
You must be signed in to change notification settings - Fork 65
Expand file tree
/
Copy pathkitty_passthrough.go
More file actions
1828 lines (1656 loc) · 63.2 KB
/
Copy pathkitty_passthrough.go
File metadata and controls
1828 lines (1656 loc) · 63.2 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
package app
import (
"bytes"
"encoding/base64"
"fmt"
"log"
"os"
"runtime"
"sync"
"time"
"github.com/Gaurav-Gosain/tuios/internal/terminal"
"github.com/Gaurav-Gosain/tuios/internal/vt"
)
func kittyPassthroughLog(format string, args ...any) {
if os.Getenv("TUIOS_DEBUG_INTERNAL") != "1" {
return
}
f, err := os.OpenFile("/tmp/tuios-debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer func() { _ = f.Close() }()
_, _ = fmt.Fprintf(f, "[%s] KITTY-PASSTHROUGH: %s\n", time.Now().Format("15:04:05.000"), fmt.Sprintf(format, args...))
}
// isKittyResponse checks if data looks like a kitty graphics protocol response
// rather than real image data. Responses are "OK" or POSIX error names like
// "ENOENT", "EINVAL", "EBADMSG" (start with 'E' followed by uppercase).
func isKittyResponse(data []byte) bool {
if len(data) == 0 {
return false
}
if string(data) == "OK" {
return true
}
// POSIX error codes: start with 'E', second char is uppercase A-Z
return len(data) >= 2 && data[0] == 'E' && data[1] >= 'A' && data[1] <= 'Z'
}
type KittyPassthrough struct {
mu sync.Mutex
enabled bool
// inlineGraphics indicates the host terminal is xterm.js with a custom
// kitty overlay (xterm-kitty-overlay.js) that renders placements as
// absolutely-positioned DOM canvases. In this mode, file-based
// transmissions (t=f, t=s) are read server-side and re-encoded as
// direct (t=d) chunks because the browser cannot read local files.
inlineGraphics bool
hostOut *os.File
hostMu sync.Mutex // serializes writes to hostOut across render + async paths
placements map[string]map[uint32]*PassthroughPlacement
imageIDMap map[string]map[uint32]uint32 // maps (windowID, guestImageID) -> hostImageID
nextHostID uint32
pendingOutput []byte
videoFrameBuf []byte // Reusable buffer for immediate video frame writes
// Async video frame writer. Video apps (mpv, youterm) send 30+ fps of
// large image data. Processing synchronously inside the VT callback
// blocks the bubbletea render loop and makes the entire UI unresponsive.
// Instead we enqueue frames to this channel; a background goroutine
// drains it and writes to hostOut. Channel capacity 1 means we always
// keep at most one pending frame; newer frames replace older ones.
asyncFrameCh chan []byte
// Pending direct transmission data (for chunked transfers)
pendingDirectData map[string]*pendingDirectTransmit // key: windowID
// Screen dimensions (updated by RefreshAllPlacements)
screenWidth int
screenHeight int
}
// pendingDirectTransmit holds accumulated data for chunked direct transmissions
type pendingDirectTransmit struct {
Data []byte
RawPayload string // Accumulated raw base64 payload (avoids decode→re-encode)
Format vt.KittyGraphicsFormat
Compression vt.KittyGraphicsCompression
Width int
Height int
ImageID uint32
Columns int
Rows int
SourceX int
SourceY int
SourceWidth int
SourceHeight int
XOffset int
YOffset int
ZIndex int32
Virtual bool
CursorMove int
// HeaderParams stores filtered params from the first (params-only) chunk,
// to be merged into the first data-carrying chunk. Needed because chafa
// sends params and data in separate APC sequences.
HeaderParams string
HeaderSent bool
// AndPlace tracks whether the original chunk that created this pending
// was a TransmitPlace (action T). Chafa sends first chunk as T (andPlace=true)
// then subsequent chunks as t (andPlace=false). We track this so the final
// chunk's PlacementResult is returned correctly for whitespace reservation.
AndPlace bool
// Position info from the first chunk (a=T command)
WindowX int
WindowY int
WindowWidth int
WindowHeight int
ContentOffsetX int
ContentOffsetY int
CursorX int
CursorY int
ScrollbackLen int
IsAltScreen bool
}
type PassthroughPlacement struct {
GuestImageID uint32
HostImageID uint32
PlacementID uint32
WindowID string
GuestX int
AbsoluteLine int // Absolute line position (scrollbackLen + cursorY at placement time)
Streaming bool // True while chunks are still being received (don't re-place)
HostX int
HostY int
Cols int
Rows int // Original image rows (before any capping)
DisplayRows int // Capped rows for initial display
Hidden bool // True when placement is completely out of view
DataDirty bool // True when image data was re-transmitted (needs re-place for video)
// Source clipping parameters (pixels) - preserved for re-placement
SourceX int
SourceY int
SourceWidth int
SourceHeight int
XOffset int
YOffset int
ZIndex int32
Virtual bool
// Image's NATIVE pixel dimensions as transmitted (from s/v params).
// Used to derive an accurate pixels-per-cell for source-region cropping
// - critical when client and daemon have different cell sizes (web mode).
ImagePixelWidth int
ImagePixelHeight int
// Track which screen the image was placed on
PlacedOnAltScreen bool // True if placed while alternate screen was active
// Current clipping state (rows/cols to clip from each edge)
ClipTop int
ClipBottom int
ClipLeft int
ClipRight int
MaxShowable int // Max rows that can be shown in current viewport
MaxShowableCols int // Max cols that can be shown in current viewport
}
type WindowPositionInfo struct {
WindowX int
WindowY int
ContentOffsetX int
ContentOffsetY int
Width int
Height int
Visible bool
ScrollbackLen int // Total scrollback lines
ScrollOffset int // Current scroll offset (0 = at bottom)
IsBeingManipulated bool // True when window is being dragged/resized
ScreenWidth int // Host terminal width
ScreenHeight int // Host terminal height
WindowZ int // Window z-index for occlusion detection
IsAltScreen bool // True when alternate screen is active (vim, less, etc.)
}
// KittyPassthroughOptions configures a KittyPassthrough instance.
type KittyPassthroughOptions struct {
// ForceEnable skips capability detection and enables kitty graphics
// unconditionally. Used in web mode where stdin isn't a real TTY so
// GetHostCapabilities() can't detect kitty support, but the browser
// terminal (xterm.js with kitty addon) supports it.
ForceEnable bool
// Output is the writer for kitty graphics APC sequences. If nil, the
// passthrough opens /dev/tty (or falls back to os.Stdout). Web mode
// should pass the sip session's PtySlave so graphics bytes flow through
// the same PTY as bubbletea's text output to the browser.
Output *os.File
}
// NewKittyPassthrough creates a passthrough using auto-detected capabilities
// and /dev/tty for output. Use NewKittyPassthroughWithOptions for finer
// control (web mode, custom writers).
func NewKittyPassthrough() *KittyPassthrough {
return NewKittyPassthroughWithOptions(KittyPassthroughOptions{})
}
// NewKittyPassthroughWithOptions creates a passthrough with custom options.
func NewKittyPassthroughWithOptions(opts KittyPassthroughOptions) *KittyPassthrough {
caps := GetHostCapabilities()
enabled := caps.KittyGraphics || opts.ForceEnable
kittyPassthroughLog("NewKittyPassthrough: KittyGraphics=%v Force=%v TerminalName=%s", caps.KittyGraphics, opts.ForceEnable, caps.TerminalName)
// Open /dev/tty once for the lifetime of the passthrough (avoids per-frame open/close)
hostOut := opts.Output
if hostOut == nil {
hostOut = os.Stdout
if tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil {
hostOut = tty
}
}
kp := &KittyPassthrough{
enabled: enabled,
inlineGraphics: opts.ForceEnable,
hostOut: hostOut,
placements: make(map[string]map[uint32]*PassthroughPlacement),
imageIDMap: make(map[string]map[uint32]uint32),
nextHostID: 1,
pendingDirectData: make(map[string]*pendingDirectTransmit),
asyncFrameCh: make(chan []byte, 1),
}
go kp.asyncFrameWriter()
return kp
}
// WriteToHost writes graphics data directly to the host terminal,
// wrapped in synchronized update sequences to prevent tearing.
// asyncFrameWriter drains asyncFrameCh and writes video frames to hostOut
// in a background goroutine so the VT callback and render loop stay
// responsive during high-fps video playback.
func (kp *KittyPassthrough) asyncFrameWriter() {
for data := range kp.asyncFrameCh {
if kp.hostOut == nil || len(data) == 0 {
continue
}
kp.hostMu.Lock()
_, _ = kp.hostOut.Write(syncBegin)
_, _ = kp.hostOut.Write(data)
_, _ = kp.hostOut.Write(syncEnd)
kp.hostMu.Unlock()
}
}
func (kp *KittyPassthrough) WriteToHost(data []byte) {
if kp.hostOut == nil || len(data) == 0 {
return
}
kp.hostMu.Lock()
_, _ = kp.hostOut.Write(syncBegin)
_, _ = kp.hostOut.Write(data)
_, _ = kp.hostOut.Write(syncEnd)
kp.hostMu.Unlock()
}
// getOrAllocateHostID returns the host image ID for a given (windowID, guestImageID) pair.
// If no mapping exists, it allocates a new host ID and stores the mapping.
func (kp *KittyPassthrough) getOrAllocateHostID(windowID string, guestImageID uint32) uint32 {
if kp.imageIDMap[windowID] == nil {
kp.imageIDMap[windowID] = make(map[uint32]uint32)
}
if hostID, ok := kp.imageIDMap[windowID][guestImageID]; ok {
return hostID
}
hostID := kp.allocateHostID()
kp.imageIDMap[windowID][guestImageID] = hostID
kittyPassthroughLog("getOrAllocateHostID: windowID=%s, guestID=%d -> hostID=%d", windowID[:8], guestImageID, hostID)
return hostID
}
func (kp *KittyPassthrough) IsEnabled() bool {
kp.mu.Lock()
defer kp.mu.Unlock()
return kp.enabled
}
func (kp *KittyPassthrough) IsInlineGraphics() bool {
return kp.inlineGraphics
}
func (kp *KittyPassthrough) FlushPending() []byte {
kp.mu.Lock()
defer kp.mu.Unlock()
if len(kp.pendingOutput) == 0 {
return nil
}
out := kp.pendingOutput
kp.pendingOutput = nil
return out
}
// Synchronized output mode 2026 (supported by Kitty, Ghostty, WezTerm, etc.)
// This prevents screen tearing by telling the terminal to buffer output
// until the end sequence is received.
var (
syncBegin = []byte("\x1b[?2026h") // Begin Synchronized Update
syncEnd = []byte("\x1b[?2026l") // End Synchronized Update
)
// flushToHost writes any pending output immediately to the host terminal,
// wrapped in synchronized update sequences to prevent tearing/flickering.
// Must be called while kp.mu is already held.
func (kp *KittyPassthrough) flushToHost() {
if len(kp.pendingOutput) > 0 && kp.hostOut != nil {
_, _ = kp.hostOut.Write(syncBegin)
_, _ = kp.hostOut.Write(kp.pendingOutput)
_, _ = kp.hostOut.Write(syncEnd)
kp.pendingOutput = kp.pendingOutput[:0]
}
}
func (kp *KittyPassthrough) allocateHostID() uint32 {
id := kp.nextHostID
kp.nextHostID++
if kp.nextHostID == 0 {
kp.nextHostID = 1
}
return id
}
// calculateImageCells calculates the number of rows and columns the image will occupy.
// Uses cmd.Rows/Columns if specified, otherwise calculates from pixel dimensions and cell size.
func (kp *KittyPassthrough) calculateImageCells(cmd *vt.KittyCommand) (rows, cols int) {
if cmd.Rows > 0 {
rows = cmd.Rows
}
if cmd.Columns > 0 {
cols = cmd.Columns
}
// If rows/cols not specified, calculate from image dimensions
if rows == 0 || cols == 0 {
caps := GetHostCapabilities()
kittyPassthroughLog("calculateImageCells: imgPixels=(%d,%d), cmdRC=(%d,%d), cellSize=(%d,%d)",
cmd.Width, cmd.Height, cmd.Columns, cmd.Rows, caps.CellWidth, caps.CellHeight)
if caps.CellWidth > 0 && caps.CellHeight > 0 {
if rows == 0 && cmd.Height > 0 {
rows = (cmd.Height + caps.CellHeight - 1) / caps.CellHeight
}
if cols == 0 && cmd.Width > 0 {
cols = (cmd.Width + caps.CellWidth - 1) / caps.CellWidth
}
}
}
kittyPassthroughLog("calculateImageCells: result rows=%d, cols=%d", rows, cols)
return rows, cols
}
// PlacementResult contains info about an image placement for cursor positioning
type PlacementResult struct {
Rows int // Number of rows the image occupies
Cols int // Number of columns the image occupies
CursorMove int // C parameter: 0=move cursor (default), 1=don't move
}
func (kp *KittyPassthrough) ForwardCommand(
cmd *vt.KittyCommand,
rawData []byte,
windowID string,
windowX, windowY int,
windowWidth, windowHeight int,
contentOffsetX, contentOffsetY int,
cursorX, cursorY int,
scrollbackLen int,
isAltScreen bool,
ptyInput func([]byte),
) *PlacementResult {
kp.mu.Lock()
defer kp.mu.Unlock()
if os.Getenv("TUIOS_DEBUG_INTERNAL") == "1" {
log.Printf("[KP] ForwardCommand action=%c enabled=%v inline=%v imageID=%d more=%v dataLen=%d",
cmd.Action, kp.enabled, kp.inlineGraphics, cmd.ImageID, cmd.More, len(cmd.Data))
}
kittyPassthroughLog("ForwardCommand: action=%c, enabled=%v, imageID=%d, windowID=%s, win=(%d,%d), size=(%d,%d), cursor=(%d,%d), scrollback=%d, altScreen=%v",
cmd.Action, kp.enabled, cmd.ImageID, windowID[:8], windowX, windowY, windowWidth, windowHeight, cursorX, cursorY, scrollbackLen, isAltScreen)
// Detect and discard echoed responses to prevent feedback loops.
// Responses have format "i=N;OK" or "i=N;ERROR_MSG" or just "OK"/"ERROR_MSG"
// When parsed, they appear as transmit commands with Data="OK" or error message.
// Real transmit commands have binary/base64 image data, not status strings.
if cmd.Action == vt.KittyActionTransmit && len(cmd.Data) > 0 && isKittyResponse(cmd.Data) {
kittyPassthroughLog("ForwardCommand: DISCARDING echoed response: %q", cmd.Data)
return nil
}
if !kp.enabled {
kittyPassthroughLog("ForwardCommand: DISABLED, returning early")
return nil
}
// Clear virtual placements on any new image activity for this window
// Virtual placements are inherently transient - they should be re-sent by the app if still needed
if placements := kp.placements[windowID]; placements != nil {
var virtualIDs []uint32
for hostID, p := range placements {
if p.Virtual {
virtualIDs = append(virtualIDs, hostID)
if !p.Hidden {
kp.deleteOnePlacement(p)
}
}
}
for _, id := range virtualIDs {
delete(placements, id)
kittyPassthroughLog("ForwardCommand: cleared stale virtual placement hostID=%d", id)
}
}
switch cmd.Action {
case vt.KittyActionQuery:
kittyPassthroughLog("ForwardCommand: handling QUERY")
kp.forwardQuery(cmd, rawData, ptyInput)
case vt.KittyActionTransmit:
kittyPassthroughLog("ForwardCommand: handling TRANSMIT, more=%v", cmd.More)
result := kp.forwardTransmit(cmd, rawData, windowID, false, 0, 0, 0, 0, 0, 0, 0, 0, 0, isAltScreen)
if result != nil {
return result
}
// On the final chunk of a chunked transmission that was part of a
// previous TransmitPlace (chafa: T ... t ... t m=0), return the image
// dimensions from the tracked placement so the guest terminal reserves
// whitespace. Without this, the image appears but the cursor doesn't
// advance below it, causing text to overdraw.
if !cmd.More {
if placements := kp.placements[windowID]; placements != nil {
for _, p := range placements {
if p.Streaming {
return &PlacementResult{
Rows: p.Rows,
Cols: p.Cols,
CursorMove: cmd.CursorMove,
}
}
}
}
}
case vt.KittyActionTransmitPlace:
kittyPassthroughLog("ForwardCommand: handling TRANSMIT+PLACE, more=%v", cmd.More)
isFileBased := cmd.Medium == vt.KittyMediumSharedMemory || cmd.Medium == vt.KittyMediumTempFile || cmd.Medium == vt.KittyMediumFile
result := kp.forwardTransmit(cmd, rawData, windowID, true, windowX, windowY, windowWidth, windowHeight, contentOffsetX, contentOffsetY, cursorX, cursorY, scrollbackLen, isAltScreen)
// Return PlacementResult from direct transmit if available
if result != nil {
return result
}
// On the final chunk (m=0), return image dimensions so the guest
// terminal reserves whitespace for the image. This applies to BOTH
// file-based AND direct transmissions (chafa uses direct with chunks).
if !cmd.More {
imgRows, imgCols := kp.calculateImageCells(cmd)
// For direct mode where the final chunk doesn't have s/v params,
// look up the stored placement from the first chunk.
if imgRows == 0 && imgCols == 0 && !isFileBased {
if placements := kp.placements[windowID]; placements != nil {
for _, p := range placements {
if p.Streaming || p.Hidden {
imgRows = p.Rows
imgCols = p.Cols
break
}
}
}
}
if imgRows > 0 || imgCols > 0 {
return &PlacementResult{Rows: imgRows, Cols: imgCols, CursorMove: cmd.CursorMove}
}
}
case vt.KittyActionPlace:
kittyPassthroughLog("ForwardCommand: handling PLACE")
kp.forwardPlace(cmd, windowID, windowX, windowY, windowWidth, windowHeight, contentOffsetX, contentOffsetY, cursorX, cursorY, scrollbackLen, isAltScreen)
// Return ORIGINAL image dimensions for whitespace reservation
imgRows, imgCols := kp.calculateImageCells(cmd)
if imgRows > 0 || imgCols > 0 {
return &PlacementResult{Rows: imgRows, Cols: imgCols, CursorMove: cmd.CursorMove}
}
case vt.KittyActionDelete:
kittyPassthroughLog("ForwardCommand: handling DELETE, d=%c, imageID=%d", cmd.Delete, cmd.ImageID)
kp.forwardDelete(cmd, windowID)
case vt.KittyActionFrame, vt.KittyActionAnimation, vt.KittyActionCompose:
// Animation protocol (a=f, a=a, a=c) is not yet supported in passthrough.
// These commands require consistent image ID management between the guest
// app and host terminal which conflicts with tuios's ID remapping.
// Apps like kitty-doom that use animation should be run directly in the
// terminal instead of inside tuios.
kittyPassthroughLog("ForwardCommand: DROPPING unsupported animation action=%c", cmd.Action)
default:
kittyPassthroughLog("ForwardCommand: UNKNOWN action %c", cmd.Action)
}
return nil
}
func (kp *KittyPassthrough) forwardQuery(cmd *vt.KittyCommand, _ []byte, ptyInput func([]byte)) {
if ptyInput != nil && cmd.Quiet < 2 {
response := vt.BuildKittyResponse(true, cmd.ImageID, "")
kittyPassthroughLog("forwardQuery: sending response for imageID=%d, response=%q, ptyInput=%v", cmd.ImageID, response, ptyInput != nil)
ptyInput(response)
} else {
kittyPassthroughLog("forwardQuery: NOT sending response, ptyInput=%v, quiet=%d", ptyInput != nil, cmd.Quiet)
}
}
func (kp *KittyPassthrough) forwardTransmit(cmd *vt.KittyCommand, rawData []byte, windowID string, andPlace bool, windowX, windowY, windowWidth, windowHeight, contentOffsetX, contentOffsetY, cursorX, cursorY, scrollbackLen int, isAltScreen bool) *PlacementResult {
if cmd.Medium == vt.KittyMediumSharedMemory || cmd.Medium == vt.KittyMediumTempFile || cmd.Medium == vt.KittyMediumFile {
kp.forwardFileTransmit(cmd, windowID, andPlace, windowX, windowY, windowWidth, windowHeight, contentOffsetX, contentOffsetY, cursorX, cursorY, scrollbackLen, isAltScreen)
// Don't flush immediately - accumulate in pendingOutput.
// Flushed during render cycle (GetKittyGraphicsCmd) so graphics
// and text arrive in the same frame, preventing tearing.
return nil
}
// rawData includes the full APC framing: \x1b_G<params>;<data>\x1b\\
// Strip the framing to get just the inner content for rewriting.
innerData := rawData
if len(innerData) >= 3 && innerData[0] == '\x1b' && innerData[1] == '_' {
innerData = innerData[2:] // skip \x1b_
if innerData[0] == 'G' {
innerData = innerData[1:] // skip G
}
}
if len(innerData) >= 2 && innerData[len(innerData)-2] == '\x1b' && innerData[len(innerData)-1] == '\\' {
innerData = innerData[:len(innerData)-2] // strip \x1b\\
}
_ = innerData // innerData unused in this v0.6.0-style implementation
hasPendingData := kp.pendingDirectData[windowID] != nil
if !andPlace && !hasPendingData {
// Pass through raw (already has framing)
kp.pendingOutput = append(kp.pendingOutput, rawData...)
return nil
}
// v0.6.0-style direct transmit: accumulate raw decoded bytes across chunks,
// then on the final chunk re-encode and emit as properly-formatted kitty
// APC chunks of our own. This avoids the mess of trying to splice chafa's
// non-standard chunk format (params-only first chunk + data-only continuations).
// Get or create pending transmission state
pending := kp.pendingDirectData[windowID]
if pending == nil {
pending = &pendingDirectTransmit{
Format: cmd.Format,
Compression: cmd.Compression,
Width: cmd.Width,
Height: cmd.Height,
ImageID: cmd.ImageID,
Columns: cmd.Columns,
Rows: cmd.Rows,
SourceX: cmd.SourceX,
SourceY: cmd.SourceY,
SourceWidth: cmd.SourceWidth,
SourceHeight: cmd.SourceHeight,
XOffset: cmd.XOffset,
YOffset: cmd.YOffset,
ZIndex: cmd.ZIndex,
Virtual: cmd.Virtual,
CursorMove: cmd.CursorMove,
AndPlace: andPlace,
WindowX: windowX,
WindowY: windowY,
WindowWidth: windowWidth,
WindowHeight: windowHeight,
ContentOffsetX: contentOffsetX,
ContentOffsetY: contentOffsetY,
CursorX: cursorX,
CursorY: cursorY,
ScrollbackLen: scrollbackLen,
IsAltScreen: isAltScreen,
}
kp.pendingDirectData[windowID] = pending
}
pending.Data = append(pending.Data, cmd.Data...)
kittyPassthroughLog("forwardTransmit: accumulated %d bytes, total=%d, more=%v",
len(cmd.Data), len(pending.Data), cmd.More)
// If more chunks coming, wait for them
if cmd.More {
return nil
}
// Final chunk - process complete image
defer delete(kp.pendingDirectData, windowID)
if len(pending.Data) == 0 {
kittyPassthroughLog("forwardTransmit: no data accumulated, skipping")
return nil
}
// Get/allocate host ID.
// - Guest image ID == 0 is kitty's "auto-assign" sentinel; each transmit
// with ID 0 is a DISTINCT image (chafa uses 0 for every invocation).
// Always allocate a fresh host ID so multiple chafa images coexist in
// scrollback without overwriting each other.
// - For non-zero guest IDs, reuse the same host ID on re-transmit so the
// image data is replaced in place.
if kp.imageIDMap[windowID] == nil {
kp.imageIDMap[windowID] = make(map[uint32]uint32)
}
var hostID uint32
if pending.ImageID == 0 {
hostID = kp.allocateHostID()
} else {
var reusingID bool
hostID, reusingID = kp.imageIDMap[windowID][pending.ImageID]
if !reusingID {
hostID = kp.allocateHostID()
kp.imageIDMap[windowID][pending.ImageID] = hostID
}
}
// Re-encode to base64 and emit as properly-formatted kitty chunks
encoded := base64.StdEncoding.EncodeToString(pending.Data)
hostX := pending.WindowX + pending.ContentOffsetX + pending.CursorX
hostY := pending.WindowY + pending.ContentOffsetY + pending.CursorY
contentWidth := pending.WindowWidth - 2
contentHeight := pending.WindowHeight - 2
// Calculate image cell dimensions
imgRows := pending.Rows
imgCols := pending.Columns
if imgRows == 0 || imgCols == 0 {
caps := GetHostCapabilities()
if caps.CellWidth > 0 && caps.CellHeight > 0 {
if imgRows == 0 && pending.Height > 0 {
imgRows = (pending.Height + caps.CellHeight - 1) / caps.CellHeight
}
if imgCols == 0 && pending.Width > 0 {
imgCols = (pending.Width + caps.CellWidth - 1) / caps.CellWidth
}
}
}
displayCols := imgCols
displayRows := imgRows
if displayCols > contentWidth && contentWidth > 0 {
displayCols = contentWidth
}
if displayRows > contentHeight && contentHeight > 0 {
displayRows = contentHeight
}
// Emit transmit-only command in proper 4096-byte kitty chunks.
// Placement is handled by RefreshAllPlacements.
const chunkSize = 4096
for i := 0; i < len(encoded); i += chunkSize {
end := min(i+chunkSize, len(encoded))
chunk := encoded[i:end]
more := end < len(encoded)
var buf bytes.Buffer
buf.WriteString("\x1b_G")
if i == 0 {
// First chunk: full header
fmt.Fprintf(&buf, "a=t,i=%d,f=%d,s=%d,v=%d,q=2",
hostID, pending.Format, pending.Width, pending.Height)
if pending.Compression == vt.KittyCompressionZlib {
buf.WriteString(",o=z")
}
} else {
// Continuation chunks: just image ID (no placement params for a=t)
fmt.Fprintf(&buf, "i=%d,q=2", hostID)
}
if more {
buf.WriteString(",m=1")
}
buf.WriteByte(';')
buf.WriteString(chunk)
buf.WriteString("\x1b\\")
kp.pendingOutput = append(kp.pendingOutput, buf.Bytes()...)
}
kittyPassthroughLog("forwardTransmit: emitted %d bytes as %d-byte chunks, hostID=%d, imgSize=(%d,%d) srcXYWH=(%d,%d,%d,%d) imgPixels=(%d,%d)",
len(encoded), chunkSize, hostID, imgCols, imgRows,
pending.SourceX, pending.SourceY, pending.SourceWidth, pending.SourceHeight,
pending.Width, pending.Height)
// Track placement for RefreshAllPlacements
if kp.placements[windowID] == nil {
kp.placements[windowID] = make(map[uint32]*PassthroughPlacement)
}
kp.placements[windowID][hostID] = &PassthroughPlacement{
GuestImageID: pending.ImageID,
HostImageID: hostID,
WindowID: windowID,
GuestX: pending.CursorX,
AbsoluteLine: pending.ScrollbackLen + pending.CursorY,
HostX: hostX,
HostY: hostY,
Cols: displayCols,
Rows: imgRows,
DisplayRows: displayRows,
SourceX: pending.SourceX,
SourceY: pending.SourceY,
SourceWidth: pending.SourceWidth,
SourceHeight: pending.SourceHeight,
XOffset: pending.XOffset,
YOffset: pending.YOffset,
ZIndex: pending.ZIndex,
Virtual: pending.Virtual,
Hidden: true, // RefreshAllPlacements places it
PlacedOnAltScreen: pending.IsAltScreen,
// The image's native pixel dimensions from the s/v params. These are
// what the image ACTUALLY has on disk/in kitty - independent of the
// client's notion of cell size. placeOne uses these to derive accurate
// pixels-per-row for source-region cropping, which is critical in
// web/daemon mode where the client and daemon may have different
// terminal cell sizes.
ImagePixelWidth: pending.Width,
ImagePixelHeight: pending.Height,
}
// Return PlacementResult if the original transmission was a TransmitPlace.
// This triggers whitespace reservation in the guest terminal so the cursor
// advances past where the image will be placed.
if pending.AndPlace {
return &PlacementResult{
Rows: imgRows,
Cols: imgCols,
CursorMove: pending.CursorMove,
}
}
return nil
}
func (kp *KittyPassthrough) forwardFileTransmit(cmd *vt.KittyCommand, windowID string, andPlace bool, windowX, windowY, windowWidth, windowHeight, contentOffsetX, contentOffsetY, cursorX, cursorY, scrollbackLen int, isAltScreen bool) {
if cmd.FilePath == "" {
return
}
filePath := cmd.FilePath
if cmd.Medium == vt.KittyMediumSharedMemory {
filePath = "/dev/shm/" + cmd.FilePath
}
kittyPassthroughLog("forwardFileTransmit: file=%s, andPlace=%v, medium=%c", filePath, andPlace, cmd.Medium)
// In inline-graphics mode (tuios-web) the host terminal cannot read
// files on the server. Read the file ourselves and divert into the
// direct-transmission path so the bytes reach the browser over the
// sip PTY. This is critical for apps like youterm / mpv that use
// shared-memory frames (t=s, /dev/shm/...) and for any t=f / t=t
// transmission.
if kp.inlineGraphics {
kp.forwardFileTransmitInline(cmd, filePath, windowID, andPlace,
windowX, windowY, windowWidth, windowHeight,
contentOffsetX, contentOffsetY,
cursorX, cursorY, scrollbackLen, isAltScreen)
return
}
// Reuse existing host ID if this window already has a placement for this
// guest image ID. This eliminates delete+re-place flicker for video playback:
// transmitting with the same ID replaces the image data in-place, and the
// existing placement automatically shows the new frame.
if kp.imageIDMap[windowID] == nil {
kp.imageIDMap[windowID] = make(map[uint32]uint32)
}
hostID, reusingID := kp.imageIDMap[windowID][cmd.ImageID]
if !reusingID {
hostID = kp.allocateHostID()
kp.imageIDMap[windowID][cmd.ImageID] = hostID
} else if andPlace {
// Reusing ID - check if dimensions changed (e.g., window resize).
// If so, delete old placement so it gets recreated at the new size.
if placements := kp.placements[windowID]; placements != nil {
for _, p := range placements {
imgRows, imgCols := kp.calculateImageCells(cmd)
if p.HostImageID == hostID && (p.Rows != imgRows || p.Cols != imgCols) {
kp.deleteOnePlacement(p)
delete(placements, hostID)
break
}
}
}
}
kittyPassthroughLog("forwardFileTransmit: mapped guestID=%d -> hostID=%d for window=%s", cmd.ImageID, hostID, windowID[:8])
// PERFORMANCE: Forward the file path directly to the host terminal.
// The host (Ghostty/Kitty) reads the file itself - no need to read the
// entire file into memory, base64 encode it, and chunk it.
// For t=s (shm), send the original shm name (NOT /dev/shm/ prefixed path).
// The host terminal prepends /dev/shm/ itself.
// For t=f/t=t, send the full file path.
encodePath := cmd.FilePath // Original name from the guest
if cmd.Medium != vt.KittyMediumSharedMemory {
encodePath = filePath // Use potentially modified path for non-shm
}
encoded := base64.StdEncoding.EncodeToString([]byte(encodePath))
hostX := windowX + contentOffsetX + cursorX
hostY := windowY + contentOffsetY + cursorY
// Calculate content area dimensions (accounting for borders)
contentWidth := windowWidth - 2 // -2 for left/right borders
contentHeight := windowHeight - 2 // -2 for top/bottom borders
// Calculate image dimensions in cells
// Note: calculateImageCells returns (rows, cols) in that order
imgRows, imgCols := kp.calculateImageCells(cmd)
// Cap to content area (not cursor position) - allow full-height images
// The image will be repositioned by RefreshAllPlacements after scrolling
displayCols := imgCols
displayRows := imgRows
if displayCols > contentWidth && contentWidth > 0 {
displayCols = contentWidth
}
if displayRows > contentHeight && contentHeight > 0 {
displayRows = contentHeight
}
kittyPassthroughLog("forwardFileTransmit: hostID=%d, hostPos=(%d,%d), imgSize=(%d,%d), displaySize=(%d,%d), contentArea=(%d,%d)",
hostID, hostX, hostY, imgCols, imgRows, displayCols, displayRows, contentWidth, contentHeight)
// Build a single transmit command with the correct medium type.
// The host terminal reads the file/shm directly - no chunking needed.
//
// For video playback (reusing ID + andPlace), use a=T (transmit+place)
// to avoid race conditions where RefreshAllPlacements runs before the
// new transmit arrives. For first frames (new ID), use a=t and let
// RefreshAllPlacements handle placement.
var buf bytes.Buffer
buf.WriteString("\x1b_G")
// Use the original medium type: f=file, s=shared memory, t=temp file
medium := "f"
switch cmd.Medium {
case vt.KittyMediumSharedMemory:
medium = "s"
case vt.KittyMediumTempFile:
medium = "t"
}
// Always transmit-only here. Placement is handled either by:
// - Video immediate path (isVideoFrame) which uses a=T with positioning
// - RefreshAllPlacements which sends a=p for non-video images
action := "t"
fmt.Fprintf(&buf, "a=%s,t=%s,i=%d,f=%d,s=%d,v=%d,q=2",
action, medium, hostID, cmd.Format, cmd.Width, cmd.Height)
if cmd.Compression == vt.KittyCompressionZlib {
buf.WriteString(",o=z")
}
if displayCols > 0 {
fmt.Fprintf(&buf, ",c=%d", displayCols)
}
if displayRows > 0 {
fmt.Fprintf(&buf, ",r=%d", displayRows)
}
if cmd.SourceX > 0 {
fmt.Fprintf(&buf, ",x=%d", cmd.SourceX)
}
if cmd.SourceY > 0 {
fmt.Fprintf(&buf, ",y=%d", cmd.SourceY)
}
if cmd.SourceWidth > 0 {
fmt.Fprintf(&buf, ",w=%d", cmd.SourceWidth)
}
sourceHeight := cmd.SourceHeight
if sourceHeight == 0 && displayRows < imgRows {
caps := GetHostCapabilities()
cellH := caps.CellHeight
if cellH <= 0 {
cellH = 20
}
sourceHeight = displayRows * cellH
}
if sourceHeight > 0 {
fmt.Fprintf(&buf, ",h=%d", sourceHeight)
}
if cmd.XOffset > 0 {
fmt.Fprintf(&buf, ",X=%d", cmd.XOffset)
}
if cmd.YOffset > 0 {
fmt.Fprintf(&buf, ",Y=%d", cmd.YOffset)
}
if cmd.ZIndex != 0 {
fmt.Fprintf(&buf, ",z=%d", cmd.ZIndex)
}
buf.WriteByte(';')
buf.WriteString(encoded)
buf.WriteString("\x1b\\")
// For video (reusing ID + shm), write IMMEDIATELY to host terminal.
// File/shm-based video is time-critical: mpv overwrites the shm/file
// with the next frame almost instantly.
// For non-video (first image, icat), always transmit via pendingOutput
// and let RefreshAllPlacements handle placement with proper clipping.
// Video: reusing ID + chunked (more=true on first chunk).
// icat/youterm: may reuse ID but sends single unchunked command (more=false).
isVideoFrame := reusingID && andPlace && cmd.More
if isVideoFrame && kp.hostOut != nil {
// Override to a=T for video immediate flush (buf was built with a=t)
bufBytes := bytes.Replace(buf.Bytes(), []byte("a=t,"), []byte("a=T,"), 1)
// Bounds check for video
visible := windowX >= 0 && windowY >= 0 && hostX >= 0 && hostY >= 0
if visible && displayCols > 0 {
visible = hostX+displayCols <= windowX+1+contentWidth
}
if visible && displayRows > 0 {
visible = hostY+displayRows <= windowY+1+contentHeight
}
if visible && kp.screenWidth > 0 && kp.screenHeight > 0 {
if hostX+displayCols > kp.screenWidth || hostY+displayRows >= kp.screenHeight-1 {
visible = false
}
}
if visible {
var posCmd []byte
posCmd = append(posCmd, syncBegin...)
posCmd = append(posCmd, fmt.Sprintf("\x1b[%d;%dH", hostY+1, hostX+1)...)
posCmd = append(posCmd, bufBytes...)
posCmd = append(posCmd, syncEnd...)
_, _ = kp.hostOut.Write(posCmd)
} else if hostID > 0 {
var del []byte
del = append(del, syncBegin...)
del = append(del, fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2\x1b\\", hostID)...)
del = append(del, syncEnd...)
_, _ = kp.hostOut.Write(del)
}
} else {
kp.pendingOutput = append(kp.pendingOutput, buf.Bytes()...)
}
// Don't clean up files here - for shared memory (t=s), the guest app
// manages the lifecycle. For temp files (t=t), the host terminal deletes
// them after reading. For regular files (t=f), they persist.
// Store placement using hostID as key (cmd.ImageID is often 0 for new images)
if kp.placements[windowID] == nil {
kp.placements[windowID] = make(map[uint32]*PassthroughPlacement)
}
kp.placements[windowID][hostID] = &PassthroughPlacement{
GuestImageID: cmd.ImageID,
HostImageID: hostID,
WindowID: windowID,
GuestX: cursorX,
AbsoluteLine: scrollbackLen + cursorY,
HostX: hostX,
HostY: hostY,
Cols: displayCols,
Rows: imgRows, // Original image rows (for scroll clipping)
DisplayRows: displayRows, // Capped rows for initial display
SourceX: cmd.SourceX,
SourceY: cmd.SourceY,
SourceWidth: cmd.SourceWidth,
SourceHeight: cmd.SourceHeight,
XOffset: cmd.XOffset,
YOffset: cmd.YOffset,
ZIndex: cmd.ZIndex,
Virtual: cmd.Virtual,
Hidden: true, // Start hidden, RefreshAllPlacements will place it
PlacedOnAltScreen: isAltScreen,
}
kittyPassthroughLog("forwardFileTransmit: stored placement hostID=%d (hidden, waiting for refresh)", hostID)
}
// forwardFileTransmitInline handles file / shm / temp-file kitty transmits
// when the host terminal cannot read server-local files (tuios-web's browser
// target). We read the file ourselves, base64 encode it, and emit a normal
// direct (t=d) transmission so the bytes reach the browser through the sip
// PTY. A placement entry is created in the standard hidden-until-refresh
// state so RefreshAllPlacements will emit the matching a=p on the next
// render cycle, identical to the native-mode flow.
func (kp *KittyPassthrough) forwardFileTransmitInline(
cmd *vt.KittyCommand,
filePath string,
windowID string,
andPlace bool,
windowX, windowY, windowWidth, windowHeight int,
contentOffsetX, contentOffsetY int,
cursorX, cursorY int,
scrollbackLen int,
isAltScreen bool,
) {
data, err := os.ReadFile(filePath)
if err != nil {
kittyPassthroughLog("forwardFileTransmitInline: read %s failed: %v", filePath, err)
return
}