Repository navigation
Expand file tree
/
Copy pathterm.zig
More file actions
324 lines (292 loc) · 12.4 KB
/
Copy pathterm.zig
File metadata and controls
324 lines (292 loc) · 12.4 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
//! Display width and terminal size.
//!
//! Width is measured in terminal cells, never bytes: this program is used with
//! CJK text, where one codepoint is two cells. Line wrapping that counts bytes
//! produces lines that overflow the terminal by a factor of two.
const std = @import("std");
const builtin = @import("builtin");
const wcwidth = @import("../unicode_width/wcwidth.zig").wcwidth;
const vt = @import("../util/terminal/ansi.zig");
const ansi = @import("style.zig");
/// Used when the terminal size cannot be read, and on Windows.
pub const default_columns: usize = 80;
/// Wrapping narrower than this leaves no room for content once a prefix has
/// been written, so a bogus terminal size is clamped up to it.
pub const min_columns: usize = 24;
/// Tab stops, for callers that expand tabs into spaces.
pub const tab_width: usize = 4;
/// Title updates use stderr so stdout remains suitable for pipes and files.
/// Save/restore uses the xterm title stack without reading terminal input.
pub const Title = struct {
io: std.Io,
enabled: bool,
pub fn init(io: std.Io, supported: bool) Title {
const self: Title = .{
.io = io,
.enabled = supported and (std.Io.File.stderr().isTty(io) catch false),
};
if (self.enabled) std.Io.File.stderr().writeStreamingAll(io, ansi.SAVE_TITLE) catch {};
self.update("ready", "");
return self;
}
pub fn deinit(self: Title) void {
if (self.enabled) std.Io.File.stderr().writeStreamingAll(self.io, ansi.RESTORE_TITLE) catch {};
}
pub fn update(self: Title, activity: []const u8, task: []const u8) void {
if (!self.enabled) return;
var buf: [768]u8 = undefined;
const sequence = formatTitle(&buf, activity, task) catch return;
std.Io.File.stderr().writeStreamingAll(self.io, sequence) catch {};
}
};
fn formatTitle(buf: []u8, activity: []const u8, task: []const u8) ![]const u8 {
var w = std.Io.Writer.fixed(buf);
try w.writeAll(ansi.TITLE_PREFIX);
const start = w.buffered().len;
try w.writeAll("zagent · ");
try writeTitleText(&w, activity, 24);
if (task.len > 0) {
try w.writeAll(" · ");
const used = std.unicode.utf8CountCodepoints(w.buffered()[start..]) catch unreachable;
// Reserve one character for a possible ellipsis: the entire visible
// title, including application name and activity, stays below 80.
try writeTitleText(&w, task, 78 - used);
}
try w.writeAll(ansi.OSC_END);
return w.buffered();
}
/// Keep valid Unicode, flatten whitespace, and remove terminal controls.
fn writeTitleText(w: *std.Io.Writer, bytes: []const u8, limit: usize) !void {
var i: usize = 0;
var count: usize = 0;
while (i < bytes.len) {
if (count == limit) {
try w.writeAll("…");
return;
}
if (bytes[i] == 0x1b) {
i = vt.skipAnsiEscape(bytes, i);
continue;
}
const cp = decodeAt(bytes, i);
const start = i;
i += cp.len;
if (!cp.valid) continue;
if (cp.value == '\n' or cp.value == '\r' or cp.value == '\t') {
try w.writeByte(' ');
} else if (cp.value < 0x20 or (cp.value >= 0x7f and cp.value <= 0x9f)) {
continue;
} else {
try w.writeAll(bytes[start..i]);
}
count += 1;
}
}
/// A codepoint decoded from a byte slice, along with how many bytes it took.
/// An invalid sequence yields the offending byte with `valid == false`, so
/// callers can keep making progress through malformed input.
pub const Codepoint = struct {
value: u21,
len: usize,
valid: bool,
};
/// Decode the codepoint starting at `index`. `index` must be in bounds.
pub fn decodeAt(bytes: []const u8, index: usize) Codepoint {
const byte = bytes[index];
if (byte < 0x80) return .{ .value = byte, .len = 1, .valid = true };
const len = std.unicode.utf8ByteSequenceLength(byte) catch
return .{ .value = byte, .len = 1, .valid = false };
if (index + len > bytes.len) return .{ .value = byte, .len = 1, .valid = false };
const value = std.unicode.utf8Decode(bytes[index..][0..len]) catch
return .{ .value = byte, .len = 1, .valid = false };
return .{ .value = value, .len = len, .valid = true };
}
/// Cells occupied by one decoded codepoint. Control characters and invalid
/// bytes are one cell rather than zero, so nothing can be used to hide width.
pub fn codepointWidth(cp: Codepoint) usize {
if (!cp.valid) return 1;
const w = wcwidth(cp.value);
return if (w < 0) 1 else @intCast(w);
}
/// Cells occupied by `bytes` when printed.
///
/// SGR and OSC sequences count as nothing, so this measures bytes that has
/// already been styled as well as plain bytes. Callers that lay out columns
/// depend on that: a padded cell is measured after its colour codes are in it.
///
/// Malformed bytes are counted one cell each and iteration continues, rather
/// than collapsing the whole measurement to zero the way a single `Utf8View`
/// decode error would. A tab counts as one cell: callers that care about tab
/// stops expand them themselves, because the answer depends on the column the
/// tab starts at, which this function does not know.
pub fn width(bytes: []const u8) usize {
var total: usize = 0;
var i: usize = 0;
while (i < bytes.len) {
if (bytes[i] == 0x1b) {
i = vt.skipAnsiEscape(bytes, i);
continue;
}
const cp = decodeAt(bytes, i);
i += cp.len;
total += codepointWidth(cp);
}
return total;
}
/// Longest prefix of `text` that fits in `avail` columns, as a byte length and
/// always on a codepoint boundary.
///
/// Returns at least one codepoint's worth of bytes even when nothing fits, so a
/// caller looping over the remainder cannot fail to make progress. Trailing
/// zero-width codepoints are pulled into the chunk so that a combining mark or
/// a ZWJ sequence never starts the following line.
pub fn takeFit(bytes: []const u8, avail: usize) usize {
if (bytes.len == 0) return 0;
var i: usize = 0;
var used: usize = 0;
while (i < bytes.len) {
const cp = decodeAt(bytes, i);
const w = codepointWidth(cp);
if (used + w > avail) break;
used += w;
i += cp.len;
}
if (i == 0) i = decodeAt(bytes, 0).len;
while (i < bytes.len) {
const cp = decodeAt(bytes, i);
if (codepointWidth(cp) != 0) break;
i += cp.len;
}
return i;
}
/// Columns a renderer may fill. One cell is left empty so that writing a full
/// line never leaves the terminal in the pending-wrap state, where the
/// following newline moves down two rows on several emulators.
pub fn usableWidth(cols: usize) usize {
return if (cols <= min_columns) min_columns else cols - 1;
}
/// Terminal width in columns, or `default_columns` if it cannot be determined.
///
/// Deliberately does not fall back to a cursor-position report: that trick has
/// to read the reply from the same descriptor, which is not readable when the
/// output is a pipe (`zagent "q" | less`) and would consume a line of the
/// user's input when it is a redirected file.
pub fn columns(file: std.Io.File) usize {
return switch (builtin.os.tag) {
// Zig 0.16's std has no GetConsoleScreenBufferInfo, so there is no
// console size to query here. Wrapping at 80 costs only the hanging
// indent, and terminal soft-wrap keeps the text readable.
.windows => default_columns,
else => blk: {
var ws: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 };
const rc = std.posix.system.ioctl(
file.handle,
std.posix.T.IOCGWINSZ,
@intFromPtr(&ws),
);
if (std.posix.errno(rc) != .SUCCESS) break :blk default_columns;
if (ws.col == 0) break :blk default_columns;
break :blk @min(@as(usize, ws.col), 1000);
},
};
}
/// Terminal height in rows, or 24 when it cannot be determined.
///
/// The screen layout needs this as well as the width: the input line is pinned
/// to the last row and the conversation is scrolled in the rows above it.
pub fn rows(file: std.Io.File) usize {
return switch (builtin.os.tag) {
.windows => 24,
else => blk: {
var ws: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 };
const rc = std.posix.system.ioctl(
file.handle,
std.posix.T.IOCGWINSZ,
@intFromPtr(&ws),
);
if (std.posix.errno(rc) != .SUCCESS) break :blk 24;
if (ws.row == 0) break :blk 24;
break :blk @min(@as(usize, ws.row), 1000);
},
};
}
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
test "title keeps Unicode and removes escape sequences and control characters" {
var buf: [768]u8 = undefined;
try std.testing.expectEqualStrings(
"\x1b]2;zagent · read_file · 查看 文件 路径\x07",
try formatTitle(&buf, "read_file", "\x1b[31m查看\x1b[0m\n文件\t路径\x07\xc2\x9c\xff\x1b]2;injected\x07"),
);
}
test "title bounds long tasks at Unicode boundaries" {
var buf: [768]u8 = undefined;
var task: [300]u8 = undefined;
for (0..100) |i| @memcpy(task[i * 3 ..][0..3], "你");
const title = try formatTitle(&buf, "thinking…", &task);
const prefix = "\x1b]2;zagent · thinking… · ";
try expect(std.mem.startsWith(u8, title, prefix));
const kept = 78 - (try std.unicode.utf8CountCodepoints("zagent · thinking… · "));
try std.testing.expectEqualStrings(task[0 .. kept * 3], title[prefix.len .. prefix.len + kept * 3]);
try std.testing.expectEqualStrings("…\x07", title[prefix.len + kept * 3 ..]);
try expect((try std.unicode.utf8CountCodepoints(title[4 .. title.len - 1])) < 80);
try expect(std.unicode.utf8ValidateSlice(title));
}
test "title bounds both long activities and tasks together" {
var buf: [768]u8 = undefined;
const segment = "这是一个很长的工具名称和任务摘要用于验证完整标题限制";
const long = segment ++ segment ++ segment ++ segment;
const title = try formatTitle(&buf, long, long);
const visible = title[4 .. title.len - 1];
try expect((try std.unicode.utf8CountCodepoints(visible)) < 80);
try expect(std.unicode.utf8ValidateSlice(visible));
const idle = try formatTitle(&buf, long, "");
try expect((try std.unicode.utf8CountCodepoints(idle[4 .. idle.len - 1])) < 80);
}
test "width counts cells, not bytes" {
try expectEqual(0, width(""));
try expectEqual(5, width("hello"));
// Two cells per CJK codepoint, three bytes each.
try expectEqual(8, width("你好世界"));
try expectEqual(10, width("hi 你好 ok"));
}
test "width handles combining marks and invalid bytes" {
// "e" + U+0301 COMBINING ACUTE ACCENT: one visible cell, two codepoints.
try expectEqual(1, width("e\xcc\x81"));
// Stray bytes count one cell each and do not stop iteration.
try expectEqual(3, width("a\xffb"));
try expectEqual(7, width("a\xffb\xfec\xffd"));
// A truncated multi-byte sequence degrades to one cell per leftover byte
// rather than reading past the end of the buffer.
try expectEqual(2, width("\xe4\xbd"));
}
test "width ignores escape sequences" {
// A padded column is measured after its colour codes are already in it.
try expectEqual(5, width("\x1b[1mhello\x1b[0m"));
try expectEqual(0, width("\x1b[0m"));
try expectEqual(4, width("\x1b]8;;https://e.com\x1b\\\x1b[4m你好\x1b[0m"));
}
test "width counts a tab as one cell" {
try expectEqual(1, width("\t"));
try expectEqual(3, width("a\tb"));
}
test "decodeAt reports length and validity" {
const ascii = decodeAt("abc", 0);
try expectEqual(1, ascii.len);
try expect(ascii.valid);
const cjk = decodeAt("你", 0);
try expectEqual(3, cjk.len);
try expect(cjk.valid);
try expectEqual(@as(u21, 0x4f60), cjk.value);
const bad = decodeAt("a\xffb", 1);
try expectEqual(1, bad.len);
try expect(!bad.valid);
}
test "usableWidth leaves a cell and has a floor" {
try expectEqual(79, usableWidth(80));
try expectEqual(24, usableWidth(25));
// At and below the floor the result stops shrinking rather than going to zero.
try expectEqual(min_columns, usableWidth(24));
try expectEqual(min_columns, usableWidth(20));
try expectEqual(min_columns, usableWidth(0));
}