feat(posimai-dev): slash commands, quick chips, input history, paste fix, voice toggle

- /morning /status /commit /deploy /fix /explain /test /ls — popup with keyboard nav
- Quick chips row: tap to send preset prompts (朝の確認/状況確認/コミット/デプロイ/エラー修正)
- Input history: ArrowUp/Down navigates previous messages (bash-style)
- Paste fix: submitChat keeps focus on chat input so Ctrl+V works immediately
- Right-click on terminal: paste clipboard to shell
- Voice auto-send toggle in settings panel (persisted to localStorage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-03-31 01:04:47 +09:00
parent d7f38faa59
commit 28b787b0df
1 changed files with 302 additions and 75 deletions

View File

@ -74,7 +74,7 @@
} }
.status-badge.disconnected { background: rgba(239,68,68,0.12); color: #F87171; } .status-badge.disconnected { background: rgba(239,68,68,0.12); color: #F87171; }
/* Claude開始ボタン — アイコンのみ */ /* Claude 開始ボタン */
.claude-btn { .claude-btn {
width: 36px; height: 36px; border-radius: 8px; border: none; cursor: pointer; width: 36px; height: 36px; border-radius: 8px; border: none; cursor: pointer;
background: var(--accent-dim); color: var(--accent); background: var(--accent-dim); color: var(--accent);
@ -83,28 +83,9 @@
} }
.claude-btn:hover { background: rgba(167,139,250,0.25); } .claude-btn:hover { background: rgba(167,139,250,0.25); }
/* マイクボタン */ /* icon-btn */
.mic-btn { .icon-btn { color: rgba(255,255,255,0.5); }
width: 38px; height: 38px; border-radius: 10px; border: none; .icon-btn:hover { color: #F3F4F6; background: rgba(255,255,255,0.06); }
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.35); cursor: pointer;
display: none; align-items: center; justify-content: center;
flex-shrink: 0; transition: background 0.15s, color 0.15s;
}
.mic-btn.available { display: flex; }
.mic-btn:hover { background: rgba(255,255,255,0.1); color: #F3F4F6; }
.mic-btn.listening {
background: rgba(239,68,68,0.15); color: #F87171;
animation: mic-pulse 1s ease-in-out infinite;
}
@keyframes mic-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
/* 設定パネル内セッション表示 */
.session-info-row {
display: flex; align-items: center; gap: 8px; margin-top: 8px;
font-size: 11px; color: rgba(255,255,255,0.35); font-family: monospace;
background: rgba(255,255,255,0.04); border-radius: 8px; padding: 8px 10px;
}
.session-info-row.hidden { display: none; }
/* ── Terminal ── */ /* ── Terminal ── */
#terminal-container { #terminal-container {
@ -120,23 +101,79 @@
#terminal-container .xterm { height: 100%; } #terminal-container .xterm { height: 100%; }
#terminal-container .xterm-viewport { border-radius: 8px 8px 0 0; } #terminal-container .xterm-viewport { border-radius: 8px 8px 0 0; }
/* スクロールバー — 細く・半透明 */
.xterm-viewport::-webkit-scrollbar { width: 4px; } .xterm-viewport::-webkit-scrollbar { width: 4px; }
.xterm-viewport::-webkit-scrollbar-track { background: transparent; } .xterm-viewport::-webkit-scrollbar-track { background: transparent; }
.xterm-viewport::-webkit-scrollbar-thumb { background: rgba(167,139,250,0.25); border-radius: 2px; } .xterm-viewport::-webkit-scrollbar-thumb { background: rgba(167,139,250,0.25); border-radius: 2px; }
.xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(167,139,250,0.5); } .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(167,139,250,0.5); }
/* ── Chat input bar ── */ /* ── Input wrapper (relative anchor for slash popup) ── */
.input-wrapper {
position: relative;
flex-shrink: 0;
background: rgba(12, 18, 33, 0.9);
border-top: 1px solid rgba(255,255,255,0.06);
}
/* ── Quick chips ── */
.chips-bar {
display: flex;
align-items: center;
padding: 8px 12px 4px;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
}
.chips-bar::-webkit-scrollbar { display: none; }
.chip {
display: flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 20px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.45);
font-size: 11px; font-weight: 500; font-family: Inter, sans-serif;
cursor: pointer; white-space: nowrap; flex-shrink: 0;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:hover { background: var(--accent-dim); color: var(--accent); border-color: rgba(167,139,250,0.2); }
/* ── Slash command popup ── */
.slash-popup {
position: absolute;
bottom: 100%;
left: 12px; right: 12px;
background: rgba(10, 14, 28, 0.98);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 -8px 32px rgba(0,0,0,0.5);
display: none;
z-index: 100;
}
.slash-popup.open { display: block; }
.slash-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.slash-item:last-child { border-bottom: none; }
.slash-item:hover, .slash-item.active { background: rgba(167,139,250,0.1); }
.slash-cmd {
font-family: monospace; font-size: 12px; font-weight: 600;
color: var(--accent); min-width: 82px;
}
.slash-label { font-size: 12px; color: rgba(255,255,255,0.35); }
/* ── Chat bar ── */
.chat-bar { .chat-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 6px 12px;
padding-bottom: max(10px, env(safe-area-inset-bottom)); padding-bottom: max(10px, env(safe-area-inset-bottom));
background: rgba(12, 18, 33, 0.9);
border-top: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
} }
.chat-input { .chat-input {
flex: 1; flex: 1;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
@ -149,8 +186,22 @@
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.chat-input::placeholder { color: rgba(255,255,255,0.25); }
.chat-input:focus { border-color: rgba(167,139,250,0.4); } .chat-input:focus { border-color: rgba(167,139,250,0.4); }
.mic-btn {
width: 38px; height: 38px; border-radius: 10px; border: none;
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.35); cursor: pointer;
display: none; align-items: center; justify-content: center;
flex-shrink: 0; transition: background 0.15s, color 0.15s;
}
.mic-btn.available { display: flex; }
.mic-btn:hover { background: rgba(255,255,255,0.1); color: #F3F4F6; }
.mic-btn.listening {
background: rgba(239,68,68,0.15); color: #F87171;
animation: mic-pulse 1s ease-in-out infinite;
}
@keyframes mic-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.send-btn { .send-btn {
width: 38px; height: 38px; border-radius: 10px; border: none; width: 38px; height: 38px; border-radius: 10px; border: none;
background: var(--accent); color: #0C1221; cursor: pointer; background: var(--accent); color: #0C1221; cursor: pointer;
@ -160,12 +211,36 @@
.send-btn:hover { opacity: 0.85; } .send-btn:hover { opacity: 0.85; }
.send-btn:disabled { opacity: 0.3; cursor: default; } .send-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Settings panel extras ── */
.overlay { display: none; } .overlay { display: none; }
.settings-panel { z-index: 1000; } .settings-panel { z-index: 1000; }
/* icon-btn override for dark bg */ .session-info-row {
.icon-btn { color: rgba(255,255,255,0.5); } display: flex; align-items: center; gap: 8px; margin-top: 8px;
.icon-btn:hover { color: #F3F4F6; background: rgba(255,255,255,0.06); } font-size: 11px; color: rgba(255,255,255,0.35); font-family: monospace;
background: rgba(255,255,255,0.04); border-radius: 8px; padding: 8px 10px;
}
.session-info-row.hidden { display: none; }
/* Toggle switch */
.toggle-row {
display: flex; align-items: center; justify-content: space-between;
margin-top: 10px; cursor: pointer; user-select: none;
}
.toggle-label { font-size: 13px; color: rgba(255,255,255,0.55); }
.toggle-wrap { position: relative; display: inline-block; width: 38px; height: 22px; flex-shrink: 0; }
.toggle-wrap input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute; inset: 0; border-radius: 11px;
background: rgba(255,255,255,0.1); transition: background 0.2s; cursor: pointer;
}
.toggle-track::after {
content: ''; position: absolute; top: 3px; left: 3px;
width: 16px; height: 16px; border-radius: 50%;
background: rgba(255,255,255,0.35); transition: transform 0.2s, background 0.2s;
}
.toggle-wrap input:checked + .toggle-track { background: rgba(167,139,250,0.3); }
.toggle-wrap input:checked + .toggle-track::after { transform: translateX(16px); background: var(--accent); }
</style> </style>
</head> </head>
<body> <body>
@ -186,6 +261,16 @@
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button> <button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
</div> </div>
</div> </div>
<div style="margin-top:20px">
<div class="settings-group-label">音声入力</div>
<label class="toggle-row">
<span class="toggle-label">認識後に自動送信</span>
<span class="toggle-wrap">
<input type="checkbox" id="voiceAutosend">
<span class="toggle-track"></span>
</span>
</label>
</div>
<div style="margin-top:20px"> <div style="margin-top:20px">
<div class="settings-group-label">セッション</div> <div class="settings-group-label">セッション</div>
<div class="session-info-row hidden" id="settingsSessionBadge"> <div class="session-info-row hidden" id="settingsSessionBadge">
@ -219,7 +304,15 @@
<div id="terminal-container"></div> <div id="terminal-container"></div>
<div class="chat-bar"> <div class="input-wrapper">
<!-- スラッシュコマンドポップアップ -->
<div class="slash-popup" id="slashPopup" role="listbox"></div>
<!-- クイックチップス -->
<div class="chips-bar" id="chipsBar"></div>
<!-- チャットバー -->
<div class="chat-bar">
<input <input
type="text" type="text"
class="chat-input" class="chat-input"
@ -235,11 +328,13 @@
<button class="send-btn" id="sendBtn" disabled aria-label="送信"> <button class="send-btn" id="sendBtn" disabled aria-label="送信">
<i data-lucide="arrow-up" style="width:16px;height:16px;stroke-width:2.5"></i> <i data-lucide="arrow-up" style="width:16px;height:16px;stroke-width:2.5"></i>
</button> </button>
</div>
</div> </div>
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script> <script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
<script> <script>
(function () { (function () {
// ── Elements ──
const statusBadge = document.getElementById('statusBadge'); const statusBadge = document.getElementById('statusBadge');
const settingsSessionRow = document.getElementById('settingsSessionBadge'); const settingsSessionRow = document.getElementById('settingsSessionBadge');
const settingsSessionId = document.getElementById('settingsSessionId'); const settingsSessionId = document.getElementById('settingsSessionId');
@ -248,8 +343,72 @@
const sendBtn = document.getElementById('sendBtn'); const sendBtn = document.getElementById('sendBtn');
const claudeBtn = document.getElementById('claudeBtn'); const claudeBtn = document.getElementById('claudeBtn');
const micBtn = document.getElementById('micBtn'); const micBtn = document.getElementById('micBtn');
const slashPopup = document.getElementById('slashPopup');
const chipsBar = document.getElementById('chipsBar');
const voiceAutosend = document.getElementById('voiceAutosend');
// xterm.js // ── Slash commands & chips ──
const COMMANDS = [
{ cmd: '/morning', label: '朝の確認', prompt: 'おはよう。git logと変更ファイルを確認して、今日やるべき作業を整理して提案して' },
{ cmd: '/status', label: '状況確認', prompt: 'git statusと直近のコミットを確認して、今の作業状態をわかりやすく教えて' },
{ cmd: '/commit', label: 'コミット', prompt: '変更内容を確認してコミットメッセージの案を出して、問題なければコミットして' },
{ cmd: '/deploy', label: 'デプロイ', prompt: 'npm run deployを実行してデプロイして' },
{ cmd: '/fix', label: 'エラー修正', prompt: '直前のエラーを確認して原因と修正方法を教えて、修正できるなら直して' },
{ cmd: '/explain', label: 'コード説明', prompt: '今の変更内容またはエラーをわかりやすく説明して' },
{ cmd: '/test', label: 'テスト', prompt: 'テストを実行して結果を教えて' },
{ cmd: '/ls', label: 'ファイル確認', prompt: 'カレントディレクトリの構造を確認して教えて' },
];
// クイックチップス最初の5件を常時表示
const CHIPS = COMMANDS.slice(0, 5);
CHIPS.forEach((c) => {
const btn = document.createElement('button');
btn.className = 'chip';
btn.textContent = c.label;
btn.addEventListener('click', () => {
sendInput(c.prompt + '\n');
});
chipsBar.appendChild(btn);
});
// スラッシュポップアップ描画
let slashActiveIndex = -1;
function renderSlashPopup(query) {
const q = query.slice(1).toLowerCase();
const matches = COMMANDS.filter((c) => c.cmd.includes(q) || c.label.includes(q));
if (!matches.length) { closeSlash(); return; }
slashPopup.innerHTML = '';
slashActiveIndex = -1;
matches.forEach((c, i) => {
const item = document.createElement('div');
item.className = 'slash-item';
item.setAttribute('role', 'option');
item.innerHTML = `<span class="slash-cmd">${c.cmd}</span><span class="slash-label">${c.label}</span>`;
item.addEventListener('mousedown', (e) => {
e.preventDefault();
selectSlash(c);
});
slashPopup.appendChild(item);
});
slashPopup.classList.add('open');
return matches;
}
function closeSlash() {
slashPopup.classList.remove('open');
slashPopup.innerHTML = '';
slashActiveIndex = -1;
}
function selectSlash(cmd) {
chatInput.value = cmd.prompt;
closeSlash();
chatInput.focus();
}
// ── xterm.js ──
const term = new Terminal({ const term = new Terminal({
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
fontSize: 14, fontSize: 14,
@ -276,7 +435,16 @@
term.open(container); term.open(container);
fitAddon.fit(); fitAddon.fit();
// WebSocket // 右クリックでターミナルにクリップボード貼り付け
container.addEventListener('contextmenu', async (e) => {
e.preventDefault();
try {
const text = await navigator.clipboard.readText();
if (text) sendInput(text);
} catch {}
});
// ── WebSocket ──
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let ws; let ws;
@ -317,31 +485,96 @@
} }
connect(); connect();
// ターミナル直接入力
term.onData((data) => sendInput(data)); term.onData((data) => sendInput(data));
// チャットバー送信 // ── 送信(フォーカスをチャット欄に保持) ──
const inputHistory = [];
let historyIndex = -1;
function submitChat() { function submitChat() {
const text = chatInput.value.trim(); const text = chatInput.value.trim();
if (!text) return; if (!text) return;
closeSlash();
sendInput(text + '\n'); sendInput(text + '\n');
inputHistory.unshift(text);
if (inputHistory.length > 50) inputHistory.pop();
historyIndex = -1;
chatInput.value = ''; chatInput.value = '';
term.focus(); // フォーカスをチャット欄に残すCtrl+V がそのまま使える)
chatInput.focus();
} }
sendBtn.addEventListener('click', submitChat); sendBtn.addEventListener('click', submitChat);
chatInput.addEventListener('keydown', (e) => { chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitChat(); } // 送信
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitChat(); return; }
// スラッシュポップアップのキーボード操作
if (slashPopup.classList.contains('open')) {
const items = slashPopup.querySelectorAll('.slash-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
slashActiveIndex = Math.min(slashActiveIndex + 1, items.length - 1);
items.forEach((el, i) => el.classList.toggle('active', i === slashActiveIndex));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
slashActiveIndex = Math.max(slashActiveIndex - 1, 0);
items.forEach((el, i) => el.classList.toggle('active', i === slashActiveIndex));
return;
}
if (e.key === 'Enter' && slashActiveIndex >= 0) {
e.preventDefault();
const match = COMMANDS.filter((c) =>
c.cmd.includes(chatInput.value.slice(1).toLowerCase()) ||
c.label.includes(chatInput.value.slice(1).toLowerCase())
);
if (match[slashActiveIndex]) selectSlash(match[slashActiveIndex]);
return;
}
if (e.key === 'Escape') { closeSlash(); return; }
}
// 入力履歴ナビ(スラッシュポップアップが閉じている時)
if (e.key === 'ArrowUp' && !slashPopup.classList.contains('open')) {
e.preventDefault();
if (historyIndex < inputHistory.length - 1) {
historyIndex++;
chatInput.value = inputHistory[historyIndex];
}
return;
}
if (e.key === 'ArrowDown' && !slashPopup.classList.contains('open')) {
e.preventDefault();
if (historyIndex > 0) { historyIndex--; chatInput.value = inputHistory[historyIndex]; }
else { historyIndex = -1; chatInput.value = ''; }
return;
}
}); });
// Claude 開始ボタン chatInput.addEventListener('input', () => {
const val = chatInput.value;
if (val.startsWith('/')) {
renderSlashPopup(val);
} else {
closeSlash();
}
});
// ── Claude 開始ボタン ──
claudeBtn.addEventListener('click', () => { claudeBtn.addEventListener('click', () => {
sendInput('claude\n'); sendInput('claude\n');
term.focus(); chatInput.focus();
});
// ── 音声入力 ──
voiceAutosend.checked = localStorage.getItem('posimai-dev-voice-autosend') === '1';
voiceAutosend.addEventListener('change', () => {
localStorage.setItem('posimai-dev-voice-autosend', voiceAutosend.checked ? '1' : '0');
}); });
// 音声入力
const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) { if (SR) {
micBtn.classList.add('available'); micBtn.classList.add('available');
@ -355,27 +588,21 @@
if (listening) { recognition.stop(); return; } if (listening) { recognition.stop(); return; }
recognition.start(); recognition.start();
}); });
recognition.onstart = () => { listening = true; micBtn.classList.add('listening'); };
recognition.onstart = () => { recognition.onend = () => { listening = false; micBtn.classList.remove('listening'); };
listening = true; recognition.onerror = () => { listening = false; micBtn.classList.remove('listening'); };
micBtn.classList.add('listening');
};
recognition.onend = () => {
listening = false;
micBtn.classList.remove('listening');
};
recognition.onresult = (e) => { recognition.onresult = (e) => {
const transcript = e.results[0][0].transcript; const transcript = e.results[0][0].transcript;
chatInput.value = transcript; chatInput.value = transcript;
if (voiceAutosend.checked) {
submitChat();
} else {
chatInput.focus(); chatInput.focus();
}; }
recognition.onerror = () => {
listening = false;
micBtn.classList.remove('listening');
}; };
} }
// Resize // ── Resize ──
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
fitAddon.fit(); fitAddon.fit();
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
@ -383,7 +610,7 @@
ro.observe(container); ro.observe(container);
window.addEventListener('resize', () => fitAddon.fit()); window.addEventListener('resize', () => fitAddon.fit());
// Settings panel // ── Settings panel ──
const settingsBtn = document.getElementById('settingsBtn'); const settingsBtn = document.getElementById('settingsBtn');
const settingsPanel = document.getElementById('settingsPanel'); const settingsPanel = document.getElementById('settingsPanel');
settingsBtn.addEventListener('click', () => { settingsBtn.addEventListener('click', () => {