|
41 | 41 | .msg:hover .msgcopy, .msgcopy:focus-visible, .msgcopy.done { opacity: .8; } |
42 | 42 | .msgcopy:hover { opacity: 1; color: var(--vscode-foreground); background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); } |
43 | 43 | .msgcopy.done { color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); border-color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); } |
| 44 | + /* per-turn workspace-checkpoint Restore control (hover-revealed on the user message) */ |
| 45 | + .ckwrap { margin-top: 6px; } |
| 46 | + .ckrestore { opacity: .62; cursor: pointer; border: 1px solid var(--border); border-radius: 6px; padding: 3px 9px; font-size: 11px; display: inline-flex; align-items: center; gap: 5px; background: transparent; color: var(--muted); transition: opacity .12s, color .12s, background .12s; } |
| 47 | + .msg.user:hover .ckrestore, .ckrestore:focus-visible { opacity: .9; } |
| 48 | + .ckrestore:hover { opacity: 1; color: var(--vscode-foreground); background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); } |
| 49 | + .ckrestore .ckn { color: var(--muted); } |
| 50 | + .ckrestore svg, .ckmuted svg { width: 1em; height: 1em; } |
| 51 | + .ckconfirm { font-size: 11.5px; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; } |
| 52 | + .ckconfirm button { cursor: pointer; border: 1px solid var(--border); border-radius: 5px; padding: 2px 9px; font-size: 11px; color: var(--vscode-foreground); background: transparent; } |
| 53 | + .ckconfirm .ckyes { background: var(--vscode-button-background); color: var(--vscode-button-foreground); border-color: transparent; } |
| 54 | + .ckconfirm .ckyes:hover { background: var(--vscode-button-hoverBackground); } |
| 55 | + .ckconfirm .ckno:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); } |
| 56 | + .ckmuted { font-size: 11.5px; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; } |
| 57 | + .ckdivider { text-align: center; font-size: 11px; color: var(--muted); margin: 8px 0; opacity: .85; } |
44 | 58 | /* ---- jump-to-latest affordance (shown only when auto-scroll is paused) ---- */ |
45 | 59 | #jumpLatest { |
46 | 60 | position: fixed; right: 16px; bottom: 96px; z-index: 20; |
|
1428 | 1442 | sendBtn.innerHTML = on ? '■' : '↵'; // ■ while streaming, ↵ otherwise |
1429 | 1443 | sendBtn.title = on ? 'Stop' : 'Send (Enter)'; |
1430 | 1444 | } |
| 1445 | + // Shell-style history recall in the composer: ↑ = previous sent message, ↓ = newer (back to your draft). |
| 1446 | + // Session-only. In multiline text, ↑/↓ recall only when the caret is on the first/last line (arrows still move otherwise). |
| 1447 | + const cmdHistory = []; let histIdx = -1; let histDraft = ''; |
| 1448 | + function caretAtFirstLine(el){ return el.value.lastIndexOf('\n', el.selectionStart - 1) === -1; } |
| 1449 | + function caretAtLastLine(el){ return el.value.indexOf('\n', el.selectionEnd) === -1; } |
| 1450 | + function histSet(text){ input.value = text; auto(); const n = text.length; try { input.setSelectionRange(n, n); } catch (e) {} } |
1431 | 1451 | function doSend(){ |
1432 | 1452 | if (streaming) { flushAll = true; ensurePump(); vscode.postMessage({ type: 'stop' }); return; } |
1433 | 1453 | const t = input.value.trim(); if (!t) return; |
| 1454 | + cmdHistory.push(t); if (cmdHistory.length > 200) { cmdHistory.shift(); } // record for ↑/↓ recall |
| 1455 | + histIdx = -1; histDraft = ''; |
1434 | 1456 | add('user', render(t)); forceStick(); input.value = ''; auto(); |
1435 | 1457 | vscode.postMessage({ type: 'send', text: t }); |
1436 | 1458 | } |
|
1497 | 1519 | }); |
1498 | 1520 | renderAccount({ signedIn: false }); |
1499 | 1521 |
|
1500 | | - input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); doSend(); } }); |
| 1522 | + input.addEventListener('keydown', e => { |
| 1523 | + if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); doSend(); return; } |
| 1524 | + const plain = !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey; |
| 1525 | + if (e.key === 'ArrowUp' && plain){ |
| 1526 | + if (!cmdHistory.length){ return; } |
| 1527 | + if (histIdx === -1){ if (!caretAtFirstLine(input)){ return; } histDraft = input.value; histIdx = cmdHistory.length; } |
| 1528 | + if (histIdx > 0){ histIdx--; histSet(cmdHistory[histIdx]); } |
| 1529 | + e.preventDefault(); return; |
| 1530 | + } |
| 1531 | + if (e.key === 'ArrowDown' && plain){ |
| 1532 | + if (histIdx === -1){ return; } |
| 1533 | + if (!caretAtLastLine(input)){ return; } |
| 1534 | + histIdx++; |
| 1535 | + if (histIdx >= cmdHistory.length){ histIdx = -1; histSet(histDraft); } |
| 1536 | + else { histSet(cmdHistory[histIdx]); } |
| 1537 | + e.preventDefault(); return; |
| 1538 | + } |
| 1539 | + }); |
1501 | 1540 | function auto(){ input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 200) + 'px'; } |
1502 | 1541 | input.addEventListener('input', auto); |
1503 | 1542 | input.addEventListener('focus', () => composer.classList.add('focused')); |
1504 | 1543 | input.addEventListener('blur', () => composer.classList.remove('focused')); |
1505 | 1544 |
|
| 1545 | + // Workspace checkpoints: a hover-revealed "Restore" control on each user turn that changed files. |
| 1546 | + const REVERT = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.8 8a4.2 4.2 0 1 0 1.3-3.05"/><path d="M2.4 3v2.5h2.5"/></svg>'; |
| 1547 | + function ckBubble(turnId){ return log.querySelector('.msg.user[data-turn-id="' + turnId + '"]'); } |
| 1548 | + function stampCheckpoint(turnId){ |
| 1549 | + const us = log.querySelectorAll('.msg.user'); const u = us[us.length - 1]; |
| 1550 | + if (u && !u.dataset.turnId){ u.dataset.turnId = turnId; } |
| 1551 | + } |
| 1552 | + function renderRestoreIdle(wrap, turnId, fileCount){ |
| 1553 | + wrap.innerHTML = '<button class="ckrestore" title="Restore the workspace to before this message ran">' + REVERT |
| 1554 | + + ' Restore checkpoint <span class="ckn">· ' + fileCount + ' file' + (fileCount === 1 ? '' : 's') + '</span></button>'; |
| 1555 | + wrap.querySelector('.ckrestore').onclick = () => { |
| 1556 | + wrap.innerHTML = '<span class="ckconfirm">Revert the changes from this message onward? <button class="ckyes">Restore</button> <button class="ckno">Cancel</button></span>'; |
| 1557 | + wrap.querySelector('.ckno').onclick = () => renderRestoreIdle(wrap, turnId, fileCount); |
| 1558 | + wrap.querySelector('.ckyes').onclick = () => { wrap.innerHTML = '<span class="ckmuted">' + REVERT + ' Restoring…</span>'; vscode.postMessage({ type: 'restoreCheckpoint', turnId: Number(turnId) }); }; |
| 1559 | + }; |
| 1560 | + } |
| 1561 | + function addRestoreControl(turnId, fileCount){ |
| 1562 | + if (!fileCount){ return; } // turn changed no files → nothing to restore |
| 1563 | + const u = ckBubble(turnId); if (!u){ return; } |
| 1564 | + u.dataset.ckFiles = fileCount; |
| 1565 | + let wrap = u.querySelector('.ckwrap'); if (!wrap){ wrap = document.createElement('div'); wrap.className = 'ckwrap'; u.appendChild(wrap); } |
| 1566 | + renderRestoreIdle(wrap, turnId, fileCount); |
| 1567 | + } |
| 1568 | + function pruneDetached(map){ for (const k in map){ const el = map[k]; if (el && !log.contains(el)){ delete map[k]; } } } |
| 1569 | + function applyCheckpointRestored(m){ |
| 1570 | + const u = ckBubble(m.turnId); if (!u){ return; } |
| 1571 | + let n = u.nextSibling; while (n){ const next = n.nextSibling; n.remove(); n = next; } // drop this turn's output + every later turn |
| 1572 | + const wrap = u.querySelector('.ckwrap'); if (wrap){ wrap.innerHTML = '<span class="ckmuted">' + REVERT + ' Restored to here · ' + (m.filesRestored || 0) + ' file' + (m.filesRestored === 1 ? '' : 's') + ' reverted</span>'; } |
| 1573 | + pruneDetached(editCards); pruneDetached(termCards); pruneDetached(verifyCards); |
| 1574 | + agentBubble = null; agentRaw = ''; setStreaming(false); forceStick(); |
| 1575 | + } |
| 1576 | + function checkpointBusyNotice(m){ |
| 1577 | + if (m.turnId != null){ const u = ckBubble(m.turnId); const wrap = u && u.querySelector('.ckwrap'); if (wrap){ renderRestoreIdle(wrap, m.turnId, Number(u.dataset.ckFiles || 1)); } } |
| 1578 | + const note = document.createElement('div'); note.className = 'ckdivider'; |
| 1579 | + note.textContent = (m.reason === 'gone') ? 'That checkpoint is no longer available.' : 'Stop the running agent before restoring a checkpoint.'; |
| 1580 | + log.appendChild(note); scrollIfStuck(); setTimeout(() => { try { note.remove(); } catch(e){} }, 3500); |
| 1581 | + } |
1506 | 1582 | window.addEventListener('message', ev => { |
1507 | 1583 | const m = ev.data; |
1508 | 1584 | if (m.type === 'config'){ |
|
1535 | 1611 | else if (m.type === 'termOutput'){ termOutputAppend(m); } |
1536 | 1612 | else if (m.type === 'termExit'){ termExitFinish(m); } |
1537 | 1613 | else if (m.type === 'bgTask'){ applyBgTask(m); } |
| 1614 | + else if (m.type === 'checkpointOpened'){ stampCheckpoint(m.turnId); } |
| 1615 | + else if (m.type === 'checkpointClosed'){ addRestoreControl(m.turnId, m.fileCount); } |
| 1616 | + else if (m.type === 'checkpointRestored'){ applyCheckpointRestored(m); } |
| 1617 | + else if (m.type === 'checkpointBusy'){ checkpointBusyNotice(m); } |
1538 | 1618 | else if (m.type === 'verifyRun'){ addVerifyRun(m); } |
1539 | 1619 | else if (m.type === 'verifyOutput'){ verifyOutputAppend(m); } |
1540 | 1620 | else if (m.type === 'verifyDone'){ verifyDoneFinish(m); } |
|
0 commit comments