jarvis-hq 0.0.5 → 0.0.7

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.
@@ -5,7 +5,7 @@ exports.html = `<!DOCTYPE html>
5
5
  <html lang="en">
6
6
  <head>
7
7
  <meta charset="UTF-8" />
8
- <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
9
9
  <title>Jarvis</title>
10
10
  <style>
11
11
  :root {
@@ -349,12 +349,25 @@ exports.html = `<!DOCTYPE html>
349
349
  .msg.user .who { text-align: right; }
350
350
 
351
351
  .tool {
352
- display: flex; align-items: center; gap: 9px; margin-top: 7px;
352
+ display: flex; align-items: center; gap: 9px; margin-top: 7px; width: 100%; text-align: left;
353
353
  background: var(--surface-2); border-radius: 10px; padding: 7px 11px;
354
354
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted);
355
+ cursor: default;
356
+ }
357
+ .tool b { color: var(--text); font-weight: 600; flex: none; align-self: flex-start; }
358
+ .tool-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
359
+ /* Only chips with more to show become interactive; the rest stay plain labels. */
360
+ .tool.more { cursor: pointer; }
361
+ .tool.more:hover { background: var(--border); }
362
+ .tool-chev { display: none; flex: none; align-self: flex-start; color: var(--faint); font-size: 15px; line-height: 1.3; transition: transform .15s ease; }
363
+ .tool.more .tool-chev { display: block; }
364
+ @media (prefers-reduced-motion: reduce) { .tool-chev { transition: none; } }
365
+ .tool.open { align-items: flex-start; }
366
+ .tool.open .tool-chev { transform: rotate(90deg); }
367
+ .tool.open .tool-text {
368
+ white-space: pre-wrap; overflow-wrap: anywhere; text-overflow: clip; cursor: auto;
369
+ max-height: 40vh; overflow-y: auto; overscroll-behavior: contain;
355
370
  }
356
- .tool b { color: var(--text); font-weight: 600; flex: none; }
357
- .tool span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
358
371
  .note { color: var(--faint); font-size: 12.5px; text-align: center; font-style: italic; }
359
372
 
360
373
  .perm {
@@ -367,12 +380,62 @@ exports.html = `<!DOCTYPE html>
367
380
  color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 150px; overflow: auto;
368
381
  }
369
382
  .perm .acts { display: flex; gap: 8px; }
383
+ .ask-questions { display: flex; flex-direction: column; gap: 16px; margin-bottom: 14px; }
384
+ .ask-q { display: flex; flex-direction: column; gap: 8px; }
385
+ .ask-q-text { font-size: 13.5px; font-weight: 500; }
386
+ .ask-opts { display: flex; flex-direction: column; gap: 6px; }
387
+ .ask-opt-btn {
388
+ text-align: left; padding: 7px 12px; border-radius: 8px; font-size: 13px;
389
+ background: var(--surface-2); border: 1px solid var(--border); color: var(--text);
390
+ cursor: pointer; box-shadow: none;
391
+ }
392
+ .ask-opt-btn:hover { border-color: var(--accent); }
393
+ .ask-opt-btn.selected { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-color: var(--accent); color: var(--text); }
394
+ .ask-opt-desc { font-size: 11.5px; color: var(--muted); margin-top: 1px; padding-left: 2px; }
395
+ .ask-other { width: 100%; padding: 7px 12px; border-radius: 8px; font-size: 13px; border: 1px solid var(--border); background: var(--surface-2); color: var(--text); outline: none; }
396
+ .ask-other:focus { border-color: var(--accent); }
370
397
 
371
398
  /* --- Composer --- */
372
- .composer { border-top: 1px solid var(--border); background: var(--bg); }
399
+ .composer { border-top: 1px solid var(--border); background: var(--bg); position: relative; }
400
+ /* Only on screen once the reader has left the tail and missed something. */
401
+ .jump {
402
+ position: absolute; bottom: calc(100% + 12px); left: 0; right: 0; margin: 0 auto;
403
+ display: none; align-items: center; gap: 7px; width: max-content;
404
+ padding: 8px 15px; border-radius: 999px; border: none;
405
+ background: var(--accent); color: var(--accent-text);
406
+ font-size: 13px; font-weight: 600; box-shadow: var(--shadow-md);
407
+ }
408
+ .jump.show { display: flex; }
409
+ .jump .chev { font-size: 15px; line-height: 1; }
373
410
  .composer .wrap { padding-top: 12px; padding-bottom: 18px; }
374
411
  .activity { display: flex; align-items: center; gap: 8px; height: 22px; padding-left: 4px; color: var(--muted); font-size: 12.5px; }
375
412
  .activity:empty { display: none; }
413
+
414
+ /* --- Plan panel --- */
415
+ /* The agent's todo list, kept above the input so the current step is always
416
+ in view. Collapsed to a single line by default; the list it hides is capped
417
+ in height so a long plan can never crowd out the conversation. */
418
+ .plan { margin-bottom: 9px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden; }
419
+ .plan-bar {
420
+ display: flex; align-items: center; gap: 9px; width: 100%;
421
+ padding: 9px 12px; font-size: 12.5px; text-align: left;
422
+ }
423
+ .plan-now { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
424
+ .plan.active .plan-now { color: var(--accent); font-weight: 500; }
425
+ .plan-count { flex: none; color: var(--faint); font-variant-numeric: tabular-nums; }
426
+ .plan-chev { flex: none; color: var(--faint); font-size: 15px; line-height: 1; transition: transform .15s ease; }
427
+ .plan.open .plan-chev { transform: rotate(90deg); }
428
+ @media (prefers-reduced-motion: reduce) { .plan-chev { transition: none; } }
429
+ .plan-list {
430
+ margin: 0; padding: 3px 12px 10px; list-style: none;
431
+ max-height: 33vh; overflow-y: auto; border-top: 1px solid var(--border);
432
+ }
433
+ .plan-item { display: flex; gap: 9px; align-items: baseline; padding: 4px 0; font-size: 13px; }
434
+ .plan-mark { flex: none; width: 13px; text-align: center; color: var(--faint); }
435
+ .plan-item.in_progress .plan-mark, .plan-item.in_progress { color: var(--accent); }
436
+ .plan-item.in_progress { font-weight: 500; }
437
+ .plan-item.completed { color: var(--faint); text-decoration: line-through; }
438
+ .plan-item.completed .plan-mark { color: var(--ok); text-decoration: none; }
376
439
  .spinner {
377
440
  width: 11px; height: 11px; flex: none; border-radius: 50%;
378
441
  border: 1.5px solid var(--border); border-top-color: var(--accent); animation: spin .7s linear infinite;
@@ -385,7 +448,7 @@ exports.html = `<!DOCTYPE html>
385
448
  padding: 9px 9px 9px 16px; box-shadow: var(--shadow-sm);
386
449
  }
