termancy Guide Components Commands API

shell

A line editor for REPLs: history and tab completion

termancy tui add shell

Also copies what it requires: input

No recording here. zig build site-gifs records one from docs/tapes/shell.tape when there is one; termancy ui demo shell runs it, if it has a demo.

A line editor for interactive shells (REPLs): an Input with history and tab completion. Up and down (ctrl+p, ctrl+n) walk the history, keeping the line being typed; tab completes the last word from completer: one match is filled in, several fill in what they share and are listed under the line. Enter submits; ctrl+d on an empty line ends the input; esc cancels.

var line: Shell = .{
    .input = .{ .gpa = gpa, .prompt = "app> " },
    .history = history,
    .completer = .{ .context = &repl, .function = completeLine },
};
defer line.deinit();
// ... run it as a program ...
switch (line.status) {
    .submitted => run(line.value()),
    .ended, .canceled => break,
    .active => unreachable,
}

With cli, completeLine can call cli.repl.Repl.complete, which answers from the same command tree the shell completion scripts use.

shell.zig

//! A line editor for interactive shells (REPLs): an `Input` with history and tab
//! completion. Up and down (ctrl+p, ctrl+n) walk the history, keeping the line being
//! typed; tab completes the last word from `completer`: one match is filled in, several
//! fill in what they share and are listed under the line. Enter submits; ctrl+d on an empty
//! line ends the input; esc cancels.
//!
//! ```zig
//! var line: Shell = .{
//!     .input = .{ .gpa = gpa, .prompt = "app> " },
//!     .history = history,
//!     .completer = .{ .context = &repl, .function = completeLine },
//! };
//! defer line.deinit();
//! // ... run it as a program ...
//! switch (line.status) {
//!     .submitted => run(line.value()),
//!     .ended, .canceled => break,
//!     .active => unreachable,
//! }
//! ```
//!
//! With `cli`, `completeLine` can call `cli.repl.Repl.complete`, which answers from the
//! same command tree the shell completion scripts use.

const std = @import("std");
const tui = @import("tui");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const Input = @import("input.zig");

pub const Status = enum { active, submitted, ended, canceled };

/// Computes the completions of the last word of `line` (empty after a space), each the
/// whole word, in `arena`.
pub const Completer = struct {
    context: *anyopaque,
    function: *const fn (
        context: *anyopaque,
        arena: Allocator,
        line: []const u8,
    ) anyerror![]const []const u8,
};

const Shell = @This();

input: Input,
/// Earlier lines, oldest first.
history: []const []const u8 = &.{},
completer: ?Completer = null,
status: Status = .active,
/// The history entry shown, while walking the history.
browsing: ?usize = null,
/// The line being typed when the walk began, shown again after the newest entry.
draft: std.ArrayList(u8) = .empty,
/// The matches of the last tab that found several, listed under the line.
choices: []const []const u8 = &.{},
/// Holds `choices` and completion results until the next tab.
scratch: ?std.heap.ArenaAllocator = null,
styles: Styles = .{},

pub const Styles = struct {
    choices: tui.Style = .{ .fg = .{ .indexed = 244 } },
};

pub fn deinit(shell: *Shell) void {
    const allocator = shell.gpa();
    shell.input.deinit();
    shell.draft.deinit(allocator);
    if (shell.scratch) |*arena| arena.deinit();
    shell.* = undefined;
}

/// The line as typed.
pub fn value(shell: *const Shell) []const u8 {
    return shell.input.value();
}

fn gpa(shell: *const Shell) Allocator {
    return shell.input.gpa;
}

pub fn update(shell: *Shell, msg: tui.Msg) tui.Cmd {
    switch (msg) {
        .key => |key| {
            if (key.matches("enter")) return shell.finish(.submitted);
            if (key.matches("esc")) return shell.finish(.canceled);
            if (key.matches("ctrl+d") and shell.value().len == 0) return shell.finish(.ended);
            if (key.matches("up") or key.matches("ctrl+p")) {
                shell.older();
            } else if (key.matches("down") or key.matches("ctrl+n")) {
                shell.newer();
            } else if (key.matches("tab")) {
                shell.complete();
            } else if (shell.input.handleKey(key)) {
                shell.choices = &.{};
            }
        },
        .paste => |text| shell.input.insert(text) catch {},
        else => {},
    }
    return .none;
}

fn finish(shell: *Shell, status: Status) tui.Cmd {
    shell.status = status;
    shell.choices = &.{};
    return .quit;
}

/// Shows the entry before the one shown, saving the typed line on the first step.
fn older(shell: *Shell) void {
    if (shell.history.len == 0) return;
    const index = if (shell.browsing) |i| i -| 1 else blk: {
        shell.draft.clearRetainingCapacity();
        shell.draft.appendSlice(shell.gpa(), shell.value()) catch return;
        break :blk shell.history.len - 1;
    };
    shell.browsing = index;
    shell.input.setValue(shell.history[index]) catch {};
}

