-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. local std = require("std") local lev = require("lev") local json = require("cjson.safe") local term = require("term") local input = require("term.input") local history = require("term.input.history") local completion = require("term.input.completion") local prompt = require("term.input.prompt") local git = require("git.prompt") local storage = require("shell.store") local shell_mode = require("shell.mode.shell") local builtins = require("shell.builtins") local messages = require("shell.messages") local pipeline = require("shell.utils.pipeline") local tty = require("shell.tty") local theme_mod = require("theme") local change_mode_combo local get_mode = function(self, mode_name) local name = mode_name or "" return self.__state.modes[name] end local get_current_mode = function(self) local chosen = self.__state.chosen_mode if not self.__state.modes[chosen] then chosen = "shell" self.__state.chosen_mode = chosen end return self.__state.modes[chosen] end local required_mode_methods = { "run", "get_input", "can_handle_combo", "handle_combo" } local validate_mode_contract = function(mode_name, mode) if type(mode) ~= "table" then return nil, "mode `" .. tostring(mode_name) .. "` must return a table" end for _, method_name in ipairs(required_mode_methods) do if type(mode[method_name]) ~= "function" then return nil, "mode `" .. tostring(mode_name) .. "` is missing required method `" .. method_name .. "`" end end return true end local activate_mode = function(self, mode_name) local mode = get_mode(self, mode_name) if not mode then return nil, "mode `" .. tostring(mode_name) .. "` is not available" end if type(mode.on_activate) ~= "function" then return true end local ok, activated, err = pcall(function() return mode:on_activate() end) if not ok then return nil, tostring(activated) end if not activated then return nil, err or ("failed to activate mode `" .. tostring(mode_name) .. "`") end return true end local run_in_sane_mode = tty.run_in_sane_mode local load_mode = function(self, mode_name, mode_cfg, history_store) local mode_module, mode_prompt, mode_completion, mode_history if not std.module_available(mode_cfg.path) then return nil, "no such module: " .. mode_cfg.path end if mode_cfg.prompt then if not std.module_available(mode_cfg.prompt) then return nil, "no such module: " .. mode_cfg.prompt end mode_prompt = prompt.new(mode_cfg.prompt) end if mode_cfg.completion then if not std.module_available(mode_cfg.completion.path) then return nil, "no such module: " .. mode_cfg.completion.path end local completion_obj, completion_err = completion.new(mode_cfg.completion) if not completion_obj then return nil, "failed to initialize completion: " .. tostring(completion_err) end mode_completion = completion_obj end mode_module = require(mode_cfg.path) if mode_cfg.history then mode_history = history.new(mode_name, history_store) end local input_rss = theme_mod.get("shell").input or {} local mode_input = input.new({ rss = input_rss, completion = mode_completion, history = mode_history, prompt = mode_prompt, l = 1, c = 1, }) local mode = mode_module.new(mode_input, mode_cfg) local valid, validation_err = validate_mode_contract(mode_name, mode) if not valid then return nil, validation_err end self.__state.modes[mode_name] = mode return true end local bind_mode_shortcut = function(self, mode_name, shortcut) if type(shortcut) ~= "string" or shortcut == "" then return false end self.__state.shortcuts[shortcut] = mode_name self.__state.ctrls[shortcut] = change_mode_combo return true end local has_mode = function(self, mode_name) return get_mode(self, mode_name) ~= nil end local list_modes = function(self) local mode_names = {} for mode_name, _ in pairs(self.__state.modes) do table.insert(mode_names, mode_name) end table.sort(mode_names) return mode_names end local get_mode_for_shortcut = function(self, shortcut) if type(shortcut) ~= "string" then return nil end return self.__state.shortcuts[shortcut] end local list_shortcuts = function(self) local shortcuts = {} for shortcut, mode_name in pairs(self.__state.shortcuts) do shortcuts[shortcut] = mode_name end return shortcuts end local has_combo_handler = function(self, combo) if type(combo) ~= "string" then return false end return type(self.__state.ctrls[combo]) == "function" end -- these are combos that we always want to process -- on the highest level first, and then pass to -- the current mode handler local clear_combo = function(self, combo) term.clear() local mode = get_current_mode(self) if mode then local mode_input = mode:get_input() if mode_input then mode_input:set_position(1, 1) mode_input:flush() end end return true end local exit_combo = function(self, combo) if self.__state.chosen_mode ~= "shell" then self.__state.chosen_mode = "shell" return clear_combo(self) end local mode = get_mode(self, "shell") if os.getenv("VIRTUAL_ENV") ~= nil and mode and type(mode.on_shell_exit) == "function" then return mode:on_shell_exit() end tty.enter_exec_mode() os.exit(0) end change_mode_combo = function(self, combo) local next_mode_name = self.__state.shortcuts[combo] or "shell" local activated, activate_err = activate_mode(self, next_mode_name) if not activated then messages.error(activate_err) return true end self.__state.chosen_mode = next_mode_name return clear_combo(self) end local KEY_RELEASE = 3 -- An in-process mode renders itself and wants key events during run() -- (e.g. a cancel combo) instead of being switched to cooked mode. -- Opt-in via an optional renders_in_process() method; default false -- preserves every existing mode and external-command execution. local mode_renders_in_process = function(mode) if type(mode.renders_in_process) ~= "function" then return false end local ok, result = pcall(function() return mode:renders_in_process() end) return ok and result == true end -- While an in-process mode's run() is executing the shell loop is busy -- inside that call, so the prompt's input loop is idle and combos would -- otherwise never reach the mode. Spawn a lev task that reads stdin -- cooperatively (epoll-driven, no busy poll) and dispatches any combo -- the mode accepts to handle_combo. Returns a stopper that tears the -- watcher down and restores blocking stdin before the prompt resumes. local spawn_combo_watcher = function(active_mode) local STDIN_FD = 0 local prev_nonblock = lev.set_nonblocking(STDIN_FD, true) local reader = lev.fd_reader(STDIN_FD) local token = lev.cancel_token() local stop = false local task = lev.spawn(function() while not stop do local key, mods, event, _shifted, base = term.read_key(reader, { cancel = token }) if not key then break -- cancelled (teardown) or stdin closed end if base then key = base end if mods and event ~= KEY_RELEASE then local shortcut = key local mod_string = term.mods_to_string(mods - 1) if mod_string ~= "" then shortcut = mod_string .. "+" .. key end if active_mode:can_handle_combo(shortcut) then active_mode:handle_combo(shortcut) end end end end, { name = "combo-watcher", detached = true }) return function() stop = true token:cancel() pcall(lev.await, task) reader:close() lev.set_nonblocking(STDIN_FD, prev_nonblock) end end local run = function(self) if not term.is_tty() then messages.error("Not connected to a TTY", { status = 29 }) os.exit(29) end term.set_raw_mode() -- Probe text-sizing support before has_kkbp() pollutes the input buffer term.has_ts() term.has_ts_combined() term.init_ts_width_mode() if not term.has_kkbp() then term.write("This terminal does not seem to support kitty keyboard protocol\r\n") term.set_sane_mode() os.exit(29) end term.enable_kkbp() term.enable_bracketed_paste() term.clear() local ensure_input_tss = theme_mod.subscribe("shell", "input") local last_input_tss = ensure_input_tss() local mode = get_current_mode(self) local mode_input = mode and mode:get_input() or nil if mode_input then mode_input:display(true) end while true do local active_mode = get_current_mode(self) local active_input = active_mode and active_mode:get_input() or nil if not active_mode or not active_input then messages.error("active mode is not available", { status = 29 }) os.exit(29) end local event, combo = active_input:run({ execute = true, exit = false, combo = true }) if event then if event == "execute" then -- Clear any pending input io.flush() local in_process = mode_renders_in_process(active_mode) local stop_watcher if in_process then tty.enter_exec_mode_inproc({ newline = true }) stop_watcher = spawn_combo_watcher(active_mode) else tty.enter_exec_mode({ newline = true }) end local cwd = std.fs.cwd() std.ps.setenv("LILUSH_EXEC_CWD", cwd) std.ps.setenv("LILUSH_EXEC_START", tostring(os.time())) local status, err, next_input, skip_history = active_mode:run() if stop_watcher then stop_watcher() end if status == nil and err == nil then status = 0 end if status ~= 0 then messages.error(err, { status = status }) end std.ps.setenv("LILUSH_EXEC_END", tostring(os.time())) std.ps.setenv("LILUSH_EXEC_STATUS", tostring(status)) if not skip_history then local cmd_text = active_input:get_content() if cmd_text ~= "" and not cmd_text:match("^ ") and not cmd_text:match("^%.%.+$") then local payload if self.__state.chosen_mode == "shell" then local env = std.environ() local start_ts = tonumber(env["LILUSH_EXEC_START"]) or os.time() local end_ts = tonumber(env["LILUSH_EXEC_END"]) or os.time() local hist_cwd = cwd local home = env["HOME"] or "" hist_cwd = hist_cwd:gsub("^" .. home, "~") payload = { cmd = cmd_text, ts = start_ts, d = end_ts - start_ts, cwd = hist_cwd, exit = status, } local git_status = git.prompt_status() if git_status then payload.git = { branch = git_status.branch, ahead = git_status.ahead, behind = git_status.behind, dirty = (git_status.modified > 0 or git_status.staged > 0), untracked = (git_status.untracked > 0), } end if active_input.__completion then payload.comp_ref = active_input.__completion.__provider.last_comp_ref end else payload = { cmd = cmd_text, ts = os.time(), exit = status, } end active_input:add_to_history(payload) end end if self.__state.chosen_mode == "shell" and active_input.__completion then local learned = active_input.__completion:source("learned") if learned then learned:record(active_input:get_content(), status or 1) end end if in_process then tty.leave_exec_mode_inproc() else tty.leave_exec_mode() end git.invalidate_prompt_cache() local current_input_tss = ensure_input_tss() if current_input_tss ~= last_input_tss then last_input_tss = current_input_tss local input_rss = theme_mod.get("shell").input or {} for _, mode_obj in pairs(self.__state.modes) do if type(mode_obj.get_input) == "function" then mode_obj:get_input():set_rss(input_rss) end end end local l, _ = term.cursor_position() active_input:set_position(l, 1) active_input:flush() if next_input then active_input:set_content(next_input) end active_input:display(true) elseif event == "combo" then if self.__state.ctrls[combo] then local redraw = run_in_sane_mode(function() return self.__state.ctrls[combo](self, combo) end) if redraw then local current_mode = get_current_mode(self) local current_input = current_mode and current_mode:get_input() or nil if current_input then current_input:display(true) end end elseif active_mode:can_handle_combo(combo) then local redraw = run_in_sane_mode(function() return active_mode:handle_combo(combo) end) if redraw then active_input:display(true) end end end end end end local run_once = function(self) local cmd = table.concat(arg, " ") or "" local mode = get_mode(self, "shell") if not mode then messages.error("shell mode is not available", { status = 29 }) os.exit(29) end local mode_input = mode:get_input() mode_input:set_content(cmd) local status, err = mode:run_once() if status ~= 0 then messages.error(err, { status = status }) end os.exit(status) end local run_script = function(self) local script_path = arg[1] if not script_path then messages.error("no script path provided", { status = 1 }) os.exit(1) end if not script_path:match("^/") then script_path = std.fs.cwd() .. "/" .. script_path end local mode = get_mode(self, "shell") if not mode then messages.error("shell mode is not available", { status = 29 }) os.exit(29) end -- Set script arguments as env vars local arg_count = #arg - 1 std.ps.setenv("0", script_path) std.ps.setenv("#", tostring(arg_count)) for i = 2, #arg do std.ps.setenv(tostring(i - 1), arg[i]) end local status = mode:run_script("run_script", { script_path }) -- Clean up env vars std.ps.unsetenv("0") std.ps.unsetenv("#") for i = 1, arg_count do std.ps.unsetenv(tostring(i)) end os.exit(status) end local new = function() local home = std.home() if not home then io.stderr:write("lilush: cannot determine home directory: HOME is not set and getpwuid failed\n") os.exit(1) end std.ps.setenv("SHELL", "/usr/bin/lilush") local lilpack = require("lilpack") lilpack.init() -- Register LILPACK theme sections and definitions local theme_exts = lilpack.extensions().theme if theme_exts then for _, sec in ipairs(theme_exts.sections) do local ok, builder = pcall(require, sec.builder) if ok and type(builder) == "function" then theme_mod.register_section(sec.name, builder) end end for _, def in ipairs(theme_exts.definitions) do local ok, data = pcall(require, def.module) if ok and type(data) == "table" then theme_mod.register_theme(def.name, data) end end end local completion_tss = theme_mod.subscribe("shell", "completion") local builtin_mode_configs = { shell = { shortcut = "F1", path = "shell.mode.shell", history = true, prompt = "shell.mode.shell.prompt", completion = { path = "shell.completion.shell", tss = completion_tss, sources = { "shell.completion.source.bin", "shell.completion.source.builtins", "shell.completion.source.cmds", "shell.completion.source.env", "shell.completion.source.fs", "shell.completion.source.learned", }, }, }, lua = { shortcut = "F2", path = "shell.mode.lua", history = true, prompt = "shell.mode.lua.prompt", completion = { path = "shell.completion.lua", tss = completion_tss, sources = { "shell.completion.source.lua_keywords", "shell.completion.source.lua_symbols", }, }, }, wiki = { shortcut = "F3", path = "shell.mode.wiki", history = true, prompt = "shell.mode.wiki.prompt", completion = { path = "shell.completion.wiki", tss = completion_tss, sources = { "shell.completion.source.wiki_commands", "shell.completion.source.wiki_dbs", "shell.completion.source.wiki_entries", "shell.completion.source.wiki_results", "shell.completion.source.wiki_indexes", }, }, }, } local builtin_mode_order = { "shell", "lua", "wiki" } local reserved_shortcuts = { F1 = true, F2 = true, F3 = true } local user_shortcut_pool = { "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12" } local shell = { cfg = { builtin_mode_configs = builtin_mode_configs, builtin_mode_order = builtin_mode_order, reserved_shortcuts = reserved_shortcuts, user_shortcut_pool = user_shortcut_pool, }, __state = { modes = {}, shortcuts = {}, ctrls = { ["CTRL+d"] = exit_combo, ["CTRL+l"] = clear_combo, }, chosen_mode = "shell", history_store = nil, assigned_shortcuts = {}, }, run = run, has_mode = has_mode, get_mode = get_mode, list_modes = list_modes, get_mode_for_shortcut = get_mode_for_shortcut, list_shortcuts = list_shortcuts, has_combo_handler = has_combo_handler, } local next_free_user_shortcut = function() for _, shortcut in ipairs(shell.cfg.user_shortcut_pool) do if not shell.__state.assigned_shortcuts[shortcut] then return shortcut end end return nil end -- Build the "shell" mode first without a history store: its constructor -- sources init.lsh, which may export env vars (e.g. LILUSH_ENCRYPTION_KEY) -- that the shell store open path needs. do local mode_cfg = shell.cfg.builtin_mode_configs.shell local ok, err = load_mode(shell, "shell", mode_cfg, nil) if not ok then messages.error(err, { status = 29 }) os.exit(29) end bind_mode_shortcut(shell, "shell", mode_cfg.shortcut) shell.__state.assigned_shortcuts[mode_cfg.shortcut] = "shell" end local history_store = storage.new() shell.__state.history_store = history_store do local shell_mode_obj = shell.__state.modes.shell local shell_history = history.new("shell", history_store) shell_mode_obj:get_input().__history = shell_history end for _, mode_name in ipairs(shell.cfg.builtin_mode_order) do if mode_name ~= "shell" then local mode_cfg = shell.cfg.builtin_mode_configs[mode_name] local ok, err = load_mode(shell, mode_name, mode_cfg, history_store) if not ok then messages.error(err, { status = 29 }) os.exit(29) end bind_mode_shortcut(shell, mode_name, mode_cfg.shortcut) shell.__state.assigned_shortcuts[mode_cfg.shortcut] = mode_name end end -- Load LILPACK modes from ~/.config/lilush/modes.json local modes_json_str = std.fs.read_file(std.home() .. "/.config/lilush/modes.json") if modes_json_str then local modes_map = json.decode(modes_json_str) if type(modes_map) == "table" then local pack_modes = lilpack.extensions().modes for shortcut, pack_name in pairs(modes_map) do local mode_cfg = pack_modes[pack_name] if not mode_cfg then messages.warning("no lilpack mode '" .. pack_name .. "' found for " .. shortcut) elseif shell.cfg.reserved_shortcuts[shortcut] then messages.warning( "mode '" .. pack_name .. "' assigned to reserved shortcut " .. shortcut .. ", skipping" ) elseif shell.__state.assigned_shortcuts[shortcut] then messages.warning( "mode '" .. pack_name .. "' assigned to taken shortcut " .. shortcut .. " (used by '" .. shell.__state.assigned_shortcuts[shortcut] .. "'), skipping" ) else local pcall_ok, ok_or_err, load_err = pcall(load_mode, shell, mode_cfg.name, mode_cfg, shell.__state.history_store) if not pcall_ok then messages.warning("mode '" .. pack_name .. "': " .. tostring(ok_or_err)) elseif not ok_or_err then messages.warning("mode '" .. pack_name .. "': " .. tostring(load_err)) else if bind_mode_shortcut(shell, mode_cfg.name, shortcut) then shell.__state.assigned_shortcuts[shortcut] = mode_cfg.name end end end end end end -- Register LILPACK builtins for _, builtin_desc in ipairs(lilpack.extensions().builtins) do local ok, family_or_err = pcall(require, builtin_desc.module) if ok and type(family_or_err) == "table" then builtins.register_family(family_or_err) else messages.warning("lilpack builtin '" .. builtin_desc.module .. "': " .. tostring(family_or_err)) end end return shell end local new_mini_base = function(variant) require("lilpack").init() local mini_shell_mode = shell_mode.new(input.new({}), { script_mode = true }) local valid, validation_err = validate_mode_contract("shell", mini_shell_mode) if not valid then messages.error(validation_err, { status = 29 }) os.exit(29) end local run_fn = run_once if variant and type(variant) == "string" and variant == "script" then run_fn = run_script end local shell = { cfg = {}, __state = { modes = { shell = mini_shell_mode }, shortcuts = {}, ctrls = {}, chosen_mode = "shell", }, run = run_fn, has_mode = has_mode, get_mode = get_mode, list_modes = list_modes, get_mode_for_shortcut = get_mode_for_shortcut, list_shortcuts = list_shortcuts, has_combo_handler = has_combo_handler, } return shell end local new_script = function() return new_mini_base("script") end return { new = new, new_mini = new_mini_base, new_script = new_script }