387
450
  .box:focus-within { border-color: var(--accent); }
388
- .box textarea { flex: 1; border: 0; background: none; resize: none; outline: none; max-height: 200px; padding: 6px 0; }
451
+ .box textarea { flex: 1; border: 0; background: none; resize: none; outline: none; max-height: 200px; padding: 6px 0; font-size: 16px; }
389
452
  .send {
390
453
  width: 36px; height: 36px; border-radius: 11px; flex: none;
391
454
  background: var(--accent); color: var(--accent-text); font-size: 17px;
@@ -487,6 +550,11 @@ exports.html = `<!DOCTYPE html>
487
550
  box-shadow: var(--shadow-md); animation: rise .18s ease;
488
551
  }
489
552
  @keyframes rise { from { opacity: 0; transform: translate(-50%, 8px); } }
553
+ .toast-action {
554
+ margin-left: 10px; padding: 2px 10px; border-radius: 999px;
555
+ background: var(--bg); color: var(--text); border: none;
556
+ font-size: 13px; font-weight: 600; cursor: pointer;
557
+ }
490
558
 
491
559
  @media (max-width: 640px) {
492
560
  .wrap { padding: 0 16px; }
@@ -578,14 +646,25 @@ exports.html = `<!DOCTYPE html>
578
646
  </div>
579
647
  </div></div>
580
648
  <div class="messages" id="messages"><div class="wrap narrow" id="messages-inner"></div></div>
581
- <div class="composer"><div class="wrap narrow">
582
- <div class="activity" id="activity"></div>
583
- <div class="box">
584
- <textarea id="input" rows="1" placeholder="Message…"></textarea>
585
- <button class="mic" id="mic" title="Dictate" aria-label="Dictate"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a4 4 0 0 0 4-4V6a4 4 0 0 0-8 0v5a4 4 0 0 0 4 4zm6-4a6 6 0 0 1-5 5.92V20h3v2H8v-2h3v-3.08A6 6 0 0 1 6 11h2a4 4 0 0 0 8 0h2z"/></svg></button>
586
- <button class="send" id="send" title="Send" aria-label="Send">&uarr;</button>
649
+ <div class="composer">
650
+ <button class="jump" id="jump"><span class="chev">&darr;</span> New messages</button>
651
+ <div class="wrap narrow">
652
+ <div class="plan" id="plan" hidden>
653
+ <button class="plan-bar" id="plan-bar" aria-expanded="false">
654
+ <span class="plan-now" id="plan-now"></span>
655
+ <span class="plan-count" id="plan-count"></span>
656
+ <span class="plan-chev" id="plan-chev">&rsaquo;</span>
657
+ </button>
658
+ <ul class="plan-list" id="plan-list" hidden></ul>
659
+ </div>
660
+ <div class="activity" id="activity"></div>
661
+ <div class="box">
662
+ <textarea id="input" rows="1" placeholder="Message…"></textarea>
663
+ <button class="mic" id="mic" title="Dictate" aria-label="Dictate"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a4 4 0 0 0 4-4V6a4 4 0 0 0-8 0v5a4 4 0 0 0 4 4zm6-4a6 6 0 0 1-5 5.92V20h3v2H8v-2h3v-3.08A6 6 0 0 1 6 11h2a4 4 0 0 0 8 0h2z"/></svg></button>
664
+ <button class="send" id="send" title="Send" aria-label="Send">&uarr;</button>
665
+ </div>
587
666
  </div>
588
- </div></div>
667
+ </div>
589
668
  </section>
590
669
 
591
670
  <script>
@@ -1327,6 +1406,65 @@ exports.html = `<!DOCTYPE html>
1327
1406
  box.hidden = false;
1328
1407
  }
1329
1408
 
1409
+ // --- Plan panel ---
1410
+ // The agent's own todo list, mirrored above the input so the step it is on is
1411
+ // always in view. TodoWrite always writes the whole plan, so each call simply
1412
+ // replaces what the panel shows; there is nothing to merge or diff. Collapsed
1413
+ // to one line by default, and the line itself is the cue — it changes as the
1414
+ // agent moves through the list.
1415
+
1416
+ var planOpen = false; // sticky across updates, reset when a thread opens
1417
+ var PLAN_MARKS = { completed: "\\u2713", in_progress: "\\u25b8", pending: "\\u2610" };
1418
+
1419
+ function renderTodos(todos) {
1420
+ var box = $("plan");
1421
+ if (!todos || !todos.length) { box.hidden = true; return; }
1422
+
1423
+ var done = 0, current = null, firstPending = null;
1424
+ todos.forEach(function (t) {
1425
+ if (t.status === "completed") done++;
1426
+ else if (t.status === "in_progress" && !current) current = t;
1427
+ else if (t.status === "pending" && !firstPending) firstPending = t;
1428
+ });
1429
+ var shown = current || firstPending;
1430
+
1431
+ // activeForm is the present-continuous wording the tool asks for, which is
1432
+ // exactly what a status line wants; content is the fallback.
1433
+ $("plan-now").textContent = shown ? (shown.activeForm || shown.content) : "Plan complete";
1434
+ $("plan-count").textContent = done + "/" + todos.length;
1435
+
1436
+ var list = $("plan-list");
1437
+ list.innerHTML = "";
1438
+ todos.forEach(function (t) {
1439
+ var li = el("li", "plan-item " + t.status);
1440
+ li.appendChild(el("span", "plan-mark", PLAN_MARKS[t.status] || PLAN_MARKS.pending));
1441
+ li.appendChild(el("span", null, t.content));
1442
+ list.appendChild(li);
1443
+ });
1444
+
1445
+ setPlanOpen(planOpen);
1446
+ box.hidden = false;
1447
+ }
1448
+
1449
+ function setPlanOpen(open) {
1450
+ planOpen = open;
1451
+ var box = $("plan");
1452
+ var lit = box.querySelector(".plan-item.in_progress");
1453
+ box.className = "plan" + (lit ? " active" : "") + (open ? " open" : "");
1454
+ $("plan-list").hidden = !open;
1455
+ $("plan-bar").setAttribute("aria-expanded", open ? "true" : "false");
1456
+ }
1457
+
1458
+ /** The last plan the thread ever wrote, so reopening it days later still
1459
+ * shows where the work got to rather than an empty panel. */
1460
+ function seedPlan(msgs) {
1461
+ var last = null;
1462
+ msgs.forEach(function (m) {
1463
+ (m.content || []).forEach(function (b) { if (b.todos) last = b.todos; });
1464
+ });
1465
+ renderTodos(last);
1466
+ }
1467
+
1330
1468
  // --- Thread ---
1331
1469
  function openThread(thread) {
1332
1470
  activeThread = thread;
@@ -1335,6 +1473,8 @@ exports.html = `<!DOCTYPE html>
1335
1473
  $("thread-sub").textContent = shortPath(thread.repoPath, 30);
1336
1474
  $("thread-sub").title = thread.repoPath || "";
1337
1475
  renderContext(thread.context);
1476
+ planOpen = false;
1477
+ renderTodos(null);
1338
1478
  $("to-bot-label").textContent = threadBack === "jarvis" ? "Jarvis" : (activeBot ? activeBot.name : "Back");
1339
1479
  var av = $("thread-avatar");
1340
1480
  av.textContent = activeBot ? (activeBot.emoji || "\\u{1F916}") : "\\u{1F916}";
@@ -1363,6 +1503,7 @@ exports.html = `<!DOCTYPE html>
1363
1503
  function renderTranscript(msgs) {
1364
1504
  var box = $("messages-inner");
1365
1505
  box.innerHTML = "";
1506
+ forceBottom(); // a freshly opened transcript always starts at its tail
1366
1507
  if (!msgs.length) {
1367
1508
  var e = el("div", "empty");
1368
1509
  e.appendChild(el("h3", null, "Say the first thing"));
@@ -1370,6 +1511,7 @@ exports.html = `<!DOCTYPE html>
1370
1511
  box.appendChild(e);
1371
1512
  return;
1372
1513
  }
