Skip to content

Commit 0e32fd1

Browse files
xperiandriclaude
andcommitted
Start the window's session where dotnet fsi would, and only on .NET
The window picked its own fsi: `dotnet.exe` from Program Files, or one of the desktop executables the extension ships, chosen through the options page the old window reads. The desktop ones cannot serve the protocol at all — the server exists in the .NET fsi alone — and the machine-wide `dotnet` ignored the SDK a solution pins. The session now starts in the open solution's folder with the `dotnet` a shell there would run, so the host resolves the SDK from that folder's `global.json` exactly as `dotnet fsi` typed at a prompt does, and the window runs the same compiler bits as `dotnet build`. The platform choice, the desktop executables and the shadow-copy switch stay with the old window. When a session comes up the window prints which fsi answered, on what runtime and where, so a pinned SDK is seen rather than guessed at. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 07d34b6 commit 0e32fd1

4 files changed

Lines changed: 112 additions & 154 deletions

File tree

‎docs/ide/FSI-Modern-Interactive-Window-Plan.md‎

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ and contains:
2828
- `SubmissionAnalysis.fs` — the rule deciding when Enter submits, tested;
2929
- `FSharpVsInteractiveWindowProvider.fs` — the MEF component that creates the tool window through
3030
`IVsInteractiveWindowFactory.Create`, calls `SetLanguage` with the F# content type and language
31-
service so the input buffer is an F# editor buffer, sets the caption from the platform, and reads
32-
its options from the `SessionsProperties` the existing Tools, Options page already writes. No new
33-
options page is needed.
31+
service so the input buffer is an F# editor buffer, and reads its options from the
32+
`SessionsProperties` the existing Tools, Options page already writes. No new options page is
33+
needed. The session starts in the open solution's folder, so `dotnet fsi` runs the SDK that
34+
folder's `global.json` resolves to.
3435

3536
Still to do before the window can be opened in Visual Studio:
3637

@@ -40,7 +41,7 @@ Still to do before the window can be opened in Visual Studio:
4041
calls the provider, rather than a new package with its own GUID and pkgdef;
4142
- the `Microsoft.VisualStudio.InteractiveWindow` prerequisite entry in the VSIX manifest, and the
4243
project's place in the VSIX itself. It is already in `VisualFSharp.slnx`, so it builds;
43-
- commands: open the window, `#reset <platform>`, and retargeting Alt+Enter at the new window;
44+
- commands: open the window, and retargeting Alt+Enter at the new window;
4445
- the debugger attach/detach commands ported from the existing window. The session reports its own
4546
process id in the handshake, so the attach no longer has to guess which process to target.
4647

@@ -76,7 +77,7 @@ What the old window does have that C# Interactive does **not** (must be preserve
7677

7778
- **Debugging**: attach/detach the VS debugger to the FSI process, "Debug in Interactive" (`#dbgbreak`), debuggability check (`--debug+ --optimize-`) with a suppressible warning dialog. Roslyn's window has no debugging at all — this is an F# advantage to keep.
7879
- Real script semantics: FSI executes actual `.fsx` interactions with `#load`/`#r`/`#i`, and `fsi` object, not a C#-script dialect.
79-
- Platform choice already includes Arm64 (`fsiArm64.exe`).
80+
- The .NET Framework hosts (`fsiAnyCpu.exe`, `fsi.exe`, `fsiArm64.exe`) stay with the old window; the new one is .NET-only, because the server mode exists only in the .NET fsi.
8081

8182
---
8283

