wisp-agents 0.20.0 → 0.22.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +17 -8
  3. package/bin/wisp.js +14 -6
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +12 -7
  6. package/server/adapters/codex.js +13 -7
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +22 -2
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/mcp-wisp.js +2 -1
  18. package/server/openapi.js +28 -2
  19. package/server/remote.js +4 -2
  20. package/server/routes/machines.js +1 -1
  21. package/server/routes/proxy.js +6 -0
  22. package/server/routes/sessions.js +47 -10
  23. package/server/routes/settings.js +24 -38
  24. package/server/routes/ship.js +19 -24
  25. package/server/routes/uploads.js +2 -0
  26. package/server/runner.js +178 -12
  27. package/server/services/auto.js +10 -2
  28. package/server/services/launch.js +67 -7
  29. package/server/services/move.js +130 -0
  30. package/server/services/phone.js +20 -1
  31. package/server/services/places.js +150 -0
  32. package/server/services/ship.js +97 -10
  33. package/server/services/uploads.js +23 -4
  34. package/server/store.js +39 -6
  35. package/skills/wisp/SKILL.md +3 -1
  36. package/web/css/blocks.css +2 -0
  37. package/web/css/composer.css +23 -1
  38. package/web/css/feed.css +52 -1
  39. package/web/css/launcher.css +12 -0
  40. package/web/css/machines.css +4 -0
  41. package/web/css/modals.css +19 -0
  42. package/web/css/palette.css +6 -0
  43. package/web/css/ship.css +14 -0
  44. package/web/css/transcript.css +115 -0
  45. package/web/index.html +2 -0
  46. package/web/js/core/state.js +5 -2
  47. package/web/js/features/actions.js +42 -10
  48. package/web/js/features/btw.js +145 -0
  49. package/web/js/features/clones.js +7 -6
  50. package/web/js/features/commands.js +3 -1
  51. package/web/js/features/composer.js +162 -18
  52. package/web/js/features/feed.js +10 -10
  53. package/web/js/features/keys.js +2 -1
  54. package/web/js/features/launcher.js +121 -67
  55. package/web/js/features/place-menu.js +44 -0
  56. package/web/js/features/places.js +25 -5
  57. package/web/js/features/project-list.js +20 -5
  58. package/web/js/features/projects.js +16 -13
  59. package/web/js/features/settings.js +24 -3
  60. package/web/js/features/ship.js +66 -29
  61. package/web/js/features/slash.js +63 -0
  62. package/web/js/features/transcript.js +290 -0
  63. package/web/js/features/turns.js +101 -29
  64. package/web/js/slash-text.js +22 -0
  65. package/web/js/ui/kbd.js +1 -0
@@ -1,6 +1,6 @@
1
1
  /* ---------- per-item composer ---------- */
2
2
  .composer {
3
- padding: 6px 8px 8px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--line);
3
+ position: relative; padding: 6px 8px 8px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--line);
4
4
  transition: box-shadow 0.15s, border-color 0.15s;
5
5
  }
6
6
  .composer:focus-within { background: var(--surface); border-color: color-mix(in srgb, var(--accent) 55%, var(--line-2)); box-shadow: 0 0 0 3px var(--accent-soft); }
@@ -32,6 +32,22 @@
32
32
  .c-queue.on { color: var(--ink); background: var(--bg-2); }
33
33
  .c-done:hover { color: var(--good); }
34
34
 
