Skip to content

Commit 24efbab

Browse files
deblasisclaude
andcommitted
terminal: implement DECSTR soft reset (CSI ! p)
CSI ! p was logged as unimplemented and ignored, so mode, SGR, charset, and scroll-region state set by a program leaked past a soft reset. Reset the programmable state to its defaults (cursor visible, replace mode, absolute origin, autowrap on, default SGR, ASCII charsets, full scrolling region, unprotected, cleared saved cursor) without touching the screen contents or the cursor position. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 55a3e33 commit 24efbab

4 files changed

Lines changed: 118 additions & 1 deletion

File tree

src/terminal/Terminal.zig

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3913,6 +3913,45 @@ pub fn fullReset(self: *Terminal) void {
39133913
self.flags.dirty.clear = true;
39143914
}
39153915

3916+
/// Soft terminal reset (DECSTR, `CSI ! p`). Resets programmable state (modes,
3917+
/// SGR, charsets, margins, saved cursor) to defaults without touching the
3918+
/// screen or cursor. This is the difference from `fullReset` (RIS).
3919+
pub fn softReset(self: *Terminal) void {
3920+
const screen: *Screen = self.screens.active;
3921+
3922+
// Modes reset by DECSTR (DEC STD 070).
3923+
self.modes.set(.cursor_visible, true); // DECTCEM
3924+
self.modes.set(.insert, false); // IRM
3925+
self.modes.set(.origin, false); // DECOM
3926+
self.modes.set(.disable_keyboard, false); // KAM
3927+
self.modes.set(.cursor_keys, false); // DECCKM
3928+
self.modes.set(.keypad_keys, false); // DECNKM
3929+
// DECAWM: the spec resets this off, but we match xterm/conhost and leave
3930+
// the default (on).
3931+
self.modes.set(.wraparound, true);
3932+
3933+
// SGR back to default.
3934+
screen.cursor.style = .{};
3935+
screen.manualStyleUpdate() catch unreachable;
3936+
3937+
// Charsets (G0-G3) back to ASCII.
3938+
screen.charset = .{};
3939+
3940+
self.setProtectedMode(.off); // DECSCA
3941+
3942+
// Full scrolling region.
3943+
self.scrolling_region = .{
3944+
.top = 0,
3945+
.bottom = self.rows - 1,
3946+
.left = 0,
3947+
.right = self.cols - 1,
3948+
};
3949+
3950+
screen.cursor.pending_wrap = false;
3951+
screen.saved_cursor = null; // DECSC
3952+
self.previous_char = null; // REP
3953+
}
3954+
39163955
/// Returns true if the point is dirty, used for testing.
39173956
fn isDirty(t: *const Terminal, pt: point.Point) bool {
39183957
return t.screens.active.pages.getCell(pt).?.isDirty();
@@ -13777,6 +13816,71 @@ test "Terminal: fullReset origin mode" {
1377713816
try testing.expect(!t.modes.get(.origin));
1377813817
}
1377913818

13819+
test "Terminal: softReset resets state without clearing the screen" {
13820+
var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 });
13821+
defer t.deinit(testing.allocator);
13822+
13823+
// Put content on the screen so we can confirm it survives.
13824+
try t.printString("hello");
13825+
13826+
// Pile on non-default programmable state.
13827+
t.modes.set(.origin, true);
13828+
t.modes.set(.insert, true);
13829+
t.modes.set(.wraparound, false);
13830+
t.modes.set(.cursor_visible, false);
13831+
t.modes.set(.disable_keyboard, true);
13832+
t.modes.set(.cursor_keys, true);
13833+
t.modes.set(.keypad_keys, true);
13834+
t.scrolling_region = .{ .top = 2, .bottom = 5, .left = 0, .right = 9 };
13835+
t.configureCharset(.G0, .dec_special);
13836+
t.setProtectedMode(.iso);
13837+
t.saveCursor();
13838+
try t.setAttribute(.{ .bold = {} });
13839+
13840+
// DECSTR must not move the cursor (unlike RIS).
13841+
const cursor_x = t.screens.active.cursor.x;
13842+
const cursor_y = t.screens.active.cursor.y;
13843+
13844+
t.softReset();
13845+
13846+
// Modes back to their defaults.
13847+
try testing.expect(t.modes.get(.cursor_visible));
13848+
try testing.expect(!t.modes.get(.insert));
13849+
try testing.expect(!t.modes.get(.origin));
13850+
try testing.expect(t.modes.get(.wraparound));
13851+
try testing.expect(!t.modes.get(.disable_keyboard));
13852+
try testing.expect(!t.modes.get(.cursor_keys));
13853+
try testing.expect(!t.modes.get(.keypad_keys));
13854+
13855+
// Cursor position is unchanged.
13856+
try testing.expectEqual(cursor_x, t.screens.active.cursor.x);
13857+
try testing.expectEqual(cursor_y, t.screens.active.cursor.y);
13858+
13859+
// Scrolling region restored to the full screen.
13860+
try testing.expectEqual(@as(usize, 0), t.scrolling_region.top);
13861+
try testing.expectEqual(@as(usize, 9), t.scrolling_region.bottom);
13862+
13863+
// SGR back to normal rendition.
13864+
try testing.expect(!t.screens.active.cursor.style.flags.bold);
13865+
13866+
// Character set reset (no pending single shift after G0->ASCII).
13867+
try testing.expect(t.screens.active.charset.single_shift == null);
13868+
13869+
// Protected-character bit cleared.
13870+
try testing.expect(!t.screens.active.cursor.protected);
13871+
13872+
// Saved cursor (DECSC) cleared.
13873+
try testing.expect(t.screens.active.saved_cursor == null);
13874+
13875+
// The screen content is preserved — this is the key difference from
13876+
// fullReset (RIS), which clears it.
13877+
{
13878+
const str = try t.plainString(testing.allocator);
13879+
defer testing.allocator.free(str);
13880+
try testing.expectEqualStrings("hello", str);
13881+
}
13882+
}
13883+
1378013884
test "Terminal: fullReset status display" {
1378113885
var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 });
1378213886
defer t.deinit(testing.allocator);

