rdsh 0.13.1

Rust port/shim for dsh: fast launcher, token saver, zero behavior breakage via passthrough
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>rdsh · Rust ハーネス</title>
<style>
:root{color-scheme:dark;--bg:#10151c;--panel:#171f29;--line:#2a3745;--text:#edf3fa;--muted:#a5b4c5;--accent:#82dec8;--danger:#ffb6b0}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,-apple-system,sans-serif}button,input,select,textarea{font:inherit}button,select,input,textarea{color:var(--text);border:1px solid var(--line);background:#111923;border-radius:9px}button{cursor:pointer;padding:9px 17px;transition:background .15s}button:hover:enabled{background:#263746}button:disabled{opacity:.45;cursor:default}button.primary{background:var(--accent);color:#102a25;border-color:var(--accent);font-weight:700}button.danger{color:var(--danger)}button:focus-visible,select:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}header{padding:24px 34px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:20px}.brand{font-size:27px;font-weight:760;letter-spacing:-1px}.brand span{font-size:14px;letter-spacing:0;font-weight:400;color:var(--muted);margin-left:13px}.badges{display:flex;gap:8px;flex-wrap:wrap}.badge{font-size:12px;border:1px solid #32564e;color:var(--accent);background:#162c29;padding:3px 10px;border-radius:20px}.layout{max-width:1500px;margin:auto;padding:26px 34px;display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:22px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden}.toolbar{padding:20px;border-bottom:1px solid var(--line);display:grid;grid-template-columns:1fr 1.4fr;gap:14px}label{display:block;font-size:12px;font-weight:650;color:var(--muted);margin-bottom:6px}select{width:100%;padding:9px 11px}.statusbar{padding:11px 20px;border-bottom:1px solid var(--line);color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:10px}#status{color:var(--accent);white-space:nowrap}#messages{height:calc(100vh - 425px);min-height:290px;overflow:auto;padding:25px 24px}.empty{color:var(--muted);text-align:center;margin:65px 15px}.empty strong{display:block;font-size:23px;color:var(--text);margin-bottom:9px}.message{margin-bottom:21px}.role{font-size:11px;letter-spacing:.07em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:6px}.message[data-role=user]{margin-left:35px;background:#23313f;padding:14px 18px;border-radius:13px}.message[data-role=user] .role{color:#bad1e9}.content{white-space:pre-wrap;overflow-wrap:anywhere}.composer{padding:18px 20px;border-top:1px solid var(--line)}textarea{resize:vertical;width:100%;min-height:85px;max-height:220px;padding:12px}.compose-actions{display:flex;justify-content:space-between;align-items:center;margin-top:11px;gap:12px}.hint{font-size:11px;color:var(--muted)}aside{display:flex;flex-direction:column;gap:18px}.aside-title{padding:16px 18px;border-bottom:1px solid var(--line);font-size:13px;font-weight:700}.workspace{padding:16px 18px;font-family:ui-monospace,monospace;overflow-wrap:anywhere;font-size:12px;color:var(--muted)}#events{padding:0 18px 14px;max-height:calc(100vh - 330px);overflow:auto}.event{border-bottom:1px solid var(--line);padding:14px 0;font-size:12px}.event:last-child{border-bottom:0}.event-title{color:var(--accent);font-weight:650;margin-bottom:6px}.event-body{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);margin:0;font:12px/1.6 ui-monospace,monospace}.error{margin:15px 20px 0;padding:12px 14px;border:1px solid #805351;background:#352326;color:#ffd1cc;border-radius:9px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px}.hidden{display:none!important}.auth{max-width:490px;padding:28px;margin:70px auto}.auth input{width:100%;padding:10px;margin-bottom:15px}.auth h1{font-size:22px}.auth p{color:var(--muted);font-size:13px}dialog{color:var(--text);background:#1a2530;border:1px solid #75cab8;border-radius:15px;padding:0;width:min(650px,90vw);max-height:85vh}dialog::backdrop{background:#050a10b8;backdrop-filter:blur(3px)}.dialog-inner{padding:25px}.dialog-inner h2{font-size:21px;margin:0 0 7px}.dialog-inner p{color:var(--muted);font-size:13px}.approval-tool{font-family:ui-monospace,monospace;font-weight:700;color:var(--accent);padding:10px 0}.approval-args{white-space:pre-wrap;overflow-wrap:anywhere;max-height:30vh;overflow:auto;background:#101923;border:1px solid var(--line);padding:15px;border-radius:9px;font:12px/1.6 ui-monospace,monospace}.dialog-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:22px}.footer-note{font-size:11px;color:var(--muted);margin:12px 18px 17px}@media(max-width:900px){header{padding:18px}.layout{padding:16px;grid-template-columns:1fr}.brand span{display:none}#messages{height:390px}#events{max-height:300px}.toolbar{grid-template-columns:1fr 1fr}.hint{display:none}}
</style>
</head>
<body>
<header><div class="brand">rdsh <span>ローカル AI ワークスペース</span></div><div class="badges"><span class="badge">Rust rdsh ハーネス</span><span class="badge">DSH 非使用</span></div></header>
<section id="auth" class="panel auth hidden"><h1>ローカル GUI に接続</h1><p>起動時に表示された URL を開くか、接続トークンを入力してください。トークンはこのタブのセッション内に保持します。</p><form id="auth-form"><label for="token">接続トークン</label><input id="token" type="password" autocomplete="off" required><button class="primary" type="submit">接続</button></form><p id="auth-error" role="alert"></p></section>
<main id="app" class="layout hidden">
<section class="panel"><div class="toolbar"><div><label for="provider">プロバイダー</label><select id="provider" aria-label="プロバイダー"></select></div><div><label for="model">モデル</label><select id="model" aria-label="モデル"></select></div></div><div class="statusbar"><span id="selected">接続中…</span><span id="status" role="status">接続中</span></div><div id="diagnostics" class="error hidden" role="status"></div><div id="error" class="error hidden" role="alert"></div><div id="messages" aria-label="会話"></div><form id="message-form" class="composer"><label for="prompt">メッセージ</label><textarea id="prompt" placeholder="依頼を入力。read でファイルを読み、devin_cli は承認後に実行します。" required></textarea><div class="compose-actions"><span class="hint">推論ループ・ツール実行・承認は Rust rdsh が管理します。</span><div><button id="cancel" type="button" class="danger hidden">停止</button> <button id="send" type="submit" class="primary">送信</button></div></div></form></section>
<aside><section class="panel"><div class="aside-title">ワークスペース</div><div id="workspace" class="workspace"></div></section><section class="panel"><div class="aside-title">実行ログ · ツールと承認</div><div id="events"></div><div class="footer-note">SDK / API は生成の接続に使用。ツールは rdsh が実行します。</div></section></aside>
</main>
<dialog id="approval"><div class="dialog-inner"><h2>ツール実行の承認</h2><p>表示された呼び出しに一度だけ許可を与えます。</p><div id="approval-tool" class="approval-tool"></div><pre id="approval-args" class="approval-args"></pre><p id="approval-reason"></p><div class="dialog-actions"><button id="reject" class="danger">拒否</button><button id="allow" class="primary">一度だけ許可</button></div></div></dialog>
<script>
(()=>{'use strict';
const $=id=>document.getElementById(id), store='rdsh-native-token';
let token=sessionStorage.getItem(store)||'',state=null,polling=false,requesting=false,failures=0,lastMessages='',lastEvents='',pendingId=null,actionError='';
const hash=new URLSearchParams(location.hash.slice(1));if(hash.has('token')){token=hash.get('token')||'';sessionStorage.setItem(store,token);history.replaceState(null,'',location.pathname+location.search)}
function auth(){ $('auth').classList.toggle('hidden',!!token);$('app').classList.toggle('hidden',!token) }
function error(message){$('error').textContent=message||'';$('error').classList.toggle('hidden',!message)}
async function api(path,body){const response=await fetch(path,{method:body===undefined?'GET':'POST',headers:{'x-rdsh-token':token,...(body===undefined?{}:{'content-type':'application/json'})},body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});let result;try{result=await response.json()}catch{result={error:'サーバーからの応答を読み取れませんでした'}}if(!response.ok){if(response.status===401){token='';sessionStorage.removeItem(store);auth();$('auth-error').textContent='接続トークンを確認してください。'}throw new Error(result.error||`接続エラー (${response.status})`)}return result}
function text(value){return typeof value==='string'?value:JSON.stringify(value,null,2)}
function fill(select,items,current){const signature=JSON.stringify(items);if(select.dataset.items!==signature){select.dataset.items=signature;select.replaceChildren();for(const item of items){const option=document.createElement('option');option.value=item.id;option.textContent=(item.name||item.id)+(item.unavailable?'(利用不可)':'');option.disabled=!!item.unavailable||!!item.models&&!item.models.some(m=>!m.unavailable);option.title=item.unavailable||'';select.append(option)}}if(select.value!==(current||''))select.value=current||''}
function controls(){const busy=requesting||!!state&&['running','approval'].includes(state.status);$('provider').disabled=busy;$('model').disabled=busy;$('send').disabled=busy||!state;$('cancel').classList.toggle('hidden',!state||!['running','approval'].includes(state.status))}
function render(next){state=next;const providers=next.providers||[],provider=providers.find(p=>p.id===next.provider),models=provider?.models||[];fill($('provider'),providers,next.provider);fill($('model'),models,next.model);$('selected').textContent=`${provider?.name||next.provider||'未選択'} / ${models.find(m=>m.id===next.model)?.name||next.model||'未選択'}`;$('workspace').textContent=next.workspace||'未設定';$('status').textContent=({idle:'● 待機中',running:'● 実行中',approval:'● 承認待ち',error:'● エラー'})[next.status]||next.status;error(next.error||actionError);const notes=[...(next.diagnostics||[])];for(const p of providers){const disabled=(p.models||[]).filter(m=>m.unavailable);if(disabled.length)notes.push(`${p.name}: ${disabled.length}件利用不可 — ${[...new Set(disabled.map(m=>m.unavailable))].join(' / ')}`)}$('diagnostics').textContent=notes.join('\n');$('diagnostics').classList.toggle('hidden',!notes.length);
const messages=JSON.stringify(next.messages||[]);if(messages!==lastMessages){lastMessages=messages;const el=$('messages'),bottom=el.scrollHeight-el.scrollTop-el.clientHeight<100;el.replaceChildren();if(!(next.messages||[]).length){const empty=document.createElement('div');empty.className='empty';const title=document.createElement('strong');title.textContent='Rust rdsh で始める';empty.append(title,document.createTextNode('モデルを選んで、生成やファイルの読み取りを試せます。'));el.append(empty)}for(const msg of next.messages||[]){const wrap=document.createElement('article');wrap.className='message';wrap.dataset.role=msg.role;const role=document.createElement('div');role.className='role';role.textContent=msg.role==='user'?'あなた':msg.role==='assistant'?'rdsh':msg.role;const content=document.createElement('div');content.className='content';content.textContent=text(msg.content);wrap.append(role,content);el.append(wrap)}if(bottom)el.scrollTop=el.scrollHeight}
const events=JSON.stringify(next.events||[]);if(events!==lastEvents){lastEvents=events;const el=$('events');el.replaceChildren();for(const evt of next.events||[]){const wrap=document.createElement('div');wrap.className='event';const title=document.createElement('div');title.className='event-title';title.textContent=`${({model:'モデル',tool:'ツール',approval:'承認',error:'エラー',info:'情報'})[evt.kind]||evt.kind}${evt.name?' · '+evt.name:''}`;const body=document.createElement('pre');body.className='event-body';body.textContent=text(evt.content);wrap.append(title,body);el.append(wrap)}el.scrollTop=el.scrollHeight}
const pending=next.pending;if(pending){pendingId=pending.id;$('approval-tool').textContent=pending.tool;$('approval-args').textContent=text(pending.arguments);$('approval-reason').textContent=pending.reason||'';if(!$('approval').open)$('approval').showModal()}else{pendingId=null;if($('approval').open)$('approval').close()}controls()}
async function refresh(){if(polling||!token)return;polling=true;try{const next=await api('/api/state');failures=0;render(next)}catch(e){failures++;error(e.message);$('status').textContent='● 再接続中'}finally{polling=false}}
async function mutate(path,body){requesting=true;controls();try{await api(path,body);actionError='';await refresh();return true}catch(e){actionError=e.message;error(actionError);return false}finally{requesting=false;controls()}}
$('auth-form').addEventListener('submit',async e=>{e.preventDefault();token=$('token').value.trim();sessionStorage.setItem(store,token);$('token').value='';auth();await refresh()});
$('provider').addEventListener('change',async()=>{const provider=state.providers.find(p=>p.id===$('provider').value),model=provider?.models?.find(m=>!m.unavailable)?.id;if(model)await mutate('/api/model',{provider:provider.id,model})});
$('model').addEventListener('change',()=>mutate('/api/model',{provider:$('provider').value,model:$('model').value}));
$('message-form').addEventListener('submit',async e=>{e.preventDefault();const prompt=$('prompt'),value=prompt.value.trim();if(!value||requesting||['running','approval'].includes(state?.status))return;if(await mutate('/api/message',{text:value}))prompt.value=''});
$('cancel').addEventListener('click',()=>mutate('/api/cancel',{}));
async function approve(allow){if(!pendingId||requesting)return;$('allow').disabled=$('reject').disabled=true;try{await mutate('/api/approval',{id:pendingId,allow})}finally{$('allow').disabled=$('reject').disabled=false}}
$('allow').addEventListener('click',()=>approve(true));$('reject').addEventListener('click',()=>approve(false));$('approval').addEventListener('cancel',e=>e.preventDefault());
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
async function loop(){await refresh();setTimeout(loop,document.hidden?4000:Math.min(700*Math.max(1,failures),5000))}auth();loop();
})();
</script>
</body>
</html>