diff --git a/.changeset/pretty-clowns-double.md b/.changeset/pretty-clowns-double.md new file mode 100644 index 000000000..70e4ab691 --- /dev/null +++ b/.changeset/pretty-clowns-double.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added an action timeline in the VS Code sidebar so you can click a prior mutating tool step and revert the workspace files and conversation back to that point. diff --git a/.gitignore b/.gitignore index 812d1c9d6..c40d5761d 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,9 @@ agents # These contain conversation snapshots and should not be committed **/checkpoints/ +# Action timeline before-images (per-session user data) +.nanocoder/timeline/ + # Heap snapshots .heap-snapshots diff --git a/assets/nanocoder-vscode.vsix b/assets/nanocoder-vscode.vsix index 7d849c6cf..e23af4f9d 100644 Binary files a/assets/nanocoder-vscode.vsix and b/assets/nanocoder-vscode.vsix differ diff --git a/plugins/vscode/media/chat-panel.css b/plugins/vscode/media/chat-panel.css index a7094b352..0d0c065da 100644 --- a/plugins/vscode/media/chat-panel.css +++ b/plugins/vscode/media/chat-panel.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.\!block{display:block!important}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}.settings-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--vscode-editor-foreground);opacity:.6;padding:.5rem .75rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;transition:opacity .15s,border-color .15s}.settings-tab:hover{opacity:.9}.settings-tab.active{opacity:1;border-bottom-color:var(--vscode-focusBorder);font-weight:600}.settings-section{background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);border-radius:6px;padding:.75rem}.settings-section-title{font-size:.8em;font-weight:600;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-bottom:.5rem}.settings-list{display:flex;flex-direction:column;gap:0}.settings-list-item{display:flex;align-items:center;gap:.5rem;padding:.375rem 0;font-size:.9em;border-bottom:1px solid var(--vscode-widget-border)}.settings-list-item:last-child{border-bottom:none}.settings-list-item-name{font-weight:500;flex-shrink:0}.settings-list-item-detail{opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.settings-list-empty{font-size:.85em;opacity:.5;padding:.25rem 0;font-style:italic}.settings-row{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.375rem 0;border-bottom:1px solid var(--vscode-widget-border)}.settings-row:last-child{border-bottom:none}.settings-row-info{display:flex;flex-direction:column;flex:1;min-width:0}.settings-row-label{font-size:.9em;font-weight:500}.settings-row-desc{font-size:.78em;opacity:.55;margin-top:.1em}.settings-toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;cursor:pointer}.settings-toggle input{opacity:0;width:0;height:0}.settings-toggle-slider{position:absolute;top:0;left:0;right:0;bottom:0;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border,transparent);border-radius:20px;transition:background-color .2s}.settings-toggle-slider:before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background-color:var(--vscode-editor-foreground);opacity:.6;border-radius:50%;transition:transform .2s,opacity .2s}.settings-toggle input:checked+.settings-toggle-slider{background-color:var(--vscode-button-background);border-color:var(--vscode-button-background)}.settings-toggle input:checked+.settings-toggle-slider:before{transform:translateX(16px);opacity:1;background-color:var(--vscode-button-foreground)}.settings-select{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground);border:1px solid var(--vscode-dropdown-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;outline:none;min-width:100px}.settings-select:focus{border-color:var(--vscode-focusBorder)}.settings-number-input{background-color:var(--vscode-input-background);color:var(--vscode-input-foreground);border:1px solid var(--vscode-input-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;width:70px;outline:none;text-align:center}.settings-number-input:focus{border-color:var(--vscode-focusBorder)}.settings-action-btn{display:flex;align-items:center;gap:.5rem;background:transparent;border:1px solid var(--vscode-button-secondaryBackground);color:var(--vscode-editor-foreground);border-radius:4px;padding:.375rem .625rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;transition:background-color .15s}.settings-action-btn:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.settings-action-btn-danger{border-color:rgba(241,76,76,.4);color:#f14c4c}.settings-action-btn-danger:hover{background-color:rgba(241,76,76,.1)}.settings-badge{display:inline-flex;align-items:center;gap:.25rem;font-size:.78em;padding:.125rem .375rem;border-radius:3px;font-weight:500}.settings-badge-ok{background-color:rgba(137,209,133,.15);color:#89d185}.settings-badge-off{background-color:hsla(0,0%,80%,.1);color:#999}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.\!block{display:block!important}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[28px\]{min-height:28px}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.65em\]{font-size:.65em}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}#timeline-strip.timeline-disabled{opacity:.45;pointer-events:none}#timeline-nodes{position:relative}.timeline-line{position:absolute;left:10px;right:10px;top:50%;height:2px;background:var(--vscode-panel-border,hsla(0,0%,50%,.35));transform:translateY(-50%);pointer-events:none}.timeline-node{position:relative;z-index:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:transparent;border:none;padding:0;cursor:pointer}.timeline-dot{width:10px;height:10px;border-radius:50%;border:2px solid var(--vscode-focusBorder);background:var(--vscode-editor-background);transition:transform .12s ease,background .12s ease}.timeline-node[data-kind=edit] .timeline-dot{border-color:var(--vscode-focusBorder)}.timeline-node[data-kind=execute] .timeline-dot{border-color:var(--vscode-editorWarning-foreground,#cca700)}.timeline-node[data-kind=other] .timeline-dot{border-color:var(--vscode-descriptionForeground,hsla(0,0%,50%,.8))}.timeline-node[data-kind=now] .timeline-dot{width:8px;height:8px;background:var(--vscode-button-background);border-color:var(--vscode-button-background)}.timeline-node.is-selected .timeline-dot,.timeline-node:hover .timeline-dot{transform:scale(1.35);background:var(--vscode-focusBorder)}.timeline-node[data-kind=now].is-selected .timeline-dot,.timeline-node[data-kind=now]:hover .timeline-dot{background:var(--vscode-button-background)}.timeline-tooltip{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);white-space:nowrap;max-width:240px;overflow:hidden;text-overflow:ellipsis;padding:4px 8px;border-radius:4px;background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);color:var(--vscode-editor-foreground);font-size:.75em;z-index:5;pointer-events:none}.timeline-node:hover .timeline-tooltip{display:block}.timeline-confirm-actions{display:flex;gap:8px;margin-top:8px}.timeline-confirm-actions button{border:none;border-radius:4px;padding:4px 10px}.settings-tab,.timeline-confirm-actions button{font-size:.85em;cursor:pointer;font-family:var(--vscode-font-family)}.settings-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--vscode-editor-foreground);opacity:.6;padding:.5rem .75rem;transition:opacity .15s,border-color .15s}.settings-tab:hover{opacity:.9}.settings-tab.active{opacity:1;border-bottom-color:var(--vscode-focusBorder);font-weight:600}.settings-section{background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);border-radius:6px;padding:.75rem}.settings-section-title{font-size:.8em;font-weight:600;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-bottom:.5rem}.settings-list{display:flex;flex-direction:column;gap:0}.settings-list-item{display:flex;align-items:center;gap:.5rem;padding:.375rem 0;font-size:.9em;border-bottom:1px solid var(--vscode-widget-border)}.settings-list-item:last-child{border-bottom:none}.settings-list-item-name{font-weight:500;flex-shrink:0}.settings-list-item-detail{opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.settings-list-empty{font-size:.85em;opacity:.5;padding:.25rem 0;font-style:italic}.settings-row{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.375rem 0;border-bottom:1px solid var(--vscode-widget-border)}.settings-row:last-child{border-bottom:none}.settings-row-info{display:flex;flex-direction:column;flex:1;min-width:0}.settings-row-label{font-size:.9em;font-weight:500}.settings-row-desc{font-size:.78em;opacity:.55;margin-top:.1em}.settings-toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;cursor:pointer}.settings-toggle input{opacity:0;width:0;height:0}.settings-toggle-slider{position:absolute;top:0;left:0;right:0;bottom:0;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border,transparent);border-radius:20px;transition:background-color .2s}.settings-toggle-slider:before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background-color:var(--vscode-editor-foreground);opacity:.6;border-radius:50%;transition:transform .2s,opacity .2s}.settings-toggle input:checked+.settings-toggle-slider{background-color:var(--vscode-button-background);border-color:var(--vscode-button-background)}.settings-toggle input:checked+.settings-toggle-slider:before{transform:translateX(16px);opacity:1;background-color:var(--vscode-button-foreground)}.settings-select{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground);border:1px solid var(--vscode-dropdown-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;outline:none;min-width:100px}.settings-select:focus{border-color:var(--vscode-focusBorder)}.settings-number-input{background-color:var(--vscode-input-background);color:var(--vscode-input-foreground);border:1px solid var(--vscode-input-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;width:70px;outline:none;text-align:center}.settings-number-input:focus{border-color:var(--vscode-focusBorder)}.settings-action-btn{display:flex;align-items:center;gap:.5rem;background:transparent;border:1px solid var(--vscode-button-secondaryBackground);color:var(--vscode-editor-foreground);border-radius:4px;padding:.375rem .625rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;transition:background-color .15s}.settings-action-btn:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.settings-action-btn-danger{border-color:rgba(241,76,76,.4);color:#f14c4c}.settings-action-btn-danger:hover{background-color:rgba(241,76,76,.1)}.settings-badge{display:inline-flex;align-items:center;gap:.25rem;font-size:.78em;padding:.125rem .375rem;border-radius:3px;font-weight:500}.settings-badge-ok{background-color:rgba(137,209,133,.15);color:#89d185}.settings-badge-off{background-color:hsla(0,0%,80%,.1);color:#999}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file diff --git a/plugins/vscode/media/chat-panel.html b/plugins/vscode/media/chat-panel.html index 3a1e57de3..bf72dcf66 100644 --- a/plugins/vscode/media/chat-panel.html +++ b/plugins/vscode/media/chat-panel.html @@ -14,6 +14,15 @@
+
diff --git a/plugins/vscode/media/chat-panel.js b/plugins/vscode/media/chat-panel.js index 366f788cb..dbf12ffc7 100644 --- a/plugins/vscode/media/chat-panel.js +++ b/plugins/vscode/media/chat-panel.js @@ -350,6 +350,152 @@ }); } + const EDIT_TOOLS = new Set(['write_file', 'string_replace', 'diff_edit', 'file_op']); + const EXECUTE_TOOLS = new Set(['execute_bash']); + + function timelineKind(toolName) { + if (EDIT_TOOLS.has(toolName)) return 'edit'; + if (EXECUTE_TOOLS.has(toolName)) return 'execute'; + return 'other'; + } + + function timelineRelativeTime(timestamp) { + const diffMs = Date.now() - new Date(timestamp).getTime(); + const minutes = Math.floor(diffMs / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; + } + + const timelineStrip = (function createTimelineStrip() { + const root = document.getElementById('timeline-strip'); + const nodesEl = document.getElementById('timeline-nodes'); + const confirmEl = document.getElementById('timeline-confirm'); + if (!root || !nodesEl || !confirmEl) { + return { + setEntries() {}, + setDisabled() {}, + clear() {}, + }; + } + + let entries = []; + let pendingId = null; + + function hideConfirm() { + pendingId = null; + confirmEl.classList.add('hidden'); + confirmEl.innerHTML = ''; + } + + function showConfirm(entry) { + pendingId = entry.id; + const files = (entry.filesChanged || []).slice(0, 3).join(', '); + const extra = (entry.filesChanged || []).length > 3 ? '…' : ''; + confirmEl.innerHTML = ''; + + const text = document.createElement('div'); + text.textContent = + `Revert workspace and conversation to before step ${entry.seq} (${entry.title || entry.toolName})? ` + + `This deletes later chat messages and undoes later file changes.` + + (files ? ` Files: ${files}${extra}` : ''); + confirmEl.appendChild(text); + + const actions = document.createElement('div'); + actions.className = 'timeline-confirm-actions'; + + const revertBtn = document.createElement('button'); + revertBtn.textContent = 'Revert'; + revertBtn.style.background = 'var(--vscode-button-background)'; + revertBtn.style.color = 'var(--vscode-button-foreground)'; + revertBtn.addEventListener('click', () => { + vscode.postMessage({ type: 'revertToCheckpoint', checkpointId: entry.id }); + hideConfirm(); + }); + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.style.background = 'var(--vscode-button-secondaryBackground)'; + cancelBtn.style.color = 'var(--vscode-button-secondaryForeground, inherit)'; + cancelBtn.addEventListener('click', hideConfirm); + + actions.appendChild(revertBtn); + actions.appendChild(cancelBtn); + confirmEl.appendChild(actions); + confirmEl.classList.remove('hidden'); + } + + function render() { + nodesEl.innerHTML = ''; + if (entries.length === 0) { + root.classList.add('hidden'); + hideConfirm(); + return; + } + root.classList.remove('hidden'); + + const line = document.createElement('div'); + line.className = 'timeline-line'; + nodesEl.appendChild(line); + + for (const entry of entries) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'timeline-node'; + btn.dataset.kind = timelineKind(entry.toolName); + btn.dataset.id = entry.id; + btn.setAttribute('aria-label', `Step ${entry.seq}: ${entry.title || entry.toolName}`); + + const dot = document.createElement('span'); + dot.className = 'timeline-dot'; + btn.appendChild(dot); + + const tip = document.createElement('span'); + tip.className = 'timeline-tooltip'; + const files = (entry.filesChanged || []).slice(0, 2).join(', '); + tip.textContent = `Step ${entry.seq} · ${entry.title || entry.toolName}` + + (files ? ` · ${files}` : '') + + ` · ${timelineRelativeTime(entry.timestamp)}`; + btn.appendChild(tip); + + btn.addEventListener('click', () => showConfirm(entry)); + nodesEl.appendChild(btn); + } + + const nowBtn = document.createElement('button'); + nowBtn.type = 'button'; + nowBtn.className = 'timeline-node is-selected'; + nowBtn.dataset.kind = 'now'; + nowBtn.setAttribute('aria-label', 'Current state'); + const nowDot = document.createElement('span'); + nowDot.className = 'timeline-dot'; + nowBtn.appendChild(nowDot); + const nowTip = document.createElement('span'); + nowTip.className = 'timeline-tooltip'; + nowTip.textContent = 'Now'; + nowBtn.appendChild(nowTip); + nowBtn.addEventListener('click', hideConfirm); + nodesEl.appendChild(nowBtn); + + nodesEl.scrollLeft = nodesEl.scrollWidth; + } + + return { + setEntries(next) { + entries = Array.isArray(next) ? next : []; + hideConfirm(); + render(); + }, + setDisabled(disabled) { + root.classList.toggle('timeline-disabled', Boolean(disabled)); + }, + clear() { + this.setEntries([]); + }, + }; + })(); function toggleHistoryView() { isHistoryView = !isHistoryView; if (isHistoryView) { @@ -504,6 +650,7 @@ // --- Send / Stop toggle logic --- function setProcessing(active) { isProcessing = active; + timelineStrip.setDisabled(active); if (!active) { // Globally cancel any stuck spinners across all tool cards, // in case multiple aggregators were created in the same session @@ -1471,6 +1618,9 @@ clearInterval(currentThoughtBox.timer); currentThoughtBox = null; } + if (!message.isLoading) { + timelineStrip.clear(); + } setProcessing(false); break; case 'sessionLoaded': @@ -1509,6 +1659,9 @@ sessionsData = message.sessions || []; renderSessions(); // Always update so list is ready when history opens break; + case 'updateTimeline': + timelineStrip.setEntries(message.entries || []); + break; case 'runPrompt': if (isHistoryView) showChatView(); dispatchPrompt(message.text); diff --git a/plugins/vscode/src/acp-client.spec.ts b/plugins/vscode/src/acp-client.spec.ts index 51ef880b3..78f111232 100644 --- a/plugins/vscode/src/acp-client.spec.ts +++ b/plugins/vscode/src/acp-client.spec.ts @@ -113,13 +113,53 @@ test('NanocoderAcpClient - a cancelled prompt does not raise an error toast', as t.regex(shownError ?? '', /RequestError/, 'A genuine failure must still surface'); }); +test('NanocoderAcpClient - listTimeline returns entries from extMethod', async (t) => { + const client = makeClient({ + extMethod: async (method: string, params: Record) => { + t.is(method, 'timeline/list'); + t.is(params.sessionId, 'session-1'); + return { + entries: [ + { + id: 'cp-1', + seq: 1, + toolCallId: 'call-1', + toolName: 'write_file', + title: 'write_file: a.ts', + timestamp: '2026-01-01T00:00:00.000Z', + filesChanged: ['a.ts'], + }, + ], + }; + }, + }); + + const entries = await client.listTimeline(); + t.is(entries.length, 1); + t.is(entries[0].id, 'cp-1'); +}); + +test('NanocoderAcpClient - revertTimeline calls timeline/revert', async (t) => { + let called: {method?: string; params?: Record} = {}; + const client = makeClient({ + extMethod: async (method: string, params: Record) => { + called = {method, params}; + return {revertedTo: {id: 'cp-1'}, filesRestored: ['a.ts']}; + }, + }); + + await client.revertTimeline('cp-1'); + t.is(called.method, 'timeline/revert'); + t.deepEqual(called.params, {sessionId: 'session-1', checkpointId: 'cp-1'}); +}); + test('NanocoderAcpClient - reconnecting clears permissions left by the dead process', async (t) => { - const outputChannel = { appendLine: () => {} } as any; + const outputChannel = {appendLine: () => {}} as any; const stateManager = new AcpStateManager(); const client = new NanocoderAcpClient(outputChannel, stateManager); const requestPromise = client.handlePermissionRequest({ - toolCall: { toolCallId: 'call_456', name: 'test_tool', arguments: {} }, + toolCall: {toolCallId: 'call_456', name: 'test_tool', arguments: {}}, }); client.setConnection({} as any); diff --git a/plugins/vscode/src/acp-client.ts b/plugins/vscode/src/acp-client.ts index 0354c5160..e5fc78aba 100644 --- a/plugins/vscode/src/acp-client.ts +++ b/plugins/vscode/src/acp-client.ts @@ -25,6 +25,15 @@ export class NanocoderAcpClient { public onPermissionsCancelled?: (toolCallIds: string[]) => void; public onStateSync?: (state: StateSyncPayload) => void; public onConnectionReady?: () => void; + public onTimelineUpdated?: (entries: Array<{ + id: string; + seq: number; + toolCallId: string; + toolName: string; + title: string; + timestamp: string; + filesChanged: string[]; + }>) => void; public currentMode?: string; public availableModes: string[] = []; @@ -437,4 +446,40 @@ export class NanocoderAcpClient { } } + async listTimeline(): Promise> { + if (!this.connection || !this._sessionId) return []; + try { + const result = await this.connection.extMethod('timeline/list', { + sessionId: this._sessionId, + }); + const entries = (result as {entries?: unknown}).entries; + return Array.isArray(entries) ? entries : []; + } catch (error) { + this.outputChannel.appendLine(`listTimeline failed: ${error}`); + return []; + } + } + + async revertTimeline(checkpointId: string): Promise { + if (!this.connection || !this._sessionId) return; + try { + await this.connection.extMethod('timeline/revert', { + sessionId: this._sessionId, + checkpointId, + }); + } catch (error) { + this.outputChannel.appendLine(`revertTimeline failed: ${error}`); + vscode.window.showErrorMessage(`Failed to revert timeline: ${error}`); + throw error; + } + } + } diff --git a/plugins/vscode/src/chat-webview-provider.ts b/plugins/vscode/src/chat-webview-provider.ts index 0341304bf..ff1683c64 100644 --- a/plugins/vscode/src/chat-webview-provider.ts +++ b/plugins/vscode/src/chat-webview-provider.ts @@ -39,6 +39,7 @@ export class ChatWebviewProvider private _view?: vscode.WebviewView; private _isWebviewReady = false; + private _timelineRefreshTimer?: ReturnType; /** Code lens prompt waiting on the webview shell and the ACP session. */ private _pendingPrompt: string | null = null; private _pendingPromptTimer: ReturnType | null = null; @@ -59,6 +60,10 @@ export class ChatWebviewProvider type: 'acpUpdate', update }); + const kind = update?.update?.sessionUpdate ?? update?.sessionUpdate; + if (kind === 'tool_call' || kind === 'tool_call_update') { + this.scheduleTimelineRefresh(); + } }; this._acpClient.onPermissionRequested = (toolCallId: string, toolCall: any, options?: any[]) => { @@ -276,6 +281,7 @@ export class ChatWebviewProvider this.postMessage({type: 'clear', isLoading: true}); this._acpClient.resumeSession(message.sessionId).finally(() => { this.postMessage({type: 'sessionLoaded'}); + this._broadcastTimeline(); }); break; case 'deleteSession': @@ -359,6 +365,12 @@ export class ChatWebviewProvider case 'copyToClipboard': this._copyToClipboard(message.text); break; + case 'requestTimeline': + this._broadcastTimeline(); + break; + case 'revertToCheckpoint': + this._handleRevert(message.checkpointId); + break; } } ); @@ -382,6 +394,7 @@ export class ChatWebviewProvider this._outputChannel.appendLine(`[Extension] Session initialized automatically: ${sessionId}`); // Broadcast session list to populate History tab await this._broadcastSessions(); + await this._broadcastTimeline(); this._flushPendingPrompt(); } } catch (error) { @@ -406,21 +419,53 @@ export class ChatWebviewProvider this.postMessage({type: 'updateSessions', sessions}); } + private scheduleTimelineRefresh() { + if (this._timelineRefreshTimer) { + clearTimeout(this._timelineRefreshTimer); + } + this._timelineRefreshTimer = setTimeout(() => { + this._broadcastTimeline(); + }, 150); + } + + private async _broadcastTimeline() { + const entries = await this._acpClient.listTimeline(); + this.postMessage({type: 'updateTimeline', entries}); + } + + private async _handleRevert(checkpointId: string) { + this._outputChannel.appendLine(`[Webview] User reverted timeline to ${checkpointId}`); + this.postMessage({type: 'clear', isLoading: true}); + try { + await this._acpClient.revertTimeline(checkpointId); + } catch { + // Error toast is raised by the ACP client. + } finally { + this.postMessage({type: 'sessionLoaded'}); + await this._broadcastTimeline(); + } + } + private _handleRequestSettings() { const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); const settings = this._settingsManager.readSettings(cwd); - this.postMessage({ type: 'settingsData', settings }); + this.postMessage({type: 'settingsData', settings}); } private _handleUpdateSetting(key: string, value: unknown) { const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); const result = this._settingsManager.updateSetting(cwd, key, value); - this.postMessage({ type: 'settingsUpdated', key, success: result.success, error: result.error }); + this.postMessage({ + type: 'settingsUpdated', + key, + success: result.success, + error: result.error, + }); // If successful, send refreshed settings so the UI stays in sync if (result.success) { const settings = this._settingsManager.readSettings(cwd); - this.postMessage({ type: 'settingsData', settings }); + this.postMessage({type: 'settingsData', settings}); } else { vscode.window.showErrorMessage(`Failed to save setting '${key}': ${result.error}`); } @@ -433,12 +478,12 @@ export class ChatWebviewProvider try { if (!fs.existsSync(filePath)) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.mkdirSync(path.dirname(filePath), {recursive: true}); fs.writeFileSync(filePath, '{}\n', 'utf-8'); } const doc = await vscode.workspace.openTextDocument(filePath); await vscode.window.showTextDocument(doc); - } catch (err) { + } catch { vscode.window.showErrorMessage( `Could not open ${file} at ${filePath}. Ensure the file exists.`, ); @@ -584,6 +629,7 @@ export class ChatWebviewProvider // message then streams into the fresh view). if (text.trim() === '/clear') { this.postMessage({type: 'clear'}); + this.postMessage({type: 'updateTimeline', entries: []}); } // Expand any @[file] / @[folder] attachments into their contents diff --git a/plugins/vscode/src/extension.ts b/plugins/vscode/src/extension.ts index 177c827d3..49827d49e 100644 --- a/plugins/vscode/src/extension.ts +++ b/plugins/vscode/src/extension.ts @@ -108,6 +108,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('nanocoder.newChat', () => { acpClient.newChat(); chatProvider.postMessage({type: 'clear'}); + chatProvider.postMessage({type: 'updateTimeline', entries: []}); outputChannel.appendLine('[Extension] New chat started — session cleared.'); }), diff --git a/plugins/vscode/src/styles.css b/plugins/vscode/src/styles.css index 25eff4733..f65b4698c 100644 --- a/plugins/vscode/src/styles.css +++ b/plugins/vscode/src/styles.css @@ -155,6 +155,119 @@ select option { z-index: 10; } +/* ── Action Timeline ────────────────────────────────── */ +#timeline-strip.timeline-disabled { + opacity: 0.45; + pointer-events: none; +} + +#timeline-nodes { + position: relative; +} + +.timeline-line { + position: absolute; + left: 10px; + right: 10px; + top: 50%; + height: 2px; + background: var(--vscode-panel-border, rgba(128,128,128,0.35)); + transform: translateY(-50%); + pointer-events: none; +} + +.timeline-node { + position: relative; + z-index: 1; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: transparent; + border: none; + padding: 0; + cursor: pointer; +} + +.timeline-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--vscode-focusBorder); + background: var(--vscode-editor-background); + transition: transform 120ms ease, background 120ms ease; +} + +.timeline-node[data-kind="edit"] .timeline-dot { + border-color: var(--vscode-focusBorder); +} + +.timeline-node[data-kind="execute"] .timeline-dot { + border-color: var(--vscode-editorWarning-foreground, #cca700); +} + +.timeline-node[data-kind="other"] .timeline-dot { + border-color: var(--vscode-descriptionForeground, rgba(128,128,128,0.8)); +} + +.timeline-node[data-kind="now"] .timeline-dot { + width: 8px; + height: 8px; + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.timeline-node.is-selected .timeline-dot, +.timeline-node:hover .timeline-dot { + transform: scale(1.35); + background: var(--vscode-focusBorder); +} + +.timeline-node[data-kind="now"].is-selected .timeline-dot, +.timeline-node[data-kind="now"]:hover .timeline-dot { + background: var(--vscode-button-background); +} + +.timeline-tooltip { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + padding: 4px 8px; + border-radius: 4px; + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border); + color: var(--vscode-editor-foreground); + font-size: 0.75em; + z-index: 5; + pointer-events: none; +} + +.timeline-node:hover .timeline-tooltip { + display: block; +} + +.timeline-confirm-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.timeline-confirm-actions button { + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 0.85em; + cursor: pointer; + font-family: var(--vscode-font-family); +} /* ── Settings UI ────────────────────────────────────── */ .settings-tab { background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--vscode-editor-foreground); opacity: 0.6; padding: 0.5rem 0.75rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: opacity 0.15s, border-color 0.15s; } .settings-tab:hover { opacity: 0.9; } diff --git a/plugins/vscode/src/webview-protocol.ts b/plugins/vscode/src/webview-protocol.ts index 4f1edd002..98eb7eb9d 100644 --- a/plugins/vscode/src/webview-protocol.ts +++ b/plugins/vscode/src/webview-protocol.ts @@ -95,6 +95,21 @@ export interface ExtensionMessageCopyResult { error?: string; } +export interface TimelineCheckpoint { + id: string; + seq: number; + toolCallId: string; + toolName: string; + title: string; + timestamp: string; + filesChanged: string[]; +} + +export interface ExtensionMessageUpdateTimeline { + type: 'updateTimeline'; + entries: TimelineCheckpoint[]; +} + export interface ExtensionMessageUpdateSessions { type: 'updateSessions'; sessions: Array<{ @@ -185,7 +200,8 @@ export type ExtensionToWebviewMessage = | ExtensionMessageCopyLastCodeBlock | ExtensionMessageCopyResult | ExtensionMessageRunPrompt - | ExtensionMessageMentionCompletions; + | ExtensionMessageMentionCompletions + | ExtensionMessageUpdateTimeline; // --------------------------------------------------------- @@ -321,6 +337,15 @@ export interface WebviewMessageRequestMentionCompletions { requestId: number; } +export interface WebviewMessageRequestTimeline { + type: 'requestTimeline'; +} + +export interface WebviewMessageRevertToCheckpoint { + type: 'revertToCheckpoint'; + checkpointId: string; +} + export type WebviewToExtensionMessage = | WebviewMessageReady | WebviewMessageSubmitMessage @@ -345,4 +370,6 @@ export type WebviewToExtensionMessage = | WebviewMessageOpenPath | WebviewMessageShowError | WebviewMessageCopyToClipboard - | WebviewMessageRequestMentionCompletions; + | WebviewMessageRequestMentionCompletions + | WebviewMessageRequestTimeline + | WebviewMessageRevertToCheckpoint; diff --git a/source/acp/acp-agent.spec.ts b/source/acp/acp-agent.spec.ts index 31d865636..7ffe9177f 100644 --- a/source/acp/acp-agent.spec.ts +++ b/source/acp/acp-agent.spec.ts @@ -1,4 +1,4 @@ -import {mkdirSync} from 'node:fs'; +import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; @@ -55,6 +55,7 @@ const createMockInitContext = (): AcpInitContext => ({ getFilteredTools: () => ({}), hasTool: () => false, getToolEntry: () => undefined, + isReadOnly: () => true, } as any, customCommandLoader: null as any, provider: 'test-provider', @@ -356,14 +357,11 @@ test('AcpAgent.prompt - propagates API errors cleanly', async t => { throw new Error('RequestError: Internal error (500)'); }; - const session = agent.registerSession('session-1', { - conn: agent['conn'], - sessionId: 'session-1', - canReadTextFile: false, - }); + const created = await agent.newSession({cwd: '/tmp'}); + const session = agent['sessions'].get(created.sessionId); - const error = await t.throwsAsync( - () => agent.prompt({sessionId: 'session-1', prompt: [{type: 'text', text: 'crash please'}]}), + await t.throwsAsync( + () => agent.prompt({sessionId: created.sessionId, prompt: [{type: 'text', text: 'crash please'}]}), {message: /RequestError/} ); @@ -668,3 +666,82 @@ test.serial( ); }, ); + +test('AcpAgent.extMethod - timeline/list returns captured entries', async t => { + const {agent} = createAgent(); + const cwd = join(tmpdir(), `nanocoder-timeline-agent-${Date.now()}`); + mkdirSync(cwd, {recursive: true}); + const created = await agent.newSession({cwd, mcpServers: []}); + const session = (agent as any).sessions.get(created.sessionId); + await session.timeline.capture({ + toolCallId: 'call-1', + toolName: 'write_file', + title: 'write_file: a.ts', + truncateToMessageIndex: 1, + files: new Map([['a.ts', 'before']]), + }); + + const result = await agent.extMethod('timeline/list', { + sessionId: created.sessionId, + }); + t.is((result.entries as any[]).length, 1); + t.is((result.entries as any[])[0].toolName, 'write_file'); +}); + +test('AcpAgent.extMethod - timeline/revert truncates messages and restores files', async t => { + const {agent} = createAgent(); + const cwd = join(tmpdir(), `nanocoder-timeline-revert-${Date.now()}`); + mkdirSync(cwd, {recursive: true}); + writeFileSync(join(cwd, 'a.ts'), 'before'); + + const created = await agent.newSession({cwd, mcpServers: []}); + const session = (agent as any).sessions.get(created.sessionId); + session.messages = [ + {role: 'user', content: 'edit a.ts'}, + {role: 'assistant', content: '', tool_calls: []}, + {role: 'tool', content: 'wrote', name: 'write_file'}, + ]; + const entry = await session.timeline.capture({ + toolCallId: 'call-1', + toolName: 'write_file', + title: 'write_file: a.ts', + truncateToMessageIndex: 1, + files: new Map([['a.ts', 'before']]), + }); + writeFileSync(join(cwd, 'a.ts'), 'after'); + + const result = await agent.extMethod('timeline/revert', { + sessionId: created.sessionId, + checkpointId: entry.id, + }); + t.is((result.revertedTo as any).id, entry.id); + t.is(readFileSync(join(cwd, 'a.ts'), 'utf-8'), 'before'); + t.is(session.messages.length, 2); + t.is(session.messages[0].role, 'user'); + t.true( + String(session.messages[1].content).includes('Reverted to before step 1'), + ); +}); + +test('AcpAgent.extMethod - timeline/revert rejects an active turn', async t => { + const {agent} = createAgent(); + const created = await agent.newSession({cwd: '/tmp', mcpServers: []}); + const session = (agent as any).sessions.get(created.sessionId); + session.turnActive = true; + await t.throwsAsync( + agent.extMethod('timeline/revert', { + sessionId: created.sessionId, + checkpointId: 'any', + }), + {message: /prompt is in progress/}, + ); +}); + +test('AcpAgent.extMethod - timeline/list throws on missing session', async t => { + const {agent} = createAgent(); + await t.throwsAsync( + agent.extMethod('timeline/list', {sessionId: 'missing'}), + {message: /Session not found/}, + ); +}); + diff --git a/source/acp/acp-agent.ts b/source/acp/acp-agent.ts index 4b26b8f22..26d430fa7 100644 --- a/source/acp/acp-agent.ts +++ b/source/acp/acp-agent.ts @@ -45,6 +45,7 @@ import {createLLMClient} from '@/client-factory'; import {getAppConfig} from '@/config/index'; import {loadPreferences, updateLastUsed} from '@/config/preferences'; import {resolveTune} from '@/config/tune'; +import {TimelineManager} from '@/services/timeline-manager'; import {sessionManager} from '@/session/session-manager'; import {getTuneToolMode} from '@/types/config'; import {getLogger} from '@/utils/logging'; @@ -216,8 +217,9 @@ export class AcpAgent implements Agent { }; if (commandName === 'clear') { - // Clear the conversation history + // Clear the conversation history and action timeline session.messages = []; + await session.timeline.clear(); const msg = 'Conversation cleared.'; this.conn.sessionUpdate({ sessionId: params.sessionId, @@ -483,6 +485,18 @@ export class AcpAgent implements Agent { params: DeleteSessionRequest, ): Promise { await sessionManager.initialize(); + const existing = this.sessions.get(params.sessionId); + if (existing) { + await existing.timeline.clear(); + } else { + const persisted = await sessionManager.loadSession(params.sessionId); + if (persisted) { + await new TimelineManager( + persisted.workingDirectory, + params.sessionId, + ).clear(); + } + } await sessionManager.deleteSession(params.sessionId); // Evict from in-memory map if present this.sessions.delete(params.sessionId); @@ -546,6 +560,50 @@ export class AcpAgent implements Agent { return {title: updated.title}; } + if (method === 'timeline/list') { + const sessionId = params.sessionId; + if (typeof sessionId !== 'string') { + throw new Error('timeline/list requires a string sessionId'); + } + const session = this.requireSession(sessionId); + return {entries: await session.timeline.list()}; + } + + if (method === 'timeline/revert') { + const sessionId = params.sessionId; + const checkpointId = params.checkpointId; + if (typeof sessionId !== 'string' || typeof checkpointId !== 'string') { + throw new Error( + 'timeline/revert requires string sessionId and checkpointId', + ); + } + const session = this.requireSession(sessionId); + if (session.turnActive) { + throw new Error( + 'Cannot revert the timeline while a prompt is in progress', + ); + } + + const result = await session.timeline.revertTo(checkpointId); + session.messages = session.messages.slice( + 0, + result.revertedTo.truncateToMessageIndex, + ); + session.messages.push({ + role: 'assistant', + content: `Reverted to before step ${result.revertedTo.seq} (${result.revertedTo.toolName}). How should we proceed instead?`, + }); + await this.saveAcpSessionToDisk(session); + await this.replaySessionHistory(session); + logger.info( + `ACP extMethod timeline/revert: session=${sessionId} checkpoint=${checkpointId}`, + ); + return { + revertedTo: result.revertedTo, + filesRestored: result.filesRestored, + }; + } + throw new Error(`Unknown extension method: ${method}`); } @@ -579,6 +637,14 @@ export class AcpAgent implements Agent { return {}; } + private requireSession(sessionId: string): AcpSession { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + return session; + } + private registerSession(sessionId: string, cwd: string): AcpSession { const session = new AcpSession({ sessionId, diff --git a/source/acp/acp-conversation.spec.ts b/source/acp/acp-conversation.spec.ts index 2ae5e39fa..5c8fb5dd6 100644 --- a/source/acp/acp-conversation.spec.ts +++ b/source/acp/acp-conversation.spec.ts @@ -1,3 +1,6 @@ +import {mkdirSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import test from 'ava'; import type {AgentSideConnection} from '@agentclientprotocol/sdk'; import {AcpSession} from '@/acp/acp-session'; @@ -49,11 +52,15 @@ const createMockSession = ( messages?: any[]; systemMessage?: any; aborted?: boolean; + sessionId?: string; + cwd?: string; } = {}, ): AcpSession => { const session = new AcpSession({ - sessionId: 'test-session', - cwd: '/tmp', + sessionId: + opts.sessionId ?? + `test-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + cwd: opts.cwd ?? '/tmp', conn, initialMode: opts.devMode ?? 'auto-accept', }); @@ -94,6 +101,7 @@ const createMockToolManager = () => ({ getFilteredTools: () => ({}), hasTool: (_name: string) => false, getToolEntry: () => ({approval: false}), + isReadOnly: (name: string) => name !== 'write_file' && name !== 'execute_bash', }); // ============================================================================ @@ -1379,6 +1387,7 @@ test('runAcpConversation - ask_user coerces object options and returns the choic getFilteredTools: () => ({}), hasTool: (n: string) => n === 'ask_user', getToolEntry: () => ({approval: false}), + isReadOnly: () => true, }; const result = await runAcpConversation({ @@ -1419,6 +1428,7 @@ test('runAcpConversation - ask_user fails cleanly when no usable options', async getFilteredTools: () => ({}), hasTool: (n: string) => n === 'ask_user', getToolEntry: () => ({approval: false}), + isReadOnly: () => true, }; await runAcpConversation({ @@ -1434,3 +1444,112 @@ test('runAcpConversation - ask_user fails cleanly when no usable options', async ); t.true(toolMsg?.content.startsWith('Error:')); }); + +// ============================================================================ +// Action timeline capture +// ============================================================================ + +test('runAcpConversation - captures a timeline checkpoint for write_file', async t => { + const {conn} = createMockConn(); + const cwd = join(tmpdir(), `nanocoder-timeline-conv-${Date.now()}`); + mkdirSync(cwd, {recursive: true}); + writeFileSync(join(cwd, 'a.ts'), 'before'); + + const session = createMockSession(conn, {devMode: 'yolo', cwd}); + const toolManager = { + ...createMockToolManager(), + hasTool: () => true, + isReadOnly: (name: string) => name !== 'write_file', + }; + + setToolRegistryGetter(() => ({ + write_file: async () => 'Wrote a.ts', + })); + + let callCount = 0; + const client = { + chat: async () => { + callCount++; + if (callCount === 1) { + return { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall( + 'write_file', + {path: 'a.ts', content: 'after'}, + 'call-write', + ), + ], + }, + }, + ], + }; + } + return {choices: [{message: {content: 'done'}}]}; + }, + } as unknown as LLMClient; + + await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + const entries = await session.timeline.list(); + t.is(entries.length, 1); + t.is(entries[0].toolName, 'write_file'); + t.is(entries[0].toolCallId, 'call-write'); + t.is(entries[0].truncateToMessageIndex, 0); +}); + +test('runAcpConversation - does not capture a timeline checkpoint for read_file', async t => { + const {conn} = createMockConn(); + const session = createMockSession(conn, {devMode: 'yolo'}); + const toolManager = { + ...createMockToolManager(), + hasTool: () => true, + isReadOnly: () => true, + }; + + setToolRegistryGetter(() => ({ + read_file: async () => 'content', + })); + + let callCount = 0; + const client = { + chat: async () => { + callCount++; + if (callCount === 1) { + return { + choices: [ + { + message: { + content: '', + tool_calls: [ + createMockToolCall('read_file', {path: 'a.ts'}, 'call-read'), + ], + }, + }, + ], + }; + } + return {choices: [{message: {content: 'done'}}]}; + }, + } as unknown as LLMClient; + + await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + t.deepEqual(await session.timeline.list(), []); +}); + diff --git a/source/acp/acp-conversation.ts b/source/acp/acp-conversation.ts index 373b7daaf..565b9fdaf 100644 --- a/source/acp/acp-conversation.ts +++ b/source/acp/acp-conversation.ts @@ -6,6 +6,7 @@ import type { import {requestToolPermission} from '@/acp/acp-permission'; import {requestUserChoice} from '@/acp/acp-question'; import type {AcpSession} from '@/acp/acp-session'; +import {beginTimelineCapture, finishTimelineCapture} from '@/acp/acp-timeline'; import {type AcpToolCallMeta, buildToolCallMeta} from '@/acp/acp-tool-call'; import {DEFAULT_HEADLESS_MAX_TURNS, getAppConfig} from '@/config/index'; import {processToolUse} from '@/message-handler'; @@ -403,6 +404,14 @@ export async function runAcpConversation( // Execute tool await emitToolCallUpdate(session, conn, toolCall, 'in_progress'); + const timelineCapture = await beginTimelineCapture( + session, + toolManager, + toolCall, + messages, + meta.title, + ); + let pollInterval: ReturnType | null = null; let isPolling = true; if (toolCall.function.name === 'agent') { @@ -453,6 +462,7 @@ export async function runAcpConversation( const toolResult = await processToolUse(toolCall, { abortSignal: abortController.signal, }); + await finishTimelineCapture(session, timelineCapture); isPolling = false; if (pollInterval) clearInterval(pollInterval); diff --git a/source/acp/acp-queued-tools.spec.ts b/source/acp/acp-queued-tools.spec.ts index 355f56c18..3881cf09b 100644 --- a/source/acp/acp-queued-tools.spec.ts +++ b/source/acp/acp-queued-tools.spec.ts @@ -53,6 +53,7 @@ const createMockToolManager = () => ({ getFilteredTools: () => ({}), hasTool: () => true, getToolEntry: () => ({approval: false}), + isReadOnly: (name: string) => name !== 'write_file' && name !== 'execute_bash', }); /** Every update carrying this tool call id, in emission order. */ diff --git a/source/acp/acp-session.spec.ts b/source/acp/acp-session.spec.ts index 28be5e4a6..671a28c65 100644 --- a/source/acp/acp-session.spec.ts +++ b/source/acp/acp-session.spec.ts @@ -89,6 +89,15 @@ test('AcpSession - abortController starts non-aborted', t => { t.false(session.abortController.signal.aborted); }); +test('AcpSession - constructs a timeline manager for the session', t => { + const session = new AcpSession({ + sessionId: 'test-id', + cwd: '/tmp', + conn: createMockConn(), + }); + t.truthy(session.timeline); +}); + // ============================================================================ // cancel() // ============================================================================ diff --git a/source/acp/acp-session.ts b/source/acp/acp-session.ts index 7b6c310d3..ccd12f5ae 100644 --- a/source/acp/acp-session.ts +++ b/source/acp/acp-session.ts @@ -2,6 +2,7 @@ import type { AgentSideConnection, ClientCapabilities, } from '@agentclientprotocol/sdk'; +import {TimelineManager} from '@/services/timeline-manager'; import type {DevelopmentMode, Message} from '@/types/core'; export class AcpSession { @@ -9,6 +10,7 @@ export class AcpSession { readonly cwd: string; readonly conn: AgentSideConnection; readonly clientCapabilities?: ClientCapabilities; + readonly timeline: TimelineManager; messages: Message[] = []; systemMessage?: Message; @@ -31,6 +33,7 @@ export class AcpSession { this.conn = options.conn; this.clientCapabilities = options.clientCapabilities; this.developmentMode = options.initialMode ?? 'auto-accept'; + this.timeline = new TimelineManager(options.cwd, options.sessionId); } cancel(): void { diff --git a/source/acp/acp-timeline.spec.ts b/source/acp/acp-timeline.spec.ts new file mode 100644 index 000000000..5a4029d48 --- /dev/null +++ b/source/acp/acp-timeline.spec.ts @@ -0,0 +1,83 @@ +import test from 'ava'; +import { + assistantToolCallIndex, + extractTimelineTargets, + isTimelineMutatingTool, +} from '@/acp/acp-timeline'; +import type {Message} from '@/types/core'; + +const mockToolManager = (readOnly: string[] = []) => + ({ + isReadOnly: (name: string) => readOnly.includes(name), + }) as any; + +test('isTimelineMutatingTool - skips read-only tools', t => { + t.false(isTimelineMutatingTool(mockToolManager(['read_file']), 'read_file')); +}); + +test('isTimelineMutatingTool - skips task-list and ask_user tools', t => { + const manager = mockToolManager(); + t.false(isTimelineMutatingTool(manager, 'write_tasks')); + t.false(isTimelineMutatingTool(manager, 'ask_user')); + t.false(isTimelineMutatingTool(manager, 'create_task')); +}); + +test('isTimelineMutatingTool - treats file and bash tools as mutating', t => { + const manager = mockToolManager(['read_file']); + t.true(isTimelineMutatingTool(manager, 'write_file')); + t.true(isTimelineMutatingTool(manager, 'string_replace')); + t.true(isTimelineMutatingTool(manager, 'execute_bash')); + t.true(isTimelineMutatingTool(manager, 'agent')); + t.true(isTimelineMutatingTool(manager, 'git_add')); +}); + +test('extractTimelineTargets - file tools return their path args', t => { + t.deepEqual(extractTimelineTargets('write_file', {path: 'src/a.ts'}), [ + 'src/a.ts', + ]); + t.deepEqual( + extractTimelineTargets('string_replace', {path: 'src/a.ts', old_str: 'x'}), + ['src/a.ts'], + ); + t.deepEqual(extractTimelineTargets('diff_edit', {path: 'src/a.ts'}), [ + 'src/a.ts', + ]); +}); + +test('extractTimelineTargets - file_op includes destination for move/copy', t => { + t.deepEqual( + extractTimelineTargets('file_op', { + operation: 'move', + path: 'a.ts', + destination: 'b.ts', + }), + ['a.ts', 'b.ts'], + ); + t.deepEqual( + extractTimelineTargets('file_op', {operation: 'delete', path: 'a.ts'}), + ['a.ts'], + ); + t.deepEqual( + extractTimelineTargets('file_op', {operation: 'mkdir', path: 'dir'}), + [], + ); +}); + +test('extractTimelineTargets - opaque tools return the git-diff fallback', t => { + t.is(extractTimelineTargets('execute_bash', {command: 'rm -rf src'}), 'opaque'); + t.is(extractTimelineTargets('agent', {prompt: 'edit files'}), 'opaque'); + t.is(extractTimelineTargets('git_commit', {message: 'wip'}), 'opaque'); +}); + +test('assistantToolCallIndex - returns the last assistant message index', t => { + const messages: Message[] = [ + {role: 'user', content: 'hi'}, + {role: 'assistant', content: '', tool_calls: []}, + ]; + t.is(assistantToolCallIndex(messages), 1); +}); + +test('assistantToolCallIndex - falls back to messages.length when none', t => { + const messages: Message[] = [{role: 'user', content: 'hi'}]; + t.is(assistantToolCallIndex(messages), 1); +}); diff --git a/source/acp/acp-timeline.ts b/source/acp/acp-timeline.ts new file mode 100644 index 000000000..891d3c64d --- /dev/null +++ b/source/acp/acp-timeline.ts @@ -0,0 +1,183 @@ +import type {ToolManager} from '@/tools/tool-manager'; +import type {Message, ToolCall} from '@/types/core'; +import type {TimelineEntryMeta} from '@/types/timeline'; +import {formatError} from '@/utils/error-formatter'; +import {logWarning} from '@/utils/message-queue'; +import type {AcpSession} from './acp-session'; + +const FILE_ARG_TOOLS = new Set([ + 'write_file', + 'string_replace', + 'diff_edit', + 'file_op', +]); + +const NON_WORKSPACE_TOOLS = new Set([ + 'ask_user', + 'write_tasks', + 'create_task', + 'update_task', + 'delete_task', + 'list_tasks', + 'switch_mode', +]); + +export function isTimelineMutatingTool( + toolManager: ToolManager, + toolName: string, +): boolean { + if (NON_WORKSPACE_TOOLS.has(toolName)) { + return false; + } + return !toolManager.isReadOnly(toolName); +} + +/** + * Exact file paths this tool will touch, or `'opaque'` when we must fall + * back to a git-status diff (bash, agent, git mutators, custom/MCP). + * An empty array means skip capture (e.g. `file_op mkdir`). + */ +export function extractTimelineTargets( + toolName: string, + args: Record, +): string[] | 'opaque' { + if (!FILE_ARG_TOOLS.has(toolName)) { + return 'opaque'; + } + + const paths: string[] = []; + if (typeof args.path === 'string' && args.path.length > 0) { + paths.push(args.path); + } + if (typeof args.file_path === 'string' && args.file_path.length > 0) { + paths.push(args.file_path); + } + + if (toolName === 'file_op') { + const operation = args.operation; + if (operation === 'mkdir') { + return []; + } + if ( + (operation === 'move' || operation === 'copy') && + typeof args.destination === 'string' && + args.destination.length > 0 + ) { + paths.push(args.destination); + } + } + + return paths; +} + +export function assistantToolCallIndex(messages: Message[]): number { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant') { + return i; + } + } + return messages.length; +} + +interface OpaqueCaptureContext { + mode: 'opaque'; + beforeKeys: Set; + files: Map; + toolCallId: string; + toolName: string; + title: string; + truncateToMessageIndex: number; +} + +interface DirectCaptureContext { + mode: 'direct'; + entry: TimelineEntryMeta | null; +} + +export type TimelineCaptureContext = + | OpaqueCaptureContext + | DirectCaptureContext + | null; + +export async function beginTimelineCapture( + session: AcpSession, + toolManager: ToolManager, + toolCall: ToolCall, + messages: Message[], + title: string, +): Promise { + const toolName = toolCall.function.name; + if (!isTimelineMutatingTool(toolManager, toolName)) { + return null; + } + + const args = (toolCall.function.arguments ?? {}) as Record; + const targets = extractTimelineTargets(toolName, args); + if (targets !== 'opaque' && targets.length === 0) { + return null; + } + + const truncateToMessageIndex = assistantToolCallIndex(messages); + const meta = { + toolCallId: toolCall.id, + toolName, + title, + truncateToMessageIndex, + }; + + try { + if (targets === 'opaque') { + const modified = session.timeline.getModifiedFiles(); + const files = await session.timeline.snapshotPaths(modified); + return { + mode: 'opaque', + beforeKeys: new Set(files.keys()), + files, + ...meta, + }; + } + + const files = await session.timeline.snapshotPaths(targets); + const entry = await session.timeline.capture({...meta, files}); + return {mode: 'direct', entry}; + } catch (error) { + logWarning('Failed to capture action timeline checkpoint', true, { + context: {toolName, error: formatError(error)}, + }); + return null; + } +} + +export async function finishTimelineCapture( + session: AcpSession, + context: TimelineCaptureContext, +): Promise { + if (!context || context.mode !== 'opaque') { + return context?.mode === 'direct' ? context.entry : null; + } + + try { + const afterModified = session.timeline.getModifiedFiles(); + for (const filePath of afterModified) { + const relative = session.timeline.toRelativePath(filePath); + if (!relative || context.beforeKeys.has(relative)) { + continue; + } + const head = session.timeline.getHeadContent(relative); + context.files.set(relative, head); + } + + return await session.timeline.capture({ + toolCallId: context.toolCallId, + toolName: context.toolName, + title: context.title, + truncateToMessageIndex: context.truncateToMessageIndex, + files: context.files, + }); + } catch (error) { + logWarning('Failed to finalize action timeline checkpoint', true, { + context: {toolName: context.toolName, error: formatError(error)}, + }); + return null; + } +} diff --git a/source/acp/conversation.contract.spec.ts b/source/acp/conversation.contract.spec.ts index 0645a5e82..7c1838923 100644 --- a/source/acp/conversation.contract.spec.ts +++ b/source/acp/conversation.contract.spec.ts @@ -23,6 +23,7 @@ const createMockToolManager = (tools: string[], approval: boolean = false) => ({ getFilteredTools: () => tools.reduce((acc, t) => ({...acc, [t]: {}}), {}), hasTool: (name: string) => tools.includes(name), getToolEntry: () => ({approval}), + isReadOnly: (name: string) => name === 'read_file', }); const createMockClient = (toolCalls: ToolCall[]) => ({ diff --git a/source/constants.ts b/source/constants.ts index 577afa118..ece26b47d 100644 --- a/source/constants.ts +++ b/source/constants.ts @@ -40,6 +40,7 @@ export const MAX_SESSION_NAME_LENGTH = 100; // === LIMITS === export const MAX_CHECKPOINT_FILES = 50; +export const MAX_TIMELINE_ENTRIES = 50; export const MAX_FIND_FILES_RESULTS = 100; export const MAX_SEARCH_RESULTS = 100; export const MAX_PROMPT_HISTORY_SIZE = 100; diff --git a/source/services/file-snapshot.ts b/source/services/file-snapshot.ts index 2ed26d67b..5b246d4f7 100644 --- a/source/services/file-snapshot.ts +++ b/source/services/file-snapshot.ts @@ -1,4 +1,4 @@ -import {execSync} from 'child_process'; +import {execFileSync, execSync} from 'child_process'; import {existsSync} from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -124,6 +124,38 @@ export class FileSnapshotService { } } + /** + * Return HEAD contents of a tracked file, or null if git is unavailable + * or the path is not in HEAD (untracked / unknown). + */ + getHeadContent(relativePath: string): string | null { + try { + return execFileSync('git', ['show', `HEAD:${relativePath}`], { + cwd: this.workspaceRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + return null; + } + } + + /** + * Delete a file inside the workspace. Refuses paths that escape the root. + */ + async deleteFile(relativePath: string): Promise { + const absolutePath = path.resolve(this.workspaceRoot, relativePath); // nosemgrep + const relative = path.relative(this.workspaceRoot, absolutePath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error( + `Refusing to delete path outside workspace: ${relativePath}`, + ); + } + if (existsSync(absolutePath)) { + await fs.unlink(absolutePath); + } + } + /** * Get the size of a file snapshot */ diff --git a/source/services/timeline-manager.spec.ts b/source/services/timeline-manager.spec.ts new file mode 100644 index 000000000..ebb02221c --- /dev/null +++ b/source/services/timeline-manager.spec.ts @@ -0,0 +1,271 @@ +import {existsSync} from 'fs'; +import * as path from 'path'; +import test from 'ava'; +import * as fs from 'fs/promises'; +import {MAX_TIMELINE_ENTRIES} from '@/constants'; +import {TimelineManager} from './timeline-manager'; + +async function createTempDir(): Promise { + const tempDir = path.join( + process.cwd(), + '.test-temp', + `timeline-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await fs.mkdir(tempDir, {recursive: true}); + return tempDir; +} + +async function cleanupTempDir(dir: string): Promise { + try { + await fs.rm(dir, {recursive: true, force: true}); + } catch { + // Ignore cleanup errors + } +} + +async function writeFile( + dir: string, + relativePath: string, + content: string, +): Promise { + const fullPath = path.join(dir, relativePath); + await fs.mkdir(path.dirname(fullPath), {recursive: true}); + await fs.writeFile(fullPath, content, 'utf-8'); +} + +function filesMap( + entries: Array<[string, string | null]>, +): Map { + return new Map(entries); +} + +test.serial('TimelineManager captures existing file before-images', async t => { + const tempDir = await createTempDir(); + try { + await writeFile(tempDir, 'src/a.ts', 'version-1'); + const manager = new TimelineManager(tempDir, 'session-one'); + + const entry = await manager.capture({ + toolCallId: 'call-1', + toolName: 'write_file', + title: 'write_file: src/a.ts', + truncateToMessageIndex: 2, + files: await manager.snapshotPaths(['src/a.ts']), + }); + + t.truthy(entry); + t.is(entry?.seq, 1); + t.deepEqual(entry?.filesChanged, ['src/a.ts']); + t.is(entry?.truncateToMessageIndex, 2); + + const listed = await manager.list(); + t.is(listed.length, 1); + t.is(listed[0].id, entry?.id); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager records nonexistent files as created', async t => { + const tempDir = await createTempDir(); + try { + const manager = new TimelineManager(tempDir, 'session-created'); + const snapshots = await manager.snapshotPaths(['brand-new.ts']); + t.is(snapshots.get('brand-new.ts'), null); + + const entry = await manager.capture({ + toolCallId: 'call-new', + toolName: 'write_file', + title: 'write_file: brand-new.ts', + truncateToMessageIndex: 1, + files: snapshots, + }); + + t.truthy(entry); + t.deepEqual(entry?.filesChanged, ['brand-new.ts']); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager skips capture when no files are provided', async t => { + const tempDir = await createTempDir(); + try { + const manager = new TimelineManager(tempDir, 'session-empty'); + const entry = await manager.capture({ + toolCallId: 'call-empty', + toolName: 'execute_bash', + title: 'execute_bash', + truncateToMessageIndex: 0, + files: new Map(), + }); + t.is(entry, null); + t.deepEqual(await manager.list(), []); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager revert restores earlier content in reverse order', async t => { + const tempDir = await createTempDir(); + try { + await writeFile(tempDir, 'a.ts', 'v1'); + const manager = new TimelineManager(tempDir, 'session-revert'); + + const first = await manager.capture({ + toolCallId: 'c1', + toolName: 'write_file', + title: 'step 1', + truncateToMessageIndex: 1, + files: filesMap([['a.ts', 'v1']]), + }); + await writeFile(tempDir, 'a.ts', 'v2'); + + const second = await manager.capture({ + toolCallId: 'c2', + toolName: 'write_file', + title: 'step 2', + truncateToMessageIndex: 3, + files: filesMap([['a.ts', 'v2']]), + }); + await writeFile(tempDir, 'a.ts', 'v3'); + + t.truthy(first); + t.truthy(second); + + const result = await manager.revertTo(first!.id); + t.is(result.revertedTo.id, first!.id); + t.true(result.filesRestored.includes('a.ts')); + + const restored = await fs.readFile(path.join(tempDir, 'a.ts'), 'utf-8'); + t.is(restored, 'v1'); + t.deepEqual(await manager.list(), []); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager revert of a later entry keeps earlier checkpoints', async t => { + const tempDir = await createTempDir(); + try { + const manager = new TimelineManager(tempDir, 'session-partial'); + const first = await manager.capture({ + toolCallId: 'c1', + toolName: 'write_file', + title: 'step 1', + truncateToMessageIndex: 1, + files: filesMap([['a.ts', 'v1']]), + }); + const second = await manager.capture({ + toolCallId: 'c2', + toolName: 'write_file', + title: 'step 2', + truncateToMessageIndex: 3, + files: filesMap([['b.ts', null]]), + }); + await writeFile(tempDir, 'b.ts', 'b2'); + + await manager.revertTo(second!.id); + t.is((await manager.list()).length, 1); + t.is((await manager.list())[0].id, first!.id); + t.false(existsSync(path.join(tempDir, 'b.ts'))); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager revert deletes files created after the checkpoint', async t => { + const tempDir = await createTempDir(); + try { + const manager = new TimelineManager(tempDir, 'session-delete'); + const entry = await manager.capture({ + toolCallId: 'c1', + toolName: 'write_file', + title: 'create', + truncateToMessageIndex: 1, + files: filesMap([['created.ts', null]]), + }); + await writeFile(tempDir, 'created.ts', 'new file'); + + await manager.revertTo(entry!.id); + t.false(existsSync(path.join(tempDir, 'created.ts'))); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager prunes oldest entries past the retention cap', async t => { + const tempDir = await createTempDir(); + try { + const manager = new TimelineManager(tempDir, 'session-prune'); + for (let i = 0; i < MAX_TIMELINE_ENTRIES + 3; i++) { + await manager.capture({ + toolCallId: `c${i}`, + toolName: 'write_file', + title: `step ${i + 1}`, + truncateToMessageIndex: i, + files: filesMap([[`f${i}.ts`, `content-${i}`]]), + }); + } + + const listed = await manager.list(); + t.is(listed.length, MAX_TIMELINE_ENTRIES); + t.is(listed[0].seq, 4); + t.is(listed[listed.length - 1].seq, MAX_TIMELINE_ENTRIES + 3); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager clear removes the session directory', async t => { + const tempDir = await createTempDir(); + try { + const manager = new TimelineManager(tempDir, 'session-clear'); + await manager.capture({ + toolCallId: 'c1', + toolName: 'write_file', + title: 'step', + truncateToMessageIndex: 0, + files: filesMap([['a.ts', 'x']]), + }); + const timelineDir = path.join( + tempDir, + '.nanocoder', + 'timeline', + 'session-clear', + ); + t.true(existsSync(timelineDir)); + await manager.clear(); + t.false(existsSync(timelineDir)); + t.deepEqual(await manager.list(), []); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager rejects unsafe session ids', t => { + t.throws(() => new TimelineManager('/tmp', '../escape'), { + message: /Invalid timeline session id/, + }); + t.throws(() => new TimelineManager('/tmp', 'foo/bar'), { + message: /Invalid timeline session id/, + }); +}); + +test.serial('TimelineManager revertTo throws for unknown checkpoint', async t => { + const tempDir = await createTempDir(); + try { + const manager = new TimelineManager(tempDir, 'session-missing'); + await t.throwsAsync(manager.revertTo('does-not-exist'), { + message: /does not exist/, + }); + } finally { + await cleanupTempDir(tempDir); + } +}); + +test.serial('TimelineManager toRelativePath rejects paths outside the workspace', t => { + const manager = new TimelineManager('/tmp/workspace', 'session-paths'); + t.is(manager.toRelativePath('/tmp/workspace/src/a.ts'), 'src/a.ts'); + t.is(manager.toRelativePath('/etc/passwd'), null); +}); diff --git a/source/services/timeline-manager.ts b/source/services/timeline-manager.ts new file mode 100644 index 000000000..313000a49 --- /dev/null +++ b/source/services/timeline-manager.ts @@ -0,0 +1,331 @@ +import {existsSync} from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import {MAX_TIMELINE_ENTRIES} from '@/constants'; +import type { + TimelineCaptureInput, + TimelineEntryMeta, + TimelineIndex, + TimelineIndexEntry, + TimelineRevertResult, +} from '@/types/timeline'; +import {formatError} from '@/utils/error-formatter'; +import {logWarning} from '@/utils/message-queue'; +import {FileSnapshotService} from './file-snapshot'; + +/** + * Per-session log of before-images captured ahead of mutating tool calls. + * Stored under `.nanocoder/timeline//`. + */ +export class TimelineManager { + private readonly workspaceRoot: string; + private readonly timelineDir: string; + private readonly fileSnapshotService: FileSnapshotService; + private index: TimelineIndex | null = null; + + constructor(workspaceRoot: string, sessionId: string) { + this.assertSafeId(sessionId); + this.workspaceRoot = workspaceRoot; + this.timelineDir = path.join( + workspaceRoot, // nosemgrep + '.nanocoder', + 'timeline', + sessionId, // nosemgrep + ); + this.fileSnapshotService = new FileSnapshotService(workspaceRoot); + } + + toRelativePath(filePath: string): string | null { + const absolutePath = path.resolve(this.workspaceRoot, filePath); // nosemgrep + const relative = path.relative(this.workspaceRoot, absolutePath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + return relative.split(path.sep).join('/'); + } + + getModifiedFiles(): string[] { + return this.fileSnapshotService.getModifiedFiles(); + } + + getHeadContent(relativePath: string): string | null { + return this.fileSnapshotService.getHeadContent(relativePath); + } + + fileExists(relativePath: string): boolean { + const absolutePath = path.resolve(this.workspaceRoot, relativePath); // nosemgrep + return existsSync(absolutePath); + } + + /** + * Snapshot the given paths. Missing files are recorded as `null`. + */ + async snapshotPaths( + filePaths: string[], + ): Promise> { + const result = new Map(); + const existing: string[] = []; + + for (const filePath of filePaths) { + const relative = this.toRelativePath(filePath); + if (!relative) { + continue; + } + if (this.fileExists(relative)) { + existing.push(relative); + } else { + result.set(relative, null); + } + } + + if (existing.length > 0) { + const captured = await this.fileSnapshotService.captureFiles(existing); + for (const [relative, content] of captured) { + result.set(relative, content); + } + } + + return result; + } + + async list(): Promise { + const index = await this.loadIndex(); + return index.entries.map(entry => this.toMeta(entry)); + } + + async capture( + input: TimelineCaptureInput, + ): Promise { + if (input.files.size === 0) { + return null; + } + + await this.ensureDir(); + const index = await this.loadIndex(); + + const createdFiles: string[] = []; + const existing = new Map(); + for (const [relative, content] of input.files) { + const normalized = this.toRelativePath(relative); + if (!normalized) { + continue; + } + if (content === null) { + createdFiles.push(normalized); + } else { + existing.set(normalized, content); + } + } + + if (createdFiles.length === 0 && existing.size === 0) { + return null; + } + + const id = crypto.randomUUID(); + const entry: TimelineIndexEntry = { + id, + seq: index.nextSeq, + toolCallId: input.toolCallId, + toolName: input.toolName, + title: input.title, + timestamp: new Date().toISOString(), + truncateToMessageIndex: input.truncateToMessageIndex, + filesChanged: [...existing.keys(), ...createdFiles], + createdFiles, + }; + + if (existing.size > 0) { + const filesDir = this.entryFilesDir(id); + await fs.mkdir(filesDir, {recursive: true}); + for (const [relativePath, content] of existing) { + const filePath = path.join(filesDir, relativePath); // nosemgrep + await fs.mkdir(path.dirname(filePath), {recursive: true}); + await fs.writeFile(filePath, content, 'utf-8'); + } + } + + index.entries.push(entry); + index.nextSeq += 1; + await this.pruneOldest(index); + await this.saveIndex(index); + + return this.toMeta(entry); + } + + async revertTo(checkpointId: string): Promise { + const index = await this.loadIndex(); + const targetIndex = index.entries.findIndex( + entry => entry.id === checkpointId, + ); + if (targetIndex === -1) { + throw new Error(`Timeline checkpoint '${checkpointId}' does not exist`); + } + + const toRevert = index.entries.slice(targetIndex).reverse(); + const filesRestored: string[] = []; + + for (const entry of toRevert) { + const restored = await this.restoreEntry(entry); + filesRestored.push(...restored); + } + + index.entries = index.entries.slice(0, targetIndex); + await this.saveIndex(index); + + for (const entry of toRevert) { + await this.removeEntryDir(entry.id); + } + + return { + revertedTo: this.toMeta(toRevert[toRevert.length - 1]), + filesRestored: [...new Set(filesRestored)], + }; + } + + async clear(): Promise { + this.index = { + nextSeq: 1, + entries: [], + }; + if (existsSync(this.timelineDir)) { + await fs.rm(this.timelineDir, {recursive: true, force: true}); + } + } + + private async restoreEntry(entry: TimelineIndexEntry): Promise { + const restored: string[] = []; + const created = new Set(entry.createdFiles); + const snapshots = new Map(); + const filesDir = this.entryFilesDir(entry.id); + + for (const relativePath of entry.filesChanged) { + if (created.has(relativePath)) { + try { + await this.fileSnapshotService.deleteFile(relativePath); + restored.push(relativePath); + } catch (error) { + logWarning('Could not delete created timeline file', true, { + context: { + relativePath, + error: formatError(error), + }, + }); + } + continue; + } + + try { + const filePath = path.join(filesDir, relativePath); // nosemgrep + const content = await fs.readFile(filePath, 'utf-8'); + snapshots.set(relativePath, content); + } catch (error) { + logWarning('Could not load timeline file snapshot', true, { + context: { + relativePath, + error: formatError(error), + }, + }); + } + } + + if (snapshots.size > 0) { + await this.fileSnapshotService.restoreFiles(snapshots); + restored.push(...snapshots.keys()); + } + + return restored; + } + + private async pruneOldest(index: TimelineIndex): Promise { + while (index.entries.length > MAX_TIMELINE_ENTRIES) { + const oldest = index.entries.shift(); + if (oldest) { + await this.removeEntryDir(oldest.id); + } + } + } + + private async removeEntryDir(id: string): Promise { + const dir = path.join(this.timelineDir, 'entries', id); // nosemgrep + if (existsSync(dir)) { + await fs.rm(dir, {recursive: true, force: true}); + } + } + + private entryFilesDir(id: string): string { + return path.join(this.timelineDir, 'entries', id, 'files'); // nosemgrep + } + + private toMeta(entry: TimelineIndexEntry): TimelineEntryMeta { + return { + id: entry.id, + seq: entry.seq, + toolCallId: entry.toolCallId, + toolName: entry.toolName, + title: entry.title, + timestamp: entry.timestamp, + truncateToMessageIndex: entry.truncateToMessageIndex, + filesChanged: entry.filesChanged, + }; + } + + private async ensureDir(): Promise { + if (!existsSync(this.timelineDir)) { + await fs.mkdir(this.timelineDir, {recursive: true}); + } + } + + private indexPath(): string { + return path.join(this.timelineDir, 'timeline.json'); // nosemgrep + } + + private async loadIndex(): Promise { + if (this.index) { + return this.index; + } + + const indexPath = this.indexPath(); + if (!existsSync(indexPath)) { + this.index = {nextSeq: 1, entries: []}; + return this.index; + } + + try { + const raw = await fs.readFile(indexPath, 'utf-8'); + const parsed = JSON.parse(raw) as TimelineIndex; + if ( + !Array.isArray(parsed.entries) || + typeof parsed.nextSeq !== 'number' + ) { + throw new Error('Invalid timeline index'); + } + this.index = parsed; + return this.index; + } catch (error) { + logWarning('Could not read timeline index, starting empty', true, { + context: {error: formatError(error)}, + }); + this.index = {nextSeq: 1, entries: []}; + return this.index; + } + } + + private async saveIndex(index: TimelineIndex): Promise { + this.index = index; + await this.ensureDir(); + await fs.writeFile( + this.indexPath(), + JSON.stringify(index, null, 2), + 'utf-8', + ); + } + + private assertSafeId(id: string): void { + if (!id || id.length > 100 || id.includes('..') || id.startsWith('.')) { + throw new Error(`Invalid timeline session id: '${id}'`); + } + if (/[<>:"/\\|?*]/.test(id)) { + throw new Error(`Invalid timeline session id: '${id}'`); + } + } +} diff --git a/source/types/index.ts b/source/types/index.ts index 05a762245..0b4e57140 100644 --- a/source/types/index.ts +++ b/source/types/index.ts @@ -11,5 +11,6 @@ export * from '@/types/hooks'; export * from '@/types/mcp'; export * from '@/types/skills'; export * from '@/types/system'; +export * from '@/types/timeline'; export * from '@/types/ui'; export * from '@/types/utils'; diff --git a/source/types/timeline.ts b/source/types/timeline.ts new file mode 100644 index 000000000..3596baefa --- /dev/null +++ b/source/types/timeline.ts @@ -0,0 +1,39 @@ +/** + * Metadata for a single action-timeline checkpoint (no file contents). + * One entry is recorded immediately before each workspace-mutating tool call. + */ +export interface TimelineEntryMeta { + id: string; + seq: number; + toolCallId: string; + toolName: string; + title: string; + timestamp: string; + /** Slice `messages` to this index (exclusive) so the assistant tool_calls turn is dropped. */ + truncateToMessageIndex: number; + filesChanged: string[]; +} + +export interface TimelineCaptureInput { + toolCallId: string; + toolName: string; + title: string; + truncateToMessageIndex: number; + /** Relative paths → UTF-8 before-image, or `null` if the file did not exist (revert deletes it). */ + files: Map; +} + +export interface TimelineRevertResult { + revertedTo: TimelineEntryMeta; + filesRestored: string[]; +} + +export interface TimelineIndex { + nextSeq: number; + entries: TimelineIndexEntry[]; +} + +export interface TimelineIndexEntry extends TimelineEntryMeta { + /** Files that did not exist before this tool call; revert unlinks them. */ + createdFiles: string[]; +} diff --git a/source/vscode/chat-panel-harness.ts b/source/vscode/chat-panel-harness.ts index e92840986..47972c96b 100644 --- a/source/vscode/chat-panel-harness.ts +++ b/source/vscode/chat-panel-harness.ts @@ -1,10 +1,9 @@ /** - * Boots `plugins/vscode/media/chat-panel.js` inside a VM against a stub DOM so - * the panel's rendering can be driven and inspected from tests. Shared by the - * chat-panel specs; extracted verbatim from chat-panel-thoughts.spec.ts. - * - * `mention-utils.js` must run first: the real webview loads it before - * `chat-panel.js`, which immediately reads `globalThis.NanocoderMentionUtils`. + * Boots the chat panel scripts inside a VM against a stub DOM so the panel's + * rendering can be driven and inspected from tests. Shared by the chat-panel + * specs. Mirrors production load order in chat-panel.html: mention-utils.js + * must run first because chat-panel.js reads `globalThis.NanocoderMentionUtils` + * at IIFE eval time. */ import {readFileSync} from 'node:fs'; import {fileURLToPath} from 'node:url';