pi-midcompact 0.3.0 → 0.5.0
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/README.md +117 -42
- package/README.zh-CN.md +236 -0
- package/figures/review-tui.png +0 -0
- package/figures/review-webui.png +0 -0
- package/package.json +3 -2
- package/skills/midcompact/SKILL.md +87 -72
- package/skills/midcompact/references/tool-interface.md +83 -0
- package/src/atoms.ts +31 -7
- package/src/content-metrics.ts +185 -0
- package/src/index.ts +526 -207
- package/src/inventory.ts +295 -0
- package/src/messages.ts +56 -1
- package/src/plan.ts +176 -20
- package/src/planning-lock.ts +42 -0
- package/src/projection.ts +43 -1
- package/src/renderers.ts +16 -19
- package/src/review-ui.ts +199 -155
- package/src/review-webui.html +1063 -0
- package/src/review-webui.ts +317 -0
- package/src/selection-ui.ts +220 -0
- package/src/selection.ts +95 -0
- package/src/start-ui.ts +57 -0
- package/src/state.ts +39 -2
- package/src/telemetry.ts +48 -18
- package/src/types.ts +141 -2
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="dark light">
|
|
7
|
+
<title>Midcompact Review</title>
|
|
8
|
+
<script>
|
|
9
|
+
// Apply theme before first paint to avoid flash.
|
|
10
|
+
try {
|
|
11
|
+
var t = localStorage.getItem('midcompact-theme');
|
|
12
|
+
if (!t) t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
13
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
14
|
+
} catch (e) {}
|
|
15
|
+
</script>
|
|
16
|
+
<style>
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #0b0e14;
|
|
19
|
+
--panel: #11151f;
|
|
20
|
+
--panel-2: #161b28;
|
|
21
|
+
--elev: #1d2333;
|
|
22
|
+
--line: #232a3d;
|
|
23
|
+
--line-soft: #1a2032;
|
|
24
|
+
--fg: #e6e9f2;
|
|
25
|
+
--fg-dim: #8f9ab4;
|
|
26
|
+
--fg-mute: #5f6a84;
|
|
27
|
+
--accent: #7aa2f7;
|
|
28
|
+
--accent-2: #bb9af7;
|
|
29
|
+
--keep: #9ece6a;
|
|
30
|
+
--range: #7dcfff;
|
|
31
|
+
--warn: #e0af68;
|
|
32
|
+
--danger: #f7768e;
|
|
33
|
+
--mark: rgba(224,175,104,.28);
|
|
34
|
+
--shadow: 0 10px 34px rgba(0,0,0,.45);
|
|
35
|
+
--ring: rgba(122,162,247,.45);
|
|
36
|
+
--r: 8px;
|
|
37
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
38
|
+
--sans: "HarmonyOS Sans SC", "HarmonyOS Sans", "Noto Sans CJK SC", "Noto Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
39
|
+
}
|
|
40
|
+
[data-theme="light"] {
|
|
41
|
+
--bg: #f3f5fa;
|
|
42
|
+
--panel: #ffffff;
|
|
43
|
+
--panel-2: #f7f9fc;
|
|
44
|
+
--elev: #eaeef6;
|
|
45
|
+
--line: #dbe1ee;
|
|
46
|
+
--line-soft: #e8ecf5;
|
|
47
|
+
--fg: #1a1f2b;
|
|
48
|
+
--fg-dim: #5b6479;
|
|
49
|
+
--fg-mute: #8a93a8;
|
|
50
|
+
--accent: #3b6ef6;
|
|
51
|
+
--accent-2: #7c3aed;
|
|
52
|
+
--keep: #17914f;
|
|
53
|
+
--range: #0d84c4;
|
|
54
|
+
--warn: #b45309;
|
|
55
|
+
--danger: #d43b52;
|
|
56
|
+
--mark: rgba(224,175,104,.42);
|
|
57
|
+
--shadow: 0 10px 30px rgba(31,41,55,.12);
|
|
58
|
+
--ring: rgba(59,110,246,.35);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
* { box-sizing: border-box; }
|
|
62
|
+
html, body { height: 100%; margin: 0; }
|
|
63
|
+
body {
|
|
64
|
+
background: var(--bg); color: var(--fg);
|
|
65
|
+
font: 13px/1.55 var(--sans);
|
|
66
|
+
display: flex; flex-direction: column;
|
|
67
|
+
-webkit-font-smoothing: antialiased;
|
|
68
|
+
}
|
|
69
|
+
button, input, textarea { font: inherit; color: inherit; }
|
|
70
|
+
:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
|
|
71
|
+
::selection { background: var(--ring); }
|
|
72
|
+
.mono { font-family: var(--mono); }
|
|
73
|
+
|
|
74
|
+
/* ---------- header ---------- */
|
|
75
|
+
header {
|
|
76
|
+
background: var(--panel); border-bottom: 1px solid var(--line);
|
|
77
|
+
padding: 12px 18px 14px; flex: 0 0 auto;
|
|
78
|
+
}
|
|
79
|
+
.bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
80
|
+
.brand { font-size: 15px; font-weight: 650; letter-spacing: 0; margin: 0; }
|
|
81
|
+
.brand b { color: var(--accent); font-weight: 750; }
|
|
82
|
+
.chip {
|
|
83
|
+
background: var(--panel-2); border: 1px solid var(--line); color: var(--fg-dim);
|
|
84
|
+
padding: 3px 9px; border-radius: 99px; font-size: 11px; font-family: var(--mono); white-space: nowrap;
|
|
85
|
+
}
|
|
86
|
+
.chip.dirty { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
|
|
87
|
+
.spacer { flex: 1 1 auto; }
|
|
88
|
+
|
|
89
|
+
.btn {
|
|
90
|
+
border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
|
|
91
|
+
padding: 6px 12px; border-radius: 8px; cursor: pointer;
|
|
92
|
+
transition: background .13s, border-color .13s, transform .06s, opacity .13s;
|
|
93
|
+
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
|
|
94
|
+
}
|
|
95
|
+
.btn:hover { background: var(--elev); border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }
|
|
96
|
+
.btn:active { transform: translateY(1px); }
|
|
97
|
+
.btn[disabled] { opacity: .42; cursor: not-allowed; transform: none; }
|
|
98
|
+
.btn-primary { background: var(--accent); border-color: var(--accent); color: #0b0e14; font-weight: 600; }
|
|
99
|
+
[data-theme="light"] .btn-primary { color: #fff; }
|
|
100
|
+
.btn-primary:hover { filter: brightness(1.07); background: var(--accent); }
|
|
101
|
+
.btn-danger { background: transparent; border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); color: var(--danger); }
|
|
102
|
+
.btn-danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); border-color: var(--danger); }
|
|
103
|
+
.btn-danger.armed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
|
104
|
+
.btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 7px; }
|
|
105
|
+
.btn-icon { padding: 6px 9px; }
|
|
106
|
+
|
|
107
|
+
.metrics { display: grid; grid-template-columns: 2fr repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
|
|
108
|
+
.metric {
|
|
109
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r); padding: 9px 12px 10px;
|
|
110
|
+
}
|
|
111
|
+
.metric > label {
|
|
112
|
+
display: block; font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-mute);
|
|
113
|
+
}
|
|
114
|
+
.metric .val { font-family: var(--mono); font-size: 14px; margin-top: 3px; }
|
|
115
|
+
.metric .val small { color: var(--fg-dim); font-size: 11px; }
|
|
116
|
+
.track {
|
|
117
|
+
position: relative; height: 7px; background: var(--elev); border-radius: 99px; margin-top: 8px; overflow: hidden;
|
|
118
|
+
}
|
|
119
|
+
.track i { position: absolute; inset: 0 auto 0 0; border-radius: 99px; display: block; }
|
|
120
|
+
.track .anchor { background: color-mix(in srgb, var(--warn) 55%, transparent); }
|
|
121
|
+
.track .proj { background: var(--accent); }
|
|
122
|
+
.legend { display: flex; gap: 12px; margin-top: 6px; font-size: 10.5px; color: var(--fg-dim); flex-wrap: wrap; }
|
|
123
|
+
.legend b { font-weight: 500; font-family: var(--mono); color: var(--fg); }
|
|
124
|
+
.dotm { width: 7px; height: 7px; border-radius: 99px; display: inline-block; margin-right: 4px; vertical-align: baseline; }
|
|
125
|
+
|
|
126
|
+
/* ---------- layout ---------- */
|
|
127
|
+
main {
|
|
128
|
+
flex: 1 1 auto; min-height: 0;
|
|
129
|
+
display: grid; gap: 1px; background: var(--line);
|
|
130
|
+
grid-template-columns: 276px minmax(0, 1fr) 388px;
|
|
131
|
+
grid-template-areas: "ranges timeline editor";
|
|
132
|
+
}
|
|
133
|
+
.pane { background: var(--bg); min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
134
|
+
.pane.ranges { grid-area: ranges; background: var(--panel); }
|
|
135
|
+
.pane.timeline { grid-area: timeline; }
|
|
136
|
+
.pane.editor { grid-area: editor; background: var(--panel); }
|
|
137
|
+
.pane-head {
|
|
138
|
+
flex: 0 0 auto; padding: 10px 12px; border-bottom: 1px solid var(--line-soft);
|
|
139
|
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
140
|
+
}
|
|
141
|
+
.pane-title { font-size: 11px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-mute); }
|
|
142
|
+
.scroll { flex: 1 1 auto; overflow: auto; min-height: 0; scrollbar-width: thin; }
|
|
143
|
+
.scroll::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
144
|
+
.scroll::-webkit-scrollbar-thumb { background: var(--elev); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
|
|
145
|
+
.scroll::-webkit-scrollbar-thumb:hover { background: var(--line); background-clip: content-box; }
|
|
146
|
+
|
|
147
|
+
.search { position: relative; flex: 1 1 140px; }
|
|
148
|
+
.search input {
|
|
149
|
+
width: 100%; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
|
|
150
|
+
padding: 6px 26px 6px 28px; font-size: 12px;
|
|
151
|
+
}
|
|
152
|
+
.search input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
|
|
153
|
+
.search .ico { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--fg-mute); font-size: 12px; }
|
|
154
|
+
.search .clr {
|
|
155
|
+
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
|
156
|
+
background: none; border: 0; color: var(--fg-mute); cursor: pointer; padding: 2px 5px; border-radius: 6px;
|
|
157
|
+
}
|
|
158
|
+
.search .clr:hover { color: var(--fg); background: var(--elev); }
|
|
159
|
+
|
|
160
|
+
.seg { display: inline-flex; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 2px; }
|
|
161
|
+
.seg button {
|
|
162
|
+
border: 0; background: none; color: var(--fg-dim); padding: 3px 9px; border-radius: 6px; cursor: pointer; font-size: 11.5px;
|
|
163
|
+
}
|
|
164
|
+
.seg button[aria-pressed="true"] { background: var(--elev); color: var(--fg); }
|
|
165
|
+
|
|
166
|
+
/* ---------- range list ---------- */
|
|
167
|
+
.rlist { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
168
|
+
.ritem {
|
|
169
|
+
width: 100%; text-align: left; background: var(--panel-2); border: 1px solid var(--line);
|
|
170
|
+
border-left: 3px solid var(--line); border-radius: 8px; padding: 8px 10px; cursor: pointer;
|
|
171
|
+
display: grid; gap: 3px; transition: background .13s, border-color .13s;
|
|
172
|
+
}
|
|
173
|
+
.ritem:hover { background: var(--elev); }
|
|
174
|
+
.ritem[aria-selected="true"] { border-color: var(--range); border-left-color: var(--range); background: color-mix(in srgb, var(--range) 12%, var(--panel-2)); }
|
|
175
|
+
.ritem .r1 { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; }
|
|
176
|
+
.ritem .rid { color: var(--range); font-weight: 700; }
|
|
177
|
+
.ritem .refs { color: var(--fg-mute); font-size: 11px; }
|
|
178
|
+
.ritem .save { margin-left: auto; color: var(--keep); font-size: 11px; }
|
|
179
|
+
.ritem .rsum {
|
|
180
|
+
color: var(--fg-dim); font-size: 11.5px; line-height: 1.45;
|
|
181
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
182
|
+
}
|
|
183
|
+
.ritem .meta { color: var(--fg-mute); font-size: 10.5px; font-family: var(--mono); }
|
|
184
|
+
.udot { width: 6px; height: 6px; border-radius: 99px; background: var(--warn); display: inline-block; }
|
|
185
|
+
|
|
186
|
+
/* ---------- timeline ---------- */
|
|
187
|
+
.tl { padding: 10px 12px 40px; display: flex; flex-direction: column; gap: 8px; }
|
|
188
|
+
.group { border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; background: var(--panel); }
|
|
189
|
+
.group.keepg { background: transparent; border-color: var(--line-soft); }
|
|
190
|
+
.ghead {
|
|
191
|
+
position: sticky; top: 0; z-index: 2; width: 100%; text-align: left; cursor: pointer;
|
|
192
|
+
display: grid; grid-template-columns: 34px auto minmax(0, 1fr) auto; align-items: center; gap: 8px;
|
|
193
|
+
padding: 8px 10px; background: var(--panel-2); border: 0; border-bottom: 1px solid var(--line-soft);
|
|
194
|
+
}
|
|
195
|
+
.ghead:hover { background: var(--elev); }
|
|
196
|
+
.group.sel { border-color: var(--range); box-shadow: 0 0 0 1px color-mix(in srgb, var(--range) 40%, transparent); }
|
|
197
|
+
.group.sel .ghead { background: color-mix(in srgb, var(--range) 13%, var(--panel-2)); }
|
|
198
|
+
.chev { color: var(--fg-mute); transition: transform .15s; font-size: 11px; text-align: center; }
|
|
199
|
+
.group.open .chev { transform: rotate(90deg); }
|
|
200
|
+
.ghead .gid { font-family: var(--mono); font-weight: 700; color: var(--range); font-size: 12px; }
|
|
201
|
+
.group.keepg .ghead .gid { color: var(--keep); }
|
|
202
|
+
.ghead .gsum { color: var(--fg-dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
203
|
+
.ghead .gstat { font-family: var(--mono); font-size: 11px; color: var(--fg-mute); white-space: nowrap; }
|
|
204
|
+
.ghead .gstat em { color: var(--keep); font-style: normal; }
|
|
205
|
+
.gbody { display: none; padding: 4px; }
|
|
206
|
+
.group.open .gbody { display: block; }
|
|
207
|
+
|
|
208
|
+
.atom {
|
|
209
|
+
display: grid; grid-template-columns: 74px 84px 58px minmax(0, 1fr); gap: 10px; align-items: start;
|
|
210
|
+
padding: 7px 8px; border-radius: 8px; border: 1px solid transparent;
|
|
211
|
+
}
|
|
212
|
+
.atom + .atom { border-top: 1px solid var(--line-soft); border-radius: 0; }
|
|
213
|
+
.atom:hover { background: var(--panel-2); }
|
|
214
|
+
.atom .aref { font-family: var(--mono); font-size: 11.5px; color: var(--accent-2); }
|
|
215
|
+
.atom .akind { font-family: var(--mono); font-size: 10.5px; color: var(--fg-dim); overflow: hidden; text-overflow: ellipsis; }
|
|
216
|
+
.atom .atok { font-family: var(--mono); font-size: 11px; color: var(--fg-mute); text-align: right; }
|
|
217
|
+
.atom .abody {
|
|
218
|
+
font-family: var(--mono); font-size: 11.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
|
|
219
|
+
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; cursor: zoom-in;
|
|
220
|
+
}
|
|
221
|
+
.atom.open .abody { -webkit-line-clamp: unset; cursor: zoom-out; }
|
|
222
|
+
.atom .tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
|
|
223
|
+
.tag {
|
|
224
|
+
font-family: var(--mono); font-size: 10px; padding: 0 5px; border-radius: 5px;
|
|
225
|
+
background: var(--elev); color: var(--fg-dim); border: 1px solid var(--line-soft);
|
|
226
|
+
}
|
|
227
|
+
.tag.lock { color: var(--warn); }
|
|
228
|
+
.tag.tool { font-weight: 700; color: var(--accent); border-color: var(--accent); }
|
|
229
|
+
.tag.tool.read { color: var(--range); border-color: var(--range); }
|
|
230
|
+
.tag.tool.write { color: var(--keep); border-color: var(--keep); }
|
|
231
|
+
.tag.tool.edit { color: var(--accent-2); border-color: var(--accent-2); }
|
|
232
|
+
.tag.tool.bash { color: var(--warn); border-color: var(--warn); }
|
|
233
|
+
.atom-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
234
|
+
.atom.selected-atom { background: color-mix(in srgb, var(--range) 10%, transparent); border-color: color-mix(in srgb, var(--range) 35%, transparent); }
|
|
235
|
+
.atom.kept-atom { background: color-mix(in srgb, var(--keep) 8%, transparent); }
|
|
236
|
+
mark { background: var(--mark); color: inherit; border-radius: 3px; padding: 0 1px; }
|
|
237
|
+
|
|
238
|
+
/* ---------- editor ---------- */
|
|
239
|
+
.epad { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
|
|
240
|
+
.etitle { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
|
241
|
+
.etitle .eid { font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--range); }
|
|
242
|
+
.etitle .erefs { font-family: var(--mono); font-size: 11.5px; color: var(--fg-mute); }
|
|
243
|
+
.estats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
244
|
+
.estat { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; }
|
|
245
|
+
.estat label { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-mute); }
|
|
246
|
+
.estat span { font-family: var(--mono); font-size: 12.5px; }
|
|
247
|
+
.field label {
|
|
248
|
+
display: flex; align-items: center; gap: 8px; font-size: 10.5px; text-transform: uppercase;
|
|
249
|
+
letter-spacing: .06em; color: var(--fg-mute); margin-bottom: 5px;
|
|
250
|
+
}
|
|
251
|
+
.field label .count { margin-left: auto; text-transform: none; letter-spacing: 0; font-family: var(--mono); }
|
|
252
|
+
.field input, .field textarea {
|
|
253
|
+
width: 100%; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
|
|
254
|
+
padding: 8px 10px; resize: vertical; font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
|
255
|
+
}
|
|
256
|
+
.field textarea { min-height: 200px; }
|
|
257
|
+
.field input:focus, .field textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
|
|
258
|
+
.field.invalid input, .field.invalid textarea { border-color: var(--danger); }
|
|
259
|
+
.erow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
260
|
+
.hint { color: var(--fg-mute); font-size: 11px; }
|
|
261
|
+
kbd {
|
|
262
|
+
font-family: var(--mono); font-size: 10.5px; background: var(--elev); border: 1px solid var(--line);
|
|
263
|
+
border-bottom-width: 2px; border-radius: 5px; padding: 0 4px; color: var(--fg-dim);
|
|
264
|
+
}
|
|
265
|
+
.empty { color: var(--fg-mute); text-align: center; padding: 44px 18px; font-size: 12.5px; line-height: 1.7; }
|
|
266
|
+
|
|
267
|
+
/* ---------- overlays ---------- */
|
|
268
|
+
.toast {
|
|
269
|
+
position: fixed; bottom: 18px; left: 50%; transform: translate(-50%, 12px);
|
|
270
|
+
background: var(--panel-2); border: 1px solid var(--line); color: var(--fg);
|
|
271
|
+
padding: 9px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 12.5px;
|
|
272
|
+
opacity: 0; transition: opacity .18s, transform .18s; pointer-events: none; z-index: 50; max-width: 70vw;
|
|
273
|
+
}
|
|
274
|
+
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
275
|
+
.toast.err { border-color: var(--danger); color: var(--danger); }
|
|
276
|
+
.toast.ok { border-color: color-mix(in srgb, var(--keep) 60%, var(--line)); }
|
|
277
|
+
.help {
|
|
278
|
+
position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; place-items: center; z-index: 60; padding: 20px;
|
|
279
|
+
}
|
|
280
|
+
.help.show { display: grid; }
|
|
281
|
+
.help-card {
|
|
282
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow);
|
|
283
|
+
padding: 18px 20px; min-width: 300px; max-width: 420px;
|
|
284
|
+
}
|
|
285
|
+
.help-card h3 { margin: 0 0 12px; font-size: 13px; }
|
|
286
|
+
.help-card p { margin: 0; color: var(--fg-dim); }
|
|
287
|
+
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
|
|
288
|
+
.help-card dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; margin: 0; font-size: 12px; }
|
|
289
|
+
.help-card dt { text-align: right; }
|
|
290
|
+
.help-card dd { margin: 0; color: var(--fg-dim); }
|
|
291
|
+
.closed { display: grid; place-items: center; height: 100%; color: var(--fg-dim); font-size: 13px; text-align: center; padding: 30px; }
|
|
292
|
+
|
|
293
|
+
@media (max-width: 1220px) {
|
|
294
|
+
main {
|
|
295
|
+
grid-template-columns: 250px minmax(0, 1fr);
|
|
296
|
+
grid-template-rows: minmax(0, 1fr) minmax(180px, 42vh);
|
|
297
|
+
grid-template-areas: "ranges timeline" "editor editor";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
@media (max-width: 860px) {
|
|
301
|
+
.metrics { grid-template-columns: 1fr; }
|
|
302
|
+
main {
|
|
303
|
+
grid-template-columns: minmax(0, 1fr);
|
|
304
|
+
grid-template-rows: minmax(120px, 24vh) minmax(220px, 1fr) auto;
|
|
305
|
+
grid-template-areas: "ranges" "timeline" "editor";
|
|
306
|
+
}
|
|
307
|
+
.pane.editor { max-height: 60vh; }
|
|
308
|
+
.atom { grid-template-columns: 70px 1fr auto; }
|
|
309
|
+
.atom .abody { grid-column: 1 / -1; }
|
|
310
|
+
}
|
|
311
|
+
@media (max-width: 600px) {
|
|
312
|
+
.ghead { grid-template-columns: 34px auto minmax(0, 1fr); }
|
|
313
|
+
.ghead .gstat { grid-column: 2 / -1; white-space: normal; }
|
|
314
|
+
.atom > div { grid-column: 1 / -1; }
|
|
315
|
+
}
|
|
316
|
+
</style>
|
|
317
|
+
</head>
|
|
318
|
+
<body>
|
|
319
|
+
<header>
|
|
320
|
+
<div class="bar">
|
|
321
|
+
<h1 class="brand"><b>Midcompact</b> <span id="mode-title">Review</span></h1>
|
|
322
|
+
<span class="chip" id="draft-badge"></span>
|
|
323
|
+
<span class="chip dirty" id="dirty-badge" hidden></span>
|
|
324
|
+
<div class="spacer"></div>
|
|
325
|
+
<button class="btn btn-sm" id="save-all" hidden>Save all</button>
|
|
326
|
+
<button class="btn btn-sm btn-primary" id="save-selection" hidden>Save selection</button>
|
|
327
|
+
<button class="btn btn-sm btn-icon" id="refresh" title="Reload state (r)" aria-label="Reload state">⟳</button>
|
|
328
|
+
<button class="btn btn-sm btn-icon" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"></button>
|
|
329
|
+
<button class="btn btn-sm btn-icon" id="help-btn" title="Shortcuts (?)" aria-label="Shortcuts">?</button>
|
|
330
|
+
<button class="btn btn-sm btn-primary" id="close">Close & return</button>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="metrics" id="metrics"></div>
|
|
333
|
+
</header>
|
|
334
|
+
|
|
335
|
+
<main>
|
|
336
|
+
<section class="pane ranges" aria-label="Compression ranges">
|
|
337
|
+
<div class="pane-head">
|
|
338
|
+
<div class="search">
|
|
339
|
+
<span class="ico">⌕</span>
|
|
340
|
+
<input id="q" type="search" placeholder="Filter ranges & atoms (/)" autocomplete="off" spellcheck="false">
|
|
341
|
+
<button class="clr" id="q-clear" hidden aria-label="Clear filter">✕</button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="scroll"><div class="rlist" id="range-list" role="listbox" aria-label="Ranges"></div></div>
|
|
345
|
+
</section>
|
|
346
|
+
|
|
347
|
+
<section class="pane timeline" aria-label="Anchor timeline">
|
|
348
|
+
<div class="pane-head">
|
|
349
|
+
<span class="pane-title">Timeline</span>
|
|
350
|
+
<span class="chip" id="tl-stat"></span>
|
|
351
|
+
<div class="spacer"></div>
|
|
352
|
+
<div class="seg" role="group" aria-label="Policy filter">
|
|
353
|
+
<button data-filter="all" aria-pressed="true">All</button>
|
|
354
|
+
<button data-filter="range" aria-pressed="false">Compressed</button>
|
|
355
|
+
<button data-filter="keep" aria-pressed="false">Kept</button>
|
|
356
|
+
</div>
|
|
357
|
+
<button class="btn btn-sm" id="toggle-all">Expand all</button>
|
|
358
|
+
</div>
|
|
359
|
+
<div class="scroll" id="tl-scroll"><div class="tl" id="timeline"></div></div>
|
|
360
|
+
</section>
|
|
361
|
+
|
|
362
|
+
<aside class="pane editor" aria-label="Range editor">
|
|
363
|
+
<div class="scroll"><div id="editor"></div></div>
|
|
364
|
+
</aside>
|
|
365
|
+
</main>
|
|
366
|
+
|
|
367
|
+
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
368
|
+
<div class="help" id="help" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
|
|
369
|
+
<div class="help-card">
|
|
370
|
+
<h3>Keyboard shortcuts</h3>
|
|
371
|
+
<dl id="help-list">
|
|
372
|
+
<dt><kbd>j</kbd> / <kbd>k</kbd></dt><dd>Next / previous range</dd>
|
|
373
|
+
<dt><kbd>/</kbd></dt><dd>Focus filter</dd>
|
|
374
|
+
<dt><kbd>e</kbd></dt><dd>Focus summary editor</dd>
|
|
375
|
+
<dt><kbd>⌘/Ctrl</kbd>+<kbd>S</kbd></dt><dd>Save current range</dd>
|
|
376
|
+
<dt><kbd>r</kbd></dt><dd>Reload state from server</dd>
|
|
377
|
+
<dt><kbd>x</kbd></dt><dd>Expand / collapse selected group</dd>
|
|
378
|
+
<dt><kbd>Esc</kbd></dt><dd>Blur field / close dialog</dd>
|
|
379
|
+
</dl>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
<div class="help" id="close-dialog" role="dialog" aria-modal="true" aria-label="Unsaved changes">
|
|
383
|
+
<div class="help-card">
|
|
384
|
+
<h3>Unsaved changes</h3>
|
|
385
|
+
<p>Save the current DraftPlan before closing this workbench?</p>
|
|
386
|
+
<div class="dialog-actions">
|
|
387
|
+
<button class="btn" id="close-stay">Stay</button>
|
|
388
|
+
<button class="btn btn-danger" id="close-discard">Discard</button>
|
|
389
|
+
<button class="btn btn-primary" id="close-save">Save & close</button>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<script id="state" type="application/json"><!--MIDCOMPACT_STATE--></script>
|
|
395
|
+
<script type="module">
|
|
396
|
+
const EMPTY = { atoms: [], draft: { revision: 0, ranges: [] }, telemetry: {} };
|
|
397
|
+
let state = (() => {
|
|
398
|
+
try {
|
|
399
|
+
const s = JSON.parse(document.getElementById('state').textContent);
|
|
400
|
+
return { view: s.view ?? 'review', atoms: s.atoms ?? [], draft: { revision: s.draft?.revision ?? 0, ranges: s.draft?.ranges ?? [] }, telemetry: s.telemetry ?? {} };
|
|
401
|
+
} catch { return EMPTY; }
|
|
402
|
+
})();
|
|
403
|
+
|
|
404
|
+
// The server owns the keepalive writes. If this page disappears, the stream
|
|
405
|
+
// closes and the terminal-side UI lock is released without treating inactivity
|
|
406
|
+
// in an open tab as disconnection.
|
|
407
|
+
const liveness = new EventSource('/api/liveness');
|
|
408
|
+
|
|
409
|
+
let selectedId = state.draft.ranges[0]?.id ?? null;
|
|
410
|
+
let query = '';
|
|
411
|
+
let policy = 'all';
|
|
412
|
+
const edits = new Map(); // rangeId -> { topic, summary }
|
|
413
|
+
const collapsed = new Set(); // group keys currently collapsed
|
|
414
|
+
const openAtoms = new Set(); // atom refs expanded to full preview
|
|
415
|
+
let lastScrolled = null;
|
|
416
|
+
let removeArmed = null;
|
|
417
|
+
let selectionDirty = false;
|
|
418
|
+
|
|
419
|
+
state.draft.ranges.forEach(r => { if (r.id !== selectedId) collapsed.add(r.id); });
|
|
420
|
+
|
|
421
|
+
/* ---------- helpers ---------- */
|
|
422
|
+
const $ = id => document.getElementById(id);
|
|
423
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
424
|
+
const fmt = n => n == null || Number.isNaN(n) ? '—'
|
|
425
|
+
: Math.abs(n) >= 1000 ? (n / 1000).toFixed(Math.abs(n) % 1000 ? 1 : 0) + 'k' : String(Math.round(n));
|
|
426
|
+
const pct = n => n == null || Number.isNaN(n) ? '—' : n.toFixed(n % 1 ? 1 : 0) + '%';
|
|
427
|
+
const clamp = n => Math.max(0, Math.min(100, n));
|
|
428
|
+
const isSelection = () => state.view === 'selection';
|
|
429
|
+
const atomByRef = ref => state.atoms.find(a => a.ref === ref);
|
|
430
|
+
const rangeAtoms = r => {
|
|
431
|
+
const start = state.atoms.findIndex(a => a.ref === r.startRef);
|
|
432
|
+
const end = state.atoms.findIndex(a => a.ref === r.endRef);
|
|
433
|
+
return start >= 0 && end >= start ? state.atoms.slice(start, end + 1) : [];
|
|
434
|
+
};
|
|
435
|
+
let selectionRefs = new Set(state.draft.ranges.flatMap(r => rangeAtoms(r).map(a => a.ref)));
|
|
436
|
+
let keepRefs = new Set();
|
|
437
|
+
const rangeById = id => state.draft.ranges.find(r => r.id === id);
|
|
438
|
+
const saveOf = r => Math.max(0, (r.originalContentChars ?? 0) - (r.replacementContentChars ?? 0));
|
|
439
|
+
|
|
440
|
+
function hl(text, q) {
|
|
441
|
+
const out = esc(text);
|
|
442
|
+
if (!q) return out;
|
|
443
|
+
const re = new RegExp(esc(q).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
444
|
+
return out.replace(re, m => '<mark>' + m + '</mark>');
|
|
445
|
+
}
|
|
446
|
+
function atomText(a) {
|
|
447
|
+
return [a.ref, a.kind, a.preview, ...(a.toolNames ?? []), ...(a.roles ?? [])].join(' ').toLowerCase();
|
|
448
|
+
}
|
|
449
|
+
function rangeText(r) {
|
|
450
|
+
return [r.id, r.startRef, r.endRef, r.topic ?? '', r.summary].join(' ').toLowerCase();
|
|
451
|
+
}
|
|
452
|
+
function draftOf(r) {
|
|
453
|
+
const e = edits.get(r.id);
|
|
454
|
+
return { topic: e ? e.topic : (r.topic ?? ''), summary: e ? e.summary : r.summary };
|
|
455
|
+
}
|
|
456
|
+
function isDirty(r) {
|
|
457
|
+
const e = edits.get(r.id);
|
|
458
|
+
return !!e && (e.topic !== (r.topic ?? '') || e.summary !== r.summary);
|
|
459
|
+
}
|
|
460
|
+
const dirtyIds = () => state.draft.ranges.filter(isDirty).map(r => r.id);
|
|
461
|
+
|
|
462
|
+
/* ---------- render ---------- */
|
|
463
|
+
function render() {
|
|
464
|
+
const n = state.draft.ranges.length;
|
|
465
|
+
$('draft-badge').textContent = `draft v${state.draft.revision} · ${n} range${n === 1 ? '' : 's'}`;
|
|
466
|
+
const d = dirtyIds();
|
|
467
|
+
const dirtyCount = d.length + (selectionDirty ? 1 : 0);
|
|
468
|
+
$('dirty-badge').hidden = dirtyCount === 0;
|
|
469
|
+
$('dirty-badge').textContent = `${dirtyCount} unsaved`;
|
|
470
|
+
$('mode-title').textContent = isSelection() ? 'Selection' : 'Review';
|
|
471
|
+
document.title = `Midcompact ${isSelection() ? 'Selection' : 'Review'}`;
|
|
472
|
+
document.querySelector('.seg').hidden = isSelection();
|
|
473
|
+
if (isSelection()) {
|
|
474
|
+
$('help-list').innerHTML = '<dt><kbd>/</kbd></dt><dd>Focus filter</dd><dt><kbd>Click</kbd></dt><dd>Add atom or KEEP</dd><dt><kbd>Save</kbd></dt><dd>Persist DraftPlan</dd><dt><kbd>Esc</kbd></dt><dd>Close dialog</dd>';
|
|
475
|
+
}
|
|
476
|
+
$('save-selection').hidden = !isSelection();
|
|
477
|
+
$('save-all').hidden = isSelection() || d.length < 2;
|
|
478
|
+
$('save-all').textContent = `Save all (${d.length})`;
|
|
479
|
+
renderMetrics();
|
|
480
|
+
renderRanges();
|
|
481
|
+
renderTimeline();
|
|
482
|
+
renderEditor();
|
|
483
|
+
if (selectedId && selectedId !== lastScrolled) {
|
|
484
|
+
lastScrolled = selectedId;
|
|
485
|
+
document.querySelector(`[data-group-key="${cssEsc(selectedId)}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
486
|
+
document.querySelector(`[data-range="${cssEsc(selectedId)}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const cssEsc = s => (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&');
|
|
490
|
+
|
|
491
|
+
function renderMetrics() {
|
|
492
|
+
const t = state.telemetry ?? {};
|
|
493
|
+
const usage = t.anchorUsage;
|
|
494
|
+
const usageLabel = usage?.tokens == null ? 'unavailable' : `${fmt(usage.tokens)} / ${fmt(usage.contextWindow)} (${pct(usage.percent)})`;
|
|
495
|
+
const selected = isSelection()
|
|
496
|
+
? state.atoms.filter(a => selectionRefs.has(a.ref) && a.compressible && !keepRefs.has(a.ref))
|
|
497
|
+
: state.atoms.filter(a => a.owningRangeId);
|
|
498
|
+
const chars = selected.reduce((sum, a) => sum + (a.contentChars ?? 0), 0);
|
|
499
|
+
const totalChars = state.atoms.reduce((sum, a) => sum + (a.contentChars ?? 0), 0);
|
|
500
|
+
const selectedShare = totalChars > 0 ? clamp((chars / totalChars) * 100) : 0;
|
|
501
|
+
const images = selected.reduce((sum, a) => sum + (a.imageCount ?? 0), 0);
|
|
502
|
+
const pending = state.draft.ranges.filter(r => !r.summary.trim()).length;
|
|
503
|
+
const usagePercent = usage?.percent == null ? 0 : clamp(usage.percent);
|
|
504
|
+
const contentMetric = isSelection()
|
|
505
|
+
? `<div class="metric"><label>Selected content chars</label><div class="val">${fmt(chars)} / ${fmt(totalChars)} <small>${pct(selectedShare)} of anchor</small></div><div class="track"><i class="proj" style="width:${selectedShare}%"></i></div><div class="legend"><span>up to <b>${pct(selectedShare)}</b> fewer anchor chars · actual is lower after summaries</span></div></div>`
|
|
506
|
+
: `<div class="metric"><label>Content chars</label><div class="val">${fmt(chars)}</div><div class="legend"><span>${selected.length} selected atoms</span></div></div>`;
|
|
507
|
+
$('metrics').innerHTML = `
|
|
508
|
+
<div class="metric"><label>Pi reported anchor</label><div class="val">${usageLabel}</div><div class="track"><i class="anchor" style="width:${usagePercent}%"></i></div><div class="legend"><span>provider usage fact · no local token estimate</span></div></div>
|
|
509
|
+
<div class="metric"><label>Draft ranges</label><div class="val">${state.draft.ranges.length} <small>${pending ? `${pending} pending` : 'ready for review'}</small></div><div class="legend"><span>revision <b>${state.draft.revision}</b></span></div></div>
|
|
510
|
+
${contentMetric}
|
|
511
|
+
<div class="metric"><label>Images</label><div class="val">${images}</div><div class="legend"><span>payload facts only</span></div></div>`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function proposedRanges() {
|
|
515
|
+
const runs = [];
|
|
516
|
+
for (const atom of state.atoms) {
|
|
517
|
+
if (!selectionRefs.has(atom.ref) || keepRefs.has(atom.ref) || !atom.compressible) continue;
|
|
518
|
+
const previous = runs.at(-1);
|
|
519
|
+
if (previous && previous.endIndex + 1 === atom.index) {
|
|
520
|
+
previous.endRef = atom.ref;
|
|
521
|
+
previous.endIndex = atom.index;
|
|
522
|
+
previous.atoms.push(atom);
|
|
523
|
+
} else runs.push({ startRef: atom.ref, endRef: atom.ref, endIndex: atom.index, atoms: [atom] });
|
|
524
|
+
}
|
|
525
|
+
return runs;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function renderRanges() {
|
|
529
|
+
const list = $('range-list');
|
|
530
|
+
if (isSelection()) {
|
|
531
|
+
const ranges = proposedRanges();
|
|
532
|
+
if (!ranges.length) {
|
|
533
|
+
list.innerHTML = '<div class="empty">No atoms selected.<br>Add a group or atom from the timeline.</div>';
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
list.innerHTML = ranges.map((range, index) => {
|
|
537
|
+
const chars = range.atoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
|
|
538
|
+
const images = range.atoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
|
|
539
|
+
return `<div class="ritem" role="option">
|
|
540
|
+
<div class="r1"><span class="rid">range ${index + 1}</span><span class="refs">${esc(range.startRef)}→${esc(range.endRef)}</span><span class="spacer"></span><button class="btn btn-sm btn-icon" data-remove-proposed="${index}" title="Remove range" aria-label="Remove range">×</button></div>
|
|
541
|
+
<div class="rsum">Pending summary</div>
|
|
542
|
+
<div class="meta">${range.atoms.length} atoms · ${fmt(chars)} chars · ${images} images</div>
|
|
543
|
+
</div>`;
|
|
544
|
+
}).join('');
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const rs = state.draft.ranges.filter(r => !query || rangeText(r).includes(query));
|
|
548
|
+
if (!state.draft.ranges.length) {
|
|
549
|
+
list.innerHTML = '<div class="empty">No compression ranges in this draft.</div>';
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (!rs.length) { list.innerHTML = '<div class="empty">No range matches the filter.</div>'; return; }
|
|
553
|
+
list.innerHTML = rs.map(r => {
|
|
554
|
+
const d = draftOf(r);
|
|
555
|
+
const ratio = r.originalContentChars ? Math.round((1 - (r.replacementContentChars ?? 0) / r.originalContentChars) * 100) : 0;
|
|
556
|
+
return `<div class="ritem" role="option" tabindex="0" data-range="${esc(r.id)}" aria-selected="${r.id === selectedId}">
|
|
557
|
+
<div class="r1">
|
|
558
|
+
<span class="rid">${hl(r.id, query)}</span>
|
|
559
|
+
<span class="refs">${esc(r.startRef)}→${esc(r.endRef)}</span>
|
|
560
|
+
${isDirty(r) ? '<span class="udot" title="unsaved edits"></span>' : ''}
|
|
561
|
+
<span class="save">−${fmt(saveOf(r))}</span>
|
|
562
|
+
</div>
|
|
563
|
+
${d.topic ? `<div class="meta" style="color:var(--accent-2)">${hl(d.topic, query)}</div>` : ''}
|
|
564
|
+
<div class="rsum">${hl(d.summary, query)}</div>
|
|
565
|
+
<div class="meta">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)} chars · ${ratio}% fewer chars</div>
|
|
566
|
+
</div>`;
|
|
567
|
+
}).join('');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function buildGroups() {
|
|
571
|
+
const groups = [];
|
|
572
|
+
let current = null;
|
|
573
|
+
state.atoms.forEach((atom, index) => {
|
|
574
|
+
const key = isSelection() ? atom.groupRef : (atom.owningRangeId ?? null);
|
|
575
|
+
if (!current || current.key !== key) {
|
|
576
|
+
current = { key, idx: index, label: atom.groupLabel, atoms: [] };
|
|
577
|
+
groups.push(current);
|
|
578
|
+
}
|
|
579
|
+
current.atoms.push(atom);
|
|
580
|
+
});
|
|
581
|
+
return groups;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function renderTimeline() {
|
|
585
|
+
const tl = $('timeline');
|
|
586
|
+
if (!state.atoms.length) { tl.innerHTML = '<div class="empty">No atoms in anchor snapshot.</div>'; $('tl-stat').textContent = ''; return; }
|
|
587
|
+
|
|
588
|
+
let shown = 0;
|
|
589
|
+
const html = buildGroups().map(g => {
|
|
590
|
+
if (!isSelection() && policy === 'range' && !g.key) return '';
|
|
591
|
+
if (!isSelection() && policy === 'keep' && g.key) return '';
|
|
592
|
+
const r = !isSelection() && g.key ? rangeById(g.key) : null;
|
|
593
|
+
const gkey = g.key ?? ('keep-' + g.idx);
|
|
594
|
+
const groupMatch = r ? rangeText(r).includes(query) : `${g.label ?? ''} ${gkey}`.toLowerCase().includes(query);
|
|
595
|
+
const atoms = query && !groupMatch ? g.atoms.filter(a => atomText(a).includes(query)) : g.atoms;
|
|
596
|
+
if (query && !groupMatch && !atoms.length) return '';
|
|
597
|
+
shown += atoms.length;
|
|
598
|
+
|
|
599
|
+
const chars = g.atoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
|
|
600
|
+
const images = g.atoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
|
|
601
|
+
const open = !collapsed.has(gkey) || (query && !groupMatch);
|
|
602
|
+
const body = atoms.map(a => atomRow(a)).join('');
|
|
603
|
+
|
|
604
|
+
if (isSelection()) {
|
|
605
|
+
const allSelected = g.atoms.every(atom => selectionRefs.has(atom.ref));
|
|
606
|
+
return `<section class="group ${open ? 'open' : ''} ${allSelected ? 'sel' : ''}" data-group-key="${esc(gkey)}">
|
|
607
|
+
<div class="ghead" aria-expanded="${open}">
|
|
608
|
+
<button class="btn btn-sm btn-icon" data-toggle="${esc(gkey)}" title="Expand group">▶</button>
|
|
609
|
+
<span class="gid">${esc(gkey)}</span>
|
|
610
|
+
<span class="gsum">${esc(g.label ?? '')}</span>
|
|
611
|
+
<span class="gstat">${g.atoms.length} atoms · ${fmt(chars)} chars · ${images} img <button class="btn btn-sm" data-select-group="${esc(gkey)}">${allSelected ? 'Clear group' : 'Add group'}</button></span>
|
|
612
|
+
</div>
|
|
613
|
+
<div class="gbody">${body}</div>
|
|
614
|
+
</section>`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!r) {
|
|
618
|
+
return `<section class="group keepg ${open ? 'open' : ''}" data-group-key="${esc(gkey)}">
|
|
619
|
+
<button class="ghead" data-toggle="${esc(gkey)}" aria-expanded="${open}">
|
|
620
|
+
<span class="chev">▶</span>
|
|
621
|
+
<span class="gid">KEEP</span>
|
|
622
|
+
<span class="gsum">${esc(g.atoms[0].ref)} → ${esc(g.atoms[g.atoms.length - 1].ref)} verbatim</span>
|
|
623
|
+
<span class="gstat">${g.atoms.length} atoms · ${fmt(chars)} chars · ${images} img</span>
|
|
624
|
+
</button>
|
|
625
|
+
<div class="gbody">${body}</div>
|
|
626
|
+
</section>`;
|
|
627
|
+
}
|
|
628
|
+
const d = draftOf(r);
|
|
629
|
+
return `<section class="group ${open ? 'open' : ''} ${r.id === selectedId ? 'sel' : ''}" data-group-key="${esc(gkey)}">
|
|
630
|
+
<button class="ghead" data-toggle="${esc(gkey)}" data-select="${esc(r.id)}" aria-expanded="${open}">
|
|
631
|
+
<span class="chev">▶</span>
|
|
632
|
+
<span class="gid">${esc(r.id)}</span>
|
|
633
|
+
<span class="gsum">${hl(d.topic || d.summary, query)}</span>
|
|
634
|
+
<span class="gstat">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)} chars <em>−${fmt(saveOf(r))}</em></span>
|
|
635
|
+
</button>
|
|
636
|
+
<div class="gbody">${body}</div>
|
|
637
|
+
</section>`;
|
|
638
|
+
}).join('');
|
|
639
|
+
|
|
640
|
+
tl.innerHTML = html || '<div class="empty">Nothing matches the current filter.</div>';
|
|
641
|
+
$('tl-stat').textContent = query || policy !== 'all' ? `${shown}/${state.atoms.length} atoms` : `${state.atoms.length} atoms`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function toolTag(name) {
|
|
645
|
+
const tone = ['read', 'write', 'edit', 'bash'].includes(String(name).toLowerCase()) ? ` ${String(name).toLowerCase()}` : '';
|
|
646
|
+
return `<span class="tag tool${tone}">${esc(name)}</span>`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function atomRow(a) {
|
|
650
|
+
const tags = [
|
|
651
|
+
...(a.toolNames ?? []).slice(0, 3).map(toolTag),
|
|
652
|
+
...(a.roles ?? []).slice(0, 2).map(t => `<span class="tag">${esc(t)}</span>`),
|
|
653
|
+
a.compressible === false ? '<span class="tag lock">not compressible</span>' : '',
|
|
654
|
+
a.protocolClosed === false ? '<span class="tag lock">open protocol</span>' : '',
|
|
655
|
+
a.compressedBlockId ? `<span class="tag">blk ${esc(a.compressedBlockId)}</span>` : '',
|
|
656
|
+
].filter(Boolean).join('');
|
|
657
|
+
const open = openAtoms.has(a.ref);
|
|
658
|
+
const selected = selectionRefs.has(a.ref);
|
|
659
|
+
const kept = keepRefs.has(a.ref) || !a.compressible;
|
|
660
|
+
const actions = isSelection() ? `<div class="atom-actions">
|
|
661
|
+
<button class="btn btn-sm" data-toggle-atom="${esc(a.ref)}" ${!a.compressible ? 'disabled' : ''}>${selected ? 'Remove' : 'Add'}</button>
|
|
662
|
+
<button class="btn btn-sm" data-keep-atom="${esc(a.ref)}" ${!a.compressible ? 'disabled' : ''}>${kept ? 'KEEP' : 'Keep'}</button>
|
|
663
|
+
</div>` : '';
|
|
664
|
+
return `<div class="atom ${open ? 'open' : ''} ${selected ? 'selected-atom' : ''} ${kept ? 'kept-atom' : ''}" data-atom="${esc(a.ref)}">
|
|
665
|
+
<span class="aref">${hl(a.ref, query)}</span>
|
|
666
|
+
<span class="akind">${hl(a.kind ?? '', query)}</span>
|
|
667
|
+
<span class="atok">${fmt(a.contentChars)} chars<br>${a.imageCount ?? 0} img</span>
|
|
668
|
+
<div>
|
|
669
|
+
<div class="abody">${hl(a.preview ?? '', query)}</div>
|
|
670
|
+
${tags ? `<div class="tags">${tags}</div>` : ''}
|
|
671
|
+
${actions}
|
|
672
|
+
</div>
|
|
673
|
+
</div>`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function renderEditor() {
|
|
677
|
+
const panel = $('editor');
|
|
678
|
+
if (isSelection()) {
|
|
679
|
+
const selectedAtoms = state.atoms.filter(atom => selectionRefs.has(atom.ref));
|
|
680
|
+
const chars = selectedAtoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
|
|
681
|
+
const images = selectedAtoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
|
|
682
|
+
panel.innerHTML = `<div class="epad">
|
|
683
|
+
<div class="etitle"><span class="eid">Selection</span><span class="erefs">shared DraftPlan</span></div>
|
|
684
|
+
<div class="estats">
|
|
685
|
+
<div class="estat"><label>atoms</label><span>${selectedAtoms.length}</span></div>
|
|
686
|
+
<div class="estat"><label>chars</label><span>${fmt(chars)}</span></div>
|
|
687
|
+
<div class="estat"><label>images</label><span>${images}</span></div>
|
|
688
|
+
</div>
|
|
689
|
+
<div class="hint">Add a whole user group or individual atoms. KEEP creates a hole; protected atoms stay verbatim. Save writes normalized, non-overlapping ranges to the same DraftPlan used by Agent.</div>
|
|
690
|
+
<button class="btn btn-primary" id="selection-save-side">Save selection</button>
|
|
691
|
+
<button class="btn" id="selection-clear">Clear selection</button>
|
|
692
|
+
</div>`;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const r = selectedId ? rangeById(selectedId) : null;
|
|
696
|
+
if (!r) {
|
|
697
|
+
panel.innerHTML = `<div class="empty">${state.draft.ranges.length
|
|
698
|
+
? 'Select a range on the left to edit its topic and summary.'
|
|
699
|
+
: 'No compression ranges to review.<br>Close and return to the terminal.'}</div>`;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const d = draftOf(r);
|
|
703
|
+
const dirty = isDirty(r);
|
|
704
|
+
const ratio = r.originalContentChars ? Math.round((1 - (r.replacementContentChars ?? 0) / r.originalContentChars) * 100) : 0;
|
|
705
|
+
panel.innerHTML = `<div class="epad">
|
|
706
|
+
<div>
|
|
707
|
+
<div class="etitle">
|
|
708
|
+
<span class="eid">${esc(r.id)}</span>
|
|
709
|
+
<span class="erefs">${esc(r.startRef)} → ${esc(r.endRef)}</span>
|
|
710
|
+
</div>
|
|
711
|
+
<div class="estats">
|
|
712
|
+
<div class="estat"><label>atoms</label><span>${r.atomCount}</span></div>
|
|
713
|
+
<div class="estat"><label>chars</label><span>${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)}</span></div>
|
|
714
|
+
<div class="estat"><label>images</label><span>${r.originalImageCount ?? 0}</span></div>
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
<div class="field" id="f-topic">
|
|
718
|
+
<label for="topic">Topic <span class="count">optional</span></label>
|
|
719
|
+
<input id="topic" value="${esc(d.topic)}" placeholder="short label, e.g. auth refactor" autocomplete="off" spellcheck="false">
|
|
720
|
+
</div>
|
|
721
|
+
<div class="field ${d.summary.trim() ? '' : 'invalid'}" id="f-summary">
|
|
722
|
+
<label for="summary">Summary <span class="count" id="sum-count"></span></label>
|
|
723
|
+
<textarea id="summary" spellcheck="false">${esc(d.summary)}</textarea>
|
|
724
|
+
</div>
|
|
725
|
+
<div class="erow">
|
|
726
|
+
<button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>Save</button>
|
|
727
|
+
<button class="btn" id="revert-btn" ${dirty ? '' : 'disabled'}>Revert</button>
|
|
728
|
+
<div class="spacer"></div>
|
|
729
|
+
<button class="btn btn-danger ${removeArmed === r.id ? 'armed' : ''}" id="remove-btn">
|
|
730
|
+
${removeArmed === r.id ? 'Confirm remove' : 'Remove range'}
|
|
731
|
+
</button>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="hint">Topic and summary are saved together (<kbd>⌘/Ctrl</kbd>+<kbd>S</kbd>). Removing a range keeps its atoms verbatim.</div>
|
|
734
|
+
</div>`;
|
|
735
|
+
updateCount();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function updateCount() {
|
|
739
|
+
const ta = $('summary');
|
|
740
|
+
if (!ta) return;
|
|
741
|
+
const len = ta.value.length;
|
|
742
|
+
$('sum-count').textContent = `${len} chars`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/* ---------- state mutation ---------- */
|
|
746
|
+
function select(id) {
|
|
747
|
+
if (!id || id === selectedId) return;
|
|
748
|
+
selectedId = id;
|
|
749
|
+
collapsed.delete(id);
|
|
750
|
+
removeArmed = null;
|
|
751
|
+
render();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function post(path, body) {
|
|
755
|
+
const res = await fetch(path, {
|
|
756
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}),
|
|
757
|
+
});
|
|
758
|
+
if (!res.ok) {
|
|
759
|
+
let msg = `request failed (${res.status})`;
|
|
760
|
+
try { const e = await res.json(); if (e && e.error) msg = e.error; } catch {}
|
|
761
|
+
throw new Error(msg);
|
|
762
|
+
}
|
|
763
|
+
return res.json();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function saveSelection() {
|
|
767
|
+
const indices = state.atoms.map((atom, index) => selectionRefs.has(atom.ref) ? index : -1).filter(index => index >= 0);
|
|
768
|
+
const spans = [];
|
|
769
|
+
for (const index of indices) {
|
|
770
|
+
const last = spans.at(-1);
|
|
771
|
+
if (last && last.endIndex + 1 === index) {
|
|
772
|
+
last.endRef = state.atoms[index].ref;
|
|
773
|
+
last.endIndex = index;
|
|
774
|
+
} else spans.push({ startRef: state.atoms[index].ref, endRef: state.atoms[index].ref, endIndex: index });
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
state = await post('/api/selection', { spans: spans.map(({ startRef, endRef }) => ({ startRef, endRef })), keepRefs: [...keepRefs] });
|
|
778
|
+
selectionDirty = false;
|
|
779
|
+
selectedId = state.draft.ranges[0]?.id ?? null;
|
|
780
|
+
render();
|
|
781
|
+
toast('DraftPlan saved. Tell the Agent to continue when ready.', 'ok');
|
|
782
|
+
return true;
|
|
783
|
+
} catch (err) { toast(String(err.message ?? err), 'err'); return false; }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function toggleSelectionAtom(ref) {
|
|
787
|
+
const atom = atomByRef(ref);
|
|
788
|
+
if (!atom || !atom.compressible) return;
|
|
789
|
+
if (selectionRefs.has(ref)) { selectionRefs.delete(ref); keepRefs.delete(ref); }
|
|
790
|
+
else selectionRefs.add(ref);
|
|
791
|
+
selectionDirty = true;
|
|
792
|
+
render();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function toggleKeepAtom(ref) {
|
|
796
|
+
const atom = atomByRef(ref);
|
|
797
|
+
if (!atom || !atom.compressible) return;
|
|
798
|
+
selectionRefs.add(ref);
|
|
799
|
+
if (keepRefs.has(ref)) keepRefs.delete(ref); else keepRefs.add(ref);
|
|
800
|
+
selectionDirty = true;
|
|
801
|
+
render();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function toggleSelectionGroup(groupRef) {
|
|
805
|
+
const atoms = state.atoms.filter(atom => atom.groupRef === groupRef);
|
|
806
|
+
const allSelected = atoms.every(atom => selectionRefs.has(atom.ref));
|
|
807
|
+
for (const atom of atoms) {
|
|
808
|
+
if (allSelected) { selectionRefs.delete(atom.ref); keepRefs.delete(atom.ref); }
|
|
809
|
+
else selectionRefs.add(atom.ref);
|
|
810
|
+
}
|
|
811
|
+
selectionDirty = true;
|
|
812
|
+
render();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function saveRange(id) {
|
|
816
|
+
const r = rangeById(id);
|
|
817
|
+
const e = edits.get(id);
|
|
818
|
+
if (!r || !e) return false;
|
|
819
|
+
if (!e.summary.trim()) { toast('Summary cannot be empty', 'err'); $('summary')?.focus(); return false; }
|
|
820
|
+
const base = `/api/range/${encodeURIComponent(id)}`;
|
|
821
|
+
if (e.topic.trim() !== (r.topic ?? '').trim()) state = await post(`${base}/edit-topic`, { value: e.topic.trim() });
|
|
822
|
+
if (e.summary.trim() !== r.summary) state = await post(`${base}/edit-summary`, { value: e.summary.trim() });
|
|
823
|
+
edits.delete(id);
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function saveCurrent() {
|
|
828
|
+
if (!selectedId || !isDirty(rangeById(selectedId) ?? {})) return;
|
|
829
|
+
try { if (await saveRange(selectedId)) { render(); toast('Saved ' + selectedId, 'ok'); } }
|
|
830
|
+
catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function refresh() {
|
|
834
|
+
try {
|
|
835
|
+
const res = await fetch('/api/state');
|
|
836
|
+
if (!res.ok) throw new Error('state fetch failed');
|
|
837
|
+
state = await res.json();
|
|
838
|
+
selectionRefs = new Set(state.draft.ranges.flatMap(range => rangeAtoms(range).map(atom => atom.ref)));
|
|
839
|
+
keepRefs.clear();
|
|
840
|
+
selectionDirty = false;
|
|
841
|
+
if (!rangeById(selectedId)) selectedId = state.draft.ranges[0]?.id ?? null;
|
|
842
|
+
render();
|
|
843
|
+
toast('Reloaded', 'ok');
|
|
844
|
+
} catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/* ---------- events ---------- */
|
|
848
|
+
$('range-list').addEventListener('click', e => {
|
|
849
|
+
const remove = e.target.closest('[data-remove-proposed]');
|
|
850
|
+
if (remove) {
|
|
851
|
+
const range = proposedRanges()[Number(remove.dataset.removeProposed)];
|
|
852
|
+
range?.atoms.forEach(atom => { selectionRefs.delete(atom.ref); keepRefs.delete(atom.ref); });
|
|
853
|
+
selectionDirty = true;
|
|
854
|
+
render();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const item = e.target.closest('[data-range]');
|
|
858
|
+
if (item) select(item.dataset.range);
|
|
859
|
+
});
|
|
860
|
+
$('range-list').addEventListener('keydown', e => {
|
|
861
|
+
const item = e.target.closest('[data-range]');
|
|
862
|
+
if (item && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(item.dataset.range); }
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
$('timeline').addEventListener('click', e => {
|
|
866
|
+
const atomToggle = e.target.closest('[data-toggle-atom]');
|
|
867
|
+
if (atomToggle) { toggleSelectionAtom(atomToggle.dataset.toggleAtom); return; }
|
|
868
|
+
const keepToggle = e.target.closest('[data-keep-atom]');
|
|
869
|
+
if (keepToggle) { toggleKeepAtom(keepToggle.dataset.keepAtom); return; }
|
|
870
|
+
const groupToggle = e.target.closest('[data-select-group]');
|
|
871
|
+
if (groupToggle) { toggleSelectionGroup(groupToggle.dataset.selectGroup); return; }
|
|
872
|
+
const atom = e.target.closest('[data-atom]');
|
|
873
|
+
if (atom && e.target.closest('.abody')) {
|
|
874
|
+
const ref = atom.dataset.atom;
|
|
875
|
+
openAtoms.has(ref) ? openAtoms.delete(ref) : openAtoms.add(ref);
|
|
876
|
+
atom.classList.toggle('open');
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const head = e.target.closest('[data-toggle]');
|
|
880
|
+
if (!head) return;
|
|
881
|
+
const key = head.dataset.toggle;
|
|
882
|
+
const chevron = e.target.closest('.chev');
|
|
883
|
+
if (head.dataset.select && !chevron && head.dataset.select !== selectedId) { select(head.dataset.select); return; }
|
|
884
|
+
collapsed.has(key) ? collapsed.delete(key) : collapsed.add(key);
|
|
885
|
+
head.closest('.group').classList.toggle('open');
|
|
886
|
+
head.setAttribute('aria-expanded', String(!collapsed.has(key)));
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
$('toggle-all').addEventListener('click', () => {
|
|
890
|
+
if (collapsed.size) { collapsed.clear(); $('toggle-all').textContent = 'Collapse all'; }
|
|
891
|
+
else {
|
|
892
|
+
buildGroups().forEach((g, i) => collapsed.add(g.key ?? ('keep-' + g.idx)));
|
|
893
|
+
$('toggle-all').textContent = 'Expand all';
|
|
894
|
+
}
|
|
895
|
+
renderTimeline();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
document.querySelectorAll('.seg [data-filter]').forEach(b => b.addEventListener('click', () => {
|
|
899
|
+
policy = b.dataset.filter;
|
|
900
|
+
document.querySelectorAll('.seg [data-filter]').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
|
|
901
|
+
renderTimeline();
|
|
902
|
+
}));
|
|
903
|
+
|
|
904
|
+
$('q').addEventListener('input', () => {
|
|
905
|
+
query = $('q').value.trim().toLowerCase();
|
|
906
|
+
$('q-clear').hidden = !query;
|
|
907
|
+
renderRanges(); renderTimeline();
|
|
908
|
+
});
|
|
909
|
+
$('q-clear').addEventListener('click', () => { $('q').value = ''; $('q').dispatchEvent(new Event('input')); $('q').focus(); });
|
|
910
|
+
|
|
911
|
+
$('editor').addEventListener('input', e => {
|
|
912
|
+
const r = rangeById(selectedId);
|
|
913
|
+
if (!r || !$('topic') || !$('summary')) return;
|
|
914
|
+
edits.set(r.id, { topic: $('topic').value, summary: $('summary').value });
|
|
915
|
+
if (!isDirty(r)) edits.delete(r.id);
|
|
916
|
+
const dirty = isDirty(r);
|
|
917
|
+
$('save-btn').disabled = !dirty;
|
|
918
|
+
$('revert-btn').disabled = !dirty;
|
|
919
|
+
$('f-summary').classList.toggle('invalid', !$('summary').value.trim());
|
|
920
|
+
updateCount();
|
|
921
|
+
renderRanges();
|
|
922
|
+
const d = dirtyIds();
|
|
923
|
+
$('dirty-badge').hidden = d.length === 0;
|
|
924
|
+
$('dirty-badge').textContent = `${d.length} unsaved`;
|
|
925
|
+
$('save-all').hidden = d.length < 2;
|
|
926
|
+
$('save-all').textContent = `Save all (${d.length})`;
|
|
927
|
+
if (e.target.id === 'topic') renderTimeline();
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
$('save-selection').addEventListener('click', saveSelection);
|
|
931
|
+
$('editor').addEventListener('click', async e => {
|
|
932
|
+
if (e.target.closest('#selection-save-side')) { await saveSelection(); return; }
|
|
933
|
+
if (e.target.closest('#selection-clear')) {
|
|
934
|
+
selectionRefs.clear(); keepRefs.clear(); selectionDirty = true; render(); return;
|
|
935
|
+
}
|
|
936
|
+
const r = rangeById(selectedId);
|
|
937
|
+
if (!r) return;
|
|
938
|
+
if (e.target.closest('#save-btn')) { await saveCurrent(); return; }
|
|
939
|
+
if (e.target.closest('#revert-btn')) { edits.delete(r.id); render(); toast('Reverted'); return; }
|
|
940
|
+
if (e.target.closest('#remove-btn')) {
|
|
941
|
+
if (removeArmed !== r.id) {
|
|
942
|
+
removeArmed = r.id;
|
|
943
|
+
renderEditor();
|
|
944
|
+
setTimeout(() => { if (removeArmed === r.id) { removeArmed = null; renderEditor(); } }, 4000);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
removeArmed = null;
|
|
948
|
+
try {
|
|
949
|
+
state = await post(`/api/range/${encodeURIComponent(r.id)}/remove`, {});
|
|
950
|
+
edits.delete(r.id);
|
|
951
|
+
selectedId = state.draft.ranges[0]?.id ?? null;
|
|
952
|
+
lastScrolled = null;
|
|
953
|
+
render();
|
|
954
|
+
toast('Removed ' + r.id, 'ok');
|
|
955
|
+
} catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
$('save-all').addEventListener('click', async () => {
|
|
960
|
+
try {
|
|
961
|
+
for (const id of dirtyIds()) await saveRange(id);
|
|
962
|
+
render(); toast('All changes saved', 'ok');
|
|
963
|
+
} catch (err) { render(); toast(String(err.message ?? err), 'err'); }
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
$('refresh').addEventListener('click', refresh);
|
|
967
|
+
|
|
968
|
+
async function closeWorkbench() {
|
|
969
|
+
try {
|
|
970
|
+
await post('/api/close', {});
|
|
971
|
+
document.body.innerHTML = `<div class="closed">${isSelection() ? 'Selection' : 'Review'} closed.<br>Return to your terminal.</div>`;
|
|
972
|
+
} catch (err) { toast(String(err.message ?? err), 'err'); }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
$('close').addEventListener('click', () => {
|
|
976
|
+
if (dirtyIds().length || selectionDirty) {
|
|
977
|
+
$('close-dialog').classList.add('show');
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
closeWorkbench();
|
|
981
|
+
});
|
|
982
|
+
$('close-stay').addEventListener('click', () => $('close-dialog').classList.remove('show'));
|
|
983
|
+
$('close-discard').addEventListener('click', () => {
|
|
984
|
+
edits.clear();
|
|
985
|
+
selectionDirty = false;
|
|
986
|
+
$('close-dialog').classList.remove('show');
|
|
987
|
+
closeWorkbench();
|
|
988
|
+
});
|
|
989
|
+
$('close-save').addEventListener('click', async () => {
|
|
990
|
+
let saved = true;
|
|
991
|
+
if (isSelection()) saved = await saveSelection();
|
|
992
|
+
else {
|
|
993
|
+
try { for (const id of dirtyIds()) saved = (await saveRange(id)) && saved; }
|
|
994
|
+
catch (err) { toast(String(err.message ?? err), 'err'); saved = false; }
|
|
995
|
+
}
|
|
996
|
+
if (!saved) return;
|
|
997
|
+
$('close-dialog').classList.remove('show');
|
|
998
|
+
await closeWorkbench();
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
/* theme */
|
|
1002
|
+
function themeIcon() {
|
|
1003
|
+
$('theme-toggle').textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '☀' : '☾';
|
|
1004
|
+
}
|
|
1005
|
+
$('theme-toggle').addEventListener('click', () => {
|
|
1006
|
+
const next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
|
|
1007
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
1008
|
+
try { localStorage.setItem('midcompact-theme', next); } catch {}
|
|
1009
|
+
themeIcon();
|
|
1010
|
+
});
|
|
1011
|
+
themeIcon();
|
|
1012
|
+
|
|
1013
|
+
/* help + shortcuts */
|
|
1014
|
+
const help = $('help');
|
|
1015
|
+
$('help-btn').addEventListener('click', () => help.classList.toggle('show'));
|
|
1016
|
+
help.addEventListener('click', e => { if (e.target === help) help.classList.remove('show'); });
|
|
1017
|
+
|
|
1018
|
+
function moveSel(delta) {
|
|
1019
|
+
const ids = state.draft.ranges.map(r => r.id);
|
|
1020
|
+
if (!ids.length) return;
|
|
1021
|
+
const i = ids.indexOf(selectedId);
|
|
1022
|
+
select(ids[Math.max(0, Math.min(ids.length - 1, i < 0 ? 0 : i + delta))]);
|
|
1023
|
+
}
|
|
1024
|
+
document.addEventListener('keydown', e => {
|
|
1025
|
+
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
|
1026
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); return; }
|
|
1027
|
+
if (e.key === 'Escape') {
|
|
1028
|
+
help.classList.remove('show');
|
|
1029
|
+
$('close-dialog').classList.remove('show');
|
|
1030
|
+
if (typing) e.target.blur();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (typing) return;
|
|
1034
|
+
if (e.key === '/') { e.preventDefault(); $('q').focus(); return; }
|
|
1035
|
+
if (e.key === '?') { help.classList.toggle('show'); return; }
|
|
1036
|
+
if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); moveSel(1); return; }
|
|
1037
|
+
if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); moveSel(-1); return; }
|
|
1038
|
+
if (e.key === 'e') { e.preventDefault(); $('summary')?.focus(); return; }
|
|
1039
|
+
if (e.key === 'r') { refresh(); return; }
|
|
1040
|
+
if (e.key === 'x' && selectedId) {
|
|
1041
|
+
collapsed.has(selectedId) ? collapsed.delete(selectedId) : collapsed.add(selectedId);
|
|
1042
|
+
renderTimeline();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
let toastTimer;
|
|
1047
|
+
function toast(msg, kind) {
|
|
1048
|
+
const t = $('toast');
|
|
1049
|
+
t.textContent = msg;
|
|
1050
|
+
t.className = 'toast show' + (kind ? ' ' + kind : '');
|
|
1051
|
+
clearTimeout(toastTimer);
|
|
1052
|
+
toastTimer = setTimeout(() => t.classList.remove('show'), kind === 'err' ? 3200 : 1800);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
window.addEventListener('beforeunload', e => { if (dirtyIds().length || selectionDirty) { e.preventDefault(); e.returnValue = ''; } });
|
|
1056
|
+
window.addEventListener('pagehide', () => {
|
|
1057
|
+
if (!dirtyIds().length && !selectionDirty) navigator.sendBeacon('/api/close', new Blob(['{}'], { type: 'application/json' }));
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
render();
|
|
1061
|
+
</script>
|
|
1062
|
+
</body>
|
|
1063
|
+
</html>
|