1514
+ seedPlan(msgs);
1373
1515
  msgs.forEach(function (m) {
1374
1516
  var content = startMessage(m.role === "user" ? "user" : "assistant");
1375
1517
  (m.content || []).forEach(function (b) {
@@ -1378,6 +1520,7 @@ exports.html = `<!DOCTYPE html>
1378
1520
  else if (b.type === "image_url") {
1379
1521
  var img = document.createElement("img");
1380
1522
  img.src = b.url; img.style.maxWidth = "100%"; img.style.borderRadius = "12px";
1523
+ img.onload = scrollDown; // an image finishing late grows the list under us
1381
1524
  content.appendChild(img);
1382
1525
  }
1383
1526
  });
@@ -1447,21 +1590,105 @@ exports.html = `<!DOCTYPE html>
1447
1590
  }
1448
1591
  }
1449
1592
 
1593
+ var TOOL_PEEK = 200; // how much of the input the collapsed one-liner carries
1594
+
1595
+ /** A chip's full text, laid out to be read: dense JSON gets indented, anything
1596
+ * else is shown as the agent wrote it. */
1597
+ function toolFullText(summary) {
1598
+ var t = summary.charAt(0);
1599
+ if (t === "{" || t === "[") {
1600
+ try { return JSON.stringify(JSON.parse(summary), null, 2); } catch (e) {}
1601
+ }
1602
+ return summary;
1603
+ }
1604
+
1450
1605
  function appendTool(content, name, input) {
1451
1606
  var chip = el("div", "tool");
1452
1607
  // Jarvis's own tools read better without the MCP plumbing in the name.
1453
1608
  chip.appendChild(el("b", null, name.replace(/^mcp__jarvis__/, "")));
1454
1609
  var summary = typeof input === "string" ? input : JSON.stringify(input || {});
1455
- chip.appendChild(el("span", null, summary.slice(0, 200)));
1610
+ var text = el("span", "tool-text", summary.slice(0, TOOL_PEEK));
1611
+ chip.appendChild(text);
1612
+ chip.appendChild(el("span", "tool-chev", "\u203A"));
1456
1613
  content.appendChild(chip);
1614
+
1615
+ // The chip only becomes clickable when the one-liner is actually hiding
1616
+ // something: text cut by the peek, newlines flattened into it, or an
1617
+ // ellipsis from the overflow. Measured now that it is laid out.
1618
+ var hidden = summary.length > TOOL_PEEK || summary.indexOf("\\n") >= 0
1619
+ || text.scrollWidth > text.clientWidth + 1;
1620
+ if (hidden) {
1621
+ var open = false;
1622
+ chip.className = "tool more";
1623
+ chip.setAttribute("role", "button");
1624
+ chip.setAttribute("tabindex", "0");
1625
+ chip.setAttribute("aria-expanded", "false");
1626
+ chip.title = "Show the full input";
1627
+ var toggle = function () {
1628
+ open = !open;
1629
+ chip.className = "tool more" + (open ? " open" : "");
1630
+ chip.setAttribute("aria-expanded", open ? "true" : "false");
1631
+ chip.title = open ? "Hide the full input" : "Show the full input";
1632
+ text.textContent = open ? toolFullText(summary) : summary.slice(0, TOOL_PEEK);
1633
+ };
1634
+ // Once open the text belongs to the reader: clicks land in it to select or
1635
+ // scroll it, and the name and chevron are what close it again.
1636
+ chip.onclick = function (ev) {
1637
+ if (!open || !text.contains(ev.target)) toggle();
1638
+ };
1639
+ chip.onkeydown = function (ev) {
1640
+ if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); toggle(); }
1641
+ };
1642
+ }
1643
+
1457
1644
  scrollDown();
1458
1645
  }
