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.
This commit is contained in:
Mitchell Hashimoto
2025-06-22 11:22:02 -07:00
parent f07816f188
commit 1947afade9
7 changed files with 460 additions and 3 deletions

View File

@@ -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" {

View File

@@ -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");

View File

@@ -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:<string>` - Send raw text as-is. This uses Zig string literal
/// syntax so you can specify control characters and other standard
/// escapes.
///
/// * `path:<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

256
src/config/io.zig Normal file
View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,