sidekick-chat 0.0.0-stage → 0.2.2

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/src/quota.mjs ADDED
@@ -0,0 +1,107 @@
1
+ import { SidekickApi } from './api.mjs';
2
+
3
+ /** Same host and credentials as chat; proxy prefix or the standard direct port. */
4
+ export function ccwebBase(base) {
5
+ if (!base) return '';
6
+ const url = new URL(base);
7
+ const path = url.pathname.replace(/\/+$/, '');
8
+ if (path.endsWith('/chat')) url.pathname = path.slice(0, -5) + '/ccweb';
9
+ else if (!path && url.port === '18792') url.port = '18794';
10
+ else throw new Error('无法推导 ccweb 地址');
11
+ url.search = '';
12
+ url.hash = '';
13
+ return url.toString().replace(/\/+$/, '');
14
+ }
15
+
16
+ const percentage = (value) => typeof value === 'number' && Number.isFinite(value)
17
+ ? Math.round(Math.max(0, Math.min(100, value))) : null;
18
+ const timestamp = (value) => {
19
+ const time = typeof value === 'number' ? value : Date.parse(value);
20
+ return Number.isFinite(time) && time > 0 ? time : null;
21
+ };
22
+ const usedWindows = (windows) => (Array.isArray(windows) ? windows : []).map((w) => ({
23
+ label: w.label || w.id || '?',
24
+ left: percentage(typeof w.percent === 'number' ? 100 - w.percent : null),
25
+ resetsAt: timestamp(w.resetsAt),
26
+ }));
27
+
28
+ export function quotaRows(values, now = Date.now()) {
29
+ const { opencode, codex, claude } = values;
30
+ const row = (label, value, windows, at, note = '') => ({
31
+ label, windows, at: timestamp(at),
32
+ stale: Boolean(value?.failed || value?.data?.stale || timestamp(at) && now - timestamp(at) > 10 * 60_000),
33
+ note: value?.failed ? (value.data ? '刷新失败,保留旧值' : '暂不可用') : note,
34
+ });
35
+ const oc = opencode?.data;
36
+ const cx = codex?.data;
37
+ const rows = [
38
+ row('OpenCode', opencode, usedWindows(oc?.windows), oc?.sampledAt, oc?.stale ? '缓存' : ''),
39
+ row('Codex', codex, usedWindows(cx?.windows), cx?.sampledAt, cx?.loggedIn === false ? '未登录' : ''),
40
+ ];
41
+ const agents = (Array.isArray(claude?.data?.agents) ? claude.data.agents : []).filter((a) => a.engine === 'claude-code');
42
+ // Keep both requested channels visible even before their first report.
43
+ for (const name of ['WSL', 'Docker']) if (!agents.some((a) => a.name === name)) agents.push({ name });
44
+ for (const agent of agents) {
45
+ rows.push(row(`Claude ${agent.name}`, claude,
46
+ (Array.isArray(agent.usage?.windows) ? agent.usage.windows : []).map((w) => ({
47
+ label: w.label || w.key || '?', left: percentage(w.leftPct), resetsAt: timestamp(w.resetsAt),
48
+ })), agent.usage?.updatedAt, agent.online === false ? '离线' : ''));
49
+ }
50
+ return rows;
51
+ }
52
+
53
+ const localTime = (at) => {
54
+ const d = new Date(at);
55
+ const pad = (n) => String(n).padStart(2, '0');
56
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
57
+ };
58
+
59
+ export function formatQuota(rows, { detail = false } = {}) {
60
+ return rows.map((r) => {
61
+ const windows = r.windows.map((w) => `${w.label} ${w.left === null ? '?' : w.left + '%'}${detail && w.resetsAt ? `(${localTime(w.resetsAt)} 重置)` : ''}`).join(' / ');
62
+ const notes = [r.note, r.stale ? '旧数据' : '', r.at ? `${localTime(r.at)} 采样` : '尚无采样'].filter(Boolean);
63
+ return `${r.label} 剩余 ${windows || '未知'} · ${notes.join(' · ')}`;
64
+ }).join('\n');
65
+ }
66
+
67
+ /** Independent failures preserve each source's last good snapshot. No overlapping polls. */
68
+ export function createQuotaMonitor(settings, onUpdate, { api = new SidekickApi(settings), agentsApi, interval = 60_000 } = {}) {
69
+ const values = {};
70
+ const ctl = new AbortController();
71
+ let timer;
72
+ let pending;
73
+ let stopped = false;
74
+ let ccApi = agentsApi;
75
+ const sources = {
76
+ opencode: () => api.request('GET', 'v1/opencode/quota', undefined, { signal: ctl.signal }),
77
+ codex: () => api.request('GET', 'v1/codex/status', undefined, { signal: ctl.signal }),
78
+ claude: () => {
79
+ ccApi ??= new SidekickApi({ ...settings, url: ccwebBase(settings.url), alt: ccwebBase(settings.alt) });
80
+ return ccApi.request('GET', 'v1/agents', undefined, { signal: ctl.signal });
81
+ },
82
+ };
83
+ function refresh() {
84
+ if (stopped) return Promise.resolve(quotaRows(values));
85
+ if (pending) return pending;
86
+ pending = (async () => {
87
+ await Promise.allSettled(Object.entries(sources).map(async ([key, fetch]) => {
88
+ try { values[key] = { data: await fetch(), failed: false }; }
89
+ catch { values[key] = { ...values[key], failed: true }; }
90
+ if (!stopped) onUpdate(quotaRows(values));
91
+ }));
92
+ return quotaRows(values);
93
+ })().finally(() => { pending = null; });
94
+ return pending;
95
+ }
96
+ return {
97
+ refresh,
98
+ start() {
99
+ if (timer || stopped) return;
100
+ onUpdate(quotaRows(values));
101
+ void refresh();
102
+ timer = setInterval(() => { void refresh(); }, interval);
103
+ timer.unref?.();
104
+ },
105
+ stop() { stopped = true; clearInterval(timer); ctl.abort(); },
106
+ };
107
+ }
package/src/tui.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import { Container, Editor, Markdown, ProcessTerminal, Text, TuiMainScreen } from '@earendil-works/pi-tui';
2
+ import { paint, formatMessage } from './format.mjs';
3
+ import { formatQuota } from './quota.mjs';
4
+
5
+ export function markdownTheme(color) {
6
+ const style = (code) => (text) => paint(color, code, text);
7
+ return Object.fromEntries(Object.entries({
8
+ heading: 'bold', link: 'cyan', linkUrl: 'gray', code: 'yellow',
9
+ codeBlock: 'yellow', codeBlockBorder: 'gray', quote: 'gray', quoteBorder: 'gray',
10
+ hr: 'gray', listBullet: 'cyan', bold: 'bold', italic: 'dim',
11
+ strikethrough: 'dim', underline: 'cyan',
12
+ }).map(([name, code]) => [name, style(code)]));
13
+ }
14
+
15
+ /** Pi's renderer owns every screen write, including the editor and busy status. */
16
+ export function createChatUI({ color, onSubmit, onQuit, terminal = new ProcessTerminal() }) {
17
+ const tui = new TuiMainScreen(terminal);
18
+ const transcript = new Container();
19
+ const live = new Container();
20
+ const drafts = new Map();
21
+ const draftKey = (frame) => `${frame.from}:${frame.id}`;
22
+ const dropDraft = (key) => {
23
+ const draft = drafts.get(key);
24
+ if (draft) live.removeChild(draft.container);
25
+ drafts.delete(key);
26
+ };
27
+ const status = new Text('', 1, 0);
28
+ const hud = new Text('', 1, 0);
29
+ const identity = (text) => text;
30
+ const editor = new Editor(tui, {
31
+ borderColor: (text) => paint(color, 'gray', text),
32
+ selectList: Object.fromEntries(['selectedPrefix', 'selectedText', 'description', 'scrollInfo', 'noMatch'].map((key) => [key, identity])),
33
+ });
34
+ tui.addChild(transcript);
35
+ tui.addChild(live);
36
+ tui.addChild(status);
37
+ tui.addChild(hud);
38
+ tui.addChild(editor);
39
+ tui.addChild(new Text(paint(color, 'gray', 'Enter 发送 · Shift+Enter 换行 · ↑ 历史 · /help · Ctrl+C 退出'), 1, 0));
40
+ tui.setFocus(editor);
41
+ tui.addInputListener((data) => {
42
+ if (data === '\u0003' || data === '\u0004' && !editor.getText()) {
43
+ onQuit();
44
+ return { consume: true };
45
+ }
46
+ });
47
+ editor.onSubmit = (text) => {
48
+ if (!text.trim()) return;
49
+ editor.addToHistory(text);
50
+ editor.setText('');
51
+ onSubmit(text.trim());
52
+ tui.requestRender();
53
+ };
54
+ return {
55
+ start: () => tui.start(),
56
+ stop: () => tui.stop(),
57
+ out(text) { transcript.addChild(new Text(text, 1, 0)); tui.requestRender(); },
58
+ message(message, options) {
59
+ if (message.draft) dropDraft(draftKey({ from: message.from, id: message.draft }));
60
+ const header = formatMessage({ ...message, text: '', images: [] }, options).trimEnd();
61
+ transcript.addChild(new Text(`\n${header}`, 1, 0));
62
+ transcript.addChild(message.from === 'user'
63
+ ? new Text(String(message.text ?? ''), 2, 0)
64
+ : new Markdown(String(message.text ?? ''), 2, 0, markdownTheme(color)));
65
+ for (const name of message.images ?? []) transcript.addChild(new Text(`[图片] ${options.imageUrl?.(name) || name}`, 2, 0));
66
+ tui.requestRender();
67
+ },
68
+ draft(frame, options) {
69
+ if (!frame?.id || !frame.from) return;
70
+ const key = draftKey(frame);
71
+ if (frame.end) dropDraft(key);
72
+ else if (typeof frame.text === 'string') {
73
+ let draft = drafts.get(key);
74
+ if (!draft) {
75
+ const container = new Container();
76
+ const header = formatMessage({ from: frame.from, ts: Date.now(), text: '' }, options).trimEnd();
77
+ container.addChild(new Text(`\n${header} · 正在生成`, 1, 0));
78
+ const body = new Markdown(frame.text, 2, 0, markdownTheme(color));
79
+ container.addChild(body);
80
+ draft = { container, body };
81
+ drafts.set(key, draft);
82
+ live.addChild(container);
83
+ } else draft.body.setText(frame.text);
84
+ }
85
+ tui.requestRender();
86
+ },
87
+ clearDrafts() { drafts.clear(); live.clear(); tui.requestRender(); },
88
+ quota(rows) { hud.setText(paint(color, 'gray', formatQuota(rows))); tui.requestRender(); },
89
+ typing(on) { status.setText(on ? paint(color, 'gray', '… 正在输入') : ''); tui.requestRender(); },
90
+ clear() { transcript.clear(); drafts.clear(); live.clear(); tui.requestRender(true); },
91
+ };
92
+ }