@@ -113,8 +114,7 @@ Five layers, copied from Roslyn's proven separation. Execution state lives **onl
113114
│ + redirected stdout/stderr (user output)
114115
┌──────────────────────────────▼────────────────────────────────────────┐
115116
│ EXECUTION HOST = fsi itself, in a new server mode │
116-
│ dotnet fsi --fsi-server-jsonrpc:<pipe> (.NET / SDK) │
117-
│ fsiAnyCpu.exe / fsiArm64.exe --fsi-server-jsonrpc:… (.NET Framework) │
117+
│ dotnet fsi --fsi-server-jsonrpc:<pipe> (the SDK global.json resolves) │
118118
│ FsiEvaluationSession driven by RPC instead of the stdin ReadLine loop │
119119
└───────────────────────────────────────────────────────────────────────┘
120120
```
@@ -125,7 +125,7 @@ Roslyn ships dedicated `InteractiveHost64/32.exe` binaries because C# scripting
125125

126126
- kills the `SERVER-PROMPT>` scraping, the PID-file hack, and the `# 1 "stdin"` directive juggling in one move;
127127
- benefits every other fsi client (Ionide, VS Code, custom tooling) — the server mode is a compiler feature, not a VS-only one;
128-
- keeps `dotnet fsi` as the .NET Core host (nothing new to deploy; the VSIX only carries the desktop `fsiAnyCpu`/`fsiArm64` it already ships).
128+
- keeps `dotnet fsi` as the host, resolved from the solution folder's `global.json`, so the window runs the same compiler bits as `dotnet build` there and Visual Studio and SDK versions are decoupled (nothing new to deploy; the desktop `fsiAnyCpu`/`fsiArm64` the VSIX ships stay with the old window).
129129

130130
### 2.2 The RPC protocol (as implemented)
131131

@@ -195,18 +195,11 @@ The single most valuable user-facing change. Mechanism (Roslyn's, adapted):
195195
- Otherwise → submit iff the text is a syntactically complete interaction (FCS parse with `ScriptParseInfo`; incomplete constructs — open `let`, unclosed paren/string — return false). This matches `dotnet fsi`'s modern multiline behavior and C#'s `SyntaxFactory.IsCompleteSubmission`.
196196
- The evaluator appends `;;` before sending to the host if absent; prompts: `> ` primary, `. ` (or `- `) continuation via `GetPrompt()`.
197197

198-
### 2.5 Platform selection
198+
### 2.5 Host selection
199199

200-
Keep today's matrix, expressed the Roslyn way (`#reset` arguments + caption suffix + options page default):
200+
The window is .NET-only. The server mode exists in the .NET fsi alone, and the desktop matrix (`fsiAnyCpu.exe`, `fsi.exe`, `fsiArm64.exe`, `#reset` platform arguments, the shadow-copy switch) stays with the old window until that is retired.
201201

202-
| Platform | Host | Notes |
203-
|---|---|---|
204-
| .NET (default) | `dotnet fsi` | SDK-resolved; caption "F# Interactive (.NET)" |
205-
| .NET Framework x64 | `fsiAnyCpu.exe --fsi-server-jsonrpc:…` | shipped in VSIX |
206-
| .NET Framework x86 | `fsi.exe` | shipped in VSIX |
207-
| Arm64 | `fsiArm64.exe` | shipped in VSIX |
208-
209-
`#reset core` / `#reset net472` / etc. exported as a specialized-content-type command that displaces the package's generic `#reset` (Roslyn's `GetApplicableCommands` name-replacement mechanism).
202+
The session is started in the open solution's folder, with the `dotnet` a shell there would run (`DOTNET_HOST_PATH`, then `PATH`, then the machine-wide install). The host resolves the SDK from that folder's `global.json` exactly as `dotnet fsi` typed in a shell would, so the window runs the same compiler bits as `dotnet build`, and Visual Studio and SDK versions are decoupled. Nothing in the window re-implements SDK resolution. `FSHARP_INTERACTIVE_PATH` names another fsi for development, until an SDK ships the protocol; the window prints which fsi answered, on what runtime and in which directory, when a session comes up.
210203

211204
### 2.6 Debugging (parity + improvement over C#)
212205

@@ -243,7 +236,7 @@ Goal: de-risk the InteractiveWindow dependency before touching the compiler.
243236
### Phase 2 — Evaluator + session on the new protocol — partly done
244237

