SLOPSHOPPER

desktop-compare-desk

Compare two local text files in responsive side-by-side columns.

newpanecommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desktop-compare-desk
│ ┃ desktop-compare-desk ✕ › fix the failing auth test and add an audit log call │ ┃ Desktop Compare Desk requires the Claude │ ┃ Desktop Code tab. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /desktop-compare-desk │ ⎿ desktop-compare-desk: Desktop Compare Desk requires the Claude D │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · desktop-compare-desk
Desktop Compare Desk requires the Claude Desktop Code tab.
README

Desktop Compare Desk

Compare two local text files in responsive side-by-side columns.

Use it when: Check two versions without switching windows.

Desktop Compare Desk fixture preview

Desktop-surface native test-kit tree, painted in a browser fixture preview with sample data. This is not a Claude Desktop app capture. Provenance.

Install and open

Requires Claude Code 2.1.287+ in the Claude Desktop Code tab; tested with 2.1.288's native test kit. Chat/Cowork tabs do not host this API. WSL Desktop sessions do not support plugins. No runtime packages or build step.

Install the current checkout immediately from a terminal in this repository:

claude plugin marketplace add .
claude plugin install desktop-compare-desk@awesome-claude-code-mods

Or install the published collection:

claude plugin marketplace add whyashthakker/awesome-claude-code-mods
claude plugin install desktop-compare-desk@awesome-claude-code-mods

Start a new local Code session in Claude Desktop, or run /reload-plugins in an open Code session, then /desktop-compare-desk. Click controls or use Tab and Enter; Esc closes the pane. Non-Desktop commands return a compatibility explanation. Desktop setup and all 20 mods.

Access and behavior

Reads two user-chosen regular non-symlink text files up to 128 KiB each on Compare. No writes. Line positions compare truncated previews; insertions shift subsequent positions.

Histories begin at load and reset on reload; no earlier activity is reconstructed. Store keys use the workspace directory; saved items are local to this plugin. Files/processes use session cwd. Stored content and clipboard exports can contain private information. No model calls, automatic prompt submissions, or permission approvals.

Verify

claude plugin validate mods/desktop-compare-desk --strict
claude plugin test mods/desktop-compare-desk

Native tests cover command opening, pane isolation, Desktop controls at two widths, unsupported surfaces, error/empty states, and preview capture. These checks validate element trees and callbacks; native Desktop painting and keyboard acceptance remain a manual check. Validation.

MIT licensed, with source and tests included.

