Skip to content

Commit 0c5a3c6

Browse files
feat: add CSI t (windowOptions) handler for size report and title stack
Implement CSI Ps t window manipulation commands: - CSI 18 t: report terminal size in characters - CSI 22 ; Ps t: push window title / icon name onto stack - CSI 23 ; Ps t: pop window title / icon name from stack Fixes #12 Co-authored-by: Ona <no-reply@ona.com>
1 parent 0047d2c commit 0c5a3c6

3 files changed

Lines changed: 192 additions & 1 deletion

File tree

inputhandler.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ type InputHandler struct {
8383
parseBuffer []uint32
8484
dirtyRowTracker *DirtyRowTracker
8585

86-
windowTitle string
86+
windowTitle string
87+
windowTitleStack []string
88+
iconNameStack []string
8789

8890
// Events
8991
OnCursorMoveEmitter EventEmitter[struct{}]
@@ -229,6 +231,7 @@ func NewInputHandler(
229231
})
230232
p.RegisterCsiHandler(FunctionIdentifier{Intermediates: " ", Final: 'q'}, h.setCursorStyle)
231233
p.RegisterCsiHandler(FunctionIdentifier{Final: 'r'}, h.setScrollRegion)
234+
p.RegisterCsiHandler(FunctionIdentifier{Final: 't'}, h.windowOptions)
232235
p.RegisterCsiHandler(FunctionIdentifier{Final: 's'}, h.csiSaveCursor)
233236
p.RegisterCsiHandler(FunctionIdentifier{Final: 'u'}, h.csiRestoreCursor)
234237
p.RegisterCsiHandler(FunctionIdentifier{Prefix: '=', Final: 'u'}, h.kittyKeyboardSet)

inputhandler_csi.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,3 +885,57 @@ func (h *InputHandler) kittyKeyboardPop(params *Params) bool {
885885
}
886886
return true
887887
}
888+
889+
// --- Window manipulation (CSI Ps t) ---
890+
891+
// windowOptions handles CSI Ps ; Ps ; Ps t — window manipulation commands.
892+
// Implements sub-commands 18 (report size), 22 (push title), and 23 (pop title).
893+
func (h *InputHandler) windowOptions(params *Params) bool {
894+
if params.Length == 0 {
895+
return true
896+
}
897+
switch params.Params[0] {
898+
case 18:
899+
// Report terminal size in characters: CSI 8 ; rows ; cols t
900+
h.coreService.TriggerDataEvent(
901+
fmt.Sprintf("\x1b[8;%d;%dt", h.bufferService.Rows, h.bufferService.Cols),
902+
false, false,
903+
)
904+
case 22:
905+
// Push title onto stack.
906+
// Ps2: 0 = both icon+title, 1 = icon only, 2 = title only.
907+
ps2 := int32(0)
908+
if params.Length >= 2 {
909+
ps2 = params.Params[1]
910+
}
911+
if ps2 == 0 || ps2 == 2 {
912+
h.windowTitleStack = append(h.windowTitleStack, h.windowTitle)
913+
}
914+
if ps2 == 0 || ps2 == 1 {
915+
// Icon name shares the window title in this implementation.
916+
h.iconNameStack = append(h.iconNameStack, h.windowTitle)
917+
}
918+
case 23:
919+
// Pop title from stack.
920+
ps2 := int32(0)
921+
if params.Length >= 2 {
922+
ps2 = params.Params[1]
923+
}
924+
if ps2 == 0 || ps2 == 2 {
925+
if len(h.windowTitleStack) > 0 {
926+
title := h.windowTitleStack[len(h.windowTitleStack)-1]
927+
h.windowTitleStack = h.windowTitleStack[:len(h.windowTitleStack)-1]
928+
h.windowTitle = title
929+
h.OnTitleChangeEmitter.Fire(title)
930+
}
931+
}
932+
if ps2 == 0 || ps2 == 1 {
933+
if len(h.iconNameStack) > 0 {
934+
h.iconNameStack = h.iconNameStack[:len(h.iconNameStack)-1]
935+
}
936+
}
937+
default:
938+
// Other sub-commands (14, 16, etc.) are renderer-specific; silently ignore.
939+
}
940+
return true
941+
}

inputhandler_csi_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,3 +1204,137 @@ func TestRequestModeANSI(t *testing.T) {
12041204
}
12051205
}
12061206

1207+
func TestWindowOptionsReportSize(t *testing.T) {
1208+
t.Parallel()
1209+
h := newTestInputHandler(80, 24)
1210+
var response string
1211+
h.coreService.OnDataEmitter.Event(func(data string) {
1212+
response = data
1213+
})
1214+
h.ParseString("\x1b[18t")
1215+
expected := "\x1b[8;24;80t"
1216+
if response != expected {
1217+
t.Errorf("expected response %q, got %q", expected, response)
1218+
}
1219+
}
1220+
1221+
func TestWindowOptionsReportSizeCustom(t *testing.T) {
1222+
t.Parallel()
1223+
h := newTestInputHandler(132, 50)
1224+
var response string
1225+
h.coreService.OnDataEmitter.Event(func(data string) {
1226+
response = data
1227+
})
1228+
h.ParseString("\x1b[18t")
1229+
expected := "\x1b[8;50;132t"
1230+
if response != expected {
1231+
t.Errorf("expected response %q, got %q", expected, response)
1232+
}
1233+
}
1234+
1235+
func TestWindowOptionsPushPopTitle(t *testing.T) {
1236+
t.Parallel()
1237+
h := newTestInputHandler(80, 24)
1238+
var lastTitle string
1239+
h.OnTitleChangeEmitter.Event(func(title string) {
1240+
lastTitle = title
1241+
})
1242+
1243+
h.ParseString("\x1b]2;first\x1b\\")
1244+
if lastTitle != "first" {
1245+
t.Fatalf("expected title %q, got %q", "first", lastTitle)
1246+
}
1247+
1248+
h.ParseString("\x1b[22;2t")
1249+
1250+
h.ParseString("\x1b]2;second\x1b\\")
1251+
if lastTitle != "second" {
1252+
t.Fatalf("expected title %q, got %q", "second", lastTitle)
1253+
}
1254+
1255+
h.ParseString("\x1b[23;2t")
1256+
if lastTitle != "first" {
1257+
t.Errorf("expected title %q after pop, got %q", "first", lastTitle)
1258+
}
1259+
}
1260+
1261+
func TestWindowOptionsPushPopTitleMultiple(t *testing.T) {
1262+
t.Parallel()
1263+
h := newTestInputHandler(80, 24)
1264+
var lastTitle string
1265+
h.OnTitleChangeEmitter.Event(func(title string) {
1266+
lastTitle = title
1267+
})
1268+
1269+
h.ParseString("\x1b]2;alpha\x1b\\")
1270+
h.ParseString("\x1b[22;2t")
1271+
h.ParseString("\x1b]2;beta\x1b\\")
1272+
h.ParseString("\x1b[22;2t")
1273+
h.ParseString("\x1b]2;gamma\x1b\\")
1274+
h.ParseString("\x1b[22;2t")
1275+
h.ParseString("\x1b]2;delta\x1b\\")
1276+
1277+
h.ParseString("\x1b[23;2t")
1278+
if lastTitle != "gamma" {
1279+
t.Errorf("expected %q, got %q", "gamma", lastTitle)
1280+
}
1281+
h.ParseString("\x1b[23;2t")
1282+
if lastTitle != "beta" {
1283+
t.Errorf("expected %q, got %q", "beta", lastTitle)
1284+
}
1285+
h.ParseString("\x1b[23;2t")
1286+
if lastTitle != "alpha" {
1287+
t.Errorf("expected %q, got %q", "alpha", lastTitle)
1288+
}
1289+
}
1290+
1291+
func TestWindowOptionsPopEmptyStack(t *testing.T) {
1292+
t.Parallel()
1293+
h := newTestInputHandler(80, 24)
1294+
titleChanges := 0
1295+
h.OnTitleChangeEmitter.Event(func(title string) {
1296+
titleChanges++
1297+
})
1298+
1299+
h.ParseString("\x1b]2;initial\x1b\\")
1300+
titleChanges = 0
1301+
1302+
h.ParseString("\x1b[23;2t")
1303+
if titleChanges != 0 {
1304+
t.Errorf("expected no title change on empty stack pop, got %d changes", titleChanges)
1305+
}
1306+
}
1307+
1308+
func TestWindowOptionsPushPopBoth(t *testing.T) {
1309+
t.Parallel()
1310+
h := newTestInputHandler(80, 24)
1311+
var lastTitle string
1312+
h.OnTitleChangeEmitter.Event(func(title string) {
1313+
lastTitle = title
1314+
})
1315+
1316+
h.ParseString("\x1b]2;both-test\x1b\\")
1317+
h.ParseString("\x1b[22;0t")
1318+
h.ParseString("\x1b]2;replaced\x1b\\")
1319+
1320+
h.ParseString("\x1b[23;0t")
1321+
if lastTitle != "both-test" {
1322+
t.Errorf("expected %q, got %q", "both-test", lastTitle)
1323+
}
1324+
}
1325+
1326+
func TestWindowOptionsUnknownSubcommand(t *testing.T) {
1327+
t.Parallel()
1328+
h := newTestInputHandler(80, 24)
1329+
var response string
1330+
h.coreService.OnDataEmitter.Event(func(data string) {
1331+
response = data
1332+
})
1333+
h.ParseString("\x1b[99t")
1334+
if response != "" {
1335+
t.Errorf("expected no response for unknown subcommand, got %q", response)
1336+
}
1337+
}
1338+
1339+
1340+

0 commit comments

Comments
 (0)