Skip to content

Commit 0afdea2

Browse files
perezdapcursoragent
andcommitted
Add winharness_docs tool and speed up agent workspace lookups.
Give agents embedded docs (paths, sessions, tools) via winharness_docs and docs CLI, skip .claude worktrees in glob/grep, and fix VS Code integrated-terminal prompt layout. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 790faf8 commit 0afdea2

20 files changed

Lines changed: 686 additions & 11 deletions

‎CONTEXT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ One run of the agent against a Conversation built from the active session branch
1313
_Avoid_: Round, exchange, request.
1414

1515
**Session**:
16-
A persisted JSONL file plus in-memory tree state. Distinct from Conversation (the ephemeral projection sent to the runtime for one turn).
16+
A persisted JSONL file plus in-memory tree state. Distinct from Conversation (the ephemeral projection sent to the runtime for one turn). Stored under `%APPDATA%\WinHarness\sessions\{workspaceKey}\` (override the config root with `WINHARNESS_CONFIG_DIR`).
1717
_Avoid_: Thread, chat (overloaded).
1818

1919
**Session entry**:

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ dotnet publish .\src\WinHarness.Cli\WinHarness.Cli.csproj -c Release -r win-x64
7575
- `winharness models use --model-id local-coder`
7676
- `winharness tools list`
7777
- `winharness tools call --name read_file --arguments-json '{"path":"README.md"}'`
78+
- `winharness docs list`
79+
- `winharness docs get --topic sessions`
7880
- `winharness mcp list`
7981
- `winharness mcp add-stdio --id filesystem --command filesystem-mcp-server.exe [--arguments-json '["--root","C:\\src"]' --environment-json '{}' --enabled true]`
8082
- `winharness mcp add-http --id remote --endpoint https://example.com/mcp [--transport http|sse --headers-json '{"X-Client-Name":"WinHarness"}' --enabled true]`

‎src/WinHarness.Cli/Program.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
using WinHarness.Sessions;
2626
using WinHarness.Serialization;
2727
using WinHarness.Tools;
28+
using WinHarness.Tools.Docs;
2829

2930
const string Version = "0.3.0";
3031

@@ -467,6 +468,21 @@ await sink.WriteAsync(
467468
}
468469
});
469470

471+
app.Add("docs list", () =>
472+
{
473+
Console.WriteLine(WinHarnessDocsCatalog.FormatCatalog());
474+
});
475+
476+
app.Add("docs get", (string topic, string? query = null, int maxOutputBytes = 16 * 1024) =>
477+
{
478+
WinHarnessDocsCatalog.DocLookupResult result = WinHarnessDocsCatalog.Lookup(topic, query, maxOutputBytes);
479+
Console.WriteLine(result.Content);
480+
if (!result.Succeeded)
481+
{
482+
Environment.ExitCode = 1;
483+
}
484+
});
485+
470486
app.Add("providers list", () =>
471487
{
472488
WinHarnessOptions options = host.Services.GetRequiredService<WinHarnessOptions>();

‎src/WinHarness.Cli/Rendering/ScreenRegionController.cs‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,41 @@ public static ScreenRegionController Create(IAnsiConsoleConfigurator ansiConfigu
9696
Environment.GetEnvironmentVariable(OptInEnvironmentVariable),
9797
ansiConfigurator.IsVirtualTerminalEnabled);
9898
bool redirected = Console.IsOutputRedirected || Console.IsInputRedirected;
99-
int height = TryGetWindowDimension(Console.WindowHeight, out int h) ? h : -1;
99+
int height = TryGetWindowDimension(Console.WindowHeight, out int h) ? AdjustHeightForHost(h) : -1;
100100
int width = TryGetWindowDimension(Console.WindowWidth, out int w) ? w : -1;
101101
return new ScreenRegionController(ScreenRegionLayout.Resolve(optedIn, redirected, width, height), optedIn, redirected);
102102
}
103103

104+
/// <summary>
105+
/// VS Code / Cursor integrated terminals set <c>TERM_PROGRAM=vscode</c> and
106+
/// clip the bottom row(s) under the panel chrome / Windows taskbar overlap.
107+
/// </summary>
108+
internal static bool IsVsCodeIntegratedTerminal() =>
109+
string.Equals(Environment.GetEnvironmentVariable("TERM_PROGRAM"), "vscode", StringComparison.OrdinalIgnoreCase);
110+
111+
/// <summary>Rows to leave unused at the physical bottom in VS Code/Cursor.</summary>
112+
private const int VsCodeBottomReserveRows = 2;
113+
114+
/// <summary>
115+
/// Returns a layout height that avoids clipped bottom rows in VS Code's
116+
/// integrated terminal. Other hosts use the reported height unchanged.
117+
/// </summary>
118+
internal static int AdjustHeightForHost(int height, bool? vsCodeTerminal = null)
119+
{
120+
if (height <= 0)
121+
{
122+
return height;
123+
}
124+
125+
bool vsCode = vsCodeTerminal ?? IsVsCodeIntegratedTerminal();
126+
if (!vsCode)
127+
{
128+
return height;
129+
}
130+
131+
return Math.Max(ScreenRegionLayout.MinimumHeight, height - VsCodeBottomReserveRows);
132+
}
133+
104134
/// <summary>
105135
/// Maps the override environment value plus the probed terminal capability to
106136
/// the final opt-in decision. Unset → trust the probe; <c>0</c>/<c>false</c> →
@@ -232,7 +262,7 @@ public void OnResize()
232262
return;
233263
}
234264

