From 35b5cdcd13f6b8efde11c47fa8f43a24aba10d11 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 11 Oct 2026 20:06:09 +0900 Subject: [PATCH] fix: preserve assistant editor input and extend release PTY gates --- README.md | 8 +- docs/src/getting-started.md | 32 ++++---- docs/src/guide/recipes.md | 55 +++++++++---- docs/src/project/releasing.md | 7 +- man/nshell.1 | 2 +- nshell.asd | 8 +- scripts/test-release-pty.pl | 78 +++++++++++++++++-- src/presentation/repl-agent.lisp | 27 +++---- src/presentation/repl-completion-seed.lisp | 13 +--- t/scripts/test-release-pty.pl | 90 +++++++++++++++++++--- t/unit/test-agent-mode.lisp | 59 ++++++++++++++ 11 files changed, 290 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 179e247..d17c1f7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The release and Nix flake support `x86_64-linux` only. With [Nix](https://nixos.org/download) and flakes enabled: ```sh -nix run github:nerima-lisp/nshell/v0.6.3 +nix run github:nerima-lisp/nshell/v0.6.4 ``` At the prompt, enter `string upper hello`. Commands and paths colorize live, and a @@ -62,7 +62,7 @@ and session settings. ## Install ```sh -nix profile install github:nerima-lisp/nshell/v0.6.3 +nix profile install github:nerima-lisp/nshell/v0.6.4 ``` Pin a release tag rather than following the default branch. @@ -70,8 +70,8 @@ Pin a release tag rather than following the default branch. To install without Nix, use the `x86_64-linux` release bundle. It removes Nix store references, carries its ELF runtime library closure, and is checked for required files and dependency metadata. CI checks an extracted archive and exercises its executable with a -real PTY, including editing, signals, job control, history restart, and terminal -restoration. See [Getting +real PTY, including multiline editing and paste, completion with spaces, +signals, job control, resize, history restart, and terminal restoration. See [Getting started](https://nerima-lisp.github.io/nshell/getting-started/) for the bundle verification and installation procedure. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index abe3a93..b3fd436 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -9,13 +9,13 @@ Install [Nix](https://nixos.org/download) and enable its `nix-command` and `flakes` experimental features before running: ```sh -nix run github:nerima-lisp/nshell/v0.6.3 +nix run github:nerima-lisp/nshell/v0.6.4 ``` ## Install ```sh -nix profile install github:nerima-lisp/nshell/v0.6.3 +nix profile install github:nerima-lisp/nshell/v0.6.4 man nshell # the manual page is installed alongside the binary nshell ``` @@ -26,34 +26,34 @@ leave the interactive shell. ### Prebuilt Linux bundle Without Nix, download both assets from the -[v0.6.3 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.3), +[v0.6.4 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.4), verify the checksum, and extract the bundle. On `x86_64-linux`, with `curl`, `sha256sum`, and `tar` available, run these commands in an empty directory: ```sh -curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.3/nshell-v0.6.3-x86_64-linux.tar.gz -curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.3/nshell-v0.6.3-x86_64-linux.tar.gz.sha256 -sha256sum --check nshell-v0.6.3-x86_64-linux.tar.gz.sha256 +curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.4/nshell-v0.6.4-x86_64-linux.tar.gz +curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.4/nshell-v0.6.4-x86_64-linux.tar.gz.sha256 +sha256sum --check nshell-v0.6.4-x86_64-linux.tar.gz.sha256 ``` Proceed only if the checksum command exits successfully and prints -`nshell-v0.6.3-x86_64-linux.tar.gz: OK`: +`nshell-v0.6.4-x86_64-linux.tar.gz: OK`: ```sh -tar -xzf nshell-v0.6.3-x86_64-linux.tar.gz -./nshell-v0.6.3-x86_64-linux/bin/nshell --version -man ./nshell-v0.6.3-x86_64-linux/share/man/man1/nshell.1 -./nshell-v0.6.3-x86_64-linux/bin/nshell +tar -xzf nshell-v0.6.4-x86_64-linux.tar.gz +./nshell-v0.6.4-x86_64-linux/bin/nshell --version +man ./nshell-v0.6.4-x86_64-linux/share/man/man1/nshell.1 +./nshell-v0.6.4-x86_64-linux/bin/nshell ``` Keep the extracted directory intact: the launcher resolves libraries and -helpers relative to itself. The version output must identify `v0.6.3`. +helpers relative to itself. The version output must identify `v0.6.4`. Type `exit` to return to your existing shell. To use `nshell` by name in the examples below, add the extracted `bin` directory to that shell's `PATH`. For Bash or Zsh, from the download directory: ```sh -export PATH="$PWD/nshell-v0.6.3-x86_64-linux/bin:$PATH" +export PATH="$PWD/nshell-v0.6.4-x86_64-linux/bin:$PATH" ``` This changes only the current terminal session. Alternatively, replace @@ -95,14 +95,14 @@ nshell -c 'string upper hello' ### Run a script ```sh -curl --fail --location --output greet.nsh https://raw.githubusercontent.com/nerima-lisp/nshell/v0.6.3/examples/greet.nsh +curl --fail --location --output greet.nsh https://raw.githubusercontent.com/nerima-lisp/nshell/v0.6.4/examples/greet.nsh nshell greet.nsh World ``` Script files support multiline blocks (functions, `if`/`for`/`while`/`switch`), comments, and a `#!` shebang; arguments after the script name are available as `$argv`. See -[`examples/`](https://github.com/nerima-lisp/nshell/tree/v0.6.3/examples) for a +[`examples/`](https://github.com/nerima-lisp/nshell/tree/v0.6.4/examples) for a runnable sample. ### Command line @@ -131,7 +131,7 @@ The Nix build supplies [SBCL](http://www.sbcl.org/), ASDF, and the dependencies. On `x86_64-linux`, with Git and flake-enabled Nix installed: ```sh -git clone --branch v0.6.3 https://github.com/nerima-lisp/nshell +git clone --branch v0.6.4 https://github.com/nerima-lisp/nshell cd nshell nix build ./result/bin/nshell diff --git a/docs/src/guide/recipes.md b/docs/src/guide/recipes.md index b95b123..8630d3b 100644 --- a/docs/src/guide/recipes.md +++ b/docs/src/guide/recipes.md @@ -2,8 +2,8 @@ ## Draw a pipeline without running it -`pipeline-graph` renders a typed pipeline as a validated Graphviz DOT graph — or -a Mermaid flowchart with `--mermaid` — without executing it. Quote the pipeline +`pipeline-graph` renders a pipeline as a Graphviz DOT graph, or a Mermaid +flowchart with `--mermaid`, without executing it. Quote the pipeline so the shell passes it as a single argument rather than running it: ```sh @@ -11,10 +11,7 @@ pipeline-graph 'cat access.log | grep 404 | wc -l' pipeline-graph --mermaid 'cat access.log | grep 404 | wc -l' ``` -It is a diagnostic built on the -[cl-dataflow-kit](https://github.com/nerima-lisp/cl-dataflow-kit) computation-graph -toolkit, and it validates the pipeline as it builds the graph, so a malformed -redirect shows up as an error rather than a picture. +Malformed redirects are reported as errors instead of rendered as graphs. ## Write a script @@ -27,13 +24,17 @@ end greet $argv[1] ``` -Run it with `nshell greet.nsh World`. Multiline blocks (`function`, `if`, `for`, +Save this as `greet.nsh` and run `nshell greet.nsh World`. Multiline blocks (`function`, `if`, `for`, `while`, `switch`, `begin`/`end`), comments, and a `#!` shebang all work; arguments after the script name arrive as `$argv`. ## Use the assistant -Start an interactive assistant task from a terminal: +The assistant requires an authenticated `claude` CLI on PATH. Set +`NSHELL_AI_COMMAND` to use another executable implementing the same streaming +protocol. `NSHELL_AI_DISABLE` disables the assistant. + +Start an interactive assistant task from an nshell terminal: ```sh agent "inspect the current job state" @@ -45,22 +46,46 @@ commands are not executed. Approval applies to one proposal, not the rest of the task. Review each command, especially in a directory containing sensitive data. -The assistant refuses to start if its sidecar reports tools or MCP servers -outside the permitted initialization contract, even if a later initialization -event reports an acceptable configuration. +The safety label comes from static command analysis, not a security sandbox. +For example, recursive deletion of the filesystem root is blocked. A safe label +does not replace your review of the command, its arguments, and its working +directory. + +Before command approval, the assistant receives your task and context: the last +command, its exit status and duration, the working directory, Git branch and +dirty state, a bounded tail of command output, and environment-variable names. +Known sensitive paths, commands, and environment values are redacted, but this +does not guarantee that task text or command output contains no secrets. Do not +submit confidential information unless the configured provider is permitted to +receive it. + +At the review panel, press `e` to edit, `s` to skip the proposal, or `q` to cancel +the task. While editing, `q` and `s` insert text; Ctrl-C cancels the task. Enter +reclassifies the edited command and approves it for execution if it is not +blocked. + +The sidecar may report only the `StructuredOutput` tool (or no tools) and no MCP +servers. Both fields must be present in its initialization event. Other +configurations are rejected even if a later event reports an acceptable one. Inspect assistant state and configure the session with `ai`: ```sh ai status ai set effort medium -ai set model 'model-name' ai set max-steps 20 -ai set budget 1.50 ai log 10 -ai reset ``` +`ai log 10` shows the last ten audit entries. `ai reset` resets the conversation +and usage counters. `ai set model 'model-name'` selects a model identifier +supported by your CLI; without a setting, the CLI chooses its default model. +The default maximum is ten proposals per task. Skipped and blocked proposals +also count toward `max-steps`; editing a proposal does not add a step. + +`ai set budget 1.50` sets a US-dollar reference amount for the usage display. +It does **not** enforce a spending limit or stop provider requests. + `ai set` changes the current session. The corresponding environment variables are `NSHELL_AI_EFFORT`, `NSHELL_AI_MODEL`, `NSHELL_AI_MAX_STEPS`, and `NSHELL_AI_BUDGET_USD`. @@ -85,7 +110,7 @@ perl -e '$SIG{ALRM}=sub { exit 124 }; alarm 300; exec @ARGV' nix run .#test perl -e '$SIG{ALRM}=sub { exit 124 }; alarm 300; exec @ARGV' sbcl --script run-tests.lisp ``` -Just the focused completion suite (`nshell/weave`): +Just the focused completion suite (`nshell/weave`), inside `nix develop`: ```sh perl -e '$SIG{ALRM}=sub { exit 124 }; alarm 300; exec @ARGV' sbcl --script scripts/weave.lisp diff --git a/docs/src/project/releasing.md b/docs/src/project/releasing.md index 4ab0d2b..03e1aaa 100644 --- a/docs/src/project/releasing.md +++ b/docs/src/project/releasing.md @@ -52,7 +52,7 @@ tools on the Linux verification host. manifest, store-reference hygiene, platform library closure, and smoke startup. Do not replace this with a check of the unbundled default package. - `./result/bin/nshell --version` reports the intended version. -- `./result/bin/nshell --help` and `man ./man/nshell.1` match the documentation +- `./result/bin/nshell --help` and `man ./result/share/man/man1/nshell.1` match the documentation and shipped behaviour. - Package the built bundle using GNU tar and gzip, as the release workflow does. Replace `vX.Y.Z` with the intended tag: @@ -80,8 +80,9 @@ tools on the Linux verification host. perl scripts/test-release-pty.pl "$extracted/$name" ``` - The PTY check exercises editing, Ctrl-C, Ctrl-Z/`bg`/`fg`, history across - restart, and terminal restoration. A source-loaded suite is not a substitute. + The PTY check exercises multiline editing and paste, completion with spaces, + Ctrl-C, Ctrl-Z/`bg`/`fg`, resize during editing, history across restart, and + terminal restoration. A source-loaded suite is not a substitute. - Release notes are drafted. Review changes since the previous tag and describe user-visible features, fixes, and any required migration. After the workflow goes green, paste them in and publish the draft: diff --git a/man/nshell.1 b/man/nshell.1 index f8fb745..448459c 100644 --- a/man/nshell.1 +++ b/man/nshell.1 @@ -1,4 +1,4 @@ -.TH NSHELL 1 "2026-09-30" "nshell 0.6.3" "User Commands" +.TH NSHELL 1 "2026-09-30" "nshell 0.6.4" "User Commands" .SH NAME nshell \- a fish-inspired interactive shell written in Common Lisp .SH SYNOPSIS diff --git a/nshell.asd b/nshell.asd index e6da1e2..da68fe9 100644 --- a/nshell.asd +++ b/nshell.asd @@ -19,7 +19,7 @@ :license "MIT" ;; Single source of truth for the version. flake.nix reads this form ;; line-by-line, and release.yml refuses a tag that disagrees with it. - :version "0.6.3" + :version "0.6.4" :homepage "https://github.com/nerima-lisp/nshell" :bug-tracker "https://github.com/nerima-lisp/nshell/issues" :source-control (:git "https://github.com/nerima-lisp/nshell.git") @@ -403,7 +403,7 @@ :in-order-to ((test-op (test-op "nshell/test")))) (asdf:defsystem "nshell/test" - :version "0.6.3" + :version "0.6.4" :author "takeokunn " :maintainer "takeokunn " :license "MIT" @@ -590,7 +590,7 @@ (error "cl-weave tests failed")))) (asdf:defsystem "nshell/weave" - :version "0.6.3" + :version "0.6.4" :author "takeokunn " :maintainer "takeokunn " :license "MIT" @@ -620,7 +620,7 @@ primary suite in nshell/test." :reporter :spec) (error "cl-weave suite failed")))) (asdf:defsystem "nshell/benchmark" - :version "0.6.3" + :version "0.6.4" :author "takeokunn " :maintainer "takeokunn " :license "MIT" diff --git a/scripts/test-release-pty.pl b/scripts/test-release-pty.pl index c75d70a..ca05185 100644 --- a/scripts/test-release-pty.pl +++ b/scripts/test-release-pty.pl @@ -188,7 +188,7 @@ expected = ('rel-%s:<%s>' % (label, value)).encode() start = len(self.output) self.send(command) - match, end = self.expect(re.escape(expected), start, command) + match, end = self.expect(re.escape(expected).replace(re.escape(b'\n'), rb'\r?\n'), start, command) self.expect(re.escape(prompt), end) return command @@ -335,6 +335,73 @@ session.wait(lambda: session.foreground() == pid) interrupt_job(pid) check(stage) + stage = 'multiline cross-line editing' + label = 'multiline-' + nonce + line = ("printf 'rel-%%s:<%%s>\\n' %s 'Lx\nR'" % label).encode() + command = b'\x1b[200~' + line + b'\x1b[201~' + b'\x1b[D' * 3 + b'\x08' + b'\x1b[C' * 3 + b'\n' + start = len(session.output) + session.send(command) + _, end = session.expect(re.escape(('rel-%s:', start, command) + session.expect(re.escape(prompt), end) + check(stage) + + stage = 'quoted multiline paste' + quoted = 'a "b" \\ c\nd' + label = 'paste-' + nonce + line = ("printf 'rel-%%s:<%%s>\\n' %s '%s'" % (label, quoted)).encode() + command = b'\x1b[200~' + line + b'\x1b[201~\n' + start = len(session.output) + session.send(command) + expected = re.escape(('rel-%s:<%s>' % (label, quoted)).encode()).replace(re.escape(b'\n'), rb'\r?\n') + _, end = session.expect(expected, start, command) + session.expect(re.escape(prompt), end) + check(stage) + + stage = 'Tab completion spaced filename' + filename = 'complete-' + nonce + ' space.txt' + expected = ('rel-complete-%s:' % nonce).encode() + with open(os.path.join(isolation, filename), 'xb') as completed_file: + completed_file.write(expected + b'\n') + command = ('cat ./complete-' + nonce).encode() + b'\t\n' + start = len(session.output) + session.send(command) + _, end = session.expect(re.escape(expected), start, command) + session.expect(re.escape(prompt), end) + check(stage) + + stage = 'Ctrl-C multiline pending input' + label = 'cancel-' + nonce + pending = ("printf 'rel-%%s:<%%s>\\n' %s 'cancel-first\ncancel-second" % label).encode() + start = len(session.output) + session.send(pending) + session.expect(ansi.join(re.escape(bytes([byte])) for byte in b'cancel-second'), start) + start = len(session.output) + session.send(b'\x03') + session.expect(re.escape(prompt), start) + session.marker('multiline-recovered', 'yes') + if ('rel-%s:<' % label).encode() in session.output[start:]: + raise RuntimeError('cancelled multiline input executed') + if session.foreground() != session.pid: + raise RuntimeError('shell did not retain foreground after multiline interrupt') + check(stage) + + stage = 'resize pending wrapped editing' + label = 'resize-' + nonce + pending = ("printf 'rel-%%s:<%%s>\\n' %s okx" % label).encode() + start = len(session.output) + session.send(pending) + session.expect(ansi.join(re.escape(bytes([byte])) for byte in b'okx'), start) + size = fcntl.ioctl(session.master, termios.TIOCGWINSZ, b'\0' * 8) + try: + fcntl.ioctl(session.master, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 20, 0, 0)) + command = b'\x08\n' + session.send(command) + _, end = session.expect(re.escape(('rel-%s:' % label).encode()), start, pending + command) + session.expect(re.escape(prompt), end) + finally: + fcntl.ioctl(session.master, termios.TIOCSWINSZ, size) + check(stage) + stage = 'interactive termios restoration' session.finish() check(stage) @@ -343,7 +410,7 @@ writer = Session(history=True) writer.expect(re.escape(prompt), 0) writer.marker('history-older', 'older') - writer.marker('history-newest', 'newest') + writer.marker('history-newest', quoted) writer.finish() reader = Session(history=True) reader.expect(re.escape(prompt), 0) @@ -351,7 +418,8 @@ raise RuntimeError('history restart did not start a different process') start = len(reader.output) reader.send(b'\x1b[A\n') - _, end = reader.expect(re.escape(('rel-history-newest-%s:' % nonce).encode()), start, b'\x1b[A\n') + expected = re.escape(('rel-history-newest-%s:<%s>' % (nonce, quoted)).encode()).replace(re.escape(b'\n'), rb'\r?\n') + _, end = reader.expect(expected, start, b'\x1b[A\n') reader.expect(re.escape(prompt), end) reader.finish() check(stage) @@ -479,10 +547,10 @@ for error in cleanup_errors: print('cleanup: ' + error, file=sys.stderr) sys.exit(1) -if len(passed) != 6 or len(sessions) != 3 or any(s.status != 0 for s in sessions): +if len(passed) != 11 or len(sessions) != 3 or any(s.status != 0 for s in sessions): print('FAIL: incomplete PTY execution', file=sys.stderr) sys.exit(1) -print('PASS: 6 PTY checks; 3 executable sessions exited zero; owned processes cleaned', flush=True) +print('PASS: 11 PTY checks; 3 executable sessions exited zero; owned processes cleaned', flush=True) PYTHON my $pid = fork // die "cannot start PTY backend: $!\n"; diff --git a/src/presentation/repl-agent.lisp b/src/presentation/repl-agent.lisp index 91efed7..26534ee 100644 --- a/src/presentation/repl-agent.lisp +++ b/src/presentation/repl-agent.lisp @@ -129,7 +129,7 @@ :buffer (nshell.application:agent-step-text step))) (render-prompt-cont) (render-transient-panel - (list "agent: 編集中 · Enter 実行 · q 中止")) + (list "agent: 編集中 · Enter 実行 · Ctrl-C 中止")) (lambda () (read-key-cont)))) (defun %agent-apply-edited-input () @@ -144,23 +144,14 @@ (%render-agent-step-panel)))) (defun %agent-editing-event (event) - (let ((type (nshell.domain.input:key-event-type event))) - (cond - ((eq type :ctrl-c) - (%finish-agent-session "canceled")) - ((and (eq type :char) - (char= (nshell.domain.input:key-event-char event) #\q)) - (%finish-agent-session "canceled")) - ((and (eq type :char) - (char= (nshell.domain.input:key-event-char event) #\s)) - (%agent-skip-current-step)) - (t - (multiple-value-bind (new-state output-event) - (reduce-input-state *input-state* event) - (setf *input-state* new-state) - (if (eq output-event :execute) - (%agent-apply-edited-input) - (process-output-event output-event))))))) + (if (eq (nshell.domain.input:key-event-type event) :ctrl-c) + (%finish-agent-session "canceled") + (multiple-value-bind (new-state output-event) + (reduce-input-state *input-state* event) + (setf *input-state* new-state) + (if (eq output-event :execute) + (%agent-apply-edited-input) + (process-output-event output-event))))) (defun %process-agent-panel-event (event) (let* ((session *agent-session*) diff --git a/src/presentation/repl-completion-seed.lisp b/src/presentation/repl-completion-seed.lisp index 9739f6c..8c0d48b 100644 --- a/src/presentation/repl-completion-seed.lisp +++ b/src/presentation/repl-completion-seed.lisp @@ -1,17 +1,8 @@ ;;; REPL completion seed data (in-package #:nshell.presentation) -;;; Git-aware dynamic completion (NSHELL.DOMAIN.COMPLETION:*GIT-BRANCH-LISTER* -;;; and *GIT-MODIFIED-PATH-LISTER*, declared in domain/completion/dynamic- -;;; sources.lisp) is a process-I/O seam the domain never touches directly. -;;; This installs the real git-backed implementation, the same composition -;;; pattern src/presentation/repl-session-init.lisp already uses for -;;; NSHELL.DOMAIN.PROMPTING:*GIT-STATUS-RESOLVER*. Installing it here is -;;; inert for every existing caller: NSHELL.DOMAIN.COMPLETION:COMPLETE only -;;; consults these hooks when its :DIRECTORY argument is supplied, which no -;;; current call site does yet (see this agent's completion report for the -;;; one-line change autosuggest.lisp and repl-output-completion-help.lisp -;;; need to wire :directory, :variable-names, and :home-directory live). +;;; Keep process I/O outside the domain by supplying Git-backed hooks +;;; for completion requests that carry the current directory. (defun %seed-git-branch-lister (directory) (multiple-value-bind (output exit-code) diff --git a/t/scripts/test-release-pty.pl b/t/scripts/test-release-pty.pl index 13fff35..29a2b2c 100644 --- a/t/scripts/test-release-pty.pl +++ b/t/scripts/test-release-pty.pl @@ -50,10 +50,72 @@ sub fixture { HISTFILE=$HOME/.nshell_history HISTSIZE=100 HISTFILESIZE=100 +shopt -s cmdhist lithist +# Bash 3.2 lacks bracketed paste: quote literal LF into the real Readline buffer. +paste_begin() { bind '"\C-j":"\C-v\C-j"'; } +paste_end() { bind '"\C-j":accept-line'; } +bind -x '"\C-xp":paste_begin' +bind -x '"\C-xq":paste_end' +bind '"\e[200~":"\C-xp"' +bind '"\e[201~":"\C-xq"' if [ "$FIXTURE_HISTORY" = off ]; then unset HISTFILE; fi BASH + $settings .= "bind '\"\\e[D\":forward-char'\n" if $name eq 'bad-multiline-edit'; + if ($name eq 'bad-quoted-paste') { + $settings .= <<'BASH'; +fixture_pastes=0 +paste_begin() { + bind '"\C-j":"\C-v\C-j"' + fixture_pastes=$((fixture_pastes + 1)) + if [ "$fixture_pastes" = 2 ]; then bind '"\\":forward-char'; fi +} +BASH + } + $settings .= "bind '\"\\C-i\":self-insert'\n" if $name eq 'bad-completion'; + if ($name eq 'bad-resize') { + $settings .= <<'BASH'; +trap 'bind '\''"\C-h":forward-char'\''' WINCH +BASH + } + if ($name eq 'ignored-multiline') { + $settings .= <<'BASH'; +printf() { + builtin printf "$@" + case "$2" in paste-*) trap '' INT ;; esac +} +BASH + } $settings .= "bind '\"\\C-h\":forward-char'\n" if $name eq 'bad-edit'; $settings .= "unset HISTFILE\n" if $name eq 'no-history'; + # Bash 3.2 splits lithist entries on reload; preserve real entries with NUL framing. + $settings .= <<'BASH'; +fixture_save_history() { + local latest entry index + read -r latest entry < <(history 1) + : > "$fixture_history_file" + for ((index=1; index<=latest; index++)); do + entry=$(history -p "!$index") || return + builtin printf '%s\0' "$entry" >> "$fixture_history_file" + done +} +if [ -n "$HISTFILE" ]; then + fixture_history_file=$HISTFILE + unset HISTFILE + if [ -f "$fixture_history_file" ]; then + while IFS= read -r -d '' entry; do + history -s "$entry" + done < "$fixture_history_file" + fi + trap fixture_save_history EXIT +fi +BASH + if ($name eq 'bad-quoted-history') { + $settings .= <<'BASH'; +if [ "$FIXTURE_HISTORY" = on ]; then + trap 'fixture_save_history; /usr/bin/tr "\\134" "/" < "$fixture_history_file" > "$fixture_history_file.bad"; /bin/mv "$fixture_history_file.bad" "$fixture_history_file"' EXIT +fi +BASH + } $settings .= "trap '' INT\n" if $name eq 'ignored-pending'; if ($name eq 'ansi-pending') { # Consume the real pending input without rendering it; only emit ANSI color 91. @@ -74,12 +136,15 @@ sub fixture { $settings .= "PROMPT_COMMAND='status=\$?; set +m'\n" if $name eq 'no-job-control'; $settings .= "jobs() { printf 'broken job table\\n'; }\n" if $name eq 'bad-jobs'; make_path("$bundle/tools"); - my $sleep_program = '#!' . $^X . "\nuse strict; use warnings;\n"; - $sleep_program .= 'open my $log, ">>", ' . perl_literal($job_trace) . " or die \$!;\n"; - $sleep_program .= 'print {$log} "$$\n"; close $log;' . "\n"; - $sleep_program .= "\$SIG{INT} = 'IGNORE';\n" if $name eq 'ignored-foreground'; - $sleep_program .= 'exec "/bin/sleep", @ARGV; die $!;' . "\n"; - write_file("$bundle/tools/sleep", $sleep_program, 0755); + my $sh_program = '#!' . $^X . "\nuse strict; use warnings;\n"; + # Record only foreground jobs, before their marker can release the runner. + $sh_program .= 'if (@ARGV == 2 && $ARGV[0] eq "-c" && $ARGV[1] =~ m{ > /dev/tty; exec sleep 30\z}) {' . "\n"; + $sh_program .= "\$SIG{INT} = 'IGNORE';\n" if $name eq 'ignored-foreground'; + $sh_program .= 'open my $log, ">>", ' . perl_literal($job_trace) . " or die \$!;\n"; + $sh_program .= 'print {$log} "$$\n" or die $!; close $log or die $!;' . "\n}\n"; + $sh_program .= 's{> /dev/tty}{> /dev/null}g for @ARGV;' . "\n" if $name eq 'missing-job-marker'; + $sh_program .= 'exec "/bin/sh", @ARGV; die $!;' . "\n"; + write_file("$bundle/tools/sh", $sh_program, 0755); if ($name eq 'watchdog') { # Independent wedged backend: real descendants, no cooperative TERM handler. my $wedged = '#!' . $^X . "\nuse strict; use warnings;\n"; @@ -90,11 +155,6 @@ sub fixture { write_file("$bundle/tools/python3", $wedged, 0755); } $settings .= 'PATH=' . perl_literal("$bundle/tools:/usr/bin:/bin") . "\n"; - if ($name eq 'missing-job-marker') { - my $sh_program = '#!' . $^X . "\nuse strict; use warnings;\n"; - $sh_program .= 's{> /dev/tty}{> /dev/null}g for @ARGV; exec "/bin/sh", @ARGV; die $!;' . "\n"; - write_file("$bundle/tools/sh", $sh_program, 0755); - } write_file($rc, $settings, 0600); my $program = '#!' . $^X . "\nuse strict; use warnings;\n"; $program .= 'open my $log, ">>", ' . perl_literal($trace) . " or die \$!;\n"; @@ -188,7 +248,7 @@ sub run_fixture { my ($status, $text, $records, $elapsed, $jobs) = run_fixture('control', 20); is($status, 0, 'independent Bash control passes real PTY checks'); diag($text) if $status; -like($text, qr/^PASS: 6 PTY checks; 3 executable sessions exited zero; owned processes cleaned$/m, +like($text, qr/^PASS: 11 PTY checks; 3 executable sessions exited zero; owned processes cleaned$/m, 'nonzero assertion and session completion counts'); like($text, qr/^CLEANUP: owned processes absent; PTYs closed$/m, 'control positively confirms cleanup completion'); @@ -208,6 +268,12 @@ sub run_fixture { ['missing-job-marker', qr/FAIL: Ctrl-C foreground:/], ['no-job-control', qr/FAIL: Ctrl-C foreground: foreground job lacks an independent real PGID/], ['bad-jobs', qr/FAIL: Ctrl-Z\/bg\/fg real PGID:/], + ['bad-multiline-edit', qr/FAIL: multiline cross-line editing:/], + ['bad-quoted-paste', qr/FAIL: quoted multiline paste:/], + ['bad-completion', qr/FAIL: Tab completion spaced filename:/], + ['ignored-multiline', qr/FAIL: Ctrl-C multiline pending input:/], + ['bad-resize', qr/FAIL: resize pending wrapped editing:/], + ['bad-quoted-history', qr/FAIL: history restart isolated HOME:/], ['bad-termios', qr/FAIL: interactive termios restoration: interactive termios was not restored \(oflag: \d+ -> \d+\)/], ['no-history', qr/FAIL: history restart isolated HOME:/], ['hang', qr/FAIL: startup: .*deadline/], diff --git a/t/unit/test-agent-mode.lisp b/t/unit/test-agent-mode.lisp index 76af44c..743415a 100644 --- a/t/unit/test-agent-mode.lisp +++ b/t/unit/test-agent-mode.lisp @@ -173,6 +173,65 @@ (nshell.presentation:input-state-buffer nshell.presentation::*input-state*)))))) + (it "accepts-q-and-s-as-text-while-editing" + (dolist (character '(#\q #\s)) + (with-repl-test-state + (let ((session (nshell.application:make-agent-session "list files" :max-steps 1)) + (dispatches 0)) + (nshell.application:agent-session-propose-step session "ls") + (setf nshell.presentation::*agent-session* session) + (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) + (with-temporary-functions + (('nshell.application::execute-command-node-in-context + (lambda (context node) + (declare (ignore context node)) + (incf dispatches) + (values "" 0))) + ('nshell.presentation::render-prompt-cont (lambda () nil)) + ('nshell.presentation::render-transient-panel + (lambda (&rest arguments) (declare (ignore arguments)) nil)) + ('nshell.presentation::process-output-event + (lambda (event) (declare (ignore event)) nil))) + (nshell.presentation::%process-agent-panel-event (input-key-event :char #\e)) + (nshell.presentation::%process-agent-panel-event (input-key-event :char character)) + (expect :editing :to-equal (nshell.application:agent-session-state session)) + (expect session :to-be nshell.presentation::*agent-session*) + (expect (concatenate 'string "ls" (string character)) + :to-equal (nshell.presentation:input-state-buffer + nshell.presentation::*input-state*)) + (expect :awaiting-approval :to-equal + (nshell.application:agent-step-status + (nshell.application:agent-session-current-step session))) + (expect 0 :to-equal dispatches) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*))))))) + + (it "cancels-editing-with-control-c-without-executing" + (with-repl-test-state + (let ((session (nshell.application:make-agent-session "list files" :max-steps 1)) + (dispatches 0)) + (let ((step (nshell.application:agent-session-propose-step session "ls"))) + (setf nshell.presentation::*agent-session* session) + (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) + (with-temporary-functions + (('nshell.application::execute-command-node-in-context + (lambda (context node) + (declare (ignore context node)) + (incf dispatches) + (values "" 0))) + ('nshell.presentation::render-prompt-cont (lambda () nil)) + ('nshell.presentation::render-transient-panel + (lambda (&rest arguments) (declare (ignore arguments)) nil))) + (nshell.presentation::%process-agent-panel-event (input-key-event :char #\e)) + (nshell.presentation::%process-agent-panel-event (input-key-event :ctrl-c)) + (expect :stopped :to-equal (nshell.application:agent-session-state session)) + (expect "canceled" :to-equal (nshell.application:agent-session-stop-reason session)) + (expect nil :to-be nshell.presentation::*agent-session*) + (expect "" :to-equal (nshell.presentation:input-state-buffer + nshell.presentation::*input-state*)) + (expect :awaiting-approval :to-equal (nshell.application:agent-step-status step)) + (expect 0 :to-equal dispatches) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*))))))) + (it "does-not-mint-approval-in-the-step-executor" (with-repl-test-state (let ((session (nshell.application:make-agent-session "list files" :max-steps 1))