From 1947afade94ccfd24f49c7efe8aeac735eb08061 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Jun 2025 11:22:02 -0700 Subject: [PATCH] `input` configuration to pass input as stdin on startup This adds a new configuration `input` that allows passing either raw text or file contents as stdin when starting the terminal. The input is sent byte-for-byte to the terminal, so control characters such as `\n` will be interpreted by the shell and can be used to run programs in the context of the loaded shell. Example: `ghostty --input="hello, world\n"` will start the your default shell, run `echo hello, world`, and then show the prompt. --- src/cli/args.zig | 7 +- src/config.zig | 1 + src/config/Config.zig | 42 +++++++ src/config/io.zig | 256 ++++++++++++++++++++++++++++++++++++++++++ src/config/string.zig | 2 +- src/termio/Termio.zig | 135 +++++++++++++++++++++- src/termio/Thread.zig | 20 ++++ 7 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 src/config/io.zig diff --git a/src/cli/args.zig b/src/cli/args.zig index 68972a622..3c34e17fe 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -414,7 +414,7 @@ pub fn parseIntoField( return error.InvalidField; } -fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T { +pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T { const info = @typeInfo(T).@"union"; assert(@typeInfo(info.tag_type.?) == .@"enum"); @@ -1090,6 +1090,7 @@ test "parseIntoField: tagged union" { b: u8, c: void, d: []const u8, + e: [:0]const u8, } = undefined, } = .{}; @@ -1108,6 +1109,10 @@ test "parseIntoField: tagged union" { // Set string field try parseIntoField(@TypeOf(data), alloc, &data, "value", "d:hello"); try testing.expectEqualStrings("hello", data.value.d); + + // Set sentinel string field + try parseIntoField(@TypeOf(data), alloc, &data, "value", "e:hello"); + try testing.expectEqualStrings("hello", data.value.e); } test "parseIntoField: tagged union unknown filed" { diff --git a/src/config.zig b/src/config.zig index fb7359b3e..018d0e6e8 100644 --- a/src/config.zig +++ b/src/config.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const formatter = @import("config/formatter.zig"); pub const Config = @import("config/Config.zig"); pub const conditional = @import("config/conditional.zig"); +pub const io = @import("config/io.zig"); pub const string = @import("config/string.zig"); pub const edit = @import("config/edit.zig"); pub const url = @import("config/url.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index cdc156032..6bc3a7f23 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -34,6 +34,7 @@ const ErrorList = @import("ErrorList.zig"); const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; +const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO; const RepeatableStringMap = @import("RepeatableStringMap.zig"); pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; @@ -807,6 +808,47 @@ command: ?Command = null, /// browser. env: RepeatableStringMap = .{}, +/// Data to send as input to the command on startup. +/// +/// The configured `command` will be launched using the typical rules, +/// then the data specified as this input will be written to the pty +/// before any other input can be provided. +/// +/// The bytes are sent as-is with no additional encoding. Therefore, be +/// cautious about input that can contain control characters, because this +/// can be used to execute programs in a shell. +/// +/// The format of this value is: +/// +/// * `raw:` - Send raw text as-is. This uses Zig string literal +/// syntax so you can specify control characters and other standard +/// escapes. +/// +/// * `path:` - Read a filepath and send the contents. The path +/// must be to a file with finite length. e.g. don't use a device +/// such as `/dev/stdin` or `/dev/urandom` as these will block +/// terminal startup indefinitely. Files are limited to 10MB +/// in size to prevent excessive memory usage. If you have files +/// larger than this you should write a script to read the file +/// and send it to the terminal. +/// +/// If no valid prefix is found, it is assumed to be a `raw:` input. +/// This is an ergonomic choice to allow you to simply write +/// `input = "Hello, world!"` (a common case) without needing to prefix +/// every value with `raw:`. +/// +/// This can be repeated multiple times to send more data. The data +/// is concatenated directly with no separator characters in between +/// (e.g. no newline). +/// +/// If any of the input sources do not exist, then none of the input +/// will be sent. Input sources are not verified until the terminal +/// is starting, so missing paths will not show up in config validation. +/// +/// Changing this configuration at runtime will only affect new +/// terminals. +input: RepeatableReadableIO = .{}, + /// If true, keep the terminal open after the command exits. Normally, the /// terminal window closes when the running command (such as a shell) exits. /// With this true, the terminal window will stay open until any keypress is diff --git a/src/config/io.zig b/src/config/io.zig new file mode 100644 index 000000000..8be4be551 --- /dev/null +++ b/src/config/io.zig @@ -0,0 +1,256 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const string = @import("string.zig"); +const formatterpkg = @import("formatter.zig"); +const cli = @import("../cli.zig"); + +/// ReadableIO is some kind of IO source that is readable. +/// +/// It can be either a direct string or a filepath. The filepath will +/// be deferred and read later, so it won't be checked for existence +/// or readability at configuration time. This allows using a path that +/// might be produced in an intermediate state. +pub const ReadableIO = union(enum) { + const Self = @This(); + + raw: [:0]const u8, + path: [:0]const u8, + + pub fn parseCLI( + self: *Self, + alloc: Allocator, + input_: ?[]const u8, + ) !void { + const input = input_ orelse return error.ValueRequired; + if (input.len == 0) return error.ValueRequired; + + // We create a buffer only to do string parsing and validate + // it works. We store the value as raw so that our formatting + // can recreate it. + { + const buf = try alloc.alloc(u8, input.len); + defer alloc.free(buf); + _ = try string.parse(buf, input); + } + + // Next, parse the tagged union using normal rules. + self.* = cli.args.parseTaggedUnion( + Self, + alloc, + input, + ) catch |err| switch (err) { + // Invalid values in the tagged union are interpreted as + // raw values. This lets users pass in simple string values + // without needing to tag them. + error.InvalidValue => .{ .raw = try alloc.dupeZ(u8, input) }, + else => return err, + }; + } + + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .raw => |v| .{ .raw = try alloc.dupeZ(u8, v) }, + .path => |v| .{ .path = try alloc.dupeZ(u8, v) }, + }; + } + + /// Same as clone but also parses the values as Zig strings in + /// the final resulting value all at once so we can avoid extra + /// allocations. + pub fn cloneParsed( + self: Self, + alloc: Allocator, + ) Allocator.Error!Self { + switch (self) { + inline else => |v, tag| { + // Parsing can't fail because we validate it in parseCLI + const copied = try alloc.dupeZ(u8, v); + const parsed = string.parse(copied, v) catch unreachable; + assert(copied.ptr == parsed.ptr); + + // If we parsed less than our original length we need + // to keep it null-terminated. + if (parsed.len < copied.len) copied[parsed.len] = 0; + + return @unionInit( + Self, + @tagName(tag), + copied[0..parsed.len :0], + ); + }, + } + } + + pub fn equal(self: Self, other: Self) bool { + if (std.meta.activeTag(self) != std.meta.activeTag(other)) { + return false; + } + + return switch (self) { + .raw => |v| std.mem.eql(u8, v, other.raw), + .path => |v| std.mem.eql(u8, v, other.path), + }; + } + + pub fn formatEntry(self: Self, formatter: anytype) !void { + var buf: [4096]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + switch (self) { + inline else => |v, tag| { + writer.writeAll(@tagName(tag)) catch return error.OutOfMemory; + writer.writeByte(':') catch return error.OutOfMemory; + writer.writeAll(v) catch return error.OutOfMemory; + }, + } + + const written = fbs.getWritten(); + try formatter.formatEntry( + []const u8, + written, + ); + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + { + var io: Self = undefined; + try Self.parseCLI(&io, alloc, "foo"); + try testing.expect(io == .raw); + try testing.expectEqualStrings("foo", io.raw); + } + { + var io: Self = undefined; + try Self.parseCLI(&io, alloc, "raw:foo"); + try testing.expect(io == .raw); + try testing.expectEqualStrings("foo", io.raw); + } + { + var io: Self = undefined; + try Self.parseCLI(&io, alloc, "path:foo"); + try testing.expect(io == .path); + try testing.expectEqualStrings("foo", io.path); + } + } + + test "formatEntry" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var buf = std.ArrayList(u8).init(alloc); + defer buf.deinit(); + + var v: Self = undefined; + try v.parseCLI(alloc, "raw:foo"); + try v.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = raw:foo\n", buf.items); + } +}; + +pub const RepeatableReadableIO = struct { + const Self = @This(); + + // Allocator for the list is the arena for the parent config. + list: std.ArrayListUnmanaged(ReadableIO) = .{}, + + pub fn parseCLI( + self: *Self, + alloc: Allocator, + input: ?[]const u8, + ) !void { + const value = input orelse return error.ValueRequired; + + // Empty value resets the list + if (value.len == 0) { + self.list.clearRetainingCapacity(); + return; + } + + var io: ReadableIO = undefined; + try ReadableIO.parseCLI(&io, alloc, value); + try self.list.append(alloc, io); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity( + alloc, + self.list.items.len, + ); + for (self.list.items) |item| { + const copy = try item.clone(alloc); + list.appendAssumeCapacity(copy); + } + + return .{ .list = list }; + } + + /// See ReadableIO.cloneParsed + pub fn cloneParsed( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Self { + var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity( + alloc, + self.list.items.len, + ); + for (self.list.items) |item| { + const copy = try item.cloneParsed(alloc); + list.appendAssumeCapacity(copy); + } + + return .{ .list = list }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.list.items; + const itemsB = other.list.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |a, b| { + if (!a.equal(b)) return false; + } else return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + if (self.list.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + for (self.list.items) |value| { + try formatter.formatEntry(ReadableIO, value); + } + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "raw:A"); + try list.parseCLI(alloc, "path:B"); + try testing.expectEqual(@as(usize, 2), list.list.items.len); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.list.items.len); + } +}; + +test { + _ = ReadableIO; + _ = RepeatableReadableIO; +} diff --git a/src/config/string.zig b/src/config/string.zig index 5e0d40e55..71826f005 100644 --- a/src/config/string.zig +++ b/src/config/string.zig @@ -3,7 +3,7 @@ const std = @import("std"); /// Parse a string literal into a byte array. The string can contain /// any valid Zig string literal escape sequences. /// -/// The output buffer never needs sto be larger than the input buffer. +/// The output buffer never needs to be larger than the input buffer. /// The buffers may alias. pub fn parse(out: []u8, bytes: []const u8) ![]u8 { var dst_i: usize = 0; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index ecfb9951e..c474d55bb 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -70,6 +70,89 @@ terminal_stream: terminalpkg.Stream(StreamHandler), /// flooding with cursor resets. last_cursor_reset: ?std.time.Instant = null, +/// State we have for thread enter. This may be null if we don't need +/// to keep track of any state or if its already been freed. +thread_enter_state: ?*ThreadEnterState = null, + +/// The state we need to keep around only until we enter the IO +/// thread. Then we can throw it all away. +const ThreadEnterState = struct { + arena: ArenaAllocator, + + /// Initial input to send to the subprocess after starting. This + /// memory is freed once the subprocess start is attempted, even + /// if it fails, because Exec only starts once. + input: configpkg.io.RepeatableReadableIO, + + pub fn create( + alloc: Allocator, + config: *const configpkg.Config, + ) !?*ThreadEnterState { + // If we have no input then we have no thread enter state + if (config.input.list.items.len == 0) return null; + + // Create our arena allocator + var arena = ArenaAllocator.init(alloc); + errdefer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Allocate our ThreadEnterState + const ptr = try arena_alloc.create(ThreadEnterState); + + // Copy the input from the config + const input = try config.input.cloneParsed(arena_alloc); + + // Return the initialized state + ptr.* = .{ + .arena = arena, + .input = input, + }; + return ptr; + } + + pub fn destroy(self: *ThreadEnterState) void { + self.arena.deinit(); + } + + /// Prepare the inputs for use. Allocations happen on the arena. + pub fn prepareInput( + self: *ThreadEnterState, + ) (Allocator.Error || error{InputNotFound})![]const Input { + const alloc = self.arena.allocator(); + + var input = try alloc.alloc( + Input, + self.input.list.items.len, + ); + for (self.input.list.items, 0..) |item, i| { + input[i] = switch (item) { + .raw => |v| .{ .string = try alloc.dupe(u8, v) }, + .path => |path| file: { + const f = std.fs.cwd().openFile( + path, + .{}, + ) catch |err| { + log.warn("failed to open input file={s} err={}", .{ + path, + err, + }); + return error.InputNotFound; + }; + + break :file .{ .file = f }; + }, + }; + } + + return input; + } + + const Input = union(enum) { + string: []const u8, + file: std.fs.File, + }; +}; + /// The configuration for this IO that is derived from the main /// configuration. This must be exported so that we don't need to /// pass around Config pointers which makes memory management a pain. @@ -211,6 +294,11 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }; }; + const thread_enter_state = try ThreadEnterState.create( + alloc, + opts.full_config, + ); + self.* = .{ .alloc = alloc, .terminal = term, @@ -232,6 +320,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }, }, }, + .thread_enter_state = thread_enter_state, }; } @@ -244,9 +333,30 @@ pub fn deinit(self: *Termio) void { // Clear any StreamHandler state self.terminal_stream.handler.deinit(); self.terminal_stream.deinit(); + + // Clear any initial state if we have it + if (self.thread_enter_state) |v| v.destroy(); } -pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void { +pub fn threadEnter( + self: *Termio, + thread: *termio.Thread, + data: *ThreadData, +) !void { + // Always free our thread enter state when we're done. + defer if (self.thread_enter_state) |v| { + v.destroy(); + self.thread_enter_state = null; + }; + + // If we have thread enter state then we're going to validate + // and set that all up now so that we can error before we actually + // start the command and pty. + const inputs: ?[]const ThreadEnterState.Input = if (self.thread_enter_state) |v| + try v.prepareInput() + else + null; + data.* = .{ .alloc = self.alloc, .loop = &thread.loop, @@ -258,6 +368,29 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo // Setup our backend try self.backend.threadEnter(self.alloc, self, data); + errdefer self.backend.threadExit(data); + + // If we have inputs, then queue them all up. + for (inputs orelse &.{}) |input| switch (input) { + .string => |v| self.queueWrite(data, v, false) catch |err| { + log.warn("failed to queue input string err={}", .{err}); + return error.InputFailed; + }, + .file => |f| self.queueWrite( + data, + f.readToEndAlloc( + self.alloc, + 10 * 1024 * 1024, // 10 MiB max + ) catch |err| { + log.warn("failed to read input file err={}", .{err}); + return error.InputFailed; + }, + false, + ) catch |err| { + log.warn("failed to queue input file err={}", .{err}); + return error.InputFailed; + }, + }; } pub fn threadExit(self: *Termio, data: *ThreadData) void { diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 35da3c2d2..58a04f5a7 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -146,6 +146,8 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { // have "OpenptyFailed". const Err = @TypeOf(err) || error{ OpenptyFailed, + InputNotFound, + InputFailed, }; switch (@as(Err, @errorCast(err))) { @@ -165,6 +167,24 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void { t.printString(str) catch {}; }, + error.InputNotFound, + error.InputFailed, + => { + const str = + \\A configured `input` path was not found, was not readable, + \\was too large, or the underlying pty failed to accept + \\the write. + \\ + \\Ghostty can't continue since it can't guarantee that + \\initial terminal state will be as desired. Please review + \\the value of `input` in your configuration file and + \\ensure that all the path values exist and are readable. + ; + + t.eraseDisplay(.complete, false); + t.printString(str) catch {}; + }, + else => { const str = std.fmt.allocPrint( alloc,