diff --git a/CHANGELOG.md b/CHANGELOG.md index ed711b2..bc9df13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 +## Unreleased + +### English + +#### Added +- **`nebula ctl orchestrate`** — a strongly typed single-request workflow: a model submits its intent once and the Runtime performs the deterministic control steps locally, bounded by a step count, a byte ceiling and a readiness timeout, with per-workflow `stop`/`continue` error handling. + +#### Fixed +- **The checked-in shell completions match the CLI again** — `ctl orchestrate`, `--gpui` and `--legacy-shell` were missing from the generated bash/fish/zsh scripts. + +#### Improved +- **The WSL file tree builds from one `find` call** — multiple starting points are batched into a single guest round trip instead of one subprocess per root. + +### 简体中文 + +#### 新增 +- **`nebula ctl orchestrate`** — 强类型单请求编排:模型只提交一次意图,Runtime 在本地完成确定性控制步骤,受步数、字节上限与就绪超时三重约束,并支持按工作流选择 `stop`/`continue` 的错误处理。 + +#### 修复 +- **签入的 shell 补全脚本与 CLI 重新同步** — 生成的 bash/fish/zsh 脚本里缺了 `ctl orchestrate`、`--gpui` 与 `--legacy-shell`。 + +#### 改进 +- **WSL 文件树只发一次 `find`** — 多个起点合并成一次来宾往返,不再每个根目录起一个子进程。 + ## 1.1.0 - 2026-08-18 ### English diff --git a/extra/completions/_nebula b/extra/completions/_nebula index 74fa6ef..907125d 100644 --- a/extra/completions/_nebula +++ b/extra/completions/_nebula @@ -27,12 +27,14 @@ _nebula() { '*--option=[Override configuration file options \[example\: '\''cursor.style="Beam"'\''\]]:OPTION:_default' \ '--print-events[Print all events to STDOUT]' \ '(--daemon)--ref-test[Generates ref test]' \ +'(--legacy-shell)--gpui[Launch the GPUI UI shell as the main window]' \ +'--legacy-shell[Launch the legacy winit shell instead of GPUI]' \ '(-v)*-q[Reduces the level of verbosity (the min level is -qq)]' \ '(-q)*-v[Increases the level of verbosity (the max level is -vvv)]' \ '--daemon[Do not spawn an initial window]' \ '--hold[Remain open after child process exit]' \ -'-h[Print help]' \ -'--help[Print help]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ '-V[Print version]' \ '--version[Print version]' \ ":: :_nebula_commands" \ @@ -76,6 +78,16 @@ _arguments "${_arguments_options[@]}" : \ '--help[Print help]' \ && ret=0 ;; +(orchestrate) +_arguments "${_arguments_options[@]}" : \ +'(--file)--spec=[Inline UTF-8 JSON object containing steps and on_error]:SPEC:_default' \ +'(--spec)--file=[Read the UTF-8 workflow JSON object from a file]:FILE:_files' \ +'--timeout-ms=[Maximum time to wait for a command response]:TIMEOUT_MS:_default' \ +'--pretty[Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; (agents) _arguments "${_arguments_options[@]}" : \ '--window=[]:WINDOW:_default' \ @@ -206,6 +218,7 @@ _arguments "${_arguments_options[@]}" : \ (split) _arguments "${_arguments_options[@]}" : \ '--window=[]:WINDOW:_default' \ +'--pane=[Split this pane instead of the currently focused pane]:PANE:_default' \ '--direction=[]:DIRECTION:((right\:"Create the new pane to the right of the focused pane" down\:"Create the new pane below the focused pane"))' \ '--timeout-ms=[Maximum time to wait for a command response]:TIMEOUT_MS:_default' \ @@ -319,6 +332,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(orchestrate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (agents) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -532,6 +549,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(orchestrate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (agents) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -664,11 +685,11 @@ esac (( $+functions[_nebula_commands] )) || _nebula_commands() { local commands; commands=( -'ctl:Control the resident Nebula runtime through the versioned local API' \ +'ctl:Agent-oriented terminal control\: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' \ 'migrate:Migrate the configuration file' \ 'config:Validate or create the Nebula configuration' \ 'notify-test:Test system notification (toast) delivery' \ -'setup-ai:Install (or --remove) the Claude Code hooks that feed Nebula'\''s real-time AI turn notifications' \ +'setup-ai:Install (or --remove) AI hooks plus the Nebula Runtime Skill for Codex and Claude Code' \ 'ssh:SSH with Nebula shell integration bootstrapped on the remote host, so tab icons / spinner / cwd track the program running over the connection (claude, vim, cargo…). All arguments are forwarded to the system \`ssh\`' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -722,6 +743,7 @@ _nebula__ctl_commands() { local commands; commands=( 'describe:Describe the protocol version, runtime version, and available capabilities' \ 'snapshot:Read the authoritative window, tab, pane, and task-state projection' \ +'orchestrate:Execute one typed multi-step terminal workflow in a single Runtime request' \ 'agents:List only panes Nebula recognizes as AI agents, with semantic state and session identity' \ 'agent-start:Start a verified AI CLI in a new terminal tab and assign a stable name' \ 'agent-fork:Create an isolated Git worktree, then start a named AI CLI in that checkout' \ @@ -794,6 +816,7 @@ _nebula__ctl__help_commands() { local commands; commands=( 'describe:Describe the protocol version, runtime version, and available capabilities' \ 'snapshot:Read the authoritative window, tab, pane, and task-state projection' \ +'orchestrate:Execute one typed multi-step terminal workflow in a single Runtime request' \ 'agents:List only panes Nebula recognizes as AI agents, with semantic state and session identity' \ 'agent-start:Start a verified AI CLI in a new terminal tab and assign a stable name' \ 'agent-fork:Create an isolated Git worktree, then start a named AI CLI in that checkout' \ @@ -876,6 +899,11 @@ _nebula__ctl__help__new-window_commands() { local commands; commands=() _describe -t commands 'nebula ctl help new-window commands' commands "$@" } +(( $+functions[_nebula__ctl__help__orchestrate_commands] )) || +_nebula__ctl__help__orchestrate_commands() { + local commands; commands=() + _describe -t commands 'nebula ctl help orchestrate commands' commands "$@" +} (( $+functions[_nebula__ctl__help__procs_commands] )) || _nebula__ctl__help__procs_commands() { local commands; commands=() @@ -931,6 +959,11 @@ _nebula__ctl__new-window_commands() { local commands; commands=() _describe -t commands 'nebula ctl new-window commands' commands "$@" } +(( $+functions[_nebula__ctl__orchestrate_commands] )) || +_nebula__ctl__orchestrate_commands() { + local commands; commands=() + _describe -t commands 'nebula ctl orchestrate commands' commands "$@" +} (( $+functions[_nebula__ctl__procs_commands] )) || _nebula__ctl__procs_commands() { local commands; commands=() @@ -979,11 +1012,11 @@ _nebula__ctl__wait_commands() { (( $+functions[_nebula__help_commands] )) || _nebula__help_commands() { local commands; commands=( -'ctl:Control the resident Nebula runtime through the versioned local API' \ +'ctl:Agent-oriented terminal control\: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' \ 'migrate:Migrate the configuration file' \ 'config:Validate or create the Nebula configuration' \ 'notify-test:Test system notification (toast) delivery' \ -'setup-ai:Install (or --remove) the Claude Code hooks that feed Nebula'\''s real-time AI turn notifications' \ +'setup-ai:Install (or --remove) AI hooks plus the Nebula Runtime Skill for Codex and Claude Code' \ 'ssh:SSH with Nebula shell integration bootstrapped on the remote host, so tab icons / spinner / cwd track the program running over the connection (claude, vim, cargo…). All arguments are forwarded to the system \`ssh\`' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -1012,6 +1045,7 @@ _nebula__help__ctl_commands() { local commands; commands=( 'describe:Describe the protocol version, runtime version, and available capabilities' \ 'snapshot:Read the authoritative window, tab, pane, and task-state projection' \ +'orchestrate:Execute one typed multi-step terminal workflow in a single Runtime request' \ 'agents:List only panes Nebula recognizes as AI agents, with semantic state and session identity' \ 'agent-start:Start a verified AI CLI in a new terminal tab and assign a stable name' \ 'agent-fork:Create an isolated Git worktree, then start a named AI CLI in that checkout' \ @@ -1088,6 +1122,11 @@ _nebula__help__ctl__new-window_commands() { local commands; commands=() _describe -t commands 'nebula help ctl new-window commands' commands "$@" } +(( $+functions[_nebula__help__ctl__orchestrate_commands] )) || +_nebula__help__ctl__orchestrate_commands() { + local commands; commands=() + _describe -t commands 'nebula help ctl orchestrate commands' commands "$@" +} (( $+functions[_nebula__help__ctl__procs_commands] )) || _nebula__help__ctl__procs_commands() { local commands; commands=() diff --git a/extra/completions/nebula.bash b/extra/completions/nebula.bash index 8211a04..1a2dd15 100644 --- a/extra/completions/nebula.bash +++ b/extra/completions/nebula.bash @@ -91,6 +91,9 @@ _nebula() { nebula__ctl,new-window) cmd="nebula__ctl__new__window" ;; + nebula__ctl,orchestrate) + cmd="nebula__ctl__orchestrate" + ;; nebula__ctl,procs) cmd="nebula__ctl__procs" ;; @@ -154,6 +157,9 @@ _nebula() { nebula__ctl__help,new-window) cmd="nebula__ctl__help__new__window" ;; + nebula__ctl__help,orchestrate) + cmd="nebula__ctl__help__orchestrate" + ;; nebula__ctl__help,procs) cmd="nebula__ctl__help__procs" ;; @@ -241,6 +247,9 @@ _nebula() { nebula__help__ctl,new-window) cmd="nebula__help__ctl__new__window" ;; + nebula__help__ctl,orchestrate) + cmd="nebula__help__ctl__orchestrate" + ;; nebula__help__ctl,procs) cmd="nebula__help__ctl__procs" ;; @@ -275,7 +284,7 @@ _nebula() { case "${cmd}" in nebula) - opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --config-file --daemon --working-directory --hold --command --title --class --option --help --version ctl migrate config notify-test setup-ai ssh help" + opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --legacy-shell --config-file --daemon --working-directory --hold --command --title --class --option --help --version ctl migrate config notify-test setup-ai ssh help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -483,7 +492,7 @@ _nebula() { return 0 ;; nebula__ctl) - opts="-h --pretty --timeout-ms --help describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" + opts="-h --pretty --timeout-ms --help describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -779,7 +788,7 @@ _nebula() { return 0 ;; nebula__ctl__help) - opts="describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" + opts="describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -960,6 +969,20 @@ _nebula() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + nebula__ctl__help__orchestrate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; nebula__ctl__help__procs) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -1126,6 +1149,43 @@ _nebula() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + nebula__ctl__orchestrate) + opts="-h --spec --file --pretty --timeout-ms --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --spec) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --file) + local oldifs + if [ -n "${IFS+x}" ]; then + oldifs="$IFS" + fi + IFS=$'\n' + COMPREPLY=($(compgen -f "${cur}")) + if [ -n "${oldifs+x}" ]; then + IFS="$oldifs" + fi + if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then + compopt -o filenames + fi + return 0 + ;; + --timeout-ms) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; nebula__ctl__procs) opts="-h --window --pane --pretty --timeout-ms --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -1299,7 +1359,7 @@ _nebula() { return 0 ;; nebula__ctl__split) - opts="-h --window --direction --pretty --timeout-ms --help" + opts="-h --window --pane --direction --pretty --timeout-ms --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1309,6 +1369,10 @@ _nebula() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --pane) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --direction) COMPREPLY=($(compgen -W "right down" -- "${cur}")) return 0 @@ -1437,7 +1501,7 @@ _nebula() { return 0 ;; nebula__help__ctl) - opts="describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait" + opts="describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1604,6 +1668,20 @@ _nebula() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + nebula__help__ctl__orchestrate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; nebula__help__ctl__procs) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/extra/completions/nebula.fish b/extra/completions/nebula.fish index d8f46ba..be611a0 100644 --- a/extra/completions/nebula.fish +++ b/extra/completions/nebula.fish @@ -1,6 +1,6 @@ # Print an optspec for argparse to handle cmd's options that are independent of any subcommand. function __fish_nebula_global_optspecs - string join \n print-events ref-test embed= config-file= q v daemon working-directory= hold e/command= T/title= class= o/option= h/help V/version + string join \n print-events ref-test embed= gpui legacy-shell config-file= q v daemon working-directory= hold e/command= T/title= class= o/option= h/help V/version end function __fish_nebula_needs_command @@ -33,49 +33,57 @@ complete -c nebula -n "__fish_nebula_needs_command" -l class -d 'Defines window complete -c nebula -n "__fish_nebula_needs_command" -s o -l option -d 'Override configuration file options [example: \'cursor.style="Beam"\']' -r complete -c nebula -n "__fish_nebula_needs_command" -l print-events -d 'Print all events to STDOUT' complete -c nebula -n "__fish_nebula_needs_command" -l ref-test -d 'Generates ref test' +complete -c nebula -n "__fish_nebula_needs_command" -l gpui -d 'Launch the GPUI UI shell as the main window' +complete -c nebula -n "__fish_nebula_needs_command" -l legacy-shell -d 'Launch the legacy winit shell instead of GPUI' complete -c nebula -n "__fish_nebula_needs_command" -s q -d 'Reduces the level of verbosity (the min level is -qq)' complete -c nebula -n "__fish_nebula_needs_command" -s v -d 'Increases the level of verbosity (the max level is -vvv)' complete -c nebula -n "__fish_nebula_needs_command" -l daemon -d 'Do not spawn an initial window' complete -c nebula -n "__fish_nebula_needs_command" -l hold -d 'Remain open after child process exit' -complete -c nebula -n "__fish_nebula_needs_command" -s h -l help -d 'Print help' +complete -c nebula -n "__fish_nebula_needs_command" -s h -l help -d 'Print help (see more with \'--help\')' complete -c nebula -n "__fish_nebula_needs_command" -s V -l version -d 'Print version' -complete -c nebula -n "__fish_nebula_needs_command" -f -a "ctl" -d 'Control the resident Nebula runtime through the versioned local API' +complete -c nebula -n "__fish_nebula_needs_command" -f -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' complete -c nebula -n "__fish_nebula_needs_command" -f -a "migrate" -d 'Migrate the configuration file' complete -c nebula -n "__fish_nebula_needs_command" -f -a "config" -d 'Validate or create the Nebula configuration' complete -c nebula -n "__fish_nebula_needs_command" -f -a "notify-test" -d 'Test system notification (toast) delivery' -complete -c nebula -n "__fish_nebula_needs_command" -f -a "setup-ai" -d 'Install (or --remove) the Claude Code hooks that feed Nebula\'s real-time AI turn notifications' +complete -c nebula -n "__fish_nebula_needs_command" -f -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Nebula Runtime Skill for Codex and Claude Code' complete -c nebula -n "__fish_nebula_needs_command" -f -a "ssh" -d 'SSH with Nebula shell integration bootstrapped on the remote host, so tab icons / spinner / cwd track the program running over the connection (claude, vim, cargo…). All arguments are forwarded to the system `ssh`' complete -c nebula -n "__fish_nebula_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -l timeout-ms -d 'Maximum time to wait for a command response' -r -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -s h -l help -d 'Print help' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "describe" -d 'Describe the protocol version, runtime version, and available capabilities' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "snapshot" -d 'Read the authoritative window, tab, pane, and task-state projection' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agents" -d 'List only panes Nebula recognizes as AI agents, with semantic state and session identity' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-start" -d 'Start a verified AI CLI in a new terminal tab and assign a stable name' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-fork" -d 'Create an isolated Git worktree, then start a named AI CLI in that checkout' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-get" -d 'Resolve one managed agent by stable id or active name' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-prompt" -d 'Send one plain-text prompt to a managed agent' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-read" -d 'Read the terminal-buffer tail owned by a managed agent' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-wait" -d 'Wait for the same managed-agent generation to reach a semantic state' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "subscribe" -d 'Stream state snapshots whenever their semantic content changes' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "new-window" -d 'Create and focus a new terminal window' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "focus" -d 'Focus a window or one of its panes' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "new-tab" -d 'Create a default-shell tab in the target window' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "split" -d 'Split the focused pane in the target window' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "prompt" -d 'Send one plain-text prompt to a pane, optionally submitting it with Enter' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "read" -d 'Read the latest logical lines from a pane\'s real terminal buffer' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "procs" -d 'List the real local process tree rooted at a pane\'s PTY shell' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "send-key" -d 'Send a restricted named control key using the pane\'s active terminal mode' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "run" -d 'Run one shell command and return its real OSC 133 exit status' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "wait" -d 'Wait until a pane reaches a semantic task state' -complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -l timeout-ms -d 'Maximum time to wait for a command response' -r +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -s h -l help -d 'Print help' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "describe" -d 'Describe the protocol version, runtime version, and available capabilities' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "snapshot" -d 'Read the authoritative window, tab, pane, and task-state projection' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "orchestrate" -d 'Execute one typed multi-step terminal workflow in a single Runtime request' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agents" -d 'List only panes Nebula recognizes as AI agents, with semantic state and session identity' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-start" -d 'Start a verified AI CLI in a new terminal tab and assign a stable name' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-fork" -d 'Create an isolated Git worktree, then start a named AI CLI in that checkout' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-get" -d 'Resolve one managed agent by stable id or active name' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-prompt" -d 'Send one plain-text prompt to a managed agent' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-read" -d 'Read the terminal-buffer tail owned by a managed agent' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "agent-wait" -d 'Wait for the same managed-agent generation to reach a semantic state' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "subscribe" -d 'Stream state snapshots whenever their semantic content changes' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "new-window" -d 'Create and focus a new terminal window' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "focus" -d 'Focus a window or one of its panes' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "new-tab" -d 'Create a default-shell tab in the target window' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "split" -d 'Split the focused pane in the target window' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "prompt" -d 'Send one plain-text prompt to a pane, optionally submitting it with Enter' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "read" -d 'Read the latest logical lines from a pane\'s real terminal buffer' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "procs" -d 'List the real local process tree rooted at a pane\'s PTY shell' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "send-key" -d 'Send a restricted named control key using the pane\'s active terminal mode' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "run" -d 'Run one shell command and return its real OSC 133 exit status' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "wait" -d 'Wait until a pane reaches a semantic task state' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-read agent-wait subscribe new-window focus new-tab split prompt read procs send-key run wait help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from describe" -l timeout-ms -d 'Maximum time to wait for a command response' -r complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from describe" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from describe" -s h -l help -d 'Print help' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from snapshot" -l timeout-ms -d 'Maximum time to wait for a command response' -r complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from snapshot" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from snapshot" -s h -l help -d 'Print help' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from orchestrate" -l spec -d 'Inline UTF-8 JSON object containing steps and on_error' -r +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from orchestrate" -l file -d 'Read the UTF-8 workflow JSON object from a file' -r -F +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from orchestrate" -l timeout-ms -d 'Maximum time to wait for a command response' -r +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from orchestrate" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from orchestrate" -s h -l help -d 'Print help' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from agents" -l window -r complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from agents" -l timeout-ms -d 'Maximum time to wait for a command response' -r complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from agents" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' @@ -149,6 +157,7 @@ complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subco complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from new-tab" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from new-tab" -s h -l help -d 'Print help' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from split" -l window -r +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from split" -l pane -d 'Split this pane instead of the currently focused pane' -r complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from split" -l direction -r -f -a "right\t'Create the new pane to the right of the focused pane' down\t'Create the new pane below the focused pane'" complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from split" -l timeout-ms -d 'Maximum time to wait for a command response' -r @@ -211,6 +220,7 @@ complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subco complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from wait" -s h -l help -d 'Print help (see more with \'--help\')' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from help" -f -a "describe" -d 'Describe the protocol version, runtime version, and available capabilities' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from help" -f -a "snapshot" -d 'Read the authoritative window, tab, pane, and task-state projection' +complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from help" -f -a "orchestrate" -d 'Execute one typed multi-step terminal workflow in a single Runtime request' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from help" -f -a "agents" -d 'List only panes Nebula recognizes as AI agents, with semantic state and session identity' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from help" -f -a "agent-start" -d 'Start a verified AI CLI in a new terminal tab and assign a stable name' complete -c nebula -n "__fish_nebula_using_subcommand ctl; and __fish_seen_subcommand_from help" -f -a "agent-fork" -d 'Create an isolated Git worktree, then start a named AI CLI in that checkout' @@ -255,15 +265,16 @@ complete -c nebula -n "__fish_nebula_using_subcommand notify-test" -s h -l help complete -c nebula -n "__fish_nebula_using_subcommand setup-ai" -l remove -d 'Remove Nebula\'s hooks from claude\'s settings.json instead of installing them' complete -c nebula -n "__fish_nebula_using_subcommand setup-ai" -s h -l help -d 'Print help' complete -c nebula -n "__fish_nebula_using_subcommand ssh" -s h -l help -d 'Print help' -complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "ctl" -d 'Control the resident Nebula runtime through the versioned local API' +complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "migrate" -d 'Migrate the configuration file' complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "config" -d 'Validate or create the Nebula configuration' complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "notify-test" -d 'Test system notification (toast) delivery' -complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "setup-ai" -d 'Install (or --remove) the Claude Code hooks that feed Nebula\'s real-time AI turn notifications' +complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Nebula Runtime Skill for Codex and Claude Code' complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "ssh" -d 'SSH with Nebula shell integration bootstrapped on the remote host, so tab icons / spinner / cwd track the program running over the connection (claude, vim, cargo…). All arguments are forwarded to the system `ssh`' complete -c nebula -n "__fish_nebula_using_subcommand help; and not __fish_seen_subcommand_from ctl migrate config notify-test setup-ai ssh help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "describe" -d 'Describe the protocol version, runtime version, and available capabilities' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "snapshot" -d 'Read the authoritative window, tab, pane, and task-state projection' +complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "orchestrate" -d 'Execute one typed multi-step terminal workflow in a single Runtime request' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "agents" -d 'List only panes Nebula recognizes as AI agents, with semantic state and session identity' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "agent-start" -d 'Start a verified AI CLI in a new terminal tab and assign a stable name' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "agent-fork" -d 'Create an isolated Git worktree, then start a named AI CLI in that checkout' diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index 84976b1..5768b32 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -172,21 +172,33 @@ impl FileDrag { if !self.active || !over_terminal { return None; } - let mut text = self.path.display().to_string(); - // Unix permits control characters (including CR/LF) in file names. - // Sending those bytes to a PTY could execute input despite the drop - // contract explicitly requiring paste-only behaviour. - if text.chars().any(char::is_control) { - return None; - } - if text.contains(char::is_whitespace) { - text = format!("\"{text}\""); - } - text.push(' '); - Some(text.into_bytes()) + drop_text_for_path(&self.path.display().to_string()) } } +/// 一条路径落进 PTY 时该写出的字节。`None` = 这条路径不能安全地写进去。 +/// +/// 两个壳共用这一层。旧壳自己追踪按压阈值与命中([`FileDrag`]),GPUI 壳用 +/// 引擎原生的拖放,但"什么样的路径可以写、写成什么形状"必须只有一处定义: +/// 少了控制字符那道闸,一个换行就能让"粘贴路径"变成"执行命令"。 +/// +/// 参数是字符串而不是 `Path`:WSL 行要写的是**来宾**路径(`/home/x`),它在 +/// 宿主上不是一个有效的 `Path`,转一圈只会被 Windows 的路径语义改写。 +pub fn drop_text_for_path(path: &str) -> Option> { + let mut text = path.to_owned(); + // Unix permits control characters (including CR/LF) in file names. + // Sending those bytes to a PTY could execute input despite the drop + // contract explicitly requiring paste-only behaviour. + if text.chars().any(char::is_control) { + return None; + } + if text.contains(char::is_whitespace) { + text = format!("\"{text}\""); + } + text.push(' '); + Some(text.into_bytes()) +} + /// WSL 子命令预算。 /// /// 原值是 6s,注释里的假设是"抽屉要快照时 WSL 终端已经在跑,等更久说明工人卡死" @@ -2118,6 +2130,33 @@ fn bucket_wsl_find_output(output: &[u8]) -> HashMap Option> { + let dir = normalize_wsl_guest_path(guest_dir); + let args = [dir.as_str(), "-mindepth", "1", "-maxdepth", "1", "-printf", WSL_FIND_PATH_FORMAT] + .into_iter() + .map(OsString::from); + let (output, exit_ok) = run_wsl_find_lenient(distro, args)?; + if !exit_ok { + // 单起点的非零退出就是"这个目录读不到"(不存在 / 无权限),没有 + // 多起点那种"其余起点仍然有效"的余地。 + return None; + } + Some( + bucket_wsl_find_output(&output) + .remove(&dir) + .unwrap_or_default() + .into_iter() + .map(|(is_dir, name, _)| (is_dir, name)) + .collect(), + ) +} + fn build_wsl_search_index( located: &crate::shell_detect::WslCwd, index: &mut Vec, diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index ed65d36..0736827 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -214,6 +214,11 @@ pub struct NebulaPaneState { /// 改写。`cwd` 只说"在哪个目录",这个说"在哪台机器"——两者都对了补齐才 /// 补得对,见 [`crate::display::suggest_engine::SuggestEnv`]。 pub suggest_env: crate::display::suggest_engine::SuggestEnv, + /// 上一次重算发现"这个来宾/远端目录还没缓存",壳该去异步拉一次。 + /// + /// 补齐本身绝不做 IO(WSL 冷启动可达 7.5 秒),所以它只能把需求登记在 + /// 这里,由拿得到异步上下文的壳去执行、回填,下一次重算就有候选了。 + pub pending_remote_dir: Option, pub suggestion: String, pub(super) suggestion_key: String, /// Popup-style completion candidates for the current line. A non-empty list diff --git a/nebula_app/src/display/suggest_engine.rs b/nebula_app/src/display/suggest_engine.rs index 81d39f6..c9cf7e9 100644 --- a/nebula_app/src/display/suggest_engine.rs +++ b/nebula_app/src/display/suggest_engine.rs @@ -199,8 +199,9 @@ pub enum SuggestEnv { /// `wsl.exe -d <发行版> -- find`,那是子进程,冷启动可达数秒,绝不能挂在 /// 按键路径上。 Wsl { distro: String }, - /// SSH tab:文件系统只能经 SFTP 往返,命令集是远端的。 - Ssh, + /// SSH tab:文件系统只能经 SFTP 往返,命令集是远端的。`destination` 进 + /// 缓存键——同一条 `/home/kud` 在两台远端上是两个不同的目录。 + Ssh { destination: String }, } impl SuggestEnv { @@ -231,7 +232,11 @@ pub(crate) fn suggest_update( // 命令目录由后台 PowerShell 探针填充;目录从 0 变为完整集合时,即使 // 用户没有继续输入,也必须让上一帧的“无候选”缓存失效。 let command_generation = sources.commands.lock().map(|commands| commands.len()).unwrap_or(0); - let key = format!("{}\u{0}{line}\u{0}{command_generation}", state.cwd); + // 远端目录是异步拉回来的,那一刻 cwd 与行都没变——少了这个代际,拉到的 + // 条目要等用户再多打一个字符才会显形。 + let remote_generation = crate::remote_dirs::generation(); + let key = + format!("{}\u{0}{line}\u{0}{command_generation}\u{0}{remote_generation}", state.cwd); if state.completion_suppressed_line.as_deref() == Some(line.as_str()) { state.suggestion_key = key; state.suggestion.clear(); @@ -313,15 +318,22 @@ pub(crate) fn suggest_update( if token.is_empty() { return; } - // 非本机的路径补齐要走各自的通道(来宾 `find` / SFTP),都是带往返的异步 - // 来源,接线见 `suggest_paths`。这里**必须直接返回**而不是"顺手用 - // `std::fs` 试试":宿主会把 `/te` 解析成当前盘的 `\te`,补出来的是 - // `D:\temp_build` 这种当前 shell 里根本不存在的路径——比补不出来更糟。 + // 非本机的路径补齐走各自的通道(来宾 `find` / SFTP),都带往返,所以只 + // 读缓存:miss 时把目录登记给壳去异步拉(见 [`remote_path_matches`])。 + // 这里**必须**分流而不是"顺手用 `std::fs` 试试":宿主会把 `/te` 解析成 + // 当前盘的 `\te`,补出来的是 `D:\temp_build` 这种在当前 shell 里根本不 + // 存在的路径——比补不出来更糟。 if !state.suggest_env.is_this_machine() { - nebula_debug_log(format!( - "suggest_result kind=none reason=foreign_fs env={:?} token={:?}", - state.suggest_env, token - )); + let remainder = remote_path_matches(state, &line, token).into_iter().next(); + if let Some((rem, _)) = remainder { + state.suggestion = clamp_ghost(&rem); + nebula_debug_log(format!("suggest_result kind=remote_path rem={:?}", state.suggestion)); + } else { + nebula_debug_log(format!( + "suggest_result kind=none env={:?} token={:?} pending={:?}", + state.suggest_env, token, state.pending_remote_dir + )); + } return; } let absolute = token.starts_with(['/', '\\', '~']) || token.as_bytes().get(1) == Some(&b':'); // Windows drive, e.g. `D:` @@ -387,6 +399,35 @@ fn clamp_ghost(rem: &str) -> String { rem.chars().take(NEBULA_GHOST_MAX).collect() } +/// 来宾 / 远端目录里匹配当前 token 的候选,`(插入余量, 是否目录)`。 +/// +/// 只读 [`crate::remote_dirs`] 的缓存——列一个来宾目录要一次子进程往返(冷 +/// 启动实测可达 7.5 秒),挂在按键路径上整个 UI 都会卡住。缓存没有时把目录 +/// 登记到 `state.pending_remote_dir`,壳看到就去异步拉,回填后代际一变,下 +/// 一次重算就有候选了。 +/// +/// 命令位置(`gre`)不问目录:那是在补命令名,为它跑一趟 SSH 往返纯属 +/// 浪费。带了 `/` 就不同了——`./scr` 或 `/usr/bin/l` 明确是在打路径。 +fn remote_path_matches( + state: &mut NebulaPaneState, + line: &str, + token: &str, +) -> Vec<(String, bool)> { + if nebula_is_command_position(line) && !token.contains('/') { + return Vec::new(); + } + let Some(request) = crate::remote_dirs::path_request(token, &state.cwd) else { + return Vec::new(); + }; + match crate::remote_dirs::lookup(&state.suggest_env, &request.dir) { + Some(entries) => crate::remote_dirs::candidates(&request, &entries), + None => { + state.pending_remote_dir = Some(request.dir); + Vec::new() + }, + } +} + /// Elide long popup labels from the LEFT (paths keep their informative /// tail; the head the user already typed is the expendable part). pub(crate) fn elide_left(text: &str, max_chars: usize) -> String { @@ -481,9 +522,24 @@ fn suggest_collect(sources: &SuggestSources<'_>, state: &mut NebulaPaneState, li // Filesystem candidates for the final token (same gating as the ghost // path: absolute tokens work without a cwd, relative ones need one). - // 非本机一律跳过:这个进程的 `std::fs` 描述的是另一台机器,补出来的路径 - // 在当前 shell 里不存在(见 [`SuggestEnv`])。 - if !token.is_empty() && state.suggest_env.is_this_machine() { + // 非本机走 [`remote_path_matches`]:同一份缓存、同一套分流,只是这里保留 + // 多个候选而不是第一个。 + if !token.is_empty() && !state.suggest_env.is_this_machine() { + for (rem, is_dir) in remote_path_matches(state, line, token).into_iter().take(POPUP_LIMIT) { + push( + &mut items, + NebulaCompletionItem { + label: elide_left(&format!("{token}{rem}"), LABEL_MAX), + insert: rem, + kind: if is_dir { + NebulaCompletionKind::Dir + } else { + NebulaCompletionKind::File + }, + }, + ); + } + } else if !token.is_empty() && state.suggest_env.is_this_machine() { let absolute = token.starts_with(['/', '\\', '~']) || token.as_bytes().get(1) == Some(&b':'); if absolute || !state.cwd.is_empty() { @@ -615,7 +671,10 @@ mod tests { "dir\\", "本机 tab 的 std::fs 就是这个 pane 的文件系统(补到目录带尾分隔符)" ); - for env in [SuggestEnv::Wsl { distro: "Debian".to_owned() }, SuggestEnv::Ssh] { + for env in [ + SuggestEnv::Wsl { distro: "Debian".to_owned() }, + SuggestEnv::Ssh { destination: "kud@box".to_owned() }, + ] { assert_eq!( fixture.ghost(env.clone(), &cwd, "ls host-only-"), "", diff --git a/nebula_app/src/gpui_shell/file_drop.rs b/nebula_app/src/gpui_shell/file_drop.rs new file mode 100644 index 0000000..a9610a8 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_drop.rs @@ -0,0 +1,61 @@ +//! 文件树 → 终端的拖放契约。 +//! +//! 载荷类型必须住在文件树和终端**都能引用**的地方:前者在 +//! `workspace::file_tree`(`workspace.rs` 的私有子模块),后者在 +//! `terminal::view`,两边互相看不见对方的私有模块。 +//! +//! 与旧壳的分工:路径写进 PTY 的**规则**(拒绝控制字符、含空白加引号、尾随 +//! 一个空格)是共享的,见 [`crate::display::side_panel::drop_text_for_path`]; +//! 这里只负责 GPUI 这一侧的"拖的是什么、拖起来长什么样"。旧壳的 +//! [`crate::display::side_panel::FileDrag`] 还要自己追踪按压阈值、指针位置和 +//! 是否悬停在终端上——GPUI 的拖放引擎原生管这些,所以本模块的载荷是纯数据。 + +use gpui::{Context, IntoElement, ParentElement as _, Render, Styled as _, Window, div, px}; + +use crate::gpui_shell::prelude::*; + +/// 一次从文件树拖向终端的条目。 +#[derive(Clone, Debug)] +pub struct FileTreeDrag { + /// 落进 PTY 的路径**原文**。 + /// + /// WSL 行带的是来宾路径(`/home/x`)而不是那个只用作展开键的 `PathBuf`: + /// 拖一个 WSL 目录到 WSL 终端里,用户要的是能直接 `cd` 的来宾路径,宿主 + /// 的拼写在那个 shell 里根本不存在。 + pub path_text: String, + /// 跟着指针走的标签。 + pub name: String, +} + +/// 跟随指针的拖动预览。 +/// +/// GPUI 的 `on_drag` 要一个 `Entity` 而不是普通元素——预览独立于 +/// 源元素的生命周期,拖过程中源行可能已经因为树刷新而不存在了。 +pub struct FileDragGhost { + name: String, +} + +impl FileDragGhost { + pub fn new(name: String) -> Self { + Self { name } + } +} + +impl Render for FileDragGhost { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + div() + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .text_color(theme.foreground) + .text_sm() + .max_w(px(320.0)) + .overflow_hidden() + .whitespace_nowrap() + .child(self.name.clone()) + } +} diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 670f694..cc6db3a 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -13,6 +13,7 @@ pub mod code_tab; pub mod config; pub mod doc_tabs; +pub mod file_drop; pub mod http; pub mod math_view; pub mod network_settings; diff --git a/nebula_app/src/gpui_shell/prelude.rs b/nebula_app/src/gpui_shell/prelude.rs index a2b804f..b7c13c9 100644 --- a/nebula_app/src/gpui_shell/prelude.rs +++ b/nebula_app/src/gpui_shell/prelude.rs @@ -33,6 +33,12 @@ pub use gpui_component::{ pub use gpui_component::IndexPath; pub use gpui_component::WindowExt as _; +// 上面那批 trait 是 `as _` 导出的:`prelude::*` 的使用者能拿到方法,但 `as _` +// 抹掉了名字,别处再 `use prelude::ActiveTheme` 就找不到。只导入具体几项、 +// 不 glob 的模块(如 `terminal::view`)需要具名版本,这里补上——两种导出并存, +// 现有的 glob 用法不受影响。 +pub use gpui_component::{ActiveTheme, Colorize}; + use gpui::{Window, px}; use gpui_component::PixelsExt as _; diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 1f2f4b3..731dc04 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -196,8 +196,11 @@ impl Element for TerminalElement { }; let suggest_anchor = snap.cursor.as_ref().map(|cursor| (cursor.row as usize, cursor.col as usize)); - self.view.update(cx, |view, _| { + self.view.update(cx, |view, cx| { view.refresh_suggestion_from_snapshot(prompt_line, suggest_anchor); + // 补齐只会**登记**要问哪个来宾/远端目录(按键路径上不做 IO), + // 真正的往返在这里派出去。 + view.drive_pending_remote_dir(cx); }); let overrides = snap.color_overrides; let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx); diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index daa6c8d..bb63f69 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -3,11 +3,11 @@ mod runtime; use gpui::{ - App, Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, - FontStyle, FontWeight, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, - ScrollWheelEvent, SharedString, Size, StatefulInteractiveElement as _, Styled as _, TextRun, - UTF16Selection, Window, div, point, px, + App, AppContext as _, Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, + Font, FontFeatures, FontStyle, FontWeight, Hsla, InteractiveElement as _, IntoElement, + KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, + Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, StatefulInteractiveElement as _, + Styled as _, TextRun, UTF16Selection, Window, div, point, px, }; use gpui_component::{PixelsExt as _, WindowExt as _}; use nebula_terminal::event::{Event as TermEvent, Notify as _, OnResize as _, WindowSize}; @@ -30,7 +30,9 @@ use super::suggest; use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab}; use crate::config::UiConfig; use crate::gpui_shell::config::Settings; -use crate::gpui_shell::prelude::{DialogButtonProps, center_confirm_dialog}; +use crate::gpui_shell::prelude::{ + ActiveTheme as _, Colorize as _, DialogButtonProps, center_confirm_dialog, +}; use futures::StreamExt as _; @@ -527,7 +529,7 @@ impl TerminalView { Some(destination.clone()), destination.clone(), None, - crate::display::SuggestEnv::Ssh, + crate::display::SuggestEnv::Ssh { destination: destination.clone() }, session::spawn_ssh(destination, initial, term_config), ), }; @@ -1469,6 +1471,66 @@ impl TerminalView { } } + /// 补齐登记了一个还没缓存的来宾 / 远端目录时,去后台拉一次。 + /// + /// 补齐本身跑在按键路径上,绝不能做 IO——一次 `wsl.exe -- find` 冷启动实测 + /// 可达 7.5 秒,一次 SFTP 是完整的网络往返。所以它只把目录登记在 + /// `pending_remote_dir`,真正的往返在这里发生:结果进 [`crate::remote_dirs`] + /// 的进程级缓存,代际一变,下一次重算就有候选了。 + /// + /// 用户的体感是"第一次 Tab 没反应,之后都有"——而不是"每次 Tab 卡住整个 + /// 窗口"。 + pub(super) fn drive_pending_remote_dir(&mut self, cx: &mut Context) { + let Some(dir) = self.suggest.pending_remote_dir.take() else { return }; + let env = self.suggest.suggest_env.clone(); + // 连按 Tab 不该排出一串子进程 / 往返。 + if !crate::remote_dirs::begin_fetch(&env, &dir) { + return; + } + match env.clone() { + crate::display::SuggestEnv::Wsl { distro } => { + cx.spawn(async move |this, cx| { + let target = dir.clone(); + // 子进程往返是阻塞的,必须落在后台线程池上。 + let entries = cx + .background_spawn(async move { + crate::remote_dirs::fetch_wsl(&distro, &target) + }) + .await; + crate::remote_dirs::finish_fetch(&env, &dir, entries); + let _ = this.update(cx, |_, cx| cx.notify()); + }) + .detach(); + }, + crate::display::SuggestEnv::Ssh { destination } => { + // SSH 的 async 只能跑在项目自己的 tokio runtime 上(连接池和 + // 认证策略都在那儿),而这里要等的是 GPUI 的任务——用一条 + // oneshot 把两个 executor 接起来。 + let Ok(runtime) = crate::ssh_session::runtime() else { return }; + let (tx, rx) = tokio::sync::oneshot::channel(); + let target = dir.clone(); + runtime.spawn(async move { + let listed = + crate::ssh_sftp::list_dir_for_completion(&destination, &target).await; + let _ = tx.send(listed); + }); + cx.spawn(async move |this, cx| { + let entries = rx.await.ok().flatten().map(|entries| { + entries + .into_iter() + .map(|(is_dir, name)| crate::remote_dirs::RemoteEntry { name, is_dir }) + .collect() + }); + crate::remote_dirs::finish_fetch(&env, &dir, entries); + let _ = this.update(cx, |_, cx| cx.notify()); + }) + .detach(); + }, + // 本机 pane 的补齐直接读 `std::fs`,走不到这条路。 + crate::display::SuggestEnv::Local => {}, + } + } + fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { if self.exited.is_some() { return; @@ -2569,6 +2631,9 @@ impl Render for TerminalView { }); } } + // 文件树拖到终端时的落点高亮:强调色压到很低的不透明度,与 hover 的 + // 中性灰区分开——用户要能一眼看出"松手会落在这里"。 + let drop_highlight = cx.theme().accent.opacity(0.18); let mut root = div() .id("nebula-terminal") .key_context(KEY_CONTEXT) @@ -2600,6 +2665,30 @@ impl Render for TerminalView { .on_mouse_up(MouseButton::Right, cx.listener(Self::on_right_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::on_middle_up)) .on_scroll_wheel(cx.listener(Self::on_scroll)) + // 文件树拖进来 = 把路径粘进 shell(旧壳 `FileDrag` 同一合同:只 + // 粘贴,绝不代按 Enter)。`drag_over` 给一层落点高亮,否则用户拖 + // 到一半不知道松手会不会生效。 + .drag_over::(move |style, _, _, _| { + style.bg(drop_highlight) + }) + .on_drop(cx.listener( + |this, + drag: &crate::gpui_shell::file_drop::FileTreeDrag, + window: &mut Window, + cx| { + let Some(bytes) = + crate::display::side_panel::drop_text_for_path(&drag.path_text) + else { + // 含控制字符的路径写进 PTY 等于替用户执行命令。 + return; + }; + this.write_bytes(bytes); + // 粘完把焦点交回终端:用户接着就要敲命令,还要先点一下 + // 才能输入的话,这个手势就白省了。 + window.focus(&this.focus_handle); + cx.notify(); + }, + )) .on_hover(cx.listener(|this, hovered: &bool, _, cx| { if !*hovered { this.clear_link_hover(cx); diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index 4eacac6..4ccc603 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -4,10 +4,10 @@ use std::path::PathBuf; use gpui::prelude::FluentBuilder as _; use gpui::{ - ClipboardItem, Context, Corner, DismissEvent, Entity, Focusable as _, InteractiveElement as _, - IntoElement as _, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, SharedString, - StatefulInteractiveElement as _, Styled as _, Subscription, Window, anchored, deferred, div, - px, + AppContext as _, ClipboardItem, Context, Corner, DismissEvent, Entity, Focusable as _, + InteractiveElement as _, IntoElement as _, MouseButton, MouseDownEvent, ParentElement as _, + Pixels, Point, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, + Window, anchored, deferred, div, px, }; use gpui_component::menu::PopupMenuItem; @@ -56,6 +56,9 @@ impl NebulaWorkspace { let menu_path = path.clone(); let guest_path = row.guest_path.clone(); let menu_guest_path = guest_path.clone(); + let drag_guest_path = guest_path.clone(); + let drag_path = path.clone(); + let drag_name = row.name.clone(); let is_dir = row.is_dir; let is_parent = row.is_parent; let selected = selected_path.as_ref() == Some(&path); @@ -142,6 +145,22 @@ impl NebulaWorkspace { this.open_document_path(open_path.clone(), window, cx); })) }) + .when(!is_parent, |item| { + // 拖到终端 = 把路径粘进 shell(旧壳 `FileDrag` 同一合同)。 + // WSL 行拖的是**来宾**路径:宿主那份拼写只是展开用的键, + // 在来宾的 shell 里不存在。 + let drag = crate::gpui_shell::file_drop::FileTreeDrag { + path_text: drag_guest_path + .clone() + .unwrap_or_else(|| drag_path.display().to_string()), + name: drag_name.clone(), + }; + item.on_drag(drag, |drag, _offset, _window, cx| { + cx.new(|_| { + crate::gpui_shell::file_drop::FileDragGhost::new(drag.name.clone()) + }) + }) + }) .when(!is_parent, |item| { // 不在抽屉行上挂 `.context_menu()`:那会把 PopupMenu 挂回 // 行的 child,菜单仍是带投影抽屉的子孙。右键只记锚点, diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 4d44e8a..a44f725 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -69,6 +69,7 @@ mod platform; #[cfg(unix)] mod polling; mod process_tree; +mod remote_dirs; mod renderer; mod runtime_api; mod scheduler; diff --git a/nebula_app/src/remote_dirs.rs b/nebula_app/src/remote_dirs.rs new file mode 100644 index 0000000..2103f77 --- /dev/null +++ b/nebula_app/src/remote_dirs.rs @@ -0,0 +1,301 @@ +//! 非本机 pane 的目录列表:补齐要问的是**来宾 / 远端**的文件系统。 +//! +//! # 为什么要一层缓存 +//! +//! 按键路径上不做 IO。WSL 一次 `wsl.exe -- find` 冷启动实测可达 7.5 秒(见 +//! [`crate::display::side_panel`] 里 `WSL_COMMAND_TIMEOUT` 的实测记录),SSH 要 +//! 一次网络往返——这种延迟挂在每次 Tab 上是不可用的。 +//! +//! 所以分工是:[`suggest_update`](crate::display::suggest_engine::suggest_update) +//! 只**读**这里的缓存,miss 时把目录登记到 +//! `NebulaPaneState::pending_remote_dir`;壳看到这个登记去异步拉取,回填后下 +//! 一次重算就有候选了。用户的体感是"第一次 Tab 没反应,之后都有" —— +//! 而不是"每次 Tab 卡住整个 UI"。 +//! +//! 缓存是进程级的:同一个发行版开三个 tab,`/usr/bin` 只拉一次。 + +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use crate::display::SuggestEnv; + +/// 来宾 / 远端目录里的一项。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemoteEntry { + pub name: String, + pub is_dir: bool, +} + +/// 缓存寿命。 +/// +/// 目录内容当然会变(用户就在那个 shell 里 `mkdir`),但补齐的职责是"少打 +/// 字",不是"当文件系统的权威视图":拿到几十秒前的列表最坏也就是少补一项, +/// 真打错了 shell 自己会报。反过来,TTL 太短会让每次 Tab 都触发一次子进程 / +/// 网络往返,那正是这层缓存要消灭的东西。 +const TTL: Duration = Duration::from_secs(45); + +struct Cached { + entries: Vec, + fetched: Instant, +} + +#[derive(Default)] +struct Cache { + by_dir: HashMap, + /// 正在拉取的目录。连按 Tab 不该排出一串子进程。 + inflight: HashSet, +} + +static CACHE: OnceLock> = OnceLock::new(); + +/// 缓存代际:每有一份新目录落地就 +1。 +/// +/// 补齐把结果缓存在 `cwd + 行` 上,而异步拉取回来时这两者都没变——少了这个 +/// 计数,拉回来的条目要等用户再多打一个字符才会显形。与 PATH 探针填充命令 +/// 目录时用的 `command_generation` 是同一手法。 +static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// 当前缓存代际,进补齐的重算键。 +pub fn generation() -> u64 { + GENERATION.load(std::sync::atomic::Ordering::Acquire) +} + +fn cache() -> MutexGuard<'static, Cache> { + CACHE + .get_or_init(|| Mutex::new(Cache::default())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// 缓存键。环境必须进键:同一条 `/home/kud` 在 Debian 来宾和某台远端上是两 +/// 个不同的目录,混用会把一台机器的文件补进另一台的命令行。 +fn key(env: &SuggestEnv, dir: &str) -> String { + match env { + SuggestEnv::Local => format!("local\u{0}{dir}"), + SuggestEnv::Wsl { distro } => format!("wsl:{distro}\u{0}{dir}"), + SuggestEnv::Ssh { destination } => format!("ssh:{destination}\u{0}{dir}"), + } +} + +/// 已缓存且未过期的条目。 +pub fn lookup(env: &SuggestEnv, dir: &str) -> Option> { + let cache = cache(); + let cached = cache.by_dir.get(&key(env, dir))?; + (cached.fetched.elapsed() < TTL).then(|| cached.entries.clone()) +} + +/// 认领一次拉取。`false` = 已经有人在拉这个目录,调用方不要再起一个。 +pub fn begin_fetch(env: &SuggestEnv, dir: &str) -> bool { + cache().inflight.insert(key(env, dir)) +} + +/// 拉取结束。`None` = 这次失败了——只解锁不落缓存,下次还会重试;落一个空 +/// 列表会让"暂时连不上"在整个 TTL 里都表现成"这个目录是空的"。 +pub fn finish_fetch(env: &SuggestEnv, dir: &str, entries: Option>) { + let key = key(env, dir); + let mut cache = cache(); + cache.inflight.remove(&key); + if let Some(entries) = entries { + cache.by_dir.insert(key, Cached { entries, fetched: Instant::now() }); + GENERATION.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + } +} + +/// 从来宾里列一个目录。**阻塞**(子进程往返),只能在后台线程上调用。 +pub fn fetch_wsl(distro: &str, dir: &str) -> Option> { + let entries = crate::display::side_panel::wsl_list_one_dir(distro, dir)?; + Some(entries.into_iter().map(|(is_dir, name)| RemoteEntry { name, is_dir }).collect()) +} + +/// 一个补齐请求要问哪个目录、拿什么前缀去筛。 +/// +/// 与本机分支的区别在于这里只认 POSIX 语义:`\` 在 Linux 上是合法文件名字符 +/// 而不是分隔符,把它当分隔符会把 `a\ b`(转义的空格)切成两段。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathRequest { + /// 要列的绝对目录。 + pub dir: String, + /// 目录里用来筛条目的前缀。 + pub prefix: String, + /// token 里属于路径部分的原文(`src/ma` 的 `src/`),拼回候选时要带上。 + pub dir_part: String, +} + +/// 把最后一个 token 拆成"问哪个目录 + 筛什么前缀"。 +/// +/// `None` = 这个 token 问不出目录来:`~` 要来宾自己展开(宿主不知道来宾的 +/// home),空 cwd 时相对路径也无从谈起。 +pub fn path_request(token: &str, cwd: &str) -> Option { + if token.starts_with('~') { + return None; + } + let (dir_part, prefix) = match token.rfind('/') { + Some(index) => (&token[..=index], &token[index + 1..]), + None => ("", token), + }; + let dir = if dir_part.starts_with('/') { + // 绝对路径:`/usr/` → `/usr`,`/` → `/`。 + let trimmed = dir_part.trim_end_matches('/'); + if trimmed.is_empty() { "/".to_owned() } else { trimmed.to_owned() } + } else { + let cwd = cwd.trim_end_matches('/'); + if cwd.is_empty() || !cwd.starts_with('/') { + // cwd 还没上报,或它根本不是 POSIX 路径(WSL tab 在 shell 发出 + // 第一个 OSC 7 之前就是这样)。相对路径这时无解。 + return None; + } + match dir_part.trim_end_matches('/') { + "" => cwd.to_owned(), + relative => format!("{cwd}/{relative}"), + } + }; + Some(PathRequest { dir, prefix: prefix.to_owned(), dir_part: dir_part.to_owned() }) +} + +/// 目录条目 → 补齐候选的插入余量(用户已经打了 `prefix`,补的是剩下的)。 +/// +/// 返回 `(余量, 是否目录)`。目录带尾随 `/`,和本机分支一样一次补一段, +/// 让用户可以连按 Tab 往下钻。 +pub fn candidates(request: &PathRequest, entries: &[RemoteEntry]) -> Vec<(String, bool)> { + let mut out: Vec<(String, bool)> = entries + .iter() + .filter(|entry| { + // 点文件只在用户明确打了 `.` 时出现;否则一个 `ls ` 会被 `.bashrc` + // 之类淹掉。 + if entry.name.starts_with('.') && !request.prefix.starts_with('.') { + return false; + } + entry.name.len() > request.prefix.len() && entry.name.starts_with(&request.prefix) + }) + .map(|entry| { + let mut rest = entry.name[request.prefix.len()..].to_owned(); + if entry.is_dir { + rest.push('/'); + } + (rest, entry.is_dir) + }) + .collect(); + // 短的优先(离用户打的最近),同长按字典序——与 `nebula_command_hints` + // 的收敛规则一致。 + out.sort_by(|left, right| { + left.0.chars().count().cmp(&right.0.chars().count()).then_with(|| left.0.cmp(&right.0)) + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_token_without_a_slash_asks_the_working_directory() { + let request = path_request("ma", "/home/kud/src").unwrap(); + assert_eq!(request.dir, "/home/kud/src"); + assert_eq!(request.prefix, "ma"); + assert_eq!(request.dir_part, ""); + } + + #[test] + fn a_relative_token_walks_down_from_the_working_directory() { + let request = path_request("src/ma", "/home/kud").unwrap(); + assert_eq!(request.dir, "/home/kud/src"); + assert_eq!(request.prefix, "ma"); + assert_eq!(request.dir_part, "src/", "拼回候选时要带上已打的路径段"); + } + + #[test] + fn an_absolute_token_ignores_the_working_directory() { + let request = path_request("/us", "/home/kud").unwrap(); + assert_eq!(request.dir, "/", "还没打完第一段,问的是根"); + assert_eq!(request.prefix, "us"); + + let request = path_request("/usr/lo", "/home/kud").unwrap(); + assert_eq!(request.dir, "/usr"); + assert_eq!(request.prefix, "lo"); + } + + /// 宿主路径不是 POSIX 路径。WSL tab 在 shell 发出第一个 OSC 7 之前,`cwd` + /// 还是启动时那个 `D:\...`——拿它拼出 `D:\x/src` 去问来宾只会白跑一趟。 + #[test] + fn a_windows_working_directory_yields_no_request() { + assert_eq!(path_request("ma", "D:\\temp_build"), None); + assert_eq!(path_request("ma", ""), None); + // 绝对 token 不依赖 cwd,照样成立。 + assert!(path_request("/us", "D:\\temp_build").is_some()); + } + + /// `~` 只有来宾自己知道展开成什么。 + #[test] + fn a_tilde_is_left_to_the_guest() { + assert_eq!(path_request("~/pro", "/home/kud"), None); + } + + #[test] + fn candidates_complete_one_segment_and_mark_directories() { + let entries = vec![ + RemoteEntry { name: "main.rs".to_owned(), is_dir: false }, + RemoteEntry { name: "man".to_owned(), is_dir: true }, + RemoteEntry { name: "other".to_owned(), is_dir: false }, + RemoteEntry { name: ".mabhidden".to_owned(), is_dir: false }, + ]; + let request = path_request("ma", "/home/kud").unwrap(); + assert_eq!( + candidates(&request, &entries), + vec![("n/".to_owned(), true), ("in.rs".to_owned(), false)], + "目录带尾斜杠好让用户连按 Tab 往下钻;短的排前面" + ); + } + + /// 点文件默认不出现——否则一个 `ls ` 会被 `.bashrc`、`.profile` 淹掉。 + #[test] + fn dotfiles_appear_only_once_the_dot_is_typed() { + let entries = vec![ + RemoteEntry { name: ".bashrc".to_owned(), is_dir: false }, + RemoteEntry { name: "bin".to_owned(), is_dir: true }, + ]; + let plain = path_request("", "/home/kud").unwrap(); + assert_eq!(candidates(&plain, &entries), vec![("bin/".to_owned(), true)]); + + let dotted = path_request(".bash", "/home/kud").unwrap(); + assert_eq!(candidates(&dotted, &entries), vec![("rc".to_owned(), false)]); + } + + /// 环境必须进缓存键:同一条 `/home/kud` 在两台机器上是两个目录。 + #[test] + fn the_cache_key_separates_machines() { + let wsl = SuggestEnv::Wsl { distro: "Debian".to_owned() }; + let other = SuggestEnv::Wsl { distro: "Ubuntu".to_owned() }; + let ssh = SuggestEnv::Ssh { destination: "kud@box".to_owned() }; + assert_ne!(key(&wsl, "/home/kud"), key(&other, "/home/kud")); + assert_ne!(key(&wsl, "/home/kud"), key(&ssh, "/home/kud")); + assert_ne!(key(&wsl, "/home/kud"), key(&wsl, "/home/other")); + } + + /// 连按 Tab 不该排出一串子进程。 + #[test] + fn only_one_fetch_is_claimed_per_directory() { + let env = SuggestEnv::Wsl { distro: "test-inflight".to_owned() }; + assert!(begin_fetch(&env, "/tmp/a"), "第一次认领成功"); + assert!(!begin_fetch(&env, "/tmp/a"), "已经在拉了"); + assert!(begin_fetch(&env, "/tmp/b"), "另一个目录不受影响"); + + finish_fetch(&env, "/tmp/a", Some(vec![RemoteEntry { + name: "x".to_owned(), + is_dir: false, + }])); + assert!(lookup(&env, "/tmp/a").is_some(), "落了缓存"); + assert!(begin_fetch(&env, "/tmp/a"), "拉完就解锁"); + } + + /// 失败只解锁、不落缓存:落一个空列表会让"暂时连不上"在整个 TTL 里都 + /// 表现成"这个目录是空的"。 + #[test] + fn a_failed_fetch_leaves_no_cache_behind() { + let env = SuggestEnv::Wsl { distro: "test-failure".to_owned() }; + assert!(begin_fetch(&env, "/tmp/gone")); + finish_fetch(&env, "/tmp/gone", None); + assert_eq!(lookup(&env, "/tmp/gone"), None); + assert!(begin_fetch(&env, "/tmp/gone"), "下次还能重试"); + } +} diff --git a/nebula_app/src/runtime_api/orchestrate.rs b/nebula_app/src/runtime_api/orchestrate.rs new file mode 100644 index 0000000..1e04e75 --- /dev/null +++ b/nebula_app/src/runtime_api/orchestrate.rs @@ -0,0 +1,733 @@ +//! 强类型单请求编排:模型只提交一次意图,Runtime 在本地完成确定性控制步骤。 + +use std::collections::{HashMap, HashSet}; + +use super::*; + +const MAX_ORCHESTRATE_STEPS: usize = 32; +const MAX_ORCHESTRATE_BYTES: usize = 64 * 1024; +const DEFAULT_READY_TIMEOUT_MS: u64 = 10_000; + +static NEXT_WORKFLOW_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum OnError { + Stop, + Continue, +} + +impl Default for OnError { + fn default() -> Self { + Self::Stop + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OrchestrateParams { + steps: Vec, + #[serde(default)] + on_error: OnError, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum OrchestrateStep { + NewTab { + id: String, + #[serde(default)] + window_id: Option, + #[serde(default)] + cwd: Option, + }, + Focus { + id: String, + target: PaneTarget, + }, + Split { + id: String, + #[serde(default)] + window_id: Option, + #[serde(default)] + target: Option, + direction: RuntimeSplitDirection, + }, + Prompt { + id: String, + target: PaneTarget, + text: String, + #[serde(default = "default_true")] + submit: bool, + }, + Run { + id: String, + target: PaneTarget, + command: String, + #[serde(default = "default_true")] + wait: bool, + #[serde(default = "default_command_timeout_ms")] + timeout_ms: u64, + }, + AgentLaunch { + id: String, + target: PaneTarget, + name: String, + kind: String, + #[serde(default)] + resume_session_id: Option, + initial_prompt: String, + #[serde(default = "default_ready_timeout_ms")] + ready_timeout_ms: u64, + }, +} + +impl OrchestrateStep { + fn id(&self) -> &str { + match self { + Self::NewTab { id, .. } + | Self::Focus { id, .. } + | Self::Split { id, .. } + | Self::Prompt { id, .. } + | Self::Run { id, .. } + | Self::AgentLaunch { id, .. } => id, + } + } + + fn op(&self) -> &'static str { + match self { + Self::NewTab { .. } => "new_tab", + Self::Focus { .. } => "focus", + Self::Split { .. } => "split", + Self::Prompt { .. } => "prompt", + Self::Run { .. } => "run", + Self::AgentLaunch { .. } => "agent_launch", + } + } + + fn target(&self) -> Option<&PaneTarget> { + match self { + Self::Focus { target, .. } + | Self::Prompt { target, .. } + | Self::Run { target, .. } + | Self::AgentLaunch { target, .. } => Some(target), + Self::Split { target, .. } => target.as_ref(), + Self::NewTab { .. } => None, + } + } + + fn referenced_step(&self) -> Option<&str> { + match self.target() { + Some(PaneTarget::Reference(reference)) => Some(&reference.step), + Some(PaneTarget::Existing(_)) | None => None, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PaneTarget { + Reference(StepReference), + Existing(ExistingPane), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct StepReference { + step: String, + field: ReferenceField, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ReferenceField { + PaneId, +} + +impl ReferenceField { + fn as_str(self) -> &'static str { + match self { + Self::PaneId => "pane_id", + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExistingPane { + pane_id: u64, + #[serde(default)] + window_id: Option, +} + +#[derive(Debug, Serialize)] +struct WorkflowReceipt { + workflow_id: String, + ok: bool, + duration_ms: u64, + partial: bool, + completed: usize, + failed_step: Option, + steps: Vec, +} + +#[derive(Debug, Serialize)] +struct StepReceipt { + id: String, + op: &'static str, + ok: bool, + duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +struct PendingAgentLaunch { + receipt_index: usize, + step_id: String, + started_at: Instant, + deadline: Instant, + agent_id: String, + generation: u64, + initial_prompt: String, + action: Value, +} + +struct AgentFinalization { + receipt_index: usize, + step_id: String, + duration_ms: u64, + outcome: Result, +} + +pub(super) fn orchestrate_connection( + stream: &mut TcpStream, + request: ApiRequest, + sink: &EventSink, + hub: &RuntimeHub, +) -> Result<(), IoError> { + let params = match parse_and_validate(&request.params) { + Ok(params) => params, + Err(error) => return write_response(stream, &ApiResponse::failure(request.id, error)), + }; + info!( + "runtime orchestrate request_id={} steps={} on_error={:?}", + request.id, + params.steps.len(), + params.on_error + ); + let receipt = execute(params, sink, hub); + let result = serde_json::to_value(receipt).map_err(IoError::other)?; + write_response(stream, &ApiResponse::success(request.id, result)) +} + +pub(super) fn validate_params(value: &Value) -> Result<(), ApiError> { + parse_and_validate(value).map(|_| ()) +} + +#[cfg(test)] +pub(super) fn execute_for_test( + value: &Value, + sink: &EventSink, + hub: &RuntimeHub, +) -> Result { + let params = parse_and_validate(value)?; + serde_json::to_value(execute(params, sink, hub)) + .map_err(|error| ApiError::new("serialization_failed", error.to_string())) +} + +fn parse_and_validate(value: &Value) -> Result { + let encoded = serde_json::to_vec(value) + .map_err(|error| ApiError::invalid_params(format!("invalid orchestration: {error}")))?; + if encoded.len() > MAX_ORCHESTRATE_BYTES { + return Err(ApiError::invalid_params(format!( + "runtime.orchestrate params exceed the {MAX_ORCHESTRATE_BYTES}-byte limit" + ))); + } + let params: OrchestrateParams = parse_params(value)?; + if params.steps.is_empty() || params.steps.len() > MAX_ORCHESTRATE_STEPS { + return Err(ApiError::invalid_params(format!( + "steps must contain between 1 and {MAX_ORCHESTRATE_STEPS} entries" + ))); + } + + let mut seen = HashSet::new(); + for step in ¶ms.steps { + validate_step_id(step.id())?; + if seen.contains(step.id()) { + return Err(ApiError::invalid_params(format!( + "duplicate orchestration step id {:?}", + step.id() + ))); + } + if let Some(PaneTarget::Reference(reference)) = step.target() + && !seen.contains(&reference.step) + { + return Err(invalid_reference( + step.id(), + &reference.step, + "references must point to an earlier successful step", + )); + } + validate_step_contract(step)?; + seen.insert(step.id().to_owned()); + } + Ok(params) +} + +fn validate_step_id(id: &str) -> Result<(), ApiError> { + let valid = !id.is_empty() + && id.len() <= 64 + && id.bytes().enumerate().all(|(index, byte)| { + if index == 0 { + byte == b'_' || byte.is_ascii_alphabetic() + } else { + byte == b'_' || byte == b'-' || byte.is_ascii_alphanumeric() + } + }); + if valid { + Ok(()) + } else { + Err(ApiError::invalid_params( + "step id must be 1-64 ASCII letters, digits, '_' or '-', and start with a letter or '_'", + )) + } +} + +fn validate_step_contract(step: &OrchestrateStep) -> Result<(), ApiError> { + match step { + OrchestrateStep::Split { window_id: Some(_), target: Some(_), .. } => { + Err(ApiError::invalid_params( + "a split step cannot combine window_id with target; put window_id inside a direct target", + )) + }, + OrchestrateStep::Prompt { text, .. } => validate_prompt(text), + OrchestrateStep::Run { command, timeout_ms, .. } => { + validate_command_line(command)?; + validate_timeout(*timeout_ms, "run timeout_ms") + }, + OrchestrateStep::AgentLaunch { + name, + kind, + resume_session_id, + initial_prompt, + ready_timeout_ms, + .. + } => { + validate_prompt(initial_prompt)?; + validate_timeout(*ready_timeout_ms, "ready_timeout_ms")?; + agent_start_command(None, 1, name.clone(), kind.clone(), resume_session_id.clone())?; + Ok(()) + }, + OrchestrateStep::NewTab { .. } + | OrchestrateStep::Focus { .. } + | OrchestrateStep::Split { .. } => Ok(()), + } +} + +fn validate_timeout(timeout_ms: u64, label: &str) -> Result<(), ApiError> { + if timeout_ms == 0 || Duration::from_millis(timeout_ms) > MAX_WAIT { + Err(ApiError::invalid_params(format!("{label} must be between 1 and 86400000"))) + } else { + Ok(()) + } +} + +fn execute(params: OrchestrateParams, sink: &EventSink, hub: &RuntimeHub) -> WorkflowReceipt { + let workflow_started = Instant::now(); + let workflow_sequence = NEXT_WORKFLOW_ID.fetch_add(1, Ordering::Relaxed); + let workflow_id = format!("workflow-{}-{workflow_sequence}", std::process::id()); + let mut actions = HashMap::::new(); + let mut receipts = Vec::with_capacity(params.steps.len()); + let mut pending_agents = Vec::new(); + + for step in ¶ms.steps { + if let Some(referenced_step) = step.referenced_step() + && let Some(index) = pending_agents + .iter() + .position(|pending: &PendingAgentLaunch| pending.step_id == referenced_step) + { + let finalization = finalize_agent(pending_agents.remove(index), sink, hub); + let dependency_failed = finalization.outcome.is_err(); + apply_agent_finalization(finalization, &mut receipts, &mut actions); + if dependency_failed && params.on_error == OnError::Stop { + break; + } + } + let started_at = Instant::now(); + let outcome = execute_step(step, sink, hub, &actions); + match outcome { + Ok(StepOutcome::Complete(action)) => { + actions.insert(step.id().to_owned(), action.clone()); + receipts.push(StepReceipt { + id: step.id().to_owned(), + op: step.op(), + ok: true, + duration_ms: elapsed_ms(started_at), + action: Some(action), + error: None, + }); + }, + Ok(StepOutcome::AgentPending(mut pending)) => { + let action = pending.action.clone(); + pending.receipt_index = receipts.len(); + receipts.push(StepReceipt { + id: step.id().to_owned(), + op: step.op(), + ok: true, + duration_ms: elapsed_ms(started_at), + action: Some(action), + error: None, + }); + pending_agents.push(pending); + }, + Err(error) => { + receipts.push(StepReceipt { + id: step.id().to_owned(), + op: step.op(), + ok: false, + duration_ms: elapsed_ms(started_at), + action: None, + error: Some(error), + }); + if params.on_error == OnError::Stop { + break; + } + }, + } + } + + // 所有 Agent 先启动,再并行等待 ready;这样多个 CLI 的冷启动互相重叠, + // 而主线程仍然只收到彼此独立的单步 prompt dispatch。 + let finalized = finalize_agents(pending_agents, sink, hub); + for finalization in finalized { + apply_agent_finalization(finalization, &mut receipts, &mut actions); + } + + let completed = receipts.iter().filter(|receipt| receipt.ok).count(); + let failed_step = receipts.iter().find(|receipt| !receipt.ok).map(|receipt| receipt.id.clone()); + let ok = failed_step.is_none(); + WorkflowReceipt { + workflow_id, + ok, + duration_ms: elapsed_ms(workflow_started), + partial: !ok && completed > 0, + completed, + failed_step, + steps: receipts, + } +} + +fn apply_agent_finalization( + finalization: AgentFinalization, + receipts: &mut [StepReceipt], + actions: &mut HashMap, +) { + let Some(receipt) = receipts.get_mut(finalization.receipt_index) else { + return; + }; + receipt.duration_ms = finalization.duration_ms; + match finalization.outcome { + Ok(action) => { + receipt.action = Some(action.clone()); + actions.insert(finalization.step_id, action); + }, + Err(error) => { + actions.remove(&finalization.step_id); + receipt.ok = false; + receipt.error = Some(error); + }, + } +} + +enum StepOutcome { + Complete(Value), + AgentPending(PendingAgentLaunch), +} + +fn execute_step( + step: &OrchestrateStep, + sink: &EventSink, + hub: &RuntimeHub, + actions: &HashMap, +) -> Result { + if let OrchestrateStep::AgentLaunch { + id, + target, + name, + kind, + resume_session_id, + initial_prompt, + ready_timeout_ms, + } = step + { + let (window_id, pane_id) = resolve_target(id, target, actions)?; + let command = agent_start_command( + window_id, + pane_id, + name.clone(), + kind.clone(), + resume_session_id.clone(), + )?; + let started_at = Instant::now(); + let result = dispatch_runtime_command(command, sink, hub)?; + let action = essential_action(&result); + let agent_id = action + .get("agent_id") + .and_then(Value::as_str) + .ok_or_else(|| invalid_runtime_response("agent.start did not return agent_id"))? + .to_owned(); + let generation = action + .get("generation") + .and_then(Value::as_u64) + .ok_or_else(|| invalid_runtime_response("agent.start did not return generation"))?; + return Ok(StepOutcome::AgentPending(PendingAgentLaunch { + receipt_index: 0, + step_id: id.clone(), + started_at, + deadline: started_at + Duration::from_millis(*ready_timeout_ms), + agent_id, + generation, + initial_prompt: initial_prompt.clone(), + action, + })); + } + + let command = match step { + OrchestrateStep::NewTab { window_id, cwd, .. } => { + RuntimeCommand::NewTab { window_id: *window_id, cwd: cwd.clone() } + }, + OrchestrateStep::Focus { id, target } => { + let (window_id, pane_id) = resolve_target(id, target, actions)?; + RuntimeCommand::Focus { window_id, pane_id: Some(pane_id) } + }, + OrchestrateStep::Split { id, window_id, target, direction } => { + let (window_id, pane_id) = match target { + Some(target) => { + let (window_id, pane_id) = resolve_target(id, target, actions)?; + (window_id, Some(pane_id)) + }, + None => (*window_id, None), + }; + RuntimeCommand::Split { window_id, pane_id, direction: *direction } + }, + OrchestrateStep::Prompt { id, target, text, submit } => { + let (window_id, pane_id) = resolve_target(id, target, actions)?; + RuntimeCommand::Prompt { window_id, pane_id, text: text.clone(), submit: *submit } + }, + OrchestrateStep::Run { id, target, command, wait, timeout_ms } => { + let (window_id, pane_id) = resolve_target(id, target, actions)?; + RuntimeCommand::Run { + window_id, + pane_id, + command: command.clone(), + wait: *wait, + timeout_ms: *timeout_ms, + } + }, + OrchestrateStep::AgentLaunch { .. } => unreachable!("handled above"), + }; + let result = dispatch_runtime_command(command, sink, hub)?; + Ok(StepOutcome::Complete(essential_action(&result))) +} + +fn resolve_target( + step_id: &str, + target: &PaneTarget, + actions: &HashMap, +) -> Result<(Option, u64), ApiError> { + match target { + PaneTarget::Existing(existing) => Ok((existing.window_id, existing.pane_id)), + PaneTarget::Reference(reference) => { + let action = actions.get(&reference.step).ok_or_else(|| { + invalid_reference( + step_id, + &reference.step, + "the referenced step did not complete successfully", + ) + })?; + let field = reference.field.as_str(); + let pane_id = action.get(field).and_then(Value::as_u64).ok_or_else(|| { + invalid_reference( + step_id, + &reference.step, + &format!("the referenced receipt has no integer {field}"), + ) + })?; + Ok((action.get("window_id").and_then(Value::as_u64), pane_id)) + }, + } +} + +fn agent_start_command( + window_id: Option, + pane_id: u64, + name: String, + kind: String, + resume_session_id: Option, +) -> Result { + let request = ApiRequest::new( + String::new(), + "agent.start", + json!({ + "window_id": window_id, + "pane_id": pane_id, + "name": name, + "kind": kind, + "resume_session_id": resume_session_id + }), + ); + RuntimeCommand::from_request(&request) +} + +fn essential_action(result: &Value) -> Value { + let source = result.get("action").or_else(|| result.get("run")).unwrap_or(result); + let Some(agent) = source.get("agent") else { return source.clone() }; + json!({ + "window_id": source.get("window_id"), + "pane_id": source.get("pane_id"), + "agent_id": agent.get("agent_id"), + "generation": agent.get("generation"), + "name": agent.get("name"), + "kind": agent.get("kind"), + "session_id": agent.get("session_id") + }) +} + +fn finalize_agents( + pending: Vec, + sink: &EventSink, + hub: &RuntimeHub, +) -> Vec { + std::thread::scope(|scope| { + let handles: Vec<_> = pending + .into_iter() + .map(|pending| { + let receipt_index = pending.receipt_index; + let step_id = pending.step_id.clone(); + let sink = sink.clone(); + let hub = hub.clone(); + let handle = scope.spawn(move || finalize_agent(pending, &sink, &hub)); + (receipt_index, step_id, handle) + }) + .collect(); + handles + .into_iter() + .map(|(receipt_index, step_id, handle)| match handle.join() { + Ok(finalization) => finalization, + Err(_) => AgentFinalization { + receipt_index, + step_id, + duration_ms: 0, + outcome: Err(ApiError::new( + "orchestration_failed", + "agent ready worker panicked", + )), + }, + }) + .collect() + }) +} + +fn finalize_agent( + pending: PendingAgentLaunch, + sink: &EventSink, + hub: &RuntimeHub, +) -> AgentFinalization { + let outcome = wait_agent_ready(hub, &pending.agent_id, pending.generation, pending.deadline) + .and_then(|(agent, ready_state)| { + dispatch_runtime_command( + RuntimeCommand::AgentPrompt { + agent: agent.agent_id, + generation: Some(agent.generation), + text: pending.initial_prompt.clone(), + submit: true, + }, + sink, + hub, + )?; + let mut action = pending.action.as_object().cloned().unwrap_or_default(); + action.insert( + "ready_state".to_owned(), + serde_json::to_value(ready_state).unwrap_or(Value::Null), + ); + action.insert("submitted".to_owned(), Value::Bool(true)); + Ok(Value::Object(action)) + }); + AgentFinalization { + receipt_index: pending.receipt_index, + step_id: pending.step_id, + duration_ms: elapsed_ms(pending.started_at), + outcome, + } +} + +pub(super) fn wait_agent_ready( + hub: &RuntimeHub, + agent_id: &str, + generation: u64, + deadline: Instant, +) -> Result<(RuntimeManagedAgent, RuntimeTaskState), ApiError> { + let (_, current, receiver) = hub.subscribe(); + let mut snapshot = current; + let mut observed_agent = false; + let mut observed_state = None; + loop { + let agent = hub.active_agent(agent_id, Some(generation))?; + observed_agent |= agent.observed; + if let Some(current) = snapshot.take() + && let Ok(pane) = current.pane(Some(agent.window_id), agent.pane_id) + { + observed_state = Some(pane.task_state); + if agent.observed && pane.task_state != RuntimeTaskState::Running { + return Ok((agent, pane.task_state)); + } + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(ApiError::new( + "agent_ready_timeout", + "the launched agent was not ready for its initial prompt before timeout", + ) + .details(json!({ + "agent_id": agent_id, + "generation": generation, + "observed": observed_agent, + "task_state": observed_state + }))); + } + snapshot = match receiver.recv_timeout(remaining) { + Ok(snapshot) => Some(snapshot), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => { + return Err(ApiError::new( + "runtime_unavailable", + "runtime subscription disconnected while waiting for agent readiness", + )); + }, + }; + } +} + +fn invalid_reference(step: &str, referenced: &str, message: &str) -> ApiError { + ApiError::new("invalid_reference", message) + .details(json!({ "step": step, "referenced_step": referenced })) +} + +fn invalid_runtime_response(message: &str) -> ApiError { + ApiError::new("invalid_runtime_response", message) +} + +fn default_ready_timeout_ms() -> u64 { + DEFAULT_READY_TIMEOUT_MS +} + +fn default_command_timeout_ms() -> u64 { + COMMAND_TIMEOUT.as_millis() as u64 +} + +fn elapsed_ms(started_at: Instant) -> u64 { + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX) +} diff --git a/nebula_app/src/ssh_sftp.rs b/nebula_app/src/ssh_sftp.rs index c5c58c8..dc61a00 100644 --- a/nebula_app/src/ssh_sftp.rs +++ b/nebula_app/src/ssh_sftp.rs @@ -542,6 +542,36 @@ async fn upload_local_paths( /// 剪贴板截图 → 远端 `/tmp` 的一次性 PNG 上传(SSH pane 的图片粘贴)。 /// 成功后经 runtime Prompt 通道把远端路径作为输入敲进 +/// 为补齐列一个远端目录,`(是否目录, 名字)`。 +/// +/// 与 [`SftpController::refresh`] 的区别在职责:那个驱动 SFTP 面板的状态机 +/// (phase / error / 当前路径都要更新),这里只要一份条目,而且失败是**正常** +/// 结果——补齐补不出来就是没有候选,不该弹错误、不该改任何 UI 状态。 +/// +/// 符号链接一律当目录:远端的 `l` 十有八九指向目录(`/bin`、`/lib` 这些), +/// 而补齐把它当文件的代价是不给尾随 `/`,用户得自己补一刀。 +pub async fn list_dir_for_completion(destination: &str, dir: &str) -> Option> { + let sftp = match crate::ssh_session::open_sftp(destination).await { + Ok(sftp) => sftp, + Err(err) => { + log::debug!("补齐列远端目录失败({destination}:{dir}): {err}"); + return None; + }, + }; + match read_remote_dir(&sftp, dir).await { + Ok(entries) => Some( + entries + .into_iter() + .map(|entry| (entry.kind != SftpEntryKind::File, entry.name)) + .collect(), + ), + Err(err) => { + log::debug!("补齐读远端目录失败({destination}:{dir}): {err}"); + None + }, + } +} + /// 目标 pane——上传在 SSH runtime 上异步进行,UI 线程零阻塞;失败只留日志, /// 绝不把错误文本粘进终端。 pub fn upload_clipboard_image( diff --git a/scripts/pty_drain_probe.ps1 b/scripts/pty_drain_probe.ps1 new file mode 100644 index 0000000..78002db --- /dev/null +++ b/scripts/pty_drain_probe.ps1 @@ -0,0 +1,208 @@ +[CmdletBinding()] +param( + # 每轮爆发的字节数。必须显著超过 event_loop 的 MAX_LOCKED_READ(65535), + # 否则 pty_read 会一路读到管道为空,走的是「已注册 waker」的安全路径。 + [ValidateRange(65536, 8388608)] + [int]$BurstBytes = 163840, + + # 爆发后的静默时长。这是复现的另一半条件:源头一静默,就再没有新的 + # 写入来唤醒 read waker,滞留的尾巴永远等不到下一次投递。 + [ValidateRange(100, 60000)] + [int]$SilenceMs = 1200, + + # 等待 CSI 6n 的 CPR 回复。超时即判定这一轮的尾部字节没有被解析。 + [ValidateRange(200, 30000)] + [int]$CprTimeoutMs = 2500, + + [ValidateRange(0, 100000)] + [int]$Cycles = 0, + + [ValidateRange(4, 512)] + [int]$MaxLogEvents = 64, + + [string]$LogPath = (Join-Path ([System.IO.Path]::GetTempPath()) "nebula-pty-drain-$PID.jsonl") +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$esc = [string][char]27 +$maxLockedRead = 65535 +$milestones = @(10, 25, 100, 400, 1600) + +$script:cycle = 0 +$script:stalls = 0 +$script:logEventCount = 0 +$script:nextMilestone = 0 +$script:startedAt = [DateTime]::UtcNow +$script:stdout = [Console]::OpenStandardOutput() +$script:lastCpr = '' + +function Write-Text { + param([Parameter(Mandatory = $true)][string]$Text) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) + $script:stdout.Write($bytes, 0, $bytes.Length) + $script:stdout.Flush() +} + +function Write-ProbeEvent { + param( + [Parameter(Mandatory = $true)][string]$EventName, + [hashtable]$Data = @{} + ) + if ($script:logEventCount -ge $MaxLogEvents) { return } + $record = [ordered]@{ + timestamp = [DateTime]::UtcNow.ToString('O') + event = $EventName + pid = $PID + cycle = $script:cycle + stalls = $script:stalls + elapsed_ms = [long]([DateTime]::UtcNow - $script:startedAt).TotalMilliseconds + } + foreach ($k in $Data.Keys) { $record[$k] = $Data[$k] } + Add-Content -LiteralPath $LogPath -Encoding UTF8 -Value ($record | ConvertTo-Json -Compress -Depth 4) + $script:logEventCount++ +} + +function Clear-StdinBacklog { + try { + while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) } + } + catch { + # 输入被重定向时没有控制台输入,此时 CPR 判据不可用。 + } +} + +# 等待终端把 CSI 6n 的回复写回 conin。收到即证明爆发的【尾部】已经被 +# VT 解析器消费;超时则说明尾部字节还躺在管道里没人读。 +function Wait-Cpr { + param([Parameter(Mandatory = $true)][int]$TimeoutMs) + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $sb = [System.Text.StringBuilder]::new(24) + while ($sw.ElapsedMilliseconds -lt $TimeoutMs) { + $available = $false + try { $available = [Console]::KeyAvailable } catch { return $null } + if ($available) { + $key = [Console]::ReadKey($true) + $ch = $key.KeyChar + if ($ch -ne [char]0) { [void]$sb.Append($ch) } + # CPR 形如 ESC [ row ; col R,读到结尾的 R 就算完整。 + if ($ch -eq 'R') { + $script:lastCpr = $sb.ToString() -replace [regex]::Escape($esc), '' + return [long]$sw.ElapsedMilliseconds + } + continue + } + Start-Sleep -Milliseconds 15 + } + return $null +} + +# 爆发体:整屏彩色重绘,凑到 BurstBytes 以上。刻意不使用 synchronized +# update —— 复现与 CSI ?2026 无关,这样一旦命中就排除了同步更新缓冲。 +function New-Burst { + param([Parameter(Mandatory = $true)][int]$TargetBytes) + + $cols = 100 + $rows = 20 + try { $cols = [Math]::Max(20, [Console]::WindowWidth); $rows = [Math]::Max(6, [Console]::WindowHeight - 6) } catch { } + + $glyphs = '#*=+~%' + $sb = [System.Text.StringBuilder]::new($TargetBytes + 8192) + $pass = 0 + while ($sb.Length -lt $TargetBytes) { + for ($r = 0; $r -lt $rows; $r++) { + [void]$sb.Append($esc).Append('[').Append($r + 4).Append(';1H') + for ($c = 0; $c -lt $cols; $c++) { + $t = ($r * 0.21) + ($c * 0.06) + ($pass * 0.7) + $red = [int](140 + 110 * [Math]::Sin($t)) + $green = [int](140 + 110 * [Math]::Sin($t + 2.094)) + $blue = [int](140 + 110 * [Math]::Sin($t + 4.188)) + [void]$sb.Append($esc).Append('[38;2;').Append($red).Append(';').Append($green) + .Append(';').Append($blue).Append('m').Append($glyphs[($r + $c + $pass) % $glyphs.Length]) + } + [void]$sb.Append($esc).Append('[0m') + } + $pass++ + } + return $sb.ToString() +} + +try { + Set-Content -LiteralPath $LogPath -Encoding UTF8 -Value '' + Clear-Content -LiteralPath $LogPath + + $burst = New-Burst -TargetBytes $BurstBytes + $burstBytes = [System.Text.Encoding]::UTF8.GetByteCount($burst) + + Write-Text "$esc[2J$esc[H" + Write-Text ("Nebula PTY drain probe pid=$PID burst=$burstBytes B (MAX_LOCKED_READ=$maxLockedRead)`r`n") + Write-Text ("silence=${SilenceMs}ms cpr_timeout=${CprTimeoutMs}ms NO synchronized-update`r`n") + Write-Text ("log=$LogPath`r`n") + + Write-ProbeEvent -EventName 'probe-start' -Data @{ + burst_bytes = $burstBytes + max_locked_read = $maxLockedRead + silence_ms = $SilenceMs + cpr_timeout_ms = $CprTimeoutMs + verdict_rule = 'CPR timeout => burst tail was never parsed => bytes stranded in the pty pipe' + powershell = $PSVersionTable.PSVersion.ToString() + } + + while ($true) { + if ($Cycles -gt 0 -and $script:cycle -ge $Cycles) { break } + $script:cycle++ + + Clear-StdinBacklog + + # 一次性灌入,让字节在 event_loop 的一轮 poll 之间堆进管道; + # CSI 6n 紧贴爆发尾部,因此它必然落在可能被滞留的那一段里。 + Write-Text $burst + Write-Text ("$esc[1;1H$esc[2KCYCLE $('{0:D6}' -f $script:cycle) stalls=$($script:stalls) last_cpr=$($script:lastCpr)") + Write-Text "$esc[6n" + + $cprMs = Wait-Cpr -TimeoutMs $CprTimeoutMs + if ($null -eq $cprMs) { + $script:stalls++ + # 限流:首次必记,之后按 8 的倍数记,避免高复现率把日志刷满。 + if ($script:stalls -eq 1 -or $script:stalls % 8 -eq 0) { + Write-ProbeEvent -EventName 'stall-detected' -Data @{ + burst_bytes = $burstBytes + cpr_timeout_ms = $CprTimeoutMs + verdict = 'no CPR reply: burst tail unparsed, bytes stranded in pipe' + } + } + } + + while ($script:nextMilestone -lt $milestones.Count -and $script:cycle -ge $milestones[$script:nextMilestone]) { + $rate = if ($script:cycle -gt 0) { [Math]::Round(100.0 * $script:stalls / $script:cycle, 1) } else { 0 } + Write-ProbeEvent -EventName 'cycle-milestone' -Data @{ + milestone_cycles = $milestones[$script:nextMilestone] + stall_rate_percent = $rate + } + $script:nextMilestone++ + } + + try { + if ([Console]::KeyAvailable) { + $key = [Console]::ReadKey($true) + if ($key.Key -eq [ConsoleKey]::Q -or $key.Key -eq [ConsoleKey]::Escape) { break } + } + } + catch { } + + Start-Sleep -Milliseconds $SilenceMs + } +} +finally { + $elapsed = [DateTime]::UtcNow - $script:startedAt + $rate = if ($script:cycle -gt 0) { [Math]::Round(100.0 * $script:stalls / $script:cycle, 1) } else { 0 } + Write-Text "$esc[0m`r`n" + Write-Text ("probe stopped: cycles={0} stalls={1} ({2}%) elapsed={3:N1}s`r`n" -f ` + $script:cycle, $script:stalls, $rate, $elapsed.TotalSeconds) + Write-ProbeEvent -EventName 'probe-stop' -Data @{ + stall_rate_percent = $rate + log_event_count = $script:logEventCount + } +} diff --git a/scripts/pty_pipe_memscan.ps1 b/scripts/pty_pipe_memscan.ps1 new file mode 100644 index 0000000..ce312c1 --- /dev/null +++ b/scripts/pty_pipe_memscan.ps1 @@ -0,0 +1,192 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][int]$ProcessId, + [Parameter(Mandatory = $true)][string]$NeedleFile, + [int]$MaxNeedles = 3, + [int]$MaxHits = 12, + [int]$ContextBytes = 260 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$source = @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public class NebMemScan { + const uint PROCESS_QUERY_INFORMATION = 0x0400; + const uint PROCESS_VM_READ = 0x0010; + const uint MEM_COMMIT = 0x1000; + const uint MEM_PRIVATE = 0x20000; + const uint PAGE_GUARD = 0x100; + const uint PAGE_NOACCESS = 0x01; + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr h); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool ReadProcessMemory(IntPtr h, IntPtr addr, byte[] buf, IntPtr size, out IntPtr read); + [DllImport("kernel32.dll")] + static extern IntPtr VirtualQueryEx(IntPtr h, IntPtr addr, out MEMORY_BASIC_INFORMATION mbi, IntPtr len); + + [StructLayout(LayoutKind.Sequential)] + public struct MEMORY_BASIC_INFORMATION { + public IntPtr BaseAddress; + public IntPtr AllocationBase; + public uint AllocationProtect; + public uint __alignment1; + public IntPtr RegionSize; + public uint State; + public uint Protect; + public uint Type; + public uint __alignment2; + } + + public class Hit { + public string Needle; + public ulong Address; + public ulong RegionBase; + public ulong RegionSize; + public uint Protect; + public string Context; + } + + public static long ScannedBytes; + public static long ScannedRegions; + + static int IndexOf(byte[] hay, int hayLen, byte[] pat, int from) { + int last = hayLen - pat.Length; + for (int i = from; i <= last; i++) { + int j = 0; + while (j < pat.Length && hay[i + j] == pat[j]) j++; + if (j == pat.Length) return i; + } + return -1; + } + + static string Render(byte[] buf, int center, int len, int contextBytes) { + int start = Math.Max(0, center - contextBytes / 2); + int end = Math.Min(len, center + contextBytes / 2); + var sb = new System.Text.StringBuilder(); + for (int i = start; i < end; i++) { + byte b = buf[i]; + if (b == 0x1b) sb.Append(""); + else if (b == 0x0a) sb.Append(""); + else if (b == 0x0d) sb.Append(""); + else if (b == 0x07) sb.Append(""); + else if (b < 0x20) sb.Append('.'); + else if (b < 0x7f) sb.Append((char)b); + else sb.Append('·'); + } + return sb.ToString(); + } + + public static List Scan(int pid, string[] needles, int maxHits, int contextBytes) { + var results = new List(); + var pats = new List(); + foreach (var n in needles) pats.Add(System.Text.Encoding.UTF8.GetBytes(n)); + + IntPtr h = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid); + if (h == IntPtr.Zero) throw new Exception("OpenProcess failed: " + Marshal.GetLastWin32Error()); + + try { + ulong addr = 0; + ulong limit = 0x7FFFFFFFFFFF; + int mbiSize = Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION)); + byte[] buf = null; + + while (addr < limit && results.Count < maxHits) { + MEMORY_BASIC_INFORMATION mbi; + if (VirtualQueryEx(h, (IntPtr)(long)addr, out mbi, (IntPtr)mbiSize) == IntPtr.Zero) break; + ulong regionSize = (ulong)mbi.RegionSize.ToInt64(); + if (regionSize == 0) break; + ulong regionBase = (ulong)mbi.BaseAddress.ToInt64(); + + bool readable = mbi.State == MEM_COMMIT + && mbi.Type == MEM_PRIVATE + && (mbi.Protect & PAGE_GUARD) == 0 + && (mbi.Protect & PAGE_NOACCESS) == 0 + && mbi.Protect != 0; + + if (readable) { + // 分块读,块间保留最长 needle - 1 字节的重叠,避免跨块漏匹配。 + int overlap = 0; + foreach (var p in pats) overlap = Math.Max(overlap, p.Length); + overlap = Math.Max(0, overlap - 1); + + int chunk = 4 * 1024 * 1024; + if (buf == null || buf.Length < chunk + overlap) buf = new byte[chunk + overlap]; + + ulong off = 0; + while (off < regionSize && results.Count < maxHits) { + int want = (int)Math.Min((ulong)chunk, regionSize - off); + IntPtr read; + if (!ReadProcessMemory(h, (IntPtr)(long)(regionBase + off), buf, (IntPtr)want, out read)) break; + int got = read.ToInt32(); + if (got <= 0) break; + ScannedBytes += got; + + for (int pi = 0; pi < pats.Count; pi++) { + int at = 0; + while (results.Count < maxHits) { + at = IndexOf(buf, got, pats[pi], at); + if (at < 0) break; + var hit = new Hit(); + hit.Needle = needles[pi]; + hit.Address = regionBase + off + (ulong)at; + hit.RegionBase = regionBase; + hit.RegionSize = regionSize; + hit.Protect = mbi.Protect; + hit.Context = Render(buf, at, got, contextBytes); + results.Add(hit); + at += pats[pi].Length; + } + } + + if (off + (ulong)got >= regionSize) break; + if (got <= overlap) break; + // 回退 overlap 字节,保证跨 4MiB 块边界的匹配不被切断。 + off += (ulong)(got - overlap); + } + ScannedRegions++; + } + + addr = regionBase + regionSize; + } + } + finally { + CloseHandle(h); + } + return results; + } +} +'@ + +Add-Type -TypeDefinition $source -Language CSharp | Out-Null + +$all = Get-Content -LiteralPath $NeedleFile -Encoding UTF8 | Where-Object { $_.Trim().Length -gt 0 } +# 取最长的几个:UTF-8 编码后越长,误报概率越低。 +$needles = @($all | Sort-Object -Property Length -Descending | Select-Object -First $MaxNeedles) + +Write-Output ("scanning pid={0} needles={1} (lengths: {2})" -f $ProcessId, $needles.Count, + (($needles | ForEach-Object { $_.Length }) -join ',')) + +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$hits = [NebMemScan]::Scan($ProcessId, $needles, $MaxHits, $ContextBytes) +$sw.Stop() + +Write-Output ("scanned {0:N0} MiB of MEM_PRIVATE across {1:N0} regions in {2:N1}s" -f ` + ([NebMemScan]::ScannedBytes / 1MB), [NebMemScan]::ScannedRegions, $sw.Elapsed.TotalSeconds) +Write-Output ("HITS: {0}" -f $hits.Count) + +$i = 0 +foreach ($hit in $hits) { + $i++ + Write-Output '' + Write-Output ("--- hit #{0} addr=0x{1:X} region=0x{2:X}+{3:N0}B protect=0x{4:X}" -f ` + $i, $hit.Address, $hit.RegionBase, $hit.RegionSize, $hit.Protect) + Write-Output (" ctx: {0}" -f $hit.Context) +} diff --git a/scripts/synchronized_update_probe.ps1 b/scripts/synchronized_update_probe.ps1 new file mode 100644 index 0000000..0834b2b --- /dev/null +++ b/scripts/synchronized_update_probe.ps1 @@ -0,0 +1,280 @@ +[CmdletBinding()] +param( + [ValidateSet('Timeout', 'WellFormed')] + [string]$Mode = 'Timeout', + + [ValidateRange(5, 1000)] + [int]$FrameIntervalMs = 40, + + [ValidateRange(1, 3600)] + [int]$NestedBeginIntervalSeconds = 5, + + [ValidateRange(0, 86400)] + [int]$DurationSeconds = 0, + + [ValidateRange(4, 1024)] + [int]$MaxLogEvents = 64, + + [int[]]$ContextMilestonesSeconds = @(30, 120, 300, 600), + + [ValidateRange(65536, 2000000)] + [int]$SyncBufferRotationBytes = 1500000, + + [string]$LogPath = (Join-Path ([System.IO.Path]::GetTempPath()) "nebula-sync-probe-$PID.log") +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$esc = [char]27 +$beginSync = [string]::Concat($esc, '[?2026h') +$endSync = [string]::Concat($esc, '[?2026l') +$eraseLine = [string]::Concat($esc, '[2K') +$startedAt = [DateTime]::UtcNow +$cycle = 0 +$tick = [long]0 +$syncOpen = $false +$lastSize = $null +$logEventCount = 0 +$milestones = @($ContextMilestonesSeconds | Where-Object { $_ -gt 0 } | Sort-Object -Unique) + +function Write-Raw { + param([Parameter(Mandatory = $true)][string]$Text) + + [Console]::Out.Write($Text) + [Console]::Out.Flush() +} + +function Write-ProbeEvent { + param( + [Parameter(Mandatory = $true)][string]$Event, + [hashtable]$Data = @{} + ) + + if ($script:logEventCount -ge $MaxLogEvents) { + return + } + + $record = [ordered]@{ + timestamp = [DateTime]::UtcNow.ToString('O') + event = $Event + pid = $PID + mode = $Mode + cycle = $script:cycle + tick = $script:tick + size = Get-ConsoleSizeKey + sync_open = $script:syncOpen + emitted_sync_bytes = $script:emittedSyncBytes + nested_begin_count = $script:nestedBeginCount + } + foreach ($key in $Data.Keys) { + $record[$key] = $Data[$key] + } + + $json = $record | ConvertTo-Json -Compress -Depth 4 + Add-Content -LiteralPath $LogPath -Encoding UTF8 -Value $json + $script:logEventCount++ +} + +function Get-ConsoleSizeKey { + try { + return '{0}x{1}' -f [Console]::WindowWidth, [Console]::WindowHeight + } + catch { + return $null + } +} + +function Read-ProbeKey { + try { + if ([Console]::KeyAvailable) { + return [Console]::ReadKey($true) + } + } + catch { + # 重定向运行时没有 Console input;定时退出模式仍可用于语法/流水线验证。 + } + + return $null +} + +function Close-SyncFrame { + if ($script:syncOpen) { + Write-Raw $script:endSync + $script:syncOpen = $false + } +} + +function Write-SyncPayload { + param([Parameter(Mandatory = $true)][string]$Text) + + $script:emittedSyncBytes += [Text.Encoding]::UTF8.GetByteCount($Text) + Write-Raw $Text +} + +function Start-ProbeCycle { + $script:cycle++ + $script:cycleStartedAt = [DateTime]::UtcNow + $script:lastSize = Get-ConsoleSizeKey + $script:nextNestedBegin = [DateTime]::UtcNow.AddSeconds($NestedBeginIntervalSeconds) + $script:timeoutDeadlineLogged = $false + $script:nextMilestoneIndex = 0 + $script:emittedSyncBytes = [long]0 + $script:nestedBeginCount = 0 + + Write-Raw "`r`n" + Write-Raw "=== Nebula synchronized-update probe ===`r`n" + Write-Raw ("mode={0} cycle={1} pid={2} size={3}`r`n" -f $Mode, $script:cycle, $PID, $script:lastSize) + Write-Raw "Log: $LogPath`r`n" + Write-Raw "Leave this tab in the background, then return later.`r`n" + Write-Raw "Q/Esc/Ctrl+C exits. Any other key starts a fresh cycle.`r`n" + + if ($Mode -eq 'Timeout') { + Write-Raw "Expected: TICK becomes visible within about one second and keeps moving.`r`n" + Write-Raw "Suspected bug: this WAITING line remains the live bottom indefinitely.`r`n" + Write-Raw "TICK: WAITING FOR THE TERMINAL SYNC TIMEOUT" + Write-SyncPayload $script:beginSync + $script:syncOpen = $true + } + else { + Write-Raw "Expected: well-formed synchronized frames keep updating continuously.`r`n" + Write-Raw "TICK: STARTING" + } + + Write-ProbeEvent -Event 'cycle-start' -Data @{ + frame_interval_ms = $FrameIntervalMs + nested_begin_interval_seconds = $NestedBeginIntervalSeconds + sync_buffer_rotation_bytes = $SyncBufferRotationBytes + } +} + +function Recover-ProbeCycle { + param( + [Parameter(Mandatory = $true)][string]$Reason, + [Parameter(Mandatory = $true)][TimeSpan]$Elapsed + ) + + # 模拟 Codex/CC 在输入或 SIGWINCH 后完成一次绘制;先闭合旧帧,保证 + # 故障现场中的缓冲内容一次提交,同时避免探针污染后续 shell 会话。 + Close-SyncFrame + Write-Raw "`r`n" + Write-Raw ("RECOVERED reason={0} tick={1} elapsed={2:N1}s`r`n" -f $Reason, $script:tick, $Elapsed.TotalSeconds) + Write-ProbeEvent -Event 'recovered' -Data @{ + reason = $Reason + process_elapsed_ms = [long]$Elapsed.TotalMilliseconds + cycle_elapsed_ms = [long]([DateTime]::UtcNow - $script:cycleStartedAt).TotalMilliseconds + } +} + +$initialRecord = [ordered]@{ + timestamp = $startedAt.ToString('O') + event = 'probe-start' + pid = $PID + mode = $Mode + frame_interval_ms = $FrameIntervalMs + nested_begin_interval_seconds = $NestedBeginIntervalSeconds + duration_seconds = $DurationSeconds + max_log_events = $MaxLogEvents + sync_buffer_rotation_bytes = $SyncBufferRotationBytes + context_milestones_seconds = $milestones + powershell = $PSVersionTable.PSVersion.ToString() + term = $env:TERM + term_program = $env:TERM_PROGRAM + wt_session = $env:WT_SESSION + size = Get-ConsoleSizeKey +} +Set-Content -LiteralPath $LogPath -Encoding UTF8 -Value ($initialRecord | ConvertTo-Json -Compress -Depth 4) +$logEventCount = 1 + +try { + Start-ProbeCycle + + while ($true) { + $now = [DateTime]::UtcNow + $elapsed = $now - $startedAt + if ($DurationSeconds -gt 0 -and $elapsed.TotalSeconds -ge $DurationSeconds) { + break + } + + $tick++ + if ($Mode -eq 'WellFormed') { + # 对照组严格成对输出 BSU/ESU;仍以 Codex 相近的高频整帧更新压测 + # 后台 Tab 的 PTY reader、Wakeup 队列和 GPUI 重绘链。 + Write-Raw ($beginSync + "`r" + $eraseLine + ('TICK: {0:D12}' -f $tick) + $endSync) + } + else { + # 故意不发送 ESU。VTE 规定 150ms 后应强制提交;短小帧保持 PTY + # 活跃,又让 2 MiB 同步缓冲上限在数十分钟内不会掩盖 timeout 问题。 + Write-SyncPayload ("`r" + $eraseLine + ('TICK: {0:D12}' -f $tick)) + if ($now -ge $nextNestedBegin) { + # 富 TUI 的连续 draw 可能在旧帧未闭合时开始下一帧;新的 BSU 会 + # 延长同步期限,是复现长期后台会话时序的关键压力条件。 + Write-SyncPayload $beginSync + $nestedBeginCount++ + $nextNestedBegin = $now.AddSeconds($NestedBeginIntervalSeconds) + } + } + + $cycleElapsed = $now - $cycleStartedAt + if ($Mode -eq 'Timeout' -and !$timeoutDeadlineLogged -and $cycleElapsed.TotalMilliseconds -ge 250) { + $timeoutDeadlineLogged = $true + Write-ProbeEvent -Event 'sync-timeout-deadline-crossed' -Data @{ + cycle_elapsed_ms = [long]$cycleElapsed.TotalMilliseconds + expected_vte_timeout_ms = 150 + } + } + + while ($nextMilestoneIndex -lt $milestones.Count -and $cycleElapsed.TotalSeconds -ge $milestones[$nextMilestoneIndex]) { + $milestone = $milestones[$nextMilestoneIndex] + Write-ProbeEvent -Event 'context-milestone' -Data @{ + milestone_seconds = $milestone + cycle_elapsed_ms = [long]$cycleElapsed.TotalMilliseconds + } + $nextMilestoneIndex++ + } + + if ($Mode -eq 'Timeout' -and $emittedSyncBytes -ge $SyncBufferRotationBytes) { + # 达到预算前主动闭帧并开启新周期,避免 VTE 的 2 MiB 强制提交把 + # 长时间测试误判为“自行恢复”。这类轮换频率通常低于每小时一次。 + Recover-ProbeCycle -Reason 'sync-buffer-budget-rotation' -Elapsed $elapsed + Start-Sleep -Milliseconds 300 + Start-ProbeCycle + continue + } + + $key = Read-ProbeKey + if ($null -ne $key) { + Recover-ProbeCycle -Reason ("key:{0}" -f $key.Key) -Elapsed $elapsed + if ($key.Key -eq [ConsoleKey]::Q -or $key.Key -eq [ConsoleKey]::Escape) { + break + } + + Start-Sleep -Milliseconds 300 + Start-ProbeCycle + continue + } + + $size = Get-ConsoleSizeKey + if ($null -ne $size -and $null -ne $lastSize -and $size -ne $lastSize) { + Recover-ProbeCycle -Reason ("resize:{0}->{1}" -f $lastSize, $size) -Elapsed $elapsed + Start-Sleep -Milliseconds 500 + Start-ProbeCycle + continue + } + if ($null -ne $size) { + $lastSize = $size + } + + Start-Sleep -Milliseconds $FrameIntervalMs + } +} +finally { + Close-SyncFrame + $elapsed = [DateTime]::UtcNow - $startedAt + Write-Raw "`r`n" + Write-Raw ("Probe stopped at tick={0}, elapsed={1:N1}s.`r`n" -f $tick, $elapsed.TotalSeconds) + Write-ProbeEvent -Event 'stopped' -Data @{ + process_elapsed_ms = [long]$elapsed.TotalMilliseconds + log_event_count = $logEventCount + } +} diff --git a/tools/probe_ime.rs b/tools/probe_ime.rs new file mode 100644 index 0000000..5cc1fe6 --- /dev/null +++ b/tools/probe_ime.rs @@ -0,0 +1,470 @@ +//! 搜狗输入法空格失效取证探针(issue: 按 space 选词变成往 PTY 写空格)。 +//! +//! 用途:把 GPUI 0.2.2 的 Windows 键盘/IME 处理**逐条复刻**到一个零依赖窗口 +//! 里,再和标准 Win32 应用行为对照,看同一串按键在两种壳下分别走到哪儿。 +//! +//! 复刻的是 `gpui/src/platform/windows/events.rs` 三处: +//! * 消息循环**不调** `TranslateMessage`(gpui 只在 wndproc 内部用一条伪造 +//! 的 `MSG` 调它,见 `translate_message()`); +//! * `WM_IME_STARTCOMPOSITION` → `handle_ime_position` 一律 `Some(0)`:吞掉, +//! 不给 `DefWindowProcW`(= 向 IME 宣称"组字串由我内嵌绘制"); +//! * `WM_KEYDOWN` → 是否交还 IME 只看应用侧 `marked_text_range()`,而 +//! `marked_text` 只在 `WM_IME_COMPOSITION` 带 `GCS_COMPSTR` 时才有值。 +//! +//! 编译(不进 workspace target,避免和正在跑的 cargo 抢锁): +//! rustc --edition 2021 -O tools/probe_ime.rs -o .probe/probe_ime.exe +//! +//! 用法:运行后窗口里输入拼音再按空格。F9 在三种模式间切换: +//! [0] gpui 复刻 —— 期望复现 bug +//! [1] 候选补丁 —— wParam==VK_PROCESSKEY 一律交还 IME +//! [2] 标准 Win32 应用 —— 循环里 TranslateMessage + 全部 DefWindowProcW +//! 日志同时打到控制台和 .probe/probe_ime.log。 + +use std::ffi::c_void; +use std::io::Write; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +type Hwnd = *mut c_void; +type Himc = *mut c_void; +type Hinstance = *mut c_void; +type Wparam = usize; +type Lparam = isize; +type Lresult = isize; + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct Point { + x: i32, + y: i32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct Msg { + hwnd: Hwnd, + message: u32, + w_param: Wparam, + l_param: Lparam, + time: u32, + pt: Point, +} + +#[repr(C)] +struct WndClassW { + style: u32, + wnd_proc: Option Lresult>, + cls_extra: i32, + wnd_extra: i32, + instance: Hinstance, + icon: *mut c_void, + cursor: *mut c_void, + background: *mut c_void, + menu_name: *const u16, + class_name: *const u16, +} + +#[link(name = "user32")] +extern "system" { + fn RegisterClassW(class: *const WndClassW) -> u16; + fn CreateWindowExW( + ex_style: u32, + class_name: *const u16, + window_name: *const u16, + style: u32, + x: i32, + y: i32, + w: i32, + h: i32, + parent: Hwnd, + menu: *mut c_void, + instance: Hinstance, + param: *mut c_void, + ) -> Hwnd; + fn DefWindowProcW(hwnd: Hwnd, msg: u32, w: Wparam, l: Lparam) -> Lresult; + fn GetMessageW(msg: *mut Msg, hwnd: Hwnd, min: u32, max: u32) -> i32; + fn TranslateMessage(msg: *const Msg) -> i32; + fn DispatchMessageW(msg: *const Msg) -> Lresult; + fn PostQuitMessage(code: i32); + fn ShowWindow(hwnd: Hwnd, cmd: i32) -> i32; + fn LoadCursorW(instance: Hinstance, name: *const u16) -> *mut c_void; + fn SetWindowTextW(hwnd: Hwnd, text: *const u16) -> i32; + fn GetKeyboardLayout(thread_id: u32) -> *mut c_void; + fn SetForegroundWindow(hwnd: Hwnd) -> i32; + fn PostMessageW(hwnd: Hwnd, msg: u32, w: Wparam, l: Lparam) -> i32; + fn keybd_event(vk: u8, scan: u8, flags: u32, extra: usize); +} + +#[link(name = "imm32")] +extern "system" { + fn ImmGetContext(hwnd: Hwnd) -> Himc; + fn ImmReleaseContext(hwnd: Hwnd, himc: Himc) -> i32; + fn ImmGetCompositionStringW(himc: Himc, index: u32, buf: *mut c_void, len: u32) -> i32; + fn ImmGetVirtualKey(hwnd: Hwnd) -> u32; + fn ImmGetOpenStatus(himc: Himc) -> i32; + fn ImmSetOpenStatus(himc: Himc, open: i32) -> i32; +} + +const WM_DESTROY: u32 = 0x0002; +const WM_KEYDOWN: u32 = 0x0100; +const WM_KEYUP: u32 = 0x0101; +const WM_CHAR: u32 = 0x0102; +const WM_IME_STARTCOMPOSITION: u32 = 0x010D; +const WM_IME_ENDCOMPOSITION: u32 = 0x010E; +const WM_IME_COMPOSITION: u32 = 0x010F; +const WM_IME_SETCONTEXT: u32 = 0x0281; +const WM_IME_NOTIFY: u32 = 0x0282; +const WM_IME_CHAR: u32 = 0x0286; +const WM_IME_REQUEST: u32 = 0x0288; +const WM_IME_KEYDOWN: u32 = 0x0290; +const WM_IME_KEYUP: u32 = 0x0291; + +const GCS_COMPSTR: u32 = 0x0008; +const GCS_CURSORPOS: u32 = 0x0080; +const GCS_RESULTSTR: u32 = 0x0800; + +const VK_PROCESSKEY: usize = 0xE5; +const VK_F9: usize = 0x78; +const VK_SPACE: usize = 0x20; + +const WM_CLOSE: u32 = 0x0010; +const KEYEVENTF_KEYUP: u32 = 0x0002; + +/// `--auto` 自测:程序化打开 IME 中文模式,再注入 `n` `i` `空格`,把三种模式 +/// 各跑一遍。用来在没有搜狗的机器上验证两件事:gpui 复刻是否忠实(微软拼音 +/// 应当一路正常),以及组字中按空格时 `wParam` 是否真的被系统换成 +/// `VK_PROCESSKEY`——后者是 `handle_keydown_msg` 那个补丁成立的前提。 +fn auto_test(hwnd_bits: usize) { + let hwnd = hwnd_bits as Hwnd; + let tap = |vk: u8| unsafe { + keybd_event(vk, 0, 0, 0); + std::thread::sleep(std::time::Duration::from_millis(60)); + keybd_event(vk, 0, KEYEVENTF_KEYUP, 0); + std::thread::sleep(std::time::Duration::from_millis(340)); + }; + + std::thread::sleep(std::time::Duration::from_millis(700)); + for round in 0..5 { + unsafe { + SetForegroundWindow(hwnd); + std::thread::sleep(std::time::Duration::from_millis(300)); + // 每轮都重新打开中文模式:上一轮的按键可能把 IME 切回英文。 + let himc = ImmGetContext(hwnd); + let opened = ImmSetOpenStatus(himc, 1); + let now = ImmGetOpenStatus(himc); + ImmReleaseContext(hwnd, himc); + log(&format!( + "\n---- 自测第 {round} 轮:模式 [{}] {} ;ImmSetOpenStatus={opened} 当前 open={now} ----", + MODE.load(Ordering::Relaxed), + mode_name(MODE.load(Ordering::Relaxed)) + )); + } + std::thread::sleep(std::time::Duration::from_millis(300)); + log("[注入] n"); + tap(b'N'); + log("[注入] i"); + tap(b'I'); + log("[注入] 空格(这一下如果被 IME 吃掉就是对的;出现 WM_CHAR 0x20 就是 bug)"); + tap(VK_SPACE as u8); + // 收尾:Esc 清掉可能残留的组字,再 F9 切下一模式。 + tap(0x1B); + if round < 4 { + log("[注入] F9 切模式"); + tap(VK_F9 as u8); + } + } + log("\n---- 自测结束 ----"); + unsafe { + PostMessageW(hwnd, WM_CLOSE, 0, 0); + } +} + +/// 0 = gpui 复刻,1 = 候选补丁,2 = 标准 Win32。 +static MODE: AtomicUsize = AtomicUsize::new(0); +static MARKED: Mutex = Mutex::new(String::new()); + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn log(line: &str) { + println!("{line}"); + let _ = std::io::stdout().flush(); + if let Ok(mut f) = + std::fs::OpenOptions::new().create(true).append(true).open(".probe/probe_ime.log") + { + let _ = writeln!(f, "{line}"); + } +} + +fn mode_name(mode: usize) -> &'static str { + match mode { + 0 => "gpui 复刻", + 1 => "候选补丁(VK_PROCESSKEY 直通)", + 2 => "标准 Win32", + 3 => "gpui 复刻 + 搜狗模拟(不回填 compstr)", + _ => "候选补丁 + 搜狗模拟", + } +} + +fn vk_name(vk: usize) -> String { + match vk { + VK_SPACE => "VK_SPACE".to_owned(), + VK_PROCESSKEY => "VK_PROCESSKEY".to_owned(), + 0x0D => "VK_RETURN".to_owned(), + 0x08 => "VK_BACK".to_owned(), + 0x1B => "VK_ESCAPE".to_owned(), + 0x30..=0x39 => format!("'{}'", (vk as u8) as char), + 0x41..=0x5A => format!("'{}'", (vk as u8) as char), + _ => format!("0x{vk:02X}"), + } +} + +/// gpui 的 `translate_message`:伪造一条 `MSG` 在 wndproc 内部调 +/// `TranslateMessage`,而不是在消息循环里按常规顺序调用。 +unsafe fn translate_fake(hwnd: Hwnd, w: Wparam, l: Lparam) { + let msg = Msg { + hwnd, + message: WM_KEYDOWN, + w_param: w, + l_param: l, + time: 0, + pt: Point::default(), + }; + let r = TranslateMessage(&msg); + log(&format!(" ↳ TranslateMessage(伪造 MSG) → {r}")); +} + +/// 读 IMM 组字串(GCS_COMPSTR / GCS_RESULTSTR)。 +unsafe fn comp_string(himc: Himc, index: u32) -> String { + let bytes = ImmGetCompositionStringW(himc, index, std::ptr::null_mut(), 0); + if bytes <= 0 { + return String::new(); + } + let units = bytes as usize / 2; + let mut buf = vec![0u16; units]; + ImmGetCompositionStringW(himc, index, buf.as_mut_ptr() as *mut c_void, bytes as u32); + String::from_utf16_lossy(&buf) +} + +unsafe extern "system" fn wnd_proc(hwnd: Hwnd, msg: u32, w: Wparam, l: Lparam) -> Lresult { + let mode = MODE.load(Ordering::Relaxed); + + match msg { + WM_KEYDOWN | WM_KEYUP => { + let tag = if msg == WM_KEYDOWN { "WM_KEYDOWN" } else { "WM_KEYUP " }; + // gpui `handle_key_event`:wParam 是 VK_PROCESSKEY 时用 + // ImmGetVirtualKey 把真实键挖出来,然后当普通键继续走。 + let unpacked = if w == VK_PROCESSKEY { + let real = ImmGetVirtualKey(hwnd) as usize; + format!(" → ImmGetVirtualKey={}", vk_name(real)) + } else { + String::new() + }; + let marked = MARKED.lock().unwrap().clone(); + log(&format!( + "{tag} wParam={}{unpacked} marked_text={:?}", + vk_name(w), + marked + )); + + if msg == WM_KEYDOWN && w == VK_F9 { + let next = (mode + 1) % 5; + MODE.store(next, Ordering::Relaxed); + MARKED.lock().unwrap().clear(); + log(&format!("\n======== 模式切换 → [{next}] {} ========\n", mode_name(next))); + let title = + wide(&format!("IME 探针 —— 模式 [{next}] {}(F9 切换)", mode_name(next))); + SetWindowTextW(hwnd, title.as_ptr()); + return 0; + } + + if mode == 2 { + // 标准应用:键盘消息一律交给 DefWindowProcW,IME 由消息循环里 + // 的 TranslateMessage 驱动。 + return DefWindowProcW(hwnd, msg, w, l); + } + + if msg == WM_KEYUP { + return 1; + } + + // 候选补丁:IMM 已经宣布这个键归 IME,应用不看也不派发。 + if (mode == 1 || mode == 4) && w == VK_PROCESSKEY { + log(" ↳ [补丁] wParam==VK_PROCESSKEY:不派发给应用,交还 IME"); + translate_fake(hwnd, w, l); + return 0; + } + + // gpui:是否交还 IME 只看应用侧 marked_text。 + if !marked.is_empty() { + log(" ↳ is_composing=true(marked_text 非空):不派发,交还 IME"); + translate_fake(hwnd, w, l); + return 0; + } + log(" ↳ is_composing=false:派发给应用 → 应用不消费空格 → 再 TranslateMessage"); + translate_fake(hwnd, w, l); + 1 + }, + WM_CHAR => { + let ch = char::from_u32(w as u32).unwrap_or('?'); + log(&format!( + "WM_CHAR ch=U+{:04X} {:?} *** 这一步就是「空格被写进 PTY」***", + w as u32, ch + )); + if mode == 2 { DefWindowProcW(hwnd, msg, w, l) } else { 0 } + }, + WM_IME_STARTCOMPOSITION => { + log("WM_IME_STARTCOMPOSITION"); + if mode == 2 { + return DefWindowProcW(hwnd, msg, w, l); + } + // gpui `handle_ime_position` 无条件 Some(0):吞掉 = 宣称内嵌绘制。 + log(" ↳ [gpui] 返回 0 吞掉(不给 DefWindowProcW)= 宣称组字串我自己画"); + 0 + }, + WM_IME_COMPOSITION => { + let himc = ImmGetContext(hwnd); + let flags = l as u32; + let comp = if flags & GCS_COMPSTR > 0 { comp_string(himc, GCS_COMPSTR) } else { String::new() }; + let result = + if flags & GCS_RESULTSTR > 0 { comp_string(himc, GCS_RESULTSTR) } else { String::new() }; + let open = ImmGetOpenStatus(himc); + ImmReleaseContext(hwnd, himc); + let mut names = Vec::new(); + if flags & GCS_COMPSTR > 0 { + names.push("GCS_COMPSTR"); + } + if flags & GCS_RESULTSTR > 0 { + names.push("GCS_RESULTSTR"); + } + if flags & GCS_CURSORPOS > 0 { + names.push("GCS_CURSORPOS"); + } + log(&format!( + "WM_IME_COMPOSITION lParam=0x{flags:04X} [{}] compstr={comp:?} resultstr={result:?} open={open}", + names.join("|") + )); + if mode == 2 { + return DefWindowProcW(hwnd, msg, w, l); + } + if flags & GCS_COMPSTR > 0 { + if mode >= 3 { + log(" ↳ [搜狗模拟] 丢弃 compstr:marked_text 保持 None(搜狗从不回填)"); + } else { + *MARKED.lock().unwrap() = comp; + log(" ↳ [gpui] replace_and_mark_text_in_range → marked_text 有值了"); + } + } + if flags & GCS_RESULTSTR > 0 { + MARKED.lock().unwrap().clear(); + log(" ↳ [gpui] replace_text_in_range → 提交进 PTY,marked_text 清空"); + return 0; + } + DefWindowProcW(hwnd, msg, w, l) + }, + WM_IME_ENDCOMPOSITION => { + log("WM_IME_ENDCOMPOSITION"); + MARKED.lock().unwrap().clear(); + DefWindowProcW(hwnd, msg, w, l) + }, + WM_IME_CHAR => { + log(&format!("WM_IME_CHAR wParam=U+{:04X}", w as u32)); + DefWindowProcW(hwnd, msg, w, l) + }, + WM_IME_KEYDOWN | WM_IME_KEYUP => { + log(&format!("WM_IME_KEY{} wParam={}", if msg == WM_IME_KEYDOWN { "DOWN" } else { "UP" }, vk_name(w))); + DefWindowProcW(hwnd, msg, w, l) + }, + WM_IME_SETCONTEXT => { + log(&format!("WM_IME_SETCONTEXT active={w} lParam=0x{:X}", l)); + DefWindowProcW(hwnd, msg, w, l) + }, + WM_IME_NOTIFY => { + log(&format!("WM_IME_NOTIFY wParam=0x{w:X} lParam=0x{:X}", l)); + DefWindowProcW(hwnd, msg, w, l) + }, + WM_IME_REQUEST => { + log(&format!("WM_IME_REQUEST wParam=0x{w:X}")); + DefWindowProcW(hwnd, msg, w, l) + }, + WM_DESTROY => { + PostQuitMessage(0); + 0 + }, + _ => DefWindowProcW(hwnd, msg, w, l), + } +} + +fn main() { + let _ = std::fs::create_dir_all(".probe"); + let _ = std::fs::remove_file(".probe/probe_ime.log"); + + unsafe { + let class_name = wide("NebulaImeProbe"); + let class = WndClassW { + style: 0, + wnd_proc: Some(wnd_proc), + cls_extra: 0, + wnd_extra: 0, + instance: std::ptr::null_mut(), + icon: std::ptr::null_mut(), + cursor: LoadCursorW(std::ptr::null_mut(), 32512 as *const u16), + // COLOR_WINDOW+1 + background: 6 as *mut c_void, + menu_name: std::ptr::null(), + class_name: class_name.as_ptr(), + }; + if RegisterClassW(&class) == 0 { + log("RegisterClassW 失败"); + return; + } + let title = wide("IME 探针 —— 模式 [0] gpui 复刻(F9 切换)"); + // WS_OVERLAPPEDWINDOW | WS_VISIBLE + let hwnd = CreateWindowExW( + 0, + class_name.as_ptr(), + title.as_ptr(), + 0x00CF_0000 | 0x1000_0000, + 200, + 200, + 760, + 300, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ); + if hwnd.is_null() { + log("CreateWindowExW 失败"); + return; + } + ShowWindow(hwnd, 5); + + log("========================================================="); + log(" 搜狗输入法空格取证探针"); + log(" 窗口已弹出(不画字,只记消息)。请在窗口里操作:"); + log(" 1) 切到搜狗输入法"); + log(" 2) 打拼音 ni,再按一次空格"); + log(" 3) 按 F9 切到 [1] 候选补丁,重复第 2 步"); + log(" 4) 再按 F9 切到 [2] 标准 Win32,重复第 2 步"); + log(&format!(" 当前键盘布局 HKL=0x{:X}", GetKeyboardLayout(0) as usize)); + log(&format!(" 模式 [0] {}", mode_name(0))); + log("========================================================="); + + if std::env::args().any(|a| a == "--auto") { + let bits = hwnd as usize; + std::thread::spawn(move || auto_test(bits)); + } + + let mut msg: Msg = std::mem::zeroed(); + while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { + // 标准应用在这里 TranslateMessage;gpui 的循环没有这一步, + // 它只在 wndproc 内部用伪造 MSG 调用(见 translate_fake)。 + if MODE.load(Ordering::Relaxed) == 2 { + TranslateMessage(&msg); + } + DispatchMessageW(&msg); + } + } +}