const std = @import("std"); const context_mod = @import("context.zig"); const diagnostics = @import("diagnostics.zig"); const input = @import("input.zig"); const job = @import("job.zig"); const layout = @import("layout.zig"); const leader = @import("leader.zig"); const lsp = @import("lsp.zig"); const mobile_acceptance = @import("mobile_acceptance.zig"); const panel = @import("panel.zig"); const pi_bridge = @import("pi_bridge.zig"); const profile = @import("profile.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const repo = @import("repo.zig"); const session = @import("session.zig"); const socket = @import("socket.zig"); const symbol = @import("symbol.zig"); const syntax = @import("syntax.zig"); const terminal_debug = @import("terminal_debug.zig"); const tui = @import("tui.zig"); pub const version = "0.1.0-dev"; const help_text = \\mim - mobile-first terminal code editor for SSH sessions \\ \\Usage: \\ mim [path] \\ mim [--help] \\ mim [--version] \\ mim context [task] [intent] \\ mim profile \\ mim mimctl context [task] [intent] \\ mim --mimctl \\ \\With no path, mim opens the current directory. Existing files open in the \\editor; missing paths start a new buffer; directories open the file browser. \\This early build also exposes compact local context and mimctl for trusted tools. \\ ; const Command = union(enum) { help, version, open: []const u8, }; fn parseArgs(args: []const []const u8) Command { if (args.len <= 1) return .{ .open = "." }; const arg = args[1]; if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) return .help; if (std.mem.eql(u8, arg, "--version")) return .version; return .{ .open = arg }; } fn versionText() []const u8 { return "mim " ++ version ++ "\n"; } pub fn main(init: std.process.Init) !u8 { var debug_allocator = std.heap.DebugAllocator(.{}){}; defer _ = debug_allocator.deinit(); const allocator = debug_allocator.allocator(); var args = std.process.Args.Iterator.init(init.minimal.args); _ = args.skip(); const stdout = std.Io.File.stdout(); const stderr = std.Io.File.stderr(); const first_arg = args.next(); if (first_arg) |arg| { if (std.mem.eql(u8, arg, "context")) { try printLocalContext(allocator, init.io, &args, stdout); return 0; } if (std.mem.eql(u8, arg, "profile")) { try printProfile(allocator, init.io, stdout); return 0; } if (isProfileStub(arg)) { try printProfileStub(init.io, stderr, arg); return 69; } if (std.mem.eql(u8, arg, "mimctl")) { if (!profile.local_socket) { try printProfileStub(init.io, stderr, "mimctl"); return 69; } const sub = args.next() orelse { try stderr.writeStreamingAll(init.io, "mim: mimctl requires a subcommand\n"); return 64; }; if (!std.mem.eql(u8, sub, "context")) { try stderr.writeStreamingAll(init.io, "mim: unknown mimctl subcommand\n"); return 64; } const socket_path = args.next() orelse { try stderr.writeStreamingAll(init.io, "mim: mimctl context requires a socket path or '-'\n"); return 64; }; const line = try collectContextRequest(allocator, &args); defer allocator.free(line); requestMimctl(allocator, init.io, init.minimal.environ, stdout, stderr, socket_path, line) catch return 69; return 0; } if (std.mem.eql(u8, arg, "--mimctl")) { if (!profile.local_socket) { try printProfileStub(init.io, stderr, "mimctl"); return 69; } const socket_path = args.next() orelse { try stderr.writeStreamingAll(init.io, "mim: --mimctl requires a socket path\n"); return 64; }; const line = try collectRemainingArgs(allocator, &args); defer allocator.free(line); if (line.len == 0) { try stderr.writeStreamingAll(init.io, "mim: --mimctl requires a request\n"); return 64; } requestMimctl(allocator, init.io, init.minimal.environ, stdout, stderr, socket_path, line) catch return 69; return 0; } } var e2e_prelude_path: ?[]const u8 = null; var open_arg = first_arg; if (first_arg) |arg| { if (std.mem.eql(u8, arg, "--e2e-prelude-file")) { e2e_prelude_path = args.next() orelse { try stderr.writeStreamingAll(init.io, "mim: --e2e-prelude-file requires a path\n"); return 64; }; open_arg = args.next(); } } const command = if (open_arg) |arg| parseArgs(&.{ "mim", arg }) else parseArgs(&.{"mim"}); switch (command) { .help => try stdout.writeStreamingAll(init.io, help_text), .version => try stdout.writeStreamingAll(init.io, versionText()), .open => |path| try openLocalEditor(allocator, init.io, stdout, stderr, path, e2e_prelude_path), } return 0; } fn openLocalEditor( allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File, stderr: std.Io.File, path: []const u8, e2e_prelude_path: ?[]const u8, ) !void { var client = try tui.Client.init(allocator, .{ .width = 80, .height = 22 }); defer client.deinit(); const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch |err| switch (err) { error.FileNotFound => null, else => { try stderr.writeStreamingAll(io, "mim: could not inspect path\n"); return err; }, }; if (stat) |metadata| { switch (metadata.kind) { .directory => try openDirectoryPreview(allocator, io, &client, path), .file => { if (std.fs.path.dirname(path)) |parent| seedDirectoryRepo(allocator, io, &client, parent) catch {}; const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(diagnostics.max_file_bytes)) catch |err| switch (err) { error.StreamTooLong => { try client.handleTraceLine("open diagnostic:file_too_large"); return runLocalEditor(allocator, io, stdout, stderr, &client, path, false, e2e_prelude_path); }, else => return err, }; defer allocator.free(bytes); const command = try std.fmt.allocPrint(allocator, "open {s}", .{bytes}); defer allocator.free(command); try client.handleTraceLine(command); }, else => try client.handleTraceLine("open diagnostic:unsupported_file"), } } else { try client.handleTraceLine("open "); } try runLocalEditor(allocator, io, stdout, stderr, &client, path, stat != null and stat.?.kind == .directory, e2e_prelude_path); } fn seedDirectoryRepo(allocator: std.mem.Allocator, io: std.Io, client: *tui.Client, path: []const u8) !void { var dir = try std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }); defer dir.close(io); var iterator = dir.iterate(); var count: usize = 0; while (try iterator.next(io)) |entry| { if (count >= 80) break; if (entry.kind != .file and entry.kind != .directory) continue; const relative_path = try std.fmt.allocPrint(allocator, "{s}/{s}{s}", .{ path, entry.name, if (entry.kind == .directory) "/" else "" }); defer allocator.free(relative_path); if (entry.kind == .file) { const bytes = std.Io.Dir.cwd().readFileAlloc(io, relative_path, allocator, .limited(diagnostics.max_file_bytes)) catch |err| switch (err) { error.StreamTooLong, error.AccessDenied, error.FileNotFound, error.NotDir => null, else => null, }; if (bytes) |content| { defer allocator.free(content); client.addRepoFileContent(relative_path, content) catch {}; } else { client.addRepoFileContent(relative_path, "") catch {}; } } else { client.addRepoFileContent(relative_path, "") catch {}; } count += 1; } } fn openDirectoryPreview(allocator: std.mem.Allocator, io: std.Io, client: *tui.Client, path: []const u8) !void { try seedDirectoryRepo(allocator, io, client, path); try client.handleTraceLine("file_picker"); } fn runLocalEditor( allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File, stderr: std.Io.File, client: *tui.Client, path: []const u8, is_dir: bool, e2e_prelude_path: ?[]const u8, ) !void { const stdin_file = std.Io.File.stdin(); const interactive = stdin_file.isTty(io) catch false; var last_saved = try client.snapshotBytesAlloc(allocator); defer allocator.free(last_saved); var last_client_saved: ?[]u8 = null; defer if (last_client_saved) |bytes| allocator.free(bytes); if (e2e_prelude_path) |prelude_path| { const prelude = try std.Io.Dir.cwd().readFileAlloc(io, prelude_path, allocator, .limited(64 * 1024)); defer allocator.free(prelude); var lines = std.mem.splitScalar(u8, prelude, '\n'); while (lines.next()) |line| if (line.len != 0) try client.handleTraceLine(line); } if (!interactive) { try renderLocalFrame(allocator, io, stdout, client, path, is_dir, false); return; } if (is_dir) try client.openFilePicker(); var raw_terminal = try RawTerminal.enable(stdin_file.handle); defer raw_terminal.restore(); while (true) { try renderLocalFrame(allocator, io, stdout, client, path, is_dir, true); const raw = try readEditorInputFdAlloc(allocator, stdin_file.handle); defer if (raw) |bytes| allocator.free(bytes); if (raw == null) return; client.handleInput(raw.?) catch |err| { if (err == tui.Error.ClientQuit) return; try stderr.writeStreamingAll(io, "mim: input failed: "); try stderr.writeStreamingAll(io, @errorName(err)); try stderr.writeStreamingAll(io, "\n"); continue; }; const after = try client.snapshotBytesAlloc(allocator); defer allocator.free(after); var dirty = !std.mem.eql(u8, after, last_saved); if (client.saved()) |saved_bytes| { const new_save_request = last_client_saved == null or !std.mem.eql(u8, saved_bytes, last_client_saved.?); if (new_save_request) { if (is_dir and client.currentPath() == null) { client.setStatusMessage("directory browser has nothing to save"); } else { const save_path = try resolveLocalSavePath(allocator, path, client.currentPath()); defer allocator.free(save_path); try persistLocalSave(allocator, io, save_path, saved_bytes, &last_saved, &last_client_saved); dirty = false; client.setStatusMessage("saved"); } } } else |_| {} if (client.requestedQuit()) { if (dirty and !client.requestedDiscardQuit()) { client.clearQuit(); client.setStatusMessage("dirty buffer: Space w saves, Space Q discards"); continue; } return; } } } fn resolveLocalSavePath(allocator: std.mem.Allocator, launch_path: []const u8, current_path: ?[]const u8) ![]u8 { const selected = current_path orelse launch_path; if (std.fs.path.isAbsolute(selected) or std.mem.indexOfScalar(u8, selected, '/') != null) return allocator.dupe(u8, selected); const base = std.fs.path.dirname(launch_path) orelse "."; return std.fs.path.join(allocator, &.{ base, selected }); } fn persistLocalSave( allocator: std.mem.Allocator, io: std.Io, path: []const u8, bytes: []const u8, last_saved: *[]u8, last_client_saved: *?[]u8, ) !void { try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = bytes }); const saved_copy = try allocator.dupe(u8, bytes); errdefer allocator.free(saved_copy); const client_copy = try allocator.dupe(u8, bytes); allocator.free(last_saved.*); last_saved.* = saved_copy; if (last_client_saved.*) |old| allocator.free(old); last_client_saved.* = client_copy; } const RawTerminal = struct { fd: std.posix.fd_t, original: std.posix.termios, fn enable(fd: std.posix.fd_t) !RawTerminal { const original = try std.posix.tcgetattr(fd); var raw = original; raw.iflag.BRKINT = false; raw.iflag.ICRNL = false; raw.iflag.INPCK = false; raw.iflag.ISTRIP = false; raw.iflag.IXON = false; raw.oflag.OPOST = false; raw.cflag.CSIZE = .CS8; raw.lflag.ECHO = false; raw.lflag.ICANON = false; raw.lflag.IEXTEN = false; raw.lflag.ISIG = false; raw.cc[@intFromEnum(std.posix.V.MIN)] = 1; raw.cc[@intFromEnum(std.posix.V.TIME)] = 0; try std.posix.tcsetattr(fd, .FLUSH, raw); return .{ .fd = fd, .original = original }; } fn restore(self: *RawTerminal) void { std.posix.tcsetattr(self.fd, .FLUSH, self.original) catch {}; } }; fn readEditorInputFdAlloc(allocator: std.mem.Allocator, fd: std.posix.fd_t) !?[]u8 { var one: [1]u8 = undefined; const n = try std.posix.read(fd, &one); if (n == 0) return null; var bytes = std.ArrayList(u8).empty; errdefer bytes.deinit(allocator); try bytes.append(allocator, one[0]); if (one[0] != 0x1b) return try bytes.toOwnedSlice(allocator); var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; const ready = std.posix.poll(&fds, 30) catch 0; if (ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) return try bytes.toOwnedSlice(allocator); const second_n = try std.posix.read(fd, &one); if (second_n == 0) return try bytes.toOwnedSlice(allocator); try bytes.append(allocator, one[0]); if (one[0] == '[') { while (bytes.items.len < 8) { const next_ready = std.posix.poll(&fds, 30) catch 0; if (next_ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) break; const next_n = try std.posix.read(fd, &one); if (next_n == 0) break; try bytes.append(allocator, one[0]); if (one[0] >= '@' and one[0] <= '~') break; } } return try bytes.toOwnedSlice(allocator); } fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader, fd: std.posix.fd_t) !?[]u8 { const first = stdin.takeByte() catch |err| switch (err) { error.EndOfStream => return null, else => return err, }; var bytes = std.ArrayList(u8).empty; errdefer bytes.deinit(allocator); try bytes.append(allocator, first); if (first == 0x1b) { var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; const ready: usize = if (fd < 0) 1 else std.posix.poll(&fds, 30) catch 0; if (ready == 0 or (fd >= 0 and (fds[0].revents & std.posix.POLL.IN) == 0)) return try bytes.toOwnedSlice(allocator); const second = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator); try bytes.append(allocator, second); if (second == '[') { const third_ready: usize = if (fd < 0) 1 else std.posix.poll(&fds, 30) catch 0; if (third_ready == 0 or (fd >= 0 and (fds[0].revents & std.posix.POLL.IN) == 0)) return try bytes.toOwnedSlice(allocator); const third = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator); try bytes.append(allocator, third); } return try bytes.toOwnedSlice(allocator); } const len = std.unicode.utf8ByteSequenceLength(first) catch 1; var i: usize = 1; while (i < len) : (i += 1) { const next = stdin.takeByte() catch break; try bytes.append(allocator, next); } return try bytes.toOwnedSlice(allocator); } fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File, client: *tui.Client, path: []const u8, is_dir: bool, interactive: bool) !void { const frame = try client.render(allocator); defer allocator.free(frame); if (interactive) { try stdout.writeStreamingAll(io, "\x1b[H\x1b[2J"); try writeTerminalText(allocator, io, stdout, frame); try writeTerminalText(allocator, io, stdout, "\n\n"); try writeTerminalText(allocator, io, stdout, "mim opened: "); try writeTerminalText(allocator, io, stdout, path); try writeTerminalText(allocator, io, stdout, if (is_dir) "\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n" else "\nEditor starts in normal mode. Press i to insert; Space commands work in normal mode; Space w saves; Space q quits when clean; Space Q discards dirty changes.\n"); } else { try stdout.writeStreamingAll(io, frame); try stdout.writeStreamingAll(io, "\n\n"); try stdout.writeStreamingAll(io, "mim opened: "); try stdout.writeStreamingAll(io, path); try stdout.writeStreamingAll(io, if (is_dir) "\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n" else "\nEditor starts in normal mode. Press i to insert; Space commands work in normal mode; Space w saves; Space q quits when clean; Space Q discards dirty changes.\n"); } } fn writeTerminalText(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File, bytes: []const u8) !void { const converted = try terminalCrlfAlloc(allocator, bytes); defer allocator.free(converted); try stdout.writeStreamingAll(io, converted); } fn terminalCrlfAlloc(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); var start: usize = 0; for (bytes, 0..) |byte, i| { if (byte != '\n') continue; if (start < i) try out.appendSlice(allocator, bytes[start..i]); try out.appendSlice(allocator, "\r\n"); start = i + 1; } if (start < bytes.len) try out.appendSlice(allocator, bytes[start..]); return out.toOwnedSlice(allocator); } fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void { const task = args.next(); const intent = args.next(); const text = try context_mod.localContextAlloc(allocator, ".", task, intent, .{}); defer allocator.free(text); try stdout.writeStreamingAll(io, text); } fn printProfile(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File) !void { const text = try profile.statusAlloc(allocator); defer allocator.free(text); try stdout.writeStreamingAll(io, text); } fn isProfileStub(arg: []const u8) bool { return std.mem.eql(u8, arg, "remote") or std.mem.eql(u8, arg, "mimctl") or std.mem.eql(u8, arg, "plugin") or std.mem.eql(u8, arg, "plugins") or std.mem.eql(u8, arg, "background-agent") or std.mem.eql(u8, arg, "bundled-pi"); } fn printProfileStub(io: std.Io, stderr: std.Io.File, arg: []const u8) !void { try stderr.writeStreamingAll(io, "mim: "); try stderr.writeStreamingAll(io, arg); try stderr.writeStreamingAll(io, " not built in this profile\n"); } fn requestMimctl( allocator: std.mem.Allocator, io: std.Io, environ: std.process.Environ, stdout: std.Io.File, stderr: std.Io.File, socket_path: []const u8, line: []const u8, ) !void { const resolved_socket_path = if (std.mem.eql(u8, socket_path, "-")) std.process.Environ.getAlloc(environ, allocator, socket.socket_env_name) catch { try stderr.writeStreamingAll(io, "mim: MIM_SOCKET is not set\n"); return error.MimctlFailed; } else try allocator.dupe(u8, socket_path); defer allocator.free(resolved_socket_path); const response = socket.request(allocator, resolved_socket_path, line) catch { try stderr.writeStreamingAll(io, "mim: mimctl request failed\n"); return error.MimctlFailed; }; defer allocator.free(response); try stdout.writeStreamingAll(io, response); } fn collectContextRequest(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) ![]u8 { var line = std.ArrayList(u8).empty; errdefer line.deinit(allocator); try line.appendSlice(allocator, "context"); while (args.next()) |arg| { try line.append(allocator, ' '); try line.appendSlice(allocator, arg); } return line.toOwnedSlice(allocator); } fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) ![]u8 { var line = std.ArrayList(u8).empty; errdefer line.deinit(allocator); var first = true; while (args.next()) |arg| { if (!first) try line.append(allocator, ' '); try line.appendSlice(allocator, arg); first = false; } return line.toOwnedSlice(allocator); } test { _ = context_mod; _ = diagnostics; _ = input; _ = job; _ = layout; _ = leader; _ = lsp; _ = mobile_acceptance; _ = panel; _ = pi_bridge; _ = profile; _ = protocol; _ = replay; _ = repo; _ = session; _ = socket; _ = symbol; _ = syntax; _ = tui; } test "regular: help text names the binary and open path" { try std.testing.expect(std.mem.indexOf(u8, help_text, "mim - mobile-first terminal code editor") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "mim [path]") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "mim context") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "mim profile") != null); } test "regular: excluded profile capabilities have explicit stubs" { try std.testing.expect(isProfileStub("remote")); try std.testing.expect(isProfileStub("mimctl")); try std.testing.expect(isProfileStub("plugin")); try std.testing.expect(isProfileStub("background-agent")); try std.testing.expect(!isProfileStub("context")); } test "regular: version output is stable enough for smoke checks" { try std.testing.expectEqualStrings("mim 0.1.0-dev\n", versionText()); } test "regular: no args opens current directory" { const argv = [_][]const u8{"mim"}; const parsed = parseArgs(&argv); switch (parsed) { .open => |path| try std.testing.expectEqualStrings(".", path), else => return error.ExpectedOpenPath, } } test "regular: positional path opens file directory or new buffer" { const argv = [_][]const u8{ "mim", "some-file.zig" }; const parsed = parseArgs(&argv); switch (parsed) { .open => |path| try std.testing.expectEqualStrings("some-file.zig", path), else => return error.ExpectedOpenPath, } } test "regular: local editor input reader preserves text utf8 and arrows" { var ascii = std.Io.Reader.fixed("a"); const ascii_event = (try readEditorInputAlloc(std.testing.allocator, &ascii, -1)).?; defer std.testing.allocator.free(ascii_event); try std.testing.expectEqualStrings("a", ascii_event); var utf8 = std.Io.Reader.fixed("é"); const utf8_event = (try readEditorInputAlloc(std.testing.allocator, &utf8, -1)).?; defer std.testing.allocator.free(utf8_event); try std.testing.expectEqualStrings("é", utf8_event); var arrow = std.Io.Reader.fixed("\x1b[D"); const arrow_event = (try readEditorInputAlloc(std.testing.allocator, &arrow, -1)).?; defer std.testing.allocator.free(arrow_event); try std.testing.expectEqualStrings("\x1b[D", arrow_event); } test "regular: local editor client exposes save and dirty quit guards" { var client = try tui.Client.init(std.testing.allocator, .{ .width = 32, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open "); const initial = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(initial); try std.testing.expectEqualStrings("", initial); try client.handleInput("i"); try client.handleInput("h"); try client.handleInput("e"); try client.handleInput("l"); try client.handleInput("l"); try client.handleInput("o"); const edited = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(edited); try std.testing.expectEqualStrings("hello", edited); try client.handleInput(" "); try client.handleInput("x"); const with_space = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(with_space); try std.testing.expectEqualStrings("hello x", with_space); try client.handleTraceLine("key escape"); try std.testing.expectEqualStrings("normal", client.modeName()); try client.handleInput(" "); try client.handleInput("w"); try std.testing.expectEqualStrings("hello x", try client.saved()); try client.handleInput("i"); try client.handleInput("!"); try client.handleTraceLine("key escape"); try client.handleInput(" "); try client.handleInput("q"); try std.testing.expect(client.requestedQuit()); client.clearQuit(); try std.testing.expect(!client.requestedQuit()); } test "terminal debug helpers are included in main test root" { _ = terminal_debug; } test "regular: raw terminal output converts newline to carriage-return newline" { const converted = try terminalCrlfAlloc(std.testing.allocator, "a\nb\n"); defer std.testing.allocator.free(converted); try std.testing.expectEqualStrings("a\r\nb\r\n", converted); }