mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-15 14:26:09 +00:00
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:
@@ -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" {
|
||||
|
@@ -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");
|
||||
|
@@ -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
256
src/config/io.zig
Normal 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;
|
||||
}
|
@@ -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;
|
||||
|
@@ -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 {
|
||||
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user