35
+ /* "/btw …" in the box: a side question. The pickers are for the next follow-up, so they step back. */
36
+ .composer.is-btw .c-mode, .composer.is-btw .c-runner, .composer.is-btw .c-place, .composer.is-btw .c-queue { opacity: 0.35; }
37
+ .c-side { font-weight: 600; color: var(--accent-ink); }
38
+
39
+ /* the / menu (features/slash.js): opens above the box, like the box's own popovers */
40
+ .slash-menu {
41
+ position: absolute; left: 8px; bottom: calc(100% + 6px); z-index: 30; min-width: 280px; max-width: calc(100% - 16px); padding: 5px;
42
+ border-radius: 12px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-2); animation: pop 0.18s var(--ease);
43
+ }
44
+ .slash-item { display: flex; align-items: baseline; gap: 8px; width: 100%; padding: 7px 10px; border-radius: 8px; text-align: left; font-size: 13px; color: var(--ink-2); }
45
+ .slash-item b { font: 600 12.5px var(--mono); color: var(--ink); }
46
+ .slash-args { font: 12px var(--mono); color: var(--faint); }
47
+ .slash-hint { margin-left: auto; padding-left: 12px; font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
48
+ .slash-item.on, .slash-item:hover { background: var(--bg-2); }
49
+ .slash-item.on b { color: var(--accent-ink); }
50
+
35
51
  /* attachments tray */
36
52
  .tray { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 0 8px; }
37
53
  .att {
@@ -59,6 +75,12 @@
59
75
  .c-runner.changed { padding-right: 8px; }
60
76
  .c-runner.changed .rb-logo { margin-right: 4px; }
61
77
  .c-runner:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
78
+ /* where the next follow-up runs: a quiet icon, the computer's name once it's switched */
79
+ .c-place { display: inline-flex; align-items: center; gap: 5px; height: 30px; min-width: 30px; padding: 0 7px; border-radius: 8px; font-size: 12px; color: var(--muted); transition: background 0.15s, color 0.15s; }
80
+ .c-place .i { width: 15px; height: 15px; }
81
+ .c-place:hover, .c-place[aria-expanded='true'], .c-place.changed { background: var(--bg-2); color: var(--ink); }
82
+ .c-place.changed { padding-right: 9px; color: var(--accent); }
83
+ .c-place:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
62
84
  .cr-label { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
63
85
 
64
86
  /* ---------- task mentions (features/mentions.js) ---------- */
package/web/css/feed.css CHANGED
@@ -26,7 +26,8 @@
26
26
  .toggle-done { cursor: pointer; user-select: none; }
27
27
  .toggle-done:hover { color: var(--ink-2); }
28
28
  .toggle-done .chev { width: 12px; height: 12px; transition: transform 0.2s; }
29
- .f-group.done:not(.collapsed) .chev { transform: rotate(90deg); }
29
+ /* Only the group's own heading: the cards inside have chevrons of their own. */
30
+ .f-group.done:not(.collapsed) > .toggle-done .chev { transform: rotate(90deg); }
30
31
  /* Folded groups are just a row of headings: stack them tight instead of leaving a section gap each. */
31
32
  .f-group.collapsed { margin-bottom: 2px; }
32
33
  .f-group.collapsed h4 { margin: 0; }
@@ -78,6 +79,9 @@
78
79
  font-size: 11px; font-weight: 500; padding: 0 7px; border-radius: 5px; line-height: 19px; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis;
79
80
  color: color-mix(in srgb, var(--pc) 65%, var(--ink)); background: color-mix(in srgb, var(--pc) 10%, transparent);
80
81
  }
82
+ /* a task in several folders: the others after its own, each in its colour */
83
+ .q-proj.multi { max-width: 180px; }
84
+ .q-also { margin-left: 4px; color: color-mix(in srgb, var(--pc) 65%, var(--ink)); }
81
85
  .q-time { font-size: 11.5px; color: var(--faint); min-width: 26px; text-align: right; font-variant-numeric: tabular-nums; }
82
86
  .q-queued { font-size: 11px; color: var(--accent-ink); font-weight: 600; }
83
87
  /* one-click done on collapsed review rows; revealed on hover/focus */
@@ -181,8 +185,55 @@
181
185
  .log { max-height: 340px; overflow-y: auto; margin-top: 12px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--line); }
182
186
  .w-actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
183
187
  .w-queued { font-size: 12px; color: var(--accent-ink); font-weight: 500; }
188
+ /* the /btw tip (features/btw.js): until it's been used once */
189
+ .w-tip { display: inline-flex; align-items: center; gap: 2px; min-width: 0; animation: fade-in 0.4s 0.6s var(--ease) both; }
190
+ .w-tip-go { display: inline-flex; align-items: center; gap: 6px; min-width: 0; font-size: 12px; color: var(--muted); }
191
+ .w-tip-go:hover { color: var(--ink); }
192
+ .w-tip-k { color: var(--faint); }
193
+ .w-tip-go code { flex: none; padding: 1px 5px; border-radius: 5px; background: var(--bg-2); font: 500 11.5px var(--mono); color: var(--ink-2); }
194
+ .w-tip-t { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
195
+ .w-tip-x { flex: none; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; color: var(--faint); opacity: 0; transition: opacity 0.15s; }
196
+ .w-tip-x .i { width: 11px; height: 11px; }
197
+ .w-tip:hover .w-tip-x, .w-tip-x:focus-visible { opacity: 1; }
198
+ .w-tip-x:hover { background: var(--bg-2); color: var(--ink); }
199
+ @media (hover: none) { .w-tip-x { opacity: 1; } }
200
+ .w-actions > .peek { flex: none; white-space: nowrap; }
201
+ /* No room beside Show all activity and Stop on a phone: the tip gets its own line under them. */
202
+ @media (max-width: 720px) {
203
+ .w-actions:has(.w-tip) { flex-wrap: wrap; row-gap: 8px; }
204
+ .w-tip { order: 1; flex-basis: 100%; }
205
+ }
184
206
  @keyframes tick-in { from { opacity: 0; transform: translateY(-4px); } }
185
207
 
208
+ /* /btw side questions (features/btw.js): plain hairline cards on the turn they were asked during */
209
+ .btw-list { display: grid; gap: 8px; margin-top: 14px; }
210
+ .btw-list:empty { display: none; }
211
+ .btw { position: relative; padding: 10px 40px 11px 14px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--line); }
212
+ .btw.new { animation: rise 0.35s var(--ease) both; }
213
+ .btw.landed > :is(.btw-a, .btw-err) { animation: fade-in 0.4s var(--ease) both; }
214
+ .btw-q { display: flex; align-items: baseline; gap: 8px; font-size: 13.5px; line-height: 20px; color: var(--ink); }
215
+ .btw-tag {
216
+ flex: none; display: inline-flex; align-items: center; gap: 4px; padding: 1px 6px 1px 4px; border-radius: 5px; cursor: default;
217
+ font: 600 11px/18px var(--sans); color: var(--accent-ink); background: var(--accent-soft); align-self: flex-start; margin-top: 1px;
218
+ }
219
+ .btw-tag .i { width: 12px; height: 12px; }
220
+ .btw-text { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-weight: 500; }
221
+ .btw-atts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
222
+ .btw-a { margin-top: 6px; font-size: 13.5px; }
223
+ .btw-a p, .btw-a ul, .btw-a ol, .btw-a pre { margin-bottom: 8px; }
224
+ .btw-wait { display: flex; align-items: center; gap: 8px; margin-top: 7px; font-size: 12.5px; color: var(--muted); }
225
+ .btw-wait .elapsed { margin-left: auto; color: var(--faint); font-variant-numeric: tabular-nums; }
226
+ .btw-dots { display: inline-flex; gap: 3px; }
227
+ .btw-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
228
+ .btw-dots i:nth-child(2) { animation-delay: 0.2s; }
229
+ .btw-dots i:nth-child(3) { animation-delay: 0.4s; }
230
+ .btw-err { display: flex; align-items: baseline; gap: 6px; margin-top: 6px; font-size: 12.5px; color: var(--bad); }
231
+ .btw-err .i { flex: none; width: 13px; height: 13px; translate: 0 2px; }
232
+ .btw-x { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; border-radius: 7px; color: var(--faint); opacity: 0; transition: opacity 0.15s, background 0.15s, color 0.15s; }
233
+ .btw-x .i { width: 12px; height: 12px; }
234
+ .btw:hover .btw-x, .btw-x:focus-visible, .btw.st-running .btw-x { opacity: 1; }
235
+ @media (hover: none) { .btw-x { opacity: 1; } }
236
+
186
237
  /* ask card (mid-run question) */
187
238
  .ask-card, .b-question {
188
239
  position: relative; padding: 16px 18px; border-radius: var(--radius); margin: 4px 0 14px;
@@ -41,6 +41,18 @@
41
41
  .lp-chip.more:hover, .lp-chip.more.open { color: var(--ink); border-color: var(--line-2); }
42
42
  .lp-chip.more .i { width: 12px; height: 12px; }
43
43
  .l-projects .lp-chip[hidden] { display: none; }
44
+ /* one task, several folders: a small badge on a chip's corner adds it (+) or leaves it out (×) */
45
+ .l-projects .lp-chip { position: relative; }
46
+ .lp-also {
47
+ position: absolute; top: -6px; right: -5px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
48
+ background: var(--surface); border: 1px solid var(--line-2); color: var(--muted); box-shadow: var(--shadow-1);
49
+ opacity: 0; transform: scale(0.6); transition: opacity 0.12s, transform 0.12s, color 0.12s, background 0.12s; pointer-events: none;
50
+ }
51
+ .lp-also .i { width: 10px; height: 10px; }
52
+ .lp-chip:is(:hover, :focus-visible) .lp-also { opacity: 1; transform: none; pointer-events: auto; }
53
+ .lp-also:hover { color: #fff; background: var(--accent); border-color: var(--accent); }
54
+ .lp-also.x:hover { background: var(--bad); border-color: var(--bad); }
55
+ @media (hover: none) { .lp-also:not(.x) { opacity: 1; transform: none; pointer-events: auto; } }
44
56
  /* narrow, with "+N" (whose list has No folder too): it goes icon-only unless picked */
45
57
  @container launcher (max-width: 520px) { .l-projects:has(.more:not([hidden])) .lp-chip.none:not(.on) span { display: none; } }
46
58
  /* recipes: one-click prompts under the launch bar */
@@ -38,6 +38,10 @@
38
38
  .wm-row:hover .wm-only { opacity: 1; }
39
39
  .wm-only:hover { background: var(--bg-2); color: var(--ink); }
40
40
  .wm-sep { height: 1px; margin: 5px 4px; background: var(--line); }
41
+ .wm-note { margin: 0 10px 6px; max-width: 300px; font-size: 12px; line-height: 1.4; color: var(--muted); }
42
+ .wm-note.warn { color: var(--warn); }
43
+ .place-menu .wm-check { border-radius: 50%; }
44
+ .place-menu .wm-row[disabled] { opacity: 0.45; cursor: default; }
41
45
  .p-chip .p-where { width: 11px; height: 11px; color: var(--muted); }
42
46
  .projects-modal .where { margin-bottom: 12px; }
43
47
  .projects-modal .where .i { width: 13px; height: 13px; color: var(--muted); }
@@ -22,6 +22,21 @@
22
22
  .rr-files { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 2px 6px; }
23
23
  .rr-file { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 7px; font-size: 12px; color: var(--ink-2); background: var(--bg-2); }
24
24
  .rr-file .i { width: 12px; height: 12px; color: var(--muted); }
25
+ /* the bar never pushes Rerun out of the box: the harness name gives way first (the logo still says
26
+ which), then the model; on a narrow dialog the mode and place labels fold to their icons */
27
+ .rerun-modal { width: min(660px, 100%); }
28
+ .rr-box { container: rerun / inline-size; }
29
+ .rr-box .l-bar > .runner-btn { flex: 0 1 auto; min-width: 0; overflow: hidden; }
30
+ .rr-box .runner-btn :is(.rb-logo, .rb-e, .chev) { flex: none; }
31
+ .rr-box .runner-btn .rb-h { flex: 0 100 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
32
+ .rr-box .runner-btn .rb-m { flex: 0 1 auto; min-width: 0; }
33
+ .rr-box .where-btn span { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
34
+ @container rerun (max-width: 520px) {
35
+ .rr-box .l-bar :is(.mode-btn, .where-btn) span, .rr-box .runner-btn .rb-h { display: none; }
36
+ .rr-box .runner-btn .rb-m { margin-left: 0; }
37
+ .rr-box .runner-btn .rb-m::before { display: none; }
38
+ }
39
+ @container rerun (max-width: 400px) { .rr-box .runner-btn :is(.rb-m, .chev) { display: none; } }
25
40
  .overlay:not([hidden]) ~ :is(.picker, .menu) { z-index: 55; }
26
41
  .add-row { display: flex; gap: 8px; }
27
42
  .path-input { position: relative; flex: 1; }
@@ -47,6 +62,10 @@
47
62
  .discover h4 .i { width: 14px; height: 14px; }
48
63
  .discover h4 .muted { font-weight: 400; }
49
64
  .prow.cloning .ppath { color: var(--muted); }
65
+ /* Where a project is checked out: one quiet tag per place. */
66
+ .ppath:has(.pplace) { display: flex; gap: 8px; font-family: inherit; }
67
+ .pplace { display: inline-flex; align-items: center; gap: 4px; }
68
+ .pplace .ic { width: 11px; height: 11px; }
50
69
  .discover h4 .link { margin-left: auto; font-size: 12px; }
51
70
  .prow.cloning .orb.live { margin: 0; }
52
71
  .hint .clonedir { width: 220px; height: 24px; padding: 0 7px; margin: 0 4px; border-radius: 7px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 12px; outline: 0; }
@@ -73,6 +73,12 @@
73
73
  .pm-row .p-run { flex: none; }
74
74
  .pm-row .pm-check { flex: none; width: 14px; height: 14px; color: var(--accent); visibility: hidden; }
75
75
  .pm-row.on .pm-check { visibility: visible; }
76
+ /* the launcher's list: + adds a project to the task, ✓ shows it's in */
77
+ .pm-also { flex: none; display: grid; place-items: center; width: 20px; height: 20px; margin: -2px -4px -2px 0; border-radius: 6px; color: var(--faint); }
78
+ .pm-also .i { width: 13px; height: 13px; }
79
+ .pm-also:hover { color: var(--ink); background: var(--surface-2); }
80
+ .pm-row.in .pm-also { color: var(--accent); }
81
+ .pm-row.in .pm-name { font-weight: 500; color: var(--ink); }
76
82
  .pm-x .i { width: 14px; height: 14px; color: var(--muted); }
77
83
  .pm-empty { padding: 18px 10px; text-align: center; font-size: 13px; color: var(--muted); }
78
84
  .menu .danger:hover { color: var(--bad); background: var(--del-bg); }
package/web/css/ship.css CHANGED
@@ -42,7 +42,10 @@
42
42
  .pill.st-running { --tone: var(--accent); }
43
43
  .it-meta .m.branch, .it-meta .m:has(> .i) { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
44
44
  .it-meta .m.branch { font: 11px var(--mono); color: var(--ink-2); }
45
+ .it-meta .m.also { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-2); }
45
46
  .it-meta .m .i { width: 12px; height: 12px; flex: none; color: var(--faint); }
47
+ .it-meta .m.moved-to { color: var(--accent-ink); cursor: pointer; }
48
+ .it-meta .m.moved-to:hover { text-decoration: underline; }
46
49
  .it-meta .m.m-plan, .it-meta .m.m-plan .i { color: var(--accent-ink); }
47
50
  .it-meta .m.m-auto, .it-meta .m.m-auto .i { color: var(--warn); }
48
51
 
@@ -60,6 +63,14 @@
60
63
  .pr-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--tone); flex: none; }
61
64
  .pr-strip.pr-pending .pr-dot { animation: pulse 1.4s ease-in-out infinite; }
62
65
  .pr-num { font-weight: 600; color: var(--ink); white-space: nowrap; }
66
+ /* a task in several repos: their PRs after the first, each with its own tone */
67
+ .pr-also { --tone: var(--good); display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 1px 7px; border-radius: 6px; font-weight: 500; color: var(--ink); white-space: nowrap; background: color-mix(in srgb, var(--tone) 10%, transparent); }
68
+ .pr-also i { width: 6px; height: 6px; border-radius: 50%; background: var(--tone); }
69
+ .pr-also:hover { background: color-mix(in srgb, var(--tone) 18%, transparent); }
70
+ .pr-also.pr-pending { --tone: var(--warn); }
71
+ .pr-also.pr-fail { --tone: var(--bad); }
72
+ .pr-also.pr-merged { --tone: #8250df; }
73
+ .pr-also.pr-closed { --tone: var(--faint); }
63
74
  .pr-line { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
64
75
  .pr-strip .ghost .i { width: 13px; height: 13px; }
65
76
  .pr-strip .pr-merge { color: var(--good); font-weight: 500; }
@@ -74,12 +85,15 @@
74
85
  .ship-route code { font: 12px var(--mono); padding: 1px 6px; border-radius: 6px; background: var(--bg-2); }
75
86
  .ship-route .i { width: 13px; height: 13px; color: var(--faint); }
76
87
  .ship-route .muted { margin-left: auto; }
88
+ .ship-route .ship-repo { font-weight: 600; color: var(--ink); }
89
+ .ship-route + .ship-route { margin-top: -6px; }
77
90
  .ship-field { display: flex; flex-direction: column; gap: 6px; margin: 12px 0 0; }
78
91
  .ship-field > span { font-size: 12.5px; font-weight: 500; color: var(--ink-2); }
79
92
  .ship-field select { width: 100%; }
80
93
  .ship-check { display: flex; margin-top: 10px; }
81
94
  .ship-foot { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
82
95
  .ship-foot > .muted { margin-right: auto; }
96
+ .ship-foot > .primary { flex: none; white-space: nowrap; }
83
97
  .ship-foot code { font-size: 11px; }
84
98
  .hint.bad { color: var(--bad); }
85
99
 
@@ -0,0 +1,115 @@
1
+ /* ---------- full transcript (features/transcript.js) ----------
2
+ A timeline under the prompt: a hairline rail with each step's icon on it, the agent's messages as
3
+ prose, and the presentation after the rail ends. */
4
+
5
+ .ghost.tx-toggle.on { color: var(--accent-ink); background: var(--accent-soft); }
6
+
7
+ .tx { display: flex; flex-direction: column; }
8
+ .tx-head { display: flex; align-items: center; gap: 6px; min-height: 22px; margin: -6px 0 8px; font-size: 12px; color: var(--faint); white-space: nowrap; }
9
+ .tx-title { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; color: var(--muted); }
10
+ .tx-count { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
11
+ .tx-title .i { width: 14px; height: 14px; }
12
+ .tx-title + .tx-count::before { content: '·'; margin-right: 6px; }
13
+ .tx-jump { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; }
14
+ .tx-jump .i { width: 12px; height: 12px; transform: rotate(90deg); }
15
+
16
+ .tx-list { position: relative; list-style: none; margin: 0 0 18px; padding: 0; }
17
+ .tx-list::before { content: ''; position: absolute; left: 11.5px; top: 12px; bottom: 15px; width: 1px; background: var(--line-2); }
18
+ /* Mid-run the rail fades out into the working panel: more is coming. */
19
+ .tx.live .tx-list { margin-bottom: 10px; }
20
+ .tx.live .tx-list::before { bottom: -10px; background: linear-gradient(var(--line-2) calc(100% - 28px), transparent); }
21
+
22
+ .tx-step, .tx-fold, .tx-end, .tx-msg { position: relative; padding-left: 28px; }
23
+ .tx-ic {
24
+ position: absolute; left: 2px; top: 5px; width: 20px; height: 20px; display: grid; place-items: center;
25
+ border-radius: 50%; background: var(--surface); color: var(--faint);
26
+ }
27
+ .tx-ic .i { width: 14px; height: 14px; }
28
+
29
+ /* a step: one line, opening to its command and output, its diff, or its error */
30
+ .tx-row {
31
+ display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; min-height: 30px; padding: 4px 8px;
32
+ border-radius: 7px; font-size: 13px; color: var(--ink-2); text-align: left; outline: 0;
33
+ }
34
+ .tx-row[role='button'] { cursor: pointer; transition: background 0.15s, color 0.15s; }
35
+ .tx-row[role='button']:hover, .tx-row[role='button']:focus-visible, .tx-step.open > .tx-row { background: var(--bg-2); color: var(--ink); }
36
+ .tx-row[role='button']:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft); }
37
+ /* Both give way on a narrow row, the command three times faster. (Shrink factors that sum under 1
38
+ would leave part of the overflow in place.) */
39
+ .tx-label { flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
40
+ .tx-cmd { flex: 0 3 auto; min-width: 0; font: 12px/1.4 var(--mono); color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
41
+ .tx-row .nums { margin-left: auto; }
42
+ .tx-t { margin-left: auto; flex: none; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; opacity: 0; transition: opacity 0.15s; }
43
+ .tx-row .nums + .tx-t { margin-left: 0; }
44
+ .tx-step:hover .tx-t, .tx-msg:hover .tx-t, .tx-step.open .tx-t { opacity: 1; }
45
+ .tx-open {
46
+ flex: none; display: grid; place-items: center; width: 22px; height: 22px; margin: -2px -4px -2px 0; border-radius: 6px;
47
+ color: var(--faint); opacity: 0; transition: opacity 0.15s, background 0.15s, color 0.15s;
48
+ }
49
+ .tx-open .i { width: 13px; height: 13px; }
50
+ .tx-step:hover .tx-open, .tx-open:focus-visible { opacity: 1; }
51
+ .tx-open:hover { color: var(--ink); background: var(--surface); }
52
+ .tx-row .chev { flex: none; width: 12px; height: 12px; color: var(--faint); transition: transform 0.2s var(--ease); }
53
+ .tx-step.open > .tx-row .chev { transform: rotate(90deg); }
54
+ .tx-step.loading > .tx-row { opacity: 0.6; cursor: progress; }
55
+
56
+ .tx-step.k-error .tx-ic, .tx-step.k-error .tx-label { color: var(--bad); }
57
+ .tx-step:is(.k-ask, .k-wait) .tx-ic, .tx-step.k-ask .tx-label { color: var(--warn); }
58
+ .tx-step.k-answer .tx-ic { color: var(--good); }
59
+ .tx-step.by-wisp .tx-ic { color: var(--accent); }
60
+ .tx-step.by-wisp .tx-label { color: var(--muted); }
61
+ /* What you were asked and answered, and plan lines, read in full. */
62
+ .tx-step:is(.k-ask, .k-answer, .k-plan, .k-check, .k-background, .by-wisp) .tx-label { white-space: normal; }
63
+
64
+ .tx-more { margin: 2px 0 8px 8px; animation: rise 0.3s var(--ease); }
65
+ .tx-out {
66
+ margin: 0; padding: 10px 12px; max-height: 340px; overflow: auto; border-radius: 8px;
67
+ background: var(--surface-2); border: 1px solid var(--line); font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word;
68
+ }
69
+ .tx-cmdline, .tx-res { display: block; }
70
+ .tx-cmdline { color: var(--ink); }
71
+ .tx-cmdline b { color: var(--faint); font-weight: 500; }
72
+ .tx-res { color: var(--muted); }
73
+ .tx-cmdline + .tx-res { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--line-2); }
74
+ .tx-out.err { color: var(--bad); background: var(--del-bg); border-color: color-mix(in srgb, var(--bad) 22%, var(--line)); }
75
+ .tx-none { margin: 0; font-size: 12.5px; color: var(--muted); }
76
+ .tx-diff .presentation { gap: 8px; }
77
+ .tx-diff .b-diff.bare { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); overflow: hidden; }
78
+
79
+ /* a message: the agent's own words, on a dot of the rail */
80
+ .tx-msg { margin: 8px 0 12px; padding-top: 2px; padding-right: 64px; }
81
+ .tx-dot { position: absolute; left: 8px; top: 9px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--surface); }
82
+ .tx-msg .prose { padding-left: 8px; font-size: 14px; color: var(--ink); }
83
+ .tx-msg .tx-t { position: absolute; right: 4px; top: 4px; }
84
+ .tx-msg:first-child { margin-top: 0; }
85
+
86
+ /* a long run of steps folds its middle */
87
+ .tx-fold .tx-row { color: var(--muted); cursor: pointer; transition: background 0.15s, color 0.15s; }
88
+ .tx-fold .tx-row:hover { background: var(--bg-2); color: var(--ink); }
89
+
90
+ .tx-end { display: flex; align-items: center; min-height: 30px; padding-left: 36px; font-size: 12.5px; font-weight: 500; color: var(--muted); }
91
+ .tx-end .tx-ic { color: var(--good); }
92
+ .tx-end.st-error .tx-ic { color: var(--bad); }
93
+ .tx-end.st-stopped .tx-ic { color: var(--warn); }
94
+
95
+ .tx-step.new, .tx-msg.new { animation: tick-in 0.45s var(--ease); }
96
+
97
+ /* The working panel under a live transcript: status and Stop on one line, a question under them. */
98
+ .w-panel.tx-panel { padding: 10px 14px; gap: 12px; }
99
+ .tx-panel .w-orb { padding-top: 0; align-self: flex-start; }
100
+ .tx-panel .w-body { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 12px; }
101
+ .tx-panel .w-status { grid-column: 1; grid-row: 1; flex-wrap: wrap; row-gap: 2px; margin: 0; }
102
+ .tx-panel .w-status > * { white-space: nowrap; }
103
+ .tx-panel .w-actions { grid-column: 2; grid-row: 1; margin: 0; }
104
+ .tx-panel .w-body > :not(.w-status, .w-actions) { grid-column: 1 / -1; }
105
+ .tx-panel .ask-slot:not(:empty) { margin-top: 10px; }
106
+
107
+ @media (max-width: 720px) {
108
+ /* The /btw tip doesn't fit beside the status: the row with it and Stop goes under it. */
109
+ .tx-panel .w-body:has(.w-tip) { display: block; }
110
+ .tx-panel .w-body:has(.w-tip) .w-actions { margin-top: 8px; }
111
+ .tx-title { display: none; }
112
+ .tx-cmd { display: none; }
113
+ .tx-msg { padding-right: 8px; }
114
+ .tx-msg .tx-t { display: none; }
115
+ }
package/web/index.html CHANGED
@@ -22,6 +22,7 @@
22
22
  <link rel="stylesheet" href="/css/automations.css">
23
23
  <link rel="stylesheet" href="/css/subagents.css">
24
24
  <link rel="stylesheet" href="/css/auto.css">
25
+ <link rel="stylesheet" href="/css/transcript.css">
25
26
  </head>
26
27
  <body>
27
28
  <svg width="0" height="0" style="position:absolute" aria-hidden="true">
@@ -66,6 +67,7 @@
66
67
  <symbol id="i-stats" viewBox="0 0 24 24"><path d="M5 19V11M12 19V5M19 19v-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
67
68
  <symbol id="i-rerun" viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 11-1.8-4.9M20 4v4h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
68
69
  <symbol id="i-replay" viewBox="0 0 24 24"><path d="M4.5 12a7.5 7.5 0 101.8-4.9M4 4v4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
70
+ <symbol id="i-transcript" viewBox="0 0 24 24"><path d="M6 7.5v9" stroke="currentColor" stroke-width="1.3" stroke-dasharray="1.6 1.8" stroke-linecap="round"/><circle cx="6" cy="5.5" r="1.9" fill="currentColor"/><circle cx="6" cy="12" r="1.9" fill="currentColor"/><circle cx="6" cy="18.5" r="1.9" fill="currentColor"/><path d="M11 5.5h9M11 12h6.5M11 18.5h8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
69
71
  <symbol id="i-text" viewBox="0 0 24 24"><path d="M5 7h14M5 12h14M5 17h9" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
70
72
  <symbol id="i-back" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
71
73
  <symbol id="i-update" viewBox="0 0 24 24"><path d="M12 16V4m-5 5l5-5 5 5M5 20h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
@@ -30,15 +30,18 @@ export const S = {
30
30
  drafts: new Map(),
31
31
  replyModes: new Map(), // session id → the mode its next follow-up switches to
32
32
  replyRunners: new Map(), // session id → { agent, model, effort } its next follow-up switches to
33
+ replyPlaces: new Map(), // session id → the place ('' = this computer) its next follow-up moves it to
33
34
  };
34
35
  export const saveOpen = () => save('wisp.open', [...S.open]);
35
36
  export const setAgents = (list) => ((S.agents = list), save('wisp.agents', list));
36
37
 
37
38
  // Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
38
- // each in the project picked for it (launch.picks[place]), and show up as one task.
39
+ // all in the project picked (launch.picks['']; any project runs on any place), and show up as one
40
+ // task. A run can work in more projects too (launch.also['']): one agent, several folders.
39
41
  export const launch = {
40
42
  project: load('wisp.launchProject', null), // the project picked in the first place
41
43
  where: load('wisp.launchWhere', ['']),
42
44
  picks: load('wisp.launchPicks', {}),
45
+ also: load('wisp.launchAlso', {}),
43
46
  };
44
- export const saveLaunch = () => (save('wisp.launchWhere', launch.where), save('wisp.launchPicks', launch.picks), save('wisp.launchProject', launch.project));
47
+ export const saveLaunch = () => (save('wisp.launchWhere', launch.where), save('wisp.launchPicks', launch.picks), save('wisp.launchAlso', launch.also), save('wisp.launchProject', launch.project));
@@ -17,7 +17,10 @@ import { collapse, expand, handOff, renderFeed, setFocus } from './feed.js';
17
17
  import { launchBehind } from './launcher.js';
18
18
  import { copyTaskId, mentionBox } from './mentions.js';
19
19
  import { runnerPicker } from './runner-picker.js';
20
- import { agentName, DEFAULT_MODE, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
20
+ import { connectMachine } from './machines.js';
21
+ import { placeMenu } from './place-menu.js';
22
+ import { machineById, pathAt, placeName, projectById } from './places.js';
23
+ import { agentById, agentName, DEFAULT_MODE, effortsFor, harnessesAt, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
21
24
  import { feedAnchor, keepInPlace } from './scroll.js';
22
25
  import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
23
26
 
@@ -221,15 +224,18 @@ export async function deleteSession(id) {
221
224
  });
222
225
  }
223
226
 
224
- // Launch the task again as a new session in the same folder: same prompt and attachments,
225
- // optionally edited, on whichever harness, model and thinking level you pick. The original stays.
227
+ // Launch the task again as a new session in the same project: same prompt and attachments,
228
+ // optionally edited, on whichever computer, harness, model and thinking level you pick. The
229
+ // original stays. Another computer runs it in its own checkout (cloned there if need be).
226
230
  export async function rerun(id) {
227
231
  const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(toastError));
228
232
  const first = s?.turns?.[0];
229
233
  if (!first?.prompt) return toast('Nothing to rerun — this session has no prompt.', { tone: 'bad' });
230
- const has = (a) => S.agents.some((x) => x.id === a && x.available);
231
- let agent = has(s.agent) ? s.agent : S.agents.find((x) => x.available)?.id || s.agent;
232
- const models = S.agents.find((x) => x.id === agent)?.models || [];
234
+ let place = s.machine?.id || '';
235
+ const here = () => harnessesAt([place]).filter((x) => x.available);
236
+ const has = (a) => here().some((x) => x.id === a);
237
+ let agent = has(s.agent) ? s.agent : here()[0]?.id || s.agent;
238
+ const models = agentById(agent)?.models || [];
233
239
  let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
234
240
  let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
235
241
  let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || DEFAULT_MODE.id;
@@ -242,13 +248,14 @@ export async function rerun(id) {
242
248
  <textarea class="rr-prompt" rows="3" spellcheck="false"></textarea>
243
249
  ${files.length ? `<div class="rr-files">${files.map((f) => `<span class="rr-file">${icon('clip')}${esc(f.name)}</span>`).join('')}</div>` : ''}
244
250
  <div class="l-bar">
251
+ <button class="tog-btn where-btn" data-a="place" aria-haspopup="menu" ${S.machines.length ? '' : 'hidden'}></button>
245
252
  <button class="mode-btn" data-a="mode" aria-haspopup="menu"></button>
246
253
  <button class="runner-btn" data-a="runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
247
254
  <span class="grow"></span>
248
255
  <button class="primary sm" data-a="go">${icon('rerun')}Rerun</button>
249
256
  </div>
250
257
  </div>
251
- <p class="hint">Starts a new session in <code>${esc(s.cwd)}</code>. “${esc(s.title)}” is kept as it is.</p>
258
+ <p class="hint rr-hint"></p>
252
259
  </div>`);
253
260
  const close = overlay(node);
254
261
  $('.close', node).onclick = close;
@@ -264,12 +271,37 @@ export async function rerun(id) {
264
271
  };
265
272
  const runnerBtn = $('[data-a="runner"]', node);
266
273
  const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
274
+ // Where it runs: where it ran, unless you pick another computer.
275
+ const placeBtn = $('[data-a="place"]', node);
276
+ const project = projectById(S.sessions.get(id)?.projectId);
277
+ const paintPlace = () => {
278
+ placeBtn.classList.toggle('on', !!place);
279
+ placeBtn.innerHTML = `${icon(place ? 'server' : 'laptop')}<span>${esc(placeName(place))}</span>${icon('chevron', 'chev')}`;
280
+ placeBtn.title = 'Run it on another computer';
281
+ const fresh = project && !pathAt(project, place);
282
+ $('.rr-hint', node).innerHTML = `Starts a new session in <code>${esc(project && !fresh ? pathAt(project, place) : s.cwd)}</code>${S.machines.length ? ` on <b>${esc(placeName(place))}</b>` : ''}${fresh ? `, in a checkout wisp clones there first` : ''}. “${esc(s.title)}” is kept as it is.`;
283
+ };
267
284
  paintMode();
268
285
  paintRunner();
286
+ paintPlace();
287
+ placeBtn.onclick = () =>
288
+ placeMenu(placeBtn, { title: 'Rerun on', current: place, from: s.machine?.id || '' }, (k) => {
289
+ place = k;
290
+ if (k && machineById(k)?.state === 'off') connectMachine(k);
291
+ // The harness has to be there too: keep it if it is, else the first one that is.
292
+ if (!has(agent) && here().length) {
293
+ agent = here()[0].id;
294
+ model = savedModel(agent);
295
+ effort = savedEffort(agent, model);
296
+ paintRunner();
297
+ }
298
+ paintPlace();
299
+ ta.focus();
300
+ });
269
301
  modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
270
302
  // Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
271
303
  runnerBtn.onclick = () =>
272
- runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
304
+ runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, places: [place], auto: true }, (a, m, e) => {
273
305
  [agent, model, effort] = [a, m, e];
274
306
  paintRunner();
275
307
  });
@@ -279,8 +311,8 @@ export async function rerun(id) {
279
311
  if (!prompt) return ta.focus();
280
312
  close();
281
313
  try {
282
- const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
283
- const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
314
+ const body = { prompt, cwd: s.cwd, also: s.also?.map((a) => a.cwd), machine: place, agent, mode, model, effort, attachments: files.map((f) => f.id) };
315
+ const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort }, machine: place }, () => api('/sessions', { method: 'POST', body }));
284
316
  setFocus(next.id);
285
317
  } catch (err) {
286
318
  toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });