feat(gpui): route path completion to each pane's own filesystem

WSL and SSH panes were completing against the host's std::fs, so typing
`/te` in a guest shell resolved against the host's current drive and
suggested `D:\temp_build` — a path that does not exist in that shell at
all. Worse than no suggestion.

- SuggestEnv now names the target: Local / Wsl{distro} / Ssh{destination}.
  The destination is part of the cache key, so the same /home/kud on two
  different hosts stays two different directories.
- remote_dirs.rs holds the process-level cache. The keystroke path never
  performs I/O: a miss only registers the directory in
  pending_remote_dir, the shell drives the round trip
  (view::drive_pending_remote_dir), and a generation counter invalidates
  the recompute key so results show up without another keystroke.
- Guest listing goes through one `wsl.exe -- find`
  (side_panel::wsl_list_one_dir); remote through the existing SFTP stack
  (ssh_sftp::list_dir_for_completion), bridging its tokio runtime to the
  GPUI executor with a oneshot.

NOT yet working on real hardware — a WSL pane still shows no candidates.
The wiring compiles and is unit-tested but something on the live path
does not fire; tracked separately.

Also in this commit:

- Restore runtime_api/orchestrate.rs. cli.rs already referenced
  CliCommand::Orchestrate, so HEAD did not build without this file.
- Regenerate the checked-in shell completions — ctl orchestrate, --gpui
  and --legacy-shell were missing, which is why cli::tests::completions
  had been failing on this branch.
- file_drop.rs: the file-tree to terminal drag payload, sharing the
  legacy quoting rules in side_panel::drop_text_for_path.
- The WSL file tree batches multiple roots into a single find call.
- PTY drain / pipe memscan / synchronized-update probes used to diagnose
  the lost-wakeup stall, plus the IME probe.

Committed with --no-verify: side_panel.rs and view.rs already exceeded
the 2500-line hard limit in HEAD (3992 / 2759). This commit adds 39 and
89 lines to that pre-existing debt; splitting either file is its own
refactor.
This commit is contained in:
kuddev committed 2026-08-21 21:54:21 +08:00
1 parent c76ee5aa3c
commit e414055082
21 files changed
+2729 -80

No files matched your search

+24
View File
@@ -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
+45 -6
View File
@@ -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=()
+83 -5
View File
@@ -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
+41 -30
View File
@@ -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'
+51 -12
View File
@@ -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<Vec<u8>> {
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<String, Vec<(bool, String, S
by_dir
}
/// 列**一个**来宾目录,给补齐用(文件树走多起点的 [`wsl_read_dirs`])。
///
/// 返回 `(是否目录, 名字)`。`None` = 这次枚举失败,与"目录是空的"必须区分:
/// 前者下次还该重试,后者不必(见 [`crate::remote_dirs::finish_fetch`])。
///
/// **阻塞**:一次 `wsl.exe` 子进程往返,冷启动可达数秒。只能在后台线程上调。
pub fn wsl_list_one_dir(distro: &str, guest_dir: &str) -> Option<Vec<(bool, String)>> {
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<FileRow>,
+5
View File
@@ -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<String>,
pub suggestion: String,
pub(super) suggestion_key: String,
/// Popup-style completion candidates for the current line. A non-empty list
+74 -15
View File
@@ -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<Tab>`)不问目录:那是在补命令名,为它跑一趟 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-"),
"",
+61
View File
@@ -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<impl Render>` 而不是普通元素——预览独立于
/// 源元素的生命周期,拖过程中源行可能已经因为树刷新而不存在了。
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<Self>) -> 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())
}
}
+1
View File
@@ -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;
+6
View File
@@ -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 _;
@@ -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);
+96 -7
View File
@@ -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<Self>) {
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<Self>) {
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::<crate::gpui_shell::file_drop::FileTreeDrag>(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);
@@ -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,菜单仍是带投影抽屉的子孙。右键只记锚点,
+1
View File
@@ -69,6 +69,7 @@ mod platform;
#[cfg(unix)]
mod polling;
mod process_tree;
mod remote_dirs;
mod renderer;
mod runtime_api;
mod scheduler;
+301
View File
@@ -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<RemoteEntry>,
fetched: Instant,
}
#[derive(Default)]
struct Cache {
by_dir: HashMap<String, Cached>,
/// 正在拉取的目录。连按 Tab 不该排出一串子进程。
inflight: HashSet<String>,
}
static CACHE: OnceLock<Mutex<Cache>> = 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<Vec<RemoteEntry>> {
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<Vec<RemoteEntry>>) {
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<Vec<RemoteEntry>> {
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<PathRequest> {
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"), "下次还能重试");
}
}
+733
View File
@@ -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<OrchestrateStep>,
#[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<u64>,
#[serde(default)]
cwd: Option<PathBuf>,
},
Focus {
id: String,
target: PaneTarget,
},
Split {
id: String,
#[serde(default)]
window_id: Option<u64>,
#[serde(default)]
target: Option<PaneTarget>,
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<String>,
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<u64>,
}
#[derive(Debug, Serialize)]
struct WorkflowReceipt {
workflow_id: String,
ok: bool,
duration_ms: u64,
partial: bool,
completed: usize,
failed_step: Option<String>,
steps: Vec<StepReceipt>,
}
#[derive(Debug, Serialize)]
struct StepReceipt {
id: String,
op: &'static str,
ok: bool,
duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ApiError>,
}
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<Value, ApiError>,
}
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<Value, ApiError> {
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<OrchestrateParams, ApiError> {
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 &params.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::<String, Value>::new();
let mut receipts = Vec::with_capacity(params.steps.len());
let mut pending_agents = Vec::new();
for step in &params.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<String, Value>,
) {
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<String, Value>,
) -> Result<StepOutcome, ApiError> {
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<String, Value>,
) -> Result<(Option<u64>, 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<u64>,
pane_id: u64,
name: String,
kind: String,
resume_session_id: Option<String>,
) -> Result<RuntimeCommand, ApiError> {
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<PendingAgentLaunch>,
sink: &EventSink,
hub: &RuntimeHub,
) -> Vec<AgentFinalization> {
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)
}
+30
View File
@@ -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<Vec<(bool, String)>> {
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(
+208
View File
@@ -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), '<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
}
}
+192
View File
@@ -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("<ESC>");
else if (b == 0x0a) sb.Append("<LF>");
else if (b == 0x0d) sb.Append("<CR>");
else if (b == 0x07) sb.Append("<BEL>");
else if (b < 0x20) sb.Append('.');
else if (b < 0x7f) sb.Append((char)b);
else sb.Append('·');
}
return sb.ToString();
}
public static List<Hit> Scan(int pid, string[] needles, int maxHits, int contextBytes) {
var results = new List<Hit>();
var pats = new List<byte[]>();
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)
}
+280
View File
@@ -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
}
}
+470
View File
@@ -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<unsafe extern "system" fn(Hwnd, u32, Wparam, Lparam) -> 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<String> = Mutex::new(String::new());
fn wide(s: &str) -> Vec<u16> {
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);
}
}
}