/// Shows the entry after the one shown, or the saved line after the newest.
fn newer(shell: *Shell) void {
    const index = shell.browsing orelse return;
    if (index + 1 < shell.history.len) {
        shell.browsing = index + 1;
        shell.input.setValue(shell.history[index + 1]) catch {};
    } else {
        shell.browsing = null;
        shell.input.setValue(shell.draft.items) catch {};
    }
}

/// Completes the last word of the line.
fn complete(shell: *Shell) void {
    const completer = shell.completer orelse return;
    if (shell.scratch) |*arena| {
        _ = arena.reset(.retain_capacity);
    } else {
        shell.scratch = .init(shell.gpa());
    }
    const arena = shell.scratch.?.allocator();
    shell.choices = &.{};
    const line = arena.dupe(u8, shell.value()) catch return;
    const found = completer.function(completer.context, arena, line) catch return;
    if (found.len == 0) return;
    const word_start = if (std.mem.lastIndexOfScalar(u8, line, ' ')) |space| space + 1 else 0;
    const shared = commonPrefix(found);
    const replacement = if (found.len == 1)
        std.mem.concat(arena, u8, &.{ found[0], " " }) catch return
    else
        shared;
    if (replacement.len >= line.len - word_start) {
        const text = std.mem.concat(arena, u8, &.{ line[0..word_start], replacement }) catch
            return;
        shell.input.setValue(text) catch return;
    }
    if (found.len > 1) shell.choices = found;
}

pub fn view(shell: *const Shell, w: *Writer) Writer.Error!void {
    try shell.input.view(w);
    if (shell.choices.len == 0) return;
    try w.writeByte('\n');
    for (shell.choices, 0..) |choice, i| {
        if (i > 0) try w.writeAll("  ");
        try shell.styles.choices.paint(w, choice);
    }
}

/// The longest prefix all of `words` share.
fn commonPrefix(words: []const []const u8) []const u8 {
    var prefix = words[0];
    for (words[1..]) |word| {
        const shared = std.mem.indexOfDiff(u8, prefix, word) orelse @min(prefix.len, word.len);
        prefix = prefix[0..shared];
    }
    return prefix;
}

const testing = std.testing;
const Driver = tui.testing.Driver(Shell);

const commands = [_][]const u8{ "get", "keys", "set", "show" };

/// Completes the first word from `commands`, nothing after it.
fn completeCommand(_: *anyopaque, arena: Allocator, line: []const u8) ![]const []const u8 {
    if (std.mem.indexOfScalar(u8, line, ' ') != null) return &.{};
    var out: std.ArrayList([]const u8) = .empty;
    for (commands) |command| {
        if (std.mem.startsWith(u8, command, line)) try out.append(arena, command);
    }
    return out.items;
}

var unused: u8 = 0;

fn start(d: *Driver, history: []const []const u8) !void {
    d.* = try .init(testing.allocator, .{
        .input = .{ .gpa = testing.allocator, .prompt = "$ " },
        .history = history,
        .completer = .{ .context = &unused, .function = completeCommand },
    }, .{});
}

fn stop(d: *Driver) void {
    d.deinit();
}

test "tab fills in one match, the shared part of several, and lists them" {
    var d: Driver = undefined;
    try start(&d, &.{});
    defer stop(&d);
    try d.typeText("k");
    try d.press("tab");
    try testing.expectEqualStrings("keys ", d.model().value());
    try d.press("ctrl+u");
    try d.typeText("s");
    try d.press("tab");
    try testing.expectEqualStrings("s", d.model().value());
    try d.expectFrame("$ s \nset  show");
    try d.typeText("h");
    try d.press("tab");
    try testing.expectEqualStrings("show ", d.model().value());
    try d.expectFrame("$ show  ");
}

test "up and down walk the history and come back to the typed line" {
    var d: Driver = undefined;
    try start(&d, &.{ "get a", "keys" });
    defer stop(&d);
    try d.typeText("dra");
    try d.press("up");
    try testing.expectEqualStrings("keys", d.model().value());
    try d.press("up");
    try d.press("up");
    try testing.expectEqualStrings("get a", d.model().value());
    try d.press("down");
    try d.press("down");
    try testing.expectEqualStrings("dra", d.model().value());
    try d.press("enter");
    try testing.expect(d.done());
    try testing.expectEqual(Status.submitted, d.model().status);
}

test "ctrl+d on an empty line ends the input; esc cancels" {
    var d: Driver = undefined;
    try start(&d, &.{});
    defer stop(&d);
    try d.typeText("x");
    try d.press("ctrl+d"); // Deletes forward, as in `Input`, when the line has text.
    try testing.expect(!d.done());
    try d.press("backspace");
    try d.press("ctrl+d");
    try testing.expect(d.done());
    try testing.expectEqual(Status.ended, d.model().status);

    var e: Driver = undefined;
    try start(&e, &.{});
    defer stop(&e);
    try e.press("esc");
    try testing.expectEqual(Status.canceled, e.model().status);
}

test commonPrefix {
    try testing.expectEqualStrings("s", commonPrefix(&.{ "set", "show" }));
    try testing.expectEqualStrings("sho", commonPrefix(&.{ "show", "shown", "sho" }));
}