Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bea9fd5148 | |||
| 56cc9eb6ad | |||
| 2069029d73 | |||
| a99430c247 | |||
| 9432300e3c | |||
| a42e5f145d | |||
| 26628c0dbe | |||
| 644885c6ec |
+53
-15
@@ -59,6 +59,21 @@
|
|||||||
"title": "Apply Agent Edit (internal seam)",
|
"title": "Apply Agent Edit (internal seam)",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.edit",
|
||||||
|
"title": "Ask Claude to Edit",
|
||||||
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude.submit",
|
||||||
|
"title": "Ask Claude to Edit",
|
||||||
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude.cancel",
|
||||||
|
"title": "Cancel",
|
||||||
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.editSelection",
|
"command": "cowriting.editSelection",
|
||||||
"title": "Ask Claude to Edit Selection",
|
"title": "Ask Claude to Edit Selection",
|
||||||
@@ -118,14 +133,30 @@
|
|||||||
"command": "cowriting.rejectProposal",
|
"command": "cowriting.rejectProposal",
|
||||||
"when": "false"
|
"when": "false"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude.submit",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude.cancel",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.pinDiffBaseline",
|
"command": "cowriting.pinDiffBaseline",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.editDocument",
|
"command": "cowriting.edit",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.editSelection",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.editDocument",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.acceptAllProposals",
|
"command": "cowriting.acceptAllProposals",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
@@ -140,13 +171,8 @@
|
|||||||
],
|
],
|
||||||
"editor/title/context": [
|
"editor/title/context": [
|
||||||
{
|
{
|
||||||
"command": "cowriting.editSelection",
|
"command": "cowriting.edit",
|
||||||
"when": "editorHasSelection && resourceLangId == markdown",
|
"when": "resourceLangId == markdown",
|
||||||
"group": "1_cowriting@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "cowriting.editDocument",
|
|
||||||
"when": "!editorHasSelection && resourceLangId == markdown",
|
|
||||||
"group": "1_cowriting@1"
|
"group": "1_cowriting@1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -164,13 +190,8 @@
|
|||||||
],
|
],
|
||||||
"editor/context": [
|
"editor/context": [
|
||||||
{
|
{
|
||||||
"command": "cowriting.editSelection",
|
"command": "cowriting.edit",
|
||||||
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||||
"group": "1_cowriting@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "cowriting.editDocument",
|
|
||||||
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
|
||||||
"group": "1_cowriting@1"
|
"group": "1_cowriting@1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -184,6 +205,11 @@
|
|||||||
"command": "cowriting.reply",
|
"command": "cowriting.reply",
|
||||||
"group": "inline",
|
"group": "inline",
|
||||||
"when": "commentController == cowriting.threads"
|
"when": "commentController == cowriting.threads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude.submit",
|
||||||
|
"group": "inline",
|
||||||
|
"when": "commentController == cowriting.askClaude"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"comments/commentThread/title": [
|
"comments/commentThread/title": [
|
||||||
@@ -196,6 +222,11 @@
|
|||||||
"command": "cowriting.reopenThread",
|
"command": "cowriting.reopenThread",
|
||||||
"group": "inline",
|
"group": "inline",
|
||||||
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.askClaude.cancel",
|
||||||
|
"group": "inline",
|
||||||
|
"when": "commentController == cowriting.askClaude"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -203,7 +234,14 @@
|
|||||||
{
|
{
|
||||||
"command": "cowriting.showTrackChangesPreview",
|
"command": "cowriting.showTrackChangesPreview",
|
||||||
"key": "ctrl+alt+r",
|
"key": "ctrl+alt+r",
|
||||||
|
"mac": "cmd+alt+r",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.edit",
|
||||||
|
"key": "ctrl+alt+e",
|
||||||
|
"mac": "cmd+alt+e",
|
||||||
|
"when": "editorTextFocus && editorLangId == markdown"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Session 0056.0 — Transcript
|
||||||
|
|
||||||
|
> App: vscode-cowriting-plugin
|
||||||
|
> Start: 2026-06-26T04-24 (PST)
|
||||||
|
> End: 2026-06-26T04-54 (PST)
|
||||||
|
> Type: planning-and-executing
|
||||||
|
> Posture: autonomous (yolo)
|
||||||
|
> Status: **FINALIZED**
|
||||||
|
|
||||||
|
## Launch prompt
|
||||||
|
|
||||||
|
`/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md`
|
||||||
|
|
||||||
|
(Continuation of brainstorming session 0055, which graduated the spec; the
|
||||||
|
operator said "implement it" → this coding session.)
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Plan + execute **#60** (P1 feature — live turn progress) from the graduated
|
||||||
|
Solution Design `coauthoring-live-progress.md`. Four cuts: (1) pure
|
||||||
|
`turnProgress.ts` reducer + tests; (2) `runEditTurn` + `onProgress`/`AbortSignal`;
|
||||||
|
(3) `liveProgressUi` + setting + both call sites; (4) host-E2E + smoke.
|
||||||
|
Branch `s60-live-progress` → PR → main.
|
||||||
|
|
||||||
|
## Pre-state
|
||||||
|
|
||||||
|
- Clean `main` at session start (fast-forwarded 13 commits — the sessions/ repo
|
||||||
|
is the code repo itself). Baseline: 222 unit green.
|
||||||
|
- #60 has a graduated Solution Design → §4.3 R2(b) satisfied.
|
||||||
|
|
||||||
|
## Session arc
|
||||||
|
|
||||||
|
1. **Claim + plan.** Claimed 0056 (no in-flight). Wrote the implementation plan
|
||||||
|
with `superpowers:writing-plans` → `docs/superpowers/plans/2026-06-26-live-turn-progress.md`
|
||||||
|
(self-reviewed: full spec coverage, no placeholders). Grounded in the exact SDK
|
||||||
|
types (`AgentToolCallPart.toolName`, `AgentUsage.inputTokens+outputTokens`), the
|
||||||
|
vitest/host-E2E patterns, and the injectable `editTurn` seam. Branch
|
||||||
|
`s60-live-progress`.
|
||||||
|
2. **Task 1 — pure reducer.** `src/turnProgress.ts` + `test/turnProgress.test.ts`
|
||||||
|
(13 cases). Fixed the type-only event import: `AgentRuntimeEvent` is exported
|
||||||
|
from **`@cline/shared`**, not `@cline/sdk` (which doesn't re-export it) — caught
|
||||||
|
by typecheck; amended the commit.
|
||||||
|
3. **Task 2 — `runEditTurn`.** Added `RunEditTurnOptions {onProgress, signal}`;
|
||||||
|
subscribes to the agent, folds events through the reducer, wires `signal` →
|
||||||
|
`agent.abort()`; `finally` unsubscribes. Mocked-agent unit tests (vitest
|
||||||
|
`doMock` intercepts the dynamic import).
|
||||||
|
4. **Task 3 — host relay.** `src/liveProgressUi.ts` (notification line +
|
||||||
|
"Cowriting: Claude" OutputChannel, reveal gated by setting) + `package.json`
|
||||||
|
`contributes.configuration` `cowriting.liveProgress.revealOutput`.
|
||||||
|
5. **Tasks 4+5 — both call sites.** `editSelection` (extension.ts) and preview
|
||||||
|
`askClaude`/`runEditAndPropose` (trackChangesPreview.ts) made cancellable,
|
||||||
|
relaying via the shared `liveProgressUi`; widened the `EditTurn` seam to accept
|
||||||
|
`opts?` (back-compat). Added `liveProgressUi` to `CowritingApi`.
|
||||||
|
6. **Task 6 — host E2E.** `test/e2e/suite/liveProgress.test.ts`: progress is
|
||||||
|
additive (INV-44), aborted turn proposes nothing (INV-47).
|
||||||
|
7. **Task 7 — verify + smoke.** typecheck + 237 unit + build green; enhanced
|
||||||
|
`scripts/smoke-live-turn.mjs` to log progress.
|
||||||
|
8. **Subagent review.** Found a **Medium** (a pre-aborted `AbortSignal` was a
|
||||||
|
no-op — `agent.abort()` runs before the SDK's AbortController exists, so the
|
||||||
|
turn ran to completion) + a **Low** (a throwing `onProgress` relay could fail
|
||||||
|
the turn). Fixed both: short-circuit (throw) before `run()` on a pre-aborted
|
||||||
|
signal; wrap the relay in try/catch. Strengthened the unit + E2E to prove it.
|
||||||
|
9. **Ship.** Pushed; opened PR #61; operator approved merge despite the
|
||||||
|
concurrency (below). Squash-merged `644885c`; issue #60 auto-closed.
|
||||||
|
|
||||||
|
## ⚠️ Concurrency event (mid-session)
|
||||||
|
|
||||||
|
Partway through, the shared working tree gained **uncommitted changes this session
|
||||||
|
did NOT make** — a `cowriting.edit` / `routeEdit` reach refactor across
|
||||||
|
`src/workspacePath.ts`, `test/workspacePath.test.ts`, and the menu/`routeEdit`
|
||||||
|
parts of `package.json` + `src/extension.ts`. It broke 3 E2E (`f12Reach` ×2,
|
||||||
|
`f11Toolbar` ×1) that still expect the old `editSelection`/`editDocument` command
|
||||||
|
surface. Handling: committed **nothing** of it, discarded **nothing**; verified
|
||||||
|
#60 green in isolation by stashing it (by path, reversible) — 237 unit + both #60
|
||||||
|
E2E pass, only the documented `F10 #38` undo-sandbox flake remains — then popped
|
||||||
|
the stash to restore it. Surfaced to the operator, who chose to merge #60 (its 7
|
||||||
|
commits are isolated; the PR diff contains none of the foreign WIP).
|
||||||
|
|
||||||
|
## Cut state (at finalize)
|
||||||
|
|
||||||
|
- **#60 merged** to main (PR #61 squash `644885c`); issue #60 closed.
|
||||||
|
- **Plan archived:** `submit-plan.sh` → content repo `plans/2026-06-26-live-turn-progress.md` (`22ec57e`).
|
||||||
|
- **Foreign refactor WIP:** still uncommitted in the working tree, untouched —
|
||||||
|
for whoever is doing it to continue/commit (it rebases onto the new main).
|
||||||
|
- **Local tree:** left on branch `s60-live-progress` (merged) DELIBERATELY — a
|
||||||
|
`checkout main`/`pull` would have disturbed the foreign uncommitted WIP. Local
|
||||||
|
main not synced; origin/main has #60. Operator should reconcile the local tree
|
||||||
|
+ the in-flight refactor.
|
||||||
|
- **Memory:** added `session-0056-60-live-progress-shipped.md` + index line.
|
||||||
|
|
||||||
|
## Next-session prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
/wgl-planning-and-executing reconcile the local tree (sync main; land/branch the in-flight cowriting.edit/routeEdit refactor), then pick the next item (open: #59 P1 bug, #57, #58, #32, #35, #40, OQ-2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deferred decisions
|
||||||
|
|
||||||
|
- **Left local on `s60-live-progress` (did not sync main):** to avoid disturbing
|
||||||
|
the concurrent uncommitted refactor in the shared tree. Low confidence this is
|
||||||
|
the tidiest end state, but it is the safest for the foreign WIP. Operator to
|
||||||
|
reconcile.
|
||||||
|
- **Token field for the activity line:** used `inputTokens + outputTokens` from
|
||||||
|
`AgentUsage` (no `totalTokens` field exists). Cache tokens excluded.
|
||||||
|
- **OutputChannel append-not-clear + auto-reveal gating:** per spec §3.5; carried
|
||||||
|
from session 0055's confirmed sub-decisions.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Session 0057.0 — Transcript
|
||||||
|
|
||||||
|
> App: vscode-cowriting-plugin
|
||||||
|
> Start: 2026-06-26T04-35 (PST)
|
||||||
|
> Type: executing-plans
|
||||||
|
> Posture: yolo
|
||||||
|
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
|
||||||
|
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||||
|
>
|
||||||
|
> This file reserves session ID 0057 for vscode-cowriting-plugin. The driver replaces this
|
||||||
|
> body with the full transcript and renames the file to its final
|
||||||
|
> SESSION-0057.0-TRANSCRIPT-2026-06-26T04-35--<end>.md form at session end.
|
||||||
|
|
||||||
|
## Launch prompt
|
||||||
|
|
||||||
|
_(launch prompt not captured at claim time)_
|
||||||
|
|
||||||
|
## Deferred decisions
|
||||||
|
|
||||||
|
_Autonomous-mode low-confidence calls the driver made and would have
|
||||||
|
liked operator input on. Appended as the session runs; surfaced at
|
||||||
|
finalize. Empty if none._
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Session 0058.0 — Transcript
|
||||||
|
|
||||||
|
> App: vscode-cowriting-plugin
|
||||||
|
> Start: 2026-06-26T05-12 (PST)
|
||||||
|
> End: 2026-06-26T05-58 (PST)
|
||||||
|
> Type: brainstorming
|
||||||
|
> Posture: autonomous (yolo)
|
||||||
|
> Status: **FINALIZED**
|
||||||
|
|
||||||
|
## Launch prompt
|
||||||
|
|
||||||
|
Operator opened by asking what was in the Gitea backlog, then directed a
|
||||||
|
brainstorming session for a new feature: *"We should also show the proposed change
|
||||||
|
in the Markdown file in the same way. The proposed change in the markdown file
|
||||||
|
should be human-editable, show the diff just like it would after the change is
|
||||||
|
completed in both the markdown file and the webview as the change is made. The
|
||||||
|
diff in both the markdown file and preview webview should be exactly as they will
|
||||||
|
be shown if the edit is accepted. Create a new working tree in a directory other
|
||||||
|
than main for this work."* — extended mid-session with: *"replace the checkbox
|
||||||
|
icon and 'x' icon on the proposed change as 'Accept' and 'Reject'. Each should
|
||||||
|
have an arrow next to them for a dropdown that is 'Accept All' or 'Reject All'.
|
||||||
|
One should be able to accept, reject, accept all, or reject all from either the
|
||||||
|
markdown file or the web preview file."*
|
||||||
|
|
||||||
|
## Pre-state
|
||||||
|
|
||||||
|
- Branch `main`, clean/pushed baseline (after FF). Latest shipped work: #60 live
|
||||||
|
turn progress (session 0056, PR #61). Session 0057 (a `cowriting.edit`/`routeEdit`
|
||||||
|
refactor) still **in-flight** sharing the checkout.
|
||||||
|
- Backlog at session start (8 open): #59 (P1 bug, Apple-Events prompt), #54/#57/#58
|
||||||
|
(P2), #32/#35/#40 (P3), #62 (untriaged).
|
||||||
|
|
||||||
|
## Turn-by-turn arc
|
||||||
|
|
||||||
|
1. **Backlog query** — listed the 8 open Gitea issues for `benstull/vscode-cowriting-plugin`
|
||||||
|
by priority; flagged #62 as the only untriaged item.
|
||||||
|
2. **Session routing** — operator's feature request was feature-shaped *and*
|
||||||
|
reversed a locked invariant (F10/INV-32 "clean editor"), so the session type
|
||||||
|
was ambiguous. Asked; operator chose **brainstorm a spec first**. Routed via
|
||||||
|
`wgl-brainstorming`; claimed session **0058**.
|
||||||
|
3. **Isolation** — session 0057 still in-flight on the shared checkout → created an
|
||||||
|
isolated worktree `vscode-cowriting-plugin-s58` on branch
|
||||||
|
`s58-inline-editor-diff` off `origin/main` (§5.4).
|
||||||
|
4. **Exploration** — dispatched an Explore agent that mapped five areas: webview
|
||||||
|
render engine (`trackChangesModel.ts`), F4 proposal model + seam, the F10
|
||||||
|
clean-editor decision (zero editor decorations remain), controllers/wiring, and
|
||||||
|
commands/toolbar. Key constraint surfaced: **VS Code text editors cannot host
|
||||||
|
editable phantom text** — anything editable in the editor is buffer content;
|
||||||
|
decorations only style existing text or inject *non-editable* before/after
|
||||||
|
content.
|
||||||
|
5. **Four design forks (AskUserQuestion, with ASCII previews):**
|
||||||
|
- Editor model → **optimistic apply + decorations**.
|
||||||
|
- Timing → **on proposal** (not live token-stream into the editor).
|
||||||
|
- Editor affordance → **CodeLens `Accept ▾ / Reject ▾` + QuickPick dropdown**.
|
||||||
|
- (Operator-added) Control parity → Accept/Reject/Accept-all/Reject-all from
|
||||||
|
both surfaces; ✓/✗ superseded.
|
||||||
|
6. **Design presented** section-by-section; operator approved with "keep going"
|
||||||
|
(nodded the two flagged sub-decisions: dedicated `EditorProposalController`;
|
||||||
|
allow save of pending text).
|
||||||
|
7. **Anchor + spec** — filed Feature **#64** (`type/feature`, `priority/P2`) since
|
||||||
|
no tracker anchor existed; wrote `specs/coauthoring-inline-editor-diff.md`
|
||||||
|
(INV-48..54, reversing INV-10/INV-32). Self-review clean. Operator **approved**.
|
||||||
|
|
||||||
|
## Cut state
|
||||||
|
|
||||||
|
- Spec **graduated** → content repo `vscode-cowriting-plugin-content` at `80d5d1a`
|
||||||
|
(`specs/coauthoring-inline-editor-diff.md`).
|
||||||
|
- Feature **#64** filed + labelled.
|
||||||
|
- Worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`) stands
|
||||||
|
ready for the downstream planning-and-executing session; no code committed this
|
||||||
|
session (brainstorming output is the spec only).
|
||||||
|
- Memory updated: `f12-inline-editor-diff-spec-graduated.md` + MEMORY.md index.
|
||||||
|
|
||||||
|
## Deferred decisions
|
||||||
|
|
||||||
|
No autonomous low-confidence calls — every fork was decided live with the operator
|
||||||
|
via AskUserQuestion, and the spec was operator-approved before submission. (The
|
||||||
|
two design sub-decisions — dedicated controller, allow-save-of-pending — were
|
||||||
|
flagged in the presented design and nodded by the operator.)
|
||||||
|
|
||||||
|
## Next-session prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
/goal Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown editor) from specs/coauthoring-inline-editor-diff.md, in worktree vscode-cowriting-plugin-s58 (branch s58-inline-editor-diff); follow the spec's 5-slice Delivery Plan
|
||||||
|
```
|
||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
# Session 0056.0 — Transcript
|
# Session 0059.0 — Transcript
|
||||||
|
|
||||||
> App: vscode-cowriting-plugin
|
> App: vscode-cowriting-plugin
|
||||||
> Start: 2026-06-26T04-24 (PST)
|
> Start: 2026-06-26T06-01 (PST)
|
||||||
> Type: planning-and-executing
|
> Type: planning-and-executing
|
||||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||||
>
|
>
|
||||||
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
|
> This file reserves session ID 0059 for vscode-cowriting-plugin. The driver replaces this
|
||||||
> body with the full transcript and renames the file to its final
|
> body with the full transcript and renames the file to its final
|
||||||
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
|
> SESSION-0059.0-TRANSCRIPT-2026-06-26T06-01--<end>.md form at session end.
|
||||||
|
|
||||||
## Launch prompt
|
## Launch prompt
|
||||||
|
|
||||||
@@ -166,5 +166,14 @@
|
|||||||
},
|
},
|
||||||
"0056": {
|
"0056": {
|
||||||
"title": ""
|
"title": ""
|
||||||
|
},
|
||||||
|
"0057": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
|
"0058": {
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
|
"0059": {
|
||||||
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-11
@@ -12,7 +12,8 @@ import { SidecarRouter } from "./sidecarRouter";
|
|||||||
import { DiffViewController } from "./diffViewController";
|
import { DiffViewController } from "./diffViewController";
|
||||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||||
import { LiveProgressUi } from "./liveProgressUi";
|
import { LiveProgressUi } from "./liveProgressUi";
|
||||||
import { isAuthorable, selectionRejection } from "./workspacePath";
|
import { InlineAskController } from "./inlineAsk";
|
||||||
|
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export interface CowritingApi {
|
|||||||
trackChangesPreviewController: TrackChangesPreviewController;
|
trackChangesPreviewController: TrackChangesPreviewController;
|
||||||
sidecarRouter: SidecarRouter;
|
sidecarRouter: SidecarRouter;
|
||||||
liveProgressUi: LiveProgressUi;
|
liveProgressUi: LiveProgressUi;
|
||||||
|
inlineAsk: InlineAskController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -36,6 +38,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
// OutputChannel) for both Ask-Claude entry points.
|
// OutputChannel) for both Ask-Claude entry points.
|
||||||
const liveProgressUi = new LiveProgressUi();
|
const liveProgressUi = new LiveProgressUi();
|
||||||
context.subscriptions.push(liveProgressUi);
|
context.subscriptions.push(liveProgressUi);
|
||||||
|
|
||||||
|
// The inline "Ask Claude to Edit" prompt — rendered at the selection/cursor via
|
||||||
|
// the Comments API rather than the top-center QuickInput (see inlineAsk.ts).
|
||||||
|
// Shared by both Ask-Claude entry points (editSelection + the preview's askClaude).
|
||||||
|
const inlineAsk = new InlineAskController(context.subscriptions);
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -112,6 +119,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
attributionController,
|
attributionController,
|
||||||
proposalController,
|
proposalController,
|
||||||
liveProgressUi,
|
liveProgressUi,
|
||||||
|
inlineAsk,
|
||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
context.subscriptions.push(trackChangesPreviewController);
|
||||||
|
|
||||||
@@ -217,17 +225,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!editor) return; // unreachable once reason is null, but narrows the type
|
if (!editor) return; // unreachable once reason is null, but narrows the type
|
||||||
const instruction = await vscode.window.showInputBox({
|
|
||||||
prompt: "What should Claude do with the selection?",
|
|
||||||
placeHolder: "e.g. tighten this paragraph",
|
|
||||||
});
|
|
||||||
if (!instruction) return;
|
|
||||||
if (editor.selection.isEmpty) {
|
|
||||||
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const document = editor.document;
|
const document = editor.document;
|
||||||
const selection = editor.selection;
|
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
|
||||||
|
// The instruction prompt opens INLINE at the selection (inlineAsk), not the
|
||||||
|
// top-center QuickInput — anchored to the text Claude will edit.
|
||||||
|
const instruction = await inlineAsk.prompt(document.uri, selection);
|
||||||
|
if (!instruction) return;
|
||||||
const selectedText = document.getText(selection);
|
const selectedText = document.getText(selection);
|
||||||
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
|
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
|
||||||
// can't skew it — the proposal renders wherever the target re-resolves.
|
// can't skew it — the proposal renders wherever the target re-resolves.
|
||||||
@@ -299,6 +302,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The single user-facing "Ask Claude to Edit" gesture (one command, one
|
||||||
|
// keybinding, one menu entry). It routes to the selection flow (editSelection)
|
||||||
|
// or the whole-document flow (editDocument) by selection/context — see
|
||||||
|
// routeEdit. The two underlying commands stay registered for the host E2E
|
||||||
|
// harness and the internal seams, but are hidden from the palette.
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
const route = routeEdit({
|
||||||
|
hasUri: !!uri,
|
||||||
|
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
|
||||||
|
hasActiveEditor: !!editor,
|
||||||
|
selectionEmpty: editor?.selection.isEmpty ?? true,
|
||||||
|
});
|
||||||
|
if (route === "selection") {
|
||||||
|
await vscode.commands.executeCommand("cowriting.editSelection");
|
||||||
|
} else {
|
||||||
|
await vscode.commands.executeCommand("cowriting.editDocument", uri);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Render threads + attributions for already-open editors, and on future opens.
|
// Render threads + attributions for already-open editors, and on future opens.
|
||||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||||
if (isAuthorable(doc.uri.scheme)) {
|
if (isAuthorable(doc.uri.scheme)) {
|
||||||
@@ -319,6 +344,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
trackChangesPreviewController,
|
trackChangesPreviewController,
|
||||||
sidecarRouter,
|
sidecarRouter,
|
||||||
liveProgressUi,
|
liveProgressUi,
|
||||||
|
inlineAsk,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
/** Trim the reply text; an empty/whitespace-only instruction is "no instruction". */
|
||||||
|
export function normalizeInstruction(text: string | undefined): string | undefined {
|
||||||
|
const t = text?.trim();
|
||||||
|
return t ? t : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Ask Claude to Edit" prompt, rendered INLINE at the selection (or cursor)
|
||||||
|
* instead of the top-center QuickInput. VS Code's native pattern for "ask AI to
|
||||||
|
* edit this" is Inline Chat — an input anchored in the editor at the target. The
|
||||||
|
* stable-API equivalent for a third-party extension is the Comments API: a
|
||||||
|
* transient comment thread whose reply box renders right at the range. (The
|
||||||
|
* coauthoring THREADS feature already uses the Comments API via its own
|
||||||
|
* controller — this is a SEPARATE, dedicated `cowriting.askClaude` controller so
|
||||||
|
* the two never collide.)
|
||||||
|
*
|
||||||
|
* One prompt is live at a time; opening a new one cancels the previous. The
|
||||||
|
* thread carries no comments — it is purely the inline input — and is disposed
|
||||||
|
* as soon as the user submits or cancels.
|
||||||
|
*/
|
||||||
|
export class InlineAskController {
|
||||||
|
private readonly controller: vscode.CommentController;
|
||||||
|
private pending?: { resolve: (v: string | undefined) => void; thread: vscode.CommentThread };
|
||||||
|
|
||||||
|
constructor(disposables: vscode.Disposable[]) {
|
||||||
|
this.controller = vscode.comments.createCommentController("cowriting.askClaude", "Ask Claude to Edit");
|
||||||
|
// The reply box's prompt + placeholder (Comments API options).
|
||||||
|
this.controller.options = {
|
||||||
|
prompt: "Ask Claude to Edit",
|
||||||
|
placeHolder: "e.g. tighten this paragraph",
|
||||||
|
};
|
||||||
|
disposables.push(
|
||||||
|
this.controller,
|
||||||
|
vscode.commands.registerCommand("cowriting.askClaude.submit", (r: vscode.CommentReply) =>
|
||||||
|
this.finish(normalizeInstruction(r?.text)),
|
||||||
|
),
|
||||||
|
vscode.commands.registerCommand("cowriting.askClaude.cancel", () => this.finish(undefined)),
|
||||||
|
// The controller itself disposes the live thread on extension teardown.
|
||||||
|
new vscode.Disposable(() => this.finish(undefined)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the inline input anchored at `range` in `uri`'s editor and resolve with
|
||||||
|
* the typed instruction (or `undefined` if cancelled / left empty). Replaces
|
||||||
|
* any prompt already open.
|
||||||
|
*/
|
||||||
|
prompt(uri: vscode.Uri, range: vscode.Range): Promise<string | undefined> {
|
||||||
|
// A fresh prompt supersedes any prior one (resolve it as cancelled).
|
||||||
|
this.finish(undefined);
|
||||||
|
return new Promise<string | undefined>((resolve) => {
|
||||||
|
const thread = this.controller.createCommentThread(uri, range, []);
|
||||||
|
thread.label = "Ask Claude to Edit";
|
||||||
|
thread.canReply = true;
|
||||||
|
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||||
|
thread.contextValue = "askClaude";
|
||||||
|
this.pending = { resolve, thread };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the live prompt (if any) and tear its thread down. */
|
||||||
|
private finish(value: string | undefined): void {
|
||||||
|
const p = this.pending;
|
||||||
|
this.pending = undefined;
|
||||||
|
if (!p) return;
|
||||||
|
p.thread.dispose();
|
||||||
|
p.resolve(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,14 @@ export interface Proposal {
|
|||||||
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
||||||
*/
|
*/
|
||||||
granularity?: "block" | "single";
|
granularity?: "block" | "single";
|
||||||
|
/**
|
||||||
|
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
|
||||||
|
* optimistically applied to the buffer. `replacement` is now in the buffer and
|
||||||
|
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
|
||||||
|
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
|
||||||
|
* proposal created but not yet optimistically applied (or older sidecars).
|
||||||
|
*/
|
||||||
|
original?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
@@ -252,6 +260,8 @@ export function serializeArtifact(a: Artifact): string {
|
|||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
||||||
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
||||||
|
...(p.original !== undefined ? { original: p.original } : {}),
|
||||||
|
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
|
||||||
},
|
},
|
||||||
p,
|
p,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { buildFingerprint } from "./anchorer";
|
|||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||||
import type { LiveProgressUi } from "./liveProgressUi";
|
import type { LiveProgressUi } from "./liveProgressUi";
|
||||||
|
import type { InlineAskController } from "./inlineAsk";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
||||||
@@ -74,6 +75,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
private readonly attribution: AttributionController,
|
private readonly attribution: AttributionController,
|
||||||
private readonly proposals: ProposalController,
|
private readonly proposals: ProposalController,
|
||||||
private readonly liveProgressUi: LiveProgressUi,
|
private readonly liveProgressUi: LiveProgressUi,
|
||||||
|
private readonly inlineAsk: InlineAskController,
|
||||||
) {
|
) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||||
@@ -220,19 +222,34 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the inline Ask-Claude input anchors: a range edit pins to its selection;
|
||||||
|
* a whole-document edit pins to the editor's cursor line if this document is the
|
||||||
|
* active editor, else to the document start.
|
||||||
|
*/
|
||||||
|
private editTargetAnchor(document: vscode.TextDocument, target: EditTarget): vscode.Range {
|
||||||
|
if (target.kind === "range") {
|
||||||
|
return new vscode.Range(document.positionAt(target.start), document.positionAt(target.end));
|
||||||
|
}
|
||||||
|
const active = vscode.window.activeTextEditor;
|
||||||
|
if (active && active.document.uri.toString() === document.uri.toString()) {
|
||||||
|
const line = active.selection.active.line;
|
||||||
|
return new vscode.Range(line, 0, line, 0);
|
||||||
|
}
|
||||||
|
return new vscode.Range(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||||
*/
|
*/
|
||||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||||
const instruction = await vscode.window.showInputBox({
|
// The instruction prompt opens INLINE (inlineAsk) at the target: the selected
|
||||||
prompt:
|
// range for a range edit, or the editor's cursor line / the document start for
|
||||||
target.kind === "document"
|
// a whole-document edit — never the top-center QuickInput.
|
||||||
? "What should Claude do with the document?"
|
const anchor = this.editTargetAnchor(document, target);
|
||||||
: "What should Claude do with the selection?",
|
const instruction = await this.inlineAsk.prompt(document.uri, anchor);
|
||||||
placeHolder: "e.g. tighten the prose",
|
|
||||||
});
|
|
||||||
if (!instruction) return;
|
if (!instruction) return;
|
||||||
try {
|
try {
|
||||||
const ids = await vscode.window.withProgress(
|
const ids = await vscode.window.withProgress(
|
||||||
|
|||||||
@@ -60,3 +60,29 @@ export function selectionRejection(ctx: SelectionContext): string | null {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditRouteContext {
|
||||||
|
/** Was the command invoked on a specific resource (a tab right-click)? */
|
||||||
|
hasUri: boolean;
|
||||||
|
/** Does that resource match the focused editor's document? */
|
||||||
|
uriMatchesActiveEditor: boolean;
|
||||||
|
/** Is there a focused text editor at all? */
|
||||||
|
hasActiveEditor: boolean;
|
||||||
|
/** Is the focused editor's selection empty (no highlight)? */
|
||||||
|
selectionEmpty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
|
||||||
|
* selection or whole-document flow. One conceptual command, two destinations:
|
||||||
|
*
|
||||||
|
* - A tab right-click on a document that ISN'T the focused editor has no
|
||||||
|
* selection to act on → edit the whole document.
|
||||||
|
* - Otherwise a non-empty selection in the focused editor → edit the selection;
|
||||||
|
* an empty selection (or no editor) → edit the whole document.
|
||||||
|
*/
|
||||||
|
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
|
||||||
|
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
|
||||||
|
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
|||||||
"cowriting.reply",
|
"cowriting.reply",
|
||||||
"cowriting.resolveThread",
|
"cowriting.resolveThread",
|
||||||
"cowriting.reopenThread",
|
"cowriting.reopenThread",
|
||||||
|
"cowriting.edit",
|
||||||
"cowriting.editSelection",
|
"cowriting.editSelection",
|
||||||
|
"cowriting.askClaude.submit",
|
||||||
|
"cowriting.askClaude.cancel",
|
||||||
"cowriting.applyAgentEdit",
|
"cowriting.applyAgentEdit",
|
||||||
"cowriting.proposeAgentEdit",
|
"cowriting.proposeAgentEdit",
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -174,16 +174,22 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
|||||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||||
});
|
});
|
||||||
|
|
||||||
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
|
// SLICE-3: the document-scoped command stays registered for #42 reuse (now behind
|
||||||
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
|
// the unified `cowriting.edit`). editDocument is hidden from the palette; the
|
||||||
|
// markdown-guarded user-facing palette command is `cowriting.edit`.
|
||||||
|
test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => {
|
||||||
const all = await vscode.commands.getCommands(true);
|
const all = await vscode.commands.getCommands(true);
|
||||||
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
|
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
|
||||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
assert.ok(all.includes("cowriting.edit"), "unified edit command registered");
|
||||||
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
|
const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes
|
||||||
(m) => m.command === "cowriting.editDocument",
|
.menus.commandPalette as Array<{ command: string; when?: string }>;
|
||||||
);
|
// editDocument is hidden (routed via cowriting.edit).
|
||||||
assert.ok(entry, "editDocument has a commandPalette entry");
|
const docEntry = palette.find((m) => m.command === "cowriting.editDocument");
|
||||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette");
|
||||||
|
// cowriting.edit is the user-facing, markdown-guarded entry.
|
||||||
|
const editEntry = palette.find((m) => m.command === "cowriting.edit");
|
||||||
|
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
|
||||||
|
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||||
});
|
});
|
||||||
|
|
||||||
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
|
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
|
||||||
|
|||||||
@@ -31,43 +31,37 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
|
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
|
||||||
// the editor TAB, selection-aware (selection → editSelection; no selection →
|
// the editor TAB. The two split commands (editSelection / editDocument) were
|
||||||
// editDocument), both markdown/authorable-gated, both routing through the single
|
// unified behind ONE user-facing command `cowriting.edit` that auto-routes by
|
||||||
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
|
// selection at runtime (routeEdit: selection → editSelection, none → editDocument)
|
||||||
// assert them directly; the tab-targeting behavior is exercised through the command.
|
// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable
|
||||||
|
// -gated. The routing itself is unit-tested (routeEdit) and exercised through the
|
||||||
|
// command below; here we assert the declarative menu `when` clauses.
|
||||||
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||||
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
|
// PUC-1/2: editor BODY (editor/context) offers the single unified entry,
|
||||||
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
|
// markdown + authorable gated, NOT selection-gated (it routes both cases).
|
||||||
|
test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => {
|
||||||
const m = menu("editor/context");
|
const m = menu("editor/context");
|
||||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
assert.ok(edit, "cowriting.edit is in editor/context");
|
||||||
assert.ok(sel, "editSelection is in editor/context");
|
assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown");
|
||||||
assert.ok(doc, "editDocument is in editor/context");
|
assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable");
|
||||||
|
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
|
||||||
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
|
// The old split pair is gone from the menu (the commands stay registered/hidden).
|
||||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
|
assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed");
|
||||||
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
|
assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed");
|
||||||
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
|
|
||||||
|
|
||||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
|
|
||||||
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
|
|
||||||
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
|
// PUC-3: editor TAB (editor/title/context) carries the same single entry.
|
||||||
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
|
test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => {
|
||||||
const m = menu("editor/title/context");
|
const m = menu("editor/title/context");
|
||||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
assert.ok(edit, "cowriting.edit is in editor/title/context");
|
||||||
assert.ok(sel, "editSelection is in editor/title/context");
|
assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown");
|
||||||
assert.ok(doc, "editDocument is in editor/title/context");
|
assert.ok(
|
||||||
|
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
|
||||||
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection");
|
"old split pair removed from the tab menu",
|
||||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection");
|
);
|
||||||
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
|
|
||||||
|
|
||||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
|
|
||||||
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
|
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
|
||||||
@@ -83,8 +77,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
|
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
|
||||||
const origInput = vscode.window.showInputBox;
|
const origPrompt = api.inlineAsk.prompt;
|
||||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
||||||
ctl.setEditTurnForTest(async () => ({
|
ctl.setEditTurnForTest(async () => ({
|
||||||
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
@@ -94,7 +88,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||||
await settle();
|
await settle();
|
||||||
} finally {
|
} finally {
|
||||||
(vscode.window as any).showInputBox = origInput;
|
(api.inlineAsk as any).prompt = origPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
|
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
|
||||||
@@ -116,8 +110,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
await vscode.window.showTextDocument(a.doc);
|
await vscode.window.showTextDocument(a.doc);
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const origInput = vscode.window.showInputBox;
|
const origPrompt = api.inlineAsk.prompt;
|
||||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
||||||
ctl.setEditTurnForTest(async () => ({
|
ctl.setEditTurnForTest(async () => ({
|
||||||
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
@@ -127,7 +121,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
await vscode.commands.executeCommand("cowriting.editDocument");
|
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||||
await settle();
|
await settle();
|
||||||
} finally {
|
} finally {
|
||||||
(vscode.window as any).showInputBox = origInput;
|
(api.inlineAsk as any).prompt = origPrompt;
|
||||||
}
|
}
|
||||||
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-1
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import assert from "node:assert";
|
||||||
|
import { describe, it, test, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
SCHEMA_VERSION,
|
SCHEMA_VERSION,
|
||||||
emptyArtifact,
|
emptyArtifact,
|
||||||
@@ -179,6 +180,19 @@ describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("serializeArtifact round-trips Proposal.original", () => {
|
||||||
|
const a = emptyArtifact("doc.md");
|
||||||
|
a.anchors["a_1"] = { fingerprint: { text: "new", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.proposals.push({
|
||||||
|
id: "pr_1", anchorId: "a_1", replacement: "new",
|
||||||
|
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s" } },
|
||||||
|
createdAt: "2026-06-26T00:00:00.000Z", original: "old", granularity: "block",
|
||||||
|
});
|
||||||
|
const json = serializeArtifact(a);
|
||||||
|
assert.match(json, /"original": "old"/);
|
||||||
|
assert.match(json, /"granularity": "block"/);
|
||||||
|
});
|
||||||
|
|
||||||
describe("attributions[] (F3 SLICE-1)", () => {
|
describe("attributions[] (F3 SLICE-1)", () => {
|
||||||
it("round-trips an attribution record through serialize → parse", () => {
|
it("round-trips an attribution record through serialize → parse", () => {
|
||||||
const a = emptyArtifact("docs/x.md");
|
const a = emptyArtifact("docs/x.md");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
|
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
|
||||||
|
|
||||||
describe("isUnderRoot", () => {
|
describe("isUnderRoot", () => {
|
||||||
const root = "/a/vscode-cowriting-plugin/sandbox";
|
const root = "/a/vscode-cowriting-plugin/sandbox";
|
||||||
@@ -58,3 +58,27 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)",
|
|||||||
expect(msg).not.toMatch(/select some text/i);
|
expect(msg).not.toMatch(/select some text/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => {
|
||||||
|
const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false };
|
||||||
|
|
||||||
|
it("non-empty selection in the focused editor → selection", () => {
|
||||||
|
expect(routeEdit(focused)).toBe("selection");
|
||||||
|
});
|
||||||
|
it("empty selection in the focused editor → document", () => {
|
||||||
|
expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document");
|
||||||
|
});
|
||||||
|
it("no active editor → document", () => {
|
||||||
|
expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document");
|
||||||
|
});
|
||||||
|
it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => {
|
||||||
|
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||||
|
"document",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("tab right-click on the focused editor with a selection → selection", () => {
|
||||||
|
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||||
|
"selection",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user