245238
- `FSharpInteractiveSession`: single `AsyncBatchingWorkQueue`-style queue serializing init/execute/set-paths (reset preempts); `LazyRemoteService` lifecycle port; auto-restart; pending-buffer queue.
246-
- Full `IInteractiveEvaluator`: `CanExecuteCode` (§2.4), `GetPrompt`, `ResetAsync` with platform args, `InitializeAsync`, `AbortExecution` → RPC `Interrupt` (note: this makes F# *better* than C# Interactive, whose `AbortExecution` is an unimplemented TODO).
239+
- Full `IInteractiveEvaluator`: `CanExecuteCode` (§2.4), `GetPrompt`, `ResetAsync`, `InitializeAsync`, `AbortExecution` → RPC `Interrupt` (note: this makes F# *better* than C# Interactive, whose `AbortExecution` is an unimplemented TODO).
247240
- Structured diagnostics from `ExecutionResult` rendered as error-classified output.
248241
- Delete the stdin/stdout path from the new window (old window untouched).
249242

@@ -260,9 +253,9 @@ Goal: de-risk the InteractiveWindow dependency before touching the compiler.
260253

261254
- Rewire `MenusAndCommands.vsct` targets: Alt+Enter Send Selection/Line, "Execute in Interactive", "Debug in Interactive" → new window (`window.SubmitAsync`, preserving the no-selection→current-line + caret-advance behavior; consider Roslyn's syntax-aware selection expansion from `SendToInteractiveSubmissionProvider`).
262255
- `AddReferences` (Solution Explorer "Send project references to F# Interactive") → `#r` submissions.
263-
- Optional: "Initialize Interactive with Project" parity — build project, reset with platform inferred from TFM, `SetPaths`, `#r` output assembly + references, `open` default namespaces (Roslyn's `ResetInteractive` flow).
264-
- Port `FsiPropertyPage` (Tools → Options → F# Tools → F# Interactive): args, platform default, shadow copy (`--shadowcopyreferences` still honored via args), langversion preview, debug mode.
265-
- Window caption platform suffix; `#reset` platform args; F1 help keyword.
256+
- Optional: "Initialize Interactive with Project" parity — build project, reset, `SetPaths`, `#r` output assembly + references, `open` default namespaces (Roslyn's `ResetInteractive` flow).
257+
- Port `FsiPropertyPage` (Tools → Options → F# Tools → F# Interactive): args, langversion preview, debug mode. The platform default and shadow copy belong to the desktop fsi and stay with the old window.
258+
- F1 help keyword.
266259

267260
### Phase 5 — Debugging parity (1–2 weeks)
268261

‎vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs‎

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,20 @@ module internal ResultRendering =
3434
let formatDiagnostic (diagnostic: FSharp.Compiler.Interactive.Protocol.DiagnosticInfo) =
3535
$"{diagnostic.fileName}({diagnostic.startLine},{diagnostic.startColumn + 1}): {diagnostic.severity} FS%04d{diagnostic.errorNumber}: {diagnostic.message}"
3636

37+
/// The line the window shows when a session comes up: which fsi answered, on what, and where.
38+
let formatSessionStart (session: FSharp.Compiler.Interactive.Protocol.InitializeResult) =
39+
$"F# Interactive {session.fsiVersion} on {session.frameworkDescription}, in {session.workingDirectory}"
40+
3741
/// Connects the interactive window to an F# Interactive session.
3842
[<Sealed>]
39-
type internal FSharpInteractiveEvaluator
40-
(
41-
host: InteractiveHostClient,
42-
getOptions: unit -> InteractiveHostOptions,
43-
onPlatformChanged: InteractiveHostPlatform -> unit
44-
) =
43+
type internal FSharpInteractiveEvaluator(host: InteractiveHostClient, getOptions: unit -> InteractiveHostOptions) =
4544

4645
let mutable currentWindow: IInteractiveWindow | null = null
4746
let mutable outputSubscription: IDisposable | null = null
4847
let mutable errorSubscription: IDisposable | null = null
4948
let mutable exitedSubscription: IDisposable | null = null
49+
let mutable startedSubscription: IDisposable | null = null
5050
let mutable disposed = false
51-
let mutable requestedPlatform: InteractiveHostPlatform voption = ValueNone
5251

5352
// The window submits text without saying where it came from, so an editor command records the
5453
// origin here for the submission it is about to make. Both run on the UI thread.
@@ -61,6 +60,11 @@ type internal FSharpInteractiveEvaluator
6160
| null -> ()
6261
| window -> window.OutputWriter.Write text
6362

63+
let writeLine (text: string) =
64+
match currentWindow with
65+
| null -> ()
66+
| window -> window.OutputWriter.WriteLine text
67+
6468
let writeError (text: string) =
6569
match currentWindow with
6670
| null -> ()
@@ -71,13 +75,6 @@ type internal FSharpInteractiveEvaluator
7175
| null -> ()
7276
| window -> window.ErrorOutputWriter.WriteLine text
7377

74-
let optionsForNextSession () =
75-
let options = getOptions ()
76-
77-
match requestedPlatform with
78-
| ValueSome platform -> { options with Platform = platform }
79-
| ValueNone -> options
80-
8178
let reportDiagnostics (result: FSharp.Compiler.Interactive.Protocol.ExecutionResult) =
8279
match result.diagnostics with
8380
| null -> ()
@@ -95,7 +92,7 @@ type internal FSharpInteractiveEvaluator
9592

9693
let ensureSessionAsync () =
9794
task {
98-
match! host.EnsureStartedAsync(optionsForNextSession ()) with
95+
match! host.EnsureStartedAsync(getOptions ()) with
9996
| Result.Ok _ -> return true
10097
| Result.Error message ->
10198
writeErrorLine message
@@ -110,12 +107,8 @@ type internal FSharpInteractiveEvaluator
110107
let reportSessionExit exitCode =
111108
writeErrorLine $"{VFSIstrings.SR.sessionTerminationDetected()} (exit code {exitCode})"
112109

113-
member _.CurrentPlatform =
114-
match requestedPlatform with
115-
| ValueSome platform -> platform
116-
| ValueNone -> (getOptions ()).Platform
117-
118-
member _.RequestPlatform platform = requestedPlatform <- ValueSome platform
110+
let reportSessionStart session =
111+
writeLine (ResultRendering.formatSessionStart session)
119112

120113
/// Attribute the next submission to a file and line, so that its diagnostics land on the user's
121114
/// own source rather than on the submission.
@@ -140,6 +133,7 @@ type internal FSharpInteractiveEvaluator
140133
unsubscribe outputSubscription
141134
unsubscribe errorSubscription
142135
unsubscribe exitedSubscription
136+
unsubscribe startedSubscription
143137

144138
match window with
145139
| null -> ()
@@ -148,6 +142,7 @@ type internal FSharpInteractiveEvaluator
148142
outputSubscription <- host.OutputReceived.Subscribe write
149143
errorSubscription <- host.ErrorOutputReceived.Subscribe writeError
150144
exitedSubscription <- host.ProcessExited.Subscribe reportSessionExit
145+
startedSubscription <- host.SessionStarted.Subscribe reportSessionStart
151146

152147
member _.InitializeAsync() =
153148
task {
@@ -159,10 +154,7 @@ type internal FSharpInteractiveEvaluator
159154
// session has none to vary, and the flag never means "do not start a replacement".
160155
member _.ResetAsync(_initialize) =
161156
task {
162-
let options = optionsForNextSession ()
163-
onPlatformChanged options.Platform
164-
165-
match! host.ResetAsync options with
157+
match! host.ResetAsync(getOptions ()) with
166158
| Result.Ok _ -> return ExecutionResult true
167159
| Result.Error message ->
168160
writeErrorLine message
@@ -221,4 +213,5 @@ type internal FSharpInteractiveEvaluator
221213
unsubscribe outputSubscription
222214
unsubscribe errorSubscription
223215
unsubscribe exitedSubscription
216+
unsubscribe startedSubscription
224217
(host :> IDisposable).Dispose()

‎vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs‎

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ open System
66
open System.ComponentModel.Composition
77
open System.Diagnostics
88
open System.IO
9-
open System.Runtime.InteropServices
109
open System.Threading
1110

1211
open Microsoft.VisualStudio
@@ -38,25 +37,36 @@ module InteractiveWindowGuids =
3837
/// Reads the session settings the existing Tools, Options page writes.
3938
module internal InteractiveHostOptionsFactory =
4039

41-
let private hostDirectory () =
42-
match Path.GetDirectoryName(typeof<InteractiveHostClient>.Assembly.Location) with
43-
| null -> Environment.CurrentDirectory
44-
| directory -> directory
45-
46-
let currentPlatform () =
47-
match SessionsProperties.fsiUseNetCore, RuntimeInformation.ProcessArchitecture with
48-
| true, _ -> NetCore
49-
| _, Architecture.Arm64 -> NetFrameworkArm64
50-
| _ when SessionsProperties.useAnyCpuVersion -> NetFramework64
51-
| _ -> NetFramework32
52-
53-
let create platform =
40+
/// The folder `dotnet fsi` is run from: the open solution's, whose `global.json` then picks the
41+
/// SDK; the user's profile when nothing is open. Read on the UI thread, where the window's
42+
/// evaluator calls in.
43+
let private startDirectory () =
44+
let solutionDirectory =
45+
try
46+
match ServiceProvider.GlobalProvider.GetService typeof<SVsSolution> with
47+
| :? IVsSolution as solution ->
48+
let hr, directory, _, _ = solution.GetSolutionInfo()
49+
50+
if
51+
ErrorHandler.Succeeded hr
52+
&& not (String.IsNullOrEmpty directory)
53+
&& Directory.Exists directory
54+
then
55+
ValueSome directory
56+
else
57+
ValueNone
58+
| _ -> ValueNone
59+
with _ ->
60+
ValueNone
61+
62+
match solutionDirectory with
63+
| ValueSome directory -> directory
64+
| ValueNone -> Environment.GetFolderPath Environment.SpecialFolder.UserProfile
65+
66+
let create () =
5467
{
55-
Platform = platform
56-
HostDirectory = hostDirectory ()
57-
InitialWorkingDirectory = Environment.GetFolderPath Environment.SpecialFolder.UserProfile
68+
InitialWorkingDirectory = startDirectory ()
5869
UserArguments = SessionsProperties.fsiArgs
59-
ShadowCopyReferences = SessionsProperties.fsiShadowCopy
6070
DebugMode = SessionsProperties.fsiDebugMode
6171
LanguageVersionPreview = SessionsProperties.fsiPreview
6272
UICultureLcid = Thread.CurrentThread.CurrentUICulture.LCID
@@ -72,27 +82,16 @@ type internal FSharpVsInteractiveWindowProvider
7282
let mutable window: IVsInteractiveWindow | null = null
7383
let mutable evaluator: FSharpInteractiveEvaluator voption = ValueNone
7484

75-
let captionFor (platform: InteractiveHostPlatform) =
76-
$"{VFSIstrings.SR.fsharpInteractive ()} ({platform.Description})"
77-
78-
let setCaption platform =
79-
match box window with
80-
| :? ToolWindowPane as pane -> pane.Caption <- captionFor platform
81-
| _ -> ()
82-
83-
let currentOptions () =
84-
InteractiveHostOptionsFactory.create (InteractiveHostOptionsFactory.currentPlatform ())
85-
8685
member this.Create(instanceId: int) =
8786
let host = new InteractiveHostClient(Process.GetCurrentProcess().Id)
88-
let created = new FSharpInteractiveEvaluator(host, currentOptions, setCaption)
87+
let created = new FSharpInteractiveEvaluator(host, InteractiveHostOptionsFactory.create)
8988
evaluator <- ValueSome created
9089

9190
let toolWindow =
9291
windowFactory.Create(
9392
InteractiveWindowGuids.ToolWindowId,
9493
instanceId,
95-
captionFor (InteractiveHostOptionsFactory.currentPlatform ()),
94+
VFSIstrings.SR.fsharpInteractive (),
9695
created,
9796
__VSCREATETOOLWIN.CTW_fForceCreate
9897
)

0 commit comments

Comments
 (0)