wisp-agents 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/README.md +27 -5
- package/bin/wisp.js +2 -2
- package/package.json +2 -2
- package/server/adapters/claude.js +50 -12
- package/server/client.js +2 -2
- package/server/index.js +173 -17
- package/server/machines.js +5 -0
- package/server/mcp-wisp.js +5 -5
- package/server/openapi.js +25 -3
- package/server/protocol.js +4 -0
- package/server/remote.js +103 -17
- package/server/router-priors.json +54 -0
- package/server/router.js +340 -0
- package/server/runner.js +66 -13
- package/server/store.js +10 -2
- package/server/subagents.js +6 -2
- package/server/telemetry.js +58 -7
- package/web/css/app.css +83 -7
- package/web/index.html +2 -0
- package/web/js/app.js +459 -54
package/web/js/app.js
CHANGED
|
@@ -21,6 +21,7 @@ const S = {
|
|
|
21
21
|
showDone: load('wisp.showDone', false),
|
|
22
22
|
showArchived: false,
|
|
23
23
|
drafts: new Map(),
|
|
24
|
+
replyModes: new Map(), // session id → the mode its next follow-up switches to
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
const $ = (s, root = document) => root.querySelector(s);
|
|
@@ -61,20 +62,30 @@ const MODES = [
|
|
|
61
62
|
{ id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files. Shell commands that need approval are denied.' },
|
|
62
63
|
{ id: 'auto', label: 'Full auto', icon: 'spark', hint: 'No permission prompts at all. Trusted repos only.' },
|
|
63
64
|
];
|
|
64
|
-
const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask' };
|
|
65
|
+
const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask', background: 'clock' };
|
|
65
66
|
const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
|
|
66
|
-
const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
|
|
67
|
+
const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
|
|
68
|
+
// Auto: not a CLI but wisp's router (server/router.js), which picks harness and model per task.
|
|
69
|
+
// Its "models" are the goal it optimises for; there are no thinking levels to pick.
|
|
70
|
+
const AUTO_GOALS = [
|
|
71
|
+
{ id: 'balanced', label: 'Balanced', hint: 'Best result per dollar' },
|
|
72
|
+
{ id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done' },
|
|
73
|
+
{ id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties' },
|
|
74
|
+
];
|
|
75
|
+
const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness and model from what worked before' };
|
|
67
76
|
// Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
|
|
68
77
|
const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
|
|
69
78
|
// A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
|
|
70
79
|
function agentById(id) {
|
|
80
|
+
if (id === 'auto') return AUTO;
|
|
71
81
|
const local = S.agents.find((a) => a.id === id);
|
|
72
82
|
if (local?.available) return local;
|
|
73
83
|
return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
|
|
74
84
|
}
|
|
75
85
|
// The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
|
|
76
86
|
// `missing` names the picked places that don't have it. Machines not online yet don't count.
|
|
77
|
-
function harnessesAt(places) {
|
|
87
|
+
function harnessesAt(places, { auto = false } = {}) {
|
|
88
|
+
if (auto) return [AUTO, ...harnessesAt(places)];
|
|
78
89
|
const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
|
|
79
90
|
if (!known.length) known.push(['', S.agents]);
|
|
80
91
|
const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
|
|
@@ -98,6 +109,7 @@ const HARNESS_LOGO = {
|
|
|
98
109
|
grok: { file: 'grok', mono: true },
|
|
99
110
|
};
|
|
100
111
|
function harnessLogo(agent) {
|
|
112
|
+
if (agent === 'auto') return `<i class="auto-mark">${icon('spark')}</i>`;
|
|
101
113
|
const l = HARNESS_LOGO[agent];
|
|
102
114
|
const src = l && `/logos/${l.file}.svg`;
|
|
103
115
|
return !l ? icon('spark') : l.mono ? `<i class="mono" style="--logo:url(${src})"></i>` : `<img src="${src}" alt="">`;
|
|
@@ -110,12 +122,19 @@ function effortsFor(agent, model) {
|
|
|
110
122
|
}
|
|
111
123
|
const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
112
124
|
|
|
113
|
-
function modelBadge(agent, model, effort) {
|
|
125
|
+
function modelBadge(agent, model, effort, route) {
|
|
114
126
|
if (agent === 'wisp') return '';
|
|
115
127
|
const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
116
|
-
const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
128
|
+
const tip = route ? routeTip(route) : `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
117
129
|
const logo = harnessLogo(agent);
|
|
118
|
-
return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
130
|
+
return `<span class="model-logo${route ? ' routed' : ''}" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
131
|
+
}
|
|
132
|
+
// Why Auto picked what it picked, in one line.
|
|
133
|
+
function routeTip(r) {
|
|
134
|
+
const goal = AUTO_GOALS.find((g) => g.id === r.goal)?.label || 'Balanced';
|
|
135
|
+
const record = r.n ? `${Math.round(r.chance * 100)}% on your ${r.type} tasks (${r.n})` : `${Math.round(r.chance * 100)}% expected on ${r.type} tasks`;
|
|
136
|
+
const why = r.trying ? 'new model, trying it out' : r.explored ? 'trying an alternative to learn' : record;
|
|
137
|
+
return `Auto · ${goal} → ${r.label} · ${why}${r.usd ? ` · ~$${r.usd.toFixed(2)}` : ''}`;
|
|
119
138
|
}
|
|
120
139
|
const projectById = (id) => S.config.projects.find((p) => p.id === id);
|
|
121
140
|
|
|
@@ -358,7 +377,7 @@ function renderHead(n, s) {
|
|
|
358
377
|
${pill}
|
|
359
378
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
360
379
|
${s.pr ? prPill(s.pr) : ''}
|
|
361
|
-
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort)}
|
|
380
|
+
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route)}
|
|
362
381
|
${s.machine ? machinePill(s.machine) : ''}
|
|
363
382
|
<span class="q-proj">${esc(s.project)}</span>
|
|
364
383
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
@@ -641,6 +660,7 @@ function syncWhere() {
|
|
|
641
660
|
// The model is remembered per harness (ids aren't portable between them).
|
|
642
661
|
function savedModel(id) {
|
|
643
662
|
const d = S.config.defaults;
|
|
663
|
+
if (id === 'auto') return AUTO_GOALS.some((g) => g.id === d.models?.auto) ? d.models.auto : 'balanced';
|
|
644
664
|
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
645
665
|
const models = agentById(id)?.models || [];
|
|
646
666
|
return models.some((m) => m.id === want) ? want : '';
|
|
@@ -655,8 +675,8 @@ function savedEffort(id, model) {
|
|
|
655
675
|
function renderLauncher() {
|
|
656
676
|
const box = $('#launcher');
|
|
657
677
|
const mode = S.config.defaults.mode || 'edits';
|
|
658
|
-
const here = harnessesAt(launchWhere);
|
|
659
|
-
const chosen = here.find((a) => a.id === (S.config.defaults.agent || '
|
|
678
|
+
const here = harnessesAt(launchWhere, { auto: true });
|
|
679
|
+
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
|
|
660
680
|
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
661
681
|
box.innerHTML = `
|
|
662
682
|
<div class="l-projects"></div>
|
|
@@ -667,8 +687,7 @@ function renderLauncher() {
|
|
|
667
687
|
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
668
688
|
<div class="runners">
|
|
669
689
|
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
670
|
-
<button class="vs-btn" id="compareBtn"
|
|
671
|
-
<button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
|
|
690
|
+
<button class="vs-btn vs-add" id="compareBtn"><span>vs</span>${icon('plus')}</button>
|
|
672
691
|
</div>
|
|
673
692
|
<button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
|
|
674
693
|
<span class="grow"></span>
|
|
@@ -726,9 +745,8 @@ function renderLauncher() {
|
|
|
726
745
|
const runnerBtn = $('#runner', box);
|
|
727
746
|
const paintRunner = () => {
|
|
728
747
|
paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
|
|
729
|
-
runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
748
|
+
runnerBtn.title = compare.length ? runnerTip(agent?.id, curModel, curEffort) : `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
730
749
|
};
|
|
731
|
-
paintRunner();
|
|
732
750
|
|
|
733
751
|
// Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
|
|
734
752
|
const wtBtn = $('#wtBtn', box);
|
|
@@ -764,34 +782,64 @@ function renderLauncher() {
|
|
|
764
782
|
api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
|
|
765
783
|
});
|
|
766
784
|
|
|
767
|
-
// Compare:
|
|
768
|
-
|
|
785
|
+
// Compare: more harnesses get the same task (up to MAX_RUNNERS in all); picks here don't touch
|
|
786
|
+
// the launcher defaults. Each extra sits behind a "vs" that removes it; the button at the end
|
|
787
|
+
// adds the next one. Past two, the pill folds the names away and keeps the logos.
|
|
788
|
+
let compare = [];
|
|
769
789
|
const vsBtn = $('#compareBtn', box);
|
|
770
|
-
const
|
|
790
|
+
const runners = vsBtn.parentElement;
|
|
771
791
|
const paintCompare = () => {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
792
|
+
const n = compare.length;
|
|
793
|
+
runners.classList.toggle('cmp', n > 0);
|
|
794
|
+
runners.classList.toggle('many', n > 1);
|
|
795
|
+
for (const x of $$('.cmp-x', runners)) x.remove();
|
|
796
|
+
compare.forEach((c, i) => {
|
|
797
|
+
const name = agentName(c.agent);
|
|
798
|
+
const del = el(`<button class="vs-btn vs-del cmp-x" data-i="${i}" title="Remove ${esc(name)} from the comparison" aria-label="Remove ${esc(name)}"><span>vs</span>${icon('x')}</button>`);
|
|
799
|
+
const btn = el(`<button class="runner-btn cmp-x" data-i="${i}" aria-haspopup="dialog"></button>`);
|
|
800
|
+
paintRunnerBtn(btn, c.agent, c.model, c.effort);
|
|
801
|
+
btn.title = runnerTip(c.agent, c.model, c.effort);
|
|
802
|
+
vsBtn.before(del, btn);
|
|
803
|
+
});
|
|
804
|
+
vsBtn.hidden = n >= MAX_RUNNERS - 1;
|
|
805
|
+
vsBtn.title = n ? `Add another harness (up to ${MAX_RUNNERS} side by side)` : 'Compare: run the same task on another harness, side by side';
|
|
806
|
+
paintRunner(); // its tooltip spells everything out once the names fold away
|
|
777
807
|
};
|
|
778
|
-
paintCompare();
|
|
779
808
|
vsBtn.onclick = () => {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
809
|
+
// The next harness nobody in the pill uses yet; once they're all in, another copy of the lead.
|
|
810
|
+
const used = new Set([agent?.id, ...compare.map((c) => c.agent)]);
|
|
811
|
+
const here = harnessesAt(launchWhere).filter((a) => a.available);
|
|
812
|
+
const next = here.find((a) => !used.has(a.id)) || agent;
|
|
813
|
+
const model = savedModel(next?.id);
|
|
814
|
+
compare = [...compare, { agent: next?.id, model, effort: savedEffort(next?.id, model) }];
|
|
785
815
|
paintCompare();
|
|
816
|
+
// Its "vs" lands right under the pointer: it can't remove anything until the pointer has left.
|
|
817
|
+
const fresh = $$('.vs-del', runners).at(-1);
|
|
818
|
+
fresh?.classList.add('fresh');
|
|
819
|
+
fresh?.addEventListener('pointerleave', () => fresh.classList.remove('fresh'), { once: true });
|
|
820
|
+
// Straight into picking it when it's a repeat, since that's rarely the one you want.
|
|
821
|
+
if (used.has(next?.id)) $$('.runner-btn.cmp-x', runners).at(-1)?.click();
|
|
786
822
|
};
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
823
|
+
runners.addEventListener('click', (e) => {
|
|
824
|
+
const x = e.target.closest('.cmp-x');
|
|
825
|
+
if (!x) return;
|
|
826
|
+
const i = Number(x.dataset.i);
|
|
827
|
+
if (x.classList.contains('vs-del')) {
|
|
828
|
+
if (x.classList.contains('fresh')) return;
|
|
829
|
+
compare = compare.filter((_, k) => k !== i);
|
|
830
|
+
paintCompare();
|
|
831
|
+
(compare.length ? $$('.vs-del', runners)[Math.min(i, compare.length - 1)] : vsBtn).focus();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
runnerPicker(x, { ...compare[i], savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
835
|
+
compare = compare.map((c, k) => (k === i ? { agent: id, model, effort } : c));
|
|
790
836
|
paintCompare();
|
|
791
837
|
});
|
|
838
|
+
});
|
|
839
|
+
paintCompare();
|
|
792
840
|
runnerBtn.onclick = () =>
|
|
793
|
-
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
794
|
-
agent = harnessesAt(launchWhere).find((a) => a.id === id);
|
|
841
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere, auto: true }, (id, model, effort) => {
|
|
842
|
+
agent = harnessesAt(launchWhere, { auto: true }).find((a) => a.id === id);
|
|
795
843
|
curModel = model;
|
|
796
844
|
curEffort = effort;
|
|
797
845
|
paintRunner();
|
|
@@ -807,13 +855,18 @@ function renderLauncher() {
|
|
|
807
855
|
const launch = async () => {
|
|
808
856
|
const prompt = ta.value.trim();
|
|
809
857
|
if (!prompt) return ta.focus();
|
|
858
|
+
if (isFeedback(prompt)) {
|
|
859
|
+
ta.value = '';
|
|
860
|
+
ta.dispatchEvent(new Event('input'));
|
|
861
|
+
return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
|
|
862
|
+
}
|
|
810
863
|
// One run per place picked under "Run on", each in the project picked for it.
|
|
811
864
|
const targets = launchWhere.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launchPicks[k]) }));
|
|
812
865
|
const unset = targets.find((t) => !t.project);
|
|
813
866
|
if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
|
|
814
867
|
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
815
868
|
if (off) return offlineToast(off.k);
|
|
816
|
-
const wanted = [agent?.id, ...(
|
|
869
|
+
const wanted = [agent?.id, ...compare.map((c) => c.agent)].filter((id) => id !== 'auto'); // Auto picks from what's there
|
|
817
870
|
for (const t of targets) {
|
|
818
871
|
const list = agentsAt(t.k);
|
|
819
872
|
const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
|
|
@@ -828,7 +881,7 @@ function renderLauncher() {
|
|
|
828
881
|
const cmp = compare;
|
|
829
882
|
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
830
883
|
const pending = targets.map((t) => addPending(prompt, t, runner));
|
|
831
|
-
if (
|
|
884
|
+
if (cmp.length) (compare = []), paintCompare();
|
|
832
885
|
ta.value = '';
|
|
833
886
|
S.drafts.delete('home');
|
|
834
887
|
ta.dispatchEvent(new Event('input'));
|
|
@@ -847,7 +900,7 @@ function renderLauncher() {
|
|
|
847
900
|
targets.map((t) =>
|
|
848
901
|
api('/sessions', {
|
|
849
902
|
method: 'POST',
|
|
850
|
-
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp ? false : undefined, compare: cmp ?
|
|
903
|
+
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
851
904
|
}),
|
|
852
905
|
),
|
|
853
906
|
);
|
|
@@ -861,7 +914,7 @@ function renderLauncher() {
|
|
|
861
914
|
if (!started.length) {
|
|
862
915
|
if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
|
|
863
916
|
tray.restore(files);
|
|
864
|
-
if (cmp && !compare) (compare = cmp), paintCompare();
|
|
917
|
+
if (cmp.length && !compare.length) (compare = cmp), paintCompare();
|
|
865
918
|
return;
|
|
866
919
|
}
|
|
867
920
|
askNotify();
|
|
@@ -875,7 +928,7 @@ function renderLauncher() {
|
|
|
875
928
|
// Picking other places (or a machine coming online) changes which harnesses exist: keep the
|
|
876
929
|
// pick if one of them still has it, else move to one that's installed there.
|
|
877
930
|
box.placesChanged = () => {
|
|
878
|
-
const here = harnessesAt(launchWhere);
|
|
931
|
+
const here = harnessesAt(launchWhere, { auto: true }); // Auto works anywhere: it picks from what's there
|
|
879
932
|
if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
|
|
880
933
|
const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
|
|
881
934
|
if (!next) return paintRunner();
|
|
@@ -897,6 +950,15 @@ function renderLauncher() {
|
|
|
897
950
|
});
|
|
898
951
|
}
|
|
899
952
|
|
|
953
|
+
// One launch runs on at most this many harnesses side by side (the server's MAX_COMPARE).
|
|
954
|
+
const MAX_RUNNERS = 4;
|
|
955
|
+
// A runner button's tooltip, for when the pill is too full to show names.
|
|
956
|
+
function runnerTip(agentId, model, effort) {
|
|
957
|
+
const a = agentById(agentId);
|
|
958
|
+
const m = (a?.models || []).find((x) => x.id === model);
|
|
959
|
+
return [a?.name || agentName(agentId), m?.label || model || 'Default model', effort && effortLabel(effort)].filter(Boolean).join(' · ');
|
|
960
|
+
}
|
|
961
|
+
|
|
900
962
|
// The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
|
|
901
963
|
function paintRunnerBtn(btn, agentId, model, effort) {
|
|
902
964
|
const a = agentById(agentId);
|
|
@@ -1132,7 +1194,7 @@ function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
|
|
|
1132
1194
|
const rb = e.target.closest('.tp-runner');
|
|
1133
1195
|
if (rb) {
|
|
1134
1196
|
const m = members[i];
|
|
1135
|
-
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places }, (agent, model, effort) => {
|
|
1197
|
+
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places, auto: true }, (agent, model, effort) => {
|
|
1136
1198
|
Object.assign(m, { agent, model, effort });
|
|
1137
1199
|
paintList();
|
|
1138
1200
|
commit();
|
|
@@ -1313,7 +1375,7 @@ function modeMenu(anchor, current, pick) {
|
|
|
1313
1375
|
if (!b) return;
|
|
1314
1376
|
close();
|
|
1315
1377
|
pick(b.dataset.mode);
|
|
1316
|
-
$('#prompt')?.focus();
|
|
1378
|
+
if (anchor.id === 'mode') $('#prompt')?.focus();
|
|
1317
1379
|
};
|
|
1318
1380
|
menu.addEventListener('keydown', (ev) => {
|
|
1319
1381
|
const items = $$('button', menu);
|
|
@@ -1750,7 +1812,7 @@ function renderBody(id, { fresh }) {
|
|
|
1750
1812
|
${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
|
|
1751
1813
|
${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
|
|
1752
1814
|
${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
|
|
1753
|
-
${s.origin ? `<span class="m" title="Launched by a ${esc(s.origin.kind)}">${icon(
|
|
1815
|
+
${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
|
|
1754
1816
|
const live = s.state === 'running' || s.state === 'waiting';
|
|
1755
1817
|
// Ship sits in the top bar and again next to the composer, so it's there after reading the result.
|
|
1756
1818
|
renderPrStrip(id);
|
|
@@ -1770,7 +1832,14 @@ function renderBody(id, { fresh }) {
|
|
|
1770
1832
|
earlier.textContent = `Show ${hiddenCount} earlier turn${hiddenCount === 1 ? '' : 's'}`;
|
|
1771
1833
|
// A new turn re-folds everything older, so the latest exchange is what's open by default.
|
|
1772
1834
|
const latestId = s.turns.at(-1)?.id;
|
|
1773
|
-
|
|
1835
|
+
const newTurn = n.latestTurn !== latestId;
|
|
1836
|
+
if (newTurn) (n.unfolded.clear(), (n.latestTurn = latestId));
|
|
1837
|
+
// Folding the last answer when a follow-up starts can take a screen or more out of the card
|
|
1838
|
+
// above the composer. Hold the composer still, so the new exchange lands where the user was
|
|
1839
|
+
// typing instead of the whole card sliding off the top of the screen.
|
|
1840
|
+
const watched = watching(id);
|
|
1841
|
+
const dock = watched && newTurn && !fresh ? $('.it-dock', body) : null;
|
|
1842
|
+
const dockTop = dock?.getBoundingClientRect().top;
|
|
1774
1843
|
const keep = new Set();
|
|
1775
1844
|
let firstNew = null;
|
|
1776
1845
|
turns.forEach((t) => {
|
|
@@ -1794,7 +1863,8 @@ function renderBody(id, { fresh }) {
|
|
|
1794
1863
|
for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
|
|
1795
1864
|
|
|
1796
1865
|
updateDock(id);
|
|
1797
|
-
if (
|
|
1866
|
+
if (dock?.isConnected) scrollByPx(dock.getBoundingClientRect().top - dockTop);
|
|
1867
|
+
if (firstNew && watched) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
1798
1868
|
}
|
|
1799
1869
|
|
|
1800
1870
|
// Whether the user is reading a card, not just catching its edge at the top or bottom of the
|
|
@@ -1860,6 +1930,14 @@ function renderFoldedTurn(t, unfold) {
|
|
|
1860
1930
|
return node;
|
|
1861
1931
|
}
|
|
1862
1932
|
|
|
1933
|
+
// A follow-up that switched the mode says so, next to the model it ran on.
|
|
1934
|
+
function modeSwitch(s, t) {
|
|
1935
|
+
const i = s.turns.indexOf(t);
|
|
1936
|
+
const prev = s.turns.slice(0, i).findLast((x) => x.mode)?.mode;
|
|
1937
|
+
const m = t.mode && prev && t.mode !== prev && MODES.find((x) => x.id === t.mode);
|
|
1938
|
+
return m ? `<span class="t-mode m-${m.id}" title="Switched from ${esc(MODES.find((x) => x.id === prev)?.label || prev)} to ${m.label} for this follow-up">${icon(m.icon)}${m.label}</span>` : '';
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1863
1941
|
// `fold` is set for older turns the user unfolded, so they can tuck them away again.
|
|
1864
1942
|
function renderTurn(s, t, isLatest, fold) {
|
|
1865
1943
|
const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
|
|
@@ -1873,6 +1951,7 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
1873
1951
|
${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
|
|
1874
1952
|
<div class="ask-foot">
|
|
1875
1953
|
${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
|
|
1954
|
+
${modeSwitch(s, t)}
|
|
1876
1955
|
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
1877
1956
|
${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
|
|
1878
1957
|
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
@@ -2001,6 +2080,7 @@ function renderTrail(s, t) {
|
|
|
2001
2080
|
const st = t.stats || {};
|
|
2002
2081
|
const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
|
|
2003
2082
|
const rolledBack = !!t.rolledBackAt;
|
|
2083
|
+
const rated = t.feedback?.rating;
|
|
2004
2084
|
const node = el(`
|
|
2005
2085
|
<div class="trail">
|
|
2006
2086
|
<div class="trail-bar">
|
|
@@ -2012,6 +2092,7 @@ function renderTrail(s, t) {
|
|
|
2012
2092
|
${t.changes?.length ? `<button class="link xs" data-tab="changes">${t.changes.length} file${t.changes.length > 1 ? 's' : ''} <span class="plus">+${totals.add}</span> <span class="minus">−${totals.del}</span></button>` : ''}
|
|
2013
2093
|
${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
|
|
2014
2094
|
${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
|
|
2095
|
+
${feedbackOn() ? `<span class="rate ${rated ? 'rated' : ''}">${['up', 'down'].map((r) => `<button class="rate-btn ${rated === r ? 'on' : ''}" data-rate="${r}" title="${r === 'up' ? 'Good result' : 'Bad result'}: tell the wisp team" aria-pressed="${rated === r}">${icon(`thumb-${r}`)}</button>`).join('')}</span>` : ''}
|
|
2015
2096
|
</div>
|
|
2016
2097
|
<div class="trail-body" hidden></div>
|
|
2017
2098
|
</div>`);
|
|
@@ -2021,6 +2102,20 @@ function renderTrail(s, t) {
|
|
|
2021
2102
|
if (e.target.closest('[data-a="rollback"]')) {
|
|
2022
2103
|
return rollbackTurn(s.id, t.id, rolledBack);
|
|
2023
2104
|
}
|
|
2105
|
+
const rb = e.target.closest('[data-rate]');
|
|
2106
|
+
if (rb) {
|
|
2107
|
+
const next = t.feedback?.rating === rb.dataset.rate ? null : rb.dataset.rate;
|
|
2108
|
+
rateTurn(s, t, node, next);
|
|
2109
|
+
if (next !== 'down' && open === 'rate') ((open = null), (body.hidden = true));
|
|
2110
|
+
if (next === 'down') {
|
|
2111
|
+
open = 'rate';
|
|
2112
|
+
$$('[data-tab]', node).forEach((x) => x.classList.remove('on'));
|
|
2113
|
+
body.hidden = false;
|
|
2114
|
+
body.innerHTML = '';
|
|
2115
|
+
body.append(whyForm(s, t, () => ((open = null), (body.hidden = true))));
|
|
2116
|
+
}
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2024
2119
|
const b = e.target.closest('[data-tab]');
|
|
2025
2120
|
if (!b) return;
|
|
2026
2121
|
const tab = b.dataset.tab;
|
|
@@ -2074,6 +2169,7 @@ function renderDock(id) {
|
|
|
2074
2169
|
<textarea class="reply" rows="1" spellcheck="false"></textarea>
|
|
2075
2170
|
<div class="c-bar">
|
|
2076
2171
|
<button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
|
|
2172
|
+
<button class="mode-btn c-mode" aria-haspopup="menu"></button>
|
|
2077
2173
|
<span class="c-hint"></span>
|
|
2078
2174
|
<button class="ghost sm c-ship" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
|
|
2079
2175
|
<button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
|
|
@@ -2091,6 +2187,8 @@ function renderDock(id) {
|
|
|
2091
2187
|
$('.c-done', comp).onclick = () => toggleDone(id);
|
|
2092
2188
|
$('.c-ship', comp).onclick = () => shipDialog(id);
|
|
2093
2189
|
$('.c-queue', comp).onclick = toggleQueue;
|
|
2190
|
+
const modeBtn = $('.c-mode', comp);
|
|
2191
|
+
modeBtn.onclick = () => modeMenu(modeBtn, replyMode(id), (m) => (setReplyMode(id, m), ta.focus()));
|
|
2094
2192
|
ta.value = S.drafts.get(id) || '';
|
|
2095
2193
|
ta.addEventListener('input', () => S.drafts.set(id, ta.value));
|
|
2096
2194
|
ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
|
|
@@ -2099,10 +2197,37 @@ function renderDock(id) {
|
|
|
2099
2197
|
e.preventDefault();
|
|
2100
2198
|
sendReply(id, ta.value);
|
|
2101
2199
|
}
|
|
2200
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
2201
|
+
e.preventDefault();
|
|
2202
|
+
setReplyMode(id, MODES[(MODES.findIndex((m) => m.id === replyMode(id)) + 1) % MODES.length].id);
|
|
2203
|
+
}
|
|
2102
2204
|
});
|
|
2103
2205
|
$('.send', comp).onclick = () => sendReply(id, ta.value);
|
|
2104
2206
|
}
|
|
2105
2207
|
|
|
2208
|
+
// The mode the next follow-up runs in: the session's own until the user picks another here.
|
|
2209
|
+
// The pick goes out with that follow-up and stays from then on.
|
|
2210
|
+
const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || 'edits';
|
|
2211
|
+
|
|
2212
|
+
function setReplyMode(id, mode) {
|
|
2213
|
+
if (mode === S.full.get(id)?.mode) S.replyModes.delete(id);
|
|
2214
|
+
else S.replyModes.set(id, mode);
|
|
2215
|
+
updateDock(id);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function paintReplyMode(id, btn) {
|
|
2219
|
+
const s = S.full.get(id);
|
|
2220
|
+
const m = MODES.find((x) => x.id === replyMode(id)) || MODES[1];
|
|
2221
|
+
const from = s && S.replyModes.has(id) && MODES.find((x) => x.id === s.mode);
|
|
2222
|
+
btn.className = `mode-btn c-mode m-${m.id}${from ? ' changed' : ''}`;
|
|
2223
|
+
btn.innerHTML = `${icon(m.icon)}${m.id === 'edits' && !from ? '' : `<span>${m.label}</span>`}`;
|
|
2224
|
+
btn.title = from
|
|
2225
|
+
? `${m.label}: ${m.hint}\nYour next follow-up switches this task from ${from.label} to ${m.label}. Shift+Tab to switch`
|
|
2226
|
+
: `${m.label}: ${m.hint}\nPick another mode for your next follow-up. Shift+Tab to switch`;
|
|
2227
|
+
// Subagents run in their lead's mode; the tour has nothing to run.
|
|
2228
|
+
btn.hidden = !s || s.agent === 'wisp' || !!s.parentId;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2106
2231
|
function updateDock(id) {
|
|
2107
2232
|
const s = S.full.get(id);
|
|
2108
2233
|
const n = S.nodes.get(id);
|
|
@@ -2123,6 +2248,7 @@ function updateDock(id) {
|
|
|
2123
2248
|
? 'Follow up to reopen…'
|
|
2124
2249
|
: `Follow up with ${name}…`;
|
|
2125
2250
|
$('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
|
|
2251
|
+
paintReplyMode(id, $('.c-mode', n.card));
|
|
2126
2252
|
renderQueued(s, $('.c-queued', n.card), name);
|
|
2127
2253
|
const q = $('.c-queue', n.card);
|
|
2128
2254
|
q.hidden = tour || s.state !== 'running';
|
|
@@ -2199,20 +2325,28 @@ async function sendReply(id, text) {
|
|
|
2199
2325
|
const n = S.nodes.get(id);
|
|
2200
2326
|
text = (text || '').trim();
|
|
2201
2327
|
if (!s || !text) return;
|
|
2328
|
+
const ta = n && $('.reply', n.card);
|
|
2329
|
+
if (isFeedback(text)) {
|
|
2330
|
+
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2331
|
+
S.drafts.delete(id);
|
|
2332
|
+
return feedbackCommand(text, { sessionId: id, restore: () => ta?.isConnected && !ta.value && ((ta.value = text), ta.dispatchEvent(new Event('input'))) });
|
|
2333
|
+
}
|
|
2202
2334
|
if (n?.tray?.busy) toast('Waiting for uploads…');
|
|
2203
2335
|
const attachments = n?.tray ? await n.tray.ids() : [];
|
|
2204
|
-
const
|
|
2336
|
+
const mode = S.replyModes.get(id);
|
|
2337
|
+
// An answer goes to the run that asked; a new mode needs a new run, so that's a follow-up.
|
|
2338
|
+
const answering = !!s.ask && !attachments.length && !mode;
|
|
2205
2339
|
// Optimistic: the composer empties and the card goes to Working before the server answers
|
|
2206
2340
|
// (it may first have to bring back an archived worktree, or reach another machine).
|
|
2207
|
-
const ta = n && $('.reply', n.card);
|
|
2208
2341
|
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2209
2342
|
S.drafts.delete(id);
|
|
2343
|
+
S.replyModes.delete(id);
|
|
2210
2344
|
const files = n?.tray?.take();
|
|
2211
|
-
const undo = startOptimistic(id, text, files, { answering });
|
|
2345
|
+
const undo = startOptimistic(id, text, files, { answering, mode });
|
|
2212
2346
|
try {
|
|
2213
2347
|
const r = answering
|
|
2214
2348
|
? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
|
|
2215
|
-
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
|
|
2349
|
+
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing(), mode } });
|
|
2216
2350
|
if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
|
|
2217
2351
|
if (r.queued)
|
|
2218
2352
|
toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
|
|
@@ -2220,6 +2354,7 @@ async function sendReply(id, text) {
|
|
|
2220
2354
|
undo?.();
|
|
2221
2355
|
// Give the message back, unless the user has already started typing another one.
|
|
2222
2356
|
if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
|
|
2357
|
+
if (mode && !S.replyModes.has(id)) setReplyMode(id, mode);
|
|
2223
2358
|
n?.tray?.restore(files);
|
|
2224
2359
|
toast(esc(err.message), { tone: 'bad' });
|
|
2225
2360
|
}
|
|
@@ -2228,7 +2363,7 @@ async function sendReply(id, text) {
|
|
|
2228
2363
|
// Show a follow-up as started right away: the card moves to Working with the prompt as a live
|
|
2229
2364
|
// turn. Only when it will start now, not when it queues or interrupts a run already working.
|
|
2230
2365
|
// Returns an undo for a failed send, or null when nothing changed.
|
|
2231
|
-
function startOptimistic(id, text, files, { answering }) {
|
|
2366
|
+
function startOptimistic(id, text, files, { answering, mode }) {
|
|
2232
2367
|
const full = S.full.get(id);
|
|
2233
2368
|
const sum = S.sessions.get(id);
|
|
2234
2369
|
if (!full || !sum) return null;
|
|
@@ -2236,8 +2371,9 @@ function startOptimistic(id, text, files, { answering }) {
|
|
|
2236
2371
|
if (live && !answering) return null;
|
|
2237
2372
|
const now = Date.now();
|
|
2238
2373
|
const turn = { id: `pending-${now}`, prompt: text, status: 'running', startedAt: now, activity: [], attachments: (files || []).filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
|
|
2239
|
-
|
|
2240
|
-
const
|
|
2374
|
+
if (mode) turn.mode = mode;
|
|
2375
|
+
const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn], ...(mode && { mode }) };
|
|
2376
|
+
const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now, ...(mode && { mode }) };
|
|
2241
2377
|
const apply = (f, sm) => {
|
|
2242
2378
|
S.full.set(id, f);
|
|
2243
2379
|
S.sessions.set(id, sm);
|
|
@@ -2383,11 +2519,12 @@ function rename(id) {
|
|
|
2383
2519
|
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
2384
2520
|
// models; picking a model commits harness + model, picking a thinking level commits it in place.
|
|
2385
2521
|
// places: the "Run on" picks, so the list is what's installed there rather than only on this computer.
|
|
2386
|
-
function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places }, pick) {
|
|
2522
|
+
function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places, auto = false }, pick) {
|
|
2387
2523
|
const open = $('.picker');
|
|
2388
2524
|
if (open) return open.close();
|
|
2389
|
-
const agents = places ? harnessesAt(places) : S.agents;
|
|
2525
|
+
const agents = places ? harnessesAt(places, { auto }) : auto ? [AUTO, ...S.agents] : S.agents;
|
|
2390
2526
|
const sub = (a) => {
|
|
2527
|
+
if (a.id === 'auto') return 'Picks harness and model for you';
|
|
2391
2528
|
if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
|
|
2392
2529
|
const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
|
|
2393
2530
|
return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
|
|
@@ -2458,6 +2595,17 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, p
|
|
|
2458
2595
|
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
2459
2596
|
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
2460
2597
|
input.placeholder = `Search ${a?.name || ''} models`;
|
|
2598
|
+
if (a?.id === 'auto') {
|
|
2599
|
+
list.innerHTML = `<div class="pk-group"><div class="pk-ghead">Goal</div>${AUTO_GOALS.map((g) => `<button class="pk-m${g.id === current ? ' on' : ''}" role="option" aria-selected="${g.id === current}" data-id="${g.id}"><span class="pk-text"><b>${g.label}</b><small>${g.hint}</small></span>${icon('check', 'pk-check')}</button>`).join('')}</div><p class="pk-note">wisp picks the harness and model for each task from what worked before, and learns from how it went. ${'<a href="#" class="pk-auto-more">How Auto picks</a>'}</p><div class="pk-empty" hidden>No models match</div>`;
|
|
2600
|
+
$('.pk-search', node).hidden = true;
|
|
2601
|
+
$('.pk-auto-more', list).onclick = (e) => {
|
|
2602
|
+
e.preventDefault();
|
|
2603
|
+
close();
|
|
2604
|
+
openSettings({ pane: 'auto' });
|
|
2605
|
+
};
|
|
2606
|
+
paintEffort();
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2461
2609
|
$('.pk-search', node).hidden = models.length <= 6;
|
|
2462
2610
|
// opencode-style ids (provider/model) group by provider.
|
|
2463
2611
|
const groups = new Map();
|
|
@@ -2739,7 +2887,7 @@ async function rerun(id) {
|
|
|
2739
2887
|
modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
|
|
2740
2888
|
// Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
|
|
2741
2889
|
runnerBtn.onclick = () =>
|
|
2742
|
-
runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort }, (a, m, e) => {
|
|
2890
|
+
runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
|
|
2743
2891
|
[agent, model, effort] = [a, m, e];
|
|
2744
2892
|
paintRunner();
|
|
2745
2893
|
});
|
|
@@ -3330,7 +3478,7 @@ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
|
|
|
3330
3478
|
const [btn, reset] = wrap.children;
|
|
3331
3479
|
const paint = () => (paintRunnerBtn(btn, draft.agent, draft.model, draft.effort), (reset.hidden = !draft.agent));
|
|
3332
3480
|
btn.onclick = () =>
|
|
3333
|
-
runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort }, (a, m, e) => {
|
|
3481
|
+
runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
|
|
3334
3482
|
Object.assign(draft, { agent: a, model: m, effort: e });
|
|
3335
3483
|
paint();
|
|
3336
3484
|
});
|
|
@@ -3610,6 +3758,8 @@ async function openPhone({ host } = {}) {
|
|
|
3610
3758
|
const status = (st) => (!st ? '' : st.ok ? `<span class="ph-ok">${icon('check')}Working</span>` : `<span class="ph-bad">${icon('warn')}${esc(st.error || 'Failed')}</span>`);
|
|
3611
3759
|
const paint = () => {
|
|
3612
3760
|
const tg = r.telegram;
|
|
3761
|
+
const projects = S.config.projects || [];
|
|
3762
|
+
const lastUsed = [...projects].sort(recentFirst)[0];
|
|
3613
3763
|
$('.ph-body', node).innerHTML = `
|
|
3614
3764
|
<div class="settings-modal ph-sec">
|
|
3615
3765
|
<div class="setting"><span>Send me</span>
|
|
@@ -3637,6 +3787,22 @@ async function openPhone({ host } = {}) {
|
|
|
3637
3787
|
? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
|
|
3638
3788
|
: 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
|
|
3639
3789
|
}</p>
|
|
3790
|
+
${
|
|
3791
|
+
tg.linked
|
|
3792
|
+
? `<div class="ph-start">
|
|
3793
|
+
<div class="ph-row"><span class="grow">Start tasks from Telegram</span><button class="switch" data-sw="tgStart" role="switch" aria-checked="${tg.allowStart}"></button></div>
|
|
3794
|
+
${
|
|
3795
|
+
tg.allowStart
|
|
3796
|
+
? `<div class="settings-modal"><label><span>Default project</span><select class="input" data-tg="project">
|
|
3797
|
+
<option value="">Last used${lastUsed ? ` (${esc(lastUsed.name)})` : ''}</option>
|
|
3798
|
+
${projects.map((p) => `<option value="${esc(p.id)}" ${p.id === tg.project ? 'selected' : ''}>${esc(p.name)}${p.machine ? ` · ${esc(placeName(p.machine))}` : ''}</option>`).join('')}
|
|
3799
|
+
</select></label></div>
|
|
3800
|
+
<p class="hint">Send <code>/new fix the flaky login test</code> to start a task in the default project, or <code>/new @${esc(projects[0]?.name || 'project')} …</code> to pick one; <code>/projects</code> lists them. Tasks use the launcher's harness, model and mode. Only your linked chat can start them.</p>`
|
|
3801
|
+
: '<p class="hint">Off: the bot only answers and follows up on tasks you started here.</p>'
|
|
3802
|
+
}
|
|
3803
|
+
</div>`
|
|
3804
|
+
: ''
|
|
3805
|
+
}
|
|
3640
3806
|
</section>
|
|
3641
3807
|
<div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
|
|
3642
3808
|
};
|
|
@@ -3644,7 +3810,8 @@ async function openPhone({ host } = {}) {
|
|
|
3644
3810
|
const t = e.target;
|
|
3645
3811
|
if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
|
|
3646
3812
|
if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
|
|
3647
|
-
if (t.dataset.tg) put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
|
|
3813
|
+
if (t.dataset.tg === 'token') put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
|
|
3814
|
+
if (t.dataset.tg === 'project') put({ telegram: { project: t.value } });
|
|
3648
3815
|
});
|
|
3649
3816
|
node.addEventListener('click', async (e) => {
|
|
3650
3817
|
const sw = e.target.closest('[data-sw]')?.dataset.sw;
|
|
@@ -3654,6 +3821,7 @@ async function openPhone({ host } = {}) {
|
|
|
3654
3821
|
return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
|
|
3655
3822
|
}
|
|
3656
3823
|
if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
|
|
3824
|
+
if (sw === 'tgStart') return put({ telegram: { allowStart: !r.telegram.allowStart } });
|
|
3657
3825
|
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
3658
3826
|
if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
|
|
3659
3827
|
if (act === 'test') {
|
|
@@ -4012,6 +4180,7 @@ const SETTINGS_PANES = [
|
|
|
4012
4180
|
{ id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
|
|
4013
4181
|
{ id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
|
|
4014
4182
|
{ id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
|
|
4183
|
+
{ id: 'auto', label: 'Auto', icon: 'spark', lede: 'wisp picks the harness and model for each task, and learns from how it went.' },
|
|
4015
4184
|
{ id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
|
|
4016
4185
|
{ id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
|
|
4017
4186
|
{ id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
|
|
@@ -4077,6 +4246,16 @@ function openSettings(opts) {
|
|
|
4077
4246
|
${row('Installed something new?', '<span class="recheck-hint"></span>', `<button class="ghost sm" id="recheck">${icon('replay')}Re-check</button>`)}
|
|
4078
4247
|
</div>
|
|
4079
4248
|
</section>
|
|
4249
|
+
<section class="sx-pane" data-pane="auto">
|
|
4250
|
+
<div class="sx-group">
|
|
4251
|
+
${row('Goal', 'What Auto optimises for. Also in the launcher, next to Auto', `<span class="pk-seg ro-goal">${AUTO_GOALS.map((g) => `<button data-goal="${g.id}" title="${esc(g.hint)}">${g.label}</button>`).join('')}</span>`)}
|
|
4252
|
+
${row('Try new models', 'A new model or harness gets a few tasks that aren’t marked important before Auto relies on it', '<button class="switch" id="roExplore" role="switch"></button>')}
|
|
4253
|
+
${row('Harnesses it may use', 'Leave all off to let Auto use every one you have', '<span class="ro-allowed"></span>')}
|
|
4254
|
+
</div>
|
|
4255
|
+
<div class="sx-group">
|
|
4256
|
+
${row('What it learned', '<span class="ro-hint"></span>', '<button class="ghost sm" id="openRouter">Open</button>')}
|
|
4257
|
+
</div>
|
|
4258
|
+
</section>
|
|
4080
4259
|
<section class="sx-pane" data-pane="machines">
|
|
4081
4260
|
<div class="sx-group">
|
|
4082
4261
|
${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
|
|
@@ -4255,6 +4434,52 @@ function openSettings(opts) {
|
|
|
4255
4434
|
renderLauncher();
|
|
4256
4435
|
};
|
|
4257
4436
|
paintWt();
|
|
4437
|
+
// Auto: goal, exploring and allowed harnesses here; the full record in its own view.
|
|
4438
|
+
const paintAuto = async () => {
|
|
4439
|
+
const r = await api('/router').catch(() => null);
|
|
4440
|
+
if (!r || !node.isConnected) return;
|
|
4441
|
+
$$('.ro-goal button', node).forEach((b) => b.classList.toggle('on', b.dataset.goal === r.goal));
|
|
4442
|
+
$('#roExplore', node).setAttribute('aria-checked', r.settings.explore);
|
|
4443
|
+
const allowed = r.settings.allowed || [];
|
|
4444
|
+
$('.ro-allowed', node).innerHTML = S.agents
|
|
4445
|
+
.filter((a) => a.available)
|
|
4446
|
+
.map((a) => `<button class="chip-tog${allowed.includes(a.id) ? ' on' : ''}" data-id="${esc(a.id)}" aria-pressed="${allowed.includes(a.id)}" title="${esc(a.name)}"><span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}</button>`)
|
|
4447
|
+
.join('');
|
|
4448
|
+
$('.ro-hint', node).textContent = r.learned
|
|
4449
|
+
? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model pairs`
|
|
4450
|
+
: `Nothing of yours yet · starting from wisp-bench (${r.priors.version})`;
|
|
4451
|
+
};
|
|
4452
|
+
const patchAuto = async (body) => {
|
|
4453
|
+
try {
|
|
4454
|
+
await api('/router', { method: 'PATCH', body });
|
|
4455
|
+
await loadConfig();
|
|
4456
|
+
renderLauncher();
|
|
4457
|
+
} catch (err) {
|
|
4458
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
4459
|
+
}
|
|
4460
|
+
paintAuto();
|
|
4461
|
+
};
|
|
4462
|
+
$('.ro-goal', node).onclick = (e) => {
|
|
4463
|
+
const b = e.target.closest('[data-goal]');
|
|
4464
|
+
if (b) {
|
|
4465
|
+
$$('.ro-goal button', node).forEach((x) => x.classList.toggle('on', x === b));
|
|
4466
|
+
patchAuto({ goal: b.dataset.goal });
|
|
4467
|
+
}
|
|
4468
|
+
};
|
|
4469
|
+
$('#roExplore', node).onclick = (e) => {
|
|
4470
|
+
const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
|
|
4471
|
+
e.currentTarget.setAttribute('aria-checked', on);
|
|
4472
|
+
patchAuto({ explore: on });
|
|
4473
|
+
};
|
|
4474
|
+
$('.ro-allowed', node).onclick = (e) => {
|
|
4475
|
+
const b = e.target.closest('[data-id]');
|
|
4476
|
+
if (!b) return;
|
|
4477
|
+
b.classList.toggle('on');
|
|
4478
|
+
b.setAttribute('aria-pressed', b.classList.contains('on'));
|
|
4479
|
+
patchAuto({ allowed: $$('.ro-allowed .on', node).map((x) => x.dataset.id) });
|
|
4480
|
+
};
|
|
4481
|
+
node.paintAuto = paintAuto;
|
|
4482
|
+
|
|
4258
4483
|
// The one-line summaries; painted again on the way back from a view that may have changed them.
|
|
4259
4484
|
const paintHints = () => {
|
|
4260
4485
|
const r = S.config.recipes?.length || 0;
|
|
@@ -4275,8 +4500,10 @@ function openSettings(opts) {
|
|
|
4275
4500
|
$('.machines-hint', node).textContent = machinesSummary();
|
|
4276
4501
|
api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
|
|
4277
4502
|
paintLearn();
|
|
4503
|
+
paintAuto();
|
|
4278
4504
|
};
|
|
4279
4505
|
paintHints();
|
|
4506
|
+
$('#openRouter', node).onclick = () => openRouter({ host: host('auto') });
|
|
4280
4507
|
$('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
|
|
4281
4508
|
$$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
|
|
4282
4509
|
$('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
|
|
@@ -4284,6 +4511,52 @@ function openSettings(opts) {
|
|
|
4284
4511
|
$('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
|
|
4285
4512
|
}
|
|
4286
4513
|
|
|
4514
|
+
// --- Auto: what the router learned ---------------------------------------------------------
|
|
4515
|
+
|
|
4516
|
+
const ARM_STATE = { ready: ['Ready', 'ok'], new: ['Trying out', 'warn'], untried: ['No data yet', ''], gone: ['Not installed', 'bad'] };
|
|
4517
|
+
async function openRouter({ host } = {}) {
|
|
4518
|
+
const node = el(`<div class="modal router-modal">
|
|
4519
|
+
<header><h2>Auto</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
4520
|
+
<div class="ro-body"><div class="it-loading"><span class="orb tiny"></span>Loading…</div></div>
|
|
4521
|
+
</div>`);
|
|
4522
|
+
const close = present(node, host);
|
|
4523
|
+
const pctOf = (x) => `${Math.round(x * 100)}%`;
|
|
4524
|
+
const outcome = (r) => (r === null ? '<span class="dim">waiting</span>' : r >= 0.9 ? '<span class="ok">worked</span>' : r >= 0.5 ? '<span>finished</span>' : r >= 0.25 ? '<span class="warn">partly</span>' : '<span class="bad">didn’t work</span>');
|
|
4525
|
+
const render = async () => {
|
|
4526
|
+
const r = await api('/router').catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
|
|
4527
|
+
if (!r || !node.isConnected) return;
|
|
4528
|
+
const arms = r.arms.filter((a) => a.state !== 'gone' || a.n);
|
|
4529
|
+
$('.ro-body', node).innerHTML = `
|
|
4530
|
+
<p class="hint">For each task Auto estimates every harness · model’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), now and then trying an alternative so the estimates stay honest.</p>
|
|
4531
|
+
<h4>Harness · model</h4>
|
|
4532
|
+
<table class="us-table ro-table"><thead><tr><th></th><th>Works</th><th>Your tasks</th><th>~Cost</th><th>~Time</th><th></th></tr></thead><tbody>${arms
|
|
4533
|
+
.map((a) => {
|
|
4534
|
+
const [state, tone] = ARM_STATE[a.state] || ARM_STATE.ready;
|
|
4535
|
+
const types = Object.entries(a.byType).map(([t, x]) => `${t} ${pctOf(x.chance)} (${x.n})`).join(' · ');
|
|
4536
|
+
return `<tr><td><span class="us-runner"><span class="model-logo">${harnessLogo(a.agent)}</span>${esc(a.label)}</span>${types ? `<small class="ro-types">${esc(types)}</small>` : ''}</td>${a.state === 'untried' ? '<td>—</td><td>—</td><td>—</td><td>—</td>' : `<td><b>${pctOf(a.chance)}</b></td><td>${a.n || '—'}</td><td>${a.usd ? `$${a.usd.toFixed(2)}` : '$0'}</td><td>${a.minutes}m</td>`}<td><span class="sx-state ${tone}"><i class="dot ${tone}"></i>${state}</span></td></tr>`;
|
|
4537
|
+
})
|
|
4538
|
+
.join('')}</tbody></table>
|
|
4539
|
+
<h4>Recent picks</h4>
|
|
4540
|
+
${r.recent.length
|
|
4541
|
+
? `<ul class="ro-recent">${r.recent
|
|
4542
|
+
.map((x) => {
|
|
4543
|
+
const s = S.sessions.get(x.id);
|
|
4544
|
+
return `<li><span class="model-logo">${harnessLogo(x.key.split(':')[0])}</span><span class="ro-title">${esc(s?.title || 'Task')}</span><small>${esc(x.label)} · ${esc(x.type)}${x.trying ? ' · trying' : x.explored ? ' · exploring' : ''}</small>${outcome(x.r)}</li>`;
|
|
4545
|
+
})
|
|
4546
|
+
.join('')}</ul>`
|
|
4547
|
+
: '<p class="mem-empty">Nothing yet. Launch with Auto and its picks show up here.</p>'}
|
|
4548
|
+
<div class="ro-foot"><button class="ghost sm danger" id="roReset">Forget what Auto learned</button><span class="hint">The wisp-bench starting point stays.</span></div>`;
|
|
4549
|
+
$('#roReset', node).onclick = async () => {
|
|
4550
|
+
if (!confirm('Forget what Auto learned from your tasks?')) return;
|
|
4551
|
+
await api('/router/reset', { method: 'POST', body: {} }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
4552
|
+
render();
|
|
4553
|
+
};
|
|
4554
|
+
};
|
|
4555
|
+
node.refresh = render;
|
|
4556
|
+
render();
|
|
4557
|
+
return close;
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4287
4560
|
// --- memory: what the background retros learned ---------------------------------------------
|
|
4288
4561
|
|
|
4289
4562
|
const MEM_KINDS = [
|
|
@@ -4450,6 +4723,7 @@ function shortcuts() {
|
|
|
4450
4723
|
['1–9', 'Pick an answer when asked'],
|
|
4451
4724
|
['↵', 'Launch / send'],
|
|
4452
4725
|
['⇧↵', 'New line'],
|
|
4726
|
+
['/feedback', 'Send feedback to the wisp team (in any prompt box)'],
|
|
4453
4727
|
['C', 'Collapse all'],
|
|
4454
4728
|
['U', 'Back to top'],
|
|
4455
4729
|
['Esc', 'Close / collapse'],
|
|
@@ -4458,6 +4732,129 @@ function shortcuts() {
|
|
|
4458
4732
|
overlay(node);
|
|
4459
4733
|
}
|
|
4460
4734
|
|
|
4735
|
+
// --- feedback ---------------------------------------------------------------------------
|
|
4736
|
+
// Three ways in, none of them taking room: thumbs at the end of a turn's trail, "/feedback …" in
|
|
4737
|
+
// any prompt box, and "Send feedback" in ⌘K. It all goes to the wisp team (PostHog), with the
|
|
4738
|
+
// run's harness, model and status but never the prompt or the result.
|
|
4739
|
+
|
|
4740
|
+
const feedbackOn = () => S.config?.feedback !== false;
|
|
4741
|
+
const FEEDBACK_CMD = /^\/feedback(?:\s|$)/i;
|
|
4742
|
+
const isFeedback = (text) => FEEDBACK_CMD.test(text);
|
|
4743
|
+
const THANKS = 'Thanks! Sent to the wisp team.';
|
|
4744
|
+
|
|
4745
|
+
// The box is already empty; `restore` puts the text back if the server refuses it.
|
|
4746
|
+
function feedbackCommand(text, { sessionId, restore } = {}) {
|
|
4747
|
+
if (!feedbackOn()) return (restore?.(), toast('Feedback is turned off on this install.', { tone: 'bad' }));
|
|
4748
|
+
const note = text.replace(FEEDBACK_CMD, '').trim();
|
|
4749
|
+
if (!note) return openFeedback({ sessionId });
|
|
4750
|
+
postFeedback({ text: note, sessionId, source: 'command' }, { onFail: restore });
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4753
|
+
// Optimistic: thanks at once. Offline, the server keeps it and sends it later.
|
|
4754
|
+
function postFeedback(body, { onFail, quiet = false } = {}) {
|
|
4755
|
+
if (!quiet) toast(THANKS);
|
|
4756
|
+
return api('/feedback', { method: 'POST', body })
|
|
4757
|
+
.then((r) => {
|
|
4758
|
+
if (!r.sent && r.available !== false) toast('Saved: it goes out once wisp is back online.');
|
|
4759
|
+
})
|
|
4760
|
+
.catch((err) => {
|
|
4761
|
+
onFail?.();
|
|
4762
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
4763
|
+
});
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4766
|
+
function paintRate(node, rating) {
|
|
4767
|
+
$('.rate', node)?.classList.toggle('rated', !!rating);
|
|
4768
|
+
$$('[data-rate]', node).forEach((b) => {
|
|
4769
|
+
b.classList.toggle('on', b.dataset.rate === rating);
|
|
4770
|
+
b.setAttribute('aria-pressed', b.dataset.rate === rating);
|
|
4771
|
+
});
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
function rateTurn(s, t, node, rating) {
|
|
4775
|
+
const prev = t.feedback;
|
|
4776
|
+
t.feedback = rating ? { rating, at: Date.now() } : undefined;
|
|
4777
|
+
paintRate(node, rating);
|
|
4778
|
+
if (rating === 'up') {
|
|
4779
|
+
const tip = $('.rate', node);
|
|
4780
|
+
tip.classList.remove('thanked');
|
|
4781
|
+
void tip.offsetWidth; // restart the animation
|
|
4782
|
+
tip.classList.add('thanked');
|
|
4783
|
+
}
|
|
4784
|
+
postFeedback({ rating, sessionId: s.id, turnId: t.id, source: 'turn' }, {
|
|
4785
|
+
quiet: true,
|
|
4786
|
+
onFail: () => ((t.feedback = prev), paintRate(node, prev?.rating)),
|
|
4787
|
+
});
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
// After 👎: one optional line on what went wrong. The rating is already sent.
|
|
4791
|
+
function whyForm(s, t, close) {
|
|
4792
|
+
const f = el(`
|
|
4793
|
+
<form class="why">
|
|
4794
|
+
<textarea class="textarea" rows="2" placeholder="What went wrong? Optional, goes to the wisp team" maxlength="4000"></textarea>
|
|
4795
|
+
<div class="why-foot"><span class="muted xs">Sent with the harness and model, not your prompt or code.</span><button type="button" class="link xs" data-a="skip">Skip</button><button class="primary xs">Send</button></div>
|
|
4796
|
+
</form>`);
|
|
4797
|
+
const ta = $('textarea', f);
|
|
4798
|
+
f.onsubmit = (e) => {
|
|
4799
|
+
e.preventDefault();
|
|
4800
|
+
const text = ta.value.trim();
|
|
4801
|
+
if (!text) return close();
|
|
4802
|
+
close();
|
|
4803
|
+
postFeedback({ rating: 'down', text, sessionId: s.id, turnId: t.id, source: 'turn' }, { onFail: () => openFeedback({ sessionId: s.id, text, rating: 'down' }) });
|
|
4804
|
+
};
|
|
4805
|
+
ta.onkeydown = (e) => {
|
|
4806
|
+
if (e.key === 'Enter' && !e.shiftKey) (e.preventDefault(), f.requestSubmit());
|
|
4807
|
+
if (e.key === 'Escape') (e.stopPropagation(), close());
|
|
4808
|
+
};
|
|
4809
|
+
$('[data-a="skip"]', f).onclick = () => {
|
|
4810
|
+
close();
|
|
4811
|
+
toast(THANKS);
|
|
4812
|
+
};
|
|
4813
|
+
requestAnimationFrame(() => ta.focus());
|
|
4814
|
+
return f;
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
function openFeedback({ sessionId, text = '', rating = null } = {}) {
|
|
4818
|
+
if (!feedbackOn()) return toast('Feedback is turned off on this install.', { tone: 'bad' });
|
|
4819
|
+
const node = el(`
|
|
4820
|
+
<form class="modal fb-modal">
|
|
4821
|
+
<header><h2>Send feedback</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
|
|
4822
|
+
<div class="fb-rate"><span>How is wisp working for you?</span>
|
|
4823
|
+
${['up', 'down'].map((r) => `<button type="button" class="rate-btn" data-rate="${r}" aria-pressed="false" title="${r === 'up' ? 'Good' : 'Not good'}">${icon(`thumb-${r}`)}</button>`).join('')}
|
|
4824
|
+
</div>
|
|
4825
|
+
<textarea class="textarea" name="text" rows="5" maxlength="4000" placeholder="What works, what breaks, what you wish it did…"></textarea>
|
|
4826
|
+
<div class="ship-foot"><span class="muted xs">Goes to the wisp team with your wisp version and OS. Not your code or prompts. Tip: type <code>/feedback …</code> in any prompt box.</span><button class="primary sm" data-a="send">${icon('send')}Send</button></div>
|
|
4827
|
+
</form>`);
|
|
4828
|
+
const close = overlay(node);
|
|
4829
|
+
$('.close', node).onclick = close;
|
|
4830
|
+
const ta = $('textarea', node);
|
|
4831
|
+
const send = $('[data-a="send"]', node);
|
|
4832
|
+
ta.value = text;
|
|
4833
|
+
const paint = () => {
|
|
4834
|
+
paintRate(node, rating);
|
|
4835
|
+
send.disabled = !rating && !ta.value.trim();
|
|
4836
|
+
};
|
|
4837
|
+
$$('[data-rate]', node).forEach((b) => {
|
|
4838
|
+
b.onclick = () => {
|
|
4839
|
+
rating = rating === b.dataset.rate ? null : b.dataset.rate;
|
|
4840
|
+
paint();
|
|
4841
|
+
};
|
|
4842
|
+
});
|
|
4843
|
+
ta.oninput = paint;
|
|
4844
|
+
ta.onkeydown = (e) => {
|
|
4845
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) (e.preventDefault(), node.requestSubmit());
|
|
4846
|
+
};
|
|
4847
|
+
node.onsubmit = (e) => {
|
|
4848
|
+
e.preventDefault();
|
|
4849
|
+
const note = ta.value.trim();
|
|
4850
|
+
if (!rating && !note) return;
|
|
4851
|
+
close();
|
|
4852
|
+
postFeedback({ rating: rating || undefined, text: note, sessionId, source: 'dialog' }, { onFail: () => openFeedback({ sessionId, text: note, rating }) });
|
|
4853
|
+
};
|
|
4854
|
+
paint();
|
|
4855
|
+
requestAnimationFrame(() => ta.focus());
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4461
4858
|
// --- command palette ------------------------------------------------------------------
|
|
4462
4859
|
|
|
4463
4860
|
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
|
@@ -4545,6 +4942,7 @@ function paletteItems() {
|
|
|
4545
4942
|
{ group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
|
|
4546
4943
|
{ group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
|
|
4547
4944
|
);
|
|
4945
|
+
if (feedbackOn()) items.push({ group: a, label: 'Send feedback…', icon: 'thumb-up', keywords: 'feedback bug report idea suggestion thumbs contact team', run: () => openFeedback() });
|
|
4548
4946
|
|
|
4549
4947
|
for (const r of S.config.recipes || []) items.push({ group: 'Recipes', label: `Run: ${r.name}`, sub: r.prompt.replace(/\s+/g, ' ').slice(0, 80), icon: 'spark', keywords: `recipe ${r.prompt}`, run: () => runRecipe({ ...r, params: recipeParams(r.prompt) }) });
|
|
4550
4948
|
|
|
@@ -4642,6 +5040,13 @@ function connect() {
|
|
|
4642
5040
|
es.addEventListener('remote', () => $('.phone-modal')?.refresh());
|
|
4643
5041
|
es.addEventListener('machines', (e) => setMachines(JSON.parse(e.data)));
|
|
4644
5042
|
es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
|
|
5043
|
+
// A new harness or model Auto can use: it gets a few tasks before Auto relies on it.
|
|
5044
|
+
es.addEventListener('router', (e) => {
|
|
5045
|
+
const { added = [] } = JSON.parse(e.data || '{}');
|
|
5046
|
+
$('.router-modal')?.refresh();
|
|
5047
|
+
$('.settings-modal')?.paintAuto?.();
|
|
5048
|
+
if (added.length) toast(`New for Auto: <b>${added.map((a) => esc(a.label)).join('</b>, <b>')}</b>${added.some((a) => a.state === 'new') ? ' · it will try it on a few tasks' : ''}`, { action: { label: 'Auto settings', run: () => openSettings({ pane: 'auto' }) }, timeout: 8000 });
|
|
5049
|
+
});
|
|
4645
5050
|
es.addEventListener('preferences', () => {
|
|
4646
5051
|
$('.memory-modal')?.refresh();
|
|
4647
5052
|
$('.settings-modal')?.paintLearn?.();
|