clarc/public/index.html
Jared Miller b67247e340
Replace pin button with floating scroll-to-bottom button
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:47:47 -05:00

2165 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>clarc</title>
<style>
:root {
--columns: 1;
--font-size: 12px;
--terminal-height: 400px;
--wrap-mode: pre-wrap;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
padding: 0;
overflow-x: hidden;
}
header {
background: #2a2a2a;
padding: 16px;
border-bottom: 2px solid #3a3a3a;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 20px;
font-weight: 600;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #888;
}
.status.connected .status-dot {
background: #4CAF50;
}
.container {
padding: 16px;
max-width: 100%;
}
#sessions {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: 12px;
}
.section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.session {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.session-header {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
min-height: 44px;
}
.session-info {
flex: 1;
min-width: 0;
}
.session-command {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
}
.state-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.state-ready {
background: #6b7280;
color: white;
}
.state-thinking {
background: #3b82f6;
color: white;
}
.state-thinking::after {
content: '...';
animation: dots 1.5s infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
.state-permission {
background: #f59e0b;
color: white;
}
.state-question {
background: #f59e0b;
color: white;
}
.state-complete {
background: #10b981;
color: white;
}
.state-interrupted {
background: #ef4444;
color: white;
}
.session-cwd {
font-size: 12px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-toggle {
font-size: 18px;
color: #888;
transition: transform 0.2s;
}
.session.expanded .session-toggle {
transform: rotate(180deg);
}
.session-output {
display: none;
padding: 16px;
background: #1a1a1a;
border-top: 1px solid #3a3a3a;
max-height: var(--terminal-height);
overflow-y: auto;
position: relative;
}
.session.expanded .session-output {
display: block;
}
.scroll-to-bottom-btn {
position: absolute;
bottom: 12px;
right: 12px;
background: rgba(42, 42, 42, 0.9);
border: 1px solid #3a3a3a;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 20px;
cursor: pointer;
transition: background 0.2s, opacity 0.2s, transform 0.2s;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.scroll-to-bottom-btn:hover {
background: rgba(58, 58, 58, 0.95);
transform: translateY(-2px);
}
.scroll-to-bottom-btn:active {
opacity: 0.7;
transform: translateY(0);
}
.scroll-to-bottom-btn.hidden {
opacity: 0;
pointer-events: none;
}
.terminal {
font-family: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace;
font-size: var(--font-size);
line-height: 1.5;
white-space: var(--wrap-mode);
word-wrap: break-word;
color: #e0e0e0;
}
.prompt {
background: #2a2a2a;
border: 2px solid #ff9800;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.prompt-text {
font-size: 14px;
margin-bottom: 12px;
line-height: 1.5;
}
.prompt-header {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #ff9800;
}
.prompt-tool {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #888;
}
.prompt-tool-name {
color: #e0e0e0;
}
.prompt-tool-input {
font-size: 13px;
font-family: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace;
color: #888;
margin-bottom: 12px;
word-break: break-all;
}
.prompt-question {
font-size: 14px;
margin-bottom: 16px;
line-height: 1.5;
}
.prompt-options {
margin-bottom: 16px;
}
.prompt-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #3a3a3a;
border-radius: 6px;
margin-bottom: 8px;
min-height: 48px;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.prompt-option:hover {
background: rgba(255, 255, 255, 0.05);
}
.prompt-option input[type="radio"],
.prompt-option input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.prompt-option-label {
flex: 1;
font-size: 14px;
line-height: 1.5;
cursor: pointer;
}
.prompt-other-input {
width: 100%;
padding: 12px;
margin-top: 8px;
border: 1px solid #3a3a3a;
border-radius: 6px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 14px;
font-family: inherit;
}
.prompt-other-input:focus {
outline: none;
border-color: #ff9800;
}
.prompt-actions {
display: flex;
gap: 8px;
}
.btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
min-height: 48px;
transition: opacity 0.2s;
}
.btn:active {
opacity: 0.7;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-approve {
background: #4CAF50;
color: white;
}
.btn-reject {
background: #f44336;
color: white;
}
.btn-submit {
background: #ff9800;
color: white;
}
.btn-secondary {
background: #3a3a3a;
color: #e0e0e0;
position: relative;
}
.btn-secondary[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 8px 12px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 12px;
white-space: nowrap;
border-radius: 4px;
border: 1px solid #3a3a3a;
z-index: 1000;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 16px;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: #2a2a2a;
border-radius: 8px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid #3a3a3a;
font-size: 18px;
font-weight: 600;
}
.modal-body {
padding: 16px;
}
.modal-description {
color: #888;
font-size: 14px;
margin-bottom: 16px;
line-height: 1.5;
}
.modal-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #3a3a3a;
border-radius: 6px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 14px;
font-family: inherit;
resize: vertical;
}
.modal-textarea:focus {
outline: none;
border-color: #ff9800;
}
.modal-error {
display: none;
padding: 12px;
margin-top: 12px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid #f44336;
border-radius: 6px;
color: #f44336;
font-size: 14px;
line-height: 1.5;
}
.modal-error.active {
display: block;
}
.modal-actions {
display: flex;
gap: 8px;
padding: 16px;
border-top: 1px solid #3a3a3a;
}
.stats-widget {
padding: 8px 12px;
background: #1e1e1e;
border-bottom: 1px solid #333;
font-size: var(--font-size);
color: #888;
}
.stats-line {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 4px;
font-size: var(--font-size);
}
.stats-line:last-child {
margin-bottom: 0;
}
.stats-item {
display: flex;
align-items: center;
gap: 4px;
}
.stats-value {
color: #fff;
font-weight: 500;
}
.mode-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.mode-plan {
background: #7c3aed;
color: white;
}
.mode-auto {
background: #059669;
color: white;
}
.model-name {
color: #666;
font-size: 11px;
}
.git-line {
font-family: monospace;
}
.git-icon {
margin-right: 4px;
opacity: 0.7;
}
.git-additions {
color: #4CAF50;
}
.git-deletions {
color: #f44336;
}
.git-file {
font-family: monospace;
font-size: 0.85em;
}
.git-file-modified {
color: #ff9800;
}
.git-file-added {
color: #4CAF50;
}
.git-file-deleted {
color: #f44336;
}
.git-file-untracked {
color: #9e9e9e;
}
.git-files {
margin-top: 8px;
}
.git-files summary {
cursor: pointer;
user-select: none;
padding: 4px 0;
}
.git-files summary:hover {
opacity: 0.8;
}
.git-file-list {
margin-top: 4px;
padding-left: 16px;
}
.git-file-more {
color: #666;
font-style: italic;
margin-top: 4px;
}
.empty-state {
padding: 32px 16px;
text-align: center;
color: #888;
font-size: 14px;
}
.settings-btn {
background: none;
border: none;
color: #888;
font-size: 20px;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.settings-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.setting-group {
margin-bottom: 20px;
}
.setting-group:last-child {
margin-bottom: 0;
}
.setting-group label {
display: block;
font-size: 13px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
font-weight: 600;
}
.setting-buttons {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.setting-btn {
padding: 8px 16px;
border: 1px solid #3a3a3a;
border-radius: 6px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 14px;
cursor: pointer;
min-width: 44px;
min-height: 44px;
transition: background 0.2s, border-color 0.2s;
}
.setting-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.setting-btn:active {
opacity: 0.7;
}
.setting-btn.active {
background: #ff9800;
border-color: #ff9800;
color: white;
}
.setting-display {
padding: 8px 16px;
color: #e0e0e0;
font-size: 14px;
font-weight: 600;
}
.header-text-btn {
font-size: 14px;
font-weight: 500;
min-width: auto;
padding: 8px 12px;
}
.popover {
position: fixed;
top: 60px;
right: 200px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1500;
min-width: 300px;
max-width: 400px;
}
.popover-content {
padding: 0;
}
.popover-header {
padding: 12px 16px;
border-bottom: 1px solid #3a3a3a;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
#session-filter-list {
max-height: 400px;
overflow-y: auto;
}
.session-filter-item {
padding: 12px 16px;
border-bottom: 1px solid #3a3a3a;
cursor: pointer;
transition: background 0.2s;
}
.session-filter-item:last-child {
border-bottom: none;
}
.session-filter-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.session-filter-item.selected {
background: rgba(255, 152, 0, 0.1);
border-left: 3px solid #ff9800;
padding-left: 13px;
}
.session-filter-command {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.session-filter-cwd {
font-size: 11px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clear-filter-btn {
width: 100%;
padding: 12px 16px;
border: none;
border-top: 1px solid #3a3a3a;
background: #1a1a1a;
color: #e0e0e0;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.clear-filter-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.clear-filter-btn:active {
opacity: 0.7;
}
@media (prefers-color-scheme: light) {
body {
background: #f5f5f5;
color: #1a1a1a;
}
header {
background: white;
border-bottom-color: #e0e0e0;
}
.session,
.prompt {
background: white;
border-color: #ff9800;
}
.stats-widget {
background: #f5f5f5;
border-bottom-color: #e0e0e0;
}
.stats-value {
color: #1a1a1a;
}
.model-name {
color: #999;
}
.session-output {
background: #f5f5f5;
border-top-color: #e0e0e0;
}
.terminal {
color: #1a1a1a;
}
.prompt-option {
border-color: #e0e0e0;
}
.prompt-option:hover {
background: rgba(0, 0, 0, 0.05);
}
.prompt-other-input {
background: white;
border-color: #e0e0e0;
color: #1a1a1a;
}
.prompt-tool-input {
color: #666;
}
.modal {
background: white;
}
.modal-header,
.modal-actions {
border-color: #e0e0e0;
}
.modal-textarea {
background: #f5f5f5;
border-color: #e0e0e0;
color: #1a1a1a;
}
}
</style>
</head>
<body>
<header>
<h1>clarc</h1>
<div style="display: flex; align-items: center; gap: 16px;">
<button class="settings-btn header-text-btn" id="sessions-filter-btn" onclick="openSessionFilter()" title="Session Filter">
<span id="sessions-count-text">0 sessions</span>
</button>
<button class="settings-btn" onclick="openStyleModal()" title="Display Settings">⚙️</button>
<div class="status" id="status">
<span class="status-dot"></span>
<span class="status-text">connecting</span>
</div>
</div>
</header>
<div id="session-filter-popover" class="popover" style="display: none;">
<div class="popover-content">
<div class="popover-header">Select Session</div>
<div id="session-filter-list"></div>
<button class="clear-filter-btn" onclick="clearSessionFilter()">Show All Sessions</button>
</div>
</div>
<div class="container">
<div class="section">
<div class="section-title">Active Sessions</div>
<div id="sessions">
<div class="empty-state">No active sessions</div>
</div>
</div>
<div class="section">
<div class="section-title">Pending Prompts</div>
<div id="prompts">
<div class="empty-state">No pending prompts</div>
</div>
</div>
</div>
<div class="modal-overlay" id="instructionsModal" onclick="handleModalOverlayClick(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">Add Instructions</div>
<div class="modal-body">
<div class="modal-description">
Your instructions will be sent with the approval.
</div>
<textarea
class="modal-textarea"
id="instructionsTextarea"
placeholder="Enter your instructions..."></textarea>
<div class="modal-error" id="instructionsError"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeInstructionsModal()">Cancel</button>
<button class="btn btn-submit" onclick="submitInstructions()">Submit</button>
</div>
</div>
</div>
<div class="modal-overlay" id="styleModal" onclick="handleStyleModalOverlayClick(event)">
<div class="modal" onclick="event.stopPropagation()" style="max-width: 320px;">
<div class="modal-header">Display Settings</div>
<div class="modal-body">
<div class="setting-group">
<label>Columns</label>
<div class="setting-buttons">
<button onclick="setSetting('columns', 1)" class="setting-btn" data-setting="columns" data-value="1">1</button>
<button onclick="setSetting('columns', 2)" class="setting-btn" data-setting="columns" data-value="2">2</button>
<button onclick="setSetting('columns', 3)" class="setting-btn" data-setting="columns" data-value="3">3</button>
<button onclick="setSetting('columns', 'fit')" class="setting-btn" data-setting="columns" data-value="fit">Fit</button>
</div>
</div>
<div class="setting-group">
<label>Text Size</label>
<div class="setting-buttons">
<button onclick="adjustSetting('fontSize', -1)" class="setting-btn"></button>
<span class="setting-display" id="font-size-display">12px</span>
<button onclick="adjustSetting('fontSize', 1)" class="setting-btn">+</button>
</div>
</div>
<div class="setting-group">
<label>Terminal Height</label>
<div class="setting-buttons">
<button onclick="adjustSetting('terminalHeight', -50)" class="setting-btn"></button>
<span class="setting-display" id="terminal-height-display">400px</span>
<button onclick="adjustSetting('terminalHeight', 50)" class="setting-btn">+</button>
</div>
</div>
<div class="setting-group">
<label>Text Wrap</label>
<div class="setting-buttons">
<button onclick="toggleSetting('wrapText')" class="setting-btn" id="wrap-toggle">On</button>
</div>
</div>
</div>
</div>
</div>
<script>
const state = {
sessions: new Map(),
prompts: new Map(),
eventSource: null,
reconnectTimeout: null,
instructionsModalPromptId: null,
focusSessionId: null, // session ID for filtering, null = show all (ephemeral, not persisted)
settings: {
columns: 1, // 1, 2, 3, or 'fit'
fontSize: 12, // px value
terminalHeight: 400, // px value
wrapText: true, // true = pre-wrap, false = pre (horizontal scroll)
},
};
const $status = document.getElementById('status');
const $sessions = document.getElementById('sessions');
const $prompts = document.getElementById('prompts');
const $instructionsModal = document.getElementById('instructionsModal');
const $instructionsTextarea = document.getElementById('instructionsTextarea');
const $instructionsError = document.getElementById('instructionsError');
const $styleModal = document.getElementById('styleModal');
function setStatus(connected) {
if (connected) {
$status.classList.add('connected');
$status.querySelector('.status-text').textContent = 'connected';
} else {
$status.classList.remove('connected');
$status.querySelector('.status-text').textContent = 'connecting';
}
}
async function fetchExistingSessions() {
try {
const res = await fetch('/api/sessions');
if (!res.ok) {
console.error('Failed to fetch existing sessions:', await res.text());
return;
}
const sessions = await res.json();
sessions.forEach(session => {
state.sessions.set(session.id, {
id: session.id,
cwd: session.cwd,
command: session.command,
output: '',
outputRenderedLength: 0,
expanded: false,
state: 'ready',
prompts: 0,
completions: 0,
tools: 0,
compressions: 0,
thinking_seconds: 0,
work_seconds: 0,
mode: 'normal',
model: null,
idle_since: null,
git_branch: null,
git_files_json: null,
autoScroll: true,
});
});
renderSessions();
} catch (error) {
console.error('Error fetching existing sessions:', error);
}
}
function connectSSE() {
if (state.eventSource) {
state.eventSource.close();
}
const es = new EventSource('/events');
es.addEventListener('open', () => {
console.debug('SSE connected');
setStatus(true);
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
fetchExistingSessions();
});
es.addEventListener('error', () => {
console.debug('SSE error, reconnecting...');
setStatus(false);
es.close();
if (!state.reconnectTimeout) {
state.reconnectTimeout = setTimeout(() => connectSSE(), 2000);
}
});
es.addEventListener('session_start', (e) => {
const data = JSON.parse(e.data);
state.sessions.set(data.session_id, {
id: data.session_id,
cwd: data.cwd,
command: data.command,
output: '',
outputRenderedLength: 0,
expanded: false,
state: 'ready',
prompts: 0,
completions: 0,
tools: 0,
compressions: 0,
thinking_seconds: 0,
work_seconds: 0,
mode: 'normal',
model: null,
idle_since: null,
git_branch: null,
git_files_json: null,
// Auto-scroll state is per-session and ephemeral - not persisted to localStorage.
// This is intentional: users may want different auto-scroll settings for different
// sessions, and persisting would require tracking state by session ID which is complex.
autoScroll: true,
});
renderSessions();
});
es.addEventListener('session_end', (e) => {
const data = JSON.parse(e.data);
state.sessions.delete(data.session_id);
renderSessions();
});
es.addEventListener('output', (e) => {
const data = JSON.parse(e.data);
const session = state.sessions.get(data.session_id);
if (session) {
session.output += data.data;
renderSessionOutput(data.session_id);
}
});
es.addEventListener('prompt', (e) => {
const data = JSON.parse(e.data);
state.prompts.set(data.prompt_id, {
id: data.prompt_id,
session_id: data.session_id,
text: data.prompt_text,
json: data.prompt_json ? JSON.parse(data.prompt_json) : null,
});
renderPrompts();
});
es.addEventListener('prompt_response', (e) => {
const data = JSON.parse(e.data);
state.prompts.delete(data.prompt_id);
renderPrompts();
});
es.addEventListener('state', (e) => {
const data = JSON.parse(e.data);
const session = state.sessions.get(data.session_id);
if (session) {
session.state = data.state;
updateSessionCard(data.session_id);
}
});
es.addEventListener('stats', (e) => {
const data = JSON.parse(e.data);
const session = state.sessions.get(data.session_id);
if (session) {
if (data.prompts !== undefined) session.prompts = data.prompts;
if (data.completions !== undefined) session.completions = data.completions;
if (data.tools !== undefined) session.tools = data.tools;
if (data.compressions !== undefined) session.compressions = data.compressions;
if (data.thinking_seconds !== undefined) session.thinking_seconds = data.thinking_seconds;
if (data.work_seconds !== undefined) session.work_seconds = data.work_seconds;
if (data.mode !== undefined) session.mode = data.mode;
if (data.model !== undefined) session.model = data.model;
if (data.idle_since !== undefined) session.idle_since = data.idle_since;
updateStatsWidget(data.session_id);
}
});
es.addEventListener('git', (e) => {
const data = JSON.parse(e.data);
const session = state.sessions.get(data.session_id);
if (session) {
session.git_branch = data.branch;
session.git_files_json = data.files_json;
updateStatsWidget(data.session_id);
}
});
state.eventSource = es;
}
window.toggleSession = (sessionId) => {
const session = state.sessions.get(Number(sessionId));
if (session) {
session.expanded = !session.expanded;
// Reset rendered length when collapsing or expanding (element gets recreated)
session.outputRenderedLength = 0;
renderSessions();
// If expanding, resize the PTY to fit the viewport
if (session.expanded) {
// Wait for DOM to update before measuring
setTimeout(() => {
handleSessionResize(Number(sessionId));
}, 0);
}
}
};
function renderStateBadge(sessionState) {
const labels = {
ready: 'Ready',
thinking: 'Thinking',
permission: 'Permission Required',
question: 'Question',
complete: 'Complete',
interrupted: 'Interrupted'
};
const label = labels[sessionState] || sessionState;
return `<span class="state-badge state-${sessionState}">${label}</span>`;
}
function formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
return `${Math.floor(seconds / 3600)}h`;
}
function getFileClass(status) {
// Git status is two characters: index status + working tree status
// Check first non-space character for primary classification
const char = status.trim()[0] || '';
if (char === 'M') return 'git-file-modified';
if (char === 'A') return 'git-file-added';
if (char === 'D') return 'git-file-deleted';
if (char === 'R') return 'git-file-modified'; // Renamed treated as modified
if (char === 'C') return 'git-file-added'; // Copied treated as added
if (char === 'U') return 'git-file-modified'; // Unmerged treated as modified
if (char === '?') return 'git-file-untracked';
if (char === '!') return 'git-file-untracked'; // Ignored
return '';
}
function renderStatsWidget(session) {
const lines = [];
// Counts line
const counts = [];
if (session.prompts > 0) counts.push(`<span class="stats-value">${session.prompts}</span> prompts`);
if (session.completions > 0) counts.push(`<span class="stats-value">${session.completions}</span> completions`);
if (session.tools > 0) counts.push(`<span class="stats-value">${session.tools}</span> tools`);
if (session.compressions > 0) counts.push(`<span class="stats-value">${session.compressions}</span> compressions`);
if (counts.length > 0) {
lines.push(`<div class="stats-line">${counts.join(' | ')}</div>`);
}
// Timing line
const timing = [];
if (session.thinking_seconds > 0) timing.push(`<span class="stats-value">${formatDuration(session.thinking_seconds)}</span> thinking`);
if (session.idle_since) {
const idleSeconds = Math.floor((Date.now() - session.idle_since * 1000) / 1000);
if (idleSeconds > 0) timing.push(`<span class="stats-value">${formatDuration(idleSeconds)}</span> idle`);
}
if (timing.length > 0) {
lines.push(`<div class="stats-line">${timing.join(' | ')}</div>`);
}
// Mode badge and model name
const badges = [];
if (session.mode === 'plan') badges.push('<span class="mode-badge mode-plan">Plan Mode</span>');
else if (session.mode === 'auto_accept') badges.push('<span class="mode-badge mode-auto">Auto-Accept</span>');
if (session.model) {
// "claude-opus-4-5-20251101" -> "opus-4-5"
const match = session.model.match(/claude-(\w+-[\d-]+)/);
if (match) badges.push(`<span class="model-name">${match[1]}</span>`);
}
if (badges.length > 0) {
lines.push(`<div class="stats-line">${badges.join(' ')}</div>`);
}
// Git info
if (session.git_branch || session.git_files_json) {
const gitParts = [];
let files = [];
let totalAdditions = 0;
let totalDeletions = 0;
// Parse files if available
if (session.git_files_json) {
try {
files = JSON.parse(session.git_files_json);
files.forEach(f => {
totalAdditions += f.additions || 0;
totalDeletions += f.deletions || 0;
});
} catch (e) {
console.error('Failed to parse git files JSON:', e);
}
}
// Build git summary line
if (session.git_branch) {
gitParts.push(`<span class="git-icon">⎇</span>`);
gitParts.push(`<span class="stats-value">${escapeHtml(session.git_branch)}</span>`);
}
if (files.length > 0) {
gitParts.push(`| <span class="stats-value">${files.length}</span> file${files.length === 1 ? '' : 's'}`);
}
if (totalAdditions > 0) {
gitParts.push(`| <span class="git-additions">+${totalAdditions}</span>`);
}
if (totalDeletions > 0) {
gitParts.push(`| <span class="git-deletions">-${totalDeletions}</span>`);
}
if (gitParts.length > 0) {
let gitLine = `<div class="stats-line git-line">${gitParts.join(' ')}</div>`;
// Add collapsible file list if there are files
if (files.length > 0) {
const fileListItems = files.slice(0, 10).map(f => {
const filePath = escapeHtml(f.path);
const fileClass = getFileClass(f.status);
const additions = f.additions ? `<span class="git-additions">+${f.additions}</span>` : '';
const deletions = f.deletions ? `<span class="git-deletions">-${f.deletions}</span>` : '';
return `<div class="git-file ${fileClass}">${escapeHtml(f.status)} ${filePath} ${additions} ${deletions}</div>`;
}).join('');
const moreText = files.length > 10 ? `<div class="git-file-more">and ${files.length - 10} more...</div>` : '';
gitLine += `
<details class="git-files">
<summary>${files.length} changed file${files.length === 1 ? '' : 's'}</summary>
<div class="git-file-list">
${fileListItems}
${moreText}
</div>
</details>
`;
}
lines.push(gitLine);
}
}
if (lines.length === 0) {
return '';
}
return `<div class="stats-widget" id="stats-${session.id}">${lines.join('')}</div>`;
}
function renderSessions() {
// Update sessions count button text
updateSessionsCountButton();
if (state.sessions.size === 0) {
$sessions.innerHTML = '<div class="empty-state">No active sessions</div>';
return;
}
// Filter sessions if focusSessionId is set
let sessionsToRender = Array.from(state.sessions.values());
if (state.focusSessionId !== null) {
const focusSession = state.sessions.get(state.focusSessionId);
if (focusSession) {
sessionsToRender = [focusSession];
} else {
// Filtered session no longer exists, clear filter
state.focusSessionId = null;
updateSessionsCountButton();
}
}
$sessions.innerHTML = sessionsToRender.map(s => `
<div class="session ${s.expanded ? 'expanded' : ''}" data-session="${s.id}">
<div class="session-header" onclick="toggleSession('${s.id}')">
<div class="session-info">
<div class="session-command">
<span>${escapeHtml(s.command || 'claude')}</span>
${renderStateBadge(s.state || 'ready')}
</div>
<div class="session-cwd">${escapeHtml(s.cwd || '~')}</div>
</div>
<div class="session-toggle">▼</div>
</div>
${renderStatsWidget(s)}
<div class="session-output" id="session-output-${s.id}">
<button class="scroll-to-bottom-btn ${s.autoScroll ? 'hidden' : ''}"
onclick="scrollToBottom(${s.id}); event.stopPropagation();"
title="Scroll to bottom">
</button>
<div class="terminal" id="output-${s.id}">${s.output}</div>
</div>
</div>
`).join('');
// Attach scroll listeners after rendering
sessionsToRender.forEach(s => {
const $output = document.getElementById(`session-output-${s.id}`);
if ($output) {
attachScrollListener(s.id, $output);
}
});
}
function renderSessionOutput(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;
const $output = document.getElementById(`output-${sessionId}`);
if ($output) {
// Append only new content since last render
const newChunk = session.output.slice(session.outputRenderedLength);
if (newChunk) {
$output.innerHTML += newChunk;
session.outputRenderedLength = session.output.length;
}
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
if ($outputContainer) {
// Only auto-scroll if session is expanded and autoScroll is enabled
if (session.expanded && session.autoScroll) {
$outputContainer.scrollTop = $outputContainer.scrollHeight;
}
// Re-attach scroll listener after DOM update
attachScrollListener(sessionId, $outputContainer);
}
}
}
function updateSessionCard(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;
const $sessionCard = document.querySelector(`[data-session="${sessionId}"]`);
if ($sessionCard) {
const $commandDiv = $sessionCard.querySelector('.session-command');
if ($commandDiv) {
$commandDiv.innerHTML = `
<span>${escapeHtml(session.command || 'claude')}</span>
${renderStateBadge(session.state || 'ready')}
`;
}
}
}
function updateStatsWidget(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;
const $statsWidget = document.getElementById(`stats-${sessionId}`);
if ($statsWidget) {
const newHtml = renderStatsWidget(session);
if (newHtml) {
$statsWidget.outerHTML = newHtml;
} else {
$statsWidget.remove();
}
} else if (session) {
// Widget doesn't exist but should - find the header and insert after it
const $sessionCard = document.querySelector(`[data-session="${sessionId}"]`);
if ($sessionCard) {
const $header = $sessionCard.querySelector('.session-header');
const newHtml = renderStatsWidget(session);
if (newHtml && $header) {
$header.insertAdjacentHTML('afterend', newHtml);
}
}
}
}
function renderPrompts() {
if (state.prompts.size === 0) {
$prompts.innerHTML = '<div class="empty-state">No pending prompts</div>';
return;
}
$prompts.innerHTML = Array.from(state.prompts.values()).map(p => {
if (p.json) {
return renderRichPrompt(p);
} else {
// Fallback for old-style prompts
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-text">${escapeHtml(p.text)}</div>
<div class="prompt-actions">
<button class="btn btn-approve" onclick="respondToPrompt('${p.id}', 'approve')">Approve</button>
<button class="btn btn-reject" onclick="respondToPrompt('${p.id}', 'reject')">Reject</button>
</div>
</div>
`;
}
}).join('');
}
function renderRichPrompt(p) {
const json = p.json;
const promptType = json.prompt_type;
if (promptType === 'permission') {
return renderPermissionPrompt(p);
} else if (promptType === 'question') {
return renderQuestionPrompt(p);
} else if (promptType === 'exit_plan') {
return renderExitPlanPrompt(p);
} else {
// Unknown type, fallback
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-text">${escapeHtml(p.text)}</div>
<div class="prompt-actions">
<button class="btn btn-approve" onclick="respondToPrompt('${p.id}', 'approve')">Approve</button>
<button class="btn btn-reject" onclick="respondToPrompt('${p.id}', 'reject')">Reject</button>
</div>
</div>
`;
}
}
function renderPermissionPrompt(p) {
const json = p.json;
const toolName = json.tool_name || 'Unknown';
const toolInput = json.tool_input || {};
const options = json.options || [];
const selectedOption = json.selected_option;
const allowsTabInstructions = json.allows_tab_instructions || false;
// Extract relevant tool input info
let toolInputDisplay = '';
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'Read') {
toolInputDisplay = toolInput.file_path || '';
} else if (toolName === 'Bash') {
toolInputDisplay = toolInput.command || '';
} else if (toolInput.path) {
toolInputDisplay = toolInput.path;
}
const optionsHtml = options.map((opt, idx) => `
<label class="prompt-option" onclick="selectOption('${p.id}', ${idx})">
<input type="radio" name="prompt-${p.id}" value="${escapeHtml(opt.value)}" ${opt.value === selectedOption ? 'checked' : ''}>
<span class="prompt-option-label">${escapeHtml(opt.label)}</span>
</label>
`).join('');
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-tool">Tool: <span class="prompt-tool-name">${escapeHtml(toolName)}</span></div>
${toolInputDisplay ? `<div class="prompt-tool-input">${escapeHtml(toolInputDisplay)}</div>` : ''}
<div class="prompt-question">${escapeHtml(p.text)}</div>
<div class="prompt-options">
${optionsHtml}
</div>
<div class="prompt-actions">
<button class="btn btn-submit" onclick="submitRichPrompt('${p.id}')">Submit</button>
${allowsTabInstructions ? `<button class="btn btn-secondary" onclick="openInstructionsModal('${p.id}')">Add instructions</button>` : ''}
</div>
</div>
`;
}
function renderQuestionPrompt(p) {
const json = p.json;
const questions = json.questions || [];
const questionsHtml = questions.map((q, qIdx) => {
const header = q.header || '';
const question = q.question || '';
const options = q.options || [];
const multiSelect = q.multi_select || false;
const allowsOther = q.allows_other || false;
const inputType = multiSelect ? 'checkbox' : 'radio';
const inputName = `prompt-${p.id}-${qIdx}`;
const optionsHtml = options.map((opt, idx) => `
<label class="prompt-option" onclick="selectOption('${p.id}', ${idx})">
<input type="${inputType}" name="${inputName}" value="${escapeHtml(opt.value)}">
<span class="prompt-option-label">${escapeHtml(opt.label)}</span>
</label>
`).join('');
const otherHtml = allowsOther ? `
<label class="prompt-option">
<input type="${inputType}" name="${inputName}" value="__other__" onclick="enableOtherInput('${p.id}-${qIdx}')">
<span class="prompt-option-label">Other</span>
</label>
<input type="text" class="prompt-other-input" id="other-${p.id}-${qIdx}" placeholder="Enter your answer..." disabled>
` : '';
return `
${header ? `<div class="prompt-header">${escapeHtml(header)}</div>` : ''}
<div class="prompt-question">${escapeHtml(question)}</div>
<div class="prompt-options">
${optionsHtml}
${otherHtml}
</div>
`;
}).join('');
return `
<div class="prompt" data-prompt="${p.id}">
${questionsHtml}
<div class="prompt-actions">
<button class="btn btn-submit" onclick="submitRichPrompt('${p.id}')">Submit</button>
</div>
</div>
`;
}
function renderExitPlanPrompt(p) {
const json = p.json;
const options = json.options || [];
const optionsHtml = options.map((opt, idx) => `
<label class="prompt-option" onclick="selectOption('${p.id}', ${idx})">
<input type="radio" name="prompt-${p.id}" value="${escapeHtml(opt.value)}">
<span class="prompt-option-label">${escapeHtml(opt.label)}</span>
</label>
`).join('');
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-header">Plan Complete</div>
<div class="prompt-question">${escapeHtml(p.text)}</div>
<div class="prompt-options">
${optionsHtml}
</div>
<div class="prompt-actions">
<button class="btn btn-submit" onclick="submitRichPrompt('${p.id}')">Submit</button>
</div>
</div>
`;
}
window.selectOption = (_promptId, _optionIdx) => {
// Radio buttons and checkboxes handle their own state
// This function is just for accessibility/mobile tap handling
};
window.enableOtherInput = (promptId) => {
const input = document.getElementById(`other-${promptId}`);
if (input) {
input.disabled = false;
input.focus();
}
};
window.submitRichPrompt = async (promptId) => {
promptId = Number(promptId);
const prompt = state.prompts.get(promptId);
if (!prompt || !prompt.json) {
console.error('Invalid prompt');
return;
}
const json = prompt.json;
let response;
if (json.prompt_type === 'permission') {
// Get selected radio button
const selected = document.querySelector(`input[name="prompt-${promptId}"]:checked`);
if (!selected) {
console.error('No option selected');
return;
}
response = {
type: 'option',
value: selected.value,
};
} else if (json.prompt_type === 'question') {
if (json.multi_select) {
// Get all checked checkboxes
const checked = Array.from(document.querySelectorAll(`input[name="prompt-${promptId}"]:checked`));
if (checked.length === 0) {
console.error('No options selected');
return;
}
const values = checked.map(el => {
if (el.value === '__other__') {
const otherInput = document.getElementById(`other-${promptId}`);
return otherInput ? otherInput.value : '';
}
return el.value;
});
response = {
type: 'text',
value: values.join(', '),
};
} else {
// Get selected radio button
const selected = document.querySelector(`input[name="prompt-${promptId}"]:checked`);
if (!selected) {
console.error('No option selected');
return;
}
if (selected.value === '__other__') {
const otherInput = document.getElementById(`other-${promptId}`);
if (!otherInput || !otherInput.value) {
console.error('Please enter a value for "Other"');
return;
}
response = {
type: 'text',
value: otherInput.value,
};
} else {
response = {
type: 'text',
value: selected.value,
};
}
}
} else if (json.prompt_type === 'exit_plan') {
// Get selected radio button
const selected = document.querySelector(`input[name="prompt-${promptId}"]:checked`);
if (!selected) {
console.error('No option selected');
return;
}
response = {
type: 'option',
value: selected.value,
};
}
try {
const res = await fetch(`/api/prompts/${promptId}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(response),
});
if (!res.ok) {
console.error('Failed to submit prompt response:', await res.text());
}
} catch (error) {
console.error('Error submitting prompt response:', error);
}
};
window.respondToPrompt = async (promptId, action) => {
promptId = Number(promptId);
try {
const res = await fetch(`/api/prompts/${promptId}/${action}`, {
method: 'POST',
});
if (!res.ok) {
console.error(`Failed to ${action} prompt:`, await res.text());
}
} catch (error) {
console.error(`Error responding to prompt:`, error);
}
};
window.openInstructionsModal = (promptId) => {
state.instructionsModalPromptId = Number(promptId);
$instructionsTextarea.value = '';
$instructionsError.classList.remove('active');
$instructionsError.textContent = '';
$instructionsModal.classList.add('active');
$instructionsTextarea.focus();
};
window.closeInstructionsModal = () => {
$instructionsModal.classList.remove('active');
state.instructionsModalPromptId = null;
$instructionsTextarea.value = '';
};
window.handleModalOverlayClick = (event) => {
if (event.target === $instructionsModal) {
closeInstructionsModal();
}
};
window.submitInstructions = async () => {
const promptId = state.instructionsModalPromptId;
if (!promptId) {
$instructionsError.textContent = 'No prompt ID for instructions';
$instructionsError.classList.add('active');
return;
}
const instruction = $instructionsTextarea.value.trim();
if (!instruction) {
$instructionsError.textContent = 'Please enter instructions';
$instructionsError.classList.add('active');
return;
}
const prompt = state.prompts.get(promptId);
if (!prompt || !prompt.json) {
$instructionsError.textContent = 'Invalid prompt';
$instructionsError.classList.add('active');
return;
}
// Get selected option
const selected = document.querySelector(`input[name="prompt-${promptId}"]:checked`);
if (!selected) {
$instructionsError.textContent = 'No option selected';
$instructionsError.classList.add('active');
return;
}
const response = {
type: 'tab_instructions',
selected_option: parseInt(selected.value, 10),
instruction: instruction,
};
// Clear any previous errors
$instructionsError.classList.remove('active');
$instructionsError.textContent = '';
// Get submit button and set loading state
const $submitBtn = document.querySelector('#instructionsModal .btn-submit');
const originalText = $submitBtn.textContent;
$submitBtn.disabled = true;
$submitBtn.textContent = 'Submitting...';
try {
const res = await fetch(`/api/prompts/${promptId}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(response),
});
if (!res.ok) {
const errorText = await res.text();
$instructionsError.textContent = `Failed to submit instructions: ${errorText}`;
$instructionsError.classList.add('active');
$submitBtn.disabled = false;
$submitBtn.textContent = originalText;
} else {
closeInstructionsModal();
$submitBtn.disabled = false;
$submitBtn.textContent = originalText;
}
} catch (error) {
$instructionsError.textContent = `Error submitting instructions: ${error.message}`;
$instructionsError.classList.add('active');
$submitBtn.disabled = false;
$submitBtn.textContent = originalText;
}
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function loadSettings() {
try {
const saved = localStorage.getItem('clarc-prefs');
if (saved) {
const parsed = JSON.parse(saved);
state.settings = { ...state.settings, ...parsed };
}
} catch (e) {
console.error('Failed to load settings:', e);
}
}
function saveSettings() {
try {
localStorage.setItem('clarc-prefs', JSON.stringify(state.settings));
} catch (e) {
console.error('Failed to save settings:', e);
}
}
function applySettings() {
const root = document.documentElement;
// Apply columns
if (state.settings.columns === 'fit') {
root.style.setProperty('--columns', 'auto-fit');
$sessions.style.gridTemplateColumns = 'repeat(auto-fit, minmax(400px, 1fr))';
} else {
root.style.setProperty('--columns', state.settings.columns);
$sessions.style.gridTemplateColumns = `repeat(${state.settings.columns}, 1fr)`;
}
// Apply font size
root.style.setProperty('--font-size', `${state.settings.fontSize}px`);
// Apply terminal height
root.style.setProperty('--terminal-height', `${state.settings.terminalHeight}px`);
// Apply wrap mode
root.style.setProperty('--wrap-mode', state.settings.wrapText ? 'pre-wrap' : 'pre');
}
function updateSettingsUI() {
// Update column buttons
document.querySelectorAll('.setting-btn[data-setting="columns"]').forEach(btn => {
const value = btn.dataset.value === 'fit' ? 'fit' : Number(btn.dataset.value);
if (value === state.settings.columns) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Update font size display
document.getElementById('font-size-display').textContent = `${state.settings.fontSize}px`;
// Update terminal height display
document.getElementById('terminal-height-display').textContent = `${state.settings.terminalHeight}px`;
// Update wrap toggle
const $wrapBtn = document.getElementById('wrap-toggle');
$wrapBtn.textContent = state.settings.wrapText ? 'On' : 'Off';
if (state.settings.wrapText) {
$wrapBtn.classList.add('active');
} else {
$wrapBtn.classList.remove('active');
}
}
window.openStyleModal = () => {
updateSettingsUI();
$styleModal.classList.add('active');
};
window.closeStyleModal = () => {
$styleModal.classList.remove('active');
};
window.handleStyleModalOverlayClick = (event) => {
if (event.target === $styleModal) {
closeStyleModal();
}
};
window.setSetting = (key, value) => {
state.settings[key] = value;
saveSettings();
applySettings();
updateSettingsUI();
};
window.adjustSetting = (key, delta) => {
const bounds = {
fontSize: { min: 10, max: 18, step: 1 },
terminalHeight: { min: 200, max: 600, step: 50 },
};
const config = bounds[key];
if (!config) return;
// Adjust with bounds
let newValue = state.settings[key] + delta;
newValue = Math.max(config.min, Math.min(config.max, newValue));
state.settings[key] = newValue;
saveSettings();
applySettings();
updateSettingsUI();
};
window.toggleSetting = (key) => {
state.settings[key] = !state.settings[key];
saveSettings();
applySettings();
updateSettingsUI();
};
// Session filtering
function updateSessionsCountButton() {
const $countText = document.getElementById('sessions-count-text');
const total = state.sessions.size;
if (state.focusSessionId !== null) {
$countText.innerHTML = `Viewing 1 of ${total} <span style="margin-left: 4px;">×</span>`;
} else {
const label = total === 1 ? 'session' : 'sessions';
$countText.textContent = `${total} ${label}`;
}
}
window.openSessionFilter = () => {
renderSessionFilterList();
const $popover = document.getElementById('session-filter-popover');
$popover.style.display = 'block';
// Close on outside click
setTimeout(() => {
document.addEventListener('click', closeSessionFilterOnOutsideClick);
}, 0);
};
window.closeSessionFilter = () => {
const $popover = document.getElementById('session-filter-popover');
$popover.style.display = 'none';
document.removeEventListener('click', closeSessionFilterOnOutsideClick);
};
function closeSessionFilterOnOutsideClick(event) {
const $popover = document.getElementById('session-filter-popover');
const $filterBtn = document.getElementById('sessions-filter-btn');
if (!$popover.contains(event.target) && !$filterBtn.contains(event.target)) {
closeSessionFilter();
}
}
window.setSessionFilter = (sessionId) => {
state.focusSessionId = Number(sessionId);
closeSessionFilter();
renderSessions();
};
window.clearSessionFilter = () => {
state.focusSessionId = null;
closeSessionFilter();
renderSessions();
};
function renderSessionFilterList() {
const $list = document.getElementById('session-filter-list');
if (state.sessions.size === 0) {
$list.innerHTML = '<div style="padding: 16px; text-align: center; color: #888;">No active sessions</div>';
return;
}
$list.innerHTML = Array.from(state.sessions.values()).map(s => {
const isSelected = state.focusSessionId === s.id;
return `
<div class="session-filter-item ${isSelected ? 'selected' : ''}" onclick="setSessionFilter(${s.id})">
<div class="session-filter-command">
<span>${escapeHtml(s.command || 'claude')}</span>
${renderStateBadge(s.state || 'ready')}
</div>
<div class="session-filter-cwd">${escapeHtml(s.cwd || '~')}</div>
</div>
`;
}).join('');
}
// Auto-scroll control
const scrollListeners = new Map();
// Viewport-based PTY resize
let resizeDebounceTimer = null;
function measureTerminalCols() {
// Create a hidden span with monospace font to measure character width
const $measure = document.createElement('span');
$measure.style.fontFamily = '"SF Mono", Monaco, "Cascadia Code", "Courier New", monospace';
$measure.style.fontSize = `${state.settings.fontSize}px`;
$measure.style.lineHeight = '1.5';
$measure.style.visibility = 'hidden';
$measure.style.position = 'absolute';
$measure.textContent = 'X'.repeat(100); // Measure 100 chars
document.body.appendChild($measure);
const charWidth = $measure.offsetWidth / 100;
document.body.removeChild($measure);
// Get terminal container width (accounting for padding)
const $container = document.querySelector('.session-output');
if (!$container) {
return 80; // Default fallback
}
const containerWidth = $container.clientWidth - 32; // 16px padding on each side
const cols = Math.floor(containerWidth / charWidth);
// Clamp to reasonable bounds
return Math.max(40, Math.min(cols, 300));
}
async function resizeSessionPTY(sessionId, cols, rows) {
try {
const res = await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ cols, rows }),
});
if (!res.ok) {
console.error('Failed to resize session PTY:', await res.text());
}
} catch (error) {
console.error('Error resizing session PTY:', error);
}
}
function handleSessionResize(sessionId) {
const cols = measureTerminalCols();
const rows = 24; // Reasonable default row count
resizeSessionPTY(sessionId, cols, rows);
}
function handleWindowResize() {
if (resizeDebounceTimer) {
clearTimeout(resizeDebounceTimer);
}
resizeDebounceTimer = setTimeout(() => {
// Resize all expanded sessions
state.sessions.forEach((session) => {
if (session.expanded) {
handleSessionResize(session.id);
}
});
}, 300); // Debounce for 300ms
}
function attachScrollListener(sessionId, $outputContainer) {
// Remove existing listener if present
const existing = scrollListeners.get(sessionId);
if (existing) {
$outputContainer.removeEventListener('scroll', existing);
}
const listener = () => {
const session = state.sessions.get(sessionId);
if (!session) return;
const { scrollTop, clientHeight, scrollHeight } = $outputContainer;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (session.autoScroll !== isAtBottom) {
session.autoScroll = isAtBottom;
updateScrollButton(sessionId);
}
};
$outputContainer.addEventListener('scroll', listener);
scrollListeners.set(sessionId, listener);
}
window.scrollToBottom = (sessionId) => {
const session = state.sessions.get(sessionId);
if (!session) return;
// Scroll to bottom
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
if ($outputContainer) {
$outputContainer.scrollTop = $outputContainer.scrollHeight;
}
// Re-enable auto-scroll
session.autoScroll = true;
updateScrollButton(sessionId);
};
function updateScrollButton(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;
const $btn = document.querySelector(`#session-output-${sessionId} .scroll-to-bottom-btn`);
if ($btn) {
if (session.autoScroll) {
$btn.classList.add('hidden');
} else {
$btn.classList.remove('hidden');
}
}
}
// Initialize
loadSettings();
applySettings();
connectSSE();
// Set up window resize listener for PTY viewport resize
window.addEventListener('resize', handleWindowResize);
</script>
</body>
</html>