Source 2 files
hooks/register.js 41 lines
1import {clean,text,code,row,frame,bounded,path,xml,num,pct,count,waitUntil,chart,gauge,columns,usageChips,strip} from './ui.js'
2const ID="desktop-compare-desk", TITLE="Desktop Compare Desk"
3let surface=null,prefix=''
4let left='before.txt',right='after.txt',a='',b='',message='Load two files to compare.'
5
6async function readText($,target){const chosen=path(target),p=/^(\/|[A-Za-z]:[\\/])/.test(chosen)?chosen:(await $.session.cwd()).replace(/[\\/]$/,'')+'/'+chosen,s=await $.fs.stat(p);if(s.kind!=='file'||s.isLink||s.size>131072)throw Error('Choose a regular non-symlink text file up to 128 KiB.');const content=await $.fs.read(p);if(content.includes('\0'))throw Error('Binary files are not supported.');return clean(content)}
7
8function changedLines(a,b){const x=a.split('\n'),y=b.split('\n');return Array.from({length:Math.max(x.length,y.length)},(_,i)=>i).filter(i=>x[i]!==y[i]).length}
9
10export function register(on){
11 on('session.start',async($,e,next)=>{
12  surface=e.surface;prefix='workspace:'+encodeURIComponent(e.cwd)+':'
13  await $.command.register({name:ID,description:"Compare two local text files in responsive side-by-side columns.",immediate:true})
14  if(surface==='desktop'){}
15  return next(e)
16 })
17 on('command.run',{command:"desktop-compare-desk"},async($,e)=>{
18  if(surface!=='desktop')return {text:TITLE+' requires the Claude Desktop Code tab.'}
19
20  await $.ui.open({id:ID,title:TITLE,focus:true,closeOnEscape:true})
21  return {}
22 })
23
24
25 on('ui.render',{component:'Pane'},async($,e,next)=>{
26  if(e.requestId!==ID)return next(e)
27  const E=$.ui.resolve(e)
28  if(e.surface!=='desktop')return text(E,TITLE+' requires the Claude Desktop Code tab.')
29  try{return frame(E,TITLE,[...await draw($,E,e),E.Button({key:'refresh',label:'Refresh',onPress:()=>$.ui.invalidate('ui.render')})])}
30  catch(error){return frame(E,TITLE,[text(E,'Unable to display: '+clean(error.message,500),{color:'yellow'}),E.Button({key:'refresh',label:'Retry',onPress:()=>$.ui.invalidate('ui.render')})])}
31 })
32}
33async function draw($,E,e){
34 const redraw=()=>$.ui.invalidate('ui.render')
35 const button=(key,label,onPress)=>E.Button({key,label,onPress})
36 const input=(key,label,value,onSubmit)=>E.Input({key,label,value,submitLabel:'apply',onSubmit})
37 const copy=async(value)=>{const r=await $.ui.copy({text:clean(value,30000)});if(!r.isCopied)$.ui.toast('Clipboard unavailable.')}
38 const aa=a.split('\n'),bb=b.split('\n'),n=Math.max(aa.length,bb.length),changed=Array.from({length:n},(_,i)=>i).filter(i=>aa[i]!==bb[i])
39return [input('left','Left file',left,v=>{left=path(v);redraw()}),input('right','Right file',right,v=>{right=path(v);redraw()}),button('load','Compare files',async()=>{try{const x=await readText($,left),y=await readText($,right);a=x;b=y;message='Compared preview: '+changedLines(x,y)+' differing line positions.'}catch(error){a='';b='';message=error.message}redraw()}),text(E,message),columns(E,e,[[text(E,left,{bold:true}),code(E,a||'No left preview.')],[text(E,right,{bold:true}),code(E,b||'No right preview.')]]),text(E,changed.length?'Changed line positions: '+changed.slice(0,40).map(i=>i+1).join(', '):'No differing preview lines.'),text(E,'Position comparison, not an insertion-aware diff. Each preview is bounded to 9,000 characters.') ]
40}
41
hooks/ui.js 33 lines
1// Pure presentation helpers, copied into each standalone plugin.
2export function clean(v, n=9000) { return String(v ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g,'').slice(0,n) }
3export function text(E,v,props={}) { return E.Text({...props,children:[clean(v)]}) }
4export function code(E,v) { return E.Code({source:clean(v),language:'text'}) }
5export function row(E,children) { return E.Box({flexDirection:'row',columnGap:2,flexWrap:'wrap',children}) }
6export function frame(E,title,children) { return E.Box({flexDirection:'column',gap:1,children:[text(E,title,{bold:true,color:'cyan'}),...children]}) }
7export function bounded(v,n=9000) { if(v.length>n)throw Error('Input exceeds '+n+' characters.');return v }
8export function path(v) { v=v.trim();if(!v||v.startsWith('-')||/[\x00-\x1f]/.test(v))throw Error('Enter a path without control characters or leading flags.');return v }
9export function xml(v) { return clean(v,200).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c])) }
10export function num(v) { return Number.isFinite(v)?v:0 }
11export function pct(v) { return Math.max(0,Math.min(100,num(v))) }
12export function count(v) { return Number.isFinite(v)?v.toLocaleString('en-US'):'not reported' }
13export function waitUntil(v,now) { const n=Date.parse(v);if(!Number.isFinite(n))return 'reset not reported';const m=Math.max(0,Math.ceil((n-now)/60000));return m?Math.floor(m/60)+'h '+m%60+'m':'reset due; awaiting report' }
14export function chart(E,values,labels,alt,scale) {
15 const data=values.slice(-12), names=labels.slice(-12),max=scale||Math.max(1,...data.map(num)),h=40+data.length*30
16 const rows=data.map((v,i)=>'<text x="12" y="'+(28+i*30)+'" fill="#d8dfeb" font-size="12">'+xml(names[i])+'</text><rect x="180" y="'+(12+i*30)+'" width="'+(Math.max(0,num(v))/max*310)+'" height="20" rx="5" fill="#8b9cf7"/><text x="505" y="'+(28+i*30)+'" fill="#d8dfeb" font-size="12">'+xml(Math.round(num(v)*10)/10)+'</text>').join('')
17 return E.Svg({source:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 '+h+'" width="580" height="'+h+'"><rect width="580" height="'+h+'" rx="12" fill="#182234"/>'+rows+'</svg>',alt:clean(alt),height:h})
18}
19export function gauge(E,value,label) { return chart(E,[pct(value)],[label],label+': '+count(value)+'%',100) }
20export function usageChips(u,records,now) {
21 const totals=records.reduce((a,r)=>({input:a.input+num(r.input_tokens),output:a.output+num(r.output_tokens),cache:a.cache+num(r.cache_read_input_tokens)}),{input:0,output:0,cache:0})
22 return [...u.rateLimits.slice(0,4).map(r=>r.kind+' '+count(r.percentUsed)+'% · '+waitUntil(r.resetsAt,now)),
23  'Context '+count(u.context?.percent)+'%', 'Input '+count(totals.input),'Output '+count(totals.output),'Cache read '+count(totals.cache),'USD '+(Number.isFinite(u.cost?.usd)?u.cost.usd.toFixed(2):'not reported')]
24}
25export function strip(E,chips,columns) {
26 const w=Math.min(1000,Math.max(240,(columns||80)*8)),colors=['#bbdbc6','#d4c8ed','#cbd5ed','#edcfbd','#bddec9','#ccd6f1','#e7d6a7']
27 let x=4,y=4
28 const pills=chips.map((s,i)=>{const cw=Math.min(w-8,s.length*6.5+24);if(x+cw>w){x=4;y+=40}const p='<rect x="'+x+'" y="'+y+'" width="'+cw+'" height="32" rx="16" fill="'+colors[i%colors.length]+'"/><text x="'+(x+12)+'" y="'+(y+21)+'" font-family="system-ui" font-size="12" fill="#202523">'+xml(s)+'</text>';x+=cw+8;return p}).join('')
29 const h=y+36
30 return E.Svg({source:'<svg xmlns="http://www.w3.org/2000/svg" width="'+w+'" height="'+h+'" viewBox="0 0 '+w+' '+h+'">'+pills+'</svg>',alt:chips.join('; '),height:h})
31}
32export function columns(E,e,children) { return E.Box({flexDirection:e.props.bodyColumns>=70?'row':'column',gap:2,children:children.map(c=>E.Box({flexDirection:'column',flexGrow:1,children:c}))}) }
33