@guillaumemeyer/dsh-plan-approval 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/client.js +420 -0
- package/index.js +11 -0
- package/package.json +46 -0
- package/types/client/index.d.ts +2 -0
- package/types/index.d.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Guillaume Meyer (guillaumemeyer)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @guillaumemeyer/dsh-plan-approval
|
|
2
|
+
|
|
3
|
+
A fullscreen, Grok-Build-style **plan-review overlay** for DeepSeek Harness (DSH).
|
|
4
|
+
|
|
5
|
+
It replaces the small in-chat plan review card with a full-screen review surface so
|
|
6
|
+
large plans are readable and navigable. It renders automatically when the agent
|
|
7
|
+
presents a plan (`exit_plan_mode`) and rides the existing `planMode` / `userQuestions`
|
|
8
|
+
review contract.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Fullscreen review** in the frame-wide `shell.overlay` layer, now at **90vw × 90vh**.
|
|
13
|
+
- **Line navigation** (vim-style): `j`/`k` move line-by-line, `gg` jump to top,
|
|
14
|
+
`G` (Shift+g) jump to bottom, with a straight left highlight on the cursor line
|
|
15
|
+
that auto-scrolls.
|
|
16
|
+
- **Inline search**: `/` opens a search bar; matching lines highlight live, `Enter`/`n`
|
|
17
|
+
cycle to the next match, `N` to the previous, `Esc` closes.
|
|
18
|
+
- **Inline comments**: `c` (or **clicking a line**) drops a comment anchored to that
|
|
19
|
+
line, shown as an inline chip. Comments are **integrated into the plan**:
|
|
20
|
+
- On **request changes (`s`)** they're serialized into the feedback answer so the
|
|
21
|
+
agent revises the plan with them.
|
|
22
|
+
- On **approval (`a`)**, if comments exist they're attached to the approval (the
|
|
23
|
+
approve-with-notes path; see below), and the merged plan is what's copied with `y`.
|
|
24
|
+
- **Actions**: `a` approve · `s` request changes · `y` copy plan · `q` quit plan.
|
|
25
|
+
- A bottom **status line** lists every keyboard shortcut.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Install the package into the DSH deployment:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm i @guillaumemeyer/dsh-plan-approval
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then add one row to the **web surface (browser roster)** composition — e.g. the
|
|
36
|
+
deployment's `cordis.patch.yml` / web `cordis.yml`, alongside other `dsh.client`
|
|
37
|
+
browser roster rows:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
- id: ui-plan-approval
|
|
41
|
+
name: '@guillaumemeyer/dsh-plan-approval'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Restart the DSH host. The modules node half re-scans loader entries, bundles
|
|
45
|
+
`/plugins/@guillaumemeyer/dsh-plan-approval/client.js`, and the overlay
|
|
46
|
+
appears on the next plan review.
|
|
47
|
+
|
|
48
|
+
> The row must live in the **web-surface `dsh.client` roster** (a `dsh.client`
|
|
49
|
+
> platform-`web` package). Placing it in an agent preset or a non-web composition
|
|
50
|
+
> will not be scanned into `window.__DSH_BOOT__`.
|
|
51
|
+
|
|
52
|
+
## How the review answers map
|
|
53
|
+
|
|
54
|
+
The underlying `exit_plan_mode` review is binary (approve vs. keep-planning). This
|
|
55
|
+
plugin keeps that contract and layers comments onto it:
|
|
56
|
+
|
|
57
|
+
- **`s` (request changes)** sends the typed feedback **plus** the inline comments as
|
|
58
|
+
the `custom` answer → the host keeps planning, and the agent revises the plan with
|
|
59
|
+
your comments. ✅ fully works.
|
|
60
|
+
- **`a` (approve)** with **no** comments sends the plain approve answer.
|
|
61
|
+
- **`a` (approve)** with **comments** sends `{ selected: [approve], custom: <comments> }`
|
|
62
|
+
— the **approve-with-notes** path. For this to be treated as an approval (rather than
|
|
63
|
+
"keep planning"), the host's `exit_plan_mode` must accept `custom` on an approval.
|
|
64
|
+
See [Host change](#host-change-approve-with-notes).
|
|
65
|
+
|
|
66
|
+
> **Fallback if the host is not patched:** `a` with comments is delivered as feedback
|
|
67
|
+
> (the host keeps planning), so nothing is silently dropped.
|
|
68
|
+
|
|
69
|
+
## Host change: approve-with-notes
|
|
70
|
+
|
|
71
|
+
The shipped `@deepseek-ai/dsh-plan-mode` tool treats any `custom` value as "keep
|
|
72
|
+
planning". To integrate comments on approval, patch its `exit_plan_mode`:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
// before: if (... || item.custom !== void 0) { /* keep planning */ }
|
|
76
|
+
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL) { /* keep planning */ }
|
|
77
|
+
// after pendingIntents.set(...):
|
|
78
|
+
const notes = item?.custom;
|
|
79
|
+
return { approved: true, ...(notes ? { notes } : {}) };
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
And allow `notes` in the tool's `output.schema` (optional `notes: { type: "string" }`),
|
|
83
|
+
plus include it in the result `render` so the model sees the comments after approval.
|
|
84
|
+
This requires a host restart to take effect, and is a change to a shipped package that
|
|
85
|
+
an upgrade would overwrite — ideally upstream it.
|
|
86
|
+
|
|
87
|
+
## Notes
|
|
88
|
+
|
|
89
|
+
- Client-only browser plugin; the node half (`index.js`) is an empty `apply`.
|
|
90
|
+
- Package is published with `publishConfig.access: "public"`; `.npmignore` discards
|
|
91
|
+
repo/dev cruft.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/client.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@guillaumemeyer/dsh-plan-approval",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
const css = `.prv-root{position:fixed;inset:0;z-index:2147480000;background:rgba(8,10,16,0.62);display:flex;align-items:center;justify-content:center;pointer-events:auto;font-family:inherit;}
|
|
9
|
+
.prv-panel{width:90vw;height:90vh;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:18px;box-shadow:0 36px 90px rgba(0,0,0,0.55);display:flex;flex-direction:column;overflow:hidden;}
|
|
10
|
+
.prv-header{display:flex;align-items:center;gap:12px;padding:16px 22px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-shrink:0;}
|
|
11
|
+
.prv-badge{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base);font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;padding:3px 9px;border-radius:999px;}
|
|
12
|
+
.prv-title{font-size:17px;font-weight:600;margin:0;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
13
|
+
.prv-search{display:flex;align-items:center;gap:10px;padding:8px 22px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-shrink:0;}
|
|
14
|
+
.prv-search-input{flex:1 1 auto;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px;font-size:13px;line-height:18px;}
|
|
15
|
+
.prv-search-count{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;}
|
|
16
|
+
.prv-body{flex:1 1 auto;min-height:0;overflow-y:auto;padding:18px 26px 26px;overscroll-behavior:contain;position:relative;}
|
|
17
|
+
.prv-line{font-size:14px;line-height:1.7;padding:0 6px;border-left:2px solid transparent;}
|
|
18
|
+
.prv-line-current{background:var(--dsw-alias-bg-layer-2);border-left-color:var(--dsw-alias-brand-primary);}
|
|
19
|
+
.prv-line-match{background:var(--dsw-alias-bg-overlay);}
|
|
20
|
+
.prv-line-match-active{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base);}
|
|
21
|
+
.prv-plain{white-space:pre-wrap;}
|
|
22
|
+
.prv-h1{font-size:22px;font-weight:700;margin:8px 0 6px;line-height:1.3;}
|
|
23
|
+
.prv-h2{font-size:18px;font-weight:600;margin:12px 0 4px;line-height:1.3;}
|
|
24
|
+
.prv-h3{font-size:15px;font-weight:600;margin:10px 0 4px;line-height:1.3;}
|
|
25
|
+
.prv-h4{font-size:14px;font-weight:600;margin:8px 0 4px;line-height:1.3;}
|
|
26
|
+
.prv-li{font-size:14px;line-height:1.65;margin:0 0 4px;padding-left:16px;position:relative;}
|
|
27
|
+
.prv-li:before{content:"•";position:absolute;left:6px;color:var(--dsw-alias-label-secondary);}
|
|
28
|
+
.prv-blank{height:10px;}
|
|
29
|
+
.prv-comment{display:inline-block;margin:0 4px;padding:1px 7px;border-radius:999px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-state-warn-primary);font-size:12px;line-height:16px;vertical-align:middle;white-space:normal;}
|
|
30
|
+
.prv-comment-field{margin:6px 0 2px;display:block;}
|
|
31
|
+
.prv-field{flex-shrink:0;padding:12px 22px;border-top:1px solid var(--dsw-alias-border-l1);}
|
|
32
|
+
.prv-input{width:100%;box-sizing:border-box;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;font-size:14px;line-height:1.5;resize:vertical;min-height:64px;}
|
|
33
|
+
.prv-fieldActions{display:flex;gap:8px;justify-content:flex-end;margin-top:8px;}
|
|
34
|
+
.prv-footer{flex-shrink:0;display:flex;align-items:center;gap:14px;padding:12px 22px;border-top:1px solid var(--dsw-alias-border-l1);flex-wrap:wrap;}
|
|
35
|
+
.prv-status{font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);min-width:150px;}
|
|
36
|
+
.prv-status.error{color:var(--dsw-alias-state-error-primary);}
|
|
37
|
+
.prv-status.ok{color:var(--dsw-alias-state-success-primary);}
|
|
38
|
+
.prv-hints{display:flex;align-items:center;gap:2px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary);}
|
|
39
|
+
.prv-hints kbd{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:5px;padding:1px 6px;font-family:inherit;font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0 3px 0 8px;}
|
|
40
|
+
.prv-actions{display:flex;gap:8px;margin-left:auto;}
|
|
41
|
+
.prv-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:999px;padding:7px 14px;font-size:13px;font-weight:500;cursor:pointer;line-height:18px;}
|
|
42
|
+
.prv-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1);}
|
|
43
|
+
.prv-btn:disabled{opacity:.55;cursor:default;}
|
|
44
|
+
.prv-btn.warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);}
|
|
45
|
+
.prv-btn.primary{background:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-bg-base);}
|
|
46
|
+
.prv-btn.ghost{border-color:transparent;background:transparent;color:var(--dsw-alias-label-secondary);}
|
|
47
|
+
.prv-btn.ghost:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}`;
|
|
48
|
+
const tagId = "@guillaumemeyer/dsh-plan-approval/styles";
|
|
49
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
50
|
+
const tag = document.createElement("style");
|
|
51
|
+
tag.dataset.plugin = "@guillaumemeyer/dsh-plan-approval";
|
|
52
|
+
tag.dataset.pluginCss = tagId;
|
|
53
|
+
tag.textContent = css;
|
|
54
|
+
document.head.appendChild(tag);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const OVERLAY_CSS = `.prv-root{position:fixed;inset:0;z-index:2147480000;background:rgba(8,10,16,0.62);display:flex;align-items:center;justify-content:center;pointer-events:auto;font-family:inherit;}
|
|
58
|
+
.prv-panel{width:90vw;height:90vh;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:18px;box-shadow:0 36px 90px rgba(0,0,0,0.55);display:flex;flex-direction:column;overflow:hidden;}
|
|
59
|
+
.prv-header{display:flex;align-items:center;gap:12px;padding:16px 22px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-shrink:0;}
|
|
60
|
+
.prv-badge{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base);font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;padding:3px 9px;border-radius:999px;}
|
|
61
|
+
.prv-title{font-size:17px;font-weight:600;margin:0;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
62
|
+
.prv-search{display:flex;align-items:center;gap:10px;padding:8px 22px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-shrink:0;}
|
|
63
|
+
.prv-search-input{flex:1 1 auto;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px;font-size:13px;line-height:18px;}
|
|
64
|
+
.prv-search-count{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;}
|
|
65
|
+
.prv-body{flex:1 1 auto;min-height:0;overflow-y:auto;padding:18px 26px 26px;overscroll-behavior:contain;position:relative;}
|
|
66
|
+
.prv-line{font-size:14px;line-height:1.7;padding:0 6px;border-left:2px solid transparent;}
|
|
67
|
+
.prv-line-current{background:var(--dsw-alias-bg-layer-2);border-left-color:var(--dsw-alias-brand-primary);}
|
|
68
|
+
.prv-line-match{background:var(--dsw-alias-bg-overlay);}
|
|
69
|
+
.prv-line-match-active{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base);}
|
|
70
|
+
.prv-plain{white-space:pre-wrap;}
|
|
71
|
+
.prv-h1{font-size:22px;font-weight:700;margin:8px 0 6px;line-height:1.3;}
|
|
72
|
+
.prv-h2{font-size:18px;font-weight:600;margin:12px 0 4px;line-height:1.3;}
|
|
73
|
+
.prv-h3{font-size:15px;font-weight:600;margin:10px 0 4px;line-height:1.3;}
|
|
74
|
+
.prv-h4{font-size:14px;font-weight:600;margin:8px 0 4px;line-height:1.3;}
|
|
75
|
+
.prv-li{font-size:14px;line-height:1.65;margin:0 0 4px;padding-left:16px;position:relative;}
|
|
76
|
+
.prv-li:before{content:"•";position:absolute;left:6px;color:var(--dsw-alias-label-secondary);}
|
|
77
|
+
.prv-blank{height:10px;}
|
|
78
|
+
.prv-comment{display:inline-block;margin:0 4px;padding:1px 7px;border-radius:999px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-state-warn-primary);font-size:12px;line-height:16px;vertical-align:middle;white-space:normal;}
|
|
79
|
+
.prv-comment-field{margin:6px 0 2px;display:block;}
|
|
80
|
+
.prv-field{flex-shrink:0;padding:12px 22px;border-top:1px solid var(--dsw-alias-border-l1);}
|
|
81
|
+
.prv-input{width:100%;box-sizing:border-box;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;font-size:14px;line-height:1.5;resize:vertical;min-height:64px;}
|
|
82
|
+
.prv-fieldActions{display:flex;gap:8px;justify-content:flex-end;margin-top:8px;}
|
|
83
|
+
.prv-footer{flex-shrink:0;display:flex;align-items:center;gap:14px;padding:12px 22px;border-top:1px solid var(--dsw-alias-border-l1);flex-wrap:wrap;}
|
|
84
|
+
.prv-status{font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);min-width:150px;}
|
|
85
|
+
.prv-status.error{color:var(--dsw-alias-state-error-primary);}
|
|
86
|
+
.prv-status.ok{color:var(--dsw-alias-state-success-primary);}
|
|
87
|
+
.prv-hints{display:flex;align-items:center;gap:2px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary);}
|
|
88
|
+
.prv-hints kbd{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:5px;padding:1px 6px;font-family:inherit;font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0 3px 0 8px;}
|
|
89
|
+
.prv-actions{display:flex;gap:8px;margin-left:auto;}
|
|
90
|
+
.prv-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:999px;padding:7px 14px;font-size:13px;font-weight:500;cursor:pointer;line-height:18px;}
|
|
91
|
+
.prv-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1);}
|
|
92
|
+
.prv-btn:disabled{opacity:.55;cursor:default;}
|
|
93
|
+
.prv-btn.warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);}
|
|
94
|
+
.prv-btn.primary{background:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-bg-base);}
|
|
95
|
+
.prv-btn.ghost{border-color:transparent;background:transparent;color:var(--dsw-alias-label-secondary);}
|
|
96
|
+
.prv-btn.ghost:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}`;
|
|
97
|
+
|
|
98
|
+
function planReviewOf(questions) {
|
|
99
|
+
if (!questions || questions.length !== 1) return void 0;
|
|
100
|
+
const question = questions[0];
|
|
101
|
+
const intent = question && question.intent;
|
|
102
|
+
if (!intent || intent.kind !== 'plan-review' || question.detail === void 0) return void 0;
|
|
103
|
+
if (question.multiSelect === true) return void 0;
|
|
104
|
+
const options = question.options || [];
|
|
105
|
+
if (options.length > 2) return void 0;
|
|
106
|
+
const approve = options.find((o) => o.label === intent.approve);
|
|
107
|
+
if (approve === void 0) return void 0;
|
|
108
|
+
return { id: question.id, question: question.question, plan: question.detail, approve: approve };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parsePlan(plan) {
|
|
112
|
+
const lines = String(plan == null ? '' : plan).split('\n');
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
if (/^\s*$/.test(line)) { out.push({ kind: 'blank', text: '' }); continue; }
|
|
116
|
+
const h = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
|
|
117
|
+
if (h) { out.push({ kind: 'h' + h[1].length, text: h[2] }); continue; }
|
|
118
|
+
const li = /^\s*[-*]\s+(.+)$/.exec(line);
|
|
119
|
+
if (li) { out.push({ kind: 'li', text: li[1] }); continue; }
|
|
120
|
+
out.push({ kind: 'plain', text: line });
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const PLAN_HINTS = [
|
|
126
|
+
['a', 'approve'], ['s', 'changes'], ['c', 'comment'], ['y', 'copy'], ['q', 'quit'],
|
|
127
|
+
['j/k', 'move'], ['gg/G', 'ends'], ['/', 'search'],
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
function PlanReviewOverlay(props) {
|
|
131
|
+
const sessions = props.sessions;
|
|
132
|
+
const current = props.useSessions((s) => s.current);
|
|
133
|
+
const [snap, setSnap] = react.useState(null);
|
|
134
|
+
|
|
135
|
+
react.useEffect(() => {
|
|
136
|
+
if (!sessions || !current) { setSnap(null); return void 0; }
|
|
137
|
+
const bind = sessions.binding(current);
|
|
138
|
+
const face = bind && bind.session;
|
|
139
|
+
if (!face) { setSnap(null); return void 0; }
|
|
140
|
+
const update = () => setSnap(face.getSnapshot());
|
|
141
|
+
update();
|
|
142
|
+
return face.subscribe(update);
|
|
143
|
+
}, [sessions, current]);
|
|
144
|
+
|
|
145
|
+
const [mode, setMode] = react.useState(null);
|
|
146
|
+
const [text, setText] = react.useState('');
|
|
147
|
+
const [busy, setBusy] = react.useState(false);
|
|
148
|
+
const [error, setError] = react.useState(null);
|
|
149
|
+
const [notice, setNotice] = react.useState(null);
|
|
150
|
+
const rootRef = react.useRef(null);
|
|
151
|
+
const bodyRef = react.useRef(null);
|
|
152
|
+
const fieldRef = react.useRef(null);
|
|
153
|
+
const searchRef = react.useRef(null);
|
|
154
|
+
const commentRef = react.useRef(null);
|
|
155
|
+
const ggArmed = react.useRef(false);
|
|
156
|
+
const commentIdRef = react.useRef(1);
|
|
157
|
+
|
|
158
|
+
const [cursor, setCursor] = react.useState(0);
|
|
159
|
+
const [searchOpen, setSearchOpen] = react.useState(false);
|
|
160
|
+
const [query, setQuery] = react.useState('');
|
|
161
|
+
const [matches, setMatches] = react.useState([]);
|
|
162
|
+
const [activeMatch, setActiveMatch] = react.useState(0);
|
|
163
|
+
const [comments, setComments] = react.useState([]);
|
|
164
|
+
const [commentDraft, setCommentDraft] = react.useState(false);
|
|
165
|
+
const [commentLine, setCommentLine] = react.useState(0);
|
|
166
|
+
const [commentText, setCommentText] = react.useState('');
|
|
167
|
+
|
|
168
|
+
let review = null;
|
|
169
|
+
let wait = null;
|
|
170
|
+
if (snap) {
|
|
171
|
+
const pending = snap.pending || [];
|
|
172
|
+
for (let i = 0; i < pending.length; i++) {
|
|
173
|
+
const item = pending[i];
|
|
174
|
+
if (item && item.kind === 'question') {
|
|
175
|
+
const r = planReviewOf(item.payload ? item.payload.questions : null);
|
|
176
|
+
if (r) { review = r; wait = item; break; }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const reviewKey = wait ? wait.key : null;
|
|
181
|
+
const planLines = review ? parsePlan(review.plan) : [];
|
|
182
|
+
|
|
183
|
+
react.useEffect(() => {
|
|
184
|
+
setMode(null); setText(''); setError(null); setNotice(null); setBusy(false);
|
|
185
|
+
setCursor(0); setSearchOpen(false); setQuery(''); setMatches([]); setActiveMatch(0);
|
|
186
|
+
setComments([]); setCommentDraft(false); setCommentText(''); ggArmed.current = false;
|
|
187
|
+
}, [reviewKey]);
|
|
188
|
+
react.useEffect(() => { if (reviewKey && rootRef.current) rootRef.current.focus(); }, [reviewKey]);
|
|
189
|
+
react.useEffect(() => { if (mode && fieldRef.current) fieldRef.current.focus(); }, [mode]);
|
|
190
|
+
react.useEffect(() => { if (searchOpen && searchRef.current) searchRef.current.focus(); }, [searchOpen]);
|
|
191
|
+
react.useEffect(() => { if (commentDraft && commentRef.current) commentRef.current.focus(); }, [commentDraft]);
|
|
192
|
+
react.useEffect(() => {
|
|
193
|
+
if (bodyRef.current) {
|
|
194
|
+
const el = bodyRef.current.querySelector('[data-line="' + cursor + '"]');
|
|
195
|
+
if (el) el.scrollIntoView({ block: 'center' });
|
|
196
|
+
}
|
|
197
|
+
}, [cursor]);
|
|
198
|
+
|
|
199
|
+
if (!review || !wait) return null;
|
|
200
|
+
|
|
201
|
+
const submitAnswer = (payload) => {
|
|
202
|
+
if (busy) return;
|
|
203
|
+
setBusy(true); setError(null); setNotice(null);
|
|
204
|
+
wait.respond({ ok: true, value: { sessionId: wait.sessionId, answer: payload } })
|
|
205
|
+
.then((receipt) => { if (!receipt || !receipt.accepted) throw new Error('response rejected'); })
|
|
206
|
+
.catch((cause) => { setBusy(false); setError(cause instanceof Error ? cause.message : String(cause)); });
|
|
207
|
+
};
|
|
208
|
+
const submitCancel = () => {
|
|
209
|
+
if (busy) return;
|
|
210
|
+
setBusy(true); setError(null); setNotice(null);
|
|
211
|
+
wait.respond({ ok: false, error: { code: 'cancelled', message: 'the user closed this question request', details: {} } })
|
|
212
|
+
.then((receipt) => { if (!receipt || !receipt.accepted) throw new Error('cancellation rejected'); })
|
|
213
|
+
.catch((cause) => { setBusy(false); setError(cause instanceof Error ? cause.message : String(cause)); });
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const approve = () => {
|
|
217
|
+
if (comments.length === 0) { submitAnswer({ answers: [{ id: review.id, selected: [review.approve.label] }] }); return; }
|
|
218
|
+
submitAnswer({ answers: [{ id: review.id, selected: [review.approve.label], custom: commentBlock() }] });
|
|
219
|
+
};
|
|
220
|
+
const quit = () => submitCancel();
|
|
221
|
+
const copyPlan = () => {
|
|
222
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
223
|
+
navigator.clipboard.writeText(mergedPlan())
|
|
224
|
+
.then(() => { setNotice('Plan copied'); setError(null); })
|
|
225
|
+
.catch(() => { setError('Copy failed'); setNotice(null); });
|
|
226
|
+
} else { setError('Copy unavailable'); setNotice(null); }
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const openInput = (m) => { if (busy) return; setMode(m); setText(''); setError(null); setNotice(null); };
|
|
230
|
+
const closeInput = () => { if (busy) return; setMode(null); setText(''); setError(null); };
|
|
231
|
+
const commentBlock = () => comments.map((c) => '- Line ' + (c.line + 1) + ': ' + c.text).join('\n');
|
|
232
|
+
const mergedPlan = () => comments.length > 0 ? (review.plan + '\n\n## Review comments\n' + commentBlock()) : review.plan;
|
|
233
|
+
const submitInput = () => {
|
|
234
|
+
const cText = commentBlock();
|
|
235
|
+
const feedback = [text.trim(), cText].filter(Boolean).join('\n\n');
|
|
236
|
+
const payload = { answers: [{ id: review.id, selected: [], ...(feedback ? { custom: feedback } : {}) }] };
|
|
237
|
+
submitAnswer(payload);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const move = (delta) => {
|
|
241
|
+
if (planLines.length === 0) return;
|
|
242
|
+
setCursor((c) => Math.max(0, Math.min(planLines.length - 1, c + delta)));
|
|
243
|
+
};
|
|
244
|
+
const goTop = () => setCursor(0);
|
|
245
|
+
const goBottom = () => setCursor(Math.max(0, planLines.length - 1));
|
|
246
|
+
|
|
247
|
+
const computeMatches = (q) => {
|
|
248
|
+
if (!q) return [];
|
|
249
|
+
const needle = q.toLowerCase();
|
|
250
|
+
const out = [];
|
|
251
|
+
for (let i = 0; i < planLines.length; i++) {
|
|
252
|
+
if (planLines[i].text.toLowerCase().indexOf(needle) >= 0) out.push(i);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
};
|
|
256
|
+
const openSearch = () => { setSearchOpen(true); setError(null); setNotice(null); };
|
|
257
|
+
const closeSearch = () => { setSearchOpen(false); setQuery(''); setMatches([]); setActiveMatch(0); if (rootRef.current) rootRef.current.focus(); };
|
|
258
|
+
const onSearchChange = (e) => {
|
|
259
|
+
const q = e.target.value;
|
|
260
|
+
setQuery(q);
|
|
261
|
+
const m = computeMatches(q);
|
|
262
|
+
setMatches(m);
|
|
263
|
+
setActiveMatch(0);
|
|
264
|
+
};
|
|
265
|
+
const searchNext = () => {
|
|
266
|
+
if (matches.length === 0) return;
|
|
267
|
+
const next = (activeMatch + 1) % matches.length;
|
|
268
|
+
setActiveMatch(next);
|
|
269
|
+
setCursor(matches[next]);
|
|
270
|
+
};
|
|
271
|
+
const searchPrev = () => {
|
|
272
|
+
if (matches.length === 0) return;
|
|
273
|
+
const prev = (activeMatch - 1 + matches.length) % matches.length;
|
|
274
|
+
setActiveMatch(prev);
|
|
275
|
+
setCursor(matches[prev]);
|
|
276
|
+
};
|
|
277
|
+
const onSearchKey = (e) => {
|
|
278
|
+
if (e.key === 'Enter') { e.preventDefault(); searchNext(); }
|
|
279
|
+
else if (e.key === 'Escape') { e.preventDefault(); closeSearch(); }
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const startComment = () => { setCommentLine(cursor); setCommentText(''); setCommentDraft(true); setError(null); setNotice(null); };
|
|
283
|
+
const clickLine = (e, i) => {
|
|
284
|
+
if (busy) return;
|
|
285
|
+
const tag = (e.target && e.target.tagName) || '';
|
|
286
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON') return;
|
|
287
|
+
setMode(null);
|
|
288
|
+
setCursor(i);
|
|
289
|
+
setCommentLine(i);
|
|
290
|
+
setCommentText('');
|
|
291
|
+
setCommentDraft(true);
|
|
292
|
+
setError(null); setNotice(null);
|
|
293
|
+
};
|
|
294
|
+
const addComment = () => {
|
|
295
|
+
const c = commentText.trim();
|
|
296
|
+
if (!c) return;
|
|
297
|
+
setComments((arr) => arr.concat([{ id: 'cmt' + (commentIdRef.current++), line: commentLine, text: c }]));
|
|
298
|
+
setCommentDraft(false); setCommentText('');
|
|
299
|
+
if (rootRef.current) rootRef.current.focus();
|
|
300
|
+
};
|
|
301
|
+
const cancelComment = () => { setCommentDraft(false); setCommentText(''); if (rootRef.current) rootRef.current.focus(); };
|
|
302
|
+
const onCommentKey = (e) => {
|
|
303
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addComment(); }
|
|
304
|
+
else if (e.key === 'Escape') { e.preventDefault(); cancelComment(); }
|
|
305
|
+
};
|
|
306
|
+
const onCommentChange = (e) => { setCommentText(e.target.value); setError(null); };
|
|
307
|
+
|
|
308
|
+
const onKey = (e) => {
|
|
309
|
+
const tag = (e.target && e.target.tagName) || '';
|
|
310
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
311
|
+
if (busy) return;
|
|
312
|
+
const rawKey = e.key || '';
|
|
313
|
+
const key = rawKey.toLowerCase();
|
|
314
|
+
if (rawKey === 'G') { e.preventDefault(); goBottom(); return; }
|
|
315
|
+
if (rawKey === 'N') { e.preventDefault(); searchPrev(); return; }
|
|
316
|
+
if (key === 'g') {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
if (ggArmed.current) { ggArmed.current = false; goTop(); }
|
|
319
|
+
else ggArmed.current = true;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
ggArmed.current = false;
|
|
323
|
+
if (key === 'j') { e.preventDefault(); move(1); }
|
|
324
|
+
else if (key === 'k') { e.preventDefault(); move(-1); }
|
|
325
|
+
else if (key === '/') { e.preventDefault(); openSearch(); }
|
|
326
|
+
else if (key === 'n') { e.preventDefault(); searchNext(); }
|
|
327
|
+
else if (key === 'c') { e.preventDefault(); startComment(); }
|
|
328
|
+
else if (key === 'a') { e.preventDefault(); approve(); }
|
|
329
|
+
else if (key === 's') { e.preventDefault(); openInput('changes'); }
|
|
330
|
+
else if (key === 'y') { e.preventDefault(); copyPlan(); }
|
|
331
|
+
else if (key === 'q' || key === 'escape') {
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
if (searchOpen) closeSearch();
|
|
334
|
+
else if (commentDraft) cancelComment();
|
|
335
|
+
else quit();
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const onFieldKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitInput(); } else if (e.key === 'Escape') { e.preventDefault(); closeInput(); } };
|
|
339
|
+
const onFieldChange = (e) => { setText(e.target.value); setError(null); setNotice(null); };
|
|
340
|
+
|
|
341
|
+
const btn = (label, opts) => react.createElement('button', {
|
|
342
|
+
type: 'button',
|
|
343
|
+
className: 'prv-btn' + (opts && opts.cls ? ' ' + opts.cls : ''),
|
|
344
|
+
disabled: busy || (opts && opts.disabled),
|
|
345
|
+
onClick: opts && opts.onClick,
|
|
346
|
+
}, label);
|
|
347
|
+
|
|
348
|
+
const matchSet = new Set(matches);
|
|
349
|
+
const activeLine = matches.length > 0 ? matches[activeMatch] : -1;
|
|
350
|
+
|
|
351
|
+
const lineEls = planLines.map((line, i) => {
|
|
352
|
+
const cls = 'prv-line prv-' + line.kind +
|
|
353
|
+
(i === cursor ? ' prv-line-current' : '') +
|
|
354
|
+
(matchSet.has(i) ? ' prv-line-match' : '') +
|
|
355
|
+
(i === activeLine ? ' prv-line-match-active' : '');
|
|
356
|
+
const lineComments = comments.filter((c) => c.line === i);
|
|
357
|
+
const isComposing = commentDraft && commentLine === i;
|
|
358
|
+
const children = [];
|
|
359
|
+
if (line.kind !== 'blank') children.push(line.text);
|
|
360
|
+
for (const c of lineComments) {
|
|
361
|
+
children.push(react.createElement('span', { key: c.id, className: 'prv-comment' }, c.text));
|
|
362
|
+
}
|
|
363
|
+
children.push((isComposing ? react.createElement('div', { key: 'cmp', className: 'prv-comment-field' },
|
|
364
|
+
react.createElement('textarea', { ref: commentRef, className: 'prv-input', value: commentText, placeholder: 'Inline comment…', disabled: busy, onChange: onCommentChange, onKeyDown: onCommentKey }),
|
|
365
|
+
react.createElement('div', { className: 'prv-fieldActions' },
|
|
366
|
+
btn('Cancel', { cls: 'ghost', onClick: cancelComment }),
|
|
367
|
+
btn('Add', { onClick: addComment }))) : null));
|
|
368
|
+
return react.createElement('div', { key: i, 'data-line': String(i), className: cls, onClick: (e) => clickLine(e, i) }, children);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const body = react.createElement('div', { ref: bodyRef, className: 'prv-body' }, lineEls);
|
|
372
|
+
|
|
373
|
+
const header = react.createElement('header', { className: 'prv-header' },
|
|
374
|
+
react.createElement('span', { className: 'prv-badge' }, 'Plan review'),
|
|
375
|
+
react.createElement('h1', { className: 'prv-title' }, review.question || 'Plan review'));
|
|
376
|
+
|
|
377
|
+
const searchBar = searchOpen ? react.createElement('div', { className: 'prv-search' },
|
|
378
|
+
react.createElement('input', { ref: searchRef, className: 'prv-search-input', value: query, placeholder: '/ search plan…', onChange: onSearchChange, onKeyDown: onSearchKey }),
|
|
379
|
+
react.createElement('span', { className: 'prv-search-count' }, matches.length > 0 ? (activeMatch + 1) + '/' + matches.length : (query ? '0' : ''))) : null;
|
|
380
|
+
|
|
381
|
+
const field = mode === 'changes' ? react.createElement('div', { className: 'prv-field' },
|
|
382
|
+
react.createElement('textarea', { ref: fieldRef, className: 'prv-input', value: text, placeholder: 'Describe the changes you want…', disabled: busy, onChange: onFieldChange, onKeyDown: onFieldKey }),
|
|
383
|
+
react.createElement('div', { className: 'prv-fieldActions' },
|
|
384
|
+
btn('Cancel', { cls: 'ghost', onClick: closeInput }),
|
|
385
|
+
btn('Submit changes', { onClick: submitInput }))) : null;
|
|
386
|
+
|
|
387
|
+
const statusText = error || notice || (comments.length > 0 ? comments.length + ' comment' + (comments.length > 1 ? 's' : '') : '');
|
|
388
|
+
const statusCls = 'prv-status' + (error ? ' error' : notice ? ' ok' : '');
|
|
389
|
+
|
|
390
|
+
const hints = react.createElement('div', { className: 'prv-hints' },
|
|
391
|
+
PLAN_HINTS.map((h, i) => react.createElement('span', { key: i },
|
|
392
|
+
react.createElement('kbd', null, h[0]), ' ', h[1], i < PLAN_HINTS.length - 1 ? ' |' : '')));
|
|
393
|
+
|
|
394
|
+
const footer = react.createElement('footer', { className: 'prv-footer' },
|
|
395
|
+
react.createElement('div', { className: statusCls, role: 'status' }, statusText),
|
|
396
|
+
hints,
|
|
397
|
+
react.createElement('div', { className: 'prv-actions' },
|
|
398
|
+
btn('Request changes', { cls: 'warn', onClick: () => openInput('changes') }),
|
|
399
|
+
btn('Comment', { cls: 'ghost', onClick: startComment }),
|
|
400
|
+
btn('Copy', { cls: 'ghost', onClick: copyPlan }),
|
|
401
|
+
btn('Quit', { cls: 'ghost', onClick: quit }),
|
|
402
|
+
btn('Approve', { cls: 'primary', onClick: approve })));
|
|
403
|
+
|
|
404
|
+
return react.createElement('div', { className: 'prv-root', onKeyDown: onKey, ref: rootRef, tabIndex: -1 },
|
|
405
|
+
react.createElement('div', { className: 'prv-panel' }, header, searchBar, body, field, footer));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function apply(ctx) {
|
|
409
|
+
const slots = ctx.get("slots");
|
|
410
|
+
if (slots === undefined) return;
|
|
411
|
+
const sessions = ctx.get("sessions");
|
|
412
|
+
slots.inject("shell.overlay", () => slots.register(
|
|
413
|
+
{ name: "shell.overlay", id: "plan-review-fullscreen", inject: () => ({ sessions }) },
|
|
414
|
+
PlanReviewOverlay,
|
|
415
|
+
));
|
|
416
|
+
}
|
|
417
|
+
exports.apply = apply;
|
|
418
|
+
return module.exports;
|
|
419
|
+
}
|
|
420
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region node half
|
|
2
|
+
/**
|
|
3
|
+
* Plan-approval plugin, node half. Pure UI plugin: the empty apply exists so
|
|
4
|
+
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
|
5
|
+
* via exports["./client"], discovered through the package.json dsh.client
|
|
6
|
+
* declaration.
|
|
7
|
+
*/
|
|
8
|
+
/** Host plugin body — no host-side behavior for this surface plugin. */
|
|
9
|
+
function apply() {}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { apply };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guillaumemeyer/dsh-plan-approval",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Fullscreen, Grok-Build-style DSH plan-review overlay with vim navigation, inline search, inline comments, and comment integration into the plan.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./types/index.d.ts",
|
|
11
|
+
"default": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./types/client/index.d.ts",
|
|
15
|
+
"default": "./client.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"client": {
|
|
21
|
+
"platform": "web"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"client.js",
|
|
27
|
+
"types/**/*.d.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"publish": "node scripts/publish.mjs",
|
|
36
|
+
"publish:dry": "npm pack --dry-run",
|
|
37
|
+
"publish:check": "node --check client.js && npm pack --dry-run",
|
|
38
|
+
"preversion": "npm run publish:check"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "^18.2.0",
|
|
43
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
44
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/types/index.d.ts
ADDED