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