diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bce1106..8fa8e18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,7 @@ jobs: set -euo pipefail lua5.4 scripts/test_settings.lua lua5.4 scripts/test_history.lua + lua5.4 scripts/test_fold.lua python3 scripts/test_parse_refs.py python3 scripts/test_json_extract.py WEZAI_COMPOSER_TEST=1 python3 plugin/composer.py --self-test diff --git a/GUIDE.md b/GUIDE.md index fa8bca9..78429e4 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -9,7 +9,7 @@ Practical examples for every major surface: Ask, the palette, `@git`, `@kube`, ` | Pane | Role | |------|------| | **Left (shell)** | Your real terminal. cwd, git repo, commands you run. Stay focused here. | -| **Right (wezai)** | Output only — answers, diffs, `git status`, terraform validate, docker ps, weather, progress. Not a shell. While Ask/Edit or a catalog command waits, this pane scrolls a spinner / timed status so it does not look frozen. | +| **Right (wezai)** | Output only — the question you asked, answers, diffs, `git status`, terraform validate, docker ps, weather, progress. Not a shell. While Ask/Edit or a catalog command waits, this pane scrolls a spinner / timed status so it does not look frozen. | | Entry point | When to use it | |-------------|----------------| @@ -32,6 +32,8 @@ CTRL+I splits a **composer** under your shell. The right-hand wezai pane stays v Pinned `@` / `#` files stay in context for follow-up questions until you **Clear**. **Compact** (palette, or type `compact`) shrinks the conversation and sticky selections but keeps those file refs. +Each Ask / `#` edit turn prints a **`you`** block with the line you typed (including `@` / `#` refs) so the log is readable later. Long pastes fold to a short preview plus `… folded N more lines`; palette **Show last question** reprints the full text. + ### Plain questions ``` @@ -146,6 +148,7 @@ Type to fuzzy-filter. Labels start with namespaces so filtering is easy: | **Edit file (`#path …`)** | Opens Ask so you can type an edit | | **Undo last edit** | Restore last `#` write from its wezai backup (or in-memory prior text if backups are off) | | **Copy last command** | Clipboard: last AI command, else shell history, else scrollback | +| **Show last question** | Reprint the last Ask/`#` line (unfolded) | | **Re-ask last question (shorter)** | Same question, shorter answer | | **Pick model…** | Switch model for next requests (`models` list / `WEZAI_MODELS`) | | **Compact chat (keep @/# files)** | Shrink conversation + sticky selection; keep pinned files | diff --git a/README.md b/README.md index 2c4071f..b2a4c0e 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Do **not** leave `update_all()` / `reload_configuration()` at config file scope | `CTRL+ALT+W` | Palette scoped to `@weather` (not `CTRL+SHIFT+W` — that is WezTerm’s close tab) | | `CTRL+SHIFT+H` | Palette scoped to `@history` (fuzzy unique commands; Delete like fish) | -Stay on your **shell** pane. The right split is **output only** (answers, diffs, git/kube/tf/docker/weather status). Catalog commands print immediately and spin `waiting…` until output arrives, so a slow `@weather:now` does not look frozen. Don’t run git from that pane — wezai always uses your shell’s cwd. +Stay on your **shell** pane. The right split is **output only** (your question, answers, diffs, git/kube/tf/docker/weather status). Ask/`#` turns echo a **`you`** block with what you typed; long pastes fold with a leftover-line note (palette **Show last question** reprints the full text). Catalog commands print immediately and spin `waiting…` until output arrives, so a slow `@weather:now` does not look frozen. Don’t run git from that pane — wezai always uses your shell’s cwd. ``` CTRL+SHIFT+P → type @git:status → Enter @@ -171,7 +171,14 @@ wezai.apply_to_config(config, { -- Dialect (fish/zsh/bash/…) is auto-appended from the active shell pane / $SHELL. -- system_prompt = "You are a concise terminal assistant. …", - ai_pane = { enabled = true, direction = "Right", size_percent = 35, pad_cols = 2 }, + ai_pane = { + enabled = true, + direction = "Right", + size_percent = 35, + pad_cols = 2, + question_fold_lines = 8, + question_fold_chars = 600, + }, history = { max_shell = 20000, -- unique commands indexed (newest first) palette_n = 200, -- unified CTRL+SHIFT+P @@ -213,6 +220,7 @@ plugin/ init.lua -- entry, keybindings, ask/edit orchestration palette.lua -- CTRL+SHIFT+P unified palette ui.lua -- output pane, styling, InputSelector + fold.lua -- fold long Ask/Edit questions in the output pane session.lua -- chat memory + pinned @/# files + drafts history.lua -- shell/scrollback history (auto-detect fish/zsh/bash) history_store.lua / history_edit.py -- unique index, fuzzy, fish-style delete diff --git a/SPECS.md b/SPECS.md index c6aa8cf..1c25ba8 100644 --- a/SPECS.md +++ b/SPECS.md @@ -62,6 +62,7 @@ plugin/ util.lua -- paths, files, JSON parse, run_cmd (pcall), resolve_executable, large-file read, version_label ui.lua -- AI pane lifecycle, styling, InputSelector, usage banner, start_progress / start_busy + fold.lua -- fold long Ask/Edit question text for the write-only AI pane session.lua -- per-tab chat memory + pinned @/# files + draft + last edit shell.lua -- shell detect, OS platform hint, risk gate, clipboard, send_command context.lua -- @ / # parsing (@@ alias), dir walk, token budget, redaction @@ -180,7 +181,9 @@ Command labels print as `(fish/macos)` style when showing suggested commands. ### 5.1 Ask (`keybinding`, default often remapped to CTRL+I by users) -Flow: **composer pane** (split of the shell, AI log stays visible) → parse refs → `context.prepare_request` → `providers.ask` → print message/command → optional `shell.send_command`. +Flow: **composer pane** (split of the shell, AI log stays visible) → parse refs → `context.prepare_request` → `providers.ask` → print the **question** (`▶ you`, folded if long) then message/command → optional `shell.send_command`. + +**Question echo:** Ask and `#` edit print whatever was typed (`source_line` when present, else `user_text`) under a `you` label after the turn rule, before progress. Long pastes fold to the first `ai_pane.question_fold_lines` lines / `question_fold_chars` (defaults 8 / 600); leftover lines show as `… folded N more lines (M chars)`. The write-only pane cannot interactively expand — palette **Show last question** reprints the full text. Secrets are redacted the same way as the prompt. Catalog AI helpers echo their `user_text` tag (`git:msg`, `kube:diagnose`, …). If `composer.enabled = false` or python3/`composer.py` is missing, fall back to WezTerm `PromptInputLine` (full-window overlay). Esc in the composer **saves a draft** and restores it on the next CTRL+I. @@ -233,7 +236,7 @@ Unified fuzzy `InputSelector` with scopes: - `weather` (`CTRL+ALT+W` — not `CTRL+SHIFT+W`, which is WezTerm CloseCurrentTab) - `history` (`CTRL+SHIFT+H`) and filtered history scopes -Core palette rows include: Ask, Ask+pane, Fix last error, Explain last command, Attach/Edit file (fuzzy), Undo edit, Copy last command, Shorter re-ask, Pick model, **Compact chat (keep @/# files)**, **Clear chat + file context**, **Show wezai install**, **Update wezai plugin**. +Core palette rows include: Ask, Ask+pane, Fix last error, Explain last command, Attach/Edit file (fuzzy), Undo edit, Copy last command, **Show last question**, Shorter re-ask, Pick model, **Compact chat (keep @/# files)**, **Clear chat + file context**, **Show wezai install**, **Update wezai plugin**. ### 5.3 History @@ -349,7 +352,7 @@ Per-tab: - Pinned `@` attach paths and `#` edit targets (re-read from disk each turn; survive Compact) - Sticky selection / extra context (cleared by Compact) - Composer draft (Esc in CTRL+I) -- Last question/command, last edit batch (for undo), history events +- Last question/command (the typed Ask/`#` line, used to echo `▶ you` and for **Show last question**), last edit batch (for undo), history events **Compact** folds older turns into a recap and drops sticky selection text. **Clear** wipes pins, draft, turns, and extras. @@ -556,7 +559,7 @@ WezTerm Lua has no `debug.getinfo`. `init.lua` locates the plugin dir by scannin - [ ] Git/kube/tf/docker/weather/history always use shell cwd (not AI pane). - [ ] `@history` / `CTRL+SHIFT+H` fuzzy-filters unique commands from the detected shell (fish native `history` or bash/zsh `HISTFILE`); row **Delete** removes all copies (fish `history delete`, bash/zsh flush+rewrite+reload). - [ ] Local Ollama HTTP: with an unloaded large model, `timeout` ≥ load+warmup still returns JSON (not curl 28 / 0 bytes); second Ask is fast while model stays loaded. -- [ ] During a multi-minute Ask wait, AI pane scrolls progress with model/endpoint, elapsed vs timeout %, and rotating hints (not only a bare “thinking…” line). +- [ ] Ask / `#` edit print a `you` block with the typed question before the assistant reply; a 20-line paste folds with a leftover-line note. Palette **Show last question** reprints the full text. - [ ] Chatty model wrapping JSON in prose still parses via `extract_json_object` when a single object is present. Unparseable Ask replies print as errors (not assistant turns). - [ ] `#` / `@@` edit accepting `content` alias when `file` is missing still shows diff confirm (diff visible in the right pane; confirm split does not cover it). - [ ] Gemini `type = "google"` `#` edits return `file` / `files` (schema allows those keys; Ask still uses message+command). @@ -586,6 +589,7 @@ WezTerm Lua has no `debug.getinfo`. `init.lua` locates the plugin dir by scannin | Apply/Cancel confirm (AI pane stays visible) | `plugin/confirm.lua`, `plugin/confirm.py` | | `run_cmd` / `resolve_executable` | `plugin/util.lua` | | Pane / UI | `plugin/ui.lua` | +| Fold long questions | `plugin/fold.lua` (`ui.print_question`) | | Install version label | `plugin/version.lua`, `plugin/util.lua` (`version_label` / `brand_with_version` / `sync_plugin_git`) | | Settings / env overlay | `plugin/settings.lua` (`wezai.env`, `WEZAI_*`) | | Providers | `plugin/providers/` | diff --git a/plugin/context.lua b/plugin/context.lua index 3badc6e..79f7188 100644 --- a/plugin/context.lua +++ b/plugin/context.lua @@ -752,6 +752,7 @@ local function confirm_needed(tokens, config) end function M.prepare_request(window, pane, line, selection, config) + local source_line = line or "" local cwd = util.get_pane_cwd(pane) local max_bytes = config.max_file_bytes or 100000 local parsed = M.parse_at_refs(line or "") @@ -966,6 +967,7 @@ function M.prepare_request(window, pane, line, selection, config) original_content = targets[1] and targets[1].content, is_new = is_new_any, user_text = instruction, + source_line = source_line, attach_labels = synth_labels, omitted = omitted_all, token_estimate = tokens, @@ -1080,6 +1082,7 @@ function M.prepare_request(window, pane, line, selection, config) prompt = prompt, files = all, user_text = question, + source_line = source_line, attach_labels = synth_labels, omitted = omitted_all, token_estimate = tokens, diff --git a/plugin/fold.lua b/plugin/fold.lua new file mode 100644 index 0000000..58fe5cf --- /dev/null +++ b/plugin/fold.lua @@ -0,0 +1,124 @@ +-- Pure helpers to fold long Ask/Edit text for the AI output pane. +-- No wezterm / subprocess. Used by ui.lua and scripts/test_fold.lua. + +local M = {} + +local DEFAULT_MAX_LINES = 8 +local DEFAULT_MAX_CHARS = 600 +local DEFAULT_MAX_LINE_CHARS = 160 + +local function to_int(v, fallback) + if v == nil then + return fallback + end + local n = tonumber(v) + if not n then + return fallback + end + return math.floor(n) +end + +local function split_lines(text) + text = (text or ""):gsub("\r\n", "\n"):gsub("\r", "\n") + if text == "" then + return {} + end + local lines = {} + for line in (text .. "\n"):gmatch("(.-)\n") do + lines[#lines + 1] = line + end + -- Drop the empty match created by the extra "\n" we appended. + if #lines > 0 and lines[#lines] == "" then + table.remove(lines) + end + return lines +end + +local function clip_line(line, max_line_chars) + if max_line_chars and max_line_chars > 0 and #line > max_line_chars then + return line:sub(1, max_line_chars) .. "…", true + end + return line, false +end + +--- Fold long text for the write-only AI pane (no interactive expand). +--- opts: max_lines, max_chars, max_line_chars. Values <= 0 disable that limit. +--- Returns display_text, meta { folded, lines, chars, shown_lines } +function M.fold_text(text, opts) + opts = opts or {} + if type(text) ~= "string" then + text = text == nil and "" or tostring(text) + end + local max_lines = to_int(opts.max_lines, DEFAULT_MAX_LINES) + local max_chars = to_int(opts.max_chars, DEFAULT_MAX_CHARS) + local max_line_chars = to_int(opts.max_line_chars, DEFAULT_MAX_LINE_CHARS) + + local lines = split_lines(text) + local total_lines = #lines + local total_chars = #text + if total_lines == 0 then + return "", { folded = false, lines = 0, chars = 0, shown_lines = 0 } + end + + local shown = {} + local shown_chars = 0 + local folded = false + + for _, line in ipairs(lines) do + if max_lines > 0 and #shown >= max_lines then + folded = true + break + end + local piece, line_cut = clip_line(line, max_line_chars) + local room = (max_chars > 0) and (max_chars - shown_chars) or nil + if room and room <= 0 then + folded = true + break + end + if room and #piece > room then + piece = piece:sub(1, room) .. "…" + line_cut = true + end + shown[#shown + 1] = piece + shown_chars = shown_chars + #piece + if line_cut then + folded = true + if max_chars > 0 and shown_chars >= max_chars then + break + end + end + end + + if #shown < total_lines then + folded = true + end + + local out = table.concat(shown, "\n") + if folded then + local hidden = math.max(0, total_lines - #shown) + if hidden > 0 then + out = out + .. string.format( + "\n… folded %d more line%s (%d chars)", + hidden, + hidden == 1 and "" or "s", + total_chars + ) + else + out = out .. string.format("\n… folded (%d chars)", total_chars) + end + end + + return out, { + folded = folded, + lines = total_lines, + chars = total_chars, + shown_lines = #shown, + } +end + +M.DEFAULT_MAX_LINES = DEFAULT_MAX_LINES +M.DEFAULT_MAX_CHARS = DEFAULT_MAX_CHARS +M.DEFAULT_MAX_LINE_CHARS = DEFAULT_MAX_LINE_CHARS + +return M diff --git a/plugin/init.lua b/plugin/init.lua index 6ff9388..1f05180 100644 --- a/plugin/init.lua +++ b/plugin/init.lua @@ -27,6 +27,7 @@ do "composer.lua", "confirm.lua", "version.lua", + "fold.lua", } local function file_exists(path) @@ -349,17 +350,20 @@ local function handle_ai_request(window, shell_pane, prompt, config, opts) prompt = prepend_history(window, prompt, config) prompt = context.redact(prompt) - if opts.user_text then - session.set_last_question(window, opts.user_text) - session.add_turn(window, "user", context.redact(opts.user_text), config.chat_max_turns) + local asked = opts.source_line or opts.user_text + if asked and tostring(asked):match("%S") then + asked = context.redact(asked) + session.set_last_question(window, asked) + session.add_turn(window, "user", asked, config.chat_max_turns) session.push_history_event( window, - { kind = "ask", text = context.redact(opts.user_text) }, + { kind = "ask", text = asked }, (config.history and config.history.max_session) or 50 ) end ui.begin_turn(ai_pane, os.date("%H:%M:%S") .. " ask") + ui.print_question(ai_pane, asked, config) local progress = ui.start_progress(ai_pane, config) local success, stdout, err, meta = providers.ask(req_config, prompt) progress.stop() @@ -508,18 +512,21 @@ local function handle_edit_request(window, shell_pane, request, config) local prompt = prepend_history(window, request.prompt, config) prompt = context.redact(prompt) - if request.user_text then - session.set_last_question(window, request.user_text) - session.add_turn(window, "user", "EDIT " .. request.target_path .. ": " .. request.user_text, config.chat_max_turns) + local asked = request.source_line or request.user_text + if asked and tostring(asked):match("%S") then + asked = context.redact(asked) + session.set_last_question(window, asked) + session.add_turn(window, "user", "EDIT " .. (request.target_path or "?") .. ": " .. asked, config.chat_max_turns) session.push_history_event(window, { kind = "edit", - text = request.user_text, + text = asked, path = request.target_path, instruction = request.user_text, }, (config.history and config.history.max_session) or 50) end ui.begin_turn(ai_pane, os.date("%H:%M:%S") .. " edit") + ui.print_question(ai_pane, asked, config) local progress = ui.start_progress(ai_pane, config) local edit_config = util.copy_config(config) @@ -697,6 +704,7 @@ local function dispatch_request(window, shell_pane, request, config, opts) handle_ai_request(window, shell_pane, request.prompt, config, { share_pane = opts.share_pane, user_text = request.user_text, + source_line = request.source_line, }) end @@ -1220,6 +1228,16 @@ palette.handlers = { ui.ai_print(ai_pane, "Could not copy to clipboard.", "error") end end, + show_question = function(win, p, cfg) + local ai_pane = ui.ensure_ai_pane(win, p, cfg) + local q = session.get_last_question(win) + if not q then + ui.ai_print(ai_pane, "No previous question.", "error") + return + end + ui.begin_turn(ai_pane, os.date("%H:%M:%S") .. " question") + ui.print_question(ai_pane, q, cfg, { unfold = true }) + end, shorter = function(win, p, cfg) local ai_pane = ui.ensure_ai_pane(win, p, cfg) local q = session.get_last_question(win) diff --git a/plugin/palette.lua b/plugin/palette.lua index 46c8880..19bcad4 100644 --- a/plugin/palette.lua +++ b/plugin/palette.lua @@ -145,6 +145,11 @@ function M.show(window, pane, config, opts) H.copy_cmd(win, p, cfg) end end) + add(choices, handlers, "core:show_question", "Show last question", function(win, p, cfg) + if H.show_question then + H.show_question(win, p, cfg) + end + end) add(choices, handlers, "core:shorter", "Re-ask last question (shorter)", function(win, p, cfg) if H.shorter then H.shorter(win, p, cfg) diff --git a/plugin/settings.lua b/plugin/settings.lua index 5b16883..8006ec3 100644 --- a/plugin/settings.lua +++ b/plugin/settings.lua @@ -57,7 +57,16 @@ local BASE = { dir = nil, }, backup_suffix = ".wezai.bak", - ai_pane = { enabled = true, direction = "Right", size_percent = 35, pad_cols = 2 }, + ai_pane = { + enabled = true, + direction = "Right", + size_percent = 35, + pad_cols = 2, + -- Echo the Ask/Edit question; fold long pastes in the write-only pane. + question_fold_lines = 8, + question_fold_chars = 600, + question_fold_line_chars = nil, -- nil → fit the pane width when known + }, -- Ask composer (CTRL+I): split of the shell pane so the AI log stays visible. composer = { enabled = true, diff --git a/plugin/ui.lua b/plugin/ui.lua index 69f5d65..fbf6615 100644 --- a/plugin/ui.lua +++ b/plugin/ui.lua @@ -2,6 +2,7 @@ local wezterm = require("wezterm") local act = wezterm.action local util = require("util") local stats = require("stats") +local fold = require("fold") local M = {} @@ -30,6 +31,7 @@ local KIND_STYLE = { system = { color = BRIGHT_BLACK, label = nil, gap_before = false }, status = { color = DIM .. YELLOW, label = nil, gap_before = false }, message = { color = CYAN, label = "assistant", gap_before = true }, + question = { color = BOLD .. MAGENTA, label = "you", gap_before = true }, command = { color = GREEN, label = "command", gap_before = true }, attach = { color = MAGENTA, label = "context", gap_before = true }, error = { color = RED, label = "error", gap_before = true }, @@ -430,7 +432,7 @@ local function format_block(text, kind, pad) table.insert(lines, rendered) end - if kind == "message" or kind == "command" or kind == "success" or kind == "error" or kind == "diff" or kind == "git" or kind == "plain" then + if kind == "message" or kind == "question" or kind == "command" or kind == "success" or kind == "error" or kind == "diff" or kind == "git" or kind == "plain" then table.insert(lines, "") end @@ -447,6 +449,9 @@ local function infer_kind(text) if text:find("^💬") then return "message" end + if text:find("^❓") then + return "question" + end if text:find("^⌘") or text:find("^Command") then return "command" end @@ -642,7 +647,50 @@ function M.begin_turn(ai_pane, title) end end --- kind: message|command|attach|error|success|warn|status|system|diff|turn +local function pane_cols(pane) + if not pane then + return nil + end + local ok, dim = pcall(function() + return pane:get_dimensions() + end) + if not ok or type(dim) ~= "table" then + return nil + end + return tonumber(dim.cols) +end + +--- Echo the Ask/Edit question. Long pastes fold to a preview + leftover count. +--- print_opts.unfold = true prints the full text (palette: Show last question). +function M.print_question(ai_pane, text, config, print_opts) + if type(text) ~= "string" then + return + end + local trimmed = text:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return + end + print_opts = print_opts or {} + if print_opts.unfold then + M.ai_print(ai_pane, trimmed, "question") + return + end + local opts = (config and config.ai_pane) or {} + local cols = pane_cols(ai_pane) + local pad = pad_cols_for(ai_pane) + local max_line = opts.question_fold_line_chars + if max_line == nil and cols then + max_line = math.max(24, cols - pad - 2) + end + local folded = fold.fold_text(trimmed, { + max_lines = opts.question_fold_lines, + max_chars = opts.question_fold_chars, + max_line_chars = max_line, + }) + M.ai_print(ai_pane, folded, "question") +end + +-- kind: message|question|command|attach|error|success|warn|status|system|diff|turn function M.ai_print(ai_pane, text, kind) if not text then return @@ -652,6 +700,8 @@ function M.ai_print(ai_pane, text, kind) local cleaned = text if kind == "message" then cleaned = cleaned:gsub("^💬%s*", "") + elseif kind == "question" then + cleaned = cleaned:gsub("^❓%s*", "") elseif kind == "command" then cleaned = cleaned:gsub("^⌘%s*", "") elseif kind == "attach" then diff --git a/scripts/test_fold.lua b/scripts/test_fold.lua new file mode 100644 index 0000000..88386b6 --- /dev/null +++ b/scripts/test_fold.lua @@ -0,0 +1,84 @@ +-- Tests for plugin/fold.lua. Run: lua scripts/test_fold.lua +package.path = "./plugin/?.lua;./plugin/?/init.lua;" .. package.path + +local fold = require("fold") + +local failed = 0 +local passed = 0 + +local function expect(cond, msg) + if cond then + passed = passed + 1 + return + end + failed = failed + 1 + io.stderr:write("FAIL: " .. msg .. "\n") +end + +local function eq(a, b, msg) + expect(a == b, (msg or "eq") .. " got " .. tostring(a) .. " expected " .. tostring(b)) +end + +-- short question is unchanged +local short, meta = fold.fold_text("how do I list listening ports?") +eq(short, "how do I list listening ports?", "short body") +expect(meta.folded == false, "short not folded") +eq(meta.lines, 1, "short line count") +eq(meta.shown_lines, 1, "short shown") + +-- empty +local empty, empty_meta = fold.fold_text("") +eq(empty, "", "empty body") +expect(empty_meta.folded == false, "empty not folded") +eq(empty_meta.lines, 0, "empty lines") + +-- many lines: keep first max_lines, note the rest +local lines = {} +for i = 1, 12 do + lines[i] = "line " .. tostring(i) +end +local long = table.concat(lines, "\n") +local folded, fmeta = fold.fold_text(long, { max_lines = 4, max_chars = 10000, max_line_chars = 200 }) +expect(fmeta.folded == true, "12 lines folded") +eq(fmeta.lines, 12, "12 line count") +eq(fmeta.shown_lines, 4, "kept 4 lines") +expect(folded:find("line 1", 1, true) ~= nil, "keeps first line") +expect(folded:find("line 4", 1, true) ~= nil, "keeps fourth line") +expect(folded:find("line 5", 1, true) == nil, "hides fifth line") +expect(folded:find("… folded 8 more lines", 1, true) ~= nil, "fold note has hidden count") +expect(folded:find("(" .. tostring(#long) .. " chars)", 1, true) ~= nil, "fold note has char count") + +-- one extra line uses singular +local five = "a\nb\nc\nd\ne" +local one_more = fold.fold_text(five, { max_lines = 4, max_chars = 10000, max_line_chars = 200 }) +expect(one_more:find("… folded 1 more line (", 1, true) ~= nil, "singular more line") + +-- long single line +local blob = string.rep("x", 80) +local clipped, cmeta = fold.fold_text(blob, { max_lines = 8, max_chars = 10000, max_line_chars = 20 }) +expect(cmeta.folded == true, "long line folded") +eq(cmeta.shown_lines, 1, "long line still one shown") +expect(clipped:find("… folded (", 1, true) ~= nil, "long line fold note") +expect(clipped:find(string.rep("x", 20) .. "…", 1, true) == 1, "long line clipped") + +-- char budget mid-text +local para = "hello world this is a longer question" +local by_chars, chmeta = fold.fold_text(para, { max_lines = 8, max_chars = 11, max_line_chars = 200 }) +expect(chmeta.folded == true, "char budget folded") +expect(by_chars:find("^hello world…") ~= nil, "char budget prefix") + +-- disable all limits +local unlimited = fold.fold_text(long, { max_lines = 0, max_chars = 0, max_line_chars = 0 }) +eq(unlimited, long, "zero limits keep full text") + +-- CR LF normalized +local crlf, crmeta = fold.fold_text("one\r\ntwo\r\nthree", { max_lines = 10, max_chars = 1000, max_line_chars = 200 }) +eq(crlf, "one\ntwo\nthree", "crlf normalized") +expect(crmeta.folded == false, "crlf not folded") +eq(crmeta.lines, 3, "crlf line count") + +if failed > 0 then + io.stderr:write(string.format("%d failed, %d passed\n", failed, passed)) + os.exit(1) +end +print(string.format("ok — %d passed", passed)) diff --git a/scripts/test_settings.lua b/scripts/test_settings.lua index 83cea34..5967044 100644 --- a/scripts/test_settings.lua +++ b/scripts/test_settings.lua @@ -92,6 +92,15 @@ eq(base.timeout, 300, "default timeout") eq(base.weather.country, "US", "default weather country") eq(base.keybinding_docker.key, "d", "CTRL+SHIFT+D docker") eq(base.docker.confirm_mutate, true, "docker confirm default") +eq(base.ai_pane.question_fold_lines, 8, "question fold lines") +eq(base.ai_pane.question_fold_chars, 600, "question fold chars") + +local fold_cfg = settings.finalize({ + ai_pane = { question_fold_lines = 3, question_fold_chars = 120 }, +}, { skip_live_env = true }) +eq(fold_cfg.ai_pane.question_fold_lines, 3, "lua fold lines override") +eq(fold_cfg.ai_pane.question_fold_chars, 120, "lua fold chars override") +eq(fold_cfg.ai_pane.size_percent, 35, "fold override keeps size") -- process-like map wins over file text when passed as env_map after env_text local stacked = settings.finalize(nil, {