1459
1646
 
1460
- function scrollDown() {
1647
+ // --- Sticking to the tail ---
1648
+ // The list follows new content only while the reader is already at the bottom.
1649
+ // Scroll up to read something and the conversation stays where you put it,
1650
+ // with a pill offering the way back down once it has moved on without you.
1651
+ var stick = true; // following the tail?
1652
+ var missed = false; // has anything arrived since we stopped following?
1653
+ var SLACK = 64; // px from the bottom that still counts as "at the bottom"
1654
+
1655
+ function atBottom() {
1656
+ var m = $("messages");
1657
+ return m.scrollHeight - m.scrollTop - m.clientHeight <= SLACK;
1658
+ }
1659
+
1660
+ function pin() {
1461
1661
  var m = $("messages");
1462
1662
  m.scrollTop = m.scrollHeight;
1463
1663
  }
1464
1664
 
1665
+ function renderJump() {
1666
+ $("jump").classList.toggle("show", !stick && missed);
1667
+ }
1668
+
1669
+ /** New content arrived: follow it, or note that the reader has missed it. */
1670
+ function scrollDown() {
1671
+ if (stick) pin();
1672
+ else if (!missed) { missed = true; renderJump(); }
1673
+ }
1674
+
1675
+ /** Go to the tail whatever the reader was doing — for the moments that mean
1676
+ * "show me the latest": opening a thread, sending, tapping the pill. */
1677
+ function forceBottom() {
1678
+ stick = true;
1679
+ missed = false;
1680
+ pin();
1681
+ renderJump();
1682
+ }
1683
+
1684
+ function watchScroll() {
1685
+ var here = atBottom();
1686
+ if (here === stick) return;
1687
+ stick = here;
1688
+ if (stick) missed = false;
1689
+ renderJump();
1690
+ }
1691
+
1465
1692
  function showError(err) {
1466
1693
  var content = startMessage("error");
1467
1694
  appendText(content, err && err.message ? err.message : String(err));
@@ -1481,6 +1708,7 @@ exports.html = `<!DOCTYPE html>
1481
1708
  function sendText(text) {
1482
1709
  if (!text || !activeThread || state !== "idle") return;
1483
1710
  appendText(startMessage("user"), text);
1711
+ forceBottom(); // your own message always brings you back down
1484
1712
  setState("starting");
1485
1713
  setActivity("Sending\u2026");
1486
1714
 
@@ -1610,6 +1838,7 @@ exports.html = `<!DOCTYPE html>
1610
1838
  case "tool_use":
1611
1839
  if (!liveBubble) liveBubble = startMessage("assistant");
1612
1840
  appendTool(liveBubble, data.tool_name, data.tool_input);
1841
+ if (data.todos) renderTodos(data.todos);
1613
1842
  break;
1614
1843
 
1615
1844
  case "context":
@@ -1731,6 +1960,7 @@ exports.html = `<!DOCTYPE html>
1731
1960
  }
1732
1961
 
1733
1962
  function renderPermission(data) {
1963
+ if (data.toolName === "AskUserQuestion") { renderAskUser(data); return; }
1734
1964
  var card = el("div", "perm");
1735
1965
  card.appendChild(el("div", "head", "Allow " + data.toolName + "?"));
1736
1966
  card.appendChild(el("pre", null, JSON.stringify(data.input, null, 2)));
@@ -1756,6 +1986,100 @@ exports.html = `<!DOCTYPE html>
1756
1986
  scrollDown();
1757
1987
  }
1758
1988
 
1989
+ function renderAskUser(data) {
1990
+ var input = data.input || {};
1991
+ var questions = Array.isArray(input.questions) ? input.questions : [];
1992
+ var answers = {}; // question text -> selected label(s)
1993
+
1994
+ var card = el("div", "perm");
1995
+ card.appendChild(el("div", "head", "Questions"));
1996
+
1997
+ var qsDiv = el("div", "ask-questions");
1998
+
1999
+ questions.forEach(function (q, qi) {
2000
+ answers[q.question] = q.multiSelect ? [] : null;
2001
+
2002
+ var qDiv = el("div", "ask-q");
2003
+ qDiv.appendChild(el("div", "ask-q-text", q.question));
2004
+
2005
+ var optsDiv = el("div", "ask-opts");
2006
+
2007
+ (q.options || []).forEach(function (opt) {
2008
+ var row = el("div");
2009
+ var btn = el("button", "ask-opt-btn", opt.label);
2010
+ btn.onclick = function () {
2011
+ if (q.multiSelect) {
2012
+ var arr = answers[q.question];
2013
+ var idx = arr.indexOf(opt.label);
2014
+ if (idx >= 0) { arr.splice(idx, 1); btn.classList.remove("selected"); }
2015
+ else { arr.push(opt.label); btn.classList.add("selected"); }
2016
+ } else {
2017
+ answers[q.question] = opt.label;
2018
+ optsDiv.querySelectorAll(".ask-opt-btn").forEach(function (b) { b.classList.remove("selected"); });
2019
+ btn.classList.add("selected");
2020
+ }
2021
+ };
2022
+ row.appendChild(btn);
2023
+ if (opt.description) row.appendChild(el("div", "ask-opt-desc", opt.description));
2024
+ optsDiv.appendChild(row);
2025
+ });
2026
+
2027
+ var otherInput = el("input");
2028
+ otherInput.type = "text";
2029
+ otherInput.placeholder = "Other\u2026";
2030
+ otherInput.className = "ask-other";
2031
+ otherInput.oninput = function () {
2032
+ var val = otherInput.value.trim();
2033
+ if (q.multiSelect) {
2034
+ // handled on submit
2035
+ } else {
2036
+ if (val) {
2037
+ answers[q.question] = val;
2038
+ optsDiv.querySelectorAll(".ask-opt-btn").forEach(function (b) { b.classList.remove("selected"); });
2039
+ } else if (!answers[q.question] || typeof answers[q.question] === "string") {
2040
+ answers[q.question] = null;
2041
+ }
2042
+ }
2043
+ };
2044
+ optsDiv.appendChild(otherInput);
2045
+ qDiv.appendChild(optsDiv);
2046
+ qsDiv.appendChild(qDiv);
2047
+ });
2048
+
2049
+ card.appendChild(qsDiv);
2050
+
2051
+ var acts = el("div", "acts");
2052
+ var submit = el("button", "btn primary", "Submit");
2053
+ submit.onclick = function () {
2054
+ // Collect "other" text inputs for multi-select questions
2055
+ var qDivs = qsDiv.querySelectorAll(".ask-q");
2056
+ questions.forEach(function (q, qi) {
2057
+ if (q.multiSelect) {
2058
+ var inp = qDivs[qi].querySelector(".ask-other");
2059
+ var val = inp ? inp.value.trim() : "";
2060
+ if (val && answers[q.question].indexOf(val) === -1) answers[q.question].push(val);
2061
+ }
2062
+ });
2063
+ submit.disabled = true;
2064
+ api("/sessions/" + encodeURIComponent(sessionId) + "/permission", {
2065
+ method: "POST",
2066
+ body: { toolUseID: data.toolUseID, answers: answers }
2067
+ }).then(function () {
2068
+ var summary = questions.map(function (q) {
2069
+ var a = answers[q.question];
2070
+ return Array.isArray(a) ? a.join(", ") : (a || "\u2014");
2071
+ }).join(" \u00b7 ");
2072
+ card.replaceWith(el("div", "note", summary));
2073
+ pendingPerms = Math.max(0, pendingPerms - 1);
2074
+ if (!pendingPerms && state === "running") setActivity("Thinking\u2026");
2075
+ }).catch(showError);
2076
+ };
2077
+ acts.appendChild(submit);
2078
+ card.appendChild(acts);
2079
+ $("messages-inner").appendChild(card);
2080
+ scrollDown();
2081
+ }
2082
+
1759
2083
  function finishTurn() {
1760
2084
  closeStream();
1761
2085
  liveBubble = null;
@@ -1924,6 +2248,70 @@ exports.html = `<!DOCTYPE html>
1924
2248
  });
1925
2249
  }
1926
2250
 
2251
+ // IndexedDB helpers — save the raw audio before the API call so a failed
2252
+ // transcription can be retried without re-recording.
2253
+ var _dictDb = null;
2254
+ function openDictationDb() {
2255
+ if (_dictDb) return Promise.resolve(_dictDb);
2256
+ return new Promise(function (resolve, reject) {
2257
+ var req = indexedDB.open("dictation", 1);
2258
+ req.onupgradeneeded = function () { req.result.createObjectStore("pending"); };
2259
+ req.onsuccess = function () { _dictDb = req.result; resolve(_dictDb); };
2260
+ req.onerror = function () { reject(req.error); };
2261
+ });
2262
+ }
2263
+ function savePendingAudio(audio, mimeType) {
2264
+ return openDictationDb().then(function (db) {
2265
+ return new Promise(function (resolve, reject) {
2266
+ var tx = db.transaction("pending", "readwrite");
2267
+ tx.objectStore("pending").put({ audio: audio, mimeType: mimeType }, "clip");
2268
+ tx.oncomplete = resolve;
2269
+ tx.onerror = function () { reject(tx.error); };
2270
+ });
2271
+ }).catch(function () {}); // best-effort
2272
+ }
2273
+ function deletePendingAudio() {
2274
+ return openDictationDb().then(function (db) {
2275
+ return new Promise(function (resolve) {
2276
+ var tx = db.transaction("pending", "readwrite");
2277
+ tx.objectStore("pending").delete("clip");
2278
+ tx.oncomplete = resolve;
2279
+ tx.onerror = resolve;
2280
+ });
2281
+ }).catch(function () {});
2282
+ }
2283
+ function loadPendingAudio() {
2284
+ return openDictationDb().then(function (db) {
2285
+ return new Promise(function (resolve) {
2286
+ var req = db.transaction("pending", "readonly").objectStore("pending").get("clip");
2287
+ req.onsuccess = function () { resolve(req.result || null); };
2288
+ req.onerror = function () { resolve(null); };
2289
+ });
2290
+ }).catch(function () { return null; });
2291
+ }
2292
+
2293
+ function sendDictation(audio, mimeType, btn, textarea) {
2294
+ return api("/dictate", { method: "POST", body: { audio: audio, mimeType: mimeType } })
2295
+ .then(function (d) {
2296
+ deletePendingAudio();
2297
+ insertDictation(textarea, d.text);
2298
+ }).catch(function (err) {
2299
+ toast(err.message || "Dictation failed", {
2300
+ label: "Retry",
2301
+ fn: function () {
2302
+ loadPendingAudio().then(function (saved) {
2303
+ if (!saved) { toast("No saved clip to retry"); return; }
2304
+ btn.classList.add("busy");
2305
+ sendDictation(saved.audio, saved.mimeType, btn, textarea)
2306
+ .then(function () { btn.classList.remove("busy"); });
2307
+ });
2308
+ },
2309
+ });
2310
+ }).then(function () {
2311
+ btn.classList.remove("busy");
2312
+ });
2313
+ }
2314
+
1927
2315
  function stopRecording() {
1928
2316
  var rec = recording;
1929
2317
  if (!rec) return;
@@ -1941,6 +2329,7 @@ exports.html = `<!DOCTYPE html>
1941
2329
  if (!window.isSecureContext) {
1942
2330
  toast("Dictation needs https or localhost"); return;
1943
2331
  }
2332
+ deletePendingAudio();
1944
2333
  navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
1945
2334
  var mime = recordingMime();
1946
2335
  var recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
@@ -1951,12 +2340,11 @@ exports.html = `<!DOCTYPE html>
1951
2340
  var blob = new Blob(chunks, { type: type });
1952
2341
  if (blob.size < 1000) { btn.classList.remove("busy"); return; } // tapped twice by accident
1953
2342
  blobToBase64(blob).then(function (audio) {
1954
- return api("/dictate", { method: "POST", body: { audio: audio, mimeType: type } });
1955
- }).then(function (d) {
1956
- insertDictation(textarea, d.text);
2343
+ return savePendingAudio(audio, type).then(function () {
2344
+ return sendDictation(audio, type, btn, textarea);
2345
+ });
1957
2346
  }).catch(function (err) {
1958
2347
  toast(err.message || "Dictation failed");
1959
- }).then(function () {
1960
2348
  btn.classList.remove("busy");
1961
2349
  });
1962
2350
  };
@@ -1977,12 +2365,19 @@ exports.html = `<!DOCTYPE html>
1977
2365
  };
1978
2366
  }