235-
int height = TryGetWindowDimension(Console.WindowHeight, out int h) ? h : -1;
265+
int height = TryGetWindowDimension(Console.WindowHeight, out int h) ? AdjustHeightForHost(h) : -1;
236266
int width = TryGetWindowDimension(Console.WindowWidth, out int w) ? w : -1;
237267
ScreenRegionLayout next = ScreenRegionLayout.Resolve(_optedIn, _redirected, width, height);
238268
if (next == _layout)
@@ -279,7 +309,11 @@ public void BeginPrompt()
279309
}
280310

281311
// DECSC first so EndPrompt can restore the conversation cursor. Then
282-
// paint the empty prompt (prefix only) via WritePromptInput.
312+
// paint the empty prompt (prefix only) via WritePromptInput. Show the
313+
// cursor while editing — some hosts (notably VS Code's integrated
314+
// terminal) do not repaint the fixed prompt row unless the cursor sits
315+
// on it.
316+
Console.CursorVisible = true;
283317
Console.Write("\u001b7");
284318
WritePromptInput(string.Empty);
285319
}
@@ -331,12 +365,19 @@ public void WritePromptInput(string buffer)
331365

332366
// Paint status + prompt without DECSC/DECRC — BeginPrompt already holds
333367
// the conversation cursor in the single DECSC slot. Status is a cyan
334-
// accent bar; prompt rows stay normal so typing reads clearly.
368+
// accent bar; prompt rows reset SGR and use a green › prefix so typed
369+
// text stays visible on hosts that do not repaint off-cursor rows.
335370
Console.Write($"\u001b[{statusRow};1H\u001b[2K{FormatBar(_footerText, Layout.Width, BarStyle.Status)}");
336371
for (int i = 0; i < promptRows; i++)
337372
{
338-
Console.Write($"\u001b[{statusRow + 1 + i};1H\u001b[2K{wrapped[start + i]}");
373+
int row = statusRow + 1 + i;
374+
Console.Write($"\u001b[{row};1H\u001b[2K{FormatPromptRow(wrapped[start + i])}");
339375
}
376+
377+
string lastPlain = wrapped[start + promptRows - 1];
378+
int cursorRow = statusRow + promptRows;
379+
Console.Write($"\u001b[{cursorRow};{lastPlain.Length + 1}H");
380+
Console.CursorVisible = true;
340381
}
341382

342383
/// <summary>
@@ -362,6 +403,7 @@ public void EndPrompt()
362403
SetRegion(Layout.ScrollTop, Layout.ScrollBottom);
363404
// Put the status bar back on its default row (Height - 1).
364405
Console.Write($"\u001b[{Layout.FooterStatusRow};1H\u001b[2K{FormatBar(_footerText, Layout.Width, BarStyle.Status)}");
406+
Console.CursorVisible = false;
365407
Console.Write("\u001b8");
366408
}
367409

@@ -488,6 +530,22 @@ private static string FormatBar(string text, int width, BarStyle style)
488530
/// </summary>
489531
private void WriteBar(int row, string text, BarStyle style) =>
490532
Console.Write($"\u001b7\u001b[{row};1H\u001b[2K{FormatBar(text, Layout.Width, style)}\u001b8");
533+
534+
/// <summary>
535+
/// Formats one prompt display row with an explicit SGR reset and a green
536+
/// <see cref="PromptLineView.Prefix"/> on the first row only (matches the
537+
/// inactive-path Spectre prompt styling).
538+
/// </summary>
539+
private static string FormatPromptRow(string line)
540+
{
541+
if (!line.StartsWith(PromptLineView.Prefix, StringComparison.Ordinal))
542+
{
543+
return $"\x1b[0m{line}";
544+
}
545+
546+
string content = line[PromptLineView.Prefix.Length..];
547+
return $"\x1b[0m\x1b[1;32m{PromptLineView.Prefix}\x1b[0m{content}";
548+
}
491549
}
492550

493551
/// <summary>

