harlay 0.1.2 → 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 +1750 -770
- package/app/dist/preload.cjs +2 -2
- package/app/renderer/popover.css +222 -153
- package/app/renderer/popover.html +10 -8
- package/app/renderer/popover.js +368 -307
- package/app/renderer/vendor/motion-LICENSE.md +21 -0
- package/app/renderer/vendor/motion.js +1 -0
- package/dist/harlay.mjs +1009 -573
- 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,240 +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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
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
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- router (⌥H): the latest chat, branching to the agents it can continue in ----
|
|
294
|
+
|
|
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));
|
|
300
|
+
}
|
|
301
|
+
|
|
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);
|
|
288
317
|
}
|
|
289
|
-
|
|
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 } });
|
|
290
331
|
return out;
|
|
291
332
|
}
|
|
292
333
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
h('
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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'))));
|
|
318
375
|
}
|
|
319
376
|
|
|
377
|
+
// ---- frame ----
|
|
378
|
+
|
|
320
379
|
function render() {
|
|
321
380
|
if (!model) return;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
$('#
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
theme.replaceChildren(icon(dark ? 'sun' : 'moon'));
|
|
332
|
-
theme.setAttribute('aria-label', dark ? 'Light mode' : 'Dark mode');
|
|
333
|
-
theme.title = theme.getAttribute('aria-label');
|
|
334
|
-
const pill = $('#pill');
|
|
335
|
-
pill.classList.toggle('auto', model.settings.autoContinue);
|
|
336
|
-
$('#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] ?? '';
|
|
337
390
|
$('#hotkey').textContent = model.hotkey;
|
|
338
391
|
if (view === 'home') renderHome();
|
|
339
|
-
else if (view === '
|
|
340
|
-
else
|
|
392
|
+
else if (view === 'route') renderRoute();
|
|
393
|
+
else if (view === 'usage') renderUsage();
|
|
394
|
+
else renderSettings();
|
|
341
395
|
}
|
|
342
396
|
|
|
343
|
-
function
|
|
344
|
-
view =
|
|
345
|
-
brief = null;
|
|
346
|
-
openKey = null;
|
|
397
|
+
function go(next) {
|
|
398
|
+
view = next;
|
|
347
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');
|
|
348
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 });
|
|
349
411
|
panel.classList.remove('in');
|
|
350
412
|
void panel.offsetWidth;
|
|
351
413
|
panel.classList.add('in');
|
|
352
414
|
}
|
|
353
415
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
$('#toggle-settings').addEventListener('click', () => {
|
|
361
|
-
if (view === 'home') {
|
|
362
|
-
view = 'settings';
|
|
363
|
-
render();
|
|
364
|
-
} else {
|
|
365
|
-
goHome();
|
|
366
|
-
}
|
|
367
|
-
});
|
|
368
|
-
$('#toggle-theme').addEventListener('click', () => api.act({ type: 'setTheme', theme: darkQuery.matches ? 'light' : 'dark' }));
|
|
369
|
-
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'));
|
|
370
420
|
$('#quit').addEventListener('click', () => api.act({ type: 'quit' }));
|
|
371
421
|
document.addEventListener('keydown', (e) => {
|
|
372
|
-
if (e.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
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
|
+
}
|
|
377
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();
|
|
378
438
|
});
|
|
379
439
|
|
|
380
440
|
// Size the window to the panel: its height plus the wrap's padding (the room the shadow needs).
|
|
@@ -383,86 +443,87 @@ new ResizeObserver(() => api.resize(Math.ceil(wrap.getBoundingClientRect().heigh
|
|
|
383
443
|
|
|
384
444
|
api.onModel((m) => {
|
|
385
445
|
model = m;
|
|
386
|
-
if (openKey && !
|
|
446
|
+
if (openKey && !findSession(openKey)) openKey = null;
|
|
387
447
|
render();
|
|
388
448
|
});
|
|
389
449
|
api.onShown(show);
|
|
390
|
-
api.onBrief((b) => {
|
|
391
|
-
// Ignore a late reply for a brief the user already navigated away from.
|
|
392
|
-
if (b.loading || !brief || brief.key === b.key) {
|
|
393
|
-
brief = b;
|
|
394
|
-
view = 'brief';
|
|
395
|
-
render();
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
450
|
api.ready();
|
|
399
451
|
|
|
400
452
|
function demoApi() {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
{ tool: 'cursor', label: 'Cursor', mode: 'app', app: true, terminal: false },
|
|
405
|
-
];
|
|
406
|
-
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;
|
|
407
456
|
const usage = [
|
|
408
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 },
|
|
409
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 },
|
|
410
459
|
{ tool: 'cursor', label: 'Cursor', windows: [], pace: null, note: 'Cursor keeps no usage on this Mac', usagePage: true },
|
|
411
460
|
];
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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 },
|
|
418
468
|
};
|
|
419
|
-
const
|
|
420
|
-
{ key:
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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' },
|
|
425
479
|
];
|
|
426
480
|
let onModel = () => {};
|
|
427
|
-
let onBrief = () => {};
|
|
428
|
-
const sampleBrief = (title) => `---\nharlay: 1\n---\n\n# Continue: ${title}\n\n> You are continuing a coding task that another AI agent started.\n\n## 1. Goal (user's words)\n> Move auth from session cookies to JWT across \`api/\`.\n\n## 2. Next step\nMigrate the last 3 route handlers in \`api/routes/\`.\n\n## 4. Progress\n- [x] Added \`src/lib/jwt.ts\`\n- [ ] Migrate \`billing.ts\`\n\n\`\`\`text\nnpm test -- auth\n\`\`\`\n`;
|
|
429
481
|
const emit = () => onModel({
|
|
430
|
-
|
|
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
|
+
})),
|
|
431
490
|
usage,
|
|
432
|
-
|
|
491
|
+
icons: {},
|
|
492
|
+
askClaudeBridge: !asked,
|
|
433
493
|
settings: { ...settings },
|
|
434
|
-
tools: tools.map((t) => ({ ...t })),
|
|
435
494
|
hotkey: '⌥H',
|
|
436
495
|
});
|
|
437
496
|
return {
|
|
438
497
|
onModel: (cb) => { onModel = cb; },
|
|
439
498
|
onShown: (cb) => setTimeout(cb, 50),
|
|
440
|
-
onBrief: (cb) => { onBrief = cb; },
|
|
441
499
|
ready: emit,
|
|
442
500
|
resize: () => {},
|
|
443
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
|
+
},
|
|
444
509
|
act: (a) => {
|
|
445
510
|
console.log('[demo] action', a);
|
|
446
|
-
if (a.type === 'setDefault') settings.defaultTarget = a.target;
|
|
447
|
-
if (a.type === 'setMode') tools.find((t) => t.tool === a.target).mode = a.mode;
|
|
448
511
|
if (a.type === 'toggleAuto') settings.autoContinue = !settings.autoContinue;
|
|
449
512
|
if (a.type === 'toggleLogin') settings.openAtLogin = !settings.openAtLogin;
|
|
450
513
|
if (a.type === 'setTheme') settings.theme = a.theme;
|
|
451
514
|
if (a.type === 'answerClaudeBridge') {
|
|
452
|
-
|
|
515
|
+
asked = true;
|
|
453
516
|
settings.claudeBridge = a.allow ? 'on' : 'off';
|
|
454
517
|
}
|
|
455
518
|
if (a.type === 'toggleClaudeBridge') settings.claudeBridge = settings.claudeBridge === 'on' ? 'off' : 'on';
|
|
456
|
-
if (a.type === '
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
if (a.type === 'handoff') {
|
|
462
|
-
const s = sessions.find((x) => x.key === a.key);
|
|
463
|
-
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;
|
|
464
523
|
emit();
|
|
465
|
-
|
|
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);
|
|
466
527
|
return;
|
|
467
528
|
}
|
|
468
529
|
emit();
|