1979
2367
 
1980
- function toast(text) {
2368
+ function toast(text, action) {
1981
2369
  var old = document.querySelector(".toast");
1982
2370
  if (old) old.remove();
1983
2371
  var t = el("div", "toast", text);
2372
+ if (action) {
2373
+ var btn = document.createElement("button");
2374
+ btn.className = "toast-action";
2375
+ btn.textContent = action.label;
2376
+ btn.onclick = function () { t.remove(); action.fn(); };
2377
+ t.appendChild(btn);
2378
+ }
1984
2379
  document.body.appendChild(t);
1985
- setTimeout(function () { t.remove(); }, 2400);
2380
+ setTimeout(function () { if (t.parentNode) t.remove(); }, action ? 7000 : 2400);
1986
2381
  }
1987
2382
 
1988
2383
  function copyText(text) {
@@ -2265,6 +2660,7 @@ exports.html = `<!DOCTYPE html>
2265
2660
  $("to-jarvis").onclick = function () { goJarvis(); };
2266
2661
  $("to-projects").onclick = function () { goHome(); };
2267
2662
  $("to-bot").onclick = function () { if (threadBack === "jarvis") goJarvis(); else goBotView(); };
2663
+ $("plan-bar").onclick = function () { setPlanOpen(!planOpen); };
2268
2664
  $("ask-send").onclick = function () { askJarvis($("ask-input").value); };
2269
2665
  var ask = $("ask-input");
2270
2666
  ask.addEventListener("input", function () {
@@ -2278,10 +2674,16 @@ exports.html = `<!DOCTYPE html>
2278
2674
  wireMic($("ask-mic"), $("ask-input"));
2279
2675
  wireMic($("mic"), $("input"));
2280
2676
 
2677
+ $("messages").addEventListener("scroll", watchScroll, { passive: true });
2678
+ $("jump").onclick = forceBottom;
2679
+ // The on-screen keyboard and rotation both shrink the list out from under us.
2680
+ window.addEventListener("resize", function () { if (stick) pin(); });
2681
+
2281
2682
  var input = $("input");
2282
2683
  input.addEventListener("input", function () {
2283
2684
  input.style.height = "auto";
2284
2685
  input.style.height = Math.min(input.scrollHeight, 200) + "px";
2686
+ if (stick) pin(); // a growing composer eats into the list; hold the tail
2285
2687
  });
2286
2688
  input.addEventListener("keydown", function (ev) {
2287
2689
  if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); if (state === "idle") send(); }
package/dist/dictation.js CHANGED
@@ -21,15 +21,17 @@ const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
21
21
  const CLEANUP_MODEL = "gpt-4o-mini";
22
22
  // Roughly two minutes of opus; anything longer is a mistake, not a message.
23
23
  const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
24
- const CLEANUP_PROMPT = `You are an intent extraction engine for a voice assistant.
25
- The user will provide a messy, unedited voice transcript containing filler words, pauses, and self-corrections.
24
+ const CLEANUP_PROMPT = `You are a voice transcription cleanup assistant.
25
+ The user will provide a raw voice transcript. Your job is light editing only — preserve their exact words and phrasing as much as possible.
26
26
 
27
27
  Your task:
28
- 1. Analyze the spoken words to find the user's ultimate intent.
29
- 2. Completely remove filler words ("uh", "um", "like") and resolve self-corrections (e.g., "set a meeting for 5, no, make it 6 PM" becomes "6 PM").
30
- 3. Output ONLY the final, polished instructions. Do not include introductory text, explanations, or quotes.
28
+ 1. Remove filler words ("uh", "um", "er", "like") and false starts.
29
+ 2. If the user spells out a word (e.g., "t-h-r-e-s-h-o-l-d"), replace the previous misspelled or unclear attempt with the correctly spelled word and drop the spelling itself.
30
+ 3. Resolve simple self-corrections (e.g., "delete the file, actually remove the folder" → "remove the folder").
31
+ 4. Apply light markdown formatting where it clearly fits: wrap code, file names, variable names, and commands in backticks; use a bullet list if the user enumerates multiple items.
32
+ 5. Output ONLY the cleaned transcript. No explanations, no quotes, no commentary.
31
33
 
32
- Keep the user's wording and voice where it is already clear; do not summarize, expand, or answer the request.`;
34
+ Do not paraphrase, summarize, reorder, or interpret — just clean up the words they said.`;
33
35
  function dictationConfigured() {
34
36
  return {
35
37
  deepgram: Boolean(process.env.DEEPGRAM_API_KEY),
package/dist/server.js CHANGED
@@ -221,7 +221,7 @@ async function handleRequest(req, res, availableAgents, workspaceCwd) {
221
221
  return;
222
222
  }
223
223
  const body = await (0, server_common_1.readBody)(req);
224
- const { toolUseID, approved, updatedInput } = body;
224
+ const { toolUseID, approved, updatedInput, answers } = body;
225
225
  if (!toolUseID) {
226
226
  (0, server_common_1.jsonError)(res, 400, "toolUseID is required");
227
227
  return;
@@ -232,9 +232,20 @@ async function handleRequest(req, res, availableAgents, workspaceCwd) {
232
232
  if (pending) {
233
233
  store.pendingPermissions.delete(toolUseID);
234
234
  (0, server_common_1.notifyPermissionsChanged)();
235
- pending.resolve(approved
236
- ? { behavior: "allow", updatedInput: updatedInput ?? pending.input }
237
- : { behavior: "deny", message: "User denied" });
235
+ if (pending.toolName === "AskUserQuestion" && answers) {
236
+ const questions = pending.input?.questions ?? [];
237
+ const lines = questions.map((q) => {
238
+ const a = answers[q.question];
239
+ const answer = Array.isArray(a) ? (a.length ? a.join(", ") : "(none selected)") : (a || "(no answer)");
240
+ return `- ${q.question}: ${answer}`;
241
+ });
242
+ pending.resolve({ behavior: "deny", message: "User's answers:\n" + lines.join("\n") });
243
+ }
244
+ else {
245
+ pending.resolve(approved
246
+ ? { behavior: "allow", updatedInput: updatedInput ?? pending.input }
247
+ : { behavior: "deny", message: "User denied" });
248
+ }
238
249
  }
239
250
  }
240
251
  else if (store.agent === "opencode" && store.sdkSessionId) {
@@ -332,6 +332,7 @@ function formatMessage(msg) {
332
332
  type: "tool_use",
333
333
  tool_name: block.name,
334
334
  tool_input: formatToolInput(block.name, block.input),
335
+ ...todoList(block.name, block.input),
335
336
  });
336
337
  }
337
338
  }
@@ -445,7 +446,7 @@ async function loadTranscript(sessionId, cwd) {
445
446
  catch {
446
447
  tool_input = JSON.stringify(b.input) ?? "";
447
448
  }
448
- blocks.push({ type: "tool_use", tool_name: b.name, tool_input });
449
+ blocks.push({ type: "tool_use", tool_name: b.name, tool_input, ...todoList(b.name, b.input) });
449
450
  }
450
451
  }
451
452
  }
@@ -537,6 +538,25 @@ async function listSessions(cwd) {
537
538
  return [];
538
539
  }
539
540
  }
541
+ /**
542
+ * The plan panel needs the list as a list, not as the one-line summary the tool
543
+ * chip shows. Carried as a separate field so the chip keeps rendering unchanged,
544
+ * and spread in so every other tool's payload stays exactly as it was.
545
+ */
546
+ function todoList(toolName, input) {
547
+ if (toolName !== "TodoWrite")
548
+ return {};
549
+ const todos = input.todos;
550
+ if (!Array.isArray(todos))
551
+ return {};
552
+ return {
553
+ todos: todos.map((t) => ({
554
+ content: String(t?.content ?? ""),
555
+ status: String(t?.status ?? "pending"),
556
+ activeForm: String(t?.activeForm ?? ""),
557
+ })),
558
+ };
559
+ }
540
560
  function formatToolInput(toolName, input) {
541
561
  switch (toolName) {
542
562
  case "Bash":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jarvis-hq",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Jarvis — a persistent AI engineering partner. One entry point from any device; project-aware handoff and delegation to Claude Code running on your machine.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {