@seanmars/tospec 0.23.0 → 0.24.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/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
- package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
- package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
- package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
- package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
- package/assets/dashboard/app.js +218 -1180
- package/assets/dashboard/index.html +31 -22
- package/assets/dashboard/lib/charts.js +355 -0
- package/assets/dashboard/lib/derive.js +369 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +324 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +232 -0
- package/assets/dashboard/views/docs.js +214 -0
- package/assets/dashboard/views/home.js +357 -0
- package/assets/dashboard/views/project.js +168 -0
- package/assets/dashboard/views/session.js +270 -0
- package/assets/dashboard/views/sidebar.js +131 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +74 -95
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/dashboard-relay.d.ts +11 -0
- package/dist/commands/dashboard-relay.d.ts.map +1 -0
- package/dist/commands/dashboard-relay.js +88 -0
- package/dist/commands/dashboard-relay.js.map +1 -0
- package/dist/commands/dashboard.d.ts +62 -102
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +375 -609
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +258 -0
- package/dist/core/dashboard-hub.d.ts.map +1 -0
- package/dist/core/dashboard-hub.js +643 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +56 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +329 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +4 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/local-server.d.ts +1 -1
- package/dist/core/local-server.js +1 -1
- package/dist/core/shared/dashboard-mod.d.ts +24 -0
- package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
- package/dist/core/shared/dashboard-mod.js +44 -0
- package/dist/core/shared/dashboard-mod.js.map +1 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +4 -1
- package/dist/core/update.js.map +1 -1
- package/package.json +3 -2
- package/assets/dashboard/chart.umd.js +0 -14
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The session panel: one session's live log, and the question it waits on.
|
|
2
|
+
// The page answers what a session asked and nothing more: there is no box to
|
|
3
|
+
// type a prompt into, so every instruction still comes from the terminal. It
|
|
4
|
+
// sits beside whatever page is open and survives navigation.
|
|
5
|
+
//
|
|
6
|
+
// Built once per opened session and updated in place on every push: a
|
|
7
|
+
// rebuilt panel would wipe a half-chosen answer and steal the keyboard focus.
|
|
8
|
+
|
|
9
|
+
import { el, icon, activityDot, lineBadge } from '../lib/dom.js';
|
|
10
|
+
import { sessionStatus, sessionDetail, ACTIVITY_LABEL } from '../lib/derive.js';
|
|
11
|
+
import { store, projectByKey, slotOf, logId, loadSessionLog, forgetSessionLog, answerAsk } from '../lib/store.js';
|
|
12
|
+
|
|
13
|
+
const KIND_LABEL = { prompt: '你', reply: 'Claude', ask: '問題', answer: '回答', permission: '權限' };
|
|
14
|
+
const LOG_LIMIT = 200;
|
|
15
|
+
|
|
16
|
+
const panel = {
|
|
17
|
+
key: null,
|
|
18
|
+
session: null,
|
|
19
|
+
opener: null,
|
|
20
|
+
/** The highest event number drawn, so a push appends only what is new. */
|
|
21
|
+
drawnSeq: 0,
|
|
22
|
+
/** The ask drawn in the ask area; undefined until the first draw. */
|
|
23
|
+
askId: undefined,
|
|
24
|
+
dom: null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function host() {
|
|
28
|
+
return document.getElementById('session-panel');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSessionPanelOpen(key, session) {
|
|
32
|
+
return panel.key === key && panel.session === session;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function openSessionPanel(key, session) {
|
|
36
|
+
if (panel.key && !isSessionPanelOpen(key, session)) forgetSessionLog(panel.key, panel.session);
|
|
37
|
+
const opener = document.activeElement;
|
|
38
|
+
Object.assign(panel, { key, session, opener, drawnSeq: 0, askId: undefined });
|
|
39
|
+
build();
|
|
40
|
+
const node = host();
|
|
41
|
+
node.hidden = false;
|
|
42
|
+
document.body.classList.add('has-session-panel');
|
|
43
|
+
loadSessionLog(key, session);
|
|
44
|
+
updateSessionPanel();
|
|
45
|
+
panel.dom.title.focus({ preventScroll: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function closeSessionPanel() {
|
|
49
|
+
if (!panel.key) return;
|
|
50
|
+
forgetSessionLog(panel.key, panel.session);
|
|
51
|
+
const opener = panel.opener;
|
|
52
|
+
Object.assign(panel, { key: null, session: null, opener: null, dom: null });
|
|
53
|
+
host().hidden = true;
|
|
54
|
+
host().replaceChildren();
|
|
55
|
+
document.body.classList.remove('has-session-panel');
|
|
56
|
+
if (opener && opener.isConnected) opener.focus({ preventScroll: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function build() {
|
|
60
|
+
const close = el('button', { type: 'button', class: 'icon-button', title: '關閉' }, [icon('close', 14), el('span', { class: 'sr-only', text: '關閉 session 面板' })]);
|
|
61
|
+
close.addEventListener('click', closeSessionPanel);
|
|
62
|
+
const title = el('h2', { id: 'session-panel-title', class: 'sp-title', tabindex: '-1' }, [
|
|
63
|
+
el('span', { text: 'Session ' }),
|
|
64
|
+
el('span', { class: 'mono', text: panel.session.slice(0, 8) }),
|
|
65
|
+
]);
|
|
66
|
+
const dot = el('span', { class: 'sp-dot' });
|
|
67
|
+
const status = el('p', { class: 'sp-status' });
|
|
68
|
+
const log = el('ol', { class: 'sp-log', 'aria-label': 'session log', 'aria-live': 'polite' });
|
|
69
|
+
const logNote = el('p', { class: 'sp-log-note' });
|
|
70
|
+
const ask = el('section', { class: 'sp-ask', 'aria-label': '等待回答' });
|
|
71
|
+
ask.hidden = true;
|
|
72
|
+
const foot = el('p', { class: 'sp-foot' });
|
|
73
|
+
const node = host();
|
|
74
|
+
node.setAttribute('aria-labelledby', 'session-panel-title');
|
|
75
|
+
node.replaceChildren(
|
|
76
|
+
el('header', { class: 'sp-head' }, [el('div', { class: 'sp-head-row' }, [dot, title, close]), status]),
|
|
77
|
+
el('div', { class: 'sp-scroll' }, [logNote, log]),
|
|
78
|
+
ask,
|
|
79
|
+
foot
|
|
80
|
+
);
|
|
81
|
+
node.onkeydown = (event) => {
|
|
82
|
+
if (event.key === 'Escape') closeSessionPanel();
|
|
83
|
+
};
|
|
84
|
+
panel.dom = { title, dot, status, log, logNote, ask, foot };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Brings the open panel up to date with the store; cheap when nothing changed. */
|
|
88
|
+
export function updateSessionPanel() {
|
|
89
|
+
if (!panel.key || !panel.dom) return;
|
|
90
|
+
const project = projectByKey(panel.key);
|
|
91
|
+
const session = project && project.sessions.find((s) => s.id === panel.session);
|
|
92
|
+
const dom = panel.dom;
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
|
|
95
|
+
const status = session ? sessionStatus(session) : 'offline';
|
|
96
|
+
dom.dot.replaceChildren(activityDot(status));
|
|
97
|
+
const detail = session ? sessionDetail(session, now) : '這個 session 已經離開 dashboard.';
|
|
98
|
+
// Filtered: the native replaceChildren writes a null out as the text "null".
|
|
99
|
+
dom.status.replaceChildren(...[
|
|
100
|
+
project ? lineBadge(project.name, slotOf(project.key)) : null,
|
|
101
|
+
el('strong', { text: ACTIVITY_LABEL[status] }),
|
|
102
|
+
detail ? el('span', { class: 'sp-detail', text: detail }) : null,
|
|
103
|
+
].filter(Boolean));
|
|
104
|
+
|
|
105
|
+
drawLog();
|
|
106
|
+
drawAsk(session);
|
|
107
|
+
dom.foot.textContent = store.interactive
|
|
108
|
+
? '這裡只能回答 session 提出的問題; 其他指示請在 terminal 輸入.'
|
|
109
|
+
: '這個 dashboard 對外開放 (--allow-remote), 只能檢視.';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function drawLog() {
|
|
113
|
+
const dom = panel.dom;
|
|
114
|
+
const log = store.logs.get(logId(panel.key, panel.session));
|
|
115
|
+
if (!log) {
|
|
116
|
+
dom.logNote.textContent = '正在讀取 log.';
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
dom.logNote.textContent = log.error ? 'log 讀取失敗: ' + log.error : log.events.length ? '' : '這個 session 還沒有活動. 它下一次動作時會出現在這裡.';
|
|
120
|
+
const events = log.events;
|
|
121
|
+
const last = events.length ? events[events.length - 1].seq : 0;
|
|
122
|
+
// Numbers restart when a session expires and comes back: draw the log anew.
|
|
123
|
+
if (last < panel.drawnSeq) {
|
|
124
|
+
dom.log.replaceChildren();
|
|
125
|
+
panel.drawnSeq = 0;
|
|
126
|
+
}
|
|
127
|
+
const fresh = events.filter((e) => e.seq > panel.drawnSeq);
|
|
128
|
+
if (!fresh.length) return;
|
|
129
|
+
const scroller = dom.log.parentElement;
|
|
130
|
+
const atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 48;
|
|
131
|
+
for (const event of fresh) dom.log.append(logRow(event));
|
|
132
|
+
while (dom.log.children.length > LOG_LIMIT) dom.log.firstElementChild.remove();
|
|
133
|
+
panel.drawnSeq = last;
|
|
134
|
+
if (atBottom) scroller.scrollTop = scroller.scrollHeight;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clock(at) {
|
|
138
|
+
const d = new Date(at);
|
|
139
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
140
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function logRow(event) {
|
|
144
|
+
const isTool = event.kind === 'tool';
|
|
145
|
+
const failed = event.ok === false;
|
|
146
|
+
const body = isTool
|
|
147
|
+
? [el('span', { class: 'mono sp-target', text: event.target || '' }), el('span', { class: 'sp-mark', text: failed ? '失敗' : '完成' })]
|
|
148
|
+
: event.kind === 'permission'
|
|
149
|
+
? [el('span', { class: 'mono sp-target', text: `${event.tool || ''} ${event.target || ''}`.trim() }), ' ', event.text || '']
|
|
150
|
+
: [event.text || ''];
|
|
151
|
+
return el('li', { class: 'sp-entry kind-' + event.kind + (failed ? ' is-fail' : '') }, [
|
|
152
|
+
el('time', { class: 'sp-time num', datetime: event.at, text: clock(event.at) }),
|
|
153
|
+
el('span', { class: 'sp-who' + (isTool ? ' mono' : ''), text: isTool ? event.tool : KIND_LABEL[event.kind] || event.kind }),
|
|
154
|
+
el('span', { class: 'sp-body' }, body),
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- what the session waits on -------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
function drawAsk(session) {
|
|
161
|
+
const dom = panel.dom;
|
|
162
|
+
const ask = session && session.ask;
|
|
163
|
+
const id = ask ? ask.id : null;
|
|
164
|
+
if (id === panel.askId) return;
|
|
165
|
+
panel.askId = id;
|
|
166
|
+
dom.ask.hidden = !ask;
|
|
167
|
+
if (!ask) {
|
|
168
|
+
dom.ask.replaceChildren();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
dom.ask.replaceChildren(...(ask.kind === 'permission' ? permissionForm(ask) : questionForm(ask)).filter(Boolean));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readOnlyNote() {
|
|
175
|
+
return store.interactive ? null : el('p', { class: 'sp-hint', text: '這個 dashboard 對外開放 (--allow-remote), 只能檢視; 請在 terminal 回答.' });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function submitAnswer(ask, answer, controls, status) {
|
|
179
|
+
const { key, session } = panel;
|
|
180
|
+
for (const control of controls) control.disabled = true;
|
|
181
|
+
status.textContent = '正在送出.';
|
|
182
|
+
try {
|
|
183
|
+
await answerAsk(key, session, ask.id, answer);
|
|
184
|
+
status.textContent = '已送出.';
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// 404: the terminal answered first, and the question is being withdrawn.
|
|
187
|
+
status.textContent = e.status === 404 ? '這個問題已經在 terminal 回答, 或已撤回.' : '沒有送出: ' + e.message;
|
|
188
|
+
if (e.status !== 404) for (const control of controls) control.disabled = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function questionForm(ask) {
|
|
193
|
+
const status = el('p', { class: 'sp-hint', 'aria-live': 'polite' });
|
|
194
|
+
const controls = [];
|
|
195
|
+
const fields = ask.questions.map((q, qi) => {
|
|
196
|
+
const name = 'sp-q-' + qi;
|
|
197
|
+
const type = q.multiSelect ? 'checkbox' : 'radio';
|
|
198
|
+
const options = q.options.map((option) => {
|
|
199
|
+
const input = el('input', { type, name, value: option.label });
|
|
200
|
+
controls.push(input);
|
|
201
|
+
return el('label', { class: 'sp-option' }, [
|
|
202
|
+
input,
|
|
203
|
+
el('span', {}, [el('strong', { text: option.label }), option.description ? el('span', { class: 'sp-option-desc', text: option.description }) : null]),
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
const otherPick = el('input', { type, name, value: '' });
|
|
207
|
+
const other = el('input', { type: 'text', class: 'sp-other', placeholder: '其他 (自行輸入)', 'aria-label': q.question + ' 其他答案' });
|
|
208
|
+
other.addEventListener('input', () => {
|
|
209
|
+
otherPick.checked = other.value.trim() !== '';
|
|
210
|
+
});
|
|
211
|
+
controls.push(otherPick, other);
|
|
212
|
+
return {
|
|
213
|
+
q,
|
|
214
|
+
node: el('fieldset', { class: 'sp-question' }, [
|
|
215
|
+
el('legend', {}, [q.header ? el('span', { class: 'tag', text: q.header }) : null, ' ', q.question]),
|
|
216
|
+
...options,
|
|
217
|
+
el('label', { class: 'sp-option sp-option-other' }, [otherPick, other]),
|
|
218
|
+
]),
|
|
219
|
+
read: () => {
|
|
220
|
+
const picked = [...document.getElementsByName(name)].filter((i) => i.checked && i.value).map((i) => i.value);
|
|
221
|
+
const typed = otherPick.checked ? other.value.trim() : '';
|
|
222
|
+
if (typed) picked.push(typed);
|
|
223
|
+
// AskUserQuestion's own form joins a multi-select answer with commas.
|
|
224
|
+
return picked.join(', ');
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const send = el('button', { type: 'submit', class: 'button button-primary', text: '送出回答' });
|
|
229
|
+
controls.push(send);
|
|
230
|
+
const form = el('form', { class: 'sp-ask-form' }, [
|
|
231
|
+
el('p', { class: 'sp-ask-title', text: 'Claude 在問你' }),
|
|
232
|
+
el('p', { class: 'sp-hint sp-ask-hint', text: 'Terminal 也在詢問; 先回答的一方有效, 另一方會自動撤回.' }),
|
|
233
|
+
...fields.map((f) => f.node),
|
|
234
|
+
el('div', { class: 'sp-form-foot' }, [status, send]),
|
|
235
|
+
readOnlyNote(),
|
|
236
|
+
]);
|
|
237
|
+
if (!store.interactive) for (const control of controls) control.disabled = true;
|
|
238
|
+
form.addEventListener('submit', (event) => {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
const answers = {};
|
|
241
|
+
for (const field of fields) {
|
|
242
|
+
const value = field.read();
|
|
243
|
+
if (!value) {
|
|
244
|
+
status.textContent = '每一題都要回答: ' + field.q.question;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
answers[field.q.question] = value;
|
|
248
|
+
}
|
|
249
|
+
submitAnswer(ask, { answers }, controls, status);
|
|
250
|
+
});
|
|
251
|
+
return [form];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function permissionForm(ask) {
|
|
255
|
+
const status = el('p', { class: 'sp-hint', 'aria-live': 'polite' });
|
|
256
|
+
const allow = el('button', { type: 'button', class: 'button button-primary', text: '允許' });
|
|
257
|
+
const deny = el('button', { type: 'button', class: 'button', text: '拒絕' });
|
|
258
|
+
const controls = [allow, deny];
|
|
259
|
+
if (!store.interactive) for (const control of controls) control.disabled = true;
|
|
260
|
+
allow.addEventListener('click', () => submitAnswer(ask, { allow: true }, controls, status));
|
|
261
|
+
deny.addEventListener('click', () => submitAnswer(ask, { allow: false }, controls, status));
|
|
262
|
+
return [
|
|
263
|
+
el('p', { class: 'sp-ask-title', text: 'Claude 要求權限' }),
|
|
264
|
+
el('p', { class: 'sp-permission' }, [el('strong', { class: 'mono', text: ask.tool }), ' ', el('code', { class: 'sp-target', text: ask.target })]),
|
|
265
|
+
el('p', { class: 'sp-hint', text: 'Terminal 也在詢問; 先回答的一方有效, 另一方會自動撤回.' }),
|
|
266
|
+
el('div', { class: 'sp-actions' }, [deny, allow]),
|
|
267
|
+
status,
|
|
268
|
+
readOnlyNote(),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The sidebar: every project with its live sessions, each expandable to its
|
|
2
|
+
// changes, so any page is one click from the overview, its project, or a
|
|
3
|
+
// sibling change. Below 960px it becomes a drawer the sign bar opens.
|
|
4
|
+
|
|
5
|
+
import { el, marker, paintLine, icon, activityDot } from '../lib/dom.js';
|
|
6
|
+
import { signalOf, lineSignal, staleDaysOf, projectActivity, sessionStatus, ACTIVITY_LABEL, SIGNAL_LABEL } from '../lib/derive.js';
|
|
7
|
+
import { store, dataOf, slotOf } from '../lib/store.js';
|
|
8
|
+
import { routes } from '../lib/routes.js';
|
|
9
|
+
|
|
10
|
+
/** key -> open, for projects the person toggled; the current project is open unless closed. */
|
|
11
|
+
const toggled = new Map();
|
|
12
|
+
let lastSignature = null;
|
|
13
|
+
|
|
14
|
+
function isOpen(key, route) {
|
|
15
|
+
return toggled.has(key) ? toggled.get(key) : key === route.key;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Everything the sidebar draws, so a push that changes none of it (most do:
|
|
20
|
+
* a snapshot of a project whose list is the same) leaves the DOM, and the
|
|
21
|
+
* keyboard focus inside it, alone.
|
|
22
|
+
*/
|
|
23
|
+
function signature(route, now) {
|
|
24
|
+
return JSON.stringify([
|
|
25
|
+
route.view,
|
|
26
|
+
route.key,
|
|
27
|
+
route.name,
|
|
28
|
+
[...toggled],
|
|
29
|
+
store.projects.map((p) => {
|
|
30
|
+
const data = dataOf(p.key);
|
|
31
|
+
const staleDays = staleDaysOf(data);
|
|
32
|
+
const changes = data.overview ? data.overview.changes.map((c) => [c.name, lineSignal(signalOf(c, staleDays, now), p.online)]) : null;
|
|
33
|
+
return [p.key, p.name, slotOf(p.key), p.online, p.sessions.map(sessionStatus), changes];
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderSidebar(host, route) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const sig = signature(route, now);
|
|
41
|
+
if (sig === lastSignature) return;
|
|
42
|
+
lastSignature = sig;
|
|
43
|
+
|
|
44
|
+
// A redraw replaces the links; put the focus back on the one that had it.
|
|
45
|
+
const focusedHref = host.contains(document.activeElement) ? document.activeElement.getAttribute('href') : null;
|
|
46
|
+
|
|
47
|
+
const sessionTotal = store.projects.reduce((n, p) => n + p.sessions.length, 0);
|
|
48
|
+
const working = store.projects.reduce((n, p) => n + p.sessions.filter((s) => sessionStatus(s) === 'working').length, 0);
|
|
49
|
+
const waiting = store.projects.reduce((n, p) => n + p.sessions.filter((s) => sessionStatus(s) === 'waiting').length, 0);
|
|
50
|
+
|
|
51
|
+
const list = el('ul', { class: 'side-projects' }, store.projects.map((project) => projectItem(project, route, now)));
|
|
52
|
+
const foot = [`${sessionTotal} 個 session`];
|
|
53
|
+
if (working) foot.push(`${working} 個工作中`);
|
|
54
|
+
if (waiting) foot.push(`${waiting} 個等你回覆`);
|
|
55
|
+
|
|
56
|
+
host.replaceChildren(
|
|
57
|
+
el('a', { class: 'side-home', href: routes.home(), 'aria-current': route.view === 'home' ? 'page' : null }, [icon('overview', 16), el('span', { text: '總覽' })]),
|
|
58
|
+
el('h2', { class: 'side-title' }, ['Projects', el('span', { class: 'count', text: String(store.projects.length) })]),
|
|
59
|
+
store.projects.length ? list : el('p', { class: 'side-empty', text: store.projectsLoaded ? '還沒有 project 註冊.' : '正在讀取.' }),
|
|
60
|
+
el('p', { class: 'side-foot num', text: foot.join(' · ') })
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (focusedHref) {
|
|
64
|
+
const again = [...host.querySelectorAll('a')].find((a) => a.getAttribute('href') === focusedHref);
|
|
65
|
+
if (again) again.focus({ preventScroll: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function projectItem(project, route, now) {
|
|
70
|
+
const data = dataOf(project.key);
|
|
71
|
+
const open = isOpen(project.key, route);
|
|
72
|
+
const activity = projectActivity(project);
|
|
73
|
+
const listId = 'side-changes-' + project.key;
|
|
74
|
+
const here = route.key === project.key;
|
|
75
|
+
|
|
76
|
+
const toggle = el('button', {
|
|
77
|
+
type: 'button',
|
|
78
|
+
class: 'side-toggle',
|
|
79
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
80
|
+
'aria-controls': listId,
|
|
81
|
+
title: open ? '收合 change 清單' : '展開 change 清單',
|
|
82
|
+
}, [icon('chevron', 14), el('span', { class: 'sr-only', text: project.name + ' 的 change' })]);
|
|
83
|
+
toggle.addEventListener('click', () => {
|
|
84
|
+
toggled.set(project.key, !open);
|
|
85
|
+
renderSidebar(toggle.closest('.sidebar'), route);
|
|
86
|
+
// The redraw replaced this button; keep the keyboard on its successor.
|
|
87
|
+
const again = document.querySelector(`[aria-controls="${listId}"]`);
|
|
88
|
+
if (again) again.focus({ preventScroll: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const n = project.sessions.length;
|
|
92
|
+
const sessionsTitle = activity === 'offline' ? '沒有 session 連線' : `${n} 個 session · ${ACTIVITY_LABEL[activity]}`;
|
|
93
|
+
const item = paintLine(el('li', { class: 'side-project' + (here ? ' is-current' : '') + (project.online ? '' : ' is-offline') }), slotOf(project.key));
|
|
94
|
+
item.append(
|
|
95
|
+
el('div', { class: 'side-row' }, [
|
|
96
|
+
toggle,
|
|
97
|
+
el('a', { class: 'side-link', href: routes.project(project.key), 'aria-current': route.view === 'project' && here ? 'page' : null }, [
|
|
98
|
+
el('span', { class: 'line-swatch', 'aria-hidden': 'true' }),
|
|
99
|
+
el('span', { class: 'side-name', text: project.name }),
|
|
100
|
+
]),
|
|
101
|
+
el('span', { class: 'side-sessions num', title: sessionsTitle }, [
|
|
102
|
+
activityDot(activity),
|
|
103
|
+
el('span', { text: String(n) }),
|
|
104
|
+
el('span', { class: 'sr-only', text: ' ' + sessionsTitle }),
|
|
105
|
+
]),
|
|
106
|
+
])
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const changes = el('ul', { class: 'side-changes', id: listId });
|
|
110
|
+
changes.hidden = !open;
|
|
111
|
+
const overview = data.overview;
|
|
112
|
+
if (!overview) {
|
|
113
|
+
changes.append(el('li', { class: 'side-empty', text: data.error ? '快照讀取失敗' : '正在讀取.' }));
|
|
114
|
+
} else if (!overview.changes.length) {
|
|
115
|
+
changes.append(el('li', { class: 'side-empty', text: '沒有進行中的 change' }));
|
|
116
|
+
} else {
|
|
117
|
+
const staleDays = staleDaysOf(data);
|
|
118
|
+
for (const change of overview.changes) {
|
|
119
|
+
const signal = lineSignal(signalOf(change, staleDays, now), project.online);
|
|
120
|
+
const current = route.view === 'change' && here && route.name === change.name;
|
|
121
|
+
changes.append(el('li', {}, [
|
|
122
|
+
el('a', { class: 'side-change signal-' + signal, href: routes.change(project.key, change.name), 'aria-current': current ? 'page' : null, title: `${change.name} · ${SIGNAL_LABEL[signal]}` }, [
|
|
123
|
+
marker(signal, 10),
|
|
124
|
+
el('span', { class: 'side-change-name', text: change.name }),
|
|
125
|
+
]),
|
|
126
|
+
]));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
item.append(changes);
|
|
130
|
+
return item;
|
|
131
|
+
}
|
package/dist/cli/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,OAAO,EAAgD,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,OAAO,EAAgD,MAAM,WAAW,CAAC;AAgIlF,QAAA,MAAM,OAAO,SAAgB,CAAC;AAggB9B,OAAO,EAAE,OAAO,EAAE,CAAC;AAiBnB;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,IAAI,WAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAqC/D"}
|
package/dist/cli/index.js
CHANGED
|
@@ -9,10 +9,9 @@ import { UpdateCommand, UPDATE_FAILURE_PAYLOAD } from '../core/update.js';
|
|
|
9
9
|
import { INIT_FAILURE_PAYLOAD } from '../core/init.js';
|
|
10
10
|
import { RULES_FAILURE_PAYLOAD } from '../core/rules.js';
|
|
11
11
|
import { MigrateCommand, MIGRATE_FAILURE_PAYLOAD } from '../core/migrate.js';
|
|
12
|
-
import { resolveRootForCommand,
|
|
12
|
+
import { resolveRootForCommand, toRootOutput, } from '../core/root-selection.js';
|
|
13
13
|
import { ValidateCommand, VALIDATE_FAILURE_PAYLOAD } from '../commands/validate.js';
|
|
14
14
|
import { ShowCommand, SHOW_FAILURE_PAYLOAD } from '../commands/show.js';
|
|
15
|
-
import { isInteractive } from '../utils/interactive.js';
|
|
16
15
|
import { registerConfigCommand, CONFIG_FAILURE_PAYLOADS } from '../commands/config.js';
|
|
17
16
|
import { registerDecisionCommand, DECISION_NEW_FAILURE_PAYLOAD, DECISION_LIST_FAILURE_PAYLOAD, } from '../commands/decision.js';
|
|
18
17
|
import { registerMetricsCommand } from '../commands/metrics.js';
|
|
@@ -420,106 +419,86 @@ newCmd
|
|
|
420
419
|
failWithError(error);
|
|
421
420
|
}
|
|
422
421
|
});
|
|
423
|
-
program
|
|
424
|
-
.command('dashboard
|
|
425
|
-
.description('
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
422
|
+
const dashboardCmd = program
|
|
423
|
+
.command('dashboard')
|
|
424
|
+
.description('Manage the dashboard hub: one local server for every tospec project, needing no project of its own. Claude Code sessions register through the tospec-dashboard mod and push snapshots and activity. Task checkbox updates are relayed to a live session that applies them within its tospec root and refuses archived or sync-certified changes; beyond that the page can only answer a session\'s questions (AskUserQuestion, permissions), and only when the hub is bound to loopback');
|
|
425
|
+
/** The bind options `start` takes and hands on, unchanged, to the `serve` child. */
|
|
426
|
+
function withHubServeOptions(command) {
|
|
427
|
+
return (command
|
|
428
|
+
// Parsed like --concurrency: `Number('')` is 0, so a bare `--port ''` used
|
|
429
|
+
// to pass the range check and bind an OS-chosen port.
|
|
430
|
+
.addOption(new Option('-p, --port <n>', 'Port to listen on (0-65535); the mod looks for 5620 unless TOSPEC_DASHBOARD_URL says otherwise').default('5620').argParser(portArg))
|
|
431
|
+
.option('--host <addr>', 'Host to bind', '127.0.0.1')
|
|
432
|
+
.option('-o, --open', 'Open the dashboard in a browser after starting')
|
|
433
|
+
.option('--allow-remote', 'Allow binding a non-loopback --host (exposes the dashboard to the network)'));
|
|
434
|
+
}
|
|
435
|
+
function hubRunOptions(options, defaultPort) {
|
|
436
|
+
return {
|
|
437
|
+
// Already validated by portArg; the default is the only unparsed value.
|
|
438
|
+
port: Number(options?.port ?? defaultPort),
|
|
439
|
+
host: options?.host ?? '127.0.0.1',
|
|
440
|
+
open: options?.open,
|
|
441
|
+
allowRemote: options?.allowRemote,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
withHubServeOptions(dashboardCmd.command('start').description('Start the dashboard hub in the background and return once it is listening')).action(async (options) => {
|
|
436
445
|
try {
|
|
437
446
|
const dashboard = await import('../commands/dashboard.js');
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
const startPath = path.resolve(targetPath);
|
|
455
|
-
// --stop is project-independent, like --list: a path with no tospec root
|
|
456
|
-
// has no dashboard of its own, which is the cue to offer the full list
|
|
457
|
-
// rather than fail.
|
|
458
|
-
if (options?.stop) {
|
|
459
|
-
const stopRoot = await resolveTospecRoot({ startPath, allowImplicitRoot: false }).catch((err) => {
|
|
460
|
-
if (isRootSelectionError(err))
|
|
461
|
-
return null;
|
|
462
|
-
throw err;
|
|
463
|
-
});
|
|
464
|
-
const stopRootPath = stopRoot?.path ?? null;
|
|
465
|
-
const candidates = await dashboard.collectStoppableDashboards(stopRootPath);
|
|
466
|
-
if (candidates.length === 0) {
|
|
467
|
-
console.log('No dashboards are running.');
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
let targets = dashboard.selectStopTargets(candidates, stopRootPath);
|
|
471
|
-
if (targets.length === 0) {
|
|
472
|
-
// Nothing matches this path, so let the user pick. Reachable
|
|
473
|
-
// non-interactively too, where there is nobody to answer.
|
|
474
|
-
const listed = candidates.map((d) => ` ${dashboard.formatRunningDashboard(d, ' ')}`);
|
|
475
|
-
if (!isInteractive()) {
|
|
476
|
-
throw new dashboard.DashboardStopError(['No dashboard for this path, and no terminal to choose one from.', ...listed].join('\n'), 'Re-run from an interactive terminal, or see tospec dashboard --list.');
|
|
477
|
-
}
|
|
478
|
-
const { checkbox } = await import('@inquirer/prompts');
|
|
479
|
-
targets = await checkbox({
|
|
480
|
-
message: 'No dashboard for this path. Select dashboards to stop:',
|
|
481
|
-
choices: candidates.map((d) => ({
|
|
482
|
-
name: dashboard.formatRunningDashboard(d, ' '),
|
|
483
|
-
value: d,
|
|
484
|
-
})),
|
|
485
|
-
});
|
|
486
|
-
if (targets.length === 0) {
|
|
487
|
-
console.log('Nothing selected.');
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
for (const d of targets) {
|
|
492
|
-
const refusal = await dashboard.stopRunningDashboard(d);
|
|
493
|
-
if (refusal === null) {
|
|
494
|
-
console.log(`Stopped dashboard (pid ${d.pid}) on port ${d.port}.`);
|
|
495
|
-
}
|
|
496
|
-
else {
|
|
497
|
-
console.error(`Could not stop dashboard (pid ${d.pid}) on port ${d.port}: ${refusal}.`);
|
|
498
|
-
process.exitCode = 1;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
// Already validated by portArg; the default is the only unparsed value.
|
|
504
|
-
const portValue = Number(options?.port ?? 5620);
|
|
505
|
-
// Starting a dashboard still requires a root — only --stop is lenient.
|
|
506
|
-
const root = await resolveTospecRoot({ startPath, allowImplicitRoot: false });
|
|
507
|
-
const runOpts = {
|
|
508
|
-
port: portValue,
|
|
509
|
-
host: options?.host ?? '127.0.0.1',
|
|
510
|
-
open: options?.open,
|
|
511
|
-
allowRemote: options?.allowRemote,
|
|
512
|
-
};
|
|
513
|
-
if (options?.detach) {
|
|
514
|
-
await dashboard.detachDashboard(root.path, process.argv[1], { ...runOpts, targetPath });
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
await dashboard.runDashboard(root.path, runOpts);
|
|
447
|
+
await dashboard.detachDashboard(process.argv[1], hubRunOptions(options, dashboard.DEFAULT_PORT));
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
failWithError(error);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
dashboardCmd
|
|
454
|
+
.command('stop')
|
|
455
|
+
.description('Stop the running dashboard hub')
|
|
456
|
+
.action(async () => {
|
|
457
|
+
try {
|
|
458
|
+
const { dashboardStopCommand } = await import('../commands/dashboard.js');
|
|
459
|
+
await dashboardStopCommand();
|
|
518
460
|
}
|
|
519
461
|
catch (error) {
|
|
520
462
|
failWithError(error);
|
|
521
463
|
}
|
|
522
464
|
});
|
|
465
|
+
dashboardCmd
|
|
466
|
+
.command('status')
|
|
467
|
+
.description('Show whether the dashboard hub is running, how many Claude Code sessions it serves, and for which projects')
|
|
468
|
+
.action(async () => {
|
|
469
|
+
try {
|
|
470
|
+
const { dashboardStatusCommand } = await import('../commands/dashboard.js');
|
|
471
|
+
await dashboardStatusCommand();
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
failWithError(error);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
// The rest are hidden: `serve` is the process `start` spawns, and `snapshot` /
|
|
478
|
+
// `query` are run by the mod, so `dashboard --help` lists only what a person runs.
|
|
479
|
+
withHubServeOptions(dashboardCmd.command('serve', { hidden: true }).description('Run the dashboard hub in the foreground (the process `dashboard start` spawns)')).action(async (options) => {
|
|
480
|
+
try {
|
|
481
|
+
const dashboard = await import('../commands/dashboard.js');
|
|
482
|
+
await dashboard.runDashboard(hubRunOptions(options, dashboard.DEFAULT_PORT));
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
failWithError(error);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
dashboardCmd
|
|
489
|
+
.command('snapshot', { hidden: true })
|
|
490
|
+
.description("Print this project's dashboard snapshot as JSON (run by the tospec-dashboard Claude Code mod)")
|
|
491
|
+
.action(async () => {
|
|
492
|
+
const { dashboardSnapshotCommand } = await import('../commands/dashboard-relay.js');
|
|
493
|
+
await dashboardSnapshotCommand();
|
|
494
|
+
});
|
|
495
|
+
dashboardCmd
|
|
496
|
+
.command('query', { hidden: true })
|
|
497
|
+
.description('Answer one dashboard request read as JSON from stdin, e.g. {"method":"GET","path":"/api/overview"} (run by the tospec-dashboard Claude Code mod)')
|
|
498
|
+
.action(async () => {
|
|
499
|
+
const { dashboardQueryCommand } = await import('../commands/dashboard-relay.js');
|
|
500
|
+
await dashboardQueryCommand();
|
|
501
|
+
});
|
|
523
502
|
registerConfigCommand(program);
|
|
524
503
|
registerDecisionCommand(program);
|
|
525
504
|
registerMetricsCommand(program);
|