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:
1 parent
c76ee5aa3c
commit
e414055082
21 files changed
+2729
-80
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-"),
|
||||
"",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,菜单仍是带投影抽屉的子孙。右键只记锚点,
|
||||
|
||||
@@ -69,6 +69,7 @@ mod platform;
|
||||
#[cfg(unix)]
|
||||
mod polling;
|
||||
mod process_tree;
|
||||
mod remote_dirs;
|
||||
mod renderer;
|
||||
mod runtime_api;
|
||||
mod scheduler;
|
||||
|
||||
@@ -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"), "下次还能重试");
|
||||
}
|
||||
}
|
||||
@@ -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 ¶ms.steps {
|
||||
validate_step_id(step.id())?;
|
||||
if seen.contains(step.id()) {
|
||||
return Err(ApiError::invalid_params(format!(
|
||||
"duplicate orchestration step id {:?}",
|
||||
step.id()
|
||||
)));
|
||||
}
|
||||
if let Some(PaneTarget::Reference(reference)) = step.target()
|
||||
&& !seen.contains(&reference.step)
|
||||
{
|
||||
return Err(invalid_reference(
|
||||
step.id(),
|
||||
&reference.step,
|
||||
"references must point to an earlier successful step",
|
||||
));
|
||||
}
|
||||
validate_step_contract(step)?;
|
||||
seen.insert(step.id().to_owned());
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn validate_step_id(id: &str) -> Result<(), ApiError> {
|
||||
let valid = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().enumerate().all(|(index, byte)| {
|
||||
if index == 0 {
|
||||
byte == b'_' || byte.is_ascii_alphabetic()
|
||||
} else {
|
||||
byte == b'_' || byte == b'-' || byte.is_ascii_alphanumeric()
|
||||
}
|
||||
});
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::invalid_params(
|
||||
"step id must be 1-64 ASCII letters, digits, '_' or '-', and start with a letter or '_'",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_step_contract(step: &OrchestrateStep) -> Result<(), ApiError> {
|
||||
match step {
|
||||
OrchestrateStep::Split { window_id: Some(_), target: Some(_), .. } => {
|
||||
Err(ApiError::invalid_params(
|
||||
"a split step cannot combine window_id with target; put window_id inside a direct target",
|
||||
))
|
||||
},
|
||||
OrchestrateStep::Prompt { text, .. } => validate_prompt(text),
|
||||
OrchestrateStep::Run { command, timeout_ms, .. } => {
|
||||
validate_command_line(command)?;
|
||||
validate_timeout(*timeout_ms, "run timeout_ms")
|
||||
},
|
||||
OrchestrateStep::AgentLaunch {
|
||||
name,
|
||||
kind,
|
||||
resume_session_id,
|
||||
initial_prompt,
|
||||
ready_timeout_ms,
|
||||
..
|
||||
} => {
|
||||
validate_prompt(initial_prompt)?;
|
||||
validate_timeout(*ready_timeout_ms, "ready_timeout_ms")?;
|
||||
agent_start_command(None, 1, name.clone(), kind.clone(), resume_session_id.clone())?;
|
||||
Ok(())
|
||||
},
|
||||
OrchestrateStep::NewTab { .. }
|
||||
| OrchestrateStep::Focus { .. }
|
||||
| OrchestrateStep::Split { .. } => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_timeout(timeout_ms: u64, label: &str) -> Result<(), ApiError> {
|
||||
if timeout_ms == 0 || Duration::from_millis(timeout_ms) > MAX_WAIT {
|
||||
Err(ApiError::invalid_params(format!("{label} must be between 1 and 86400000")))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(params: OrchestrateParams, sink: &EventSink, hub: &RuntimeHub) -> WorkflowReceipt {
|
||||
let workflow_started = Instant::now();
|
||||
let workflow_sequence = NEXT_WORKFLOW_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let workflow_id = format!("workflow-{}-{workflow_sequence}", std::process::id());
|
||||
let mut actions = HashMap::<String, Value>::new();
|
||||
let mut receipts = Vec::with_capacity(params.steps.len());
|
||||
let mut pending_agents = Vec::new();
|
||||
|
||||
for step in ¶ms.steps {
|
||||
if let Some(referenced_step) = step.referenced_step()
|
||||
&& let Some(index) = pending_agents
|
||||
.iter()
|
||||
.position(|pending: &PendingAgentLaunch| pending.step_id == referenced_step)
|
||||
{
|
||||
let finalization = finalize_agent(pending_agents.remove(index), sink, hub);
|
||||
let dependency_failed = finalization.outcome.is_err();
|
||||
apply_agent_finalization(finalization, &mut receipts, &mut actions);
|
||||
if dependency_failed && params.on_error == OnError::Stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let started_at = Instant::now();
|
||||
let outcome = execute_step(step, sink, hub, &actions);
|
||||
match outcome {
|
||||
Ok(StepOutcome::Complete(action)) => {
|
||||
actions.insert(step.id().to_owned(), action.clone());
|
||||
receipts.push(StepReceipt {
|
||||
id: step.id().to_owned(),
|
||||
op: step.op(),
|
||||
ok: true,
|
||||
duration_ms: elapsed_ms(started_at),
|
||||
action: Some(action),
|
||||
error: None,
|
||||
});
|
||||
},
|
||||
Ok(StepOutcome::AgentPending(mut pending)) => {
|
||||
let action = pending.action.clone();
|
||||
pending.receipt_index = receipts.len();
|
||||
receipts.push(StepReceipt {
|
||||
id: step.id().to_owned(),
|
||||
op: step.op(),
|
||||
ok: true,
|
||||
duration_ms: elapsed_ms(started_at),
|
||||
action: Some(action),
|
||||
error: None,
|
||||
});
|
||||
pending_agents.push(pending);
|
||||
},
|
||||
Err(error) => {
|
||||
receipts.push(StepReceipt {
|
||||
id: step.id().to_owned(),
|
||||
op: step.op(),
|
||||
ok: false,
|
||||
duration_ms: elapsed_ms(started_at),
|
||||
action: None,
|
||||
error: Some(error),
|
||||
});
|
||||
if params.on_error == OnError::Stop {
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 所有 Agent 先启动,再并行等待 ready;这样多个 CLI 的冷启动互相重叠,
|
||||
// 而主线程仍然只收到彼此独立的单步 prompt dispatch。
|
||||
let finalized = finalize_agents(pending_agents, sink, hub);
|
||||
for finalization in finalized {
|
||||
apply_agent_finalization(finalization, &mut receipts, &mut actions);
|
||||
}
|
||||
|
||||
let completed = receipts.iter().filter(|receipt| receipt.ok).count();
|
||||
let failed_step = receipts.iter().find(|receipt| !receipt.ok).map(|receipt| receipt.id.clone());
|
||||
let ok = failed_step.is_none();
|
||||
WorkflowReceipt {
|
||||
workflow_id,
|
||||
ok,
|
||||
duration_ms: elapsed_ms(workflow_started),
|
||||
partial: !ok && completed > 0,
|
||||
completed,
|
||||
failed_step,
|
||||
steps: receipts,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_agent_finalization(
|
||||
finalization: AgentFinalization,
|
||||
receipts: &mut [StepReceipt],
|
||||
actions: &mut HashMap<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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user