‎src/WinHarness.Core/Runtime/SingleAgentRuntime.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ public sealed class SingleAgentRuntime : IAgentRuntime
2929
- To check whether a CLI exists, use where.exe <name> or pwsh -NoProfile -Command "Get-Command <name>".
3030
- Do not use Unix shell builtins or POSIX-only commands such as command -v, which, bash, sh, ls, cat, rm -rf, chmod, or sudo unless the user explicitly asks for WSL/POSIX and the executable is known to exist.
3131
- Prefer PowerShell equivalents: Get-ChildItem, Get-Content, Remove-Item, New-Item, Set-Content, Join-Path.
32+
33+
WinHarness reference: call winharness_docs before guessing paths or searching the workspace. Omit topic to list subjects (paths, sessions, tools, providers, mcp, chat, diagnostics).
3234
""";
3335

3436
// Safety net for a single streamed response that never stops (e.g. a model that

‎src/WinHarness.Tools/BuiltinToolProvider.cs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ public ValueTask<IReadOnlyList<ITool>> ListToolsAsync(CancellationToken cancella
7373
new EditFileTool(_workspaceRoot, _longPathService),
7474
new RunCommandTool(_workspaceRoot, _commandExecutor, _longPathService),
7575
new GlobTool(_workspaceRoot, _longPathService),
76-
new GrepTool(_workspaceRoot, _longPathService)
76+
new GrepTool(_workspaceRoot, _longPathService),
77+
new WinHarnessDocsTool()
7778
];
7879

7980
return ValueTask.FromResult(tools);
@@ -195,7 +196,7 @@ protected static async ValueTask AtomicWriteAllTextAsync(
195196

196197
private static readonly HashSet<string> IgnoredDirectoryNames = new(StringComparer.OrdinalIgnoreCase)
197198
{
198-
".git", "bin", "obj", "node_modules", ".vs", ".idea", ".vscode"
199+
".git", ".claude", "bin", "obj", "node_modules", ".vs", ".idea", ".vscode"
199200
};
200201

201202
/// <summary>
@@ -608,7 +609,7 @@ protected override ValueTask<CommandResult> ExecuteInteractiveAsync(CommandReque
608609
private sealed class GlobTool : BuiltinTool
609610
{
610611
public GlobTool(string workspaceRoot, ILongPathService longPathService)
611-
: base(workspaceRoot, longPathService, "glob", "List files matching a simple glob pattern (* and ? wildcards; skips .git, bin, obj, node_modules).", """{"type":"object","properties":{"pattern":{"type":"string"},"maxResults":{"type":"integer"}},"required":["pattern"]}""")
612+
: base(workspaceRoot, longPathService, "glob", "List files matching a simple glob pattern (* and ? wildcards; skips .git, .claude, bin, obj, node_modules).", """{"type":"object","properties":{"pattern":{"type":"string"},"maxResults":{"type":"integer"}},"required":["pattern"]}""")
612613
{
613614
}
614615

@@ -639,7 +640,7 @@ public override ValueTask<ToolResult> ExecuteAsync(ToolInvocation invocation, Ca
639640
private sealed class GrepTool : BuiltinTool
640641
{
641642
public GrepTool(string workspaceRoot, ILongPathService longPathService)
642-
: base(workspaceRoot, longPathService, "grep", "Search text files (skips .git, bin, obj, node_modules).", """{"type":"object","properties":{"pattern":{"type":"string"},"filePattern":{"type":"string"},"maxResults":{"type":"integer"},"maxFileBytes":{"type":"integer"}},"required":["pattern"]}""")
643+
: base(workspaceRoot, longPathService, "grep", "Search text files (skips .git, .claude, bin, obj, node_modules).", """{"type":"object","properties":{"pattern":{"type":"string"},"filePattern":{"type":"string"},"maxResults":{"type":"integer"},"maxFileBytes":{"type":"integer"}},"required":["pattern"]}""")
643644
{
644645
}
645646

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System.Reflection;
2+
using System.Text;
3+
4+
namespace WinHarness.Tools.Docs;
5+
6+
/// <summary>
7+
/// Embedded WinHarness agent documentation topics shipped inside the tools assembly.
8+
/// </summary>
9+
public static class WinHarnessDocsCatalog
10+
{
11+
private const string ResourcePrefix = "WinHarness.Docs.topics.";
12+
private const int DefaultMaxOutputBytes = 16 * 1024;
13+
14+
private static readonly DocTopic[] Topics =
15+
[
16+
new("paths", "Configuration and data directory layout"),
17+
new("sessions", "Persisted JSONL chat sessions"),
18+
new("tools", "Built-in workspace tools and run_command rules"),
19+
new("providers", "Providers, models, login, and reasoning effort"),
20+
new("mcp", "MCP server configuration"),
21+
new("chat", "REPL slash commands and chat flags"),
22+
new("diagnostics", "Structured logs and timing fields"),
23+
];
24+
25+
public static IReadOnlyList<DocTopic> ListTopics() => Topics;
26+
27+
public static string FormatCatalog()
28+
{
29+
StringBuilder builder = new();
30+
foreach (DocTopic topic in Topics)
31+
{
32+
builder.Append(topic.Id);
33+
builder.Append(" — ");
34+
builder.AppendLine(topic.Summary);
35+
}
36+
37+
return builder.ToString().TrimEnd();
38+
}
39+
40+
public static DocLookupResult Lookup(string? topicId, string? query, int maxOutputBytes)
41+
{
42+
if (string.IsNullOrWhiteSpace(topicId))
43+
{
44+
return new DocLookupResult(true, FormatCatalog(), null);
45+
}
46+
47+
string normalizedId = topicId.Trim();
48+
DocTopic? topic = Topics.FirstOrDefault(candidate =>
49+
string.Equals(candidate.Id, normalizedId, StringComparison.OrdinalIgnoreCase));
50+
if (topic is null)
51+
{
52+
string message = $"Unknown topic '{normalizedId}'. Valid topics: {string.Join(", ", Topics.Select(static t => t.Id))}.";
53+
return new DocLookupResult(false, message, "unknown_topic");
54+
}
55+
56+
string? body = LoadTopicBody(topic.Id);
57+
if (body is null)
58+
{
59+
return new DocLookupResult(false, $"Topic '{topic.Id}' is registered but its embedded document is missing.", "missing_topic");
60+
}
61+
62+
if (!string.IsNullOrWhiteSpace(query))
63+
{
64+
body = FilterByQuery(body, query);
65+
}
66+
67+
int limit = maxOutputBytes > 0 ? maxOutputBytes : DefaultMaxOutputBytes;
68+
body = Truncate(body, limit);
69+
return new DocLookupResult(true, body, null);
70+
}
71+
72+
private static string? LoadTopicBody(string topicId)
73+
{
74+
string resourceName = ResourcePrefix + topicId + ".md";
75+
Assembly assembly = typeof(WinHarnessDocsCatalog).Assembly;
76+
using Stream? stream = assembly.GetManifestResourceStream(resourceName);
77+
if (stream is null)
78+
{
79+
return null;
80+
}
81+
82+
using StreamReader reader = new(stream, Encoding.UTF8);
83+
return reader.ReadToEnd();
84+
}
85+
86+
private static string FilterByQuery(string body, string query)
87+
{
88+
string needle = query.Trim();
89+
if (needle.Length == 0)
90+
{
91+
return body;
92+
}
93+
94+
StringBuilder builder = new();
95+
foreach (string line in body.Split('\n'))
96+
{
97+
if (line.Contains(needle, StringComparison.OrdinalIgnoreCase))
98+
{
99+
if (builder.Length > 0)
100+
{
101+
builder.AppendLine();
102+
}
103+
104+
builder.Append(line.TrimEnd('\r'));
105+
}
106+
}
107+
108+
return builder.Length == 0
109+
? $"No lines matched query '{needle}'."
110+
: builder.ToString();
111+
}
112+
113+
private static string Truncate(string content, int maxBytes)
114+
{
115+
byte[] bytes = Encoding.UTF8.GetBytes(content);
116+
if (bytes.Length <= maxBytes)
117+
{
118+
return content;
119+
}
120+
121+
int limit = Math.Max(0, maxBytes);
122+
while (limit > 0 && (bytes[limit] & 0xC0) == 0x80)
123+
{
124+
limit--;
125+
}
126+
127+
return Encoding.UTF8.GetString(bytes.AsSpan(0, limit)) + Environment.NewLine + "[output truncated]";
128+
}
129+
130+
public sealed record DocTopic(string Id, string Summary);
131+
132+
public sealed record DocLookupResult(bool Succeeded, string Content, string? ErrorCode);
133+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# WinHarness chat REPL
2+
3+
## Launch
4+
5+
```text
6+
winharness chat
7+
winharness chat --verbose
8+
winharness chat --output json
9+
winharness rpc
10+
```
11+
12+
Default: continues the most recent persisted session for the current directory.
13+
14+
## Common slash commands
15+
16+
| Command | Purpose |
17+
| --- | --- |
18+
| `/help` | Command list |
19+
| `/session` | Session file path, id, name, counts |
20+
| `/new` | New session file |
21+
| `/resume` | Pick a saved session |
22+
| `/model` / `/provider` | Switch model or provider |
23+
| `/effort` | Reasoning effort |
24+
| `/skills` / `/skill` | Session skills |
25+
| `/compact` | Summarize older context |
26+
| `/clear` | Clear in-memory view (file unchanged) |
27+
| `/exit` | Leave |
28+
29+
See `winharness_docs` topic `sessions` for session-specific commands.
30+
31+
## While a turn is running
32+
33+
| Input | Behavior |
34+
| --- | --- |
35+
| text + Enter | Follow-up queued for after the turn |
36+
| `>> text` + Enter | Steering injected between tool rounds |
37+
| `/abort` | Cancel turn |
38+
39+
## Context files (project)
40+
41+
Loaded from workspace: `AGENTS.md`, `CLAUDE.md`, `.winharness/SYSTEM.md`, skills under `.agents/skills` or `.winharness/skills` when trusted.
42+
43+
Global copies under `%APPDATA%\WinHarness\`.
44+
45+
## WinHarness self-reference
46+
47+
Use `winharness_docs` (or `winharness docs get --topic <id>`) for paths, sessions, tools, and diagnostics — not workspace grep.

0 commit comments

Comments
 (0)