src/terminal/stream.zig

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ pub const Action = union(Key) {
7777
next_line,
7878
reverse_index,
7979
full_reset,
80+
soft_reset,
8081
set_mode: Mode,
8182
reset_mode: Mode,
8283
save_mode: Mode,
@@ -176,6 +177,7 @@ pub const Action = union(Key) {
176177
"next_line",
177178
"reverse_index",
178179
"full_reset",
180+
"soft_reset",
179181
"set_mode",
180182
"reset_mode",
181183
"save_mode",
@@ -1855,8 +1857,17 @@ pub fn Stream(comptime H: type) type {
18551857
}
18561858
},
18571859

1858-
// DECRQM - Request Mode
1860+
// DECSTR (soft reset) / DECRQM (request mode)
18591861
'p' => switch (input.intermediates.len) {
1862+
1 => switch (input.intermediates[0]) {
1863+
// DECSTR - Soft Terminal Reset (CSI ! p)
1864+
'!' => self.handler.vt(.soft_reset, {}),
1865+
else => log.warn(
1866+
"ignoring unimplemented CSI p with intermediates: {s}",
1867+
.{input.intermediates},
1868+
),
1869+
},
1870+
18601871
2 => decrqm: {
18611872
const ansi_mode = ansi: {
18621873
switch (input.intermediates.len) {

src/terminal/stream_terminal.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ pub const Handler = struct {
271271
self.terminal.modes.set(.cursor_blinking, self.default_cursor_blink);
272272
self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style;
273273
},
274+
.soft_reset => self.terminal.softReset(),
274275
.start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id),
275276
.end_hyperlink => self.terminal.screens.active.endHyperlink(),
276277
.semantic_prompt => try self.terminal.semanticPrompt(value),

src/termio/stream_handler.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ pub const StreamHandler = struct {
270270
.next_line => try self.nextLine(),
271271
.reverse_index => try self.reverseIndex(),
272272
.full_reset => try self.fullReset(),
273+
.soft_reset => self.terminal.softReset(),
273274
.set_mode => try self.setMode(value.mode, true),
274275
.reset_mode => try self.setMode(value.mode, false),
275276
.save_mode => self.terminal.modes.save(value.mode),

0 commit comments

Comments
 (0)