harlay 0.1.3 → 0.2.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/app/dist/main.cjs +1639 -791
- package/app/dist/preload.cjs +2 -2
- package/app/renderer/popover.css +222 -154
- package/app/renderer/popover.html +10 -8
- package/app/renderer/popover.js +363 -398
- package/app/renderer/vendor/motion-LICENSE.md +21 -0
- package/app/renderer/vendor/motion.js +1 -0
- package/dist/harlay.mjs +905 -608
- package/package.json +1 -1
package/app/renderer/popover.js
CHANGED
|
@@ -6,22 +6,24 @@ const api = window.harlay ?? demoApi();
|
|
|
6
6
|
|
|
7
7
|
const ICON = {
|
|
8
8
|
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
back: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3.5 5.5 8l4.5 4.5"/></svg>',
|
|
10
|
+
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
|
|
11
|
+
arrow: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>',
|
|
12
|
+
fresh: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8a5 5 0 1 1-1.6-3.7"/><path d="M13.2 2.5v3h-3"/></svg>',
|
|
13
|
+
gauge: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 11.5a6 6 0 1 1 11 0"/><path d="M8 9.5 10.8 6"/><circle cx="8" cy="10" r=".9" fill="currentColor"/></svg>',
|
|
14
|
+
list: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M5.5 4h8M5.5 8h8M5.5 12h8"/><circle cx="2.6" cy="4" r=".6" fill="currentColor"/><circle cx="2.6" cy="8" r=".6" fill="currentColor"/><circle cx="2.6" cy="12" r=".6" fill="currentColor"/></svg>',
|
|
15
|
+
copy: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="5.5" y="5.5" width="8" height="8" rx="1.6"/><path d="M10.5 3.2V3a.9.9 0 0 0-.9-.9H3.4a.9.9 0 0 0-.9.9v6.2a.9.9 0 0 0 .9.9h.3"/></svg>',
|
|
15
16
|
};
|
|
16
|
-
const
|
|
17
|
+
const TITLE = { usage: 'Usage', settings: 'Settings' };
|
|
18
|
+
const BADGE = { 'claude-code': 'CC', codex: 'CX', cursor: 'CU' };
|
|
19
|
+
const AGENT_LABEL = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor' };
|
|
20
|
+
const HEALTH_LABEL = { fresh: 'Healthy', long: 'Getting long', struggling: 'Running low' };
|
|
17
21
|
|
|
18
22
|
let model = null;
|
|
19
23
|
let view = 'home';
|
|
20
24
|
let openKey = null;
|
|
21
|
-
/** The
|
|
22
|
-
let
|
|
23
|
-
let copied = false;
|
|
24
|
-
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
25
|
+
/** The open "add a chat by ID" form: { tool, value, busy, error }. */
|
|
26
|
+
let adding = null;
|
|
25
27
|
|
|
26
28
|
const $ = (sel) => document.querySelector(sel);
|
|
27
29
|
|
|
@@ -38,6 +40,11 @@ function h(tag, props, ...kids) {
|
|
|
38
40
|
return el;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** Replaces a section's content; empty values (null, false) are skipped, as in h(). */
|
|
44
|
+
function put(sel, ...kids) {
|
|
45
|
+
$(sel).replaceChildren(...kids.flat(Infinity).filter((k) => k != null && k !== false));
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
function icon(name, cls) {
|
|
42
49
|
const t = document.createElement('template');
|
|
43
50
|
t.innerHTML = ICON[name];
|
|
@@ -46,82 +53,157 @@ function icon(name, cls) {
|
|
|
46
53
|
return svg;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
/** The agent's app icon, or a lettered badge when its app isn't installed (or in the browser demo). */
|
|
57
|
+
function agentIcon(tool) {
|
|
58
|
+
const src = model.icons?.[tool];
|
|
59
|
+
return src ? h('img', { class: 'agent', src, alt: '' }) : h('span', { class: 'agent badge', 'aria-hidden': 'true' }, BADGE[tool] ?? '?');
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
|
|
54
|
-
const { sessions, tools } = model;
|
|
55
|
-
const stopped = sessions.filter((s) => s.status === 'limit').length;
|
|
56
|
-
const installed = tools.filter((t) => t.app || t.terminal).length;
|
|
57
|
-
if (!sessions.length) {
|
|
58
|
-
return h('div', { class: 'hero' }, h('h1', {}, 'No sessions yet.'), h('p', {}, 'Start Claude Code, Codex or Cursor and they show up here.'));
|
|
59
|
-
}
|
|
60
|
-
if (stopped) {
|
|
61
|
-
return h('div', { class: 'hero' },
|
|
62
|
-
h('h1', {}, stopped === 1 ? 'One agent ' : `${stopped} agents `, h('em', {}, 'stopped.')),
|
|
63
|
-
h('p', {}, 'Hand it off and keep going.'));
|
|
64
|
-
}
|
|
65
|
-
return h('div', { class: 'hero' },
|
|
66
|
-
h('h1', {}, 'Nothing is ', h('em', {}, 'stuck.')),
|
|
67
|
-
h('p', {}, `${plural(sessions.length, 'recent session')} · ${plural(installed, 'agent')} ready`));
|
|
68
|
-
}
|
|
62
|
+
// ---- home: chats grouped by agent ----
|
|
69
63
|
|
|
70
|
-
function renderSession(s
|
|
64
|
+
function renderSession(s) {
|
|
71
65
|
const open = s.key === openKey;
|
|
72
|
-
const meta = [s.toolLabel, showRepo ? s.repo : null, s.ago].filter(Boolean).join(' · ');
|
|
73
|
-
const tool = (t) => model.tools.find((x) => x.tool === t.tool);
|
|
74
|
-
const actions = s.busy
|
|
75
|
-
? h('div', { class: 'busy' }, h('span', { class: 'spin' }), 'Handing off…')
|
|
76
|
-
: h('div', { class: 'targets' }, s.targets.map((t) =>
|
|
77
|
-
h('button', {
|
|
78
|
-
class: `btn${t.isDefault && t.enabled ? ' primary' : ''}`,
|
|
79
|
-
disabled: !t.enabled,
|
|
80
|
-
title: t.enabled ? null : `${t.label} isn't available in ${tool(t).mode === 'app' ? 'App' : 'Terminal'} mode`,
|
|
81
|
-
onclick: () => api.act({ type: 'handoff', key: s.key, target: t.tool }),
|
|
82
|
-
}, t.label)));
|
|
83
66
|
return h('div', { class: `item${open ? ' open' : ''}` },
|
|
84
|
-
h('button', {
|
|
85
|
-
class: '
|
|
86
|
-
'
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
h('span', { class: `st ${s.status}` }),
|
|
90
|
-
h('span', { class: 'main' }, h('span', { class: 'title' }, s.title), h('span', { class: 'meta' }, meta)),
|
|
91
|
-
TAG[s.status] ? h('span', { class: `tag ${s.status}` }, TAG[s.status]) : null,
|
|
92
|
-
icon('chev', 'chev')),
|
|
67
|
+
h('button', { class: 'row', 'aria-expanded': String(open), title: s.title, onclick: () => { openKey = open ? null : s.key; render(); } },
|
|
68
|
+
h('span', { class: `st ${s.status}` }, s.status === 'idle' ? null : h('i')),
|
|
69
|
+
h('span', { class: 'title' }, s.title),
|
|
70
|
+
healthBattery(s.health),
|
|
71
|
+
h('span', { class: 'time' }, s.ago)),
|
|
93
72
|
h('div', { class: 'drawer' },
|
|
94
73
|
s.status !== 'idle' ? h('p', { class: 'detail' }, s.detail) : null,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
74
|
+
deliveryLinks(s),
|
|
75
|
+
healthCard(s),
|
|
76
|
+
h('p', { class: 'sub' }, s.health && s.health.state !== 'fresh' ? 'Or continue in' : 'Continue in'),
|
|
77
|
+
s.busy
|
|
78
|
+
? h('div', { class: 'busy' }, h('span', { class: 'spin' }), 'Handing off…')
|
|
79
|
+
: s.targets.length
|
|
80
|
+
? h('div', { class: 'targets' }, s.targets.map((t) =>
|
|
81
|
+
h('button', { class: 'btn', onclick: () => api.act({ type: 'handoff', key: s.key, target: t.tool }) }, agentIcon(t.tool), t.label)))
|
|
82
|
+
: h('p', { class: 'muted-note' }, 'No other agent is installed.'),
|
|
83
|
+
s.pinned ? h('div', { class: 'links' }, h('button', { class: 'link quiet', onclick: () => api.act({ type: 'unpinChat', key: s.key }) }, 'Remove from list')) : null,
|
|
84
|
+
s.cwd ? h('p', { class: 'path', title: s.cwd }, s.cwd) : null));
|
|
99
85
|
}
|
|
100
86
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
87
|
+
/** A handoff the agent hasn't read yet: send it again, or copy its prompt to paste into any chat. */
|
|
88
|
+
function deliveryLinks(s) {
|
|
89
|
+
if (!s.delivery || s.delivery === 'read') return null;
|
|
90
|
+
const copy = h('button', { class: 'link', onclick: () => { api.act({ type: 'copyPrompt', key: s.key }); copy.lastChild.textContent = 'Copied'; } }, icon('copy'), h('span', {}, 'Copy prompt'));
|
|
91
|
+
return h('div', { class: 'links' },
|
|
92
|
+
h('button', { class: 'link', onclick: () => api.act({ type: 'resendHandoff', key: s.key }) }, icon('arrow'), 'Open again'),
|
|
93
|
+
copy);
|
|
107
94
|
}
|
|
108
95
|
|
|
96
|
+
// ---- chat health ----
|
|
97
|
+
|
|
109
98
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
99
|
+
* The chat's battery: one look per state, no finer than the state (the signals are risks, not a measurement): full and
|
|
100
|
+
* green when healthy, half and amber when getting long, low and red when struggling. The word is in the tooltip.
|
|
112
101
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
102
|
+
const BATTERY = { fresh: 17, long: 9, struggling: 3 };
|
|
103
|
+
|
|
104
|
+
function healthBattery(health, size = '') {
|
|
105
|
+
if (!health) return null;
|
|
106
|
+
const charge = BATTERY[health.state];
|
|
107
|
+
const t = document.createElement('template');
|
|
108
|
+
t.innerHTML = `<svg class="battery ${health.state}" viewBox="0 0 24 12" aria-hidden="true"><rect class="b-case" x=".75" y=".75" width="20.5" height="10.5" rx="2.6"/><rect class="b-nub" x="22" y="3.9" width="1.6" height="4.2" rx=".8"/><rect class="b-fill" x="2.5" y="2.5" width="${charge}" height="7" rx="1.3"/></svg>`;
|
|
109
|
+
return h('span', { class: `hw ${size}`, title: HEALTH_LABEL[health.state], role: 'img', 'aria-label': `Chat health: ${HEALTH_LABEL[health.state]}` }, t.content.firstElementChild);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A reason with its `command` shown as code. */
|
|
113
|
+
function reasonText(text) {
|
|
114
|
+
return text.split(/(`[^`]+`)/).map((part) => (/^`[^`]+`$/.test(part) ? h('code', {}, part.slice(1, -1)) : part));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** "850", "42K", "1.2M", as the reasons write token counts. */
|
|
118
|
+
function formatTokens(n) {
|
|
119
|
+
return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` : n >= 1000 ? `${Math.round(n / 1000)}K` : String(n);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function healthCard(s) {
|
|
123
|
+
const hl = s.health;
|
|
124
|
+
if (!hl) return null;
|
|
125
|
+
const label = `New chat in ${s.agentLabel}`;
|
|
126
|
+
// Each number appears once: a long context's reason has the count, otherwise it gets a line of its own.
|
|
127
|
+
const tokens = hl.context?.tokens ?? null;
|
|
128
|
+
const size = tokens !== null && !hl.reasons.some((r) => r.startsWith('Long context')) ? `${formatTokens(tokens)} tokens` : null;
|
|
129
|
+
// A healthy chat has nothing to explain: just its size.
|
|
130
|
+
if (hl.state === 'fresh') return size ? h('p', { class: 'detail h-size' }, size) : null;
|
|
131
|
+
const lines = size ? [...hl.reasons, size] : hl.reasons;
|
|
132
|
+
// The state in words, then why: the specific reasons are what to weigh.
|
|
133
|
+
return h('div', { class: `health ${hl.state}` },
|
|
134
|
+
h('p', { class: 'h-state' }, HEALTH_LABEL[hl.state]),
|
|
135
|
+
lines.length ? h('ul', { class: 'h-why' }, lines.map((r) => h('li', {}, reasonText(r)))) : null,
|
|
136
|
+
s.busy
|
|
137
|
+
? null
|
|
138
|
+
: s.canStartFresh
|
|
139
|
+
? h('button', { class: 'btn', title: 'A new chat that picks up where this one left off', onclick: () => api.act({ type: 'freshStart', key: s.key }) }, icon('fresh'), label)
|
|
140
|
+
: h('p', { class: 'muted-note' }, `${s.agentLabel} isn’t installed in its mode, so a new chat can’t open here.`));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderAddForm(g) {
|
|
144
|
+
if (adding?.tool !== g.tool) return null;
|
|
145
|
+
const input = h('input', {
|
|
146
|
+
type: 'text',
|
|
147
|
+
value: adding.value,
|
|
148
|
+
placeholder: `Paste a ${g.label} chat ID`,
|
|
149
|
+
spellcheck: 'false',
|
|
150
|
+
disabled: adding.busy,
|
|
151
|
+
oninput: (e) => { adding.value = e.target.value; adding.error = null; },
|
|
152
|
+
onkeydown: (e) => {
|
|
153
|
+
if (e.key === 'Enter') submitAdd();
|
|
154
|
+
if (e.key === 'Escape') { e.stopPropagation(); adding = null; render(); }
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
return [
|
|
158
|
+
h('div', { class: 'add-form' }, input, h('button', { class: 'btn primary', disabled: adding.busy, onclick: submitAdd }, adding.busy ? 'Adding…' : 'Add')),
|
|
159
|
+
adding.error ? h('p', { class: 'add-error' }, adding.error) : null,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function submitAdd() {
|
|
164
|
+
if (!adding || adding.busy || !adding.value.trim()) return;
|
|
165
|
+
adding.busy = true;
|
|
166
|
+
render();
|
|
167
|
+
const res = await api.addChat(adding.tool, adding.value.trim()).catch(() => null);
|
|
168
|
+
if (res?.ok) adding = null;
|
|
169
|
+
else adding = { ...adding, busy: false, error: res?.error ?? 'Couldn’t add that chat.' };
|
|
170
|
+
render();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderGroup(g) {
|
|
174
|
+
const formOpen = adding?.tool === g.tool;
|
|
175
|
+
return h('section', { class: 'grp' },
|
|
176
|
+
h('div', { class: 'grp-h' },
|
|
177
|
+
agentIcon(g.tool),
|
|
178
|
+
h('span', { class: 'grp-name' }, g.label),
|
|
179
|
+
h('button', {
|
|
180
|
+
class: 'add',
|
|
181
|
+
'aria-expanded': String(formOpen),
|
|
182
|
+
'aria-label': `Add a ${g.label} chat by ID`,
|
|
183
|
+
title: `Add a ${g.label} chat by ID`,
|
|
184
|
+
onclick: () => { adding = formOpen ? null : { tool: g.tool, value: '', busy: false, error: null }; render(); },
|
|
185
|
+
}, icon('plus'))),
|
|
186
|
+
renderAddForm(g),
|
|
187
|
+
g.sessions.length ? g.sessions.map((s) => renderSession({ ...s, agentLabel: g.label })) : h('p', { class: 'none' }, 'No chats yet'));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderHome() {
|
|
191
|
+
const { groups } = model;
|
|
192
|
+
put('#home',
|
|
193
|
+
groups.length
|
|
194
|
+
? h('div', { class: 'scroll' }, groups.map(renderGroup))
|
|
195
|
+
: h('p', { class: 'empty' }, 'Install Claude Code, Codex or Cursor and their chats show up here.'),
|
|
196
|
+
model.usage?.length ? h('button', { class: 'more', onclick: () => go('usage') }, icon('gauge', 'gauge'), 'Check agent usage', icon('arrow', 'arrow')) : null);
|
|
197
|
+
// Keep focus in the add-a-chat field across re-renders.
|
|
198
|
+
const field = $('#home .add-form input');
|
|
199
|
+
if (field && !adding?.busy) {
|
|
200
|
+
field.focus();
|
|
201
|
+
field.setSelectionRange(field.value.length, field.value.length);
|
|
121
202
|
}
|
|
122
|
-
return { attention, projects: [...projects.values()] };
|
|
123
203
|
}
|
|
124
204
|
|
|
205
|
+
// ---- usage ----
|
|
206
|
+
|
|
125
207
|
/** A bar's fill. The page's CSP forbids style attributes, so the width is set through the CSSOM. */
|
|
126
208
|
function fill(pct, cls) {
|
|
127
209
|
const el = h('i', { class: cls });
|
|
@@ -129,10 +211,16 @@ function fill(pct, cls) {
|
|
|
129
211
|
return el;
|
|
130
212
|
}
|
|
131
213
|
|
|
214
|
+
function toggleRow(label, hint, checked, action, disabled = false) {
|
|
215
|
+
return h('div', { class: 'set-row' },
|
|
216
|
+
h('span', { class: 'k' }, label, hint ? h('span', { class: 'hint' }, hint) : null),
|
|
217
|
+
h('button', { class: 'switch', role: 'switch', disabled, 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
|
|
218
|
+
}
|
|
219
|
+
|
|
132
220
|
/** Per-agent usage: a bar per window (≈ marks Harlay's estimate), then reset time, pace and notes. */
|
|
133
221
|
function renderUsage() {
|
|
134
|
-
const
|
|
135
|
-
|
|
222
|
+
const { usage, settings } = model;
|
|
223
|
+
const level = (pct) => (pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : '');
|
|
136
224
|
const ask = model.askClaudeBridge
|
|
137
225
|
? h('div', { class: 'ask' },
|
|
138
226
|
h('p', { class: 'ask-title' }, 'Show Claude’s exact usage?'),
|
|
@@ -141,301 +229,212 @@ function renderUsage() {
|
|
|
141
229
|
h('button', { class: 'btn primary', onclick: () => api.act({ type: 'answerClaudeBridge', allow: true }) }, 'Allow'),
|
|
142
230
|
h('button', { class: 'btn', onclick: () => api.act({ type: 'answerClaudeBridge', allow: false }) }, 'Not now')))
|
|
143
231
|
: null;
|
|
144
|
-
|
|
145
|
-
|
|
232
|
+
const hasClaude = usage.some((r) => r.tool === 'claude-code');
|
|
233
|
+
put('#usage',
|
|
146
234
|
ask,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
h('div', { class: 'u-body' },
|
|
235
|
+
h('div', { class: 'u' },
|
|
236
|
+
usage.map((r) => {
|
|
237
|
+
const meta = [r.windows.length === 1 ? r.windows[0].resets : null, r.pace, r.note].filter(Boolean);
|
|
238
|
+
return h('div', { class: 'u-agent' },
|
|
239
|
+
h('div', { class: 'u-head' }, agentIcon(r.tool), h('span', { class: 'grp-name' }, r.label)),
|
|
153
240
|
r.windows.map((w) => h('div', { class: 'u-win', title: w.exact ? 'Reported by the agent' : 'Harlay’s estimate from your session logs' },
|
|
154
241
|
h('span', { class: 'u-lbl' }, w.label),
|
|
155
242
|
h('span', { class: 'u-bar' }, fill(w.pct, level(w.pct))),
|
|
156
243
|
h('span', { class: 'u-pct' }, `${w.exact ? '' : '≈'}${w.pct}%`),
|
|
157
244
|
r.windows.length > 1 && w.resets ? h('span', { class: 'u-reset' }, w.resets.replace('resets ', '')) : null)),
|
|
158
245
|
meta.length || r.usagePage
|
|
159
|
-
? h('
|
|
160
|
-
r.usagePage ? h('button', { class: 'link
|
|
161
|
-
: null)
|
|
162
|
-
|
|
246
|
+
? h('p', { class: 'u-meta' }, meta.join(' · '),
|
|
247
|
+
r.usagePage ? h('button', { class: 'link', onclick: () => api.act({ type: 'openCursorUsage' }) }, 'Open usage page') : null)
|
|
248
|
+
: null);
|
|
249
|
+
})),
|
|
250
|
+
hasClaude && !model.askClaudeBridge
|
|
251
|
+
? h('div', { class: 'set' }, toggleRow(
|
|
252
|
+
'Exact Claude usage',
|
|
253
|
+
settings.claudeBridge === 'unavailable'
|
|
254
|
+
? 'Claude Code’s ~/.claude/settings.json isn’t valid JSON, so Harlay leaves it alone.'
|
|
255
|
+
: 'Read from Claude Code’s status line in Terminal.',
|
|
256
|
+
settings.claudeBridge === 'on',
|
|
257
|
+
{ type: 'toggleClaudeBridge' },
|
|
258
|
+
settings.claudeBridge === 'unavailable'))
|
|
259
|
+
: null);
|
|
163
260
|
}
|
|
164
261
|
|
|
165
|
-
|
|
166
|
-
const { sessions } = model;
|
|
167
|
-
if (!sessions.length) {
|
|
168
|
-
$('#home').replaceChildren(renderHero(), renderUsage(), h('p', { class: 'empty' }, 'Harlay reads session logs from ~/.claude, ~/.codex and ~/.cursor on this Mac.'));
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const { attention, projects } = groupSessions(sessions);
|
|
172
|
-
$('#home').replaceChildren(
|
|
173
|
-
renderHero(),
|
|
174
|
-
renderUsage(),
|
|
175
|
-
h('div', { class: 'scroll' },
|
|
176
|
-
attention.length ? [h('div', { class: 'label group attn' }, 'Needs attention'), attention.map((s) => renderSession(s, true))] : null,
|
|
177
|
-
projects.map((p) => [
|
|
178
|
-
h('div', { class: 'label group', title: p.path }, p.title, h('span', { class: 'count' }, String(p.sessions.length))),
|
|
179
|
-
p.sessions.map((s) => renderSession(s, false)),
|
|
180
|
-
])));
|
|
181
|
-
}
|
|
262
|
+
// ---- settings ----
|
|
182
263
|
|
|
183
264
|
function seg(options, current, onPick) {
|
|
184
265
|
return h('div', { class: 'seg', role: 'group' }, options.map((o) =>
|
|
185
|
-
h('button', { 'aria-pressed': String(o.value === current),
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function toggleRow(label, hint, checked, action, disabled = false) {
|
|
189
|
-
return h('div', { class: 'set-row' },
|
|
190
|
-
h('span', { class: 'k' }, label, hint ? h('span', { class: 'hint' }, hint) : null),
|
|
191
|
-
h('button', { class: 'switch', role: 'switch', disabled, 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
|
|
266
|
+
h('button', { 'aria-pressed': String(o.value === current), onclick: () => onPick(o.value) }, o.label)));
|
|
192
267
|
}
|
|
193
268
|
|
|
194
269
|
function renderSettings() {
|
|
195
|
-
const { settings
|
|
196
|
-
|
|
197
|
-
h('div', { class: '
|
|
198
|
-
|
|
199
|
-
h('div', { class: 'label' }, 'Default target'),
|
|
200
|
-
seg(tools.map((t) => ({ value: t.tool, label: t.label })), settings.defaultTarget, (v) => api.act({ type: 'setDefault', target: v })),
|
|
201
|
-
h('div', { class: 'label' }, 'Open targets in'),
|
|
202
|
-
tools.map((t) => h('div', { class: 'set-row' },
|
|
203
|
-
h('span', { class: 'k' }, t.label),
|
|
204
|
-
seg([
|
|
205
|
-
{ value: 'app', label: 'App', disabled: !t.app },
|
|
206
|
-
{ value: 'terminal', label: 'Terminal', disabled: !t.terminal },
|
|
207
|
-
], t.mode, (m) => api.act({ type: 'setMode', target: t.tool, mode: m })))),
|
|
208
|
-
h('div', { class: 'label' }, 'Appearance'),
|
|
270
|
+
const { settings } = model;
|
|
271
|
+
put('#settings', h('div', { class: 'set' },
|
|
272
|
+
h('div', { class: 'set-row' },
|
|
273
|
+
h('span', { class: 'k' }, 'Appearance'),
|
|
209
274
|
seg([
|
|
210
275
|
{ value: 'system', label: 'System' },
|
|
211
276
|
{ value: 'light', label: 'Light' },
|
|
212
277
|
{ value: 'dark', label: 'Dark' },
|
|
213
|
-
], settings.theme, (v) => api.act({ type: 'setTheme', theme: v })),
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' }),
|
|
217
|
-
h('div', { class: 'label' }, 'Usage'),
|
|
218
|
-
toggleRow(
|
|
219
|
-
'Exact Claude usage',
|
|
220
|
-
settings.claudeBridge === 'unavailable'
|
|
221
|
-
? 'Claude Code’s ~/.claude/settings.json isn’t valid JSON, so Harlay leaves it alone.'
|
|
222
|
-
: 'Claude Code in Terminal passes its real usage to Harlay through its status line. Your own status line keeps working; turning this off restores it.',
|
|
223
|
-
settings.claudeBridge === 'on',
|
|
224
|
-
{ type: 'toggleClaudeBridge' },
|
|
225
|
-
settings.claudeBridge === 'unavailable')));
|
|
278
|
+
], settings.theme, (v) => api.act({ type: 'setTheme', theme: v }))),
|
|
279
|
+
toggleRow('Auto-continue', 'Hand off without asking when an agent stops', settings.autoContinue, { type: 'toggleAuto' }),
|
|
280
|
+
toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' })));
|
|
226
281
|
}
|
|
227
282
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
text.split(/(`[^`]+`)/).forEach((part) => {
|
|
232
|
-
if (/^`[^`]+`$/.test(part)) return out.push(h('code', {}, part.slice(1, -1)));
|
|
233
|
-
part.split(/(\*\*[^*]+\*\*)/).forEach((p) => {
|
|
234
|
-
if (/^\*\*[^*]+\*\*$/.test(p)) out.push(h('strong', {}, p.slice(2, -2)));
|
|
235
|
-
else if (p) out.push(p.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'));
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
return out;
|
|
283
|
+
function findSession(key) {
|
|
284
|
+
for (const g of model.groups) for (const s of g.sessions) if (s.key === key) return s;
|
|
285
|
+
return null;
|
|
239
286
|
}
|
|
240
287
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (text.startsWith('---\n')) {
|
|
245
|
-
const end = text.indexOf('\n---\n', 4);
|
|
246
|
-
if (end !== -1) text = text.slice(end + 5);
|
|
247
|
-
}
|
|
248
|
-
const lines = text.split('\n');
|
|
249
|
-
const out = [];
|
|
250
|
-
let list = null;
|
|
251
|
-
let quote = null;
|
|
252
|
-
let para = [];
|
|
253
|
-
const flush = () => {
|
|
254
|
-
if (para.length) out.push(h('p', {}, inline(para.join(' '))));
|
|
255
|
-
para = [];
|
|
256
|
-
list = null;
|
|
257
|
-
quote = null;
|
|
258
|
-
};
|
|
259
|
-
for (let i = 0; i < lines.length; i++) {
|
|
260
|
-
const line = lines[i];
|
|
261
|
-
let m;
|
|
262
|
-
if (line.startsWith('```')) {
|
|
263
|
-
flush();
|
|
264
|
-
const code = [];
|
|
265
|
-
while (++i < lines.length && !lines[i].startsWith('```')) code.push(lines[i]);
|
|
266
|
-
out.push(h('pre', {}, code.join('\n')));
|
|
267
|
-
} else if ((m = /^(#{1,6})\s+(.*)$/.exec(line))) {
|
|
268
|
-
flush();
|
|
269
|
-
out.push(h(m[1].length === 1 ? 'h3' : 'h4', {}, inline(m[2])));
|
|
270
|
-
} else if ((m = /^>\s?(.*)$/.exec(line))) {
|
|
271
|
-
if (para.length || list) flush();
|
|
272
|
-
if (!quote) out.push((quote = h('blockquote', {})));
|
|
273
|
-
if (m[1]) quote.append(h('p', {}, inline(m[1])));
|
|
274
|
-
} else if ((m = /^\s*(?:[-*]|\d+\.)\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(line))) {
|
|
275
|
-
if (para.length || quote) flush();
|
|
276
|
-
if (!list) out.push((list = h('ul', {})));
|
|
277
|
-
const box = m[1] === undefined ? null : h('span', { class: `box${m[1] === ' ' ? '' : ' done'}` });
|
|
278
|
-
list.append(h('li', {}, box, inline(m[2])));
|
|
279
|
-
} else if (line.trim() === '') {
|
|
280
|
-
flush();
|
|
281
|
-
} else if (line.startsWith('|')) {
|
|
282
|
-
flush();
|
|
283
|
-
out.push(h('pre', {}, line));
|
|
284
|
-
} else {
|
|
285
|
-
if (list || quote) flush();
|
|
286
|
-
para.push(line.trim());
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
flush();
|
|
290
|
-
return out;
|
|
288
|
+
function findTool(key) {
|
|
289
|
+
for (const g of model.groups) if (g.sessions.some((s) => s.key === key)) return g.tool;
|
|
290
|
+
return null;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
|
|
294
|
-
const BRIEF_BLOCKS = {
|
|
295
|
-
original_goal: 'Original goal',
|
|
296
|
-
rules_from_user: 'Rules from the user',
|
|
297
|
-
recent_user_messages: 'Recent messages (oldest first)',
|
|
298
|
-
where_it_stopped: 'Where it stopped',
|
|
299
|
-
files: 'Files',
|
|
300
|
-
last_failure: 'Last failure',
|
|
301
|
-
};
|
|
293
|
+
// ---- router (⌥H): the latest chat, branching to the agents it can continue in ----
|
|
302
294
|
|
|
303
|
-
/**
|
|
304
|
-
function
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
return
|
|
295
|
+
/** Usage of an agent's fullest window, for the thin meter under its icon. */
|
|
296
|
+
function usageOf(tool) {
|
|
297
|
+
const row = model.usage?.find((u) => u.tool === tool);
|
|
298
|
+
if (!row || !row.windows.length) return null;
|
|
299
|
+
return Math.max(...row.windows.map((w) => w.pct));
|
|
308
300
|
}
|
|
309
301
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
let
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const t = line.trim();
|
|
326
|
-
let m;
|
|
327
|
-
if (!block && /^<handoff\s/.test(t)) {
|
|
328
|
-
const a = tagAttrs(t);
|
|
329
|
-
out.push(h('h3', {}, `Handoff from ${a.from || 'another agent'}${a.task ? ` · ${a.task}` : ''}`));
|
|
330
|
-
} else if (!block && (m = /^<([a-z_]+)(?:\s[^>]*)?>$/.exec(t)) && Object.hasOwn(BRIEF_BLOCKS, m[1])) {
|
|
331
|
-
block = m[1];
|
|
332
|
-
out.push(h('h4', {}, BRIEF_BLOCKS[block]));
|
|
333
|
-
if (block === 'last_failure') pre = [];
|
|
334
|
-
} else if ((m = /^<\/([a-z_]+)>$/.exec(t)) && (m[1] === block || (!block && m[1] === 'handoff'))) {
|
|
335
|
-
close();
|
|
336
|
-
} else if (pre) {
|
|
337
|
-
pre.push(line);
|
|
338
|
-
} else if (t === '') {
|
|
339
|
-
list = null;
|
|
340
|
-
} else if (!block) {
|
|
341
|
-
out.push(h('p', { class: 'muted' }, t));
|
|
342
|
-
} else if (t.startsWith('- ')) {
|
|
343
|
-
if (!list) out.push((list = h('ul', {})));
|
|
344
|
-
list.append(h('li', {}, t.slice(2)));
|
|
345
|
-
} else {
|
|
346
|
-
list = null;
|
|
347
|
-
out.push(h('p', {}, t));
|
|
348
|
-
}
|
|
302
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
303
|
+
|
|
304
|
+
/** Curves from the chat down to each tile; the selected one is drawn in the accent color and flows. */
|
|
305
|
+
function routeLines(n, selected) {
|
|
306
|
+
const W = 320, H = 46, x0 = W / 2;
|
|
307
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
308
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
309
|
+
svg.setAttribute('class', 'r-lines');
|
|
310
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
311
|
+
for (let i = 0; i < n; i++) {
|
|
312
|
+
const x = ((i + 0.5) * W) / n;
|
|
313
|
+
const p = document.createElementNS(SVG_NS, 'path');
|
|
314
|
+
p.setAttribute('d', `M${x0} 0 C ${x0} ${H * 0.55}, ${x} ${H * 0.45}, ${x} ${H}`);
|
|
315
|
+
p.setAttribute('class', i === selected ? 'on' : '');
|
|
316
|
+
svg.append(p);
|
|
349
317
|
}
|
|
350
|
-
|
|
318
|
+
const dot = document.createElementNS(SVG_NS, 'circle');
|
|
319
|
+
dot.setAttribute('cx', String(x0));
|
|
320
|
+
dot.setAttribute('cy', '2');
|
|
321
|
+
dot.setAttribute('r', '2.5');
|
|
322
|
+
svg.append(dot);
|
|
323
|
+
return svg;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The router's choices: other agents (default first), then a new chat in the same agent. */
|
|
327
|
+
function routeChoices(s, tool) {
|
|
328
|
+
const out = s.targets.map((t) => ({ tool: t.tool, label: t.label, mode: t.mode, act: { type: 'handoff', key: s.key, target: t.tool } }));
|
|
329
|
+
// Not when this agent just hit its limit: a new chat with it would stop too.
|
|
330
|
+
if (s.canStartFresh && s.status !== 'limit') out.push({ tool, label: 'New chat', mode: null, fresh: true, act: { type: 'freshStart', key: s.key } });
|
|
351
331
|
return out;
|
|
352
332
|
}
|
|
353
333
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
h('
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
334
|
+
let routeKey = null;
|
|
335
|
+
let routeSel = 0;
|
|
336
|
+
|
|
337
|
+
function renderRoute() {
|
|
338
|
+
const s = routeKey ? findSession(routeKey) : null;
|
|
339
|
+
if (!s) return go('home');
|
|
340
|
+
const tool = findTool(s.key);
|
|
341
|
+
const choices = routeChoices(s, tool);
|
|
342
|
+
routeSel = Math.min(routeSel, Math.max(0, choices.length - 1));
|
|
343
|
+
const tile = (c, i) => {
|
|
344
|
+
const pct = c.fresh ? null : usageOf(c.tool);
|
|
345
|
+
return h('button', {
|
|
346
|
+
class: `tile${i === routeSel ? ' sel' : ''}`,
|
|
347
|
+
title: c.fresh ? 'New chat that picks up where this one left off' : `Continue in ${c.label}${c.mode === 'terminal' ? ' (Terminal)' : ''}`,
|
|
348
|
+
onmouseenter: () => { if (routeSel !== i) { routeSel = i; render(); } },
|
|
349
|
+
onclick: () => api.act(c.act),
|
|
350
|
+
},
|
|
351
|
+
h('span', { class: 'tile-ic' },
|
|
352
|
+
agentIcon(c.tool),
|
|
353
|
+
c.fresh ? h('span', { class: 'corner' }, icon('fresh')) : c.mode === 'terminal' ? h('span', { class: 'corner mono' }, '>_') : null),
|
|
354
|
+
h('span', { class: 'tile-name' }, c.label),
|
|
355
|
+
pct === null ? h('span', { class: 'tile-meter' }) : h('span', { class: 'tile-meter on', title: `${pct}% of its limit used` }, fill(pct, pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : '')),
|
|
356
|
+
h('kbd', { class: 'tile-key' }, String(i + 1)));
|
|
357
|
+
};
|
|
358
|
+
put('#route',
|
|
359
|
+
// Opened by itself because this chat hit its limit: say so, and when it resets.
|
|
360
|
+
s.status === 'limit' ? h('p', { class: 'r-alert' }, `${AGENT_LABEL[tool]} stopped · ${s.detail}. Continue it in:`) : null,
|
|
361
|
+
h('div', { class: 'r-src' },
|
|
362
|
+
h('span', { class: 'r-src-ic' }, agentIcon(tool), h('span', { class: `st ${s.status}` }, s.status === 'idle' ? null : h('i'))),
|
|
363
|
+
h('span', { class: 'r-src-title', title: s.title }, s.title),
|
|
364
|
+
h('span', { class: 'r-src-time' }, s.ago)),
|
|
365
|
+
s.busy
|
|
366
|
+
? h('div', { class: 'busy r-busy' }, h('span', { class: 'spin' }))
|
|
367
|
+
: choices.length
|
|
368
|
+
? [routeLines(choices.length, routeSel), h('div', { class: `r-tiles n${choices.length}` }, choices.map(tile))]
|
|
369
|
+
: h('p', { class: 'none' }, 'No other agent installed'),
|
|
370
|
+
h('div', { class: 'r-foot' },
|
|
371
|
+
h('span', { class: 'r-hint' }, h('kbd', {}, '↵'), h('kbd', {}, '1–' + Math.max(1, choices.length))),
|
|
372
|
+
h('span', { class: 'r-acts' },
|
|
373
|
+
h('button', { class: 'link r-dismiss', onclick: () => api.hide() }, 'Not now'),
|
|
374
|
+
h('button', { class: 'link r-all', onclick: () => go('home') }, icon('list'), 'All chats'))));
|
|
379
375
|
}
|
|
380
376
|
|
|
377
|
+
// ---- frame ----
|
|
378
|
+
|
|
381
379
|
function render() {
|
|
382
380
|
if (!model) return;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
$('#
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
theme.replaceChildren(icon(dark ? 'sun' : 'moon'));
|
|
393
|
-
theme.setAttribute('aria-label', dark ? 'Light mode' : 'Dark mode');
|
|
394
|
-
theme.title = theme.getAttribute('aria-label');
|
|
395
|
-
const pill = $('#pill');
|
|
396
|
-
pill.classList.toggle('auto', model.settings.autoContinue);
|
|
397
|
-
$('#pill-text').textContent = model.settings.autoContinue ? 'Auto-continue' : 'Standing by';
|
|
381
|
+
for (const v of ['home', 'route', 'usage', 'settings']) $(`#${v}`).hidden = view !== v;
|
|
382
|
+
const home = view === 'home' || view === 'route';
|
|
383
|
+
$('#brand').hidden = !home;
|
|
384
|
+
$('#open-settings').hidden = !home;
|
|
385
|
+
// The router (⌥H) goes back to all chats too.
|
|
386
|
+
$('#back').hidden = view === 'home';
|
|
387
|
+
$('#back').title = view === 'route' ? 'All chats' : 'Back';
|
|
388
|
+
$('#view-title').hidden = home;
|
|
389
|
+
$('#view-title').textContent = TITLE[view] ?? '';
|
|
398
390
|
$('#hotkey').textContent = model.hotkey;
|
|
399
391
|
if (view === 'home') renderHome();
|
|
400
|
-
else if (view === '
|
|
401
|
-
else
|
|
392
|
+
else if (view === 'route') renderRoute();
|
|
393
|
+
else if (view === 'usage') renderUsage();
|
|
394
|
+
else renderSettings();
|
|
402
395
|
}
|
|
403
396
|
|
|
404
|
-
function
|
|
405
|
-
view =
|
|
406
|
-
brief = null;
|
|
407
|
-
openKey = null;
|
|
397
|
+
function go(next) {
|
|
398
|
+
view = next;
|
|
408
399
|
render();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function show(opts) {
|
|
403
|
+
openKey = null;
|
|
404
|
+
adding = null;
|
|
405
|
+
routeKey = opts?.route ?? null;
|
|
406
|
+
routeSel = 0;
|
|
407
|
+
go(routeKey && findSession(routeKey) ? 'route' : 'home');
|
|
409
408
|
const panel = $('#panel');
|
|
409
|
+
// Otherwise the window gives focus back to the last button clicked (often the gear), with a keyboard focus ring.
|
|
410
|
+
panel.focus({ preventScroll: true });
|
|
410
411
|
panel.classList.remove('in');
|
|
411
412
|
void panel.offsetWidth;
|
|
412
413
|
panel.classList.add('in');
|
|
413
414
|
}
|
|
414
415
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
$('#toggle-settings').addEventListener('click', () => {
|
|
422
|
-
if (view === 'home') {
|
|
423
|
-
view = 'settings';
|
|
424
|
-
render();
|
|
425
|
-
} else {
|
|
426
|
-
goHome();
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
$('#toggle-theme').addEventListener('click', () => api.act({ type: 'setTheme', theme: darkQuery.matches ? 'light' : 'dark' }));
|
|
430
|
-
darkQuery.addEventListener('change', render);
|
|
416
|
+
$('#back').append(icon('back'));
|
|
417
|
+
$('#open-settings').append(icon('gear'));
|
|
418
|
+
$('#back').addEventListener('click', () => go('home'));
|
|
419
|
+
$('#open-settings').addEventListener('click', () => go('settings'));
|
|
431
420
|
$('#quit').addEventListener('click', () => api.act({ type: 'quit' }));
|
|
432
421
|
document.addEventListener('keydown', (e) => {
|
|
433
|
-
if (e.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
api.
|
|
422
|
+
if (view === 'route' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
423
|
+
const s = routeKey ? findSession(routeKey) : null;
|
|
424
|
+
const choices = s && !s.busy ? routeChoices(s, findTool(s.key)) : [];
|
|
425
|
+
const n = Number(e.key);
|
|
426
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length) return api.act(choices[n - 1].act);
|
|
427
|
+
if (e.key === 'Enter' && choices[routeSel]) return api.act(choices[routeSel].act);
|
|
428
|
+
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && choices.length) {
|
|
429
|
+
routeSel = (routeSel + (e.key === 'ArrowRight' ? 1 : choices.length - 1)) % choices.length;
|
|
430
|
+
return render();
|
|
431
|
+
}
|
|
438
432
|
}
|
|
433
|
+
if (e.key !== 'Escape') return;
|
|
434
|
+
if (view === 'route') api.hide();
|
|
435
|
+
else if (view !== 'home') go('home');
|
|
436
|
+
else if (adding) { adding = null; render(); }
|
|
437
|
+
else api.hide();
|
|
439
438
|
});
|
|
440
439
|
|
|
441
440
|
// Size the window to the panel: its height plus the wrap's padding (the room the shadow needs).
|
|
@@ -444,121 +443,87 @@ new ResizeObserver(() => api.resize(Math.ceil(wrap.getBoundingClientRect().heigh
|
|
|
444
443
|
|
|
445
444
|
api.onModel((m) => {
|
|
446
445
|
model = m;
|
|
447
|
-
if (openKey && !
|
|
446
|
+
if (openKey && !findSession(openKey)) openKey = null;
|
|
448
447
|
render();
|
|
449
448
|
});
|
|
450
449
|
api.onShown(show);
|
|
451
|
-
api.onBrief((b) => {
|
|
452
|
-
// Ignore a late reply for a brief the user already navigated away from.
|
|
453
|
-
if (b.loading || !brief || brief.key === b.key) {
|
|
454
|
-
brief = b;
|
|
455
|
-
view = 'brief';
|
|
456
|
-
render();
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
450
|
api.ready();
|
|
460
451
|
|
|
461
452
|
function demoApi() {
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
{ tool: 'cursor', label: 'Cursor', mode: 'app', app: true, terminal: false },
|
|
466
|
-
];
|
|
467
|
-
const settings = { defaultTarget: 'codex', autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off' };
|
|
453
|
+
const label = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor' };
|
|
454
|
+
const settings = { autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off' };
|
|
455
|
+
let asked = false;
|
|
468
456
|
const usage = [
|
|
469
457
|
{ tool: 'claude-code', label: 'Claude Code', windows: [{ label: '5h', pct: 72, exact: false, resets: 'resets 4:10 PM' }], pace: '≈ 38 min to limit at this pace', note: '≈ estimated from your session logs', usagePage: false },
|
|
470
458
|
{ tool: 'codex', label: 'Codex', windows: [{ label: '5h', pct: 18, exact: true, resets: 'resets in 52m' }, { label: 'Week', pct: 41, exact: true, resets: 'resets Fri 7:12 PM' }], pace: null, note: '1 free limit reset available', usagePage: false },
|
|
471
459
|
{ tool: 'cursor', label: 'Cursor', windows: [], pace: null, note: 'Cursor keeps no usage on this Mac', usagePage: true },
|
|
472
460
|
];
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
461
|
+
const fresh = { state: 'fresh', reasons: [], context: { tokens: 42_000, window: null, compactions: 0 } };
|
|
462
|
+
const long = { state: 'long', reasons: ['Long context: 197K tokens'], context: { tokens: 197_000, window: null, compactions: 0 } };
|
|
463
|
+
const longer = { state: 'long', reasons: ['Long context: 527K tokens'], context: { tokens: 527_000, window: null, compactions: 0 } };
|
|
464
|
+
const struggling = {
|
|
465
|
+
state: 'struggling',
|
|
466
|
+
reasons: ['`npm test -- auth` failed 4 times in a row', 'You pushed back 2 times in your last 6 messages', 'Compacted 2 times, so early details are summaries'],
|
|
467
|
+
context: { tokens: 188_000, window: 258_400, compactions: 2 },
|
|
479
468
|
};
|
|
480
|
-
const
|
|
481
|
-
{ key:
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
469
|
+
const chat = (tool, id, title, ago, health, status = 'idle', detail = 'Idle', pinned = false) =>
|
|
470
|
+
({ key: `${tool}:${id}`, tool, title, ago, status, detail, cwd: `/Users/you/${title.split(' ')[0].toLowerCase()}`, busy: false, pinned, health, canStartFresh: true, delivery: null });
|
|
471
|
+
const chats = [
|
|
472
|
+
chat('claude-code', '1', 'Migrate auth to JWT', 'now', struggling, 'limit', 'Limit hit · resets 3:00 PM'),
|
|
473
|
+
chat('claude-code', '2', 'Harlay landing page', '1h', long),
|
|
474
|
+
chat('claude-code', '3', 'Software architecture diagram', '3h', fresh),
|
|
475
|
+
chat('codex', '4', 'Fix flaky checkout tests', '4m', longer, 'warning', '92% of limit used'),
|
|
476
|
+
chat('codex', '5', 'Rate limiter for /login', '2h', fresh),
|
|
477
|
+
chat('codex', '6', 'Implement the task in Colab', '1d', fresh),
|
|
478
|
+
{ ...chat('cursor', '7', 'Onboarding copy pass', '3h', null, 'handed_off', "Handed off to Codex · Codex hasn't picked it up"), delivery: 'missed' },
|
|
486
479
|
];
|
|
487
480
|
let onModel = () => {};
|
|
488
|
-
let onBrief = () => {};
|
|
489
|
-
const sampleBrief = (title) => [
|
|
490
|
-
`<handoff from="Claude Code" reason="usage_limit" task="${title.replace(/"/g, "'")}">`,
|
|
491
|
-
"You are taking over a coding session that Claude Code could not finish (it hit a usage limit). What you need to start is below; don't re-read the repo to rebuild it.",
|
|
492
|
-
'',
|
|
493
|
-
'<rules_from_user>',
|
|
494
|
-
"- Don't change the public /v1 API shape.",
|
|
495
|
-
'- Never push to main.',
|
|
496
|
-
'</rules_from_user>',
|
|
497
|
-
'',
|
|
498
|
-
'<recent_user_messages oldest_first="true">',
|
|
499
|
-
'- Move auth from session cookies to JWT across api/.',
|
|
500
|
-
'- Also migrate billing.ts before you run the tests.',
|
|
501
|
-
'</recent_user_messages>',
|
|
502
|
-
'',
|
|
503
|
-
'<where_it_stopped>',
|
|
504
|
-
'```bash npm test -- auth ``` still fails on the refresh test. Next I will migrate the last 3 route handlers in api/routes/.',
|
|
505
|
-
'Open todos: [in progress] Migrate billing.ts; [pending] Delete the legacy session store',
|
|
506
|
-
'</where_it_stopped>',
|
|
507
|
-
'',
|
|
508
|
-
'<files>',
|
|
509
|
-
'Touched: src/lib/jwt.ts, api/routes/auth.ts',
|
|
510
|
-
'Uncommitted: src/lib/jwt.ts (added, +42 -0), api/routes/auth.ts (modified, +12 -30)',
|
|
511
|
-
'</files>',
|
|
512
|
-
'',
|
|
513
|
-
'<last_failure>',
|
|
514
|
-
'$ npm test -- auth (exit 1)',
|
|
515
|
-
'FAIL auth.test.ts > refresh token rotates',
|
|
516
|
-
' expected 401, received 200',
|
|
517
|
-
'</last_failure>',
|
|
518
|
-
'',
|
|
519
|
-
"Only if you need more: .harlay/handoffs/1005-1742-migrate-auth-to-jwt/transcript.md (search it, don't read it all) and .harlay/handoffs/1005-1742-migrate-auth-to-jwt/diff.patch.",
|
|
520
|
-
'</handoff>',
|
|
521
|
-
'',
|
|
522
|
-
'Continue from the most recent user message. Start with the next concrete step.',
|
|
523
|
-
'',
|
|
524
|
-
].join('\n');
|
|
525
481
|
const emit = () => onModel({
|
|
526
|
-
|
|
482
|
+
groups: ['claude-code', 'codex', 'cursor'].map((tool) => ({
|
|
483
|
+
tool,
|
|
484
|
+
label: label[tool],
|
|
485
|
+
sessions: chats.filter((c) => c.tool === tool).map((c) => ({
|
|
486
|
+
...c,
|
|
487
|
+
targets: ['codex', 'claude-code', 'cursor'].filter((t) => t !== tool).map((t) => ({ tool: t, label: label[t], mode: t === 'cursor' ? 'terminal' : 'app' })),
|
|
488
|
+
})),
|
|
489
|
+
})),
|
|
527
490
|
usage,
|
|
528
|
-
|
|
491
|
+
icons: {},
|
|
492
|
+
askClaudeBridge: !asked,
|
|
529
493
|
settings: { ...settings },
|
|
530
|
-
tools: tools.map((t) => ({ ...t })),
|
|
531
494
|
hotkey: '⌥H',
|
|
532
495
|
});
|
|
533
496
|
return {
|
|
534
497
|
onModel: (cb) => { onModel = cb; },
|
|
535
498
|
onShown: (cb) => setTimeout(cb, 50),
|
|
536
|
-
onBrief: (cb) => { onBrief = cb; },
|
|
537
499
|
ready: emit,
|
|
538
500
|
resize: () => {},
|
|
539
501
|
hide: () => console.log('[demo] hide'),
|
|
502
|
+
addChat: async (tool, id) => {
|
|
503
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
504
|
+
if (id.length < 4) return { ok: false, error: 'That doesn’t look like a chat ID.' };
|
|
505
|
+
chats.push(chat(tool, id, `Chat ${id.slice(0, 8)}`, '5d', fresh, 'idle', 'Idle', true));
|
|
506
|
+
emit();
|
|
507
|
+
return { ok: true };
|
|
508
|
+
},
|
|
540
509
|
act: (a) => {
|
|
541
510
|
console.log('[demo] action', a);
|
|
542
|
-
if (a.type === 'setDefault') settings.defaultTarget = a.target;
|
|
543
|
-
if (a.type === 'setMode') tools.find((t) => t.tool === a.target).mode = a.mode;
|
|
544
511
|
if (a.type === 'toggleAuto') settings.autoContinue = !settings.autoContinue;
|
|
545
512
|
if (a.type === 'toggleLogin') settings.openAtLogin = !settings.openAtLogin;
|
|
546
513
|
if (a.type === 'setTheme') settings.theme = a.theme;
|
|
547
514
|
if (a.type === 'answerClaudeBridge') {
|
|
548
|
-
|
|
515
|
+
asked = true;
|
|
549
516
|
settings.claudeBridge = a.allow ? 'on' : 'off';
|
|
550
517
|
}
|
|
551
518
|
if (a.type === 'toggleClaudeBridge') settings.claudeBridge = settings.claudeBridge === 'on' ? 'off' : 'on';
|
|
552
|
-
if (a.type === '
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
if (a.type === 'handoff') {
|
|
558
|
-
const s = sessions.find((x) => x.key === a.key);
|
|
559
|
-
s.busy = true;
|
|
519
|
+
if (a.type === 'unpinChat') chats.splice(chats.findIndex((c) => c.key === a.key), 1);
|
|
520
|
+
if (a.type === 'handoff' || a.type === 'freshStart') {
|
|
521
|
+
const c = chats.find((x) => x.key === a.key);
|
|
522
|
+
c.busy = true;
|
|
560
523
|
emit();
|
|
561
|
-
|
|
524
|
+
const to = label[a.target ?? c.tool];
|
|
525
|
+
setTimeout(() => { c.busy = false; c.status = 'handed_off'; c.delivery = 'waiting'; c.detail = `Handed off to ${to} · press Enter in ${to} to send it`; c.health = fresh; emit(); }, 1200);
|
|
526
|
+
setTimeout(() => { c.delivery = 'read'; c.detail = `Handed off to ${to} · ${to} read it`; emit(); }, 4000);
|
|
562
527
|
return;
|
|
563
528
|
}
|
|
